<?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>Data-Model on Tarragon</title><link>https://tarrragon.github.io/blog/tags/data-model/</link><description>Recent content in Data-Model 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/data-model/index.xml" rel="self" type="application/rss+xml"/><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/03-sheet-as-database/data-model-and-capacity/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/03-sheet-as-database/data-model-and-capacity/</guid><description>&lt;p>Sheets 當資料庫的資料模型設計，核心原則是「一列一事件、欄位固定、只 append 不改」。這種形狀叫 raw log（原始紀錄）：每筆瀏覽就是一列、寫進去就不再動它，彙總與分析交給後續的排程另外算。先講為什麼這樣設計，再講這張表能長到多大。&lt;/p>
&lt;h2 id="raw-log-的欄位設計">raw log 的欄位設計&lt;/h2>
&lt;p>raw log 表每一欄對應一個固定的維度，每一列是一筆完整的事件。流量統計的 raw log 大致是：&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;th>裝置&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>2026-07-06 20:11:03&lt;/td>
 &lt;td>/posts/foo/&lt;/td>
 &lt;td>google.com&lt;/td>
 &lt;td>zh-TW&lt;/td>
 &lt;td>mobile&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2026-07-06 20:11:47&lt;/td>
 &lt;td>/automation/&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>en-US&lt;/td>
 &lt;td>desktop&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三個設計決定讓這張表好用又好彙總。&lt;strong>時間放第一欄、用接收端的伺服器時間&lt;/strong>，讓資料天然按寫入順序排列，也讓「某天的資料」用時間範圍就能篩。&lt;strong>每一欄都是原子維度、不把多個資訊塞進一欄&lt;/strong>（不要把「路徑+裝置」合併成一欄），因為彙總時要能單獨 group by 路徑、或單獨 group by 裝置，欄位分開才做得到。&lt;strong>只 append、不回頭改已寫的列&lt;/strong>，讓這張表成為不可變的事實紀錄——要改的是彙總結果、不是原始 log，原始資料保持乾淨才能隨時重算。&lt;/p>
&lt;p>raw log 直接看沒有意義（幾千列逐筆瀏覽），它的價值是當彙總的原料。把 raw log 依日期與路徑 group 成「每天每篇看幾次」的日報，是&lt;a href="https://tarrragon.github.io/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程&lt;/a>的工作。&lt;/p>
&lt;h2 id="sheets-的容量邊界">Sheets 的容量邊界&lt;/h2>
&lt;p>Sheets 是為「人看的試算表」設計的，不是高吞吐資料庫，所以它有兩類上限要放在心上。&lt;/p>
&lt;p>&lt;strong>總 cell 數上限&lt;/strong>：單一試算表有一個總儲存格數量的硬上限（目前以千萬 cell 計，Google 會不定期調整，實際數字以官方為準）。raw log 每列的 cell 數等於欄數，5 欄的話，這個上限換算成列數是相當大的量——對個人 blog 是好幾年的資料，不會很快碰到。但它是有限的，不是無限成長。&lt;/p>
&lt;p>&lt;strong>讀寫效能隨列數下降&lt;/strong>：實務上先浮現的邊界是效能，比硬上限更早遇到。當 raw log 累積到數萬、數十萬列，任何「整表讀進來算」的操作（例如彙總 trigger 每次全表掃描）會越來越慢，可能逼近觸發器的&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">單次 6 分鐘執行上限&lt;/a>。&lt;/p>
&lt;h2 id="撐不住的訊號與應對">撐不住的訊號與應對&lt;/h2>
&lt;p>判斷 Sheets 開始撐不住，看這幾個訊號：彙總 trigger 的執行時間逐月變長、逼近 6 分鐘；開啟試算表本身變慢、捲動卡頓；或 cell 數逼近上限的警告。出現這些訊號時，有兩條應對路徑，依情況選：&lt;/p>
&lt;p>&lt;strong>分表&lt;/strong>：把 raw log 按月（或按季）拆成多張工作表，例如 &lt;code>log-2026-07&lt;/code>、&lt;code>log-2026-08&lt;/code>。彙總 trigger 只讀當月表、不必掃全部歷史，執行時間就回到穩定。這是最小改動、繼續留在 Sheets 的做法，適合量成長但還在 Sheets 能力範圍內。&lt;/p>
&lt;p>&lt;strong>遷移&lt;/strong>：當量大到連分表都吃力、或需要更即時的查詢，訊號就指向「該換更重的儲存」——把接收端從 Apps Script 換成 Cloudflare Workers + D1（免費 SQLite），資料庫的查詢能力與吞吐遠高於 Sheets。遷移的完整訊號與路徑在&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五&lt;/a>，選型的取捨在&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>Sheets 撐不住的第一訊號通常是彙總變慢、不是 cell 爆掉&lt;/strong>——先分表爭取空間，分表也吃力才談遷移。對絕大多數個人 blog，這些邊界很久才會碰到，先讓簡單版跑著、留意訊號即可。&lt;/p>
&lt;h2 id="事件模型會改變這裡的估算">事件模型會改變這裡的估算&lt;/h2>
&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>把它改成一進一離兩列，並在 payload 上多加六個欄位——列數接近翻倍、單列的 cell 數從五增為十一，兩者相乘之後容量上限會比這裡算出來的早很多抵達。真要估的話，用那一章的欄位清單重算一次，而不是沿用這一節的數字。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>資料進來、也知道表能長多大之後，把 raw log 變成看得懂的日報，見&lt;a href="https://tarrragon.github.io/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Sheets 當資料庫的資料模型設計，核心原則是「一列一事件、欄位固定、只 append 不改」。這種形狀叫 raw log（原始紀錄）：每筆瀏覽就是一列、寫進去就不再動它，彙總與分析交給後續的排程另外算。先講為什麼這樣設計，再講這張表能長到多大。</p>
<h2 id="raw-log-的欄位設計">raw log 的欄位設計</h2>
<p>raw log 表每一欄對應一個固定的維度，每一列是一筆完整的事件。流量統計的 raw log 大致是：</p>
<table>
  <thead>
      <tr>
          <th>時間</th>
          <th>路徑</th>
          <th>來源</th>
          <th>語言</th>
          <th>裝置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2026-07-06 20:11:03</td>
          <td>/posts/foo/</td>
          <td>google.com</td>
          <td>zh-TW</td>
          <td>mobile</td>
      </tr>
      <tr>
          <td>2026-07-06 20:11:47</td>
          <td>/automation/</td>
          <td></td>
          <td>en-US</td>
          <td>desktop</td>
      </tr>
  </tbody>
</table>
<p>三個設計決定讓這張表好用又好彙總。<strong>時間放第一欄、用接收端的伺服器時間</strong>，讓資料天然按寫入順序排列，也讓「某天的資料」用時間範圍就能篩。<strong>每一欄都是原子維度、不把多個資訊塞進一欄</strong>（不要把「路徑+裝置」合併成一欄），因為彙總時要能單獨 group by 路徑、或單獨 group by 裝置，欄位分開才做得到。<strong>只 append、不回頭改已寫的列</strong>，讓這張表成為不可變的事實紀錄——要改的是彙總結果、不是原始 log，原始資料保持乾淨才能隨時重算。</p>
<p>raw log 直接看沒有意義（幾千列逐筆瀏覽），它的價值是當彙總的原料。把 raw log 依日期與路徑 group 成「每天每篇看幾次」的日報，是<a href="/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程</a>的工作。</p>
<h2 id="sheets-的容量邊界">Sheets 的容量邊界</h2>
<p>Sheets 是為「人看的試算表」設計的，不是高吞吐資料庫，所以它有兩類上限要放在心上。</p>
<p><strong>總 cell 數上限</strong>：單一試算表有一個總儲存格數量的硬上限（目前以千萬 cell 計，Google 會不定期調整，實際數字以官方為準）。raw log 每列的 cell 數等於欄數，5 欄的話，這個上限換算成列數是相當大的量——對個人 blog 是好幾年的資料，不會很快碰到。但它是有限的，不是無限成長。</p>
<p><strong>讀寫效能隨列數下降</strong>：實務上先浮現的邊界是效能，比硬上限更早遇到。當 raw log 累積到數萬、數十萬列，任何「整表讀進來算」的操作（例如彙總 trigger 每次全表掃描）會越來越慢，可能逼近觸發器的<a href="/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">單次 6 分鐘執行上限</a>。</p>
<h2 id="撐不住的訊號與應對">撐不住的訊號與應對</h2>
<p>判斷 Sheets 開始撐不住，看這幾個訊號：彙總 trigger 的執行時間逐月變長、逼近 6 分鐘；開啟試算表本身變慢、捲動卡頓；或 cell 數逼近上限的警告。出現這些訊號時，有兩條應對路徑，依情況選：</p>
<p><strong>分表</strong>：把 raw log 按月（或按季）拆成多張工作表，例如 <code>log-2026-07</code>、<code>log-2026-08</code>。彙總 trigger 只讀當月表、不必掃全部歷史，執行時間就回到穩定。這是最小改動、繼續留在 Sheets 的做法，適合量成長但還在 Sheets 能力範圍內。</p>
<p><strong>遷移</strong>：當量大到連分表都吃力、或需要更即時的查詢，訊號就指向「該換更重的儲存」——把接收端從 Apps Script 換成 Cloudflare Workers + D1（免費 SQLite），資料庫的查詢能力與吞吐遠高於 Sheets。遷移的完整訊號與路徑在<a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五</a>，選型的取捨在<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>Sheets 撐不住的第一訊號通常是彙總變慢、不是 cell 爆掉</strong>——先分表爭取空間，分表也吃力才談遷移。對絕大多數個人 blog，這些邊界很久才會碰到，先讓簡單版跑著、留意訊號即可。</p>
<h2 id="事件模型會改變這裡的估算">事件模型會改變這裡的估算</h2>
<p>上面的估算假設一次瀏覽產生一列。<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">模組六的事件模型</a>把它改成一進一離兩列，並在 payload 上多加六個欄位——列數接近翻倍、單列的 cell 數從五增為十一，兩者相乘之後容量上限會比這裡算出來的早很多抵達。真要估的話，用那一章的欄位清單重算一次，而不是沿用這一節的數字。</p>
<h2 id="下一步">下一步</h2>
<p>資料進來、也知道表能長多大之後，把 raw log 變成看得懂的日報，見<a href="/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程</a>。</p>
]]></content:encoded></item></channel></rss>