<?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>Sendbeacon on Tarragon</title><link>https://tarrragon.github.io/blog/tags/sendbeacon/</link><description>Recent content in Sendbeacon on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 03 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/sendbeacon/index.xml" rel="self" type="application/rss+xml"/><item><title>Beacon</title><link>https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/</guid><description>&lt;p>Beacon 是瀏覽器主動送出、且送出後不等待回應的一則事件回報請求。它的用途是把「頁面上發生了某件事」（被瀏覽、被點擊、即將關閉）這個事實，從使用者的瀏覽器回傳到一個接收端記錄下來。在靜態站的 &lt;a href="https://tarrragon.github.io/blog/automation/00-mental-model/static-site-and-glue-layer/" data-link-title="靜態站的能力邊界與膠水層" data-link-desc="靜態站量不到自己流量的根本原因、膠水層補的是哪一塊伺服器能力、以及 client beacon 怎麼把資料送回你手上">client beacon 架構&lt;/a>裡，beacon 是唯一能把瀏覽資料送回你手上的途徑——因為靜態站沒有伺服器 access log 可以被動記錄。可先對照 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署&lt;/a>（beacon 送達的接收端怎麼來的）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Beacon 跟一般 API 請求的差別在「不等回應」。發起者送出後就繼續做自己的事，不阻塞、不讀回應、不重試——這種模式叫 fire-and-forget。對流量統計這類「送到就好、漏一兩筆無所謂」的場景，fire-and-forget 讓回報完全不影響使用者體驗。beacon 送出的請求由接收端的 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">doPost&lt;/a> 進入點接住處理。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>瀏覽器的 &lt;code>navigator.sendBeacon&lt;/code> 是專為此設計的 API：它保證即使頁面正在關閉，請求也會在背景送完，而且預設用 &lt;code>text/plain&lt;/code> 送出，這一點在打跨網域端點時關鍵——&lt;code>text/plain&lt;/code> 屬於 simple request、不觸發 CORS preflight。&lt;/p>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>跨網域打 Apps Script 這類端點時，&lt;code>text/plain&lt;/code> 是否夠用決定要不要處理 CORS preflight。實作細節見&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Beacon 是瀏覽器主動送出、且送出後不等待回應的一則事件回報請求。它的用途是把「頁面上發生了某件事」（被瀏覽、被點擊、即將關閉）這個事實，從使用者的瀏覽器回傳到一個接收端記錄下來。在靜態站的 <a href="/blog/automation/00-mental-model/static-site-and-glue-layer/" data-link-title="靜態站的能力邊界與膠水層" data-link-desc="靜態站量不到自己流量的根本原因、膠水層補的是哪一塊伺服器能力、以及 client beacon 怎麼把資料送回你手上">client beacon 架構</a>裡，beacon 是唯一能把瀏覽資料送回你手上的途徑——因為靜態站沒有伺服器 access log 可以被動記錄。可先對照 <a href="/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署</a>（beacon 送達的接收端怎麼來的）。</p>
<h2 id="概念位置">概念位置</h2>
<p>Beacon 跟一般 API 請求的差別在「不等回應」。發起者送出後就繼續做自己的事，不阻塞、不讀回應、不重試——這種模式叫 fire-and-forget。對流量統計這類「送到就好、漏一兩筆無所謂」的場景，fire-and-forget 讓回報完全不影響使用者體驗。beacon 送出的請求由接收端的 <a href="/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">doPost</a> 進入點接住處理。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>瀏覽器的 <code>navigator.sendBeacon</code> 是專為此設計的 API：它保證即使頁面正在關閉，請求也會在背景送完，而且預設用 <code>text/plain</code> 送出，這一點在打跨網域端點時關鍵——<code>text/plain</code> 屬於 simple request、不觸發 CORS preflight。</p>
<h2 id="判讀方式">判讀方式</h2>
<p>跨網域打 Apps Script 這類端點時，<code>text/plain</code> 是否夠用決定要不要處理 CORS preflight。實作細節見<a href="/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙</a>。</p>
]]></content:encoded></item><item><title>前端 beacon 與 CORS 障礙</title><link>https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/</guid><description>&lt;p>前端 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 的責任是：在頁面載入時偵測「這一頁被看了」，把一則最小事件送到 Apps Script 的接收端，而且不能拖慢頁面、不能因為送失敗而影響閱讀。做對這件事的關鍵，是選一種&lt;strong>不會觸發 CORS preflight&lt;/strong> 的送出方式——這是靜態站打 Apps Script 最容易卡住的地方，先講清楚為什麼。&lt;/p>
&lt;h2 id="cors-preflight-為什麼會擋住-beacon">CORS preflight 為什麼會擋住 beacon&lt;/h2>
&lt;p>瀏覽器對跨網域的請求有一套安全機制。當網頁（你的 blog，網域 A）要送一個請求到另一個網域（Apps Script，網域 B），而這個請求「不夠單純」時，瀏覽器會先偷偷送一個 &lt;code>OPTIONS&lt;/code> 請求去問網域 B：「我等一下要送這種請求，你允許嗎？」這個預先詢問叫 &lt;strong>preflight&lt;/strong>。要等網域 B 回答允許，真正的請求才會送出。&lt;/p>
&lt;p>Apps Script 在這裡有一個結構性的問題：它的 web app &lt;strong>無法回應 &lt;code>OPTIONS&lt;/code> 請求&lt;/strong>。原因是 Apps Script 只有 &lt;code>doGet&lt;/code> 和 &lt;code>doPost&lt;/code> 兩個進入點，沒有 &lt;code>doOptions&lt;/code>；preflight 送來的 &lt;code>OPTIONS&lt;/code> 打不到你的程式，Apps Script 平台直接用預設方式回應，而那個回應不帶「我允許」的 CORS 標頭。於是 preflight 失敗、真正的請求根本沒送出。這就是「我照 API 教學寫了 &lt;code>fetch&lt;/code> 打 Apps Script，瀏覽器 console 一片 CORS 紅字」的根本原因——問題不在你的程式，在於 preflight 發生在你的程式被執行&lt;strong>之前&lt;/strong>，你沒有機會處理它。&lt;/p>
&lt;p>避開的方法是讓請求「夠單純」，單純到瀏覽器判定不需要 preflight。這種請求叫 &lt;strong>simple request&lt;/strong>，條件之一是 &lt;code>Content-Type&lt;/code> 必須是 &lt;code>text/plain&lt;/code>、&lt;code>application/x-www-form-urlencoded&lt;/code> 或 &lt;code>multipart/form-data&lt;/code> 其中之一。關鍵就在這：&lt;strong>只要用 &lt;code>text/plain&lt;/code> 送，就不會有 preflight&lt;/strong>，請求直接送達，Apps Script 的 &lt;code>doPost&lt;/code> 正常收到。用 &lt;code>application/json&lt;/code> 送則會觸發 preflight、然後失敗。&lt;/p>
&lt;h2 id="用-sendbeacon-送-textplain">用 sendBeacon 送 text/plain&lt;/h2>
&lt;p>&lt;code>navigator.sendBeacon&lt;/code> 是瀏覽器為「送出後就不管」這種場景設計的 API，正好適合流量 beacon。它有三個特性剛好對上需求：送出後不阻塞頁面、即使使用者馬上關頁面也保證在背景送完、而且它送出的 &lt;code>Content-Type&lt;/code> 預設就是 &lt;code>text/plain&lt;/code>（當你傳字串或 text 型別的 Blob 時）——自動滿足 simple request 條件，不觸發 preflight。&lt;/p>
&lt;p>最小可用的 beacon 長這樣：&lt;/p>





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- layouts/_default/baseof.html，在 &lt;/body&gt; 前 --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">{{ partial &#34;beacon.html&#34; . }}</span></span></code></pre></div><p>放在 <code>&lt;/body&gt;</code> 前、而不是 <code>&lt;head&gt;</code> 裡，是為了讓 beacon 在頁面主要內容都載入後才送，不跟關鍵資源搶頻寬。<code>sendBeacon</code> 本身就不阻塞，這個位置只是讓它更晚一點、更不影響體驗。</p>
<p>一個上線前要注意的邊界：本機 <code>hugo server</code> 預覽時 beacon 也會照送，會把開發時的瀏覽混進統計。實務上會加一個判斷，只在正式網域才送——例如檢查 <code>location.hostname</code> 是不是正式網域，是才執行。這個 hostname guard 擋掉本機預覽；「在正式站上自己重整」則需要另一套機制，見<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out</a>。防濫用與資料乾淨度的整體討論在模組五。</p>
<h2 id="下一步">下一步</h2>
<p>前端會送了，但現在 beacon 送出去還沒有人接。接收端要怎麼解析這個 <code>text/plain</code> 的 JSON、怎麼安全寫進 Sheet、部署後怎麼確認收到第一筆——見<a href="/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">接收端 handler：寫進第一筆</a>。想先補齊 <code>doPost</code> 跟部署模型的背景，回<a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a>。資料開始累積之後怎麼讀出意義，見<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a>。</p>
]]></content:encoded></item><item><title>事件模型與停留時間</title><link>https://tarrragon.github.io/blog/automation/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>模組二：流量 beacon 實作</title><link>https://tarrragon.github.io/blog/automation/02-analytics-beacon/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/02-analytics-beacon/</guid><description>&lt;p>把模組零的 client beacon 架構真的做出來：前端在頁面載入時送一則瀏覽事件，Apps Script 接住它、寫進 Google Sheet。這一章的終點是「打開試算表，看到自己剛剛的瀏覽出現在第一列」。&lt;/p>
&lt;p>實作拆成兩半，剛好對應架構的兩端。前端這半的重點是用對送出方式，避開靜態站打 Apps Script 最常見的 CORS 障礙。接收端這半的重點是把請求解析出來、安全地 append 進試算表。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙&lt;/a>&lt;/td>
 &lt;td>用 &lt;code>sendBeacon&lt;/code> 送 &lt;code>text/plain&lt;/code> 避開 preflight、beacon 該送什麼、放進 Hugo 哪裡&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">接收端 handler：寫進第一筆&lt;/a>&lt;/td>
 &lt;td>&lt;code>doPost&lt;/code> 解析請求、&lt;code>appendRow&lt;/code> 寫進 Sheet、部署後收到第一筆瀏覽&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基&lt;/a>：&lt;code>doGet&lt;/code>/&lt;code>doPost&lt;/code> 與部署模型的完整說明&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫&lt;/a>：資料進來後的並發、資料模型與容量&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後&lt;/a>：這裡的 payload 只描述單次瀏覽，補上訪客識別、停留時間與自動化訊號之後才讀得出「幾個人來過、有沒有真的在讀」&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>把模組零的 client beacon 架構真的做出來：前端在頁面載入時送一則瀏覽事件，Apps Script 接住它、寫進 Google Sheet。這一章的終點是「打開試算表，看到自己剛剛的瀏覽出現在第一列」。</p>
<p>實作拆成兩半，剛好對應架構的兩端。前端這半的重點是用對送出方式，避開靜態站打 Apps Script 最常見的 CORS 障礙。接收端這半的重點是把請求解析出來、安全地 append 進試算表。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙</a></td>
          <td>用 <code>sendBeacon</code> 送 <code>text/plain</code> 避開 preflight、beacon 該送什麼、放進 Hugo 哪裡</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">接收端 handler：寫進第一筆</a></td>
          <td><code>doPost</code> 解析請求、<code>appendRow</code> 寫進 Sheet、部署後收到第一筆瀏覽</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a>：<code>doGet</code>/<code>doPost</code> 與部署模型的完整說明</li>
<li>→ <a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫</a>：資料進來後的並發、資料模型與容量</li>
<li>→ <a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a>：這裡的 payload 只描述單次瀏覽，補上訪客識別、停留時間與自動化訊號之後才讀得出「幾個人來過、有沒有真的在讀」</li>
</ul>
]]></content:encoded></item></channel></rss>