<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Beacon on Tarragon</title><link>https://tarrragon.github.io/blog/tags/beacon/</link><description>Recent content in Beacon on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 03 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/beacon/index.xml" rel="self" type="application/rss+xml"/><item><title>訪客識別與 opt-out</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/</guid><description>&lt;p>訪客識別的責任是回答「這兩列記錄是不是同一個人產生的」。這套統計靠一段頁面上的 JavaScript 把瀏覽事件送到自己架的接收端（一支 Google Apps Script 的 web app），再寫進試算表；送出的欄位是頁面路徑、來源網址、語言與裝置類別（模組二建立的那組 payload）。這些欄位描述的都是&lt;strong>單一次瀏覽的屬性&lt;/strong>，沒有任何一個能把兩列連起來。缺了這個能力，報表只能算出「某頁被打開幾次」，算不出「幾個人來過」「有沒有人回訪」「一個人平均讀幾篇」——而後面這些才是判斷內容有沒有被讀進去的依據。&lt;/p>
&lt;p>補上識別能力之前，先看清楚原本被寄予厚望的那個欄位為什麼不夠用——如果進來的問題是「為什麼來源網址幾乎都是空白」，答案在下一節。&lt;/p>
&lt;h2 id="來源網址為什麼大量空白">來源網址為什麼大量空白&lt;/h2>
&lt;p>來源網址（&lt;code>document.referrer&lt;/code>）記錄的是「進到這一頁之前，讀者在哪一頁」。它的值不由接收端決定、也不由當前頁面決定，而是由&lt;strong>上一頁送出請求時附帶的 &lt;code>Referer&lt;/code> 標頭&lt;/strong>決定——這代表控制權在上一頁手上。&lt;/p>
&lt;p>上一頁能控制到什麼程度，由它的 Referrer-Policy 設定。現代瀏覽器的預設值是 &lt;code>strict-origin-when-cross-origin&lt;/code>：跨網域時只送出來源網站的網域（例如 &lt;code>https://www.google.com/&lt;/code>）、不送完整網址；同網域之間才送完整網址。這個預設有一個直接的推論——&lt;strong>在未覆寫預設政策、且換頁是整頁載入的站上，站內導覽會留下來源網址&lt;/strong>，因為那是同網域的跳轉。&lt;/p>
&lt;p>兩個前提都要成立，而它們都可能不成立。站方能用 meta 標籤或回應標頭覆寫政策，設成 &lt;code>no-referrer&lt;/code> 時連站內導覽都不留來源，部分 CDN 與佈景主題會預設加上這類設定。單頁應用程式（client-side routing）換頁時不重新載入文件，&lt;code>document.referrer&lt;/code> 因此停在最初進站的那個值、不隨導覽更新。&lt;/p>
&lt;p>這裡有一個容易混淆的地方：&lt;strong>站方的 Referrer-Policy 決定的是「離開這個站時送出多少來源」，與「收到的訪客帶著什麼來源」是兩件事&lt;/strong>。後者由上一頁的站決定，自己這端管不到。&lt;/p>
&lt;p>前提成不成立可以直接驗：在自己的站上點兩篇文章，看記錄裡有沒有出現自家網域的來源。沒有出現時，下面所有以站內來源為依據的判讀都不適用。&lt;/p>
&lt;p>從這個推論可以反過來判讀空白：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>空白的成因&lt;/th>
 &lt;th>特徵&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>直接輸入網址或書籤&lt;/td>
 &lt;td>沒有上一頁，自然沒有來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>app 內建瀏覽器&lt;/td>
 &lt;td>通訊軟體與社群 app 開啟連結時常設定為不送出來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>原生程式喚起系統瀏覽器&lt;/td>
 &lt;td>郵件用戶端、PDF 閱讀器、桌面版通訊軟體開啟外部連結&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>連結本身帶 &lt;code>rel=&amp;quot;noreferrer&amp;quot;&lt;/code>&lt;/td>
 &lt;td>由來源頁的作者個別設定，與站方的整體政策是兩回事&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>單頁應用程式的站內換頁&lt;/td>
 &lt;td>不重新載入文件，&lt;code>document.referrer&lt;/code> 停在最初進站的值&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>站方覆寫 Referrer-Policy&lt;/td>
 &lt;td>設成 &lt;code>no-referrer&lt;/code> 時連站內導覽都不留來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自動化抓取&lt;/td>
 &lt;td>多數抓取工具直接請求目標網址，沒有導覽過程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>隱私瀏覽器或擴充功能&lt;/td>
 &lt;td>主動剝除來源標頭&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表沒有窮盡所有可能，但它已經足以說明關鍵：&lt;strong>除了「自動化抓取」那一列，其餘全部是真人&lt;/strong>。單看空白這一件事分不出它們，&lt;strong>因此空白不是「缺資料」，它是一個需要其他欄位才能拆開的分類&lt;/strong>。&lt;/p>
&lt;p>拆開要靠交叉判讀，而每一類留下的欄位組合不同。&lt;strong>app 內建瀏覽器&lt;/strong>的記錄通常伴隨行動裝置分類，部分 webview 回報不到視窗尺寸而語言偏好正常。&lt;strong>自動化抓取&lt;/strong>在裝置欄多半落在桌機、語言偏好常是空的或單一英文，而且&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">自動化訊號欄&lt;/a>會有命中。這一類的佔比通常比預期低——beacon 靠 JavaScript 觸發，而多數抓取工具根本不執行 JS、不會出現在這份資料裡。&lt;strong>隱私瀏覽器與擴充功能&lt;/strong>的特徵最不明顯：裝置與語言都正常，識別碼卻可能每次都是新的。&lt;strong>直接輸入、書籤與原生程式喚起&lt;/strong>在這幾個欄位上與一般真人瀏覽沒有差別。它們是排除掉可辨識的那幾類之後剩下的殘差，而殘差裡也包含這張表沒有列到的成因——把殘差整個當成「直接訪問」會高估那一類。&lt;/p>
&lt;p>站內來源的比例可以當成追蹤指標，但它沒有跨站通用的門檻。真人從搜尋結果進來、看完一篇就離開是常見的，內容型網站的站內跳轉本來就偏低，剛上線只有幾篇文章的站更接近零。&lt;strong>同樣的比例在不同的站代表完全不同的事。&lt;/strong>&lt;/p>
&lt;p>能用的作法是先量出自己的基線：取一批組成已知的記錄（例如設定 opt-out 之前自己走過的閱讀路線），算出其中站內來源佔多少，之後用偏離這個基線的幅度判讀，而不是套一個絕對數字。基線的完整量法見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量&lt;/a>。&lt;/p>
&lt;h2 id="兩層隨機識別碼">兩層隨機識別碼&lt;/h2>
&lt;p>識別的做法是讓瀏覽器自己產生一個隨機值、存起來、每次送 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 時一起帶上。這個值不從任何個人資訊推導出來，它只是一個標籤——&lt;strong>同一個標籤出現在多列上，代表那些列來自同一個瀏覽器&lt;/strong>。&lt;/p>
&lt;p>用兩層而非一層，是因為要回答的是兩個不同的問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>識別碼&lt;/th>
 &lt;th>存放位置&lt;/th>
 &lt;th>生命週期&lt;/th>
 &lt;th>回答的問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>訪客識別碼&lt;/td>
 &lt;td>&lt;code>localStorage&lt;/code>&lt;/td>
 &lt;td>直到使用者清除資料&lt;/td>
 &lt;td>這個人有沒有回訪&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>session 識別碼&lt;/td>
 &lt;td>&lt;code>sessionStorage&lt;/code>&lt;/td>
 &lt;td>分頁關閉即消失&lt;/td>
 &lt;td>這一次來看了幾篇&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>訪客識別碼存在 &lt;code>localStorage&lt;/code>，它跨越分頁與瀏覽器重啟繼續存在。&lt;/strong> 同一個訪客識別碼出現在相隔數天的兩列上，代表這個人回來了。這是判斷內容有沒有留住讀者的直接依據，而它無法從單次瀏覽的任何屬性推導出來。&lt;/p>
&lt;p>&lt;strong>session 識別碼存在 &lt;code>sessionStorage&lt;/code>，分頁一關就消失。&lt;/strong> 同一個 session 識別碼底下有五個不同路徑，代表這次來訪讀了五篇。這個數字回答的是「單次閱讀深度」，跟回訪率是正交的兩件事——有人每天回來但每次只看一篇，也有人只來過一次卻一口氣讀完整個模組。&lt;/p>
&lt;p>實作上兩者共用同一段邏輯，差別只在傳入哪個儲存體：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// storage 在隱私模式或停用 cookie 時會丟例外，統計不該因此讓頁面出錯
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="nx">safeGet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="nx">store&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getItem&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="k">catch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">safeSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">value&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">store&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setItem&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">value&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="k">catch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* 存不了就當作無法識別 */&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">stableId&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">existing&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">safeGet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">existing&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="nx">existing&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">fresh&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">window&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">crypto&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">crypto&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">randomUUID&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="o">?&lt;/span> &lt;span class="nx">crypto&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">randomUUID&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="o">:&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">36&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nb">Math&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">random&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">36&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">safeSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">fresh&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">safeGet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">fresh&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">visitorId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">stableId&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">localStorage&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;blog_visitor_id&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">sessionId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">stableId&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">sessionStorage&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;blog_session_id&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個實作細節各自對應一種會實際發生的狀況。&lt;strong>存取包在 try/catch 裡&lt;/strong>，因為 Safari 的私密瀏覽把配額壓到接近零、&lt;code>setItem&lt;/code> 會丟 &lt;code>QuotaExceededError&lt;/code>，而封鎖網站資料的設定會丟 &lt;code>SecurityError&lt;/code>；統計失敗不該讓頁面的其他 JS 一起中斷。&lt;strong>&lt;code>crypto.randomUUID&lt;/code> 有 fallback&lt;/strong>，因為它需要安全連線環境（secure context，HTTPS 或 localhost），在少數環境下不存在。&lt;strong>寫入後再讀一次確認&lt;/strong>，因為 &lt;code>safeSet&lt;/code> 把例外吞掉了——寫入成功與否在這個結構下只能靠讀回來判斷。讀不回來時回傳空字串、彙總時歸為不可識別，比回傳一個「下次就不見了」的值誠實：假的識別碼會讓每次瀏覽看起來都是新訪客。&lt;/p>
&lt;p>這裡有一個附帶的觀察能力：自動化抓取工具每次都在全新的瀏覽器環境執行，所以&lt;strong>每一列都帶著不同的訪客識別碼、而且每個 session 只有一列&lt;/strong>。這個模式不需要額外偵測就會自己浮現。&lt;/p>
&lt;h2 id="退出機制的選型與邊界">退出機制的選型與邊界&lt;/h2>
&lt;p>排除特定瀏覽的機制有三種候選，選擇的依據是各自的技術可行性與副作用。&lt;/p>
&lt;p>&lt;strong>IP 位址在這個架構下不可行。&lt;/strong> Apps Script 的 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">&lt;code>doPost(e)&lt;/code>&lt;/a> 事件物件只提供 &lt;code>parameter&lt;/code>、&lt;code>postData&lt;/code>、&lt;code>pathInfo&lt;/code> 這些欄位，不提供任何 HTTP 標頭——接收端讀不到來源 IP。即使技術上讀得到也不該用：家用寬頻的 IP 會變動，行動網路的多個用戶共用同一個對外 IP，用 IP 排除自己會連帶排除掉一批陌生讀者。&lt;/p></description><content:encoded><![CDATA[<p>訪客識別的責任是回答「這兩列記錄是不是同一個人產生的」。這套統計靠一段頁面上的 JavaScript 把瀏覽事件送到自己架的接收端（一支 Google Apps Script 的 web app），再寫進試算表；送出的欄位是頁面路徑、來源網址、語言與裝置類別（模組二建立的那組 payload）。這些欄位描述的都是<strong>單一次瀏覽的屬性</strong>，沒有任何一個能把兩列連起來。缺了這個能力，報表只能算出「某頁被打開幾次」，算不出「幾個人來過」「有沒有人回訪」「一個人平均讀幾篇」——而後面這些才是判斷內容有沒有被讀進去的依據。</p>
<p>補上識別能力之前，先看清楚原本被寄予厚望的那個欄位為什麼不夠用——如果進來的問題是「為什麼來源網址幾乎都是空白」，答案在下一節。</p>
<h2 id="來源網址為什麼大量空白">來源網址為什麼大量空白</h2>
<p>來源網址（<code>document.referrer</code>）記錄的是「進到這一頁之前，讀者在哪一頁」。它的值不由接收端決定、也不由當前頁面決定，而是由<strong>上一頁送出請求時附帶的 <code>Referer</code> 標頭</strong>決定——這代表控制權在上一頁手上。</p>
<p>上一頁能控制到什麼程度，由它的 Referrer-Policy 設定。現代瀏覽器的預設值是 <code>strict-origin-when-cross-origin</code>：跨網域時只送出來源網站的網域（例如 <code>https://www.google.com/</code>）、不送完整網址；同網域之間才送完整網址。這個預設有一個直接的推論——<strong>在未覆寫預設政策、且換頁是整頁載入的站上，站內導覽會留下來源網址</strong>，因為那是同網域的跳轉。</p>
<p>兩個前提都要成立，而它們都可能不成立。站方能用 meta 標籤或回應標頭覆寫政策，設成 <code>no-referrer</code> 時連站內導覽都不留來源，部分 CDN 與佈景主題會預設加上這類設定。單頁應用程式（client-side routing）換頁時不重新載入文件，<code>document.referrer</code> 因此停在最初進站的那個值、不隨導覽更新。</p>
<p>這裡有一個容易混淆的地方：<strong>站方的 Referrer-Policy 決定的是「離開這個站時送出多少來源」，與「收到的訪客帶著什麼來源」是兩件事</strong>。後者由上一頁的站決定，自己這端管不到。</p>
<p>前提成不成立可以直接驗：在自己的站上點兩篇文章，看記錄裡有沒有出現自家網域的來源。沒有出現時，下面所有以站內來源為依據的判讀都不適用。</p>
<p>從這個推論可以反過來判讀空白：</p>
<table>
  <thead>
      <tr>
          <th>空白的成因</th>
          <th>特徵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>直接輸入網址或書籤</td>
          <td>沒有上一頁，自然沒有來源</td>
      </tr>
      <tr>
          <td>app 內建瀏覽器</td>
          <td>通訊軟體與社群 app 開啟連結時常設定為不送出來源</td>
      </tr>
      <tr>
          <td>原生程式喚起系統瀏覽器</td>
          <td>郵件用戶端、PDF 閱讀器、桌面版通訊軟體開啟外部連結</td>
      </tr>
      <tr>
          <td>連結本身帶 <code>rel=&quot;noreferrer&quot;</code></td>
          <td>由來源頁的作者個別設定，與站方的整體政策是兩回事</td>
      </tr>
      <tr>
          <td>單頁應用程式的站內換頁</td>
          <td>不重新載入文件，<code>document.referrer</code> 停在最初進站的值</td>
      </tr>
      <tr>
          <td>站方覆寫 Referrer-Policy</td>
          <td>設成 <code>no-referrer</code> 時連站內導覽都不留來源</td>
      </tr>
      <tr>
          <td>自動化抓取</td>
          <td>多數抓取工具直接請求目標網址，沒有導覽過程</td>
      </tr>
      <tr>
          <td>隱私瀏覽器或擴充功能</td>
          <td>主動剝除來源標頭</td>
      </tr>
  </tbody>
</table>
<p>這張表沒有窮盡所有可能，但它已經足以說明關鍵：<strong>除了「自動化抓取」那一列，其餘全部是真人</strong>。單看空白這一件事分不出它們，<strong>因此空白不是「缺資料」，它是一個需要其他欄位才能拆開的分類</strong>。</p>
<p>拆開要靠交叉判讀，而每一類留下的欄位組合不同。<strong>app 內建瀏覽器</strong>的記錄通常伴隨行動裝置分類，部分 webview 回報不到視窗尺寸而語言偏好正常。<strong>自動化抓取</strong>在裝置欄多半落在桌機、語言偏好常是空的或單一英文，而且<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">自動化訊號欄</a>會有命中。這一類的佔比通常比預期低——beacon 靠 JavaScript 觸發，而多數抓取工具根本不執行 JS、不會出現在這份資料裡。<strong>隱私瀏覽器與擴充功能</strong>的特徵最不明顯：裝置與語言都正常，識別碼卻可能每次都是新的。<strong>直接輸入、書籤與原生程式喚起</strong>在這幾個欄位上與一般真人瀏覽沒有差別。它們是排除掉可辨識的那幾類之後剩下的殘差，而殘差裡也包含這張表沒有列到的成因——把殘差整個當成「直接訪問」會高估那一類。</p>
<p>站內來源的比例可以當成追蹤指標，但它沒有跨站通用的門檻。真人從搜尋結果進來、看完一篇就離開是常見的，內容型網站的站內跳轉本來就偏低，剛上線只有幾篇文章的站更接近零。<strong>同樣的比例在不同的站代表完全不同的事。</strong></p>
<p>能用的作法是先量出自己的基線：取一批組成已知的記錄（例如設定 opt-out 之前自己走過的閱讀路線），算出其中站內來源佔多少，之後用偏離這個基線的幅度判讀，而不是套一個絕對數字。基線的完整量法見<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>。</p>
<h2 id="兩層隨機識別碼">兩層隨機識別碼</h2>
<p>識別的做法是讓瀏覽器自己產生一個隨機值、存起來、每次送 <a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon</a> 時一起帶上。這個值不從任何個人資訊推導出來，它只是一個標籤——<strong>同一個標籤出現在多列上，代表那些列來自同一個瀏覽器</strong>。</p>
<p>用兩層而非一層，是因為要回答的是兩個不同的問題：</p>
<table>
  <thead>
      <tr>
          <th>識別碼</th>
          <th>存放位置</th>
          <th>生命週期</th>
          <th>回答的問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訪客識別碼</td>
          <td><code>localStorage</code></td>
          <td>直到使用者清除資料</td>
          <td>這個人有沒有回訪</td>
      </tr>
      <tr>
          <td>session 識別碼</td>
          <td><code>sessionStorage</code></td>
          <td>分頁關閉即消失</td>
          <td>這一次來看了幾篇</td>
      </tr>
  </tbody>
</table>
<p><strong>訪客識別碼存在 <code>localStorage</code>，它跨越分頁與瀏覽器重啟繼續存在。</strong> 同一個訪客識別碼出現在相隔數天的兩列上，代表這個人回來了。這是判斷內容有沒有留住讀者的直接依據，而它無法從單次瀏覽的任何屬性推導出來。</p>
<p><strong>session 識別碼存在 <code>sessionStorage</code>，分頁一關就消失。</strong> 同一個 session 識別碼底下有五個不同路徑，代表這次來訪讀了五篇。這個數字回答的是「單次閱讀深度」，跟回訪率是正交的兩件事——有人每天回來但每次只看一篇，也有人只來過一次卻一口氣讀完整個模組。</p>
<p>實作上兩者共用同一段邏輯，差別只在傳入哪個儲存體：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// storage 在隱私模式或停用 cookie 時會丟例外，統計不該因此讓頁面出錯
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">safeGet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">try</span> <span class="p">{</span> <span class="k">return</span> <span class="nx">store</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="nx">key</span><span class="p">);</span> <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">function</span> <span class="nx">safeSet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">value</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">try</span> <span class="p">{</span> <span class="nx">store</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="nx">key</span><span class="p">,</span> <span class="nx">value</span><span class="p">);</span> <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* 存不了就當作無法識別 */</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">function</span> <span class="nx">stableId</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="kd">var</span> <span class="nx">existing</span> <span class="o">=</span> <span class="nx">safeGet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">existing</span><span class="p">)</span> <span class="k">return</span> <span class="nx">existing</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kd">var</span> <span class="nx">fresh</span> <span class="o">=</span> <span class="p">(</span><span class="nb">window</span><span class="p">.</span><span class="nx">crypto</span> <span class="o">&amp;&amp;</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">randomUUID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="o">?</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">randomUUID</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="o">:</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">().</span><span class="nx">toString</span><span class="p">(</span><span class="mi">36</span><span class="p">)</span> <span class="o">+</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">random</span><span class="p">().</span><span class="nx">toString</span><span class="p">(</span><span class="mi">36</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">10</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nx">safeSet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">fresh</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="k">return</span> <span class="nx">safeGet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span> <span class="o">?</span> <span class="nx">fresh</span> <span class="o">:</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="kd">var</span> <span class="nx">visitorId</span> <span class="o">=</span> <span class="nx">stableId</span><span class="p">(</span><span class="nx">localStorage</span><span class="p">,</span> <span class="s2">&#34;blog_visitor_id&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="kd">var</span> <span class="nx">sessionId</span> <span class="o">=</span> <span class="nx">stableId</span><span class="p">(</span><span class="nx">sessionStorage</span><span class="p">,</span> <span class="s2">&#34;blog_session_id&#34;</span><span class="p">);</span></span></span></code></pre></div><p>三個實作細節各自對應一種會實際發生的狀況。<strong>存取包在 try/catch 裡</strong>，因為 Safari 的私密瀏覽把配額壓到接近零、<code>setItem</code> 會丟 <code>QuotaExceededError</code>，而封鎖網站資料的設定會丟 <code>SecurityError</code>；統計失敗不該讓頁面的其他 JS 一起中斷。<strong><code>crypto.randomUUID</code> 有 fallback</strong>，因為它需要安全連線環境（secure context，HTTPS 或 localhost），在少數環境下不存在。<strong>寫入後再讀一次確認</strong>，因為 <code>safeSet</code> 把例外吞掉了——寫入成功與否在這個結構下只能靠讀回來判斷。讀不回來時回傳空字串、彙總時歸為不可識別，比回傳一個「下次就不見了」的值誠實：假的識別碼會讓每次瀏覽看起來都是新訪客。</p>
<p>這裡有一個附帶的觀察能力：自動化抓取工具每次都在全新的瀏覽器環境執行，所以<strong>每一列都帶著不同的訪客識別碼、而且每個 session 只有一列</strong>。這個模式不需要額外偵測就會自己浮現。</p>
<h2 id="退出機制的選型與邊界">退出機制的選型與邊界</h2>
<p>排除特定瀏覽的機制有三種候選，選擇的依據是各自的技術可行性與副作用。</p>
<p><strong>IP 位址在這個架構下不可行。</strong> Apps Script 的 <a href="/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼"><code>doPost(e)</code></a> 事件物件只提供 <code>parameter</code>、<code>postData</code>、<code>pathInfo</code> 這些欄位，不提供任何 HTTP 標頭——接收端讀不到來源 IP。即使技術上讀得到也不該用：家用寬頻的 IP 會變動，行動網路的多個用戶共用同一個對外 IP，用 IP 排除自己會連帶排除掉一批陌生讀者。</p>
<p><strong>Cookie 可行但代價不對等。</strong> Cookie 會隨著每一個同網域的 HTTP 請求自動送出，而靜態站託管平台根本不讀它——頁面、圖片、CSS、字型的每一個請求都多帶一段用不到的資料。單筆負擔很小，但它落在讀者的每一次連線上，換來的功能與存在 <code>localStorage</code> 完全相同。</p>
<p><strong><code>localStorage</code> 只在 JS 主動讀取時才被用到</strong>，不污染任何請求，容量也充裕。整段邏輯放在 beacon 送出之前，讀到標記就直接返回：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">OPTOUT_KEY</span> <span class="o">=</span> <span class="s2">&#34;blog_analytics_optout&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// 設定入口用網址參數，讓不熟悉開發者工具的讀者也能操作
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">query</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URLSearchParams</span><span class="p">(</span><span class="nx">location</span><span class="p">.</span><span class="nx">search</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">query</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;noanalytics&#34;</span><span class="p">)</span> <span class="o">===</span> <span class="s2">&#34;1&#34;</span><span class="p">)</span> <span class="nx">safeSet</span><span class="p">(</span><span class="nx">localStorage</span><span class="p">,</span> <span class="nx">OPTOUT_KEY</span><span class="p">,</span> <span class="s2">&#34;1&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">query</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;noanalytics&#34;</span><span class="p">)</span> <span class="o">===</span> <span class="s2">&#34;0&#34;</span><span class="p">)</span> <span class="p">{</span> <span class="k">try</span> <span class="p">{</span> <span class="nx">localStorage</span><span class="p">.</span><span class="nx">removeItem</span><span class="p">(</span><span class="nx">OPTOUT_KEY</span><span class="p">);</span> <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{}</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">// 判定：有標記就不送
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="nx">safeGet</span><span class="p">(</span><span class="nx">localStorage</span><span class="p">,</span> <span class="nx">OPTOUT_KEY</span><span class="p">)</span> <span class="o">===</span> <span class="s2">&#34;1&#34;</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span></span></span></code></pre></div><p>開啟一次 <code>https://網域/?noanalytics=1</code>，該瀏覽器之後就不再送出統計。這段程式碼與前面的 <code>safeGet</code> / <code>safeSet</code> 同屬 beacon 那個立即執行函式，<code>return</code> 讓整個統計流程在此中止。</p>
<p>這個機制的邊界要說清楚，因為它決定了使用方式。<strong>設定綁定「瀏覽器 + 來源」這個組合</strong>，不綁人也不綁裝置——<code>localStorage</code> 依 origin（協定、網域與 port 的組合）分割、路徑不影響，所以同一台電腦的不同瀏覽器要各設定一次，手機要另外設定。<strong>隱私瀏覽視窗的設定活不過該視窗</strong>：Chrome 無痕與 Firefox 隱私視窗寫得進去、當次有效，關閉最後一個私密分頁時清除；Safari 私密瀏覽則因為配額接近零而根本寫不進去。<strong>清除網站資料時會一併消失</strong>，需要重新設定。</p>
<p>還有一個平台特性值得知道：Safari 的追蹤預防機制（Intelligent Tracking Prevention，ITP）會清除網站以指令碼寫入的儲存資料，觸發條件是「連續七個有使用 Safari 的日子裡，沒有在該站發生過使用者互動」。這個天數是廠商政策、改過不只一次（曾經是三十天），引用時要回頭確認現行值。計時單位是使用瀏覽器的天數而非日曆天，重置條件是互動而非單純造訪。這既影響退出設定，也代表 Safari 讀者的回訪率會被系統性低估——同一個人隔一段時間回來會拿到新的訪客識別碼，在資料上看起來是新訪客。判讀回訪數字時要記得這個偏差的方向。</p>
<h2 id="隱私邊界">隱私邊界</h2>
<p>這套識別的性質是<strong>第一方隨機標籤</strong>：值由瀏覽器產生、只送到自己的接收端、不含任何從個人資訊推導出來的成分、也不與任何外部服務交換。它能回答的是「這幾列來自同一個瀏覽器」，回答不了「這個人是誰」。</p>
<p>相對地，有幾件事是刻意不做的。<strong>不送完整的 <code>userAgent</code></strong>，只送 mobile / tablet / desktop 三選一的裝置分類——完整字串帶著版本與系統細節，組合起來足以構成<a href="/blog/automation/knowledge-cards/browser-fingerprint/" data-link-title="Browser Fingerprint（瀏覽器指紋）" data-link-desc="由多個單獨無害的瀏覽器環境屬性組合而成的裝置特徵集合，決定自建統計蒐集判別訊號時的能力上限">瀏覽器指紋</a>。<strong>不送螢幕解析度、時區、字型清單</strong>這類單獨無害但組合起來高度唯一的欄位。<strong>不記錄頁面上的任何輸入內容</strong>。</p>
<p>還有一項刻意的選擇藏在程式碼裡、值得寫出來：<strong>路徑送的是 <code>location.pathname</code> 而不是 <code>location.href</code></strong>，差別在於前者不含 query string。搜尋關鍵字、追蹤參數、以及任何被塞進網址的識別碼都因此不會進入資料。這一行如果哪天被改成 <code>href</code>，上面整段隱私說明會在沒有任何人察覺的情況下失真。</p>
<p>有一個欄位需要如實交代：<strong>來源網址送的是完整值</strong>。同網域導覽時它是站內另一篇文章的完整網址，跨站時多半只有對方的網域。它不是這個站產生的資訊，但它確實被記錄下來。</p>
<p>公開這些界線本身也是設計的一部分：實作是靜態站的一段 JS，任何人都能讀原始碼驗證，所以說明必須與實作一致。退出的入口要放在讀者找得到的地方，而不是只寫在原始碼註解裡。</p>
<h2 id="下一步">下一步</h2>
<p>記錄之間有了關聯，但每列承載的資訊仍然停在「這一頁被打開了」。讀者停留多久、有沒有真的在讀，要靠第二則事件才量得到——<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間</a>講怎麼拆、以及那個秒數實際量到的是什麼。</p>
<p>已經想處理「哪些記錄是機器產生的」，可以跳過事件模型直接看<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>，但那一章的行為訊號建立在事件模型上，讀起來會少一層。隱私與防濫用的完整討論在<a href="/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">模組五的配額、防濫用與隱私邊界</a>。</p>
]]></content:encoded></item><item><title>Beacon</title><link>https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/</guid><description>&lt;p>Beacon 是瀏覽器主動送出、且送出後不等待回應的一則事件回報請求。它的用途是把「頁面上發生了某件事」（被瀏覽、被點擊、即將關閉）這個事實，從使用者的瀏覽器回傳到一個接收端記錄下來。在靜態站的 &lt;a href="https://tarrragon.github.io/blog/automation/00-mental-model/static-site-and-glue-layer/" data-link-title="靜態站的能力邊界與膠水層" data-link-desc="靜態站量不到自己流量的根本原因、膠水層補的是哪一塊伺服器能力、以及 client beacon 怎麼把資料送回你手上">client beacon 架構&lt;/a>裡，beacon 是唯一能把瀏覽資料送回你手上的途徑——因為靜態站沒有伺服器 access log 可以被動記錄。可先對照 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署&lt;/a>（beacon 送達的接收端怎麼來的）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Beacon 跟一般 API 請求的差別在「不等回應」。發起者送出後就繼續做自己的事，不阻塞、不讀回應、不重試——這種模式叫 fire-and-forget。對流量統計這類「送到就好、漏一兩筆無所謂」的場景，fire-and-forget 讓回報完全不影響使用者體驗。beacon 送出的請求由接收端的 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">doPost&lt;/a> 進入點接住處理。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>瀏覽器的 &lt;code>navigator.sendBeacon&lt;/code> 是專為此設計的 API：它保證即使頁面正在關閉，請求也會在背景送完，而且預設用 &lt;code>text/plain&lt;/code> 送出，這一點在打跨網域端點時關鍵——&lt;code>text/plain&lt;/code> 屬於 simple request、不觸發 CORS preflight。&lt;/p>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>跨網域打 Apps Script 這類端點時，&lt;code>text/plain&lt;/code> 是否夠用決定要不要處理 CORS preflight。實作細節見&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Beacon 是瀏覽器主動送出、且送出後不等待回應的一則事件回報請求。它的用途是把「頁面上發生了某件事」（被瀏覽、被點擊、即將關閉）這個事實，從使用者的瀏覽器回傳到一個接收端記錄下來。在靜態站的 <a href="/blog/automation/00-mental-model/static-site-and-glue-layer/" data-link-title="靜態站的能力邊界與膠水層" data-link-desc="靜態站量不到自己流量的根本原因、膠水層補的是哪一塊伺服器能力、以及 client beacon 怎麼把資料送回你手上">client beacon 架構</a>裡，beacon 是唯一能把瀏覽資料送回你手上的途徑——因為靜態站沒有伺服器 access log 可以被動記錄。可先對照 <a href="/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署</a>（beacon 送達的接收端怎麼來的）。</p>
<h2 id="概念位置">概念位置</h2>
<p>Beacon 跟一般 API 請求的差別在「不等回應」。發起者送出後就繼續做自己的事，不阻塞、不讀回應、不重試——這種模式叫 fire-and-forget。對流量統計這類「送到就好、漏一兩筆無所謂」的場景，fire-and-forget 讓回報完全不影響使用者體驗。beacon 送出的請求由接收端的 <a href="/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">doPost</a> 進入點接住處理。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>瀏覽器的 <code>navigator.sendBeacon</code> 是專為此設計的 API：它保證即使頁面正在關閉，請求也會在背景送完，而且預設用 <code>text/plain</code> 送出，這一點在打跨網域端點時關鍵——<code>text/plain</code> 屬於 simple request、不觸發 CORS preflight。</p>
<h2 id="判讀方式">判讀方式</h2>
<p>跨網域打 Apps Script 這類端點時，<code>text/plain</code> 是否夠用決定要不要處理 CORS preflight。實作細節見<a href="/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙</a>。</p>
]]></content:encoded></item><item><title>前端 beacon 與 CORS 障礙</title><link>https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/</guid><description>&lt;p>前端 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 的責任是：在頁面載入時偵測「這一頁被看了」，把一則最小事件送到 Apps Script 的接收端，而且不能拖慢頁面、不能因為送失敗而影響閱讀。做對這件事的關鍵，是選一種&lt;strong>不會觸發 CORS preflight&lt;/strong> 的送出方式——這是靜態站打 Apps Script 最容易卡住的地方，先講清楚為什麼。&lt;/p>
&lt;h2 id="cors-preflight-為什麼會擋住-beacon">CORS preflight 為什麼會擋住 beacon&lt;/h2>
&lt;p>瀏覽器對跨網域的請求有一套安全機制。當網頁（你的 blog，網域 A）要送一個請求到另一個網域（Apps Script，網域 B），而這個請求「不夠單純」時，瀏覽器會先偷偷送一個 &lt;code>OPTIONS&lt;/code> 請求去問網域 B：「我等一下要送這種請求，你允許嗎？」這個預先詢問叫 &lt;strong>preflight&lt;/strong>。要等網域 B 回答允許，真正的請求才會送出。&lt;/p>
&lt;p>Apps Script 在這裡有一個結構性的問題：它的 web app &lt;strong>無法回應 &lt;code>OPTIONS&lt;/code> 請求&lt;/strong>。原因是 Apps Script 只有 &lt;code>doGet&lt;/code> 和 &lt;code>doPost&lt;/code> 兩個進入點，沒有 &lt;code>doOptions&lt;/code>；preflight 送來的 &lt;code>OPTIONS&lt;/code> 打不到你的程式，Apps Script 平台直接用預設方式回應，而那個回應不帶「我允許」的 CORS 標頭。於是 preflight 失敗、真正的請求根本沒送出。這就是「我照 API 教學寫了 &lt;code>fetch&lt;/code> 打 Apps Script，瀏覽器 console 一片 CORS 紅字」的根本原因——問題不在你的程式，在於 preflight 發生在你的程式被執行&lt;strong>之前&lt;/strong>，你沒有機會處理它。&lt;/p>
&lt;p>避開的方法是讓請求「夠單純」，單純到瀏覽器判定不需要 preflight。這種請求叫 &lt;strong>simple request&lt;/strong>，條件之一是 &lt;code>Content-Type&lt;/code> 必須是 &lt;code>text/plain&lt;/code>、&lt;code>application/x-www-form-urlencoded&lt;/code> 或 &lt;code>multipart/form-data&lt;/code> 其中之一。關鍵就在這：&lt;strong>只要用 &lt;code>text/plain&lt;/code> 送，就不會有 preflight&lt;/strong>，請求直接送達，Apps Script 的 &lt;code>doPost&lt;/code> 正常收到。用 &lt;code>application/json&lt;/code> 送則會觸發 preflight、然後失敗。&lt;/p>
&lt;h2 id="用-sendbeacon-送-textplain">用 sendBeacon 送 text/plain&lt;/h2>
&lt;p>&lt;code>navigator.sendBeacon&lt;/code> 是瀏覽器為「送出後就不管」這種場景設計的 API，正好適合流量 beacon。它有三個特性剛好對上需求：送出後不阻塞頁面、即使使用者馬上關頁面也保證在背景送完、而且它送出的 &lt;code>Content-Type&lt;/code> 預設就是 &lt;code>text/plain&lt;/code>（當你傳字串或 text 型別的 Blob 時）——自動滿足 simple request 條件，不觸發 preflight。&lt;/p>
&lt;p>最小可用的 beacon 長這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">ENDPOINT&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://script.google.com/macros/s/你的部署ID/exec&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">payload&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">JSON&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">stringify&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">path&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">location&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">pathname&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 哪一頁
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">ref&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">referrer&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 從哪裡連來的
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">lang&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">language&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 瀏覽器語言
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 用 Blob 明確指定 text/plain，確保是 simple request
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">var&lt;/span> &lt;span class="nx">blob&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">Blob&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="nx">payload&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">type&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;text/plain;charset=UTF-8&amp;#34;&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">sendBeacon&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ENDPOINT&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">blob&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">})();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>送出的是一個 JSON &lt;strong>字串&lt;/strong>，但外層 &lt;code>Content-Type&lt;/code> 是 &lt;code>text/plain&lt;/code>——接收端再自己 &lt;code>JSON.parse&lt;/code> 把字串還原成物件（模組二的接收端那半會做）。這是「內容是 JSON、但傳輸標成純文字」的常見手法，專門用來繞過 preflight。&lt;/p>
&lt;p>payload 刻意只放不涉及個人身分的欄位：看了哪一頁、從哪連來、瀏覽器語言。不送任何能識別個人的資訊。附帶一提，Apps Script 的 web app 收不到訪客的 IP 位址，記不到也就無從外洩——這反而讓這套統計在隱私上乾淨很多，時間戳記交給接收端用伺服器時間補（模組二接收端那半處理）。&lt;/p>
&lt;p>這幾個欄位描述的都是&lt;strong>單次瀏覽的屬性&lt;/strong>，任兩列之間沒有關聯，因此算得出「某頁被打開幾次」、算不出「幾個人來過」。要補上那個能力需要額外的識別欄位，而「收不到 IP」這個事實在辨識自動化流量時會再次成為關鍵限制——兩者都在&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六&lt;/a>處理。&lt;/p>
&lt;h2 id="加上閱讀裝置">加上閱讀裝置&lt;/h2>
&lt;p>閱讀裝置（讀者用手機、平板還是桌機看）是一個對排版決策有用的欄位：某篇長表格文章若行動裝置佔比高，就值得回頭檢查它在窄螢幕的可讀性。要在 payload 加這個欄位，關鍵是&lt;strong>只送粗粒度的分類標籤、不送完整 &lt;code>userAgent&lt;/code>&lt;/strong>——&lt;code>mobile&lt;/code> / &lt;code>tablet&lt;/code> / &lt;code>desktop&lt;/code> 這種三選一的標籤足以回答「讀者用什麼裝置」，而完整 &lt;code>userAgent&lt;/code> 帶著版本、系統等可組成指紋的細節，送它就破壞了前面刻意維持的不記 PII 立場。&lt;/p>
&lt;p>分類邏輯在前端從 &lt;code>navigator.userAgent&lt;/code> 判斷：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">deviceType&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">ua&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">userAgent&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// iPadOS 的 Safari 會把 userAgent 偽裝成 Mac，靠觸控點數補抓
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">var&lt;/span> &lt;span class="nx">iPadAsMac&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sr">/Macintosh/&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ua&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">maxTouchPoints&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="sr">/iPad|Tablet|PlayBook|Silk/&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ua&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nx">iPadAsMac&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="sr">/Android/&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ua&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="sr">/Mobile/&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ua&lt;/span>&lt;span class="p">)))&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;tablet&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="sr">/Mobi|iPhone|iPod|Android|BlackBerry|IEMobile|Opera Mini/&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ua&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;mobile&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;desktop&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>有兩個判斷值得說明，因為它們是「照一般 &lt;code>userAgent&lt;/code> 教學寫會分錯」的地方。&lt;strong>Android 手機與平板的區分靠 &lt;code>Mobile&lt;/code> 這個 token&lt;/strong>：Android 手機的 &lt;code>userAgent&lt;/code> 含 &lt;code>Mobile&lt;/code>、Android 平板不含，所以「有 &lt;code>Android&lt;/code> 但沒有 &lt;code>Mobile&lt;/code>」判為平板。&lt;strong>iPad 會偽裝成 Mac&lt;/strong>：新版 iPadOS 的 Safari 為了要到桌面版網頁，把 &lt;code>userAgent&lt;/code> 報成 &lt;code>Macintosh&lt;/code>，單看字串會把 iPad 誤判成 &lt;code>desktop&lt;/code>；靠 &lt;code>navigator.maxTouchPoints &amp;gt; 1&lt;/code>（Mac 桌機沒有觸控）搭配 &lt;code>Macintosh&lt;/code> 字樣才抓得回來。這類分類規則會隨瀏覽器演進而過時，所以只做粗分類、不追求精確到型號——粗分類容錯高，型號級的判斷維護成本高又容易錯。&lt;/p></description><content:encoded><![CDATA[<p>前端 <a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon</a> 的責任是：在頁面載入時偵測「這一頁被看了」，把一則最小事件送到 Apps Script 的接收端，而且不能拖慢頁面、不能因為送失敗而影響閱讀。做對這件事的關鍵，是選一種<strong>不會觸發 CORS preflight</strong> 的送出方式——這是靜態站打 Apps Script 最容易卡住的地方，先講清楚為什麼。</p>
<h2 id="cors-preflight-為什麼會擋住-beacon">CORS preflight 為什麼會擋住 beacon</h2>
<p>瀏覽器對跨網域的請求有一套安全機制。當網頁（你的 blog，網域 A）要送一個請求到另一個網域（Apps Script，網域 B），而這個請求「不夠單純」時，瀏覽器會先偷偷送一個 <code>OPTIONS</code> 請求去問網域 B：「我等一下要送這種請求，你允許嗎？」這個預先詢問叫 <strong>preflight</strong>。要等網域 B 回答允許，真正的請求才會送出。</p>
<p>Apps Script 在這裡有一個結構性的問題：它的 web app <strong>無法回應 <code>OPTIONS</code> 請求</strong>。原因是 Apps Script 只有 <code>doGet</code> 和 <code>doPost</code> 兩個進入點，沒有 <code>doOptions</code>；preflight 送來的 <code>OPTIONS</code> 打不到你的程式，Apps Script 平台直接用預設方式回應，而那個回應不帶「我允許」的 CORS 標頭。於是 preflight 失敗、真正的請求根本沒送出。這就是「我照 API 教學寫了 <code>fetch</code> 打 Apps Script，瀏覽器 console 一片 CORS 紅字」的根本原因——問題不在你的程式，在於 preflight 發生在你的程式被執行<strong>之前</strong>，你沒有機會處理它。</p>
<p>避開的方法是讓請求「夠單純」，單純到瀏覽器判定不需要 preflight。這種請求叫 <strong>simple request</strong>，條件之一是 <code>Content-Type</code> 必須是 <code>text/plain</code>、<code>application/x-www-form-urlencoded</code> 或 <code>multipart/form-data</code> 其中之一。關鍵就在這：<strong>只要用 <code>text/plain</code> 送，就不會有 preflight</strong>，請求直接送達，Apps Script 的 <code>doPost</code> 正常收到。用 <code>application/json</code> 送則會觸發 preflight、然後失敗。</p>
<h2 id="用-sendbeacon-送-textplain">用 sendBeacon 送 text/plain</h2>
<p><code>navigator.sendBeacon</code> 是瀏覽器為「送出後就不管」這種場景設計的 API，正好適合流量 beacon。它有三個特性剛好對上需求：送出後不阻塞頁面、即使使用者馬上關頁面也保證在背景送完、而且它送出的 <code>Content-Type</code> 預設就是 <code>text/plain</code>（當你傳字串或 text 型別的 Blob 時）——自動滿足 simple request 條件，不觸發 preflight。</p>
<p>最小可用的 beacon 長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">ENDPOINT</span> <span class="o">=</span> <span class="s2">&#34;https://script.google.com/macros/s/你的部署ID/exec&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">var</span> <span class="nx">payload</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">path</span><span class="o">:</span> <span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span><span class="p">,</span>        <span class="c1">// 哪一頁
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="nx">ref</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">referrer</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>   <span class="c1">// 從哪裡連來的
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>    <span class="nx">lang</span><span class="o">:</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">language</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="c1">// 瀏覽器語言
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="c1">// 用 Blob 明確指定 text/plain，確保是 simple request
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>  <span class="kd">var</span> <span class="nx">blob</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Blob</span><span class="p">([</span><span class="nx">payload</span><span class="p">],</span> <span class="p">{</span> <span class="nx">type</span><span class="o">:</span> <span class="s2">&#34;text/plain;charset=UTF-8&#34;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">navigator</span><span class="p">.</span><span class="nx">sendBeacon</span><span class="p">(</span><span class="nx">ENDPOINT</span><span class="p">,</span> <span class="nx">blob</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">})();</span></span></span></code></pre></div><p>送出的是一個 JSON <strong>字串</strong>，但外層 <code>Content-Type</code> 是 <code>text/plain</code>——接收端再自己 <code>JSON.parse</code> 把字串還原成物件（模組二的接收端那半會做）。這是「內容是 JSON、但傳輸標成純文字」的常見手法，專門用來繞過 preflight。</p>
<p>payload 刻意只放不涉及個人身分的欄位：看了哪一頁、從哪連來、瀏覽器語言。不送任何能識別個人的資訊。附帶一提，Apps Script 的 web app 收不到訪客的 IP 位址，記不到也就無從外洩——這反而讓這套統計在隱私上乾淨很多，時間戳記交給接收端用伺服器時間補（模組二接收端那半處理）。</p>
<p>這幾個欄位描述的都是<strong>單次瀏覽的屬性</strong>，任兩列之間沒有關聯，因此算得出「某頁被打開幾次」、算不出「幾個人來過」。要補上那個能力需要額外的識別欄位，而「收不到 IP」這個事實在辨識自動化流量時會再次成為關鍵限制——兩者都在<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六</a>處理。</p>
<h2 id="加上閱讀裝置">加上閱讀裝置</h2>
<p>閱讀裝置（讀者用手機、平板還是桌機看）是一個對排版決策有用的欄位：某篇長表格文章若行動裝置佔比高，就值得回頭檢查它在窄螢幕的可讀性。要在 payload 加這個欄位，關鍵是<strong>只送粗粒度的分類標籤、不送完整 <code>userAgent</code></strong>——<code>mobile</code> / <code>tablet</code> / <code>desktop</code> 這種三選一的標籤足以回答「讀者用什麼裝置」，而完整 <code>userAgent</code> 帶著版本、系統等可組成指紋的細節，送它就破壞了前面刻意維持的不記 PII 立場。</p>
<p>分類邏輯在前端從 <code>navigator.userAgent</code> 判斷：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">deviceType</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">ua</span> <span class="o">=</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">userAgent</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c1">// iPadOS 的 Safari 會把 userAgent 偽裝成 Mac，靠觸控點數補抓
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="kd">var</span> <span class="nx">iPadAsMac</span> <span class="o">=</span> <span class="sr">/Macintosh/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">maxTouchPoints</span> <span class="o">&gt;</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="sr">/iPad|Tablet|PlayBook|Silk/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">)</span> <span class="o">||</span> <span class="nx">iPadAsMac</span> <span class="o">||</span> <span class="p">(</span><span class="sr">/Android/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="sr">/Mobile/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">)))</span> <span class="k">return</span> <span class="s2">&#34;tablet&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="sr">/Mobi|iPhone|iPod|Android|BlackBerry|IEMobile|Opera Mini/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">))</span> <span class="k">return</span> <span class="s2">&#34;mobile&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="k">return</span> <span class="s2">&#34;desktop&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>有兩個判斷值得說明，因為它們是「照一般 <code>userAgent</code> 教學寫會分錯」的地方。<strong>Android 手機與平板的區分靠 <code>Mobile</code> 這個 token</strong>：Android 手機的 <code>userAgent</code> 含 <code>Mobile</code>、Android 平板不含，所以「有 <code>Android</code> 但沒有 <code>Mobile</code>」判為平板。<strong>iPad 會偽裝成 Mac</strong>：新版 iPadOS 的 Safari 為了要到桌面版網頁，把 <code>userAgent</code> 報成 <code>Macintosh</code>，單看字串會把 iPad 誤判成 <code>desktop</code>；靠 <code>navigator.maxTouchPoints &gt; 1</code>（Mac 桌機沒有觸控）搭配 <code>Macintosh</code> 字樣才抓得回來。這類分類規則會隨瀏覽器演進而過時，所以只做粗分類、不追求精確到型號——粗分類容錯高，型號級的判斷維護成本高又容易錯。</p>
<p>把 <code>dev</code> 加進 payload：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">payload</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">path</span><span class="o">:</span> <span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">ref</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">referrer</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">lang</span><span class="o">:</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">language</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">dev</span><span class="o">:</span> <span class="nx">deviceType</span><span class="p">(),</span>   <span class="c1">// mobile / tablet / desktop
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>接收端多寫一欄就好（模組二接收端那半會補上 <code>裝置</code> 欄）。</p>
<h2 id="放進-hugo-的哪裡">放進 Hugo 的哪裡</h2>
<p>這段 JS 要在每一頁都執行，所以放進網站的共用版型、而不是單篇文章。Hugo 的慣例是做一個 partial，在關閉 <code>&lt;/body&gt;</code> 前引入：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c">&lt;!-- layouts/partials/beacon.html --&gt;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kd">var</span> <span class="nx">ENDPOINT</span> <span class="o">=</span> <span class="s2">&#34;https://script.google.com/macros/s/你的部署ID/exec&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">function</span> <span class="nx">deviceType</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="kd">var</span> <span class="nx">ua</span> <span class="o">=</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">userAgent</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="kd">var</span> <span class="nx">iPadAsMac</span> <span class="o">=</span> <span class="sr">/Macintosh/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">maxTouchPoints</span> <span class="o">&gt;</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="sr">/iPad|Tablet|PlayBook|Silk/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">)</span> <span class="o">||</span> <span class="nx">iPadAsMac</span> <span class="o">||</span> <span class="p">(</span><span class="sr">/Android/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="sr">/Mobile/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">)))</span> <span class="k">return</span> <span class="s2">&#34;tablet&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="sr">/Mobi|iPhone|iPod|Android|BlackBerry|IEMobile|Opera Mini/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">ua</span><span class="p">))</span> <span class="k">return</span> <span class="s2">&#34;mobile&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="k">return</span> <span class="s2">&#34;desktop&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="kd">var</span> <span class="nx">payload</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">      <span class="nx">path</span><span class="o">:</span> <span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="nx">ref</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">referrer</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="nx">lang</span><span class="o">:</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">language</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="nx">dev</span><span class="o">:</span> <span class="nx">deviceType</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="kd">var</span> <span class="nx">blob</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Blob</span><span class="p">([</span><span class="nx">payload</span><span class="p">],</span> <span class="p">{</span> <span class="nx">type</span><span class="o">:</span> <span class="s2">&#34;text/plain;charset=UTF-8&#34;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">navigator</span><span class="p">.</span><span class="nx">sendBeacon</span><span class="p">(</span><span class="nx">ENDPOINT</span><span class="p">,</span> <span class="nx">blob</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="p">})();</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p>然後在 baseof 版型的結尾引入它：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- layouts/_default/baseof.html，在 &lt;/body&gt; 前 --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">{{ partial &#34;beacon.html&#34; . }}</span></span></code></pre></div><p>放在 <code>&lt;/body&gt;</code> 前、而不是 <code>&lt;head&gt;</code> 裡，是為了讓 beacon 在頁面主要內容都載入後才送，不跟關鍵資源搶頻寬。<code>sendBeacon</code> 本身就不阻塞，這個位置只是讓它更晚一點、更不影響體驗。</p>
<p>一個上線前要注意的邊界：本機 <code>hugo server</code> 預覽時 beacon 也會照送，會把開發時的瀏覽混進統計。實務上會加一個判斷，只在正式網域才送——例如檢查 <code>location.hostname</code> 是不是正式網域，是才執行。這個 hostname guard 擋掉本機預覽；「在正式站上自己重整」則需要另一套機制，見<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out</a>。防濫用與資料乾淨度的整體討論在模組五。</p>
<h2 id="下一步">下一步</h2>
<p>前端會送了，但現在 beacon 送出去還沒有人接。接收端要怎麼解析這個 <code>text/plain</code> 的 JSON、怎麼安全寫進 Sheet、部署後怎麼確認收到第一筆——見<a href="/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">接收端 handler：寫進第一筆</a>。想先補齊 <code>doPost</code> 跟部署模型的背景，回<a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a>。資料開始累積之後怎麼讀出意義，見<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a>。</p>
]]></content:encoded></item><item><title>模組零：心智模型</title><link>https://tarrragon.github.io/blog/automation/00-mental-model/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/00-mental-model/</guid><description>&lt;p>回答「這個需求到底需不需要伺服器、如果不需要那資料從哪裡進來」。膠水層與 client beacon 是整個指南的共同語言，後面每一章的實作都是這個架構的具體展開。&lt;/p>
&lt;p>先建立三個直覺：靜態站的能力邊界在哪、資料要靠瀏覽器主動回報到一個你掌握的接收端、以及免費額度該用「同時併發」而非「總次數」來思考。把這三件事想清楚，才知道流量統計為什麼要這樣搭、以及什麼時候該換更重的工具。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/00-mental-model/static-site-and-glue-layer/" data-link-title="靜態站的能力邊界與膠水層" data-link-desc="靜態站量不到自己流量的根本原因、膠水層補的是哪一塊伺服器能力、以及 client beacon 怎麼把資料送回你手上">靜態站的能力邊界與膠水層&lt;/a>&lt;/td>
 &lt;td>為什麼靜態站量不到自己的流量、膠水層補的是哪一塊、beacon 架構怎麼運作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">免費額度的思考方式與工具選型&lt;/a>&lt;/td>
 &lt;td>免費額度該看併發不看總量、Apps Script 與 Cloudflare Workers 的適用邊界&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作&lt;/a>：把本章的 beacon 架構實際做出來&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系&lt;/a>：想清楚「要收集什麼事件」的概念層&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「這個需求到底需不需要伺服器、如果不需要那資料從哪裡進來」。膠水層與 client beacon 是整個指南的共同語言，後面每一章的實作都是這個架構的具體展開。</p>
<p>先建立三個直覺：靜態站的能力邊界在哪、資料要靠瀏覽器主動回報到一個你掌握的接收端、以及免費額度該用「同時併發」而非「總次數」來思考。把這三件事想清楚，才知道流量統計為什麼要這樣搭、以及什麼時候該換更重的工具。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/00-mental-model/static-site-and-glue-layer/" data-link-title="靜態站的能力邊界與膠水層" data-link-desc="靜態站量不到自己流量的根本原因、膠水層補的是哪一塊伺服器能力、以及 client beacon 怎麼把資料送回你手上">靜態站的能力邊界與膠水層</a></td>
          <td>為什麼靜態站量不到自己的流量、膠水層補的是哪一塊、beacon 架構怎麼運作</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">免費額度的思考方式與工具選型</a></td>
          <td>免費額度該看併發不看總量、Apps Script 與 Cloudflare Workers 的適用邊界</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作</a>：把本章的 beacon 架構實際做出來</li>
<li>→ <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系</a>：想清楚「要收集什麼事件」的概念層</li>
</ul>
]]></content:encoded></item><item><title>靜態站的能力邊界與膠水層</title><link>https://tarrragon.github.io/blog/automation/00-mental-model/static-site-and-glue-layer/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/00-mental-model/static-site-and-glue-layer/</guid><description>&lt;p>靜態站的能力邊界是一條很硬的線：使用者的瀏覽器把 HTML/CSS/JS 抓下來之後，所有程式碼都在&lt;strong>使用者那一端&lt;/strong>執行，沒有任何一行程式跑在你控制的機器上。GitHub Pages、Netlify、Cloudflare Pages 這類託管都是把檔案原封不動送給瀏覽器，中間沒有一個「屬於你、會執行邏輯、看得到每個請求」的伺服器。這條線決定了靜態站能做什麼、不能做什麼。&lt;/p>
&lt;h2 id="為什麼靜態站量不到自己的流量">為什麼靜態站量不到自己的流量&lt;/h2>
&lt;p>流量統計的本質是「把每一次瀏覽記在某個你能事後查詢的地方」。傳統有伺服器的網站天生做得到，因為每個請求都會經過你的伺服器，伺服器在回應頁面的同時，順手在 access log 寫下一行「誰、什麼時候、看了哪一頁」。這行 log 是伺服器端的副產品，不需要前端配合。&lt;/p>
&lt;p>靜態站沒有這個副產品。GitHub Pages 確實有伺服器在送檔案，但那是 GitHub 的伺服器、不是你的——你拿不到它的 access log，也不能在它上面跑任何統計邏輯。所以對靜態站來說，「有人來看過」這個事實，在預設情況下沒有留在任何你查得到的地方。頁面被看完、瀏覽器關掉，紀錄就消失了。&lt;/p>
&lt;p>要把這個事實留下來，唯一的切入點是&lt;strong>頁面裡的 JavaScript&lt;/strong>——那是靜態站上唯一會執行的程式碼。做法是讓這段 JS 在頁面載入時，主動送一個請求到一個由你掌握的接收端，接收端把這次瀏覽記下來。這種「瀏覽器主動回報一則事件」的請求，慣例上叫做 &lt;strong>beacon&lt;/strong>。&lt;/p>
&lt;h2 id="膠水層補的是哪一塊">膠水層補的是哪一塊&lt;/h2>
&lt;p>膠水層（glue layer）是一段掛在別人免費平台上、只在被呼叫時才執行的伺服器端邏輯，用來補上靜態站缺的那一小塊伺服器能力。它的形態是一個函式，而非一台你要維護的主機：平時不佔資源、不計費，有請求打進來時才醒過來跑幾百毫秒，跑完就休眠。&lt;/p>
&lt;p>用流量統計來看這個分工會很清楚：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>靜態站（瀏覽器端）&lt;/strong>：負責偵測「這一頁被看了」，並發出 beacon。這段程式碼在使用者瀏覽器跑。&lt;/li>
&lt;li>&lt;strong>膠水層（伺服器端函式）&lt;/strong>：負責接住 beacon，把這次瀏覽寫進某個儲存體。這段程式碼在免費平台跑，是你控制的接收端。&lt;/li>
&lt;li>&lt;strong>儲存體&lt;/strong>：真正記住資料的地方。可以是一張 Google Sheet、一個免費資料庫、或平台自帶的 key-value 儲存。&lt;/li>
&lt;/ul>
&lt;p>膠水層補的就是中間那一塊——一個「屬於你、會執行、看得到每個 beacon」的接收端。它不必是完整後端，只要能接住請求、寫一筆資料就夠了。本指南模組二用 Google Apps Script 當這個膠水層，模組三用 Google Sheet 當儲存體。&lt;/p>
&lt;h2 id="client-beacon-架構怎麼運作">client beacon 架構怎麼運作&lt;/h2>
&lt;p>一次完整的流量紀錄走三步，全部由瀏覽器發起：&lt;/p>
&lt;ol>
&lt;li>使用者打開你的一篇文章，瀏覽器載入頁面，頁面裡的 JS 開始執行。&lt;/li>
&lt;li>JS 組出一則事件（例如「路徑 &lt;code>/posts/foo/&lt;/code>、時間、referrer」），送一個 HTTP 請求到膠水層的網址。&lt;/li>
&lt;li>膠水層收到請求，把這則事件 append 進儲存體，回一個簡短回應。瀏覽器不需要等這個回應、也不需要用它做任何事。&lt;/li>
&lt;/ol>
&lt;p>第三步的「不需要等回應」是 beacon 跟一般 API 呼叫最重要的差別。使用者是來讀文章的，不是來等統計寫完的——beacon 應該是 fire-and-forget（送出就忘）：發出去、不阻塞頁面、不管有沒有成功都不影響閱讀體驗。瀏覽器有一個專門為此設計的 API &lt;code>navigator.sendBeacon&lt;/code>，它保證即使使用者馬上關頁面，這個請求也會在背景送完。模組二會用到它。&lt;/p>
&lt;p>這個架構有一個取捨要先知道：beacon 只記得到「會執行 JS 的訪客」。爬蟲、關掉 JavaScript 的人、在 beacon 送出前就離開的人，都不會被算進去。因為紀錄的觸發點在瀏覽器端的 JS，client beacon 量到的數字&lt;strong>偏保守&lt;/strong>——它少算，但不會多算。對個人 blog 想知道「哪篇文章有人看」這個目的，偏保守完全夠用；但如果哪天需要精確到不能漏一個請求（例如計費），就得回到有伺服器 access log 的架構。判斷訊號很簡單：&lt;strong>漏算幾個訪客會不會造成實際損失&lt;/strong>——不會，就用 beacon；會，就別用。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>beacon 該送到哪、那個接收端能承受多大的量、免費到什麼程度會開始要錢——這些是&lt;a href="https://tarrragon.github.io/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">免費額度的思考方式與工具選型&lt;/a>要回答的。想直接把 beacon 做出來，跳到&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>靜態站的能力邊界是一條很硬的線：使用者的瀏覽器把 HTML/CSS/JS 抓下來之後，所有程式碼都在<strong>使用者那一端</strong>執行，沒有任何一行程式跑在你控制的機器上。GitHub Pages、Netlify、Cloudflare Pages 這類託管都是把檔案原封不動送給瀏覽器，中間沒有一個「屬於你、會執行邏輯、看得到每個請求」的伺服器。這條線決定了靜態站能做什麼、不能做什麼。</p>
<h2 id="為什麼靜態站量不到自己的流量">為什麼靜態站量不到自己的流量</h2>
<p>流量統計的本質是「把每一次瀏覽記在某個你能事後查詢的地方」。傳統有伺服器的網站天生做得到，因為每個請求都會經過你的伺服器，伺服器在回應頁面的同時，順手在 access log 寫下一行「誰、什麼時候、看了哪一頁」。這行 log 是伺服器端的副產品，不需要前端配合。</p>
<p>靜態站沒有這個副產品。GitHub Pages 確實有伺服器在送檔案，但那是 GitHub 的伺服器、不是你的——你拿不到它的 access log，也不能在它上面跑任何統計邏輯。所以對靜態站來說，「有人來看過」這個事實，在預設情況下沒有留在任何你查得到的地方。頁面被看完、瀏覽器關掉，紀錄就消失了。</p>
<p>要把這個事實留下來，唯一的切入點是<strong>頁面裡的 JavaScript</strong>——那是靜態站上唯一會執行的程式碼。做法是讓這段 JS 在頁面載入時，主動送一個請求到一個由你掌握的接收端，接收端把這次瀏覽記下來。這種「瀏覽器主動回報一則事件」的請求，慣例上叫做 <strong>beacon</strong>。</p>
<h2 id="膠水層補的是哪一塊">膠水層補的是哪一塊</h2>
<p>膠水層（glue layer）是一段掛在別人免費平台上、只在被呼叫時才執行的伺服器端邏輯，用來補上靜態站缺的那一小塊伺服器能力。它的形態是一個函式，而非一台你要維護的主機：平時不佔資源、不計費，有請求打進來時才醒過來跑幾百毫秒，跑完就休眠。</p>
<p>用流量統計來看這個分工會很清楚：</p>
<ul>
<li><strong>靜態站（瀏覽器端）</strong>：負責偵測「這一頁被看了」，並發出 beacon。這段程式碼在使用者瀏覽器跑。</li>
<li><strong>膠水層（伺服器端函式）</strong>：負責接住 beacon，把這次瀏覽寫進某個儲存體。這段程式碼在免費平台跑，是你控制的接收端。</li>
<li><strong>儲存體</strong>：真正記住資料的地方。可以是一張 Google Sheet、一個免費資料庫、或平台自帶的 key-value 儲存。</li>
</ul>
<p>膠水層補的就是中間那一塊——一個「屬於你、會執行、看得到每個 beacon」的接收端。它不必是完整後端，只要能接住請求、寫一筆資料就夠了。本指南模組二用 Google Apps Script 當這個膠水層，模組三用 Google Sheet 當儲存體。</p>
<h2 id="client-beacon-架構怎麼運作">client beacon 架構怎麼運作</h2>
<p>一次完整的流量紀錄走三步，全部由瀏覽器發起：</p>
<ol>
<li>使用者打開你的一篇文章，瀏覽器載入頁面，頁面裡的 JS 開始執行。</li>
<li>JS 組出一則事件（例如「路徑 <code>/posts/foo/</code>、時間、referrer」），送一個 HTTP 請求到膠水層的網址。</li>
<li>膠水層收到請求，把這則事件 append 進儲存體，回一個簡短回應。瀏覽器不需要等這個回應、也不需要用它做任何事。</li>
</ol>
<p>第三步的「不需要等回應」是 beacon 跟一般 API 呼叫最重要的差別。使用者是來讀文章的，不是來等統計寫完的——beacon 應該是 fire-and-forget（送出就忘）：發出去、不阻塞頁面、不管有沒有成功都不影響閱讀體驗。瀏覽器有一個專門為此設計的 API <code>navigator.sendBeacon</code>，它保證即使使用者馬上關頁面，這個請求也會在背景送完。模組二會用到它。</p>
<p>這個架構有一個取捨要先知道：beacon 只記得到「會執行 JS 的訪客」。爬蟲、關掉 JavaScript 的人、在 beacon 送出前就離開的人，都不會被算進去。因為紀錄的觸發點在瀏覽器端的 JS，client beacon 量到的數字<strong>偏保守</strong>——它少算，但不會多算。對個人 blog 想知道「哪篇文章有人看」這個目的，偏保守完全夠用；但如果哪天需要精確到不能漏一個請求（例如計費），就得回到有伺服器 access log 的架構。判斷訊號很簡單：<strong>漏算幾個訪客會不會造成實際損失</strong>——不會，就用 beacon；會，就別用。</p>
<h2 id="下一步">下一步</h2>
<p>beacon 該送到哪、那個接收端能承受多大的量、免費到什麼程度會開始要錢——這些是<a href="/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">免費額度的思考方式與工具選型</a>要回答的。想直接把 beacon 做出來，跳到<a href="/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作</a>。</p>
]]></content:encoded></item><item><title>事件模型與停留時間</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/</guid><description>&lt;p>事件模型決定一次瀏覽在資料裡留下幾列、每列各自承載什麼。起點的模型是一次瀏覽一列、在頁面載入時寫下（模組二建立的那個）——它能回答「某頁被打開幾次」，回答不了「打開之後發生了什麼」。頁面被打開一秒就關掉，跟被讀了三分鐘、中途捲動了十次，在資料上完全相同。&lt;/p>
&lt;p>補上這個能力需要第二則事件，而第二則事件的設計比第一則複雜：載入的時機明確且必定發生，離開的時機則有多種可能，且不保證每次都能送出。&lt;/p>
&lt;h2 id="進入與離開拆成兩則事件">進入與離開拆成兩則事件&lt;/h2>
&lt;p>模型是每次瀏覽產生兩列：&lt;strong>進入事件在頁面載入時送出，離開事件在頁面不再可見時送出&lt;/strong>，兩列靠 session 識別碼與路徑配對起來。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// send()：把物件轉成 JSON 字串、用 navigator.sendBeacon 送出（模組二建立）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">// deviceType()：回傳 mobile / tablet / desktop（模組二建立）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">// visitorId / sessionId：兩層隨機識別碼（見〈訪客識別與 opt-out〉）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">base&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">path&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">location&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">pathname&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">lang&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">language&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">dev&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">deviceType&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">vid&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">visitorId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sid&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">sessionId&lt;/span> &lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">// 進入：載入時立刻送，帶來源網址
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">Object&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">assign&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">t&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;view&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ref&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">referrer&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span> &lt;span class="nx">base&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">// 離開：帶停留秒數與是否互動過
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">arrivedAt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">interacted&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">leaveSent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;scroll&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;pointerdown&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;keydown&amp;#34;&lt;/span>&lt;span class="p">].&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">evt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">evt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">interacted&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">},&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">once&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">passive&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">sendLeave&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">leaveSent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">leaveSent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">Object&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">assign&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;leave&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">dur&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">Math&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">round&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="nx">arrivedAt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="mi">1000&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">act&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">interacted&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="mi">1&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span> &lt;span class="nx">base&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>拆成兩列而不是合併成一列，是為了讓兩件事各自失敗。&lt;/strong> 合併的設計會是「等到離開時才送一列，裡面同時放路徑與停留時間」——它看起來省一半的列數，代價是離開事件送不出去時，這次瀏覽在資料裡完全不存在。而離開事件送不出去是會實際發生的，至少有這些途徑：行動裝置被系統回收記憶體、瀏覽器被強制關閉、裝置斷電、網路在切換基地台時中斷、廣告或隱私擴充功能攔截這個請求、以及接收端在流量尖峰撞到併發上限而拒絕。拆開之後，最壞的結果只損失品質欄位，計數的基準線不受影響。&lt;/p>
&lt;p>這份丟失清單在後面還有一個用途。&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量&lt;/a>會用「沒有離開事件」當作機器的候選訊號，而上面每一條途徑都會讓真人產生同樣的形態——&lt;strong>行動流量佔比高的站尤其明顯&lt;/strong>。判讀那個訊號時要一併想起這一段。&lt;/p>
&lt;p>這個取捨的另一面是列數翻倍。試算表容量的影響見&lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/data-model-and-capacity/" data-link-title="資料模型與容量邊界" data-link-desc="raw log 表的欄位怎麼設計才好彙總、以及 Sheets 累積到多少列會開始撐不住、撐不住的訊號長什麼樣">模組三的資料模型與容量邊界&lt;/a>，執行配額（併發上限與觸發器時間）的影響見&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">模組五的配額碰撞&lt;/a>。個人網站的量體下這個代價可以接受；量體大到需要壓縮時，正確的方向是在接收端合併而不是在前端少送——前端少送會讓資料從一開始就不存在。&lt;/p>
&lt;h2 id="用-visibilitychange-觸發離開事件">用 visibilitychange 觸發離開事件&lt;/h2>
&lt;p>離開事件的觸發時機有三個候選，可靠度差距很大。&lt;/p>
&lt;p>&lt;strong>&lt;code>beforeunload&lt;/code> 不適用。&lt;/strong> 行動版 Safari 不支援它，所以 iOS 上的所有瀏覽器都不會觸發；而在 Firefox，註冊這個監聽器會讓頁面失去進入 bfcache 的資格——那是瀏覽器把整個頁面狀態留在記憶體裡、讓上一頁 / 下一頁能瞬間還原的機制，失去資格代表讀者按返回時要重新載入——為了統計而拖慢讀者的返回操作，取捨方向是錯的。各家瀏覽器一致排除出 bfcache 的其實是 &lt;code>unload&lt;/code>，現代瀏覽器的 &lt;code>beforeunload&lt;/code> 本身已不影響 bfcache，但 Firefox 這個例外加上行動端完全不支援，已經足以讓它出局。&lt;/p>
&lt;p>&lt;strong>&lt;code>pagehide&lt;/code> 涵蓋頁面卸載與進入快取兩種時機&lt;/strong>（&lt;code>event.persisted&lt;/code> 可區分兩者），比 &lt;code>beforeunload&lt;/code> 可靠，但漏掉「切換到別的分頁或別的 app」這個最常見的離開方式。&lt;/p>
&lt;p>&lt;strong>&lt;code>visibilitychange&lt;/code> 進入 &lt;code>hidden&lt;/code> 狀態涵蓋範圍最廣&lt;/strong>：切換分頁、導覽到新頁、切換 app、行動裝置鎖定螢幕、最小化或關閉瀏覽器都會走到。規格把「轉為 hidden」描述成頁面最後一個能可靠觀察到的事件。兩者都註冊、靠旗標防止重複送出：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;visibilitychange&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">visibilityState&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s2">&amp;#34;hidden&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">sendLeave&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;pagehide&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sendLeave&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>sendLeave&lt;/code> 開頭那個 &lt;code>leaveSent&lt;/code> 旗標在這裡是必要的而非防禦性的——一次真實的離開通常會同時觸發這兩個事件。&lt;/p>
&lt;p>它帶來一個容易漏掉的交互作用：&lt;strong>頁面進入 bfcache 時 JS 狀態整個保留下來&lt;/strong>，讀者按返回還原頁面之後，&lt;code>leaveSent&lt;/code> 仍然是 &lt;code>true&lt;/code>、&lt;code>arrivedAt&lt;/code> 仍然停在上一次載入的時刻。不處理的話，這次閱讀不會產生任何記錄，而在快取裡待的那段時間會被算進上一筆的停留秒數。修法是監聽 &lt;code>pageshow&lt;/code>，在 &lt;code>event.persisted&lt;/code> 為真時把三個狀態變數重置並補送一則進入事件：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;pageshow&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">evt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">evt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">persisted&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 一般載入不需要處理
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">leaveSent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">interacted&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">arrivedAt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">Object&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">assign&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">t&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;view&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ref&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">referrer&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span> &lt;span class="nx">base&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>送出方式是 &lt;code>navigator.sendBeacon&lt;/code>（見&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon 知識卡&lt;/a>），它的設計目標正是這個場景：頁面正在關閉時，一般的請求會隨著頁面銷毀而被取消，而 &lt;code>sendBeacon&lt;/code> 把資料排進瀏覽器的傳送佇列、不阻擋頁面卸載。&lt;/p>
&lt;p>它保證的是「不延遲卸載」而非「一定送達」——排進佇列之後的傳送結果不會回報，行動裝置被切走或強制關閉時仍可能沒送出去。同步能得知的只有排隊本身成不成功：佇列有 64 KiB 的上限，超過或排隊失敗時回傳 &lt;code>false&lt;/code>。流量事件的 payload 遠小於這個上限，但要監控收集鏈健康度時，這個回傳值是唯一在瀏覽器端可見的失敗訊號。&lt;/p>
&lt;h2 id="停留秒數對應的是可見時長">停留秒數對應的是可見時長&lt;/h2>
&lt;p>這個欄位的語意需要精確界定，否則會被當成閱讀時長使用而得出錯誤結論。&lt;strong>它量的是「頁面從載入到不再可見之間經過的時間」&lt;/strong>，這與讀者實際花在這篇文章上的時間有系統性的差距。&lt;/p>
&lt;p>差距來自兩個方向。&lt;strong>切換到別的分頁去查資料再切回來，會被切成多段&lt;/strong>——第一段在切走時就結束了，切回來不會產生新的進入事件（頁面沒有重新載入），所以回來之後的閱讀時間完全沒有被記錄。反過來，&lt;strong>開著分頁去做別的事&lt;/strong>，如果沒有切換到其他分頁（例如直接離開電腦），這段時間會全部算進停留秒數。&lt;/p>
&lt;p>站內導覽時還有一個可以驗證的現象：離開事件與下一頁進入事件的時間戳落在同一兩秒內，因為舊頁進入 &lt;code>hidden&lt;/code> 與新頁開始載入是同一個動作的兩面。時間戳由接收端在寫入時補上，所以這個間隔還包含網路傳輸與接收端排隊，不是純粹的瀏覽器事件間隔。用它重建連續的閱讀路徑時，配對條件要留這個寬容範圍。&lt;/p>
&lt;p>這個欄位因此適合分辨量級、不適合精確比較，而分界由誤差來源決定：中途切換到別的分頁會讓後半段完全不計，所以量測誤差可以達到一次分頁切換的完整時長——查一個名詞、回一則訊息都是數十秒的量級。&lt;strong>比較的粒度要大於這個誤差&lt;/strong>，否則差異可能全部來自切換行為而非閱讀。分桶而不是比大小，是這個限制下唯一穩健的用法。&lt;/p>
&lt;h2 id="互動旗標">互動旗標&lt;/h2>
&lt;p>&lt;code>act&lt;/code> 記錄的是「這次瀏覽期間有沒有發生過捲動、點擊或按鍵」。它用 &lt;code>{ once: true }&lt;/code> 註冊，第一次觸發後就移除監聽器——需要的資訊只是「有沒有發生過」，不是次數，持續監聽只會增加沒有用途的執行成本。&lt;/p>
&lt;p>&lt;code>passive: true&lt;/code> 這個選項對捲動事件是必要的：它向瀏覽器宣告這個監聽器不會取消預設行為，讓捲動不必等待 JS 執行完成。統計程式碼不該是頁面捲動卡頓的來源。&lt;/p>
&lt;p>這個旗標的判讀價值在於它與停留秒數的組合。停留六秒且沒有任何互動，與停留六秒且捲動過，是兩種完全不同的閱讀行為——前者是頁面被打開然後放著，後者是有人真的在看。單獨看秒數分不出這兩者。&lt;/p>
&lt;h2 id="資料模型變更會讓既有的報表公式換語意">資料模型變更會讓既有的報表公式換語意&lt;/h2>
&lt;p>這個變更有一個容易被漏掉的下游影響：&lt;strong>在單事件模型下寫的統計公式，在雙事件模型下仍然合法執行，但回答的已經不是同一個問題&lt;/strong>。&lt;/p>
&lt;p>具體的例子是判斷「這次 session 只看了一頁」。單事件模型下，這等價於「同一個 session 識別碼只出現一次」：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">COUNTIFS(session欄, 當列session) = 1&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>改成雙事件之後，每次瀏覽至少產生兩列，這個條件從此永遠不成立。公式沒有報錯、沒有回傳錯誤值，只是那個分類的計數變成恆為零——而零看起來就像「沒有這種流量」。正確的寫法要把事件型別納入條件：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">COUNTIFS(session欄, 當列session, 事件欄, &amp;#34;view&amp;#34;) = 1&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同一個影響也及於&lt;a href="https://tarrragon.github.io/blog/automation/04-triggers-automation/time-driven-aggregation/" data-link-title="時間觸發器：把 raw log 彙總成日報" data-link-desc="用時間觸發器每天定時把原始瀏覽 log group 成日報，以及在 90 分鐘觸發配額下只讀增量的效率寫法">模組四的每日彙總&lt;/a>：那段 group by 對每一列加一，在單事件模型下等於數瀏覽次數，改成雙事件之後多數瀏覽會被數兩次。倍率不是固定的二——離開事件本來就會丟失一部分，實際倍率落在一與二之間、且隨行動裝置佔比浮動。這正是這類失效難察覺的原因：固定倍數還有機會被看出來，浮動的比例只會讓數字「看起來變多了」。修法是在迴圈裡先篩掉非進入事件，模組四的程式碼旁已標明這個前提。&lt;/p></description><content:encoded><![CDATA[<p>事件模型決定一次瀏覽在資料裡留下幾列、每列各自承載什麼。起點的模型是一次瀏覽一列、在頁面載入時寫下（模組二建立的那個）——它能回答「某頁被打開幾次」，回答不了「打開之後發生了什麼」。頁面被打開一秒就關掉，跟被讀了三分鐘、中途捲動了十次，在資料上完全相同。</p>
<p>補上這個能力需要第二則事件，而第二則事件的設計比第一則複雜：載入的時機明確且必定發生，離開的時機則有多種可能，且不保證每次都能送出。</p>
<h2 id="進入與離開拆成兩則事件">進入與離開拆成兩則事件</h2>
<p>模型是每次瀏覽產生兩列：<strong>進入事件在頁面載入時送出，離開事件在頁面不再可見時送出</strong>，兩列靠 session 識別碼與路徑配對起來。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// send()：把物件轉成 JSON 字串、用 navigator.sendBeacon 送出（模組二建立）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">// deviceType()：回傳 mobile / tablet / desktop（模組二建立）
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">// visitorId / sessionId：兩層隨機識別碼（見〈訪客識別與 opt-out〉）
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">base</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">path</span><span class="o">:</span> <span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span><span class="p">,</span> <span class="nx">lang</span><span class="o">:</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">language</span><span class="p">,</span> <span class="nx">dev</span><span class="o">:</span> <span class="nx">deviceType</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">             <span class="nx">vid</span><span class="o">:</span> <span class="nx">visitorId</span><span class="p">,</span> <span class="nx">sid</span><span class="o">:</span> <span class="nx">sessionId</span> <span class="p">};</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// 進入：載入時立刻送，帶來源網址
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="nx">send</span><span class="p">(</span><span class="nb">Object</span><span class="p">.</span><span class="nx">assign</span><span class="p">({</span> <span class="nx">t</span><span class="o">:</span> <span class="s2">&#34;view&#34;</span><span class="p">,</span> <span class="nx">ref</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">referrer</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span> <span class="p">},</span> <span class="nx">base</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 離開：帶停留秒數與是否互動過
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">arrivedAt</span> <span class="o">=</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">var</span> <span class="nx">interacted</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="kd">var</span> <span class="nx">leaveSent</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">[</span><span class="s2">&#34;scroll&#34;</span><span class="p">,</span> <span class="s2">&#34;pointerdown&#34;</span><span class="p">,</span> <span class="s2">&#34;keydown&#34;</span><span class="p">].</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">evt</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="nx">addEventListener</span><span class="p">(</span><span class="nx">evt</span><span class="p">,</span> <span class="kd">function</span> <span class="p">()</span> <span class="p">{</span> <span class="nx">interacted</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span> <span class="p">},</span> <span class="p">{</span> <span class="nx">once</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">passive</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="kd">function</span> <span class="nx">sendLeave</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">leaveSent</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="nx">leaveSent</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="nx">send</span><span class="p">(</span><span class="nb">Object</span><span class="p">.</span><span class="nx">assign</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">t</span><span class="o">:</span> <span class="s2">&#34;leave&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">dur</span><span class="o">:</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">((</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">-</span> <span class="nx">arrivedAt</span><span class="p">)</span> <span class="o">/</span> <span class="mi">1000</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="nx">act</span><span class="o">:</span> <span class="nx">interacted</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">  <span class="p">},</span> <span class="nx">base</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>拆成兩列而不是合併成一列，是為了讓兩件事各自失敗。</strong> 合併的設計會是「等到離開時才送一列，裡面同時放路徑與停留時間」——它看起來省一半的列數，代價是離開事件送不出去時，這次瀏覽在資料裡完全不存在。而離開事件送不出去是會實際發生的，至少有這些途徑：行動裝置被系統回收記憶體、瀏覽器被強制關閉、裝置斷電、網路在切換基地台時中斷、廣告或隱私擴充功能攔截這個請求、以及接收端在流量尖峰撞到併發上限而拒絕。拆開之後，最壞的結果只損失品質欄位，計數的基準線不受影響。</p>
<p>這份丟失清單在後面還有一個用途。<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>會用「沒有離開事件」當作機器的候選訊號，而上面每一條途徑都會讓真人產生同樣的形態——<strong>行動流量佔比高的站尤其明顯</strong>。判讀那個訊號時要一併想起這一段。</p>
<p>這個取捨的另一面是列數翻倍。試算表容量的影響見<a href="/blog/automation/03-sheet-as-database/data-model-and-capacity/" data-link-title="資料模型與容量邊界" data-link-desc="raw log 表的欄位怎麼設計才好彙總、以及 Sheets 累積到多少列會開始撐不住、撐不住的訊號長什麼樣">模組三的資料模型與容量邊界</a>，執行配額（併發上限與觸發器時間）的影響見<a href="/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">模組五的配額碰撞</a>。個人網站的量體下這個代價可以接受；量體大到需要壓縮時，正確的方向是在接收端合併而不是在前端少送——前端少送會讓資料從一開始就不存在。</p>
<h2 id="用-visibilitychange-觸發離開事件">用 visibilitychange 觸發離開事件</h2>
<p>離開事件的觸發時機有三個候選，可靠度差距很大。</p>
<p><strong><code>beforeunload</code> 不適用。</strong> 行動版 Safari 不支援它，所以 iOS 上的所有瀏覽器都不會觸發；而在 Firefox，註冊這個監聽器會讓頁面失去進入 bfcache 的資格——那是瀏覽器把整個頁面狀態留在記憶體裡、讓上一頁 / 下一頁能瞬間還原的機制，失去資格代表讀者按返回時要重新載入——為了統計而拖慢讀者的返回操作，取捨方向是錯的。各家瀏覽器一致排除出 bfcache 的其實是 <code>unload</code>，現代瀏覽器的 <code>beforeunload</code> 本身已不影響 bfcache，但 Firefox 這個例外加上行動端完全不支援，已經足以讓它出局。</p>
<p><strong><code>pagehide</code> 涵蓋頁面卸載與進入快取兩種時機</strong>（<code>event.persisted</code> 可區分兩者），比 <code>beforeunload</code> 可靠，但漏掉「切換到別的分頁或別的 app」這個最常見的離開方式。</p>
<p><strong><code>visibilitychange</code> 進入 <code>hidden</code> 狀態涵蓋範圍最廣</strong>：切換分頁、導覽到新頁、切換 app、行動裝置鎖定螢幕、最小化或關閉瀏覽器都會走到。規格把「轉為 hidden」描述成頁面最後一個能可靠觀察到的事件。兩者都註冊、靠旗標防止重複送出：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">&#34;visibilitychange&#34;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">visibilityState</span> <span class="o">===</span> <span class="s2">&#34;hidden&#34;</span><span class="p">)</span> <span class="nx">sendLeave</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">&#34;pagehide&#34;</span><span class="p">,</span> <span class="nx">sendLeave</span><span class="p">);</span></span></span></code></pre></div><p><code>sendLeave</code> 開頭那個 <code>leaveSent</code> 旗標在這裡是必要的而非防禦性的——一次真實的離開通常會同時觸發這兩個事件。</p>
<p>它帶來一個容易漏掉的交互作用：<strong>頁面進入 bfcache 時 JS 狀態整個保留下來</strong>，讀者按返回還原頁面之後，<code>leaveSent</code> 仍然是 <code>true</code>、<code>arrivedAt</code> 仍然停在上一次載入的時刻。不處理的話，這次閱讀不會產生任何記錄，而在快取裡待的那段時間會被算進上一筆的停留秒數。修法是監聽 <code>pageshow</code>，在 <code>event.persisted</code> 為真時把三個狀態變數重置並補送一則進入事件：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">&#34;pageshow&#34;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">evt</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">evt</span><span class="p">.</span><span class="nx">persisted</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>          <span class="c1">// 一般載入不需要處理
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nx">leaveSent</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">interacted</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">arrivedAt</span> <span class="o">=</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">send</span><span class="p">(</span><span class="nb">Object</span><span class="p">.</span><span class="nx">assign</span><span class="p">({</span> <span class="nx">t</span><span class="o">:</span> <span class="s2">&#34;view&#34;</span><span class="p">,</span> <span class="nx">ref</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">referrer</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span> <span class="p">},</span> <span class="nx">base</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>送出方式是 <code>navigator.sendBeacon</code>（見<a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon 知識卡</a>），它的設計目標正是這個場景：頁面正在關閉時，一般的請求會隨著頁面銷毀而被取消，而 <code>sendBeacon</code> 把資料排進瀏覽器的傳送佇列、不阻擋頁面卸載。</p>
<p>它保證的是「不延遲卸載」而非「一定送達」——排進佇列之後的傳送結果不會回報，行動裝置被切走或強制關閉時仍可能沒送出去。同步能得知的只有排隊本身成不成功：佇列有 64 KiB 的上限，超過或排隊失敗時回傳 <code>false</code>。流量事件的 payload 遠小於這個上限，但要監控收集鏈健康度時，這個回傳值是唯一在瀏覽器端可見的失敗訊號。</p>
<h2 id="停留秒數對應的是可見時長">停留秒數對應的是可見時長</h2>
<p>這個欄位的語意需要精確界定，否則會被當成閱讀時長使用而得出錯誤結論。<strong>它量的是「頁面從載入到不再可見之間經過的時間」</strong>，這與讀者實際花在這篇文章上的時間有系統性的差距。</p>
<p>差距來自兩個方向。<strong>切換到別的分頁去查資料再切回來，會被切成多段</strong>——第一段在切走時就結束了，切回來不會產生新的進入事件（頁面沒有重新載入），所以回來之後的閱讀時間完全沒有被記錄。反過來，<strong>開著分頁去做別的事</strong>，如果沒有切換到其他分頁（例如直接離開電腦），這段時間會全部算進停留秒數。</p>
<p>站內導覽時還有一個可以驗證的現象：離開事件與下一頁進入事件的時間戳落在同一兩秒內，因為舊頁進入 <code>hidden</code> 與新頁開始載入是同一個動作的兩面。時間戳由接收端在寫入時補上，所以這個間隔還包含網路傳輸與接收端排隊，不是純粹的瀏覽器事件間隔。用它重建連續的閱讀路徑時，配對條件要留這個寬容範圍。</p>
<p>這個欄位因此適合分辨量級、不適合精確比較，而分界由誤差來源決定：中途切換到別的分頁會讓後半段完全不計，所以量測誤差可以達到一次分頁切換的完整時長——查一個名詞、回一則訊息都是數十秒的量級。<strong>比較的粒度要大於這個誤差</strong>，否則差異可能全部來自切換行為而非閱讀。分桶而不是比大小，是這個限制下唯一穩健的用法。</p>
<h2 id="互動旗標">互動旗標</h2>
<p><code>act</code> 記錄的是「這次瀏覽期間有沒有發生過捲動、點擊或按鍵」。它用 <code>{ once: true }</code> 註冊，第一次觸發後就移除監聽器——需要的資訊只是「有沒有發生過」，不是次數，持續監聽只會增加沒有用途的執行成本。</p>
<p><code>passive: true</code> 這個選項對捲動事件是必要的：它向瀏覽器宣告這個監聽器不會取消預設行為，讓捲動不必等待 JS 執行完成。統計程式碼不該是頁面捲動卡頓的來源。</p>
<p>這個旗標的判讀價值在於它與停留秒數的組合。停留六秒且沒有任何互動，與停留六秒且捲動過，是兩種完全不同的閱讀行為——前者是頁面被打開然後放著，後者是有人真的在看。單獨看秒數分不出這兩者。</p>
<h2 id="資料模型變更會讓既有的報表公式換語意">資料模型變更會讓既有的報表公式換語意</h2>
<p>這個變更有一個容易被漏掉的下游影響：<strong>在單事件模型下寫的統計公式，在雙事件模型下仍然合法執行，但回答的已經不是同一個問題</strong>。</p>
<p>具體的例子是判斷「這次 session 只看了一頁」。單事件模型下，這等價於「同一個 session 識別碼只出現一次」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">COUNTIFS(session欄, 當列session) = 1</span></span></code></pre></div><p>改成雙事件之後，每次瀏覽至少產生兩列，這個條件從此永遠不成立。公式沒有報錯、沒有回傳錯誤值，只是那個分類的計數變成恆為零——而零看起來就像「沒有這種流量」。正確的寫法要把事件型別納入條件：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">COUNTIFS(session欄, 當列session, 事件欄, &#34;view&#34;) = 1</span></span></code></pre></div><p>同一個影響也及於<a href="/blog/automation/04-triggers-automation/time-driven-aggregation/" data-link-title="時間觸發器：把 raw log 彙總成日報" data-link-desc="用時間觸發器每天定時把原始瀏覽 log group 成日報，以及在 90 分鐘觸發配額下只讀增量的效率寫法">模組四的每日彙總</a>：那段 group by 對每一列加一，在單事件模型下等於數瀏覽次數，改成雙事件之後多數瀏覽會被數兩次。倍率不是固定的二——離開事件本來就會丟失一部分，實際倍率落在一與二之間、且隨行動裝置佔比浮動。這正是這類失效難察覺的原因：固定倍數還有機會被看出來，浮動的比例只會讓數字「看起來變多了」。修法是在迴圈裡先篩掉非進入事件，模組四的程式碼旁已標明這個前提。</p>
<p><strong>因此資料模型的變更範圍必須包含下游的分析邏輯</strong>，把公式與彙總程式的重算當成同一次變更的一部分，而不是之後再說。延後處理會失去對照：之後回頭看時，看到的數字已經是新邏輯算出來的，沒有東西可以比對。這類失效的完整判讀方法見<a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a>，抽象層的原則見 <a href="/blog/report/new-data-shape-silently-changes-analysis/" data-link-title="資料多出一種形狀時，既有分析邏輯靜默換語意" data-link-desc="資料模型新增一種列或事件型別後，判斷既有的公式、查詢與聚合是否還在算同一件事——它們不報錯，只是漏掉或重複整類資料">#250 資料多出一種形狀時，既有分析邏輯靜默換語意</a>。</p>
<h2 id="下一步">下一步</h2>
<p>事件模型完成後，資料裡有了停留時間與互動訊號。這兩個欄位除了描述閱讀行為，也比環境屬性難偽裝，因此是分辨真人與機器的主要依據——前提是先知道它們在真人身上也會缺席。怎麼組合它們與其他訊號、以及怎麼量出自己站上的真人基線，見<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>。</p>
]]></content:encoded></item><item><title>辨識自動化流量</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/</guid><description>&lt;p>辨識自動化流量的責任是在統計裡把機器抓取與真人閱讀分開。未分離的統計會高估閱讀量，而高估的方向是單向的——沒有人會因為數字太好看而去查證。&lt;/p>
&lt;p>這一章的作法受兩個限制支配：接收端拿不到判別素材，以及會出現在這份統計裡的機器比想像中少。先講清楚這兩件事，它們決定了訊號從哪裡來、以及能期待抓到什麼。&lt;/p>
&lt;h2 id="接收端讀不到請求標頭">接收端讀不到請求標頭&lt;/h2>
&lt;p>Apps Script 的 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">&lt;code>doPost(e)&lt;/code>&lt;/a> 事件物件提供 &lt;code>parameter&lt;/code>、&lt;code>parameters&lt;/code>、&lt;code>queryString&lt;/code>、&lt;code>contentLength&lt;/code>、&lt;code>postData&lt;/code>、&lt;code>pathInfo&lt;/code> 這幾個欄位，&lt;strong>不提供任何 HTTP 標頭&lt;/strong>。這代表接收端拿不到 &lt;code>User-Agent&lt;/code>，也拿不到來源 IP。&lt;/p>
&lt;p>傳統的伺服器端過濾（比對 UA 字串、查 IP 反解、比對已知爬蟲的 IP 區段）在這個架構下全部不可用。所有判斷素材必須由前端蒐集之後放進 payload 送出——這既是限制，也界定了設計空間：&lt;strong>能用的訊號只有瀏覽器環境暴露給 JavaScript 的那些&lt;/strong>。&lt;/p>
&lt;p>這個限制連帶產生一條必須守住的界線。前端能取得完整的 &lt;code>userAgent&lt;/code>、螢幕尺寸、時區、字型清單，把它們全部送出確實能提高辨識率，但那正好構成&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/browser-fingerprint/" data-link-title="Browser Fingerprint（瀏覽器指紋）" data-link-desc="由多個單獨無害的瀏覽器環境屬性組合而成的裝置特徵集合，決定自建統計蒐集判別訊號時的能力上限">瀏覽器指紋&lt;/a>——為了分辨機器而蒐集足以識別個人的資料，取捨方向是錯的。後面的每個訊號都在「有判別力」與「不構成指紋」之間取值。&lt;/p>
&lt;h2 id="會被-beacon-記錄到的機器只有一小部分">會被 beacon 記錄到的機器只有一小部分&lt;/h2>
&lt;p>beacon 靠 JavaScript 觸發，所以&lt;strong>只有會執行 JS 的抓取工具才會進入這份統計&lt;/strong>。這一條大幅縮小要辨識的對象範圍，而它與直覺相反：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>抓取工具&lt;/th>
 &lt;th>執行 JS&lt;/th>
 &lt;th>出現在統計裡&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Googlebot&lt;/td>
 &lt;td>會&lt;/td>
 &lt;td>會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>無頭瀏覽器驅動的抓取工具（Playwright / Puppeteer）&lt;/td>
 &lt;td>會&lt;/td>
 &lt;td>會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>監控與可用性檢查服務&lt;/td>
 &lt;td>多數會&lt;/td>
 &lt;td>會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GPTBot / ClaudeBot / PerplexityBot 等 AI 訓練爬蟲&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>facebookexternalhit 等連結預覽&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CCBot / AhrefsBot / SemrushBot&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>AI 訓練爬蟲不執行 JavaScript&lt;/strong> 是其中最反直覺的一項。Vercel 在 2025 年對自家平台流量的量測顯示，GPTBot 與 ClaudeBot 都會抓取 JS 檔案卻從不執行；連結預覽服務只讀 HTML 的 meta 標籤。&lt;strong>在主流爬蟲裡，目前已知具備完整 JS 渲染的只有 Googlebot&lt;/strong>——這是對演化中的世界所做的觀察，不是永久成立的分類，各家的渲染能力隨時可能改變。&lt;/p>
&lt;p>這件事有三個後續影響。其一，這套統計看不見的機器流量遠多於看得見的——伺服器日誌裡的爬蟲比例與這裡的數字不可比較。其二，下一節那份具名清單接住的是「會執行 JS」與「誠實申報身分」的交集，比清單本身小得多；清單裡多數條目預期永遠不會命中，保留它們是為了接住少數會渲染的變體。&lt;/p>
&lt;p>其三會影響上一章的判讀：&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">來源網址空白的成因&lt;/a>把「自動化抓取」列為其中一類，而這一節說明了那一類的實際佔比比直覺低——真正大量抓取的工具不執行 JS、根本不在這份資料裡。空白列偏向真人的機率因此比乍看之下高。&lt;/p>
&lt;h2 id="訊號分層與信心度">訊號分層與信心度&lt;/h2>
&lt;p>四個訊號的可靠度不同，分開記錄而不是合併成單一結論。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>信心&lt;/th>
 &lt;th>主要誤差方向&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>webdriver&lt;/code>&lt;/td>
 &lt;td>&lt;code>navigator.webdriver === true&lt;/code>&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>幾乎不誤判真人；規避工具會蓋掉&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>ua&lt;/code>&lt;/td>
 &lt;td>UA 字串比對自我申報名稱&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>不誤判真人；抓不到偽裝者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>lang&lt;/code>&lt;/td>
 &lt;td>&lt;code>navigator.languages&lt;/code> 為空&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>隱私擴充的真人誤中；新版無頭已不命中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>size&lt;/code>&lt;/td>
 &lt;td>&lt;code>window.outerWidth&lt;/code> 為 0&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>嵌入式 webview 的真人誤中；同上&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>這個信心排序由誤差方向推導而來，不是本站資料測得的命中率。&lt;/strong> 沒有標註任何誤判比例是刻意的——那需要一份已知組成的對照流量，而本文的每個判準都停在「這個訊號可能怎麼錯」的層次。既然正確率無從得知，處置方式就不是「先用著、之後校正」，而是&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/#%e6%a8%99%e8%a8%98%e8%80%8c%e9%9d%9e%e4%b8%9f%e6%a3%84" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">讓判定與使用分離&lt;/a>：訊號照實記下、判定留在彙總端，改了規則之後可以拿同一批舊資料重跑比對。&lt;/p>
&lt;p>&lt;strong>&lt;code>navigator.webdriver&lt;/code> 是自動化框架的預設標記。&lt;/strong> Playwright、Puppeteer、Selenium 啟動的瀏覽器這個值都是 &lt;code>true&lt;/code>，一般瀏覽器是 &lt;code>false&lt;/code>。它的判別力強而誤判極少，代價是刻意規避偵測的工具第一件事就是把它蓋掉——因此它抓得到的是「沒有隱藏身分意圖」的自動化訪客。&lt;/p>
&lt;p>&lt;strong>UA 比對抓的是自我申報。&lt;/strong> 搜尋引擎與正派的抓取服務會在 &lt;code>User-Agent&lt;/code> 裡寫明自己是誰，這是它們與網站經營者之間的慣例。比對這個字樣不會誤判真人，因為真人的瀏覽器不會自稱是機器。&lt;/p>
&lt;p>&lt;strong>空語言清單與零視窗尺寸原本是無頭環境的特徵，而這兩條正在失效。&lt;/strong> Chrome 112 起的新無頭模式會建立一個不顯示的平台視窗，並帶有正常的語言偏好，兩項特徵大多不再成立。它們的誤差方向因此從「誤判真人」偏向「漏判機器」——保留是因為舊版工具仍在使用中，但不能當作主力。&lt;/p>
&lt;p>因為誤差方向不同，&lt;strong>記錄下來的是命中了哪些訊號、而不是一個布林結論&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">botSignal&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">hits&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">webdriver&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">hits&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;webdriver&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// UA 比對見下一節
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">languages&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nx">navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">languages&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">hits&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;lang&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nb">window&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">outerWidth&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nb">window&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">outerHeight&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">hits&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;size&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">catch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 偵測本身出錯不該影響統計，當作沒有訊號
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">hits&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;,&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個設計讓&lt;strong>判定閾值留在彙總端&lt;/strong>——也就是試算表那一側，資料寫進去之後用公式分類的地方。日後發現某條訊號誤判太多，改公式就好，不必改前端重新部署；而前端一旦部署出去，快取的頁面還會用舊版跑一段時間。&lt;/p>
&lt;p>送出的字串寫進試算表的一個欄位，後面稱它為訊號欄。&lt;/p>
&lt;h2 id="具名比對與通用退回">具名比對與通用退回&lt;/h2>
&lt;p>UA 比對要回答的問題有兩層：「這是不是機器」以及「是哪一隻機器」。後者的價值在於處置方向不同——搜尋引擎索引是內容被收錄的證據，監控服務是自己設定的，而不具名的抓取工具才是需要留意的那一類。&lt;/p>
&lt;p>直覺的作法是列一份已知名單做精確比對。這個作法單獨使用時有一個不會顯現的失效模式：&lt;strong>名單會過時，而過時的表現是新出現的抓取工具完全不被標記&lt;/strong>——統計上呈現為自動化流量比例逐漸下降，而這個下降與「內容吸引到更多真人」在數字上不可區分。&lt;/p>
&lt;p>因此比對分成兩層，讓過時的代價落在精度而非覆蓋：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 以下兩個常數與比對邏輯接在上一段 botSignal() 的 try 區塊內，共用同一個 hits 陣列
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">// 已知抓取工具的自我申報名稱，命中時送出具體名稱
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">KNOWN_BOTS&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sr">/claudebot|claude-user|claude-searchbot|gptbot|oai-searchbot|chatgpt-user|perplexitybot|googlebot|bingbot|applebot|duckduckbot|yandexbot|bytespider|amazonbot|ccbot|meta-externalagent|facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|semrushbot|ahrefsbot|dotbot|petalbot/i&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 通用字樣，接住誠實申報但不在具名清單裡的自動化訪客
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">GENERIC_BOT&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sr">/bot\b|crawler|spider|headless|slurp|bingpreview|python-requests|curl\//i&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">ua&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">userAgent&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">named&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">ua&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">match&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">KNOWN_BOTS&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">generic&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">named&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="kc">null&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="nx">ua&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">match&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">GENERIC_BOT&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">named&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">hits&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;ua:&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">named&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">].&lt;/span>&lt;span class="nx">toLowerCase&lt;/span>&lt;span class="p">());&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="k">else&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">generic&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">hits&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;ua:&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">generic&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">].&lt;/span>&lt;span class="nx">toLowerCase&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">replace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sr">/[^a-z]+$/&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">));&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩層的比對來源不同是關鍵：具名層比對的是身分（清單裡的名字），通用層匹配的是類別詞（&lt;code>bot&lt;/code>、&lt;code>crawler&lt;/code>、&lt;code>spider&lt;/code>、&lt;code>headless&lt;/code>），後者不需要知道任何一隻抓取工具的名字就能運作。清單過時時，新工具仍然被通用層接住——失去的是「知道是誰」，不是「知道有」。&lt;/p></description><content:encoded><![CDATA[<p>辨識自動化流量的責任是在統計裡把機器抓取與真人閱讀分開。未分離的統計會高估閱讀量，而高估的方向是單向的——沒有人會因為數字太好看而去查證。</p>
<p>這一章的作法受兩個限制支配：接收端拿不到判別素材，以及會出現在這份統計裡的機器比想像中少。先講清楚這兩件事，它們決定了訊號從哪裡來、以及能期待抓到什麼。</p>
<h2 id="接收端讀不到請求標頭">接收端讀不到請求標頭</h2>
<p>Apps Script 的 <a href="/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼"><code>doPost(e)</code></a> 事件物件提供 <code>parameter</code>、<code>parameters</code>、<code>queryString</code>、<code>contentLength</code>、<code>postData</code>、<code>pathInfo</code> 這幾個欄位，<strong>不提供任何 HTTP 標頭</strong>。這代表接收端拿不到 <code>User-Agent</code>，也拿不到來源 IP。</p>
<p>傳統的伺服器端過濾（比對 UA 字串、查 IP 反解、比對已知爬蟲的 IP 區段）在這個架構下全部不可用。所有判斷素材必須由前端蒐集之後放進 payload 送出——這既是限制，也界定了設計空間：<strong>能用的訊號只有瀏覽器環境暴露給 JavaScript 的那些</strong>。</p>
<p>這個限制連帶產生一條必須守住的界線。前端能取得完整的 <code>userAgent</code>、螢幕尺寸、時區、字型清單，把它們全部送出確實能提高辨識率，但那正好構成<a href="/blog/automation/knowledge-cards/browser-fingerprint/" data-link-title="Browser Fingerprint（瀏覽器指紋）" data-link-desc="由多個單獨無害的瀏覽器環境屬性組合而成的裝置特徵集合，決定自建統計蒐集判別訊號時的能力上限">瀏覽器指紋</a>——為了分辨機器而蒐集足以識別個人的資料，取捨方向是錯的。後面的每個訊號都在「有判別力」與「不構成指紋」之間取值。</p>
<h2 id="會被-beacon-記錄到的機器只有一小部分">會被 beacon 記錄到的機器只有一小部分</h2>
<p>beacon 靠 JavaScript 觸發，所以<strong>只有會執行 JS 的抓取工具才會進入這份統計</strong>。這一條大幅縮小要辨識的對象範圍，而它與直覺相反：</p>
<table>
  <thead>
      <tr>
          <th>抓取工具</th>
          <th>執行 JS</th>
          <th>出現在統計裡</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Googlebot</td>
          <td>會</td>
          <td>會</td>
      </tr>
      <tr>
          <td>無頭瀏覽器驅動的抓取工具（Playwright / Puppeteer）</td>
          <td>會</td>
          <td>會</td>
      </tr>
      <tr>
          <td>監控與可用性檢查服務</td>
          <td>多數會</td>
          <td>會</td>
      </tr>
      <tr>
          <td>GPTBot / ClaudeBot / PerplexityBot 等 AI 訓練爬蟲</td>
          <td>不會</td>
          <td>不會</td>
      </tr>
      <tr>
          <td>facebookexternalhit 等連結預覽</td>
          <td>不會</td>
          <td>不會</td>
      </tr>
      <tr>
          <td>CCBot / AhrefsBot / SemrushBot</td>
          <td>不會</td>
          <td>不會</td>
      </tr>
  </tbody>
</table>
<p><strong>AI 訓練爬蟲不執行 JavaScript</strong> 是其中最反直覺的一項。Vercel 在 2025 年對自家平台流量的量測顯示，GPTBot 與 ClaudeBot 都會抓取 JS 檔案卻從不執行；連結預覽服務只讀 HTML 的 meta 標籤。<strong>在主流爬蟲裡，目前已知具備完整 JS 渲染的只有 Googlebot</strong>——這是對演化中的世界所做的觀察，不是永久成立的分類，各家的渲染能力隨時可能改變。</p>
<p>這件事有三個後續影響。其一，這套統計看不見的機器流量遠多於看得見的——伺服器日誌裡的爬蟲比例與這裡的數字不可比較。其二，下一節那份具名清單接住的是「會執行 JS」與「誠實申報身分」的交集，比清單本身小得多；清單裡多數條目預期永遠不會命中，保留它們是為了接住少數會渲染的變體。</p>
<p>其三會影響上一章的判讀：<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">來源網址空白的成因</a>把「自動化抓取」列為其中一類，而這一節說明了那一類的實際佔比比直覺低——真正大量抓取的工具不執行 JS、根本不在這份資料裡。空白列偏向真人的機率因此比乍看之下高。</p>
<h2 id="訊號分層與信心度">訊號分層與信心度</h2>
<p>四個訊號的可靠度不同，分開記錄而不是合併成單一結論。</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>來源</th>
          <th>信心</th>
          <th>主要誤差方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>webdriver</code></td>
          <td><code>navigator.webdriver === true</code></td>
          <td>高</td>
          <td>幾乎不誤判真人；規避工具會蓋掉</td>
      </tr>
      <tr>
          <td><code>ua</code></td>
          <td>UA 字串比對自我申報名稱</td>
          <td>高</td>
          <td>不誤判真人；抓不到偽裝者</td>
      </tr>
      <tr>
          <td><code>lang</code></td>
          <td><code>navigator.languages</code> 為空</td>
          <td>中</td>
          <td>隱私擴充的真人誤中；新版無頭已不命中</td>
      </tr>
      <tr>
          <td><code>size</code></td>
          <td><code>window.outerWidth</code> 為 0</td>
          <td>中</td>
          <td>嵌入式 webview 的真人誤中；同上</td>
      </tr>
  </tbody>
</table>
<p><strong>這個信心排序由誤差方向推導而來，不是本站資料測得的命中率。</strong> 沒有標註任何誤判比例是刻意的——那需要一份已知組成的對照流量，而本文的每個判準都停在「這個訊號可能怎麼錯」的層次。既然正確率無從得知，處置方式就不是「先用著、之後校正」，而是<a href="/blog/automation/06-reading-the-data/automated-traffic/#%e6%a8%99%e8%a8%98%e8%80%8c%e9%9d%9e%e4%b8%9f%e6%a3%84" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">讓判定與使用分離</a>：訊號照實記下、判定留在彙總端，改了規則之後可以拿同一批舊資料重跑比對。</p>
<p><strong><code>navigator.webdriver</code> 是自動化框架的預設標記。</strong> Playwright、Puppeteer、Selenium 啟動的瀏覽器這個值都是 <code>true</code>，一般瀏覽器是 <code>false</code>。它的判別力強而誤判極少，代價是刻意規避偵測的工具第一件事就是把它蓋掉——因此它抓得到的是「沒有隱藏身分意圖」的自動化訪客。</p>
<p><strong>UA 比對抓的是自我申報。</strong> 搜尋引擎與正派的抓取服務會在 <code>User-Agent</code> 裡寫明自己是誰，這是它們與網站經營者之間的慣例。比對這個字樣不會誤判真人，因為真人的瀏覽器不會自稱是機器。</p>
<p><strong>空語言清單與零視窗尺寸原本是無頭環境的特徵，而這兩條正在失效。</strong> Chrome 112 起的新無頭模式會建立一個不顯示的平台視窗，並帶有正常的語言偏好，兩項特徵大多不再成立。它們的誤差方向因此從「誤判真人」偏向「漏判機器」——保留是因為舊版工具仍在使用中，但不能當作主力。</p>
<p>因為誤差方向不同，<strong>記錄下來的是命中了哪些訊號、而不是一個布林結論</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">botSignal</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">hits</span> <span class="o">=</span> <span class="p">[];</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">navigator</span><span class="p">.</span><span class="nx">webdriver</span> <span class="o">===</span> <span class="kc">true</span><span class="p">)</span> <span class="nx">hits</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;webdriver&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="c1">// UA 比對見下一節
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">navigator</span><span class="p">.</span><span class="nx">languages</span> <span class="o">||</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">languages</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="nx">hits</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;lang&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">window</span><span class="p">.</span><span class="nx">outerWidth</span> <span class="o">||</span> <span class="o">!</span><span class="nb">window</span><span class="p">.</span><span class="nx">outerHeight</span><span class="p">)</span> <span class="nx">hits</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;size&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="c1">// 偵測本身出錯不該影響統計，當作沒有訊號
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">return</span> <span class="nx">hits</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s2">&#34;,&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個設計讓<strong>判定閾值留在彙總端</strong>——也就是試算表那一側，資料寫進去之後用公式分類的地方。日後發現某條訊號誤判太多，改公式就好，不必改前端重新部署；而前端一旦部署出去，快取的頁面還會用舊版跑一段時間。</p>
<p>送出的字串寫進試算表的一個欄位，後面稱它為訊號欄。</p>
<h2 id="具名比對與通用退回">具名比對與通用退回</h2>
<p>UA 比對要回答的問題有兩層：「這是不是機器」以及「是哪一隻機器」。後者的價值在於處置方向不同——搜尋引擎索引是內容被收錄的證據，監控服務是自己設定的，而不具名的抓取工具才是需要留意的那一類。</p>
<p>直覺的作法是列一份已知名單做精確比對。這個作法單獨使用時有一個不會顯現的失效模式：<strong>名單會過時，而過時的表現是新出現的抓取工具完全不被標記</strong>——統計上呈現為自動化流量比例逐漸下降，而這個下降與「內容吸引到更多真人」在數字上不可區分。</p>
<p>因此比對分成兩層，讓過時的代價落在精度而非覆蓋：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 以下兩個常數與比對邏輯接在上一段 botSignal() 的 try 區塊內，共用同一個 hits 陣列
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">// 已知抓取工具的自我申報名稱，命中時送出具體名稱
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">KNOWN_BOTS</span> <span class="o">=</span> <span class="sr">/claudebot|claude-user|claude-searchbot|gptbot|oai-searchbot|chatgpt-user|perplexitybot|googlebot|bingbot|applebot|duckduckbot|yandexbot|bytespider|amazonbot|ccbot|meta-externalagent|facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|semrushbot|ahrefsbot|dotbot|petalbot/i</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 通用字樣，接住誠實申報但不在具名清單裡的自動化訪客
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">GENERIC_BOT</span> <span class="o">=</span> <span class="sr">/bot\b|crawler|spider|headless|slurp|bingpreview|python-requests|curl\//i</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">var</span> <span class="nx">ua</span> <span class="o">=</span> <span class="nx">navigator</span><span class="p">.</span><span class="nx">userAgent</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">var</span> <span class="nx">named</span> <span class="o">=</span> <span class="nx">ua</span><span class="p">.</span><span class="nx">match</span><span class="p">(</span><span class="nx">KNOWN_BOTS</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">var</span> <span class="nx">generic</span> <span class="o">=</span> <span class="nx">named</span> <span class="o">?</span> <span class="kc">null</span> <span class="o">:</span> <span class="nx">ua</span><span class="p">.</span><span class="nx">match</span><span class="p">(</span><span class="nx">GENERIC_BOT</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">named</span><span class="p">)</span> <span class="nx">hits</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;ua:&#34;</span> <span class="o">+</span> <span class="nx">named</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">toLowerCase</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">generic</span><span class="p">)</span> <span class="nx">hits</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;ua:&#34;</span> <span class="o">+</span> <span class="nx">generic</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">toLowerCase</span><span class="p">().</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/[^a-z]+$/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">));</span></span></span></code></pre></div><p>兩層的比對來源不同是關鍵：具名層比對的是身分（清單裡的名字），通用層匹配的是類別詞（<code>bot</code>、<code>crawler</code>、<code>spider</code>、<code>headless</code>），後者不需要知道任何一隻抓取工具的名字就能運作。清單過時時，新工具仍然被通用層接住——失去的是「知道是誰」，不是「知道有」。</p>
<p>上面的 <code>GENERIC_BOT</code> 裡混了 <code>slurp</code>、<code>bingpreview</code>、<code>python-requests</code>、<code>curl/</code> 四個具體名稱，嚴格說它們屬於具名層——把產品名放進退回層，退回層就變成清單的延伸而不是獨立的一層。實際運作上它們是無害的殘留，但這個混用會讓「通用層純靠類別詞」這個保證打折：<code>wget</code>、<code>Go-http-client</code>、<code>node-fetch</code>、<code>axios</code> 這些同樣不含類別詞的 UA，兩層都接不住。</p>
<p>真正獨立於 UA 的那一層是 <code>webdriver</code> / <code>lang</code> / <code>size</code> ——它們讀的是瀏覽器環境而非字串，UA 比對整個失效時仍然運作。三層的關係值得明說：具名層給身分、通用層給類別、環境訊號層給「這個執行環境不像人用的」，而只有第三層完全不依賴任何列舉。</p>
<p>這個結構要能被觀測，標記本身得分得出層級。上面的寫法送出的是 <code>ua:googlebot</code> 與 <code>ua:bot</code>，而 <code>ua:slurp</code> 這種看起來像具名層、其實來自通用層——不比對兩條 regex 就分不出來。加一個前綴讓它自證：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">named</span><span class="p">)</span> <span class="nx">hits</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;ua:named:&#34;</span> <span class="o">+</span> <span class="nx">named</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">toLowerCase</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">generic</span><span class="p">)</span> <span class="nx">hits</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;ua:generic:&#34;</span> <span class="o">+</span> <span class="nx">generic</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">toLowerCase</span><span class="p">().</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/[^a-z]+$/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">));</span></span></span></code></pre></div><p>分得出層級之後，<code>COUNTIF(訊號欄, &quot;*ua:generic*&quot;) / COUNTIF(訊號欄, &quot;*ua:*&quot;)</code> 就是清單的過時指標：<strong>這個比例上升代表有新工具在被通用層接住、該更新具名清單了</strong>。這把「清單過時」從一件不可見的事變成一個可以看的數字，而且不需要有人事先知道漏了哪些新工具。抽象層的原則見 <a href="/blog/report/stale-list-costs-precision-not-coverage/" data-link-title="清單過時的代價要落在精度、不落在覆蓋" data-link-desc="判定依賴一份會過時的清單（已知名稱、白名單、簽章）時，決定清單失效之後還剩下什麼——只比對清單的設計會讓偵測能力隨時間下降，而數字看起來變好">#251 清單過時的代價要落在精度、不落在覆蓋</a>。</p>
<p>清單維護有一個實際邊界值得先講明。Anthropic 目前把不同用途拆成 <code>ClaudeBot</code>、<code>Claude-User</code>、<code>Claude-SearchBot</code> 多個 token，這類拆分會持續發生，而拆分後的新名稱在被加進清單前由通用層接住——那正是這個設計在運作。</p>
<p>另一個邊界是<strong>命名系統的混淆</strong>，而這份清單踩過一次。它上線時含有 <code>google-extended</code> 這個條目——那其實是 robots.txt 的控制 token，不會出現在任何請求的 <code>User-Agent</code> 裡，因此它從第一天起就是死的。系統不會回報「某個條目從未命中」，這個缺陷是靠一次外部查核才浮現的。robots.txt 的控制 token 與 UA 字串是兩套獨立的命名，這正是「訊號只能來自瀏覽器暴露給 JavaScript 的東西」這條限制的具體案例。</p>
<p>只送出命中的關鍵字、不送完整 <code>userAgent</code>，是前面那條界線的具體落實——那個關鍵字是抓取工具主動公開申報的身分，不構成指紋特徵。</p>
<h2 id="行為訊號對沒有規避意圖的工具有效代價是誤判方向指向真人">行為訊號對沒有規避意圖的工具有效，代價是誤判方向指向真人</h2>
<p>行為留下的痕跡比環境屬性稍微難改，但<strong>它們擋不住有規避意圖的工具</strong>。前面對 <code>webdriver</code> 說過的那句讓步在這裡同樣適用，而且成本更低：讓抓取工具在關閉前觸發一次 <code>visibilitychange</code> 是三行程式碼，隨機化頁面存活時間是一行，重用瀏覽器的 storage state 讓識別碼保持穩定是兩行——最後這項甚至提升效率，因為它本來就是自動化測試處理登入狀態的標準做法。</p>
<p>這一節的三個訊號在<strong>對象沒有隱藏意圖</strong>時判別力最強，同時<strong>誤判代價也最高</strong>——誤差方向一致指向「把真人判成機器」，而這個方向最難察覺，因為結果看起來是流量變乾淨了。</p>
<p><strong>離開事件缺席</strong>是其中最直接的一個。抓取工具取得頁面內容後直接終止瀏覽器環境，不會經歷「頁面轉為不可見」這個狀態轉換，因此不產生離開事件。</p>
<p>真人這一側同樣會缺席，而且不罕見。<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型</a>列過那份丟失清單：記憶體回收、強制關閉、斷電、網路中斷、擴充功能攔截、接收端併發撞頂。<strong>行動流量佔比高的站，真人的離開事件缺席率本來就不低</strong>，把缺席直接當成機器訊號會系統性誤判這一群人。</p>
<p><strong>只有離開事件、沒有進入事件</strong>這個相反的形態也一樣。可觀測的只有一件事：接收端在進入事件的時間點沒有收到任何請求。抓取工具的網路攔截設定是一種相容的成因——許多框架只放行文件與必要資源，等內容抓完、進入關閉流程時攔截器已經解除——但它只是幾個候選之一。</p>
<p>真人也會產生孤兒離開事件，至少三條路徑：<strong>接收端併發撞頂</strong>（<a href="/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額</a>的併發上限，見<a href="/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">模組五的配額碰撞</a>——爆量時同時湧入的進入事件被拒，離開事件數秒後送達時併發已消退，正好剩下孤兒）、廣告或隱私擴充功能攔截第一次請求、以及網路瞬斷。第一條特別值得注意，因為它與爆量時段重疊，會把流量高峰的真人整批劃給機器。</p>
<p><strong>停留時間的一致性</strong>洩漏的是排程。抓取工具的頁面存活時間由設定決定，因此相隔數小時、來自不同識別碼的多列可能有精確相同的秒數。這一條需要區間限定：<code>dur</code> 經過取整為秒，而<strong>真人的跳出流量集中在最短的那幾秒，在那個區間裡精確重複是常態而非訊號</strong>。分界落在哪一秒由下一節的短停留分佈量出來，不是一個可以跨站沿用的固定值。只有落在跳出區間之外的精確重複才有判別力。</p>
<p>三個訊號各自的觀察都只有個位數的記錄撐著，所以它們指出的是「該往哪裡看」，不是「這一列是什麼」。</p>
<h2 id="先量本站的基線">先量本站的基線</h2>
<p>前面所有訊號都描述「機器可能長什麼樣」，而判定需要的是「這一列與本站的真人有多不一樣」。<strong>判準寫成絕對條件（缺席、為零、幾乎沒有）在換一個站之後就不成立</strong>：行動流量佔八成的站與桌機為主的站，真人的離開事件缺席率差距很大；剛上線五篇文章的站，真人的站內導覽比例天然接近零。</p>
<p>基線要用一批<strong>組成已知</strong>的流量來量，而唯一穩定可得的那批是自己的瀏覽：在設定 opt-out 之前刻意走幾條完整的閱讀路線，那批記錄裡每一列是誰產生的都確定。</p>
<table>
  <thead>
      <tr>
          <th>基線指標</th>
          <th>量法</th>
          <th>已知的偏差</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>離開事件缺席率</td>
          <td>已知樣本裡沒有配對離開事件的進入事件佔比</td>
          <td>站主多用桌機，行動裝置的缺席率會被低估</td>
      </tr>
      <tr>
          <td>站內來源比例</td>
          <td>已知樣本裡來源網址為自家網域的佔比</td>
          <td>站主知道站的結構、走站內導覽，這個值會系統性偏高</td>
      </tr>
      <tr>
          <td>短停留分佈</td>
          <td>已知樣本的 <code>dur</code> 分佈，找出跳出區間落在哪幾秒</td>
          <td>站主讀自己的文章，短停留佔比偏低</td>
      </tr>
      <tr>
          <td>孤兒離開事件比例</td>
          <td>已知樣本裡找不到配對進入事件的比例</td>
          <td>同一台裝置、同一個網路環境，攔截與斷線情境不足</td>
      </tr>
  </tbody>
</table>
<p>第三欄不是免責聲明，它決定判讀的方向：<strong>站內來源比例的基線偏高，代表真實讀者低於這個值是正常的</strong>，拿它當「低於就可疑」的門檻會系統性誤判。四個指標的偏差方向都指向同一側——真人看起來比基線「差」。</p>
<p>有一種量法要避開：用「訊號欄乾淨」的記錄當已知樣本。那等於用被校正的偵測器定義校正用的樣本，結論會自我印證。同理，量離開事件缺席率時不能只取互動旗標為 1 的記錄——那個條件蘊含離開事件已經送達，算出來必然是零。</p>
<p>樣本規模也要誠實看待：刻意走幾條路線得到的是數十列，那個量足以看出量級差異（缺席率是一成還是六成），不足以做統計顯著性判斷。判準因此寫成「明顯偏離基線的量級」而非某個百分點門檻。</p>
<p>基線本身要重量：改版換了頁面結構、流量來源組成變化、行動裝置佔比上升，都會讓它漂移。</p>
<h2 id="這一節的限制會累積到哪裡">這一節的限制會累積到哪裡</h2>
<p>前面每一節都交代了自己的限制：只有執行 JavaScript 的訪客會被記錄、離開事件會在幾種情境下丟失、訊號的誤差方向一致指向誤判真人。這些限制加上其他篇的（退出機制、配額漏記、儲存清除）共同界定了這份資料的母體，而母體決定了哪些結論下得出來——完整的總帳與外部對照方法見<a href="/blog/automation/06-reading-the-data/data-coverage/" data-link-title="這份統計的分母是什麼" data-link-desc="報表上的數字要拿去回答問題之前，先確定它涵蓋了誰、漏掉了誰，以及哪些結論在這個涵蓋範圍下仍然成立">這份統計的分母是什麼</a>。</p>
<h2 id="標記而非丟棄">標記而非丟棄</h2>
<p>偵測到疑似自動化流量時有兩種處置：前端直接不送，或照常記錄並加上標記欄位。</p>
<p>這一章從頭到尾談的是<strong>標記</strong>，不是阻擋。靜態站託管層沒有可以攔請求的地方，這些訊號只在資料進來之後才產生作用——真的要擋抓取工具需要 CDN 或反向代理層的規則，那是另一個主題。</p>
<p><strong>照常記錄買到的是三件不需要 ground truth 的事。</strong> 判定的正確率本身無從得知——這套統計沒有一份標好答案的對照資料，前面每個訊號都只能談誤差方向、談不了誤判率。真正的理由在別處：</p>
<p><strong>改了規則之後可以重跑舊資料</strong>，比對同一批記錄在新舊規則下的分佈差多少。這回答不了「哪一個對」，但看得出「改動影響了多少列」，而那是決定要不要改的實際依據。</p>
<p><strong>日後認出新的抓取工具時可以回溯標記</strong>。今天標成 <code>ua:bot</code> 的那些列，等它被加進具名清單之後仍在表裡，過去半年的歷史因此跟著變得可讀。丟掉的列沒有這個機會。</p>
<p><strong>總量守恆檢查的前提就是列沒有被丟掉</strong>。前端過濾會讓分母本身失真，而分母失真時所有比例都不可信。</p>
<p>代價是試算表多存一些列，以及彙總時必須記得過濾。</p>
<p>對免費配額敏感、或流量大到列數成為問題時，取捨會反過來——那時應該只丟棄高信心訊號（<code>webdriver</code> 與具名 UA）命中的部分，中信心與行為訊號仍然照常記錄。</p>
<h2 id="彙總端的分類與條件順序">彙總端的分類與條件順序</h2>
<p>分類邏輯把訊號與行為組合起來。以下是虛擬碼——欄位名要換成試算表的實際範圍參照，而每個閾值都該換成前面量出的基線值，寫成固定條件只是為了讓結構清楚：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">=IFS(
</span></span><span class="line"><span class="ln">2</span><span class="cl">  AND(事件=&#34;leave&#34;, 同session同路徑的view筆數=0), &#34;待查-孤兒離開事件&#34;,
</span></span><span class="line"><span class="ln">3</span><span class="cl">  事件&lt;&gt;&#34;view&#34;, &#34;&#34;,
</span></span><span class="line"><span class="ln">4</span><span class="cl">  REGEXMATCH(訊號欄, &#34;webdriver|ua&#34;), &#34;機器-高信心&#34;,
</span></span><span class="line"><span class="ln">5</span><span class="cl">  AND(同session的leave筆數=0, 裝置&lt;&gt;&#34;mobile&#34;), &#34;待查-無離開事件&#34;,
</span></span><span class="line"><span class="ln">6</span><span class="cl">  訊號欄&lt;&gt;&#34;&#34;, &#34;訊號弱-待觀察&#34;,
</span></span><span class="line"><span class="ln">7</span><span class="cl">  同session的view筆數=1, &#34;單頁即走&#34;,
</span></span><span class="line"><span class="ln">8</span><span class="cl">  AND(同session的leave筆數&gt;0, 訊號欄=&#34;&#34;), &#34;真人&#34;,
</span></span><span class="line"><span class="ln">9</span><span class="cl">  TRUE, &#34;未分類&#34;)</span></span></code></pre></div><p>條件的順序承載語意。<strong>孤兒離開事件排在最前面</strong>，否則第二條會把所有離開事件留白，那一整類記錄就在統計裡消失了。<strong>非進入事件留白</strong>避免同一次瀏覽被計數兩次。</p>
<p>分類名稱用「待查」而非「爬蟲」是刻意的：行為訊號給的是需要進一步確認的候選。孤兒離開事件要對照<a href="/blog/automation/knowledge-cards/executions-page/" data-link-title="Executions（執行項目）" data-link-desc="Apps Script 平台記錄每一次函式執行的時間、耗時與狀態的頁面，用來判斷請求究竟有沒有抵達接收端">執行項目</a>同時段有沒有失敗紀錄——有的話那是配額問題而非機器。無離開事件那一條排除了行動裝置，因為真人在該環境的缺席率本來就高。</p>
<p>最後兩條分支的分工是刻意的。<strong>「真人」寫成顯式條件、catch-all 標成「未分類」</strong>，而不是反過來讓沒被接住的列直接落進「真人」——後者是這份報表最被消費、最不會被追問的類別，新的資料形狀靜默落進去只會讓數字變好看。<code>IFS</code> 在所有條件都不成立時回傳 <code>#N/A</code>，而滿欄的錯誤值與滿欄的留白一樣不會被追問，所以 catch-all 要給一個會被看見的標籤。這條防線的完整推導見<a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a>。</p>
<p>用總量守恆檢查這份分類時，<strong>作用域是進入事件那些列</strong>——離開事件被第二條留白，把它們算進分母會讓差額永遠等於離開事件的列數，而一個恆常的巨大差額只會訓練出忽略它的習慣。</p>
<h2 id="下一步">下一步</h2>
<p>判定規則到位了，接下來要問的是這些規則吃進去的資料本身可不可信。判定寫對了但公式算錯、程式改了但端點沒生效、錯誤訊息指向的位置不是問題的位置——<a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a>處理這三類。</p>
<p>爆量時段的漏記是另一回事，它來自配額而非判定，見<a href="/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">模組五的配額、防濫用與隱私邊界</a>。</p>
]]></content:encoded></item><item><title>模組二：流量 beacon 實作</title><link>https://tarrragon.github.io/blog/automation/02-analytics-beacon/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/02-analytics-beacon/</guid><description>&lt;p>把模組零的 client beacon 架構真的做出來：前端在頁面載入時送一則瀏覽事件，Apps Script 接住它、寫進 Google Sheet。這一章的終點是「打開試算表，看到自己剛剛的瀏覽出現在第一列」。&lt;/p>
&lt;p>實作拆成兩半，剛好對應架構的兩端。前端這半的重點是用對送出方式，避開靜態站打 Apps Script 最常見的 CORS 障礙。接收端這半的重點是把請求解析出來、安全地 append 進試算表。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙&lt;/a>&lt;/td>
 &lt;td>用 &lt;code>sendBeacon&lt;/code> 送 &lt;code>text/plain&lt;/code> 避開 preflight、beacon 該送什麼、放進 Hugo 哪裡&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">接收端 handler：寫進第一筆&lt;/a>&lt;/td>
 &lt;td>&lt;code>doPost&lt;/code> 解析請求、&lt;code>appendRow&lt;/code> 寫進 Sheet、部署後收到第一筆瀏覽&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基&lt;/a>：&lt;code>doGet&lt;/code>/&lt;code>doPost&lt;/code> 與部署模型的完整說明&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫&lt;/a>：資料進來後的並發、資料模型與容量&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後&lt;/a>：這裡的 payload 只描述單次瀏覽，補上訪客識別、停留時間與自動化訊號之後才讀得出「幾個人來過、有沒有真的在讀」&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>把模組零的 client beacon 架構真的做出來：前端在頁面載入時送一則瀏覽事件，Apps Script 接住它、寫進 Google Sheet。這一章的終點是「打開試算表，看到自己剛剛的瀏覽出現在第一列」。</p>
<p>實作拆成兩半，剛好對應架構的兩端。前端這半的重點是用對送出方式，避開靜態站打 Apps Script 最常見的 CORS 障礙。接收端這半的重點是把請求解析出來、安全地 append 進試算表。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙</a></td>
          <td>用 <code>sendBeacon</code> 送 <code>text/plain</code> 避開 preflight、beacon 該送什麼、放進 Hugo 哪裡</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">接收端 handler：寫進第一筆</a></td>
          <td><code>doPost</code> 解析請求、<code>appendRow</code> 寫進 Sheet、部署後收到第一筆瀏覽</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a>：<code>doGet</code>/<code>doPost</code> 與部署模型的完整說明</li>
<li>→ <a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫</a>：資料進來後的並發、資料模型與容量</li>
<li>→ <a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a>：這裡的 payload 只描述單次瀏覽，補上訪客識別、停留時間與自動化訊號之後才讀得出「幾個人來過、有沒有真的在讀」</li>
</ul>
]]></content:encoded></item><item><title>模組六：收到資料之後</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/06-reading-the-data/</guid><description>&lt;p>這一章的責任是把已經收進來的資料變成可判讀的欄位。模組二的終點是「打開試算表，看到自己剛剛的瀏覽出現在第一列」，而從那之後會遇到的問題是：資料持續進來了，欄位卻讀不出意義——來源網址幾乎都是空白、任兩列之間沒有任何關聯可以判斷是不是同一個人、也分不出哪些是真人哪些是機器抓取。&lt;/p>
&lt;p>這些問題有一個共同性質——&lt;strong>它們只在真實流量打進來之後才浮現，設計階段推導不出來&lt;/strong>。模組零到五處理的是「怎麼把管線接起來」，那條路徑上的每個障礙（CORS preflight、部署授權、配額上限）都能從文件讀出來。這一章處理的是管線接好之後才出現的那一類問題：資料格式完全正確、程式沒有報錯、統計數字看起來合理，而結論是錯的。&lt;/p>
&lt;p>補強的方向是給 payload 加上足以交叉判讀的欄位，並理解每個欄位的能力邊界。訪客識別回答「是不是同一個人」，事件模型回答「停留多久、有沒有真的在讀」，自動化訊號回答「這是不是機器」。三者互相驗證——單獨任何一個都能被繞過或誤判，組合起來才有判讀能力。&lt;/p>
&lt;p>補強完之後還有一步：把各篇的限制加總，確定這份資料的母體涵蓋了誰。分母不清楚時，比例算得再精確也不知道在說什麼。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out&lt;/a>&lt;/td>
 &lt;td>來源網址為何大量空白、用兩層隨機識別碼分辨回訪與單次瀏覽、退出統計為何用 localStorage 而非 cookie 或 IP&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間&lt;/a>&lt;/td>
 &lt;td>進入與離開拆成兩則事件、&lt;code>visibilitychange&lt;/code> 的觸發時機、停留秒數對應的是可見時長、資料模型變更讓既有報表公式換語意&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量&lt;/a>&lt;/td>
 &lt;td>哪些機器會進入這份統計、接收端讀不到 HTTP 標頭的結構限制、訊號分層與信心度、具名比對與通用退回、先量本站基線、標記而非丟棄、彙總端的分類與條件順序&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/data-coverage/" data-link-title="這份統計的分母是什麼" data-link-desc="報表上的數字要拿去回答問題之前，先確定它涵蓋了誰、漏掉了誰，以及哪些結論在這個涵蓋範圍下仍然成立">這份統計的分母是什麼&lt;/a>&lt;/td>
 &lt;td>七條看不見的損失通道、用外部對照估量級、相對問題與絕對問題的分界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷&lt;/a>&lt;/td>
 &lt;td>錯誤訊息指向的位置不是問題的位置、程式改了但端點沒生效、公式不報錯卻算錯&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="欄位變更要同步到接收端">欄位變更要同步到接收端&lt;/h2>
&lt;p>這一章的三篇實作都在前端的 payload 上加欄位，而&lt;strong>接收端與試算表表頭不會自己跟著長&lt;/strong>。模組二的 &lt;code>doPost&lt;/code> 是一個寫死的五欄陣列，新欄位送到之後會被靜默丟棄——沒有錯誤、沒有警告，只是那幾欄永遠是空的。這正是&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷&lt;/a>講的欄位錯位，而它最容易發生在「跟著教學一路改前端」的過程裡。&lt;/p>
&lt;p>三篇讀完之後，payload 會長成十一個欄位：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>來自哪一篇&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>path&lt;/code> / &lt;code>ref&lt;/code> / &lt;code>lang&lt;/code> / &lt;code>dev&lt;/code>&lt;/td>
 &lt;td>模組二既有&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>vid&lt;/code> / &lt;code>sid&lt;/code>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>t&lt;/code> / &lt;code>dur&lt;/code> / &lt;code>act&lt;/code>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>bot&lt;/code>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>對應的接收端要把 &lt;code>appendRow&lt;/code> 的陣列改成同樣長度，並讓表頭在工作表被清空時自動重建：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">HEADERS&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;時間&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;路徑&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;來源&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;語言&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;裝置&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="s1">&amp;#39;事件&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;訪客ID&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;SessionID&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;自動化訊號&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;停留秒數&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;有互動&amp;#39;&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">getLogSheet&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">ss&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">SpreadsheetApp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getActive&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">sheet&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">ss&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getSheetByName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;工作表1&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nx">ss&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">insertSheet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;工作表1&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getLastRow&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getRange&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">HEADERS&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">setValues&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="nx">HEADERS&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setFrozenRows&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">// doPost 內：陣列順序必須與 HEADERS 一致
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">getLogSheet&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">appendRow&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">new&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ref&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">lang&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dev&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s1">&amp;#39;view&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">vid&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">sid&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">bot&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dur&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="kc">undefined&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dur&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">act&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="kc">undefined&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">act&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把表頭建立放進讀取路徑而非一次性腳本，是因為一次性腳本有個隱性假設：「執行過了」這個狀態存在執行者的記憶裡、不在系統裡。固定位置的 &lt;code>setValues&lt;/code> 重複執行結果相同，工作表被清空後也會自動重建。&lt;/p>
&lt;p>改完之後記得走「管理部署作業 → 編輯 → 版本選新版本」讓端點跑新程式碼——改了程式碼卻沒更新部署是這裡最常見的假故障。&lt;/p>
&lt;p>&lt;strong>新欄位只對之後進來的資料生效。&lt;/strong> 既有的記錄不會回填，所以這一章教的判讀方法要等新資料累積一段時間才用得上；拿舊資料套新判準會得到「所有記錄都不可識別」這個沒有意義的結論。&lt;/p>
&lt;h2 id="讀者旅程">讀者旅程&lt;/h2>
&lt;p>已經跟著模組二把資料收進來、正要開始看報表的人，從&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別&lt;/a>順讀，並在動手改 payload 前先看上一節的欄位變更清單。&lt;/p>
&lt;p>已經在懷疑手上的數字的人，入口看症狀分三條：&lt;strong>某個分類永遠是零&lt;/strong>多半是資料模型變更後公式沒跟著改，推導在&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型談報表公式換語意的那一節&lt;/a>；&lt;strong>懷疑數字裡混了機器&lt;/strong>走&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量&lt;/a>；&lt;strong>想知道這些數字到底涵蓋了誰&lt;/strong>走&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/data-coverage/" data-link-title="這份統計的分母是什麼" data-link-desc="報表上的數字要拿去回答問題之前，先確定它涵蓋了誰、漏掉了誰，以及哪些結論在這個涵蓋範圍下仍然成立">這份統計的分母是什麼&lt;/a>；&lt;strong>流量突然變乾淨、或懷疑統計整個壞掉&lt;/strong>走&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷&lt;/a>。&lt;/p>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作&lt;/a>：payload 的基本欄位與 CORS 障礙，本章的補強都疊在那個實作上&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫&lt;/a>：欄位增加後的資料模型與容量影響&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全&lt;/a>：隱私邊界與防濫用的完整討論，本章的 opt-out 是那一章「過濾自己的瀏覽」的具體實作&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 的事件分類&lt;/a>：要收哪些事件、怎麼分類的概念層&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 的行為資料商業利用&lt;/a>：漏斗、cohort 與歸因的分析框架，本章只處理自建管線的資料品質&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這一章的責任是把已經收進來的資料變成可判讀的欄位。模組二的終點是「打開試算表，看到自己剛剛的瀏覽出現在第一列」，而從那之後會遇到的問題是：資料持續進來了，欄位卻讀不出意義——來源網址幾乎都是空白、任兩列之間沒有任何關聯可以判斷是不是同一個人、也分不出哪些是真人哪些是機器抓取。</p>
<p>這些問題有一個共同性質——<strong>它們只在真實流量打進來之後才浮現，設計階段推導不出來</strong>。模組零到五處理的是「怎麼把管線接起來」，那條路徑上的每個障礙（CORS preflight、部署授權、配額上限）都能從文件讀出來。這一章處理的是管線接好之後才出現的那一類問題：資料格式完全正確、程式沒有報錯、統計數字看起來合理，而結論是錯的。</p>
<p>補強的方向是給 payload 加上足以交叉判讀的欄位，並理解每個欄位的能力邊界。訪客識別回答「是不是同一個人」，事件模型回答「停留多久、有沒有真的在讀」，自動化訊號回答「這是不是機器」。三者互相驗證——單獨任何一個都能被繞過或誤判，組合起來才有判讀能力。</p>
<p>補強完之後還有一步：把各篇的限制加總，確定這份資料的母體涵蓋了誰。分母不清楚時，比例算得再精確也不知道在說什麼。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out</a></td>
          <td>來源網址為何大量空白、用兩層隨機識別碼分辨回訪與單次瀏覽、退出統計為何用 localStorage 而非 cookie 或 IP</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間</a></td>
          <td>進入與離開拆成兩則事件、<code>visibilitychange</code> 的觸發時機、停留秒數對應的是可見時長、資料模型變更讓既有報表公式換語意</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a></td>
          <td>哪些機器會進入這份統計、接收端讀不到 HTTP 標頭的結構限制、訊號分層與信心度、具名比對與通用退回、先量本站基線、標記而非丟棄、彙總端的分類與條件順序</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/06-reading-the-data/data-coverage/" data-link-title="這份統計的分母是什麼" data-link-desc="報表上的數字要拿去回答問題之前，先確定它涵蓋了誰、漏掉了誰，以及哪些結論在這個涵蓋範圍下仍然成立">這份統計的分母是什麼</a></td>
          <td>七條看不見的損失通道、用外部對照估量級、相對問題與絕對問題的分界</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a></td>
          <td>錯誤訊息指向的位置不是問題的位置、程式改了但端點沒生效、公式不報錯卻算錯</td>
      </tr>
  </tbody>
</table>
<h2 id="欄位變更要同步到接收端">欄位變更要同步到接收端</h2>
<p>這一章的三篇實作都在前端的 payload 上加欄位，而<strong>接收端與試算表表頭不會自己跟著長</strong>。模組二的 <code>doPost</code> 是一個寫死的五欄陣列，新欄位送到之後會被靜默丟棄——沒有錯誤、沒有警告，只是那幾欄永遠是空的。這正是<a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a>講的欄位錯位，而它最容易發生在「跟著教學一路改前端」的過程裡。</p>
<p>三篇讀完之後，payload 會長成十一個欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>來自哪一篇</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>path</code> / <code>ref</code> / <code>lang</code> / <code>dev</code></td>
          <td>模組二既有</td>
      </tr>
      <tr>
          <td><code>vid</code> / <code>sid</code></td>
          <td><a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out</a></td>
      </tr>
      <tr>
          <td><code>t</code> / <code>dur</code> / <code>act</code></td>
          <td><a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間</a></td>
      </tr>
      <tr>
          <td><code>bot</code></td>
          <td><a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a></td>
      </tr>
  </tbody>
</table>
<p>對應的接收端要把 <code>appendRow</code> 的陣列改成同樣長度，並讓表頭在工作表被清空時自動重建：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">HEADERS</span> <span class="o">=</span> <span class="p">[</span><span class="s1">&#39;時間&#39;</span><span class="p">,</span> <span class="s1">&#39;路徑&#39;</span><span class="p">,</span> <span class="s1">&#39;來源&#39;</span><span class="p">,</span> <span class="s1">&#39;語言&#39;</span><span class="p">,</span> <span class="s1">&#39;裝置&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">               <span class="s1">&#39;事件&#39;</span><span class="p">,</span> <span class="s1">&#39;訪客ID&#39;</span><span class="p">,</span> <span class="s1">&#39;SessionID&#39;</span><span class="p">,</span> <span class="s1">&#39;自動化訊號&#39;</span><span class="p">,</span> <span class="s1">&#39;停留秒數&#39;</span><span class="p">,</span> <span class="s1">&#39;有互動&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kd">function</span> <span class="nx">getLogSheet</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">var</span> <span class="nx">ss</span> <span class="o">=</span> <span class="nx">SpreadsheetApp</span><span class="p">.</span><span class="nx">getActive</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kd">var</span> <span class="nx">sheet</span> <span class="o">=</span> <span class="nx">ss</span><span class="p">.</span><span class="nx">getSheetByName</span><span class="p">(</span><span class="s1">&#39;工作表1&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="nx">ss</span><span class="p">.</span><span class="nx">insertSheet</span><span class="p">(</span><span class="s1">&#39;工作表1&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">sheet</span><span class="p">.</span><span class="nx">getLastRow</span><span class="p">()</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">sheet</span><span class="p">.</span><span class="nx">getRange</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">HEADERS</span><span class="p">.</span><span class="nx">length</span><span class="p">).</span><span class="nx">setValues</span><span class="p">([</span><span class="nx">HEADERS</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">sheet</span><span class="p">.</span><span class="nx">setFrozenRows</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">return</span> <span class="nx">sheet</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">// doPost 內：陣列順序必須與 HEADERS 一致
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="nx">getLogSheet</span><span class="p">().</span><span class="nx">appendRow</span><span class="p">([</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="k">new</span> <span class="nb">Date</span><span class="p">(),</span> <span class="nx">data</span><span class="p">.</span><span class="nx">path</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">ref</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">lang</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">dev</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="nx">data</span><span class="p">.</span><span class="nx">t</span> <span class="o">||</span> <span class="s1">&#39;view&#39;</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">vid</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">sid</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">bot</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="nx">data</span><span class="p">.</span><span class="nx">dur</span> <span class="o">===</span> <span class="kc">undefined</span> <span class="o">?</span> <span class="s1">&#39;&#39;</span> <span class="o">:</span> <span class="nx">data</span><span class="p">.</span><span class="nx">dur</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="nx">data</span><span class="p">.</span><span class="nx">act</span> <span class="o">===</span> <span class="kc">undefined</span> <span class="o">?</span> <span class="s1">&#39;&#39;</span> <span class="o">:</span> <span class="nx">data</span><span class="p">.</span><span class="nx">act</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">]);</span></span></span></code></pre></div><p>把表頭建立放進讀取路徑而非一次性腳本，是因為一次性腳本有個隱性假設：「執行過了」這個狀態存在執行者的記憶裡、不在系統裡。固定位置的 <code>setValues</code> 重複執行結果相同，工作表被清空後也會自動重建。</p>
<p>改完之後記得走「管理部署作業 → 編輯 → 版本選新版本」讓端點跑新程式碼——改了程式碼卻沒更新部署是這裡最常見的假故障。</p>
<p><strong>新欄位只對之後進來的資料生效。</strong> 既有的記錄不會回填，所以這一章教的判讀方法要等新資料累積一段時間才用得上；拿舊資料套新判準會得到「所有記錄都不可識別」這個沒有意義的結論。</p>
<h2 id="讀者旅程">讀者旅程</h2>
<p>已經跟著模組二把資料收進來、正要開始看報表的人，從<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別</a>順讀，並在動手改 payload 前先看上一節的欄位變更清單。</p>
<p>已經在懷疑手上的數字的人，入口看症狀分三條：<strong>某個分類永遠是零</strong>多半是資料模型變更後公式沒跟著改，推導在<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型談報表公式換語意的那一節</a>；<strong>懷疑數字裡混了機器</strong>走<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>；<strong>想知道這些數字到底涵蓋了誰</strong>走<a href="/blog/automation/06-reading-the-data/data-coverage/" data-link-title="這份統計的分母是什麼" data-link-desc="報表上的數字要拿去回答問題之前，先確定它涵蓋了誰、漏掉了誰，以及哪些結論在這個涵蓋範圍下仍然成立">這份統計的分母是什麼</a>；<strong>流量突然變乾淨、或懷疑統計整個壞掉</strong>走<a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作</a>：payload 的基本欄位與 CORS 障礙，本章的補強都疊在那個實作上</li>
<li>→ <a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫</a>：欄位增加後的資料模型與容量影響</li>
<li>→ <a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全</a>：隱私邊界與防濫用的完整討論，本章的 opt-out 是那一章「過濾自己的瀏覽」的具體實作</li>
<li>→ <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 的事件分類</a>：要收哪些事件、怎麼分類的概念層</li>
<li>→ <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 的行為資料商業利用</a>：漏斗、cohort 與歸因的分析框架，本章只處理自建管線的資料品質</li>
</ul>
]]></content:encoded></item><item><title>免伺服器自動化實務指南：用免費雲端服務給靜態站補上動態能力</title><link>https://tarrragon.github.io/blog/automation/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/</guid><description>&lt;p>本指南處理一類具體的工程問題：手上有一個沒有後端的靜態站或小專案，需要一點點動態能力（收一筆表單、記一次瀏覽、每天彙總一次資料），但這個需求還不到、也不值得租一台伺服器的程度。核心概念是「膠水層」——用別人已經在跑、且對個人用量免費的雲端服務，補上靜態站缺的那一小塊伺服器端邏輯，讓你不必自己維護一台開機的主機。&lt;/p>
&lt;p>靜態站的能力邊界很明確：瀏覽器把 HTML/JS 抓下來後，所有邏輯都在使用者的瀏覽器裡跑，沒有任何一段程式碼在你控制的伺服器上執行。這代表任何「要記在你這邊」的資料——誰來看過、表單填了什麼、累積計數——都需要一個瀏覽器以外、由你掌握的接收端。這個接收端不必是傳統伺服器；它可以是一段掛在免費平台上、只在被呼叫時才執行的函式。本指南教怎麼用這種函式把靜態站的能力補齊。&lt;/p>
&lt;p>貫穿全指南的案例是「幫這個架在 GitHub Pages 上的 blog 做流量統計」。GitHub Pages 不給 access log、也不能跑伺服器端程式碼，所以流量資料只能靠瀏覽器主動回報。這個案例會從模組零的架構推導、一路實作到模組六的資料判讀，讀者跟著做完會得到一個真的能用、資料存在自己試算表裡的流量統計系統——「自己手上」在這裡指的是資料的控制權與查詢自由，基礎設施仍然是 Google 託管的。&lt;/p>
&lt;p>第一種膠水工具選 Google Apps Script + Google Sheets：它對個人 Google 帳號免費、不需要信用卡、Sheets 直接當資料庫兼儀表板，起步門檻是所有選項裡最低的。指南後續會加入其他膠水工具（例如 Cloudflare Workers）作為對照，說明各自的適用邊界——Apps Script 適合資料量小、需要人可直接讀寫試算表的場景；當量體變大或需要低延遲時，模組五會給出換工具的判準與遷移路徑。&lt;/p>
&lt;h2 id="教材邊界">教材邊界&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>放在本指南&lt;/th>
 &lt;th>不放在本指南&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>心智模型&lt;/td>
 &lt;td>靜態站能力邊界、膠水層架構、client beacon、免費額度的思考方式&lt;/td>
 &lt;td>大型後端架構、微服務拆分&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>工具地基&lt;/td>
 &lt;td>Apps Script web app 部署模型（&lt;code>doGet&lt;/code>/&lt;code>doPost&lt;/code>）、授權模型、V8 runtime&lt;/td>
 &lt;td>Apps Script 在 Workspace 企業版的進階整合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實作&lt;/td>
 &lt;td>beacon 前端、接收端 handler、Sheets 讀寫、觸發器排程、配額與安全&lt;/td>
 &lt;td>商業級 analytics 平台的自建（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料品質&lt;/td>
 &lt;td>訪客識別、事件模型、自動化流量辨識、靜默失效診斷&lt;/td>
 &lt;td>跨裝置身分解析、廣告歸因、第三方資料串接&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>選型&lt;/td>
 &lt;td>自建 vs 現成分析服務、Apps Script vs Workers 的適用邊界、何時該換工具&lt;/td>
 &lt;td>雲端主機比價、Kubernetes&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>流量分析的&lt;strong>概念層&lt;/strong>（事件分類、漏斗、cohort、歸因）不在本指南，在 &lt;a href="https://tarrragon.github.io/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系&lt;/a>。本指南是&lt;strong>動手做&lt;/strong>的那一半：怎麼用免費工具把資料真的收進來、存起來、彙總出報表。兩者互補——先看 Monitoring 想清楚要收什麼，再回本指南把管線搭起來。&lt;/p>
&lt;h2 id="backlog">Backlog&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>類型&lt;/th>
 &lt;th>前置條件&lt;/th>
 &lt;th>規模&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>雙事件配對的彙總實作（平均停留、閱讀深度）&lt;/td>
 &lt;td>主章&lt;/td>
 &lt;td>模組四彙總改寫成事件感知版本&lt;/td>
 &lt;td>1 篇&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cloudflare Workers 對照與遷移路徑&lt;/td>
 &lt;td>主章&lt;/td>
 &lt;td>實機驗證免費額度與部署流程&lt;/td>
 &lt;td>2 篇&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrer-Policy 知識卡&lt;/td>
 &lt;td>知識卡&lt;/td>
 &lt;td>無（模組六已完整展開、判定它是否仍需獨立卡）&lt;/td>
 &lt;td>1 張&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>現成分析服務的實測對照&lt;/td>
 &lt;td>vendor&lt;/td>
 &lt;td>實測 Plausible / Umami / GoatCounter 的免費額度與能力&lt;/td>
 &lt;td>1 篇&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Monitoring 對本指南的反向連結&lt;/td>
 &lt;td>跨模組&lt;/td>
 &lt;td>盤點 monitoring 各模組的實作落點&lt;/td>
 &lt;td>小&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>跨模組那一列是目前唯一的結構性不對稱：本指南對 monitoring 有五條引用，反向零條，而 monitoring 的漏斗與 cohort 分析都預設資料乾淨、可識別、無機器混入——那些前提正是模組六在處理的。&lt;/p>
&lt;p>模組六補上了資料判讀，但彙總層仍停在模組四的單事件版本——那裡目前只標明了前提與修法方向，尚未有一篇把「配對進入與離開事件、算出平均停留與閱讀深度」實作出來。順序上它依賴模組六的事件模型定案，因此排在其後。&lt;/p>
&lt;h2 id="章節">章節&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/00-mental-model/" data-link-title="模組零：心智模型" data-link-desc="判斷一個動態需求要不要伺服器、資料從哪個接收端進來、免費額度撐得住多大量時的思考框架">模組零：心智模型&lt;/a>&lt;/td>
 &lt;td>靜態站能力邊界、膠水層、client beacon 架構、免費額度、自建 vs 現成服務、GAS vs Workers 選型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基&lt;/a>&lt;/td>
 &lt;td>Apps Script 是什麼、V8 runtime、web app 部署模型、授權模型、跟一般伺服器的差異&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作&lt;/a>&lt;/td>
 &lt;td>前端 beacon、接收端 handler、寫進 Sheet、CORS 的雷與解法，從零到第一筆&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫&lt;/a>&lt;/td>
 &lt;td>&lt;code>appendRow&lt;/code>、資料模型、並發與 &lt;code>LockService&lt;/code>、Sheets 的容量邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程&lt;/a>&lt;/td>
 &lt;td>time-driven trigger 每日彙總、&lt;code>onFormSubmit&lt;/code>、把原始 log 變成日報&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全&lt;/a>&lt;/td>
 &lt;td>部署權限、免費配額上限、CORS 設定、防濫用、隱私邊界與同意機制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後&lt;/a>&lt;/td>
 &lt;td>訪客識別與 opt-out、事件模型與停留時間、辨識自動化流量、假故障與靜默失效診斷&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>模組零到五走的是「怎麼把管線接起來」，那條路徑上的每個障礙都能從文件讀出來。模組六的位置不同——它處理管線接好、資料真的開始累積之後才浮現的問題：來源網址大量空白、任兩列之間看不出是不是同一個人、真人與機器抓取混在一起。這些無法在設計階段推導，只有真實流量打進來才會現形。&lt;/p></description><content:encoded><![CDATA[<p>本指南處理一類具體的工程問題：手上有一個沒有後端的靜態站或小專案，需要一點點動態能力（收一筆表單、記一次瀏覽、每天彙總一次資料），但這個需求還不到、也不值得租一台伺服器的程度。核心概念是「膠水層」——用別人已經在跑、且對個人用量免費的雲端服務，補上靜態站缺的那一小塊伺服器端邏輯，讓你不必自己維護一台開機的主機。</p>
<p>靜態站的能力邊界很明確：瀏覽器把 HTML/JS 抓下來後，所有邏輯都在使用者的瀏覽器裡跑，沒有任何一段程式碼在你控制的伺服器上執行。這代表任何「要記在你這邊」的資料——誰來看過、表單填了什麼、累積計數——都需要一個瀏覽器以外、由你掌握的接收端。這個接收端不必是傳統伺服器；它可以是一段掛在免費平台上、只在被呼叫時才執行的函式。本指南教怎麼用這種函式把靜態站的能力補齊。</p>
<p>貫穿全指南的案例是「幫這個架在 GitHub Pages 上的 blog 做流量統計」。GitHub Pages 不給 access log、也不能跑伺服器端程式碼，所以流量資料只能靠瀏覽器主動回報。這個案例會從模組零的架構推導、一路實作到模組六的資料判讀，讀者跟著做完會得到一個真的能用、資料存在自己試算表裡的流量統計系統——「自己手上」在這裡指的是資料的控制權與查詢自由，基礎設施仍然是 Google 託管的。</p>
<p>第一種膠水工具選 Google Apps Script + Google Sheets：它對個人 Google 帳號免費、不需要信用卡、Sheets 直接當資料庫兼儀表板，起步門檻是所有選項裡最低的。指南後續會加入其他膠水工具（例如 Cloudflare Workers）作為對照，說明各自的適用邊界——Apps Script 適合資料量小、需要人可直接讀寫試算表的場景；當量體變大或需要低延遲時，模組五會給出換工具的判準與遷移路徑。</p>
<h2 id="教材邊界">教材邊界</h2>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>放在本指南</th>
          <th>不放在本指南</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>心智模型</td>
          <td>靜態站能力邊界、膠水層架構、client beacon、免費額度的思考方式</td>
          <td>大型後端架構、微服務拆分</td>
      </tr>
      <tr>
          <td>工具地基</td>
          <td>Apps Script web app 部署模型（<code>doGet</code>/<code>doPost</code>）、授權模型、V8 runtime</td>
          <td>Apps Script 在 Workspace 企業版的進階整合</td>
      </tr>
      <tr>
          <td>實作</td>
          <td>beacon 前端、接收端 handler、Sheets 讀寫、觸發器排程、配額與安全</td>
          <td>商業級 analytics 平台的自建（見 <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring</a>）</td>
      </tr>
      <tr>
          <td>資料品質</td>
          <td>訪客識別、事件模型、自動化流量辨識、靜默失效診斷</td>
          <td>跨裝置身分解析、廣告歸因、第三方資料串接</td>
      </tr>
      <tr>
          <td>選型</td>
          <td>自建 vs 現成分析服務、Apps Script vs Workers 的適用邊界、何時該換工具</td>
          <td>雲端主機比價、Kubernetes</td>
      </tr>
  </tbody>
</table>
<p>流量分析的<strong>概念層</strong>（事件分類、漏斗、cohort、歸因）不在本指南，在 <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系</a>。本指南是<strong>動手做</strong>的那一半：怎麼用免費工具把資料真的收進來、存起來、彙總出報表。兩者互補——先看 Monitoring 想清楚要收什麼，再回本指南把管線搭起來。</p>
<h2 id="backlog">Backlog</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>類型</th>
          <th>前置條件</th>
          <th>規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>雙事件配對的彙總實作（平均停留、閱讀深度）</td>
          <td>主章</td>
          <td>模組四彙總改寫成事件感知版本</td>
          <td>1 篇</td>
      </tr>
      <tr>
          <td>Cloudflare Workers 對照與遷移路徑</td>
          <td>主章</td>
          <td>實機驗證免費額度與部署流程</td>
          <td>2 篇</td>
      </tr>
      <tr>
          <td>Referrer-Policy 知識卡</td>
          <td>知識卡</td>
          <td>無（模組六已完整展開、判定它是否仍需獨立卡）</td>
          <td>1 張</td>
      </tr>
      <tr>
          <td>現成分析服務的實測對照</td>
          <td>vendor</td>
          <td>實測 Plausible / Umami / GoatCounter 的免費額度與能力</td>
          <td>1 篇</td>
      </tr>
      <tr>
          <td>Monitoring 對本指南的反向連結</td>
          <td>跨模組</td>
          <td>盤點 monitoring 各模組的實作落點</td>
          <td>小</td>
      </tr>
  </tbody>
</table>
<p>跨模組那一列是目前唯一的結構性不對稱：本指南對 monitoring 有五條引用，反向零條，而 monitoring 的漏斗與 cohort 分析都預設資料乾淨、可識別、無機器混入——那些前提正是模組六在處理的。</p>
<p>模組六補上了資料判讀，但彙總層仍停在模組四的單事件版本——那裡目前只標明了前提與修法方向，尚未有一篇把「配對進入與離開事件、算出平均停留與閱讀深度」實作出來。順序上它依賴模組六的事件模型定案，因此排在其後。</p>
<h2 id="章節">章節</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/00-mental-model/" data-link-title="模組零：心智模型" data-link-desc="判斷一個動態需求要不要伺服器、資料從哪個接收端進來、免費額度撐得住多大量時的思考框架">模組零：心智模型</a></td>
          <td>靜態站能力邊界、膠水層、client beacon 架構、免費額度、自建 vs 現成服務、GAS vs Workers 選型</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a></td>
          <td>Apps Script 是什麼、V8 runtime、web app 部署模型、授權模型、跟一般伺服器的差異</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作</a></td>
          <td>前端 beacon、接收端 handler、寫進 Sheet、CORS 的雷與解法，從零到第一筆</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫</a></td>
          <td><code>appendRow</code>、資料模型、並發與 <code>LockService</code>、Sheets 的容量邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程</a></td>
          <td>time-driven trigger 每日彙總、<code>onFormSubmit</code>、把原始 log 變成日報</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全</a></td>
          <td>部署權限、免費配額上限、CORS 設定、防濫用、隱私邊界與同意機制</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a></td>
          <td>訪客識別與 opt-out、事件模型與停留時間、辨識自動化流量、假故障與靜默失效診斷</td>
      </tr>
  </tbody>
</table>
<p>模組零到五走的是「怎麼把管線接起來」，那條路徑上的每個障礙都能從文件讀出來。模組六的位置不同——它處理管線接好、資料真的開始累積之後才浮現的問題：來源網址大量空白、任兩列之間看不出是不是同一個人、真人與機器抓取混在一起。這些無法在設計階段推導，只有真實流量打進來才會現形。</p>
<h2 id="讀者旅程">讀者旅程</h2>
<p>想直接把流量統計做出來：模組零建立架構直覺後，跳模組二照著實作，缺概念再回模組一補。想完整理解這套膠水模式、之後套用到其他專案：模組零到六順讀。已經收到資料、正要開始看報表：直接進<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六</a>。只想查某個 Apps Script 術語：看 <a href="/blog/automation/knowledge-cards/" data-link-title="Automation 知識卡" data-link-desc="免伺服器自動化的術語索引：beacon、web app 部署、doGet/doPost、執行配額、時間觸發器、瀏覽器指紋、執行項目">knowledge-cards</a>。</p>
<hr>
]]></content:encoded></item></channel></rss>