<?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>模組六：收到資料之後 on Tarragon</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/</link><description>Recent content in 模組六：收到資料之後 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/automation/06-reading-the-data/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>事件模型與停留時間</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>這份統計的分母是什麼</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/data-coverage/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/06-reading-the-data/data-coverage/</guid><description>&lt;p>任何一份統計的第一個問題是它的母體。前三篇各自交代了自己那個欄位的限制——來源網址會空白、離開事件會丟失、只有執行 JavaScript 的訪客會被記錄——而那些限制分散在各篇時看起來都是可接受的小瑕疵。加總起來才會看到它們共同界定了什麼：&lt;strong>這份資料的母體不是「所有讀者」，而是「會執行 JavaScript、沒有攔截 beacon、也沒有退出統計的那些瀏覽」&lt;/strong>。&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;th>量級判斷&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>不執行 JS 的抓取工具&lt;/td>
 &lt;td>beacon 靠 JS 觸發&lt;/td>
 &lt;td>大，但那是想排除的&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>被擴充功能攔截的瀏覽&lt;/td>
 &lt;td>接收端是外部網域，廣告與隱私擴充的通用規則會擋&lt;/td>
 &lt;td>&lt;strong>技術讀者為主的站可能是最大一塊&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>停用 JS 或載入未完成&lt;/td>
 &lt;td>同上的觸發前提&lt;/td>
 &lt;td>小&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設過 opt-out 的讀者&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>配額撞頂時的漏記&lt;/td>
 &lt;td>爆量時併發上限&lt;/td>
 &lt;td>只在流量尖峰&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>被視為新訪客的回訪&lt;/td>
 &lt;td>Safari 的追蹤預防清除儲存資料&lt;/td>
 &lt;td>中，Safari 讀者的回訪率被系統性低估&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>七條的性質不同，處置方向也不同，值得分開看。&lt;/p>
&lt;p>&lt;strong>第一條是刻意的。&lt;/strong> 多數抓取工具不執行 JavaScript，因此不會出現在這份資料裡——這正是&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;p>&lt;strong>第二條是最容易被低估、也最沒有痕跡的一條。&lt;/strong> 接收端的網址對本站而言是外部網域，而 uBlock Origin 與 Brave 的預設規則會攔下大量指向分析類端點的請求。被攔下時什麼都不會留下——沒有失敗紀錄、沒有孤兒事件、沒有任何欄位是空的，那次瀏覽就是不存在。這條損失無法從資料內部觀測，只能從外部估（下一節談怎麼估）。讀者組成越偏技術，這一塊越大，而技術部落格的讀者組成正是最偏的那一端。&lt;/p>
&lt;p>&lt;strong>第三與第四條是小量但方向明確的。&lt;/strong> 停用 JavaScript 的訪客現在很少；退出統計的讀者則是自己設計出來的缺口，那個缺口存在本身比它的大小重要。&lt;/p>
&lt;p>&lt;strong>第五條與第六條影響的是行為欄位而非計數。&lt;/strong> 離開事件遺失讓停留時間與互動旗標偏向缺失，而缺失集中在行動裝置；配額撞頂只在流量尖峰發生，症狀是那個時段的數字偏低。兩者都不影響「哪一篇被打開幾次」這個基準線，只影響建立在行為欄位上的判讀。&lt;/p>
&lt;p>&lt;strong>第七條讓回訪率單向偏低。&lt;/strong> Safari 的追蹤預防機制清除儲存資料之後，同一個人回來會拿到新的訪客識別碼，在資料上是新訪客。這個偏差不會反向發生——不會有人被誤判成回訪——所以回訪率永遠是低估，而低估幅度隨 Safari 讀者佔比變動。&lt;/p>
&lt;h2 id="怎麼估自己站的量級">怎麼估自己站的量級&lt;/h2>
&lt;p>表格裡的量級判斷是方向性的，實際比例要靠外部對照才估得出來。三個成本不高的作法：&lt;/p>
&lt;p>&lt;strong>用兩個瀏覽器環境自測攔截率。&lt;/strong> 一個乾淨、一個裝上常見的內容攔截器，各自走一遍相同的路徑，比對記錄有沒有進來。這量不出站上的實際比例，但能確認攔截&lt;strong>會不會&lt;/strong>發生——如果連常見設定都擋得住，那條損失就是真的存在而非理論風險。&lt;/p>
&lt;p>&lt;strong>拿搜尋主控台的點擊數當外部對照。&lt;/strong> Google Search Console 記錄的是搜尋結果的點擊次數，那個計數發生在讀者的瀏覽器之外、不受 JavaScript 與攔截器影響。用同一段時間、同一批頁面比對兩邊的數字，差額大致就是這份統計漏掉的搜尋流量比例。這是這個架構下最容易取得的一把外部尺。&lt;/p>
&lt;p>&lt;strong>看自己的裝置分佈與已知的偏差方向對不對得上。&lt;/strong> 若記錄裡行動裝置佔比明顯低於一般內容網站的水準，可能的解釋之一是行動端的離開事件與整段瀏覽更容易丟失，而不是讀者真的都用桌機。&lt;/p>
&lt;p>三種作法都不會給出精確的修正係數，它們的用途是判斷「漏掉的量是百分之幾還是幾成」——而那個量級足以決定一個結論能不能下。&lt;/p>
&lt;h2 id="這份統計適合回答什麼">這份統計適合回答什麼&lt;/h2>
&lt;p>&lt;strong>相對問題可以回答&lt;/strong>：哪幾篇比較多人讀、這個月比上個月如何、哪些路徑常被連著讀、哪一篇的停留時間明顯偏短。這些問題的答案建立在「各種損失通道在比較的兩邊大致相同」這個前提上，而在同一個站、相近的時間範圍內，這個前提通常成立。&lt;/p>
&lt;p>&lt;strong>絕對問題不適合回答&lt;/strong>：有多少人看過、真實的跳出率是多少、有多少比例的讀者會回訪。這些數字永遠是低估，而低估的幅度無法從資料本身估出來——這正是上一節要用外部對照的原因。&lt;/p>
&lt;p>有一類問題落在中間，判斷起來要小心：&lt;strong>跨時間的比較在損失通道本身改變時會失效&lt;/strong>。改版換了頁面結構、內容主題從技術轉向非技術（讀者的攔截器安裝率隨之改變）、加了新的 JavaScript 讓載入變慢，都會讓前後兩段時間的分母不同。看到某個指標長期趨勢反轉時，先確認是不是分母變了。&lt;/p>
&lt;h2 id="這張總帳怎麼影響最初的選擇">這張總帳怎麼影響最初的選擇&lt;/h2>
&lt;p>&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;/p>
&lt;p>值得注意的是，&lt;strong>這些限制大部分不是自建造成的&lt;/strong>。JavaScript 觸發、攔截器、瀏覽器的儲存清除政策同樣適用於 Plausible、Umami、GoatCounter 或任何靠前端腳本收資料的服務——它們的網域出現在攔截清單上的機率甚至更高。自建額外承擔的是實作與維護：退出機制、欄位變更同步、判讀規則的更新。&lt;/p>
&lt;p>所以重算的問題不是「自建的數字準不準」，而是「知道這些限制之後，還願不願意為了資料的控制權與理解的深度承擔那些維護工作」。答案是否定的話，換成現成服務不會損失準確度，只會換成另一組別人幫忙維護的限制。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>母體界定清楚之後，剩下的是確認收集鏈本身健康——判定規則寫對了但公式算錯、程式改了但端點沒生效、錯誤訊息指向的位置不是問題的位置，見&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>。想回頭看某一條損失的機制細節：JavaScript 觸發前提與抓取工具見&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;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間&lt;/a>、退出機制與儲存清除見&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;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></description><content:encoded><![CDATA[<p>任何一份統計的第一個問題是它的母體。前三篇各自交代了自己那個欄位的限制——來源網址會空白、離開事件會丟失、只有執行 JavaScript 的訪客會被記錄——而那些限制分散在各篇時看起來都是可接受的小瑕疵。加總起來才會看到它們共同界定了什麼：<strong>這份資料的母體不是「所有讀者」，而是「會執行 JavaScript、沒有攔截 beacon、也沒有退出統計的那些瀏覽」</strong>。</p>
<p>這一篇把散落的限制收成一張總帳，理由是報表上的每個數字都會被拿去回答某個問題，而問題答得準不準取決於母體涵蓋了誰。不知道分母的比例沒有意義。</p>
<h2 id="看不見的部分">看不見的部分</h2>
<table>
  <thead>
      <tr>
          <th>看不見的部分</th>
          <th>成因</th>
          <th>量級判斷</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不執行 JS 的抓取工具</td>
          <td>beacon 靠 JS 觸發</td>
          <td>大，但那是想排除的</td>
      </tr>
      <tr>
          <td>被擴充功能攔截的瀏覽</td>
          <td>接收端是外部網域，廣告與隱私擴充的通用規則會擋</td>
          <td><strong>技術讀者為主的站可能是最大一塊</strong></td>
      </tr>
      <tr>
          <td>停用 JS 或載入未完成</td>
          <td>同上的觸發前提</td>
          <td>小</td>
      </tr>
      <tr>
          <td>設過 opt-out 的讀者</td>
          <td>設計如此</td>
          <td>小</td>
      </tr>
      <tr>
          <td>遺失的離開事件</td>
          <td>記憶體回收、強制關閉、網路中斷、擴充攔截</td>
          <td>中，行動裝置為主</td>
      </tr>
      <tr>
          <td>配額撞頂時的漏記</td>
          <td>爆量時併發上限</td>
          <td>只在流量尖峰</td>
      </tr>
      <tr>
          <td>被視為新訪客的回訪</td>
          <td>Safari 的追蹤預防清除儲存資料</td>
          <td>中，Safari 讀者的回訪率被系統性低估</td>
      </tr>
  </tbody>
</table>
<p>七條的性質不同，處置方向也不同，值得分開看。</p>
<p><strong>第一條是刻意的。</strong> 多數抓取工具不執行 JavaScript，因此不會出現在這份資料裡——這正是<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>那一篇說明的結構限制。它讓這份統計比伺服器日誌乾淨，代價是兩者的爬蟲比例完全不可比較。看過日誌裡「七成流量是機器」那類數字的人，不要拿它與這裡的數字對照。</p>
<p><strong>第二條是最容易被低估、也最沒有痕跡的一條。</strong> 接收端的網址對本站而言是外部網域，而 uBlock Origin 與 Brave 的預設規則會攔下大量指向分析類端點的請求。被攔下時什麼都不會留下——沒有失敗紀錄、沒有孤兒事件、沒有任何欄位是空的，那次瀏覽就是不存在。這條損失無法從資料內部觀測，只能從外部估（下一節談怎麼估）。讀者組成越偏技術，這一塊越大，而技術部落格的讀者組成正是最偏的那一端。</p>
<p><strong>第三與第四條是小量但方向明確的。</strong> 停用 JavaScript 的訪客現在很少；退出統計的讀者則是自己設計出來的缺口，那個缺口存在本身比它的大小重要。</p>
<p><strong>第五條與第六條影響的是行為欄位而非計數。</strong> 離開事件遺失讓停留時間與互動旗標偏向缺失，而缺失集中在行動裝置；配額撞頂只在流量尖峰發生，症狀是那個時段的數字偏低。兩者都不影響「哪一篇被打開幾次」這個基準線，只影響建立在行為欄位上的判讀。</p>
<p><strong>第七條讓回訪率單向偏低。</strong> Safari 的追蹤預防機制清除儲存資料之後，同一個人回來會拿到新的訪客識別碼，在資料上是新訪客。這個偏差不會反向發生——不會有人被誤判成回訪——所以回訪率永遠是低估，而低估幅度隨 Safari 讀者佔比變動。</p>
<h2 id="怎麼估自己站的量級">怎麼估自己站的量級</h2>
<p>表格裡的量級判斷是方向性的，實際比例要靠外部對照才估得出來。三個成本不高的作法：</p>
<p><strong>用兩個瀏覽器環境自測攔截率。</strong> 一個乾淨、一個裝上常見的內容攔截器，各自走一遍相同的路徑，比對記錄有沒有進來。這量不出站上的實際比例，但能確認攔截<strong>會不會</strong>發生——如果連常見設定都擋得住，那條損失就是真的存在而非理論風險。</p>
<p><strong>拿搜尋主控台的點擊數當外部對照。</strong> Google Search Console 記錄的是搜尋結果的點擊次數，那個計數發生在讀者的瀏覽器之外、不受 JavaScript 與攔截器影響。用同一段時間、同一批頁面比對兩邊的數字，差額大致就是這份統計漏掉的搜尋流量比例。這是這個架構下最容易取得的一把外部尺。</p>
<p><strong>看自己的裝置分佈與已知的偏差方向對不對得上。</strong> 若記錄裡行動裝置佔比明顯低於一般內容網站的水準，可能的解釋之一是行動端的離開事件與整段瀏覽更容易丟失，而不是讀者真的都用桌機。</p>
<p>三種作法都不會給出精確的修正係數，它們的用途是判斷「漏掉的量是百分之幾還是幾成」——而那個量級足以決定一個結論能不能下。</p>
<h2 id="這份統計適合回答什麼">這份統計適合回答什麼</h2>
<p><strong>相對問題可以回答</strong>：哪幾篇比較多人讀、這個月比上個月如何、哪些路徑常被連著讀、哪一篇的停留時間明顯偏短。這些問題的答案建立在「各種損失通道在比較的兩邊大致相同」這個前提上，而在同一個站、相近的時間範圍內，這個前提通常成立。</p>
<p><strong>絕對問題不適合回答</strong>：有多少人看過、真實的跳出率是多少、有多少比例的讀者會回訪。這些數字永遠是低估，而低估的幅度無法從資料本身估出來——這正是上一節要用外部對照的原因。</p>
<p>有一類問題落在中間，判斷起來要小心：<strong>跨時間的比較在損失通道本身改變時會失效</strong>。改版換了頁面結構、內容主題從技術轉向非技術（讀者的攔截器安裝率隨之改變）、加了新的 JavaScript 讓載入變慢，都會讓前後兩段時間的分母不同。看到某個指標長期趨勢反轉時，先確認是不是分母變了。</p>
<h2 id="這張總帳怎麼影響最初的選擇">這張總帳怎麼影響最初的選擇</h2>
<p><a href="/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">模組零的自建取捨</a>當時給的判準是「想搞懂機制運作，或資料必須在自己手上」。走到這裡之後，那個取捨可以帶著具體內容重算一次。</p>
<p>值得注意的是，<strong>這些限制大部分不是自建造成的</strong>。JavaScript 觸發、攔截器、瀏覽器的儲存清除政策同樣適用於 Plausible、Umami、GoatCounter 或任何靠前端腳本收資料的服務——它們的網域出現在攔截清單上的機率甚至更高。自建額外承擔的是實作與維護：退出機制、欄位變更同步、判讀規則的更新。</p>
<p>所以重算的問題不是「自建的數字準不準」，而是「知道這些限制之後，還願不願意為了資料的控制權與理解的深度承擔那些維護工作」。答案是否定的話，換成現成服務不會損失準確度，只會換成另一組別人幫忙維護的限制。</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>。想回頭看某一條損失的機制細節：JavaScript 觸發前提與抓取工具見<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>、離開事件遺失見<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間</a>、退出機制與儲存清除見<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out</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>假故障與靜默失效的診斷</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/diagnosing-silent-failures/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/06-reading-the-data/diagnosing-silent-failures/</guid><description>&lt;p>這一章處理的故障有一個共同結構：&lt;strong>症狀出現的位置不是問題所在的位置&lt;/strong>。錯誤訊息指向的那一行是無辜的、程式碼改對了卻沒有生效、公式完全合法卻算出錯誤的數字。三者的診斷路徑不同，但都要求先放下「訊息說哪裡壞就修哪裡」這個預設。&lt;/p>
&lt;p>這套統計的執行環境被切成四段——頁面上的 JavaScript、Apps Script 的部署層、接收端程式碼、Google 試算表——而每一段的狀態各自獨立。任何一段沒跟上都不會產生跨段的錯誤訊號，這是免伺服器架構特別容易產生假故障的結構原因。&lt;/p>
&lt;h2 id="錯誤訊息指向的位置不是問題的位置">錯誤訊息指向的位置不是問題的位置&lt;/h2>
&lt;p>Google 服務類別的查詢方法在找不到目標時回傳 &lt;code>null&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">inspectHeaders&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">sheet&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 class="nx">getSheetByName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;log&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 class="kd">var&lt;/span> &lt;span class="nx">lastCol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getLastColumn&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// TypeError: Cannot read properties of null
&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>訊息指向 &lt;code>getLastColumn&lt;/code>，而真正的問題在上一行——這個試算表裡沒有名為 &lt;code>log&lt;/code> 的工作表。&lt;strong>判讀規則是：訊息形如「無法讀取 null 的某屬性」時，問題在產生那個 &lt;code>null&lt;/code> 的地方，不在使用它的地方。&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="kd">function&lt;/span> &lt;span class="nx">listSheets&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="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 class="nx">getSheets&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">sheet&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="kd">var&lt;/span> &lt;span class="nx">lastCol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getLastColumn&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">Logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;工作表「%s」 欄數=%s 列數=%s&amp;#39;&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">getName&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">lastCol&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>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式碼的價值在於它不帶任何關於名稱的假設。同樣的模式適用於所有「用名稱或 ID 取得物件」的 API——先列出實際存在的東西，再對照程式碼裡寫的。&lt;/p>
&lt;p>有一個延伸判讀值得記住：&lt;code>getActive()&lt;/code> 回傳的是&lt;strong>這個 Apps Script 專案所綁定的試算表&lt;/strong>。專案綁在 A 表、而資料寫進 B 表（透過 &lt;code>openById&lt;/code>）是完全合法的配置，這時 &lt;code>getActive()&lt;/code> 拿到的是 A 表。工作表名稱與欄位都對不上時，要先確認取得試算表的方式，再懷疑工作表名稱。&lt;/p>
&lt;h2 id="程式改了但端點沒生效">程式改了但端點沒生效&lt;/h2>
&lt;p>Apps Script 把「編輯器裡的程式碼」與「端點服務的版本」設計成兩個獨立狀態。手動執行函式跑的是前者，HTTP 請求打到的是後者——&lt;strong>這代表「我執行某個函式成功了」與「端點跑的是新版」是兩件事，前者成立不推出後者&lt;/strong>。&lt;/p>
&lt;p>這個設計本身是合理的版本控制：可以邊改程式碼邊讓線上端點維持穩定，改完再一次切版。代價是「改了沒生效」成為最常見的假故障。&lt;/p>
&lt;p>判讀的訣竅是問一個問題：&lt;strong>這次觸發是自己按的，還是外面打進來的&lt;/strong>。自己按的走編輯器最新碼，外面打進來的走部署版本。症狀是「手動測試都對、實際收到的資料卻是舊格式」時，答案幾乎必定是部署版本沒更新。&lt;/p>
&lt;p>更新的操作是「管理部署作業 → 編輯 → 版本選新版本 → 部署」，這會保持網址不變（&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>的更新語意）。用「新增部署作業」會產生新網址，前端設定的端點就對不上了——這一點與&lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">模組一的部署模型&lt;/a>講的是同一件事，只是從故障診斷的方向再看一次。&lt;/p>
&lt;p>驗證用&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/executions-page/" data-link-title="Executions（執行項目）" data-link-desc="Apps Script 平台記錄每一次函式執行的時間、耗時與狀態的頁面，用來判斷請求究竟有沒有抵達接收端">執行項目&lt;/a>頁面：它列出每一次 &lt;code>doPost&lt;/code> 的執行時間、耗時與狀態。這個頁面能回答一個關鍵問題——&lt;strong>請求到底有沒有打到端點&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>有執行紀錄但狀態失敗：請求送達了，接收端出錯&lt;/li>
&lt;li>完全沒有對應時間的執行紀錄：請求根本沒送達，問題在瀏覽器端或網路&lt;/li>
&lt;/ul>
&lt;p>這個分岔把排查範圍砍半，而且它的證據來自平台本身，不依賴任何推測。實際運用的例子是判斷「某些記錄只有離開事件、沒有進入事件」的成因：執行紀錄裡完全找不到進入事件對應時間的執行，因此問題落在接收端之前——請求根本沒送出來。&lt;/p>
&lt;p>這一步能確定的就到這裡。&lt;strong>再往下的「為什麼沒送出來」屬於假說，執行紀錄支撐不了它&lt;/strong>：可能是那個瀏覽器環境攔截了頁面載入期間的請求，也可能是接收端當時併發撞頂而請求被拒（那會留下失敗紀錄，可以排除），或是網路瞬斷。把觀測與解釋分開記錄，之後拿到更多樣本時才知道哪一個假說被推翻了。&lt;/p>
&lt;h2 id="靜默失效合法執行輸出合法數字是錯的">靜默失效：合法執行、輸出合法、數字是錯的&lt;/h2>
&lt;p>第三類故障持續執行、持續產出看起來合理的數字，而數字是錯的——前兩類至少會停下來。&lt;/p>
&lt;p>試算表的分類公式是典型場所，而它最難察覺的形態是「修正一個問題時引入下一個」。&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型&lt;/a>那一章示範過第一次：單事件模型下的計數條件在雙事件模型下永遠不成立，那個分類的計數從此恆為零。&lt;/p>
&lt;p>修法是把事件型別加進條件，而修正版的開頭多了一句「非進入事件的列一律留白」——避免同一次瀏覽被計數兩次。&lt;strong>第二次失效就出在那一句。&lt;/strong> 它預設每次瀏覽都有進入事件，而真實資料裡存在只有離開事件的記錄（成因見上一節）。這些列被開頭那個條件整批留白，於是一整類資料在統計裡完全不存在——這不是算錯，是那一類從報表上消失了。&lt;/p>
&lt;p>兩次的共同形態相同：公式沒有語法錯誤、沒有回傳錯誤值，只有分佈變了。&lt;/p>
&lt;p>&lt;strong>零是這裡最危險的輸出，因為它與「沒有這種流量」完全不可區分。&lt;/strong> 要察覺算錯，得先有一個獨立來源知道這裡本來應該有多少——而分析公式存在的理由，正是因為沒有那個獨立來源。&lt;/p>
&lt;p>三個作法讓這類失效可被發現：&lt;/p>
&lt;p>&lt;strong>用總量守恆檢查涵蓋。&lt;/strong> 分類後各類數量的總和與原始列數的差額應該為零。差額不為零代表有資料形狀沒被任何條件接住，而這個檢查不要求預先知道漏了什麼——這是它比逐條檢視有效的地方。&lt;/p>
&lt;p>&lt;strong>讓未涵蓋的資料顯示成標籤而非留白。&lt;/strong> 分類邏輯的最後放一個 catch-all 分支，把沒被接住的列標成「未分類」。留白與「不適用」在視覺上不可區分，而一個會被看見的標籤才有機會促使人去查。&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;/p>
&lt;p>抽象層的完整推導見 &lt;a href="https://tarrragon.github.io/blog/report/new-data-shape-silently-changes-analysis/" data-link-title="資料多出一種形狀時，既有分析邏輯靜默換語意" data-link-desc="資料模型新增一種列或事件型別後，判斷既有的公式、查詢與聚合是否還在算同一件事——它們不報錯，只是漏掉或重複整類資料">#250 資料多出一種形狀時，既有分析邏輯靜默換語意&lt;/a>。&lt;/p>
&lt;h2 id="診斷順序">診斷順序&lt;/h2>
&lt;p>三類故障的排查有一個共通的推進方式：&lt;strong>沿著資料的路徑，逐段確認實際狀態，而不是逐段確認程式碼寫了什麼&lt;/strong>。&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;/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;td>被攔截或逾時，換一個瀏覽器環境重試以確認&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>平台部署層&lt;/td>
 &lt;td>管理部署作業，比對網址與版本&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;td>有失敗紀錄查例外，無紀錄代表沒抵達、回上一列&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>試算表&lt;/td>
 &lt;td>直接看最新一列的欄位&lt;/td>
 &lt;td>資料寫進去了、位置正確&lt;/td>
 &lt;td>欄位錯位查 &lt;code>appendRow&lt;/code> 的順序與表頭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>彙總排程&lt;/td>
 &lt;td>觸發器的執行紀錄、日報最新一列的日期&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;td>差額不為零時找沒被任何條件接住的資料形狀&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三段的觀測值需要補充。&lt;strong>瀏覽器那一段&lt;/strong>看的是請求有沒有發出，而它沒發出通常有兩個良性成因：beacon 前端有一道網域判斷（只在正式站送、本機預覽不送），以及讀者可能設過&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>——兩者都會讓程式在送出前就返回，看起來與故障相同。&lt;/p>
&lt;p>&lt;strong>傳輸途中那一段&lt;/strong>是「發出了但沒抵達」的落點。接收端是一個外部網域，廣告與隱私擴充功能的通用規則可能直接擋下這個請求；瀏覽器的網路面板會顯示它被封鎖而非失敗。這一段存在的理由是前後兩段都答不了它：瀏覽器說「我送了」、執行紀錄說「我沒收到」，兩邊都沒說謊。&lt;/p>
&lt;p>&lt;strong>彙總排程那一段&lt;/strong>容易被漏掉，因為它不在 beacon 的即時路徑上。定時彙總讀的是同一張表，而它有自己的一組常數（讀幾欄、事件型別在第幾欄），資料模型變更時它們與分析公式同樣需要重算。&lt;/p>
&lt;p>&lt;strong>試算表那一段&lt;/strong>看的是最新一列的時間戳與欄位對位。&lt;strong>欄位錯位與資料沒寫進去是兩種不同的故障&lt;/strong>——前者每一格都有值、看起來完全正常，成因是寫入端的欄位順序與表頭不同步，而它不會產生任何錯誤。&lt;/p>
&lt;p>每一段都有一個「不看程式碼就能得到的觀測值」，這是這張表的設計重點。讀程式碼只能知道它宣稱會做什麼，而這幾類故障的成因全都是宣稱與實際之間的落差——落差只有實際觀測才看得見。&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="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out&lt;/a>、行為欄位見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間&lt;/a>、判定規則見&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;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/data-coverage/" data-link-title="這份統計的分母是什麼" data-link-desc="報表上的數字要拿去回答問題之前，先確定它涵蓋了誰、漏掉了誰，以及哪些結論在這個涵蓋範圍下仍然成立">這份統計的分母是什麼&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></description><content:encoded><![CDATA[<p>這一章處理的故障有一個共同結構：<strong>症狀出現的位置不是問題所在的位置</strong>。錯誤訊息指向的那一行是無辜的、程式碼改對了卻沒有生效、公式完全合法卻算出錯誤的數字。三者的診斷路徑不同，但都要求先放下「訊息說哪裡壞就修哪裡」這個預設。</p>
<p>這套統計的執行環境被切成四段——頁面上的 JavaScript、Apps Script 的部署層、接收端程式碼、Google 試算表——而每一段的狀態各自獨立。任何一段沒跟上都不會產生跨段的錯誤訊號，這是免伺服器架構特別容易產生假故障的結構原因。</p>
<h2 id="錯誤訊息指向的位置不是問題的位置">錯誤訊息指向的位置不是問題的位置</h2>
<p>Google 服務類別的查詢方法在找不到目標時回傳 <code>null</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">inspectHeaders</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">sheet</span> <span class="o">=</span> <span class="nx">SpreadsheetApp</span><span class="p">.</span><span class="nx">getActive</span><span class="p">().</span><span class="nx">getSheetByName</span><span class="p">(</span><span class="s1">&#39;log&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">var</span> <span class="nx">lastCol</span> <span class="o">=</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getLastColumn</span><span class="p">();</span>   <span class="c1">// TypeError: Cannot read properties of null
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>訊息指向 <code>getLastColumn</code>，而真正的問題在上一行——這個試算表裡沒有名為 <code>log</code> 的工作表。<strong>判讀規則是：訊息形如「無法讀取 null 的某屬性」時，問題在產生那個 <code>null</code> 的地方，不在使用它的地方。</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="kd">function</span> <span class="nx">listSheets</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">SpreadsheetApp</span><span class="p">.</span><span class="nx">getActive</span><span class="p">().</span><span class="nx">getSheets</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">sheet</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kd">var</span> <span class="nx">lastCol</span> <span class="o">=</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getLastColumn</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Logger</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;工作表「%s」 欄數=%s 列數=%s&#39;</span><span class="p">,</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getName</span><span class="p">(),</span> <span class="nx">lastCol</span><span class="p">,</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getLastRow</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式碼的價值在於它不帶任何關於名稱的假設。同樣的模式適用於所有「用名稱或 ID 取得物件」的 API——先列出實際存在的東西，再對照程式碼裡寫的。</p>
<p>有一個延伸判讀值得記住：<code>getActive()</code> 回傳的是<strong>這個 Apps Script 專案所綁定的試算表</strong>。專案綁在 A 表、而資料寫進 B 表（透過 <code>openById</code>）是完全合法的配置，這時 <code>getActive()</code> 拿到的是 A 表。工作表名稱與欄位都對不上時，要先確認取得試算表的方式，再懷疑工作表名稱。</p>
<h2 id="程式改了但端點沒生效">程式改了但端點沒生效</h2>
<p>Apps Script 把「編輯器裡的程式碼」與「端點服務的版本」設計成兩個獨立狀態。手動執行函式跑的是前者，HTTP 請求打到的是後者——<strong>這代表「我執行某個函式成功了」與「端點跑的是新版」是兩件事，前者成立不推出後者</strong>。</p>
<p>這個設計本身是合理的版本控制：可以邊改程式碼邊讓線上端點維持穩定，改完再一次切版。代價是「改了沒生效」成為最常見的假故障。</p>
<p>判讀的訣竅是問一個問題：<strong>這次觸發是自己按的，還是外面打進來的</strong>。自己按的走編輯器最新碼，外面打進來的走部署版本。症狀是「手動測試都對、實際收到的資料卻是舊格式」時，答案幾乎必定是部署版本沒更新。</p>
<p>更新的操作是「管理部署作業 → 編輯 → 版本選新版本 → 部署」，這會保持網址不變（<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>的更新語意）。用「新增部署作業」會產生新網址，前端設定的端點就對不上了——這一點與<a href="/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">模組一的部署模型</a>講的是同一件事，只是從故障診斷的方向再看一次。</p>
<p>驗證用<a href="/blog/automation/knowledge-cards/executions-page/" data-link-title="Executions（執行項目）" data-link-desc="Apps Script 平台記錄每一次函式執行的時間、耗時與狀態的頁面，用來判斷請求究竟有沒有抵達接收端">執行項目</a>頁面：它列出每一次 <code>doPost</code> 的執行時間、耗時與狀態。這個頁面能回答一個關鍵問題——<strong>請求到底有沒有打到端點</strong>：</p>
<ul>
<li>有執行紀錄但狀態失敗：請求送達了，接收端出錯</li>
<li>完全沒有對應時間的執行紀錄：請求根本沒送達，問題在瀏覽器端或網路</li>
</ul>
<p>這個分岔把排查範圍砍半，而且它的證據來自平台本身，不依賴任何推測。實際運用的例子是判斷「某些記錄只有離開事件、沒有進入事件」的成因：執行紀錄裡完全找不到進入事件對應時間的執行，因此問題落在接收端之前——請求根本沒送出來。</p>
<p>這一步能確定的就到這裡。<strong>再往下的「為什麼沒送出來」屬於假說，執行紀錄支撐不了它</strong>：可能是那個瀏覽器環境攔截了頁面載入期間的請求，也可能是接收端當時併發撞頂而請求被拒（那會留下失敗紀錄，可以排除），或是網路瞬斷。把觀測與解釋分開記錄，之後拿到更多樣本時才知道哪一個假說被推翻了。</p>
<h2 id="靜默失效合法執行輸出合法數字是錯的">靜默失效：合法執行、輸出合法、數字是錯的</h2>
<p>第三類故障持續執行、持續產出看起來合理的數字，而數字是錯的——前兩類至少會停下來。</p>
<p>試算表的分類公式是典型場所，而它最難察覺的形態是「修正一個問題時引入下一個」。<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型</a>那一章示範過第一次：單事件模型下的計數條件在雙事件模型下永遠不成立，那個分類的計數從此恆為零。</p>
<p>修法是把事件型別加進條件，而修正版的開頭多了一句「非進入事件的列一律留白」——避免同一次瀏覽被計數兩次。<strong>第二次失效就出在那一句。</strong> 它預設每次瀏覽都有進入事件，而真實資料裡存在只有離開事件的記錄（成因見上一節）。這些列被開頭那個條件整批留白，於是一整類資料在統計裡完全不存在——這不是算錯，是那一類從報表上消失了。</p>
<p>兩次的共同形態相同：公式沒有語法錯誤、沒有回傳錯誤值，只有分佈變了。</p>
<p><strong>零是這裡最危險的輸出，因為它與「沒有這種流量」完全不可區分。</strong> 要察覺算錯，得先有一個獨立來源知道這裡本來應該有多少——而分析公式存在的理由，正是因為沒有那個獨立來源。</p>
<p>三個作法讓這類失效可被發現：</p>
<p><strong>用總量守恆檢查涵蓋。</strong> 分類後各類數量的總和與原始列數的差額應該為零。差額不為零代表有資料形狀沒被任何條件接住，而這個檢查不要求預先知道漏了什麼——這是它比逐條檢視有效的地方。</p>
<p><strong>讓未涵蓋的資料顯示成標籤而非留白。</strong> 分類邏輯的最後放一個 catch-all 分支，把沒被接住的列標成「未分類」。留白與「不適用」在視覺上不可區分，而一個會被看見的標籤才有機會促使人去查。</p>
<p><strong>資料模型變更時把公式的重算納入同一次變更。</strong> 分析邏輯是資料模型的下游依賴，與欄位定義、寫入程式碼屬於同一次變更的範圍——延後處理的代價與重算的方法見<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型</a>那一節的示範。</p>
<p>抽象層的完整推導見 <a href="/blog/report/new-data-shape-silently-changes-analysis/" data-link-title="資料多出一種形狀時，既有分析邏輯靜默換語意" data-link-desc="資料模型新增一種列或事件型別後，判斷既有的公式、查詢與聚合是否還在算同一件事——它們不報錯，只是漏掉或重複整類資料">#250 資料多出一種形狀時，既有分析邏輯靜默換語意</a>。</p>
<h2 id="診斷順序">診斷順序</h2>
<p>三類故障的排查有一個共通的推進方式：<strong>沿著資料的路徑，逐段確認實際狀態，而不是逐段確認程式碼寫了什麼</strong>。</p>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>觀測方式</th>
          <th>正常代表什麼</th>
          <th>異常時往哪走</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>瀏覽器</td>
          <td>開發者工具的網路面板，找送往端點的請求</td>
          <td>事件有產生、也送出去了</td>
          <td>查網域判斷與退出標記（見下方說明）</td>
      </tr>
      <tr>
          <td>傳輸途中</td>
          <td>網路面板看該請求的狀態碼，或關掉擴充再試</td>
          <td>請求離開瀏覽器、也沒被攔下</td>
          <td>被攔截或逾時，換一個瀏覽器環境重試以確認</td>
      </tr>
      <tr>
          <td>平台部署層</td>
          <td>管理部署作業，比對網址與版本</td>
          <td>端點跑的是預期的那份程式碼</td>
          <td>用同一個部署更新版本，不新增部署</td>
      </tr>
      <tr>
          <td>接收端執行</td>
          <td>執行項目頁面，看時間與狀態</td>
          <td>請求送達了、程式跑完了</td>
          <td>有失敗紀錄查例外，無紀錄代表沒抵達、回上一列</td>
      </tr>
      <tr>
          <td>試算表</td>
          <td>直接看最新一列的欄位</td>
          <td>資料寫進去了、位置正確</td>
          <td>欄位錯位查 <code>appendRow</code> 的順序與表頭</td>
      </tr>
      <tr>
          <td>彙總排程</td>
          <td>觸發器的執行紀錄、日報最新一列的日期</td>
          <td>定時彙總跑過、輸出有更新</td>
          <td>查欄數與事件型別這兩個常數是否跟上資料模型</td>
      </tr>
      <tr>
          <td>分析公式</td>
          <td>進入事件列的總量守恆與未分類計數</td>
          <td>統計涵蓋了所有實際存在的形狀</td>
          <td>差額不為零時找沒被任何條件接住的資料形狀</td>
      </tr>
  </tbody>
</table>
<p>三段的觀測值需要補充。<strong>瀏覽器那一段</strong>看的是請求有沒有發出，而它沒發出通常有兩個良性成因：beacon 前端有一道網域判斷（只在正式站送、本機預覽不送），以及讀者可能設過<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">退出標記</a>——兩者都會讓程式在送出前就返回，看起來與故障相同。</p>
<p><strong>傳輸途中那一段</strong>是「發出了但沒抵達」的落點。接收端是一個外部網域，廣告與隱私擴充功能的通用規則可能直接擋下這個請求；瀏覽器的網路面板會顯示它被封鎖而非失敗。這一段存在的理由是前後兩段都答不了它：瀏覽器說「我送了」、執行紀錄說「我沒收到」，兩邊都沒說謊。</p>
<p><strong>彙總排程那一段</strong>容易被漏掉，因為它不在 beacon 的即時路徑上。定時彙總讀的是同一張表，而它有自己的一組常數（讀幾欄、事件型別在第幾欄），資料模型變更時它們與分析公式同樣需要重算。</p>
<p><strong>試算表那一段</strong>看的是最新一列的時間戳與欄位對位。<strong>欄位錯位與資料沒寫進去是兩種不同的故障</strong>——前者每一格都有值、看起來完全正常，成因是寫入端的欄位順序與表頭不同步，而它不會產生任何錯誤。</p>
<p>每一段都有一個「不看程式碼就能得到的觀測值」，這是這張表的設計重點。讀程式碼只能知道它宣稱會做什麼，而這幾類故障的成因全都是宣稱與實際之間的落差——落差只有實際觀測才看得見。</p>
<h2 id="下一步">下一步</h2>
<p>收集鏈確認健康之後，資料判讀的環節分別是：識別欄位見<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out</a>、行為欄位見<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間</a>、判定規則見<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>、而這些欄位加總起來涵蓋了誰見<a href="/blog/automation/06-reading-the-data/data-coverage/" data-link-title="這份統計的分母是什麼" data-link-desc="報表上的數字要拿去回答問題之前，先確定它涵蓋了誰、漏掉了誰，以及哪些結論在這個涵蓋範圍下仍然成立">這份統計的分母是什麼</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></channel></rss>