<?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>Apps-Script on Tarragon</title><link>https://tarrragon.github.io/blog/tags/apps-script/</link><description>Recent content in Apps-Script 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/apps-script/index.xml" rel="self" type="application/rss+xml"/><item><title>Apps Script 是什麼、跟一般伺服器差在哪</title><link>https://tarrragon.github.io/blog/automation/01-apps-script-basics/what-is-apps-script/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/01-apps-script-basics/what-is-apps-script/</guid><description>&lt;p>Apps Script 是 Google 托管的 JavaScript 執行環境：你寫的程式碼跑在 Google 的伺服器上、用 V8 引擎執行，對個人 Google 帳號免費。它的定位是「膠水」——用少量程式碼把 Google 的服務（試算表、Gmail、日曆、雲端硬碟）跟外部串起來，補上這些服務單靠介面做不到的自動化。對流量統計這個案例，它扮演的是接住 beacon、把資料寫進 Sheet 的接收端。&lt;/p>
&lt;h2 id="沒有常駐程序跟一般伺服器最大的差別">沒有常駐程序：跟一般伺服器最大的差別&lt;/h2>
&lt;p>Apps Script 跟一台伺服器最根本的差別是&lt;strong>沒有一個常駐、屬於你的程序&lt;/strong>。一般伺服器是一支持續執行的程式，開機後一直在記憶體裡等請求；Apps Script 的程式碼平時不執行，只在被觸發（有人打 web app 網址、觸發器到點、你手動按執行）時才啟動一個執行實例，跑完就結束。這個模型帶來幾個要先知道的取捨：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>不必管主機&lt;/strong>：沒有作業系統要維護、沒有開關機、沒有閒置費用。程式碼不跑時完全不佔資源。&lt;/li>
&lt;li>&lt;strong>每次執行是獨立的&lt;/strong>：兩次執行之間，記憶體裡的變數不會保留。要跨執行記住東西，得寫進外部儲存（Sheet、&lt;code>PropertiesService&lt;/code>、Drive）。這跟伺服器可以用行程內記憶體 cache 是相反的。&lt;/li>
&lt;li>&lt;strong>有執行上限&lt;/strong>：單次執行最長 6 分鐘、同時併發有數量限制。長時間或高併發的工作不適合，細節見&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額&lt;/a>。&lt;/li>
&lt;/ul>
&lt;p>理解「沒有常駐程序」才知道 Apps Script 適合什麼：短、偶發、由事件觸發的工作，例如「接一則 beacon 寫一列」「每天彙總一次」。不適合的是需要持續連線、低延遲、或狀態常駐記憶體的服務。&lt;/p>
&lt;h2 id="容器綁定-vs-獨立專案">容器綁定 vs 獨立專案&lt;/h2>
&lt;p>Apps Script 專案有兩種存在形式，差別在「它跟一個 Google 檔案綁不綁定」。&lt;/p>
&lt;p>&lt;strong>容器綁定（container-bound）&lt;/strong> 的專案依附在一個具體檔案上——從某張試算表的 &lt;code>擴充功能 → Apps Script&lt;/code> 開出來的專案，就綁定那張試算表。它的好處是程式裡用 &lt;code>SpreadsheetApp.getActiveSpreadsheet()&lt;/code> 直接拿到那張表，不必記檔案 ID；流量統計用這種，程式跟資料表天生綁在一起，最省事。它也能存取容器檔案特有的事件（例如試算表的 &lt;code>onEdit&lt;/code>、表單的 &lt;code>onFormSubmit&lt;/code>）。&lt;/p>
&lt;p>&lt;strong>獨立專案（standalone）&lt;/strong> 不依附任何檔案，從 &lt;code>script.google.com&lt;/code> 直接建立。它適合「不特別綁一個檔案」的工具，或要跨多個檔案操作的情境；存取試算表要用 &lt;code>SpreadsheetApp.openById(&amp;quot;表的ID&amp;quot;)&lt;/code> 明確指定。&lt;/p>
&lt;p>選擇判準很直接：&lt;strong>這段程式主要就是服務某一個檔案嗎&lt;/strong>——是（流量統計服務那張 log 表），用容器綁定；否（一個要操作很多表的通用工具），用獨立專案。&lt;/p>
&lt;h2 id="用到的服務">用到的服務&lt;/h2>
&lt;p>Apps Script 透過一組內建服務物件操作 Google 資源，這個案例會碰到的主要是：&lt;/p>
&lt;ul>
&lt;li>&lt;code>SpreadsheetApp&lt;/code>：讀寫試算表，&lt;code>appendRow&lt;/code>、&lt;code>getRange&lt;/code> 等，是資料的儲存層（模組三詳談）。&lt;/li>
&lt;li>&lt;code>ContentService&lt;/code>：產生 web app 的回應內容，&lt;code>doPost&lt;/code> 必須回傳它的輸出。&lt;/li>
&lt;li>&lt;code>ScriptApp&lt;/code>：管理觸發器，時間排程彙總會用到（模組四）。&lt;/li>
&lt;li>&lt;code>PropertiesService&lt;/code>：存少量 key-value 設定或狀態，適合放「上次處理到哪一列」這種跨執行要記住的小資料。&lt;/li>
&lt;/ul>
&lt;p>這些服務都以你的 Google 帳號身分執行、受你的授權範圍約束，授權模型是下一篇&lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">web app 部署模型&lt;/a>的主題。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>知道 Apps Script 是什麼之後，要讓它能被 blog 的 beacon 打到，得把它部署成有公開網址的 web app。部署模型、&lt;code>doGet&lt;/code>/&lt;code>doPost&lt;/code>、以及授權流程，見&lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">web app 部署模型&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Apps Script 是 Google 托管的 JavaScript 執行環境：你寫的程式碼跑在 Google 的伺服器上、用 V8 引擎執行，對個人 Google 帳號免費。它的定位是「膠水」——用少量程式碼把 Google 的服務（試算表、Gmail、日曆、雲端硬碟）跟外部串起來，補上這些服務單靠介面做不到的自動化。對流量統計這個案例，它扮演的是接住 beacon、把資料寫進 Sheet 的接收端。</p>
<h2 id="沒有常駐程序跟一般伺服器最大的差別">沒有常駐程序：跟一般伺服器最大的差別</h2>
<p>Apps Script 跟一台伺服器最根本的差別是<strong>沒有一個常駐、屬於你的程序</strong>。一般伺服器是一支持續執行的程式，開機後一直在記憶體裡等請求；Apps Script 的程式碼平時不執行，只在被觸發（有人打 web app 網址、觸發器到點、你手動按執行）時才啟動一個執行實例，跑完就結束。這個模型帶來幾個要先知道的取捨：</p>
<ul>
<li><strong>不必管主機</strong>：沒有作業系統要維護、沒有開關機、沒有閒置費用。程式碼不跑時完全不佔資源。</li>
<li><strong>每次執行是獨立的</strong>：兩次執行之間，記憶體裡的變數不會保留。要跨執行記住東西，得寫進外部儲存（Sheet、<code>PropertiesService</code>、Drive）。這跟伺服器可以用行程內記憶體 cache 是相反的。</li>
<li><strong>有執行上限</strong>：單次執行最長 6 分鐘、同時併發有數量限制。長時間或高併發的工作不適合，細節見<a href="/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額</a>。</li>
</ul>
<p>理解「沒有常駐程序」才知道 Apps Script 適合什麼：短、偶發、由事件觸發的工作，例如「接一則 beacon 寫一列」「每天彙總一次」。不適合的是需要持續連線、低延遲、或狀態常駐記憶體的服務。</p>
<h2 id="容器綁定-vs-獨立專案">容器綁定 vs 獨立專案</h2>
<p>Apps Script 專案有兩種存在形式，差別在「它跟一個 Google 檔案綁不綁定」。</p>
<p><strong>容器綁定（container-bound）</strong> 的專案依附在一個具體檔案上——從某張試算表的 <code>擴充功能 → Apps Script</code> 開出來的專案，就綁定那張試算表。它的好處是程式裡用 <code>SpreadsheetApp.getActiveSpreadsheet()</code> 直接拿到那張表，不必記檔案 ID；流量統計用這種，程式跟資料表天生綁在一起，最省事。它也能存取容器檔案特有的事件（例如試算表的 <code>onEdit</code>、表單的 <code>onFormSubmit</code>）。</p>
<p><strong>獨立專案（standalone）</strong> 不依附任何檔案，從 <code>script.google.com</code> 直接建立。它適合「不特別綁一個檔案」的工具，或要跨多個檔案操作的情境；存取試算表要用 <code>SpreadsheetApp.openById(&quot;表的ID&quot;)</code> 明確指定。</p>
<p>選擇判準很直接：<strong>這段程式主要就是服務某一個檔案嗎</strong>——是（流量統計服務那張 log 表），用容器綁定；否（一個要操作很多表的通用工具），用獨立專案。</p>
<h2 id="用到的服務">用到的服務</h2>
<p>Apps Script 透過一組內建服務物件操作 Google 資源，這個案例會碰到的主要是：</p>
<ul>
<li><code>SpreadsheetApp</code>：讀寫試算表，<code>appendRow</code>、<code>getRange</code> 等，是資料的儲存層（模組三詳談）。</li>
<li><code>ContentService</code>：產生 web app 的回應內容，<code>doPost</code> 必須回傳它的輸出。</li>
<li><code>ScriptApp</code>：管理觸發器，時間排程彙總會用到（模組四）。</li>
<li><code>PropertiesService</code>：存少量 key-value 設定或狀態，適合放「上次處理到哪一列」這種跨執行要記住的小資料。</li>
</ul>
<p>這些服務都以你的 Google 帳號身分執行、受你的授權範圍約束，授權模型是下一篇<a href="/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">web app 部署模型</a>的主題。</p>
<h2 id="下一步">下一步</h2>
<p>知道 Apps Script 是什麼之後，要讓它能被 blog 的 beacon 打到，得把它部署成有公開網址的 web app。部署模型、<code>doGet</code>/<code>doPost</code>、以及授權流程，見<a href="/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">web app 部署模型</a>。</p>
]]></content:encoded></item><item><title>時間觸發器：把 raw log 彙總成日報</title><link>https://tarrragon.github.io/blog/automation/04-triggers-automation/time-driven-aggregation/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/04-triggers-automation/time-driven-aggregation/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/time-driven-trigger/" data-link-title="Time-Driven Trigger（時間觸發器）" data-link-desc="讓 Apps Script 在固定時間自動執行的排程機制，把被動等呼叫的膠水層變成主動定時跑的任務">時間觸發器&lt;/a>（time-driven trigger）讓 Apps Script 從「被動等 beacon 打進來」變成「主動定時執行」。它的用途是把 raw log 這種逐筆、直接看沒意義的原始資料，每天固定時間彙總成「昨天每篇看幾次」的日報，讓人打開試算表看到的是整理過的數字。先講怎麼設定定時、再講彙總邏輯、最後講怎麼在配額內寫得有效率。&lt;/p>
&lt;h2 id="設定每日定時執行">設定每日定時執行&lt;/h2>
&lt;p>觸發器可以用程式建立，也可以在編輯器左側的「觸發條件」介面點選建立。用程式建立的好處是設定跟著專案走、可版本控制：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">createDailyTrigger&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ScriptApp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">newTrigger&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;aggregateYesterday&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 class="p">.&lt;/span>&lt;span class="nx">timeBased&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">atHour&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// 每天凌晨 1 點
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">.&lt;/span>&lt;span class="nx">everyDays&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">create&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>這段執行一次（手動按執行），就註冊了一個「每天凌晨 1 點呼叫 &lt;code>aggregateYesterday&lt;/code>」的觸發器。凌晨執行是刻意的：那時流量低、raw log 當天的資料已經齊了，彙總前一天不會漏。要注意 &lt;code>createDailyTrigger&lt;/code> 只該跑一次——每跑一次就多註冊一個觸發器，重複跑會變成一天彙總很多次。管理現有觸發器用 &lt;code>ScriptApp.getProjectTriggers()&lt;/code> 查、&lt;code>deleteTrigger&lt;/code> 刪。&lt;/p>
&lt;h2 id="彙總邏輯">彙總邏輯&lt;/h2>
&lt;p>彙總做的事是：讀 raw log、把同一天同一路徑的瀏覽數出來、寫進另一張「日報」工作表。核心是一個 group by：&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">aggregateYesterday&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">ss&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">SpreadsheetApp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getActiveSpreadsheet&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">ss&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getSheetByName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;工作表1&amp;#34;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getDataRange&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">getValues&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="kd">var&lt;/span> &lt;span class="nx">report&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">ss&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getSheetByName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;日報&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nx">ss&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">insertSheet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;日報&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">counts&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{};&lt;/span> &lt;span class="c1">// key = &amp;#34;日期|路徑&amp;#34; → 次數
&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="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="c1">// 從第 2 列起，跳過標題
&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="kd">var&lt;/span> &lt;span class="nx">d&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&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"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">var&lt;/span> &lt;span class="nx">day&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">Utilities&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">formatDate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">d&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;Asia/Taipei&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;yyyy-MM-dd&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">day&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s2">&amp;#34;|&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">path&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">counts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">counts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&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">13&lt;/span>&lt;span class="cl"> &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="kd">var&lt;/span> &lt;span class="nx">rows&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">Object&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">keys&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">counts&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">map&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">k&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="kd">var&lt;/span> &lt;span class="nx">parts&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">k&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">split&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;|&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="nx">parts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nx">parts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nx">counts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">k&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">18&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">19&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">rows&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&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="nx">report&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getRange&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">report&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getLastRow&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">rows&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">setValues&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rows&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段 group by 有一個前提要明寫出來：&lt;strong>它假設 raw log 裡一列等於一次瀏覽&lt;/strong>。這在模組二的資料模型下成立，因為 beacon 只在頁面載入時送一次。日後若在 payload 加入其他事件型別（例如離開事件，見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">模組六&lt;/a>），每次瀏覽會產生多列，而這個迴圈仍然合法執行、只是把多數瀏覽數了不只一次——數字膨脹而沒有任何錯誤訊息。膨脹的倍率不固定（離開事件會丟失一部分），所以它比整數倍的錯誤更難從數字本身看出來。屆時的修法是在迴圈裡先篩事件型別：&lt;/p>





&lt;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="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">]&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="k">continue&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 只數進入事件（第 6 欄）
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把這個前提寫在程式碼旁邊，是為了讓資料模型變更的人搜尋得到所有依賴它的地方。這類「條件式合法執行但已換語意」的失效方式，見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷&lt;/a>。&lt;/p>
&lt;p>兩個實作決定值得說明。&lt;strong>日期用 &lt;code>Utilities.formatDate&lt;/code> 明確指定時區&lt;/strong>（這裡 &lt;code>Asia/Taipei&lt;/code>），否則跨午夜的資料可能因為時區偏移被算到錯的日子。&lt;strong>寫日報用 &lt;code>setValues&lt;/code> 一次寫一整塊、不用 &lt;code>appendRow&lt;/code> 逐列寫&lt;/strong>——彙總結果可能有幾十上百列，逐列 append 會慢且容易逼近執行時間，一次 &lt;code>setValues&lt;/code> 快得多（呼應&lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/append-and-concurrency/" data-link-title="寫入與並發：appendRow 與 LockService" data-link-desc="多個 beacon 同時寫進同一張 Sheet 時，appendRow 的競態風險與 LockService 序列化寫入的時機判斷">寫入與並發&lt;/a>講的批次寫入）。&lt;/p>
&lt;h2 id="在-90-分鐘配額內寫得有效率">在 90 分鐘配額內寫得有效率&lt;/h2>
&lt;p>觸發器受一條配額約束：個人帳號所有觸發器每天總執行時間上限 90 分鐘，且單次一樣不能超過 6 分鐘（見&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額&lt;/a>）。上面那段每次 &lt;code>getDataRange().getValues()&lt;/code> 把&lt;strong>整張 raw log&lt;/strong> 讀進來——log 還小時沒問題，但累積到數十萬列後，光讀取就可能逼近 6 分鐘。&lt;/p>
&lt;p>有效率的寫法是&lt;strong>只讀增量&lt;/strong>：記住「上次彙總處理到第幾列」，這次只讀新增的部分。用 &lt;code>PropertiesService&lt;/code> 存這個游標：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">props&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">PropertiesService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getScriptProperties&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">lastRow&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">Number&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">props&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;lastAggregatedRow&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">||&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">3&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">sheet&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">ss&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getSheetByName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;工作表1&amp;#34;&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="kd">var&lt;/span> &lt;span class="nx">newCount&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getLastRow&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="nx">lastRow&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="nx">newCount&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">fresh&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getRange&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lastRow&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">newCount&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getValues&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 5 = 目前的欄數
&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="c1">// ... 只彙總 fresh ...
&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">props&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;lastAggregatedRow&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">String&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getLastRow&lt;/span>&lt;span class="p">()));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&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>5&lt;/code> 是目前的欄數，它與前面那個「一列等於一次瀏覽」是同一種前提：欄位增加時要跟著改，而讀少了不會報錯、只會讓後面的欄位變成 &lt;code>undefined&lt;/code>。&lt;/p>
&lt;p>&lt;strong>兩個常數必須一起改，否則會互相抵銷。&lt;/strong> 前面那個篩選寫的是 &lt;code>raw[i][5] !== &amp;quot;view&amp;quot;&lt;/code>，查的是第六欄；這裡只讀五欄時 &lt;code>raw[i][5]&lt;/code> 恆為 &lt;code>undefined&lt;/code>，條件恆真、每一列都被跳過、日報變成空白。兩處相隔一節、都不產生錯誤，而症狀（日報是空的）指向的位置與成因所在的位置不同。&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;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>時間觸發器是「到點就跑」的排程。另一類觸發器是「某個事件發生就跑」，例如表單被提交——見&lt;a href="https://tarrragon.github.io/blog/automation/04-triggers-automation/form-and-event-triggers/" data-link-title="表單與事件觸發器" data-link-desc="由 Google 檔案事件（表單提交、試算表編輯）觸發的 Apps Script，以及 simple 與 installable 觸發器在權限上的差別">表單與事件觸發器&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/automation/knowledge-cards/time-driven-trigger/" data-link-title="Time-Driven Trigger（時間觸發器）" data-link-desc="讓 Apps Script 在固定時間自動執行的排程機制，把被動等呼叫的膠水層變成主動定時跑的任務">時間觸發器</a>（time-driven trigger）讓 Apps Script 從「被動等 beacon 打進來」變成「主動定時執行」。它的用途是把 raw log 這種逐筆、直接看沒意義的原始資料，每天固定時間彙總成「昨天每篇看幾次」的日報，讓人打開試算表看到的是整理過的數字。先講怎麼設定定時、再講彙總邏輯、最後講怎麼在配額內寫得有效率。</p>
<h2 id="設定每日定時執行">設定每日定時執行</h2>
<p>觸發器可以用程式建立，也可以在編輯器左側的「觸發條件」介面點選建立。用程式建立的好處是設定跟著專案走、可版本控制：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">createDailyTrigger</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">ScriptApp</span><span class="p">.</span><span class="nx">newTrigger</span><span class="p">(</span><span class="s2">&#34;aggregateYesterday&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="p">.</span><span class="nx">timeBased</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">.</span><span class="nx">atHour</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>          <span class="c1">// 每天凌晨 1 點
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>    <span class="p">.</span><span class="nx">everyDays</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">.</span><span class="nx">create</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>這段執行一次（手動按執行），就註冊了一個「每天凌晨 1 點呼叫 <code>aggregateYesterday</code>」的觸發器。凌晨執行是刻意的：那時流量低、raw log 當天的資料已經齊了，彙總前一天不會漏。要注意 <code>createDailyTrigger</code> 只該跑一次——每跑一次就多註冊一個觸發器，重複跑會變成一天彙總很多次。管理現有觸發器用 <code>ScriptApp.getProjectTriggers()</code> 查、<code>deleteTrigger</code> 刪。</p>
<h2 id="彙總邏輯">彙總邏輯</h2>
<p>彙總做的事是：讀 raw log、把同一天同一路徑的瀏覽數出來、寫進另一張「日報」工作表。核心是一個 group by：</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">aggregateYesterday</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">ss</span> <span class="o">=</span> <span class="nx">SpreadsheetApp</span><span class="p">.</span><span class="nx">getActiveSpreadsheet</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">var</span> <span class="nx">raw</span> <span class="o">=</span> <span class="nx">ss</span><span class="p">.</span><span class="nx">getSheetByName</span><span class="p">(</span><span class="s2">&#34;工作表1&#34;</span><span class="p">).</span><span class="nx">getDataRange</span><span class="p">().</span><span class="nx">getValues</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">report</span> <span class="o">=</span> <span class="nx">ss</span><span class="p">.</span><span class="nx">getSheetByName</span><span class="p">(</span><span class="s2">&#34;日報&#34;</span><span class="p">)</span> <span class="o">||</span> <span class="nx">ss</span><span class="p">.</span><span class="nx">insertSheet</span><span class="p">(</span><span class="s2">&#34;日報&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kd">var</span> <span class="nx">counts</span> <span class="o">=</span> <span class="p">{};</span> <span class="c1">// key = &#34;日期|路徑&#34; → 次數
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="k">for</span> <span class="p">(</span><span class="kd">var</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>      <span class="c1">// 從第 2 列起，跳過標題
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>    <span class="kd">var</span> <span class="nx">d</span> <span class="o">=</span> <span class="nx">raw</span><span class="p">[</span><span class="nx">i</span><span class="p">][</span><span class="mi">0</span><span class="p">];</span>                        <span class="c1">// 時間欄
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>    <span class="kd">var</span> <span class="nx">day</span> <span class="o">=</span> <span class="nx">Utilities</span><span class="p">.</span><span class="nx">formatDate</span><span class="p">(</span><span class="nx">d</span><span class="p">,</span> <span class="s2">&#34;Asia/Taipei&#34;</span><span class="p">,</span> <span class="s2">&#34;yyyy-MM-dd&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="kd">var</span> <span class="nx">path</span> <span class="o">=</span> <span class="nx">raw</span><span class="p">[</span><span class="nx">i</span><span class="p">][</span><span class="mi">1</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="kd">var</span> <span class="nx">key</span> <span class="o">=</span> <span class="nx">day</span> <span class="o">+</span> <span class="s2">&#34;|&#34;</span> <span class="o">+</span> <span class="nx">path</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">counts</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="nx">counts</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="o">||</span> <span class="mi">0</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <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="kd">var</span> <span class="nx">rows</span> <span class="o">=</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">keys</span><span class="p">(</span><span class="nx">counts</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">k</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="kd">var</span> <span class="nx">parts</span> <span class="o">=</span> <span class="nx">k</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="s2">&#34;|&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="nx">parts</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="nx">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="nx">counts</span><span class="p">[</span><span class="nx">k</span><span class="p">]];</span>   <span class="c1">// 日期、路徑、次數
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">rows</span><span class="p">.</span><span class="nx">length</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">report</span><span class="p">.</span><span class="nx">getRange</span><span class="p">(</span><span class="nx">report</span><span class="p">.</span><span class="nx">getLastRow</span><span class="p">()</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">rows</span><span class="p">.</span><span class="nx">length</span><span class="p">,</span> <span class="mi">3</span><span class="p">).</span><span class="nx">setValues</span><span class="p">(</span><span class="nx">rows</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段 group by 有一個前提要明寫出來：<strong>它假設 raw log 裡一列等於一次瀏覽</strong>。這在模組二的資料模型下成立，因為 beacon 只在頁面載入時送一次。日後若在 payload 加入其他事件型別（例如離開事件，見<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">模組六</a>），每次瀏覽會產生多列，而這個迴圈仍然合法執行、只是把多數瀏覽數了不只一次——數字膨脹而沒有任何錯誤訊息。膨脹的倍率不固定（離開事件會丟失一部分），所以它比整數倍的錯誤更難從數字本身看出來。屆時的修法是在迴圈裡先篩事件型別：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">raw</span><span class="p">[</span><span class="nx">i</span><span class="p">][</span><span class="mi">5</span><span class="p">]</span> <span class="o">!==</span> <span class="s2">&#34;view&#34;</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>   <span class="c1">// 只數進入事件（第 6 欄）
</span></span></span></code></pre></div><p>把這個前提寫在程式碼旁邊，是為了讓資料模型變更的人搜尋得到所有依賴它的地方。這類「條件式合法執行但已換語意」的失效方式，見<a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a>。</p>
<p>兩個實作決定值得說明。<strong>日期用 <code>Utilities.formatDate</code> 明確指定時區</strong>（這裡 <code>Asia/Taipei</code>），否則跨午夜的資料可能因為時區偏移被算到錯的日子。<strong>寫日報用 <code>setValues</code> 一次寫一整塊、不用 <code>appendRow</code> 逐列寫</strong>——彙總結果可能有幾十上百列，逐列 append 會慢且容易逼近執行時間，一次 <code>setValues</code> 快得多（呼應<a href="/blog/automation/03-sheet-as-database/append-and-concurrency/" data-link-title="寫入與並發：appendRow 與 LockService" data-link-desc="多個 beacon 同時寫進同一張 Sheet 時，appendRow 的競態風險與 LockService 序列化寫入的時機判斷">寫入與並發</a>講的批次寫入）。</p>
<h2 id="在-90-分鐘配額內寫得有效率">在 90 分鐘配額內寫得有效率</h2>
<p>觸發器受一條配額約束：個人帳號所有觸發器每天總執行時間上限 90 分鐘，且單次一樣不能超過 6 分鐘（見<a href="/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額</a>）。上面那段每次 <code>getDataRange().getValues()</code> 把<strong>整張 raw log</strong> 讀進來——log 還小時沒問題，但累積到數十萬列後，光讀取就可能逼近 6 分鐘。</p>
<p>有效率的寫法是<strong>只讀增量</strong>：記住「上次彙總處理到第幾列」，這次只讀新增的部分。用 <code>PropertiesService</code> 存這個游標：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">props</span> <span class="o">=</span> <span class="nx">PropertiesService</span><span class="p">.</span><span class="nx">getScriptProperties</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">lastRow</span> <span class="o">=</span> <span class="nb">Number</span><span class="p">(</span><span class="nx">props</span><span class="p">.</span><span class="nx">getProperty</span><span class="p">(</span><span class="s2">&#34;lastAggregatedRow&#34;</span><span class="p">)</span> <span class="o">||</span> <span class="mi">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">var</span> <span class="nx">sheet</span> <span class="o">=</span> <span class="nx">ss</span><span class="p">.</span><span class="nx">getSheetByName</span><span class="p">(</span><span class="s2">&#34;工作表1&#34;</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">newCount</span> <span class="o">=</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getLastRow</span><span class="p">()</span> <span class="o">-</span> <span class="nx">lastRow</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">newCount</span> <span class="o">&gt;</span> <span class="mi">0</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">fresh</span> <span class="o">=</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getRange</span><span class="p">(</span><span class="nx">lastRow</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">newCount</span><span class="p">,</span> <span class="mi">5</span><span class="p">).</span><span class="nx">getValues</span><span class="p">();</span>   <span class="c1">// 5 = 目前的欄數
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="c1">// ... 只彙總 fresh ...
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span>  <span class="nx">props</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s2">&#34;lastAggregatedRow&#34;</span><span class="p">,</span> <span class="nb">String</span><span class="p">(</span><span class="nx">sheet</span><span class="p">.</span><span class="nx">getLastRow</span><span class="p">()));</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>那個 <code>5</code> 是目前的欄數，它與前面那個「一列等於一次瀏覽」是同一種前提：欄位增加時要跟著改，而讀少了不會報錯、只會讓後面的欄位變成 <code>undefined</code>。</p>
<p><strong>兩個常數必須一起改，否則會互相抵銷。</strong> 前面那個篩選寫的是 <code>raw[i][5] !== &quot;view&quot;</code>，查的是第六欄；這裡只讀五欄時 <code>raw[i][5]</code> 恆為 <code>undefined</code>，條件恆真、每一列都被跳過、日報變成空白。兩處相隔一節、都不產生錯誤，而症狀（日報是空的）指向的位置與成因所在的位置不同。</p>
<p>只讀增量讓每次彙總的成本跟「昨天新增多少」成正比、而不是跟「歷史總量」成正比，執行時間就穩定、不隨資料累積膨脹。這跟<a href="/blog/automation/03-sheet-as-database/data-model-and-capacity/" data-link-title="資料模型與容量邊界" data-link-desc="raw log 表的欄位怎麼設計才好彙總、以及 Sheets 累積到多少列會開始撐不住、撐不住的訊號長什麼樣">資料模型與容量邊界</a>講的分表是互補的兩招——分表縮小單張表、只讀增量縮小單次讀取，都是為了讓彙總不被歷史總量拖垮。</p>
<h2 id="下一步">下一步</h2>
<p>時間觸發器是「到點就跑」的排程。另一類觸發器是「某個事件發生就跑」，例如表單被提交——見<a href="/blog/automation/04-triggers-automation/form-and-event-triggers/" data-link-title="表單與事件觸發器" data-link-desc="由 Google 檔案事件（表單提交、試算表編輯）觸發的 Apps Script，以及 simple 與 installable 觸發器在權限上的差別">表單與事件觸發器</a>。</p>
]]></content:encoded></item><item><title>部署與存取權限的安全含義</title><link>https://tarrragon.github.io/blog/automation/05-deploy-quota-security/deployment-and-access/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/05-deploy-quota-security/deployment-and-access/</guid><description>&lt;p>beacon 接收端要接住匿名訪客，就必須設成公開可存取，這帶來一組要理解清楚的安全含義。核心觀念是：這個端點的威脅模型不是「資料外洩」，而是「別人拿這個公開端點能做什麼」。先釐清兩個部署設定各自的安全角色，再界定實際的風險範圍。&lt;/p>
&lt;h2 id="execute-as-與-who-has-access-的分工">execute as 與 who has access 的分工&lt;/h2>
&lt;p>部署 web app 時的兩個設定，一個決定「用誰的權限跑」、一個決定「誰能呼叫」，安全含義不同。&lt;/p>
&lt;p>&lt;strong>execute as（執行身分）決定程式用誰的權限執行。&lt;/strong> 選「我」時，任何打進來的請求都以你的身分執行程式——這是必要的，因為匿名訪客沒有你試算表的寫入權，只有用你的身分才寫得進去。它的含義是：這支程式能碰的資料範圍等於你授權給它的範圍（那一張試算表），而不是呼叫者的權限。所以程式裡&lt;strong>只該做你願意讓匿名請求觸發的事&lt;/strong>——&lt;code>doPost&lt;/code> 寫一列 log 是安全的，但如果程式裡寫了「刪除整張表」的邏輯，那也會用你的權限被匿名觸發。&lt;/p>
&lt;p>&lt;strong>who has access（誰可以存取）決定誰能呼叫這個網址。&lt;/strong> 接匿名 beacon 必須選「所有人」（完全不需登入），這也表示網址一旦洩漏（而它必然公開，見下一段），任何人都能呼叫。這兩個設定合起來的效果是：&lt;strong>一個任何人都能觸發、但只會執行你寫的那幾行、且以你的身分操作你授權的那張表的端點。&lt;/strong>&lt;/p>
&lt;h2 id="這個網址必然公開">這個網址必然公開&lt;/h2>
&lt;p>beacon 端點的網址會被寫進每一頁的 client-side JavaScript，任何人檢視原始碼都看得到。它藏不住，這是 client beacon 架構的本質、不是設定失誤——GA、Cloudflare Web Analytics 那些的收集端點同樣是公開的。所以安全策略不能建立在「保密網址」上，而要建立在「限制這個公開端點能造成的破壞」上。&lt;/p>
&lt;p>這也界定了什麼&lt;strong>不是&lt;/strong>風險：別人拿到網址讀不到你的 Sheet，因為 &lt;code>doPost&lt;/code> 只做 &lt;code>appendRow&lt;/code> 然後回一個固定的 &lt;code>{ok:true}&lt;/code>，不回傳任何試算表內容；他也碰不到你其他 Google 資料，因為授權範圍只綁那張表。端點是「只能寫、寫入內容固定、回應不洩漏」的，這個形狀本身就把資料外洩排除了。&lt;/p>
&lt;h2 id="實際的風險範圍">實際的風險範圍&lt;/h2>
&lt;p>公開端點的真實風險是騷擾型的，有兩種。&lt;strong>髒資料&lt;/strong>：有人直接對網址 POST 任意內容，往你的 log 塞垃圾列、污染統計。&lt;strong>配額消耗&lt;/strong>：有人狂打這個端點，吃掉你的執行配額，嚴重時排擠正常 beacon（配額的細節見&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;p>這兩種風險的共通點是它們&lt;strong>不會洩漏或破壞你的資料，只會污染統計或耗資源&lt;/strong>。對沒沒無聞的個人 blog，攻擊者缺乏動機花力氣灌一個小站的瀏覽計數，實際發生機率低。所以務實的姿態是：先讓公開端點跑著、把「限制破壞範圍」的保護當成「遇到再加」的選項，而不是上線前就必備。具體的防護手段在下一篇。&lt;/p>
&lt;h2 id="更新部署不換網址安全角度">更新部署不換網址（安全角度）&lt;/h2>
&lt;p>日常維護會反覆改 &lt;code>doPost&lt;/code>，改完要讓 &lt;code>/exec&lt;/code> 反映新版本、而網址不變——用「管理部署作業 → 編輯 → 版本選新版本」，不要用「新增部署作業」（那會產生新網址）。這個操作除了避免假故障（見&lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">web app 部署模型&lt;/a>），也有安全意義：換網址意味著舊網址可能還殘留在快取的頁面裡繼續被打，管理起來多一個要追蹤的公開端點。用同一個部署更新，公開端點就始終只有一個、行為可控。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>存取權限的安全框架清楚後，配額實際會怎麼碰撞、怎麼擋濫用、隱私怎麼守、以及量大到該遷移的訊號，見&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">配額、濫用與隱私&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>beacon 接收端要接住匿名訪客，就必須設成公開可存取，這帶來一組要理解清楚的安全含義。核心觀念是：這個端點的威脅模型不是「資料外洩」，而是「別人拿這個公開端點能做什麼」。先釐清兩個部署設定各自的安全角色，再界定實際的風險範圍。</p>
<h2 id="execute-as-與-who-has-access-的分工">execute as 與 who has access 的分工</h2>
<p>部署 web app 時的兩個設定，一個決定「用誰的權限跑」、一個決定「誰能呼叫」，安全含義不同。</p>
<p><strong>execute as（執行身分）決定程式用誰的權限執行。</strong> 選「我」時，任何打進來的請求都以你的身分執行程式——這是必要的，因為匿名訪客沒有你試算表的寫入權，只有用你的身分才寫得進去。它的含義是：這支程式能碰的資料範圍等於你授權給它的範圍（那一張試算表），而不是呼叫者的權限。所以程式裡<strong>只該做你願意讓匿名請求觸發的事</strong>——<code>doPost</code> 寫一列 log 是安全的，但如果程式裡寫了「刪除整張表」的邏輯，那也會用你的權限被匿名觸發。</p>
<p><strong>who has access（誰可以存取）決定誰能呼叫這個網址。</strong> 接匿名 beacon 必須選「所有人」（完全不需登入），這也表示網址一旦洩漏（而它必然公開，見下一段），任何人都能呼叫。這兩個設定合起來的效果是：<strong>一個任何人都能觸發、但只會執行你寫的那幾行、且以你的身分操作你授權的那張表的端點。</strong></p>
<h2 id="這個網址必然公開">這個網址必然公開</h2>
<p>beacon 端點的網址會被寫進每一頁的 client-side JavaScript，任何人檢視原始碼都看得到。它藏不住，這是 client beacon 架構的本質、不是設定失誤——GA、Cloudflare Web Analytics 那些的收集端點同樣是公開的。所以安全策略不能建立在「保密網址」上，而要建立在「限制這個公開端點能造成的破壞」上。</p>
<p>這也界定了什麼<strong>不是</strong>風險：別人拿到網址讀不到你的 Sheet，因為 <code>doPost</code> 只做 <code>appendRow</code> 然後回一個固定的 <code>{ok:true}</code>，不回傳任何試算表內容；他也碰不到你其他 Google 資料，因為授權範圍只綁那張表。端點是「只能寫、寫入內容固定、回應不洩漏」的，這個形狀本身就把資料外洩排除了。</p>
<h2 id="實際的風險範圍">實際的風險範圍</h2>
<p>公開端點的真實風險是騷擾型的，有兩種。<strong>髒資料</strong>：有人直接對網址 POST 任意內容，往你的 log 塞垃圾列、污染統計。<strong>配額消耗</strong>：有人狂打這個端點，吃掉你的執行配額，嚴重時排擠正常 beacon（配額的細節見<a href="/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">配額、濫用與隱私</a>）。</p>
<p>這兩種風險的共通點是它們<strong>不會洩漏或破壞你的資料，只會污染統計或耗資源</strong>。對沒沒無聞的個人 blog，攻擊者缺乏動機花力氣灌一個小站的瀏覽計數，實際發生機率低。所以務實的姿態是：先讓公開端點跑著、把「限制破壞範圍」的保護當成「遇到再加」的選項，而不是上線前就必備。具體的防護手段在下一篇。</p>
<h2 id="更新部署不換網址安全角度">更新部署不換網址（安全角度）</h2>
<p>日常維護會反覆改 <code>doPost</code>，改完要讓 <code>/exec</code> 反映新版本、而網址不變——用「管理部署作業 → 編輯 → 版本選新版本」，不要用「新增部署作業」（那會產生新網址）。這個操作除了避免假故障（見<a href="/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">web app 部署模型</a>），也有安全意義：換網址意味著舊網址可能還殘留在快取的頁面裡繼續被打，管理起來多一個要追蹤的公開端點。用同一個部署更新，公開端點就始終只有一個、行為可控。</p>
<h2 id="下一步">下一步</h2>
<p>存取權限的安全框架清楚後，配額實際會怎麼碰撞、怎麼擋濫用、隱私怎麼守、以及量大到該遷移的訊號，見<a href="/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">配額、濫用與隱私</a>。</p>
]]></content:encoded></item><item><title>Web App Deployment（Web App 部署）</title><link>https://tarrragon.github.io/blog/automation/knowledge-cards/web-app-deployment/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/knowledge-cards/web-app-deployment/</guid><description>&lt;p>Web app 部署是把一段 Apps Script 程式掛成「有公開網址、可被 HTTP 呼叫」的端點的動作。部署前程式只能在編輯器裡手動執行；部署後它得到一個 &lt;code>https://script.google.com/macros/s/.../exec&lt;/code> 網址，任何符合存取權限的請求打這個網址就會觸發 &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 請求，決定端點收到請求時執行什麼">doGet 或 doPost&lt;/a>。這是讓 Apps Script 能當 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 接收端的前提。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>部署有兩個決定端點行為的設定。&lt;strong>執行身分（execute as）&lt;/strong> 決定程式用誰的權限跑：選「我」時，匿名訪客送來的請求也用你的身分執行，才能存取你的試算表。&lt;strong>誰可存取（who has access）&lt;/strong> 決定誰能呼叫這個網址：接收匿名訪客的 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 必須選「所有人」，因為訪客沒有登入 Google。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>一個實務要點是部署與網址的關係：每次「新增部署作業」會產生一個新網址，但改完程式後應該用「管理部署作業」更新既有部署，網址才不變。用錯方式會讓前端指向舊網址、以為程式沒生效。&lt;/p>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>誰可存取設為「所有人」後，要判斷的是匿名端點被濫用的風險有多大：存取權限的安全含義見&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Web app 部署是把一段 Apps Script 程式掛成「有公開網址、可被 HTTP 呼叫」的端點的動作。部署前程式只能在編輯器裡手動執行；部署後它得到一個 <code>https://script.google.com/macros/s/.../exec</code> 網址，任何符合存取權限的請求打這個網址就會觸發 <a href="/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">doGet 或 doPost</a>。這是讓 Apps Script 能當 <a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon</a> 接收端的前提。</p>
<h2 id="概念位置">概念位置</h2>
<p>部署有兩個決定端點行為的設定。<strong>執行身分（execute as）</strong> 決定程式用誰的權限跑：選「我」時，匿名訪客送來的請求也用你的身分執行，才能存取你的試算表。<strong>誰可存取（who has access）</strong> 決定誰能呼叫這個網址：接收匿名訪客的 <a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon</a> 必須選「所有人」，因為訪客沒有登入 Google。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>一個實務要點是部署與網址的關係：每次「新增部署作業」會產生一個新網址，但改完程式後應該用「管理部署作業」更新既有部署，網址才不變。用錯方式會讓前端指向舊網址、以為程式沒生效。</p>
<h2 id="判讀方式">判讀方式</h2>
<p>誰可存取設為「所有人」後，要判斷的是匿名端點被濫用的風險有多大：存取權限的安全含義見<a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五</a>。</p>
]]></content:encoded></item><item><title>web app 部署模型與授權</title><link>https://tarrragon.github.io/blog/automation/01-apps-script-basics/web-app-deployment-model/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/01-apps-script-basics/web-app-deployment-model/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署&lt;/a>是把 Apps Script 從「只能在編輯器裡手動執行」變成「有公開網址、任何 HTTP 請求都能觸發」的動作。這是讓 blog 的 beacon 能打到接收端的前提。這一篇講三件事：程式怎麼接住請求（&lt;code>doGet&lt;/code>/&lt;code>doPost&lt;/code>）、部署產生的兩種網址差在哪、以及授權為什麼第一次會跳警告。&lt;/p>
&lt;h2 id="doget-與-dopost兩個進入點">doGet 與 doPost：兩個進入點&lt;/h2>
&lt;p>web app 對外的行為由兩個特殊函式定義。收到 GET 請求時，Google 平台呼叫 &lt;code>doGet(e)&lt;/code>；收到 POST 請求時呼叫 &lt;code>doPost(e)&lt;/code>。參數 &lt;code>e&lt;/code> 帶著請求內容：&lt;code>doGet&lt;/code> 從 &lt;code>e.parameter&lt;/code> 拿 query string，&lt;code>doPost&lt;/code> 從 &lt;code>e.postData.contents&lt;/code> 拿請求主體。流量統計的 beacon 用 &lt;code>sendBeacon&lt;/code> 送 POST，所以接收端實作 &lt;code>doPost&lt;/code>，從 &lt;code>e.postData.contents&lt;/code> 讀那串 JSON 字串。&lt;/p>
&lt;p>兩個函式都必須回傳一個 &lt;code>ContentService&lt;/code> 或 &lt;code>HtmlService&lt;/code> 的輸出，這是平台的硬性要求——不回傳會被當成執行沒有正常結束。詳細用法見知識卡 &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 請求，決定端點收到請求時執行什麼">doGet / doPost&lt;/a>。&lt;/p>
&lt;p>值得先記住的一條限制是 Apps Script &lt;strong>沒有&lt;/strong> &lt;code>doOptions&lt;/code>，所以它無法回應跨網域請求的 CORS preflight。這條限制決定了前端 beacon 必須用不觸發 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>
&lt;h2 id="exec-與-dev兩種網址">exec 與 dev：兩種網址&lt;/h2>
&lt;p>部署 web app 後會遇到兩個結尾不同的網址，用途不一樣，搞混會在測試時卡住。&lt;/p>
&lt;p>&lt;code>/exec&lt;/code> 是&lt;strong>正式版網址&lt;/strong>：它對應你「部署」的那個版本、網址固定不變、遵守你設定的「誰可以存取」。blog 的 beacon 要填的是這個。&lt;code>/dev&lt;/code> 是&lt;strong>測試版網址&lt;/strong>：它永遠對應編輯器裡最新存檔的程式碼（不必重新部署就生效），但它只有對這個專案有編輯權的人（也就是你，登入狀態下）能存取。&lt;code>/dev&lt;/code> 適合你自己邊改邊測，&lt;code>/exec&lt;/code> 才是給匿名訪客用的。&lt;/p>
&lt;p>因為 &lt;code>/dev&lt;/code> 要登入、&lt;code>/exec&lt;/code> 才允許匿名，用 &lt;code>/dev&lt;/code> 當 beacon 端點會讓所有沒登入的訪客都被擋掉——這是一個容易誤用的點。beacon 一律用 &lt;code>/exec&lt;/code>。&lt;/p>
&lt;h2 id="更新部署為什麼要用同一個網址">更新部署為什麼要用同一個網址&lt;/h2>
&lt;p>改完程式後怎麼讓 &lt;code>/exec&lt;/code> 反映新版本，是另一個容易出錯的地方。Apps Script 有兩個看起來都能「部署」的入口：&lt;code>新增部署作業&lt;/code> 會產生一個&lt;strong>全新的&lt;/strong> &lt;code>/exec&lt;/code> 網址；&lt;code>管理部署作業 → 編輯 → 版本選「新版本」&lt;/code> 則是把&lt;strong>既有部署&lt;/strong>更新到新程式碼、&lt;strong>網址不變&lt;/strong>。&lt;/p>
&lt;p>正確做法是後者：第一次用「新增部署作業」拿到網址、填進 blog；之後每次改程式，都用「管理部署作業」更新同一個部署。如果每次都「新增部署作業」，會不斷產生新網址，而 blog 裡填的還是舊網址、指向舊版本的程式，於是「我明明改了程式怎麼沒生效」。記住這條分工，就避開了這個常見的假故障。&lt;/p>
&lt;h2 id="首次授權與未驗證警告">首次授權與未驗證警告&lt;/h2>
&lt;p>第一次部署（或第一次執行會存取你資料的程式）時，Google 會要求授權，流程中會出現一個「Google 尚未驗證這個應用程式」的警告畫面。這個警告是正常的：它出現的原因是這支腳本是你自己寫的、沒有經過 Google 的應用程式審核，而不是因為程式有問題。走「進階 → 前往（專案名稱）」繼續、再「允許」授予它存取你試算表的權限，就完成授權。&lt;/p>
&lt;p>授權授予的範圍只涵蓋程式實際用到的服務（這個案例是那一張試算表），不會給到你其他的 Google 資料。之後這支 web app 以你的身分執行，能做的事就是 &lt;code>doPost&lt;/code> 裡寫的那些。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>部署模型清楚後，就能把接收端實際做出來、部署、收到第一筆瀏覽——見&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;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署</a>是把 Apps Script 從「只能在編輯器裡手動執行」變成「有公開網址、任何 HTTP 請求都能觸發」的動作。這是讓 blog 的 beacon 能打到接收端的前提。這一篇講三件事：程式怎麼接住請求（<code>doGet</code>/<code>doPost</code>）、部署產生的兩種網址差在哪、以及授權為什麼第一次會跳警告。</p>
<h2 id="doget-與-dopost兩個進入點">doGet 與 doPost：兩個進入點</h2>
<p>web app 對外的行為由兩個特殊函式定義。收到 GET 請求時，Google 平台呼叫 <code>doGet(e)</code>；收到 POST 請求時呼叫 <code>doPost(e)</code>。參數 <code>e</code> 帶著請求內容：<code>doGet</code> 從 <code>e.parameter</code> 拿 query string，<code>doPost</code> 從 <code>e.postData.contents</code> 拿請求主體。流量統計的 beacon 用 <code>sendBeacon</code> 送 POST，所以接收端實作 <code>doPost</code>，從 <code>e.postData.contents</code> 讀那串 JSON 字串。</p>
<p>兩個函式都必須回傳一個 <code>ContentService</code> 或 <code>HtmlService</code> 的輸出，這是平台的硬性要求——不回傳會被當成執行沒有正常結束。詳細用法見知識卡 <a href="/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">doGet / doPost</a>。</p>
<p>值得先記住的一條限制是 Apps Script <strong>沒有</strong> <code>doOptions</code>，所以它無法回應跨網域請求的 CORS preflight。這條限制決定了前端 beacon 必須用不觸發 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>
<h2 id="exec-與-dev兩種網址">exec 與 dev：兩種網址</h2>
<p>部署 web app 後會遇到兩個結尾不同的網址，用途不一樣，搞混會在測試時卡住。</p>
<p><code>/exec</code> 是<strong>正式版網址</strong>：它對應你「部署」的那個版本、網址固定不變、遵守你設定的「誰可以存取」。blog 的 beacon 要填的是這個。<code>/dev</code> 是<strong>測試版網址</strong>：它永遠對應編輯器裡最新存檔的程式碼（不必重新部署就生效），但它只有對這個專案有編輯權的人（也就是你，登入狀態下）能存取。<code>/dev</code> 適合你自己邊改邊測，<code>/exec</code> 才是給匿名訪客用的。</p>
<p>因為 <code>/dev</code> 要登入、<code>/exec</code> 才允許匿名，用 <code>/dev</code> 當 beacon 端點會讓所有沒登入的訪客都被擋掉——這是一個容易誤用的點。beacon 一律用 <code>/exec</code>。</p>
<h2 id="更新部署為什麼要用同一個網址">更新部署為什麼要用同一個網址</h2>
<p>改完程式後怎麼讓 <code>/exec</code> 反映新版本，是另一個容易出錯的地方。Apps Script 有兩個看起來都能「部署」的入口：<code>新增部署作業</code> 會產生一個<strong>全新的</strong> <code>/exec</code> 網址；<code>管理部署作業 → 編輯 → 版本選「新版本」</code> 則是把<strong>既有部署</strong>更新到新程式碼、<strong>網址不變</strong>。</p>
<p>正確做法是後者：第一次用「新增部署作業」拿到網址、填進 blog；之後每次改程式，都用「管理部署作業」更新同一個部署。如果每次都「新增部署作業」，會不斷產生新網址，而 blog 裡填的還是舊網址、指向舊版本的程式，於是「我明明改了程式怎麼沒生效」。記住這條分工，就避開了這個常見的假故障。</p>
<h2 id="首次授權與未驗證警告">首次授權與未驗證警告</h2>
<p>第一次部署（或第一次執行會存取你資料的程式）時，Google 會要求授權，流程中會出現一個「Google 尚未驗證這個應用程式」的警告畫面。這個警告是正常的：它出現的原因是這支腳本是你自己寫的、沒有經過 Google 的應用程式審核，而不是因為程式有問題。走「進階 → 前往（專案名稱）」繼續、再「允許」授予它存取你試算表的權限，就完成授權。</p>
<p>授權授予的範圍只涵蓋程式實際用到的服務（這個案例是那一張試算表），不會給到你其他的 Google 資料。之後這支 web app 以你的身分執行，能做的事就是 <code>doPost</code> 裡寫的那些。</p>
<h2 id="下一步">下一步</h2>
<p>部署模型清楚後，就能把接收端實際做出來、部署、收到第一筆瀏覽——見<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>。部署設定「誰可以存取」的安全含義，見<a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五</a>。</p>
]]></content:encoded></item><item><title>免費額度的思考方式與工具選型</title><link>https://tarrragon.github.io/blog/automation/00-mental-model/free-tier-and-tool-choice/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/00-mental-model/free-tier-and-tool-choice/</guid><description>&lt;p>免費膠水層能不能撐住你的量，要看的限制不是「一天總共幾次」，而是「同一瞬間能有幾個請求在跑」。這兩者常被搞混，導致人用錯誤的方式估算容量。個人 blog 的流量幾乎不可能打爆總量限制，卻可能在某篇文章被分享的那一刻，短時間湧入的併發請求撞上併發上限。先建立正確的思考單位，才知道免費夠不夠、什麼時候要換工具。&lt;/p>
&lt;h2 id="免費額度該看併發不看總量">免費額度該看併發，不看總量&lt;/h2>
&lt;p>Google Apps Script 對個人（gmail.com）帳號的關鍵限制有這幾條：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>限制&lt;/th>
 &lt;th>個人帳號額度&lt;/th>
 &lt;th>對流量 beacon 的意義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>單次執行時間&lt;/td>
 &lt;td>6 分鐘 / 次&lt;/td>
 &lt;td>一次寫一筆瀏覽遠遠用不到，接住就寫、幾百毫秒結束&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同時併發執行&lt;/td>
 &lt;td>30 / 使用者&lt;/td>
 &lt;td>真正的天花板：同一瞬間最多 30 個 beacon 在處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>觸發器每日總時間&lt;/td>
 &lt;td>90 分鐘 / 天&lt;/td>
 &lt;td>影響的是排程彙總（模組四），不是接收 beacon&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對外 URL Fetch&lt;/td>
 &lt;td>20,000 次 / 天&lt;/td>
 &lt;td>膠水層主動打外部 API 才算，接收 beacon 不算&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>對一個接住 beacon、寫一筆進 Sheet 的膠水層，binding 的限制是&lt;strong>同時併發 30&lt;/strong>。這條線的意思是：只要不是在同一瞬間有超過 30 個人的瀏覽器同時打 beacon 進來，就不會撞牆。一篇文章一天被看一千次、但這一千次分散在整天，任何瞬間的併發都遠低於 30，完全沒問題。會出事的情境是「某篇文章上了熱門、一分鐘湧入幾百個瀏覽」——這時併發才可能逼近 30，多出來的請求會拿到錯誤。&lt;/p>
&lt;p>值得注意的是，Google 並沒有為個人帳號的 web app &lt;strong>每日呼叫次數&lt;/strong>公布一個硬性數字，所以正確的估算方式不是去湊「一天幾次」，而是問「我的流量會不會在某個瞬間有 30 個以上的同時請求」。對個人 blog，答案幾乎都是不會。&lt;/p>
&lt;h2 id="apps-script-vs-cloudflare-workers-的適用邊界">Apps Script vs Cloudflare Workers 的適用邊界&lt;/h2>
&lt;p>Apps Script 跟 Cloudflare Workers 都能當免費膠水層接住 beacon，但它們補的場景不一樣。選錯不會壞，但會讓你在錯的地方費力。&lt;/p>
&lt;p>&lt;strong>Apps Script 適合資料量小、需要人直接讀寫試算表的場景。&lt;/strong> 它最大的優勢是 Google Sheet 同時是儲存體跟儀表板——資料一 append 進去，你打開試算表就能看、能排序、能畫圖、能用樞紐分析，不必另外做前端。起步不需要信用卡、不需要買 domain、不需要學部署工具。代價是效能：每次執行有冷啟動延遲，Sheets 當資料庫在資料列數很多時讀寫會變慢，併發上限只有 30。對「一個人的 blog 想知道哪篇有人看」，這些代價都碰不到。&lt;/p>
&lt;p>&lt;strong>Cloudflare Workers 適合量體較大、需要低延遲的場景。&lt;/strong> 免費方案給到每天十萬次請求量級，冷啟動幾乎感覺不到，全球邊緣節點讓 beacon 延遲很低。代價是它沒有內建的試算表 UI——資料要存進搭配的 KV 或 D1（免費的 SQLite），看資料得自己寫查詢或做一個小前端。它更接近「一段真的後端程式」，彈性大但要自己搭的部分多。&lt;/p>
&lt;p>選型判準用一句話收斂：&lt;strong>先問資料要不要讓人打開試算表直接看、量會不會大到讓 Sheets 變慢。&lt;/strong> 想要試算表即儀表板、量不大——Apps Script。要低延遲或預期量大、能接受自己搭查詢介面——Workers。本指南先走 Apps Script，因為它把「看資料」這件事直接解決了，最適合第一次做流量統計、想快點看到成果的人。模組五會給出「Sheets 開始撐不住時怎麼判斷、怎麼往 Workers 遷移」的訊號與路徑。&lt;/p>
&lt;h2 id="自建與現成分析服務的差異">自建與現成分析服務的差異&lt;/h2>
&lt;p>前面比較的兩個選項都是「自己搭」。在動手之前值得先看一眼另一條路：Google Analytics、Plausible、Umami、GoatCounter 這類現成服務同樣是貼一段 JS 就開始收資料，而且不必自己處理接收端、儲存、退出機制與資料判讀。&lt;/p>
&lt;p>能力上的差異比想像中小。這些服務與自建同樣受&lt;strong>只看得到執行 JavaScript 的訪客&lt;/strong>這條限制，也同樣會被廣告與隱私擴充功能攔截——它們的網域出現在攔截清單上的機率甚至更高。真正的差別在三個地方：&lt;/p>
&lt;p>&lt;strong>資料的所在位置。&lt;/strong> 自建的資料在自己的試算表裡，想怎麼查就怎麼查、要保留多久就保留多久；託管服務的資料在對方的系統裡，導出格式與保留期限由對方決定。&lt;/p>
&lt;p>&lt;strong>要處理的工作量。&lt;/strong> 退出機制、欄位變更同步、判讀規則的維護在自建這側全部要自己做，這些工作在&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六&lt;/a>佔的篇幅比 beacon 本身還多。託管服務把這些都包好了。&lt;/p>
&lt;p>&lt;strong>理解的深度。&lt;/strong> 自己做過一次之後，「這個數字是怎麼來的、它漏掉了什麼」變成可以回答的問題。用託管服務時那些答案在對方的文件裡，而多數人不會去讀。&lt;/p>
&lt;p>判準因此是：想搞懂這套機制怎麼運作、或者資料必須在自己手上——自建。只想知道哪幾篇比較多人看——現成服務省下的工作比預期多，本指南反而是拿來理解它們在做什麼的參考。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>工具選定 Apps Script 後，先搞懂它的部署模型跟授權模型，才不會在模組二實作時卡在「為什麼我的網址打不通」。往&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;/p></description><content:encoded><![CDATA[<p>免費膠水層能不能撐住你的量，要看的限制不是「一天總共幾次」，而是「同一瞬間能有幾個請求在跑」。這兩者常被搞混，導致人用錯誤的方式估算容量。個人 blog 的流量幾乎不可能打爆總量限制，卻可能在某篇文章被分享的那一刻，短時間湧入的併發請求撞上併發上限。先建立正確的思考單位，才知道免費夠不夠、什麼時候要換工具。</p>
<h2 id="免費額度該看併發不看總量">免費額度該看併發，不看總量</h2>
<p>Google Apps Script 對個人（gmail.com）帳號的關鍵限制有這幾條：</p>
<table>
  <thead>
      <tr>
          <th>限制</th>
          <th>個人帳號額度</th>
          <th>對流量 beacon 的意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單次執行時間</td>
          <td>6 分鐘 / 次</td>
          <td>一次寫一筆瀏覽遠遠用不到，接住就寫、幾百毫秒結束</td>
      </tr>
      <tr>
          <td>同時併發執行</td>
          <td>30 / 使用者</td>
          <td>真正的天花板：同一瞬間最多 30 個 beacon 在處理</td>
      </tr>
      <tr>
          <td>觸發器每日總時間</td>
          <td>90 分鐘 / 天</td>
          <td>影響的是排程彙總（模組四），不是接收 beacon</td>
      </tr>
      <tr>
          <td>對外 URL Fetch</td>
          <td>20,000 次 / 天</td>
          <td>膠水層主動打外部 API 才算，接收 beacon 不算</td>
      </tr>
  </tbody>
</table>
<p>對一個接住 beacon、寫一筆進 Sheet 的膠水層，binding 的限制是<strong>同時併發 30</strong>。這條線的意思是：只要不是在同一瞬間有超過 30 個人的瀏覽器同時打 beacon 進來，就不會撞牆。一篇文章一天被看一千次、但這一千次分散在整天，任何瞬間的併發都遠低於 30，完全沒問題。會出事的情境是「某篇文章上了熱門、一分鐘湧入幾百個瀏覽」——這時併發才可能逼近 30，多出來的請求會拿到錯誤。</p>
<p>值得注意的是，Google 並沒有為個人帳號的 web app <strong>每日呼叫次數</strong>公布一個硬性數字，所以正確的估算方式不是去湊「一天幾次」，而是問「我的流量會不會在某個瞬間有 30 個以上的同時請求」。對個人 blog，答案幾乎都是不會。</p>
<h2 id="apps-script-vs-cloudflare-workers-的適用邊界">Apps Script vs Cloudflare Workers 的適用邊界</h2>
<p>Apps Script 跟 Cloudflare Workers 都能當免費膠水層接住 beacon，但它們補的場景不一樣。選錯不會壞，但會讓你在錯的地方費力。</p>
<p><strong>Apps Script 適合資料量小、需要人直接讀寫試算表的場景。</strong> 它最大的優勢是 Google Sheet 同時是儲存體跟儀表板——資料一 append 進去，你打開試算表就能看、能排序、能畫圖、能用樞紐分析，不必另外做前端。起步不需要信用卡、不需要買 domain、不需要學部署工具。代價是效能：每次執行有冷啟動延遲，Sheets 當資料庫在資料列數很多時讀寫會變慢，併發上限只有 30。對「一個人的 blog 想知道哪篇有人看」，這些代價都碰不到。</p>
<p><strong>Cloudflare Workers 適合量體較大、需要低延遲的場景。</strong> 免費方案給到每天十萬次請求量級，冷啟動幾乎感覺不到，全球邊緣節點讓 beacon 延遲很低。代價是它沒有內建的試算表 UI——資料要存進搭配的 KV 或 D1（免費的 SQLite），看資料得自己寫查詢或做一個小前端。它更接近「一段真的後端程式」，彈性大但要自己搭的部分多。</p>
<p>選型判準用一句話收斂：<strong>先問資料要不要讓人打開試算表直接看、量會不會大到讓 Sheets 變慢。</strong> 想要試算表即儀表板、量不大——Apps Script。要低延遲或預期量大、能接受自己搭查詢介面——Workers。本指南先走 Apps Script，因為它把「看資料」這件事直接解決了，最適合第一次做流量統計、想快點看到成果的人。模組五會給出「Sheets 開始撐不住時怎麼判斷、怎麼往 Workers 遷移」的訊號與路徑。</p>
<h2 id="自建與現成分析服務的差異">自建與現成分析服務的差異</h2>
<p>前面比較的兩個選項都是「自己搭」。在動手之前值得先看一眼另一條路：Google Analytics、Plausible、Umami、GoatCounter 這類現成服務同樣是貼一段 JS 就開始收資料，而且不必自己處理接收端、儲存、退出機制與資料判讀。</p>
<p>能力上的差異比想像中小。這些服務與自建同樣受<strong>只看得到執行 JavaScript 的訪客</strong>這條限制，也同樣會被廣告與隱私擴充功能攔截——它們的網域出現在攔截清單上的機率甚至更高。真正的差別在三個地方：</p>
<p><strong>資料的所在位置。</strong> 自建的資料在自己的試算表裡，想怎麼查就怎麼查、要保留多久就保留多久；託管服務的資料在對方的系統裡，導出格式與保留期限由對方決定。</p>
<p><strong>要處理的工作量。</strong> 退出機制、欄位變更同步、判讀規則的維護在自建這側全部要自己做，這些工作在<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六</a>佔的篇幅比 beacon 本身還多。託管服務把這些都包好了。</p>
<p><strong>理解的深度。</strong> 自己做過一次之後，「這個數字是怎麼來的、它漏掉了什麼」變成可以回答的問題。用託管服務時那些答案在對方的文件裡，而多數人不會去讀。</p>
<p>判準因此是：想搞懂這套機制怎麼運作、或者資料必須在自己手上——自建。只想知道哪幾篇比較多人看——現成服務省下的工作比預期多，本指南反而是拿來理解它們在做什麼的參考。</p>
<h2 id="下一步">下一步</h2>
<p>工具選定 Apps Script 後，先搞懂它的部署模型跟授權模型，才不會在模組二實作時卡在「為什麼我的網址打不通」。往<a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a>。</p>
]]></content:encoded></item><item><title>表單與事件觸發器</title><link>https://tarrragon.github.io/blog/automation/04-triggers-automation/form-and-event-triggers/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/04-triggers-automation/form-and-event-triggers/</guid><description>&lt;p>事件觸發器讓 Apps Script 在「某個 Google 檔案發生某件事」的當下自動執行，跟時間觸發器的「到點就跑」互補。最常用的是 &lt;code>onFormSubmit&lt;/code>（有人提交 Google 表單）跟 &lt;code>onEdit&lt;/code>（有人編輯試算表）。這一篇講事件觸發器能做什麼、以及一個容易踩的權限分界。&lt;/p>
&lt;h2 id="onformsubmit表單提交即時處理">onFormSubmit：表單提交即時處理&lt;/h2>
&lt;p>Google 表單本身會把回應存進一張試算表，但「存進去之後要做什麼」需要 Apps Script。&lt;code>onFormSubmit&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">onFormSubmit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&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">answers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">namedValues&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// { &amp;#34;Email&amp;#34;: [&amp;#34;a@b.com&amp;#34;], &amp;#34;問題&amp;#34;: [&amp;#34;內容&amp;#34;] }
&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="c1">// 例如：寄一封通知信給自己
&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="nx">MailApp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">sendEmail&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;you@gmail.com&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;新表單回應&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span 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 class="nx">answers&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這讓表單從「被動收集」變成「即時反應」——提交當下就寄通知、寫進別的表、或呼叫外部 API。對 blog 的延伸應用，例如做一個「訂閱通知」或「回饋表單」，&lt;code>onFormSubmit&lt;/code> 是接手處理的入口。要注意 &lt;code>MailApp.sendEmail&lt;/code> 受每日寄信配額約束（個人帳號每天 100 封收件人，見&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額&lt;/a>），高頻通知要留意。&lt;/p>
&lt;h2 id="onedit試算表被編輯">onEdit：試算表被編輯&lt;/h2>
&lt;p>&lt;code>onEdit&lt;/code> 在試算表任何一格被改動時觸發，拿得到改了哪一格、新值是什麼。它適合「維護試算表內部的衍生狀態」，例如某欄被填值時自動在旁邊算出對應結果。對流量統計這條主線用得不多，但它是 Sheets 自動化的常見工具，值得知道它存在。&lt;/p>
&lt;h2 id="simple-與-installable一個權限分界">simple 與 installable：一個權限分界&lt;/h2>
&lt;p>事件觸發器有兩種形式，差別在&lt;strong>能不能做需要授權的事&lt;/strong>，這是容易踩的分界。&lt;/p>
&lt;p>&lt;strong>Simple 觸發器&lt;/strong>是靠函式命名約定自動生效的：把函式命名為 &lt;code>onOpen&lt;/code>、&lt;code>onEdit&lt;/code> 這些保留名字，不必註冊就會在對應事件觸發。代價是它跑在受限環境裡——&lt;strong>不能做需要授權的操作&lt;/strong>，例如寄信、呼叫外部 URL、存取其他檔案。它適合純粹在當前檔案內、不碰外部的輕量反應。&lt;/p>
&lt;p>&lt;strong>Installable 觸發器&lt;/strong>是明確註冊的（用 &lt;code>ScriptApp.newTrigger(...).forSpreadsheet(...).onEdit().create()&lt;/code>，或在觸發條件介面建立），它以你的授權身分執行，&lt;strong>能做需要權限的操作&lt;/strong>（寄信、UrlFetch、跨檔案）。&lt;code>onFormSubmit&lt;/code> 要做寄信這類事，必須是 installable 的。&lt;/p>
&lt;p>判準很直接：&lt;strong>這個事件處理要不要碰當前檔案以外的東西&lt;/strong>——不要（只在表內算個值），simple 夠用；要（寄信、打 API、寫別的表），得用 installable。踩雷的典型情境是「我寫了一個 &lt;code>onFormSubmit&lt;/code> 想寄通知信，怎麼都沒寄出」——因為它被當成 simple 觸發器、而 simple 不能寄信；改成明確註冊的 installable 就好。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>觸發器（時間的與事件的）把統計從「手動查」變成「自動跑」。整套統計上線後怎麼守住配額、擋濫用、保持資料乾淨，見&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>事件觸發器讓 Apps Script 在「某個 Google 檔案發生某件事」的當下自動執行，跟時間觸發器的「到點就跑」互補。最常用的是 <code>onFormSubmit</code>（有人提交 Google 表單）跟 <code>onEdit</code>（有人編輯試算表）。這一篇講事件觸發器能做什麼、以及一個容易踩的權限分界。</p>
<h2 id="onformsubmit表單提交即時處理">onFormSubmit：表單提交即時處理</h2>
<p>Google 表單本身會把回應存進一張試算表，但「存進去之後要做什麼」需要 Apps Script。<code>onFormSubmit</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">onFormSubmit</span><span class="p">(</span><span class="nx">e</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">answers</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">namedValues</span><span class="p">;</span>        <span class="c1">// { &#34;Email&#34;: [&#34;a@b.com&#34;], &#34;問題&#34;: [&#34;內容&#34;] }
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="c1">// 例如：寄一封通知信給自己
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="nx">MailApp</span><span class="p">.</span><span class="nx">sendEmail</span><span class="p">(</span><span class="s2">&#34;you@gmail.com&#34;</span><span class="p">,</span> <span class="s2">&#34;新表單回應&#34;</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">answers</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這讓表單從「被動收集」變成「即時反應」——提交當下就寄通知、寫進別的表、或呼叫外部 API。對 blog 的延伸應用，例如做一個「訂閱通知」或「回饋表單」，<code>onFormSubmit</code> 是接手處理的入口。要注意 <code>MailApp.sendEmail</code> 受每日寄信配額約束（個人帳號每天 100 封收件人，見<a href="/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額</a>），高頻通知要留意。</p>
<h2 id="onedit試算表被編輯">onEdit：試算表被編輯</h2>
<p><code>onEdit</code> 在試算表任何一格被改動時觸發，拿得到改了哪一格、新值是什麼。它適合「維護試算表內部的衍生狀態」，例如某欄被填值時自動在旁邊算出對應結果。對流量統計這條主線用得不多，但它是 Sheets 自動化的常見工具，值得知道它存在。</p>
<h2 id="simple-與-installable一個權限分界">simple 與 installable：一個權限分界</h2>
<p>事件觸發器有兩種形式，差別在<strong>能不能做需要授權的事</strong>，這是容易踩的分界。</p>
<p><strong>Simple 觸發器</strong>是靠函式命名約定自動生效的：把函式命名為 <code>onOpen</code>、<code>onEdit</code> 這些保留名字，不必註冊就會在對應事件觸發。代價是它跑在受限環境裡——<strong>不能做需要授權的操作</strong>，例如寄信、呼叫外部 URL、存取其他檔案。它適合純粹在當前檔案內、不碰外部的輕量反應。</p>
<p><strong>Installable 觸發器</strong>是明確註冊的（用 <code>ScriptApp.newTrigger(...).forSpreadsheet(...).onEdit().create()</code>，或在觸發條件介面建立），它以你的授權身分執行，<strong>能做需要權限的操作</strong>（寄信、UrlFetch、跨檔案）。<code>onFormSubmit</code> 要做寄信這類事，必須是 installable 的。</p>
<p>判準很直接：<strong>這個事件處理要不要碰當前檔案以外的東西</strong>——不要（只在表內算個值），simple 夠用；要（寄信、打 API、寫別的表），得用 installable。踩雷的典型情境是「我寫了一個 <code>onFormSubmit</code> 想寄通知信，怎麼都沒寄出」——因為它被當成 simple 觸發器、而 simple 不能寄信；改成明確註冊的 installable 就好。</p>
<h2 id="下一步">下一步</h2>
<p>觸發器（時間的與事件的）把統計從「手動查」變成「自動跑」。整套統計上線後怎麼守住配額、擋濫用、保持資料乾淨，見<a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全</a>。</p>
]]></content:encoded></item><item><title>配額、濫用防護、隱私與遷移訊號</title><link>https://tarrragon.github.io/blog/automation/05-deploy-quota-security/quota-abuse-privacy/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/05-deploy-quota-security/quota-abuse-privacy/</guid><description>&lt;p>統計上線後長期要守住的是四件事：配額不被打爆、髒資料不污染統計、隱私邊界守住、以及在量成長到 Sheets 撐不住前認出訊號。這一篇把這四件事各給一個務實的判斷與做法，收在「什麼時候該換更重的工具」。&lt;/p>
&lt;h2 id="配額實際碰撞會怎樣">配額實際碰撞會怎樣&lt;/h2>
&lt;p>免費配額的三條線（單次 6 分鐘、同時併發 30、觸發器每日 90 分鐘，見&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額&lt;/a>）在正常個人 blog 幾乎碰不到，但知道碰到時的症狀有助於診斷。&lt;/p>
&lt;p>&lt;strong>同時併發 30&lt;/strong> 是接收 beacon 最可能先碰的線：某篇文章瞬間爆紅、一秒內湧入超過 30 個瀏覽，第 31 個以後的 &lt;code>doPost&lt;/code> 會拿到錯誤、那幾筆瀏覽漏記。症狀是「爆量時段的統計數字明顯偏低」。&lt;strong>單次 6 分鐘&lt;/strong> 接 beacon 用不到（寫一列幾百毫秒），但彙總 trigger 全表掃描在資料很多時會逼近，症狀是彙總 trigger 開始逾時失敗。&lt;strong>每日 90 分鐘&lt;/strong> 是所有觸發器加總，正常一天彙總一次遠遠用不完，除非彙總寫得很沒效率或觸發器被重複註冊。&lt;/p>
&lt;p>碰到併發上限的處理不是「調高配額」（個人帳號調不了），而是「削峰」——beacon 本來就是可容忍少量遺失的統計，爆量漏記幾筆不影響「哪篇熱門」的判斷；真的很在意，訊號就指向遷移到吞吐更高的 Workers。&lt;/p>
&lt;h2 id="濫用防護與資料乾淨度">濫用防護與資料乾淨度&lt;/h2>
&lt;p>公開端點的騷擾型風險（髒資料、配額消耗，見&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/deployment-and-access/" data-link-title="部署與存取權限的安全含義" data-link-desc="beacon 接收端設成匿名可存取時，execute as 與 who has access 各自的安全含義、以及這個公開端點實際能被拿來做什麼">部署與存取權限&lt;/a>），對應兩個層級的防護。&lt;/p>
&lt;p>&lt;strong>過濾自己的瀏覽&lt;/strong>是最先該做、報酬最高的一項——不是防外人，是防站主把開發與自我瀏覽混進統計。前端的 hostname guard 已經擋掉本機預覽（見&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&lt;/a>）；要進一步排除「在正式站上自己一直重整」，可以在瀏覽器存一個退出標記、beacon 讀到就不送，或彙總時依訪客識別碼過濾。兩者的取捨在於前者省下配額與列數、後者保留資料可回頭校正，而它們可以並用。退出標記的實作、邊界與各瀏覽器的差異見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out&lt;/a>——存標記的版本同時也是給讀者的退出入口，適合公開在網站說明裡。&lt;/p>
&lt;p>&lt;strong>擋隨手亂打&lt;/strong>用一個約定 token：前端 payload 放一個固定字串、&lt;code>doPost&lt;/code> 檢查對不上就丟掉不寫。這能擋掉不知情的爬蟲與隨手 POST，成本很低。但要誠實看待它的邊界：token 也在 client JS 裡看得到，鐵了心要灌的人抓一下原始碼就有——所以 token 是「擋雜訊」不是「擋攻擊」。對個人 blog，擋雜訊通常就夠了；真的被針對性灌爆，那是遷移到有更多防護手段（rate limiting、驗證）的平台的訊號。&lt;/p>
&lt;h2 id="隱私與-pii-邊界">隱私與 PII 邊界&lt;/h2>
&lt;p>這套統計在隱私上的立足點是&lt;strong>根本不收集個人身分資訊&lt;/strong>，這讓它天然乾淨。具體有三條線值得明確守住：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>不記 IP&lt;/strong>：Apps Script 的 web app 收不到訪客 IP，所以就算想記也記不到——這反而是好事，少一個 PII 來源。&lt;/li>
&lt;li>&lt;strong>裝置只送粗標籤&lt;/strong>：&lt;code>mobile&lt;/code> / &lt;code>tablet&lt;/code> / &lt;code>desktop&lt;/code> 三選一足以回答「用什麼裝置看」，不送完整 &lt;code>userAgent&lt;/code>（那帶版本等可組成&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/browser-fingerprint/" data-link-title="Browser Fingerprint（瀏覽器指紋）" data-link-desc="由多個單獨無害的瀏覽器環境屬性組合而成的裝置特徵集合，決定自建統計蒐集判別訊號時的能力上限">瀏覽器指紋&lt;/a>的細節）。&lt;/li>
&lt;li>&lt;strong>不放可識別個人的欄位&lt;/strong>：這一章講到的 payload 只有路徑、來源、語言、裝置，沒有任何綁到個人的東西。&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">模組六&lt;/a>會加上兩個隨機識別碼——它們同樣不含個人資訊，但確實會被寫進瀏覽器的儲存空間，下一段的同意機制討論就是為此。&lt;/li>
&lt;/ul>
&lt;p>不收 PII 讓「處理個資刪除請求」這類負擔不會發生，但&lt;strong>同意機制的判斷不是由 PII 決定的&lt;/strong>。歐盟 ePrivacy 指令第 5(3) 條管的是「在使用者終端設備儲存或讀取資訊」這個動作本身，與存的內容是否為個資無關——市面上宣稱免同意橫幅的分析服務（Plausible、Fathom）之所以能這樣宣稱，正是因為它們完全不在瀏覽器儲存任何識別碼。&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">模組六的訪客識別&lt;/a>在 &lt;code>localStorage&lt;/code> 存了兩個，因此那一步跨過了這條線。&lt;/p>
&lt;p>實務上個人網站的合規風險與商業追蹤網路不在同一個量級，但這是取捨不是豁免。想更保守，可以尊重瀏覽器的 Do Not Track 訊號（&lt;code>navigator.doNotTrack === &amp;quot;1&amp;quot;&lt;/code> 時不送 beacon），或者不存識別碼、只做頁面計數。&lt;/p>
&lt;h2 id="遷移訊號什麼時候該離開-sheets">遷移訊號：什麼時候該離開 Sheets&lt;/h2>
&lt;p>Apps Script + Sheets 是為「量小、要人直接看試算表」設計的（選型見&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;ul>
&lt;li>爆量時段頻繁撞併發上限、漏記明顯到影響判斷。&lt;/li>
&lt;li>彙總 trigger 即使分表、只讀增量後仍逼近 6 分鐘。&lt;/li>
&lt;li>raw log 的 cell 數逼近試算表上限（見&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;/li>
&lt;li>需要的查詢已經超出試算表能力（多維交叉、即時儀表板）。&lt;/li>
&lt;/ul>
&lt;p>遷移的方向是把接收端換成 Cloudflare Workers、儲存換成 D1（免費 SQLite）：吞吐與查詢能力遠高於 Sheets，代價是失去「打開試算表就能看」的便利、要自己做查詢介面。判準用一句話收斂：&lt;strong>當「Sheets 即儀表板」的便利已經被它的容量與吞吐限制抵銷，就是遷移的時機&lt;/strong>——在那之前，簡單版一直是對的選擇。&lt;/p>
&lt;h2 id="管線完成之後">管線完成之後&lt;/h2>
&lt;p>到這裡，一套不租主機、資料存在自己試算表裡的流量統計已經建好，而且知道它的配額邊界、防濫用手段與遷移訊號在哪。&lt;/p>
&lt;p>&lt;strong>管線能跑，不代表資料讀得出意義。&lt;/strong> 收到的來源網址大量空白、任兩列之間看不出是不是同一個人、真人與機器抓取混在一起——這些問題只在真實流量打進來之後才浮現，處理方式見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後&lt;/a>。那一章會在 payload 上增加欄位，本章的配額判斷也會因為列數翻倍而需要重算。&lt;/p>
&lt;p>想把同一套膠水模式套到別的靜態站或別的工具，回&lt;a href="https://tarrragon.github.io/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">模組零的選型&lt;/a>重新判斷即可。&lt;/p></description><content:encoded><![CDATA[<p>統計上線後長期要守住的是四件事：配額不被打爆、髒資料不污染統計、隱私邊界守住、以及在量成長到 Sheets 撐不住前認出訊號。這一篇把這四件事各給一個務實的判斷與做法，收在「什麼時候該換更重的工具」。</p>
<h2 id="配額實際碰撞會怎樣">配額實際碰撞會怎樣</h2>
<p>免費配額的三條線（單次 6 分鐘、同時併發 30、觸發器每日 90 分鐘，見<a href="/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額</a>）在正常個人 blog 幾乎碰不到，但知道碰到時的症狀有助於診斷。</p>
<p><strong>同時併發 30</strong> 是接收 beacon 最可能先碰的線：某篇文章瞬間爆紅、一秒內湧入超過 30 個瀏覽，第 31 個以後的 <code>doPost</code> 會拿到錯誤、那幾筆瀏覽漏記。症狀是「爆量時段的統計數字明顯偏低」。<strong>單次 6 分鐘</strong> 接 beacon 用不到（寫一列幾百毫秒），但彙總 trigger 全表掃描在資料很多時會逼近，症狀是彙總 trigger 開始逾時失敗。<strong>每日 90 分鐘</strong> 是所有觸發器加總，正常一天彙總一次遠遠用不完，除非彙總寫得很沒效率或觸發器被重複註冊。</p>
<p>碰到併發上限的處理不是「調高配額」（個人帳號調不了），而是「削峰」——beacon 本來就是可容忍少量遺失的統計，爆量漏記幾筆不影響「哪篇熱門」的判斷；真的很在意，訊號就指向遷移到吞吐更高的 Workers。</p>
<h2 id="濫用防護與資料乾淨度">濫用防護與資料乾淨度</h2>
<p>公開端點的騷擾型風險（髒資料、配額消耗，見<a href="/blog/automation/05-deploy-quota-security/deployment-and-access/" data-link-title="部署與存取權限的安全含義" data-link-desc="beacon 接收端設成匿名可存取時，execute as 與 who has access 各自的安全含義、以及這個公開端點實際能被拿來做什麼">部署與存取權限</a>），對應兩個層級的防護。</p>
<p><strong>過濾自己的瀏覽</strong>是最先該做、報酬最高的一項——不是防外人，是防站主把開發與自我瀏覽混進統計。前端的 hostname guard 已經擋掉本機預覽（見<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</a>）；要進一步排除「在正式站上自己一直重整」，可以在瀏覽器存一個退出標記、beacon 讀到就不送，或彙總時依訪客識別碼過濾。兩者的取捨在於前者省下配額與列數、後者保留資料可回頭校正，而它們可以並用。退出標記的實作、邊界與各瀏覽器的差異見<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out</a>——存標記的版本同時也是給讀者的退出入口，適合公開在網站說明裡。</p>
<p><strong>擋隨手亂打</strong>用一個約定 token：前端 payload 放一個固定字串、<code>doPost</code> 檢查對不上就丟掉不寫。這能擋掉不知情的爬蟲與隨手 POST，成本很低。但要誠實看待它的邊界：token 也在 client JS 裡看得到，鐵了心要灌的人抓一下原始碼就有——所以 token 是「擋雜訊」不是「擋攻擊」。對個人 blog，擋雜訊通常就夠了；真的被針對性灌爆，那是遷移到有更多防護手段（rate limiting、驗證）的平台的訊號。</p>
<h2 id="隱私與-pii-邊界">隱私與 PII 邊界</h2>
<p>這套統計在隱私上的立足點是<strong>根本不收集個人身分資訊</strong>，這讓它天然乾淨。具體有三條線值得明確守住：</p>
<ul>
<li><strong>不記 IP</strong>：Apps Script 的 web app 收不到訪客 IP，所以就算想記也記不到——這反而是好事，少一個 PII 來源。</li>
<li><strong>裝置只送粗標籤</strong>：<code>mobile</code> / <code>tablet</code> / <code>desktop</code> 三選一足以回答「用什麼裝置看」，不送完整 <code>userAgent</code>（那帶版本等可組成<a href="/blog/automation/knowledge-cards/browser-fingerprint/" data-link-title="Browser Fingerprint（瀏覽器指紋）" data-link-desc="由多個單獨無害的瀏覽器環境屬性組合而成的裝置特徵集合，決定自建統計蒐集判別訊號時的能力上限">瀏覽器指紋</a>的細節）。</li>
<li><strong>不放可識別個人的欄位</strong>：這一章講到的 payload 只有路徑、來源、語言、裝置，沒有任何綁到個人的東西。<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">模組六</a>會加上兩個隨機識別碼——它們同樣不含個人資訊，但確實會被寫進瀏覽器的儲存空間，下一段的同意機制討論就是為此。</li>
</ul>
<p>不收 PII 讓「處理個資刪除請求」這類負擔不會發生，但<strong>同意機制的判斷不是由 PII 決定的</strong>。歐盟 ePrivacy 指令第 5(3) 條管的是「在使用者終端設備儲存或讀取資訊」這個動作本身，與存的內容是否為個資無關——市面上宣稱免同意橫幅的分析服務（Plausible、Fathom）之所以能這樣宣稱，正是因為它們完全不在瀏覽器儲存任何識別碼。<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">模組六的訪客識別</a>在 <code>localStorage</code> 存了兩個，因此那一步跨過了這條線。</p>
<p>實務上個人網站的合規風險與商業追蹤網路不在同一個量級，但這是取捨不是豁免。想更保守，可以尊重瀏覽器的 Do Not Track 訊號（<code>navigator.doNotTrack === &quot;1&quot;</code> 時不送 beacon），或者不存識別碼、只做頁面計數。</p>
<h2 id="遷移訊號什麼時候該離開-sheets">遷移訊號：什麼時候該離開 Sheets</h2>
<p>Apps Script + Sheets 是為「量小、要人直接看試算表」設計的（選型見<a href="/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">模組零</a>）。以下訊號累積出現時，代表量已經長到該換更重的工具：</p>
<ul>
<li>爆量時段頻繁撞併發上限、漏記明顯到影響判斷。</li>
<li>彙總 trigger 即使分表、只讀增量後仍逼近 6 分鐘。</li>
<li>raw log 的 cell 數逼近試算表上限（見<a href="/blog/automation/03-sheet-as-database/data-model-and-capacity/" data-link-title="資料模型與容量邊界" data-link-desc="raw log 表的欄位怎麼設計才好彙總、以及 Sheets 累積到多少列會開始撐不住、撐不住的訊號長什麼樣">資料模型與容量邊界</a>）。</li>
<li>需要的查詢已經超出試算表能力（多維交叉、即時儀表板）。</li>
</ul>
<p>遷移的方向是把接收端換成 Cloudflare Workers、儲存換成 D1（免費 SQLite）：吞吐與查詢能力遠高於 Sheets，代價是失去「打開試算表就能看」的便利、要自己做查詢介面。判準用一句話收斂：<strong>當「Sheets 即儀表板」的便利已經被它的容量與吞吐限制抵銷，就是遷移的時機</strong>——在那之前，簡單版一直是對的選擇。</p>
<h2 id="管線完成之後">管線完成之後</h2>
<p>到這裡，一套不租主機、資料存在自己試算表裡的流量統計已經建好，而且知道它的配額邊界、防濫用手段與遷移訊號在哪。</p>
<p><strong>管線能跑，不代表資料讀得出意義。</strong> 收到的來源網址大量空白、任兩列之間看不出是不是同一個人、真人與機器抓取混在一起——這些問題只在真實流量打進來之後才浮現，處理方式見<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a>。那一章會在 payload 上增加欄位，本章的配額判斷也會因為列數翻倍而需要重算。</p>
<p>想把同一套膠水模式套到別的靜態站或別的工具，回<a href="/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">模組零的選型</a>重新判斷即可。</p>
]]></content:encoded></item><item><title>接收端 handler：寫進第一筆</title><link>https://tarrragon.github.io/blog/automation/02-analytics-beacon/receiver-handler/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/02-analytics-beacon/receiver-handler/</guid><description>&lt;p>接收端的責任是：接住前端送來的 beacon，把它解析成一筆結構化紀錄，安全地 append 進 Google Sheet，然後回一個簡短回應。這一篇從一張空白試算表開始，做到部署後打開試算表看到第一列瀏覽紀錄。前端那半見&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>，這裡假設 beacon 已經在送。&lt;/p>
&lt;h2 id="準備一張-sheet-當儲存體">準備一張 Sheet 當儲存體&lt;/h2>
&lt;p>先在 Google Sheets 建一張新試算表，命名例如 &lt;code>blog-analytics&lt;/code>。第一列放欄位標題，對應接收端要寫的欄位：&lt;code>時間&lt;/code>、&lt;code>路徑&lt;/code>、&lt;code>來源&lt;/code>、&lt;code>語言&lt;/code>、&lt;code>裝置&lt;/code>。這一列標題只是給人看的，接收端從第二列開始 append 資料。&lt;/p>
&lt;p>Apps Script 有兩種綁定方式，這裡用&lt;strong>容器綁定&lt;/strong>：直接在這張試算表的選單開 &lt;code>擴充功能 → Apps Script&lt;/code>，開出來的 script 專案天生就跟這張試算表綁在一起，程式裡用 &lt;code>SpreadsheetApp.getActiveSpreadsheet()&lt;/code> 就能拿到它，不必記試算表 ID。兩種綁定方式的差異、以及什麼時候該用獨立專案，見&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;/p>
&lt;h2 id="寫-dopost-接住-beacon">寫 doPost 接住 beacon&lt;/h2>
&lt;p>前端用 &lt;code>sendBeacon&lt;/code> 送 &lt;code>POST&lt;/code>，所以接收端要實作 &lt;code>doPost&lt;/code>。&lt;code>sendBeacon&lt;/code> 送來的內容放在 &lt;code>e.postData.contents&lt;/code>，是一個 JSON 字串（前端刻意用 &lt;code>text/plain&lt;/code> 傳輸、內容仍是 JSON），接收端 &lt;code>JSON.parse&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">doPost&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">data&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">parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">postData&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">contents&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">sheet&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">SpreadsheetApp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getActiveSpreadsheet&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">getSheetByName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;工作表1&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="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendRow&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">new&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="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">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="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"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ref&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="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">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">lang&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="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">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dev&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 裝置：mobile / tablet / desktop
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&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">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ContentService&lt;/span>
&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="nx">createTextOutput&lt;/span>&lt;span class="p">(&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 class="nx">ok&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">16&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">setMimeType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ContentService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MimeType&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JSON&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 class="k">catch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ContentService&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">createTextOutput&lt;/span>&lt;span class="p">(&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 class="nx">ok&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">error&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">String&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">}))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">setMimeType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ContentService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MimeType&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JSON&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個實作決定值得說明：&lt;/p>
&lt;p>&lt;strong>時間用伺服器的 &lt;code>new Date()&lt;/code>，不用前端傳的時間。&lt;/strong> 前端時間來自使用者的裝置，時區、時鐘準不準都不可控，還多一個可被偽造的欄位。接收端執行的當下時間就是最接近真實的瀏覽時間，用它最乾淨。這也是前端 payload 不需要送時間戳的原因。&lt;/p>
&lt;p>&lt;strong>每個欄位都用 &lt;code>|| &amp;quot;&amp;quot;&lt;/code> 給預設值。&lt;/strong> beacon 有可能因為前端狀況送來缺欄位的資料，&lt;code>data.path&lt;/code> 若是 &lt;code>undefined&lt;/code>，直接寫進 Sheet 會是空格但不會報錯；用 &lt;code>|| &amp;quot;&amp;quot;&lt;/code> 明確寫成空字串，讓資料形狀一致，之後彙總不會踩到 &lt;code>undefined&lt;/code>。&lt;/p>
&lt;p>&lt;strong>整段包在 &lt;code>try/catch&lt;/code> 裡。&lt;/strong> 接收端可能收到格式壞掉的請求（爬蟲亂打、payload 不是合法 JSON）。&lt;code>JSON.parse&lt;/code> 遇到壞資料會丟例外，沒接住的話這次執行算失敗、也可能讓錯誤累積。包起來後，壞請求就回一個 &lt;code>ok: false&lt;/code>、不影響服務。真正在意這些壞請求要不要防、怎麼防，見模組五。&lt;/p>
&lt;p>回應用 &lt;code>ContentService.createTextOutput&lt;/code> 回一小段 JSON。&lt;code>sendBeacon&lt;/code> 是 fire-and-forget、前端根本不會讀這個回應，所以回什麼不重要——但 &lt;code>doPost&lt;/code> 必須回一個 &lt;code>ContentService&lt;/code> 的輸出，這是 Apps Script web app 的硬性要求，不回會被當成執行沒有正常結束。&lt;/p>
&lt;h2 id="部署成-web-app">部署成 web app&lt;/h2>
&lt;p>程式寫好還打不通，因為它還沒有對外網址。在 Apps Script 編輯器右上角 &lt;code>部署 → 新增部署作業&lt;/code>，類型選 &lt;code>網頁應用程式&lt;/code>，兩個設定要對：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>執行身分（Execute as）&lt;/strong>：選「我」。beacon 是匿名訪客送來的，他們沒有你試算表的權限；選「我」讓這段程式用你的身分執行，才寫得進你的 Sheet。&lt;/li>
&lt;li>&lt;strong>誰可存取（Who has access）&lt;/strong>：選「所有人」。訪客是沒登入 Google 的匿名瀏覽器，只有「所有人」這個選項能讓匿名 beacon 打得進來。&lt;/li>
&lt;/ul>
&lt;p>部署後會拿到一個網址，形如 &lt;code>https://script.google.com/macros/s/AKfyc.../exec&lt;/code>。把這個網址填回前端 beacon 的 &lt;code>ENDPOINT&lt;/code>（見前端那半）。這兩個設定的安全含義——「所有人可存取」會不會被濫用、要不要加保護——在模組五完整討論；先讓它通，才有東西可以保護。&lt;/p>
&lt;h2 id="確認收到第一筆">確認收到第一筆&lt;/h2>
&lt;p>部署完、前端網址也填好後，打開你的 blog 任一頁（正式網域，不是本機預覽），beacon 就會送出。回到 Google Sheet，第二列應該出現一筆：伺服器時間、你剛看的路徑、來源、語言。看到這一列，整條 client beacon 管線就打通了——前端偵測、跨網域送達、接收端解析、寫進儲存體，每一環都在運作。&lt;/p></description><content:encoded><![CDATA[<p>接收端的責任是：接住前端送來的 beacon，把它解析成一筆結構化紀錄，安全地 append 進 Google Sheet，然後回一個簡短回應。這一篇從一張空白試算表開始，做到部署後打開試算表看到第一列瀏覽紀錄。前端那半見<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>，這裡假設 beacon 已經在送。</p>
<h2 id="準備一張-sheet-當儲存體">準備一張 Sheet 當儲存體</h2>
<p>先在 Google Sheets 建一張新試算表，命名例如 <code>blog-analytics</code>。第一列放欄位標題，對應接收端要寫的欄位：<code>時間</code>、<code>路徑</code>、<code>來源</code>、<code>語言</code>、<code>裝置</code>。這一列標題只是給人看的，接收端從第二列開始 append 資料。</p>
<p>Apps Script 有兩種綁定方式，這裡用<strong>容器綁定</strong>：直接在這張試算表的選單開 <code>擴充功能 → Apps Script</code>，開出來的 script 專案天生就跟這張試算表綁在一起，程式裡用 <code>SpreadsheetApp.getActiveSpreadsheet()</code> 就能拿到它，不必記試算表 ID。兩種綁定方式的差異、以及什麼時候該用獨立專案，見<a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a>。</p>
<h2 id="寫-dopost-接住-beacon">寫 doPost 接住 beacon</h2>
<p>前端用 <code>sendBeacon</code> 送 <code>POST</code>，所以接收端要實作 <code>doPost</code>。<code>sendBeacon</code> 送來的內容放在 <code>e.postData.contents</code>，是一個 JSON 字串（前端刻意用 <code>text/plain</code> 傳輸、內容仍是 JSON），接收端 <code>JSON.parse</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">doPost</span><span class="p">(</span><span class="nx">e</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">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">var</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">postData</span><span class="p">.</span><span class="nx">contents</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">var</span> <span class="nx">sheet</span> <span class="o">=</span> <span class="nx">SpreadsheetApp</span><span class="p">.</span><span class="nx">getActiveSpreadsheet</span><span class="p">().</span><span class="nx">getSheetByName</span><span class="p">(</span><span class="s2">&#34;工作表1&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">sheet</span><span class="p">.</span><span class="nx">appendRow</span><span class="p">([</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="k">new</span> <span class="nb">Date</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="nx">data</span><span class="p">.</span><span class="nx">path</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"> 9</span><span class="cl"><span class="c1"></span>      <span class="nx">data</span><span class="p">.</span><span class="nx">ref</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">10</span><span class="cl"><span class="c1"></span>      <span class="nx">data</span><span class="p">.</span><span class="nx">lang</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">11</span><span class="cl"><span class="c1"></span>      <span class="nx">data</span><span class="p">.</span><span class="nx">dev</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>      <span class="c1">// 裝置：mobile / tablet / desktop
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>    <span class="p">]);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="nx">ContentService</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="p">.</span><span class="nx">createTextOutput</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="nx">ok</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}))</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="p">.</span><span class="nx">setMimeType</span><span class="p">(</span><span class="nx">ContentService</span><span class="p">.</span><span class="nx">MimeType</span><span class="p">.</span><span class="nx">JSON</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="nx">ContentService</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="p">.</span><span class="nx">createTextOutput</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="nx">ok</span><span class="o">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">error</span><span class="o">:</span> <span class="nb">String</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">}))</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">      <span class="p">.</span><span class="nx">setMimeType</span><span class="p">(</span><span class="nx">ContentService</span><span class="p">.</span><span class="nx">MimeType</span><span class="p">.</span><span class="nx">JSON</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>三個實作決定值得說明：</p>
<p><strong>時間用伺服器的 <code>new Date()</code>，不用前端傳的時間。</strong> 前端時間來自使用者的裝置，時區、時鐘準不準都不可控，還多一個可被偽造的欄位。接收端執行的當下時間就是最接近真實的瀏覽時間，用它最乾淨。這也是前端 payload 不需要送時間戳的原因。</p>
<p><strong>每個欄位都用 <code>|| &quot;&quot;</code> 給預設值。</strong> beacon 有可能因為前端狀況送來缺欄位的資料，<code>data.path</code> 若是 <code>undefined</code>，直接寫進 Sheet 會是空格但不會報錯；用 <code>|| &quot;&quot;</code> 明確寫成空字串，讓資料形狀一致，之後彙總不會踩到 <code>undefined</code>。</p>
<p><strong>整段包在 <code>try/catch</code> 裡。</strong> 接收端可能收到格式壞掉的請求（爬蟲亂打、payload 不是合法 JSON）。<code>JSON.parse</code> 遇到壞資料會丟例外，沒接住的話這次執行算失敗、也可能讓錯誤累積。包起來後，壞請求就回一個 <code>ok: false</code>、不影響服務。真正在意這些壞請求要不要防、怎麼防，見模組五。</p>
<p>回應用 <code>ContentService.createTextOutput</code> 回一小段 JSON。<code>sendBeacon</code> 是 fire-and-forget、前端根本不會讀這個回應，所以回什麼不重要——但 <code>doPost</code> 必須回一個 <code>ContentService</code> 的輸出，這是 Apps Script web app 的硬性要求，不回會被當成執行沒有正常結束。</p>
<h2 id="部署成-web-app">部署成 web app</h2>
<p>程式寫好還打不通，因為它還沒有對外網址。在 Apps Script 編輯器右上角 <code>部署 → 新增部署作業</code>，類型選 <code>網頁應用程式</code>，兩個設定要對：</p>
<ul>
<li><strong>執行身分（Execute as）</strong>：選「我」。beacon 是匿名訪客送來的，他們沒有你試算表的權限；選「我」讓這段程式用你的身分執行，才寫得進你的 Sheet。</li>
<li><strong>誰可存取（Who has access）</strong>：選「所有人」。訪客是沒登入 Google 的匿名瀏覽器，只有「所有人」這個選項能讓匿名 beacon 打得進來。</li>
</ul>
<p>部署後會拿到一個網址，形如 <code>https://script.google.com/macros/s/AKfyc.../exec</code>。把這個網址填回前端 beacon 的 <code>ENDPOINT</code>（見前端那半）。這兩個設定的安全含義——「所有人可存取」會不會被濫用、要不要加保護——在模組五完整討論；先讓它通，才有東西可以保護。</p>
<h2 id="確認收到第一筆">確認收到第一筆</h2>
<p>部署完、前端網址也填好後，打開你的 blog 任一頁（正式網域，不是本機預覽），beacon 就會送出。回到 Google Sheet，第二列應該出現一筆：伺服器時間、你剛看的路徑、來源、語言。看到這一列，整條 client beacon 管線就打通了——前端偵測、跨網域送達、接收端解析、寫進儲存體，每一環都在運作。</p>
<p>如果沒出現，最常見的兩個原因：一是前端 <code>ENDPOINT</code> 填成舊的部署網址（每次「新增部署作業」網址會變，改程式後要用「管理部署作業」更新同一個部署、網址才不變），二是部署的存取權限沒設成「所有人」導致匿名 beacon 被擋。這兩個都是設定問題，不是程式問題。</p>
<h2 id="下一步">下一步</h2>
<p>第一筆進來了，往下有兩條路。</p>
<p>想知道<strong>這些資料怎麼讀出意義</strong>——來源網址為什麼大量空白、怎麼判斷兩列是不是同一個人、哪些是機器抓取——走<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a>。那一章會在 payload 上增加欄位，屆時這裡的 <code>appendRow</code> 與試算表的表頭要跟著改，改法在該章的<a href="/blog/automation/06-reading-the-data/#%e6%ac%84%e4%bd%8d%e8%ae%8a%e6%9b%b4%e8%a6%81%e5%90%8c%e6%ad%a5%e5%88%b0%e6%8e%a5%e6%94%b6%e7%ab%af" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">欄位變更清單</a>。</p>
<p>想先把<strong>儲存層打穩</strong>——多個瀏覽在同一瞬間打進來時 <code>appendRow</code> 會不會互相覆蓋、資料列累積到幾萬列時 Sheets 還撐不撐得住——走<a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫</a>。</p>
]]></content:encoded></item><item><title>模組一：Apps Script 地基</title><link>https://tarrragon.github.io/blog/automation/01-apps-script-basics/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/01-apps-script-basics/</guid><description>&lt;p>回答「Apps Script 到底是什麼、它跟一台伺服器差在哪」。這一章打的是地基：&lt;code>doGet&lt;/code>/&lt;code>doPost&lt;/code> 進入點、部署成 web app 的模型、以及「執行身分」與「誰可存取」這兩個授權設定的含義。把地基搞懂，模組二做 beacon 時遇到的網址與權限狀況就不再是黑盒。&lt;/p>
&lt;p>Apps Script 是 Google 托管的 JavaScript 執行環境，跑在 V8 runtime 上，對個人帳號免費。它跟一般伺服器最大的差別是沒有一台常駐主機——程式只在被呼叫時執行、跑完就休眠，你不必管作業系統、不必管開機關機、不必付閒置費用。代價是它有執行時間與併發上限（見&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;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/01-apps-script-basics/what-is-apps-script/" data-link-title="Apps Script 是什麼、跟一般伺服器差在哪" data-link-desc="把 Apps Script 定位成 Google 托管的無主機 JS 執行環境時，容器綁定與獨立專案的差別、以及沒有常駐程序帶來的取捨">Apps Script 是什麼、跟一般伺服器差在哪&lt;/a>&lt;/td>
 &lt;td>無主機執行模型、容器綁定 vs 獨立專案、沒有常駐程序的取捨、用到的服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">web app 部署模型與授權&lt;/a>&lt;/td>
 &lt;td>&lt;code>doGet&lt;/code>/&lt;code>doPost&lt;/code>、&lt;code>exec&lt;/code> 與 &lt;code>dev&lt;/code> 兩種網址、更新部署不換網址、首次授權警告&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/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">模組零：免費額度的思考方式與工具選型&lt;/a>：為什麼選 Apps Script&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作&lt;/a>：把地基用在實作上&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「Apps Script 到底是什麼、它跟一台伺服器差在哪」。這一章打的是地基：<code>doGet</code>/<code>doPost</code> 進入點、部署成 web app 的模型、以及「執行身分」與「誰可存取」這兩個授權設定的含義。把地基搞懂，模組二做 beacon 時遇到的網址與權限狀況就不再是黑盒。</p>
<p>Apps Script 是 Google 托管的 JavaScript 執行環境，跑在 V8 runtime 上，對個人帳號免費。它跟一般伺服器最大的差別是沒有一台常駐主機——程式只在被呼叫時執行、跑完就休眠，你不必管作業系統、不必管開機關機、不必付閒置費用。代價是它有執行時間與併發上限（見<a href="/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">模組零：免費額度的思考方式</a>）。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/01-apps-script-basics/what-is-apps-script/" data-link-title="Apps Script 是什麼、跟一般伺服器差在哪" data-link-desc="把 Apps Script 定位成 Google 托管的無主機 JS 執行環境時，容器綁定與獨立專案的差別、以及沒有常駐程序帶來的取捨">Apps Script 是什麼、跟一般伺服器差在哪</a></td>
          <td>無主機執行模型、容器綁定 vs 獨立專案、沒有常駐程序的取捨、用到的服務</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">web app 部署模型與授權</a></td>
          <td><code>doGet</code>/<code>doPost</code>、<code>exec</code> 與 <code>dev</code> 兩種網址、更新部署不換網址、首次授權警告</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">模組零：免費額度的思考方式與工具選型</a>：為什麼選 Apps Script</li>
<li>→ <a href="/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作</a>：把地基用在實作上</li>
</ul>
]]></content:encoded></item><item><title>辨識自動化流量</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/</guid><description>&lt;p>辨識自動化流量的責任是在統計裡把機器抓取與真人閱讀分開。未分離的統計會高估閱讀量，而高估的方向是單向的——沒有人會因為數字太好看而去查證。&lt;/p>
&lt;p>這一章的作法受兩個限制支配：接收端拿不到判別素材，以及會出現在這份統計裡的機器比想像中少。先講清楚這兩件事，它們決定了訊號從哪裡來、以及能期待抓到什麼。&lt;/p>
&lt;h2 id="接收端讀不到請求標頭">接收端讀不到請求標頭&lt;/h2>
&lt;p>Apps Script 的 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">&lt;code>doPost(e)&lt;/code>&lt;/a> 事件物件提供 &lt;code>parameter&lt;/code>、&lt;code>parameters&lt;/code>、&lt;code>queryString&lt;/code>、&lt;code>contentLength&lt;/code>、&lt;code>postData&lt;/code>、&lt;code>pathInfo&lt;/code> 這幾個欄位，&lt;strong>不提供任何 HTTP 標頭&lt;/strong>。這代表接收端拿不到 &lt;code>User-Agent&lt;/code>，也拿不到來源 IP。&lt;/p>
&lt;p>傳統的伺服器端過濾（比對 UA 字串、查 IP 反解、比對已知爬蟲的 IP 區段）在這個架構下全部不可用。所有判斷素材必須由前端蒐集之後放進 payload 送出——這既是限制，也界定了設計空間：&lt;strong>能用的訊號只有瀏覽器環境暴露給 JavaScript 的那些&lt;/strong>。&lt;/p>
&lt;p>這個限制連帶產生一條必須守住的界線。前端能取得完整的 &lt;code>userAgent&lt;/code>、螢幕尺寸、時區、字型清單，把它們全部送出確實能提高辨識率，但那正好構成&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/browser-fingerprint/" data-link-title="Browser Fingerprint（瀏覽器指紋）" data-link-desc="由多個單獨無害的瀏覽器環境屬性組合而成的裝置特徵集合，決定自建統計蒐集判別訊號時的能力上限">瀏覽器指紋&lt;/a>——為了分辨機器而蒐集足以識別個人的資料，取捨方向是錯的。後面的每個訊號都在「有判別力」與「不構成指紋」之間取值。&lt;/p>
&lt;h2 id="會被-beacon-記錄到的機器只有一小部分">會被 beacon 記錄到的機器只有一小部分&lt;/h2>
&lt;p>beacon 靠 JavaScript 觸發，所以&lt;strong>只有會執行 JS 的抓取工具才會進入這份統計&lt;/strong>。這一條大幅縮小要辨識的對象範圍，而它與直覺相反：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>抓取工具&lt;/th>
 &lt;th>執行 JS&lt;/th>
 &lt;th>出現在統計裡&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Googlebot&lt;/td>
 &lt;td>會&lt;/td>
 &lt;td>會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>無頭瀏覽器驅動的抓取工具（Playwright / Puppeteer）&lt;/td>
 &lt;td>會&lt;/td>
 &lt;td>會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>監控與可用性檢查服務&lt;/td>
 &lt;td>多數會&lt;/td>
 &lt;td>會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GPTBot / ClaudeBot / PerplexityBot 等 AI 訓練爬蟲&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>facebookexternalhit 等連結預覽&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CCBot / AhrefsBot / SemrushBot&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;td>不會&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>AI 訓練爬蟲不執行 JavaScript&lt;/strong> 是其中最反直覺的一項。Vercel 在 2025 年對自家平台流量的量測顯示，GPTBot 與 ClaudeBot 都會抓取 JS 檔案卻從不執行；連結預覽服務只讀 HTML 的 meta 標籤。&lt;strong>在主流爬蟲裡，目前已知具備完整 JS 渲染的只有 Googlebot&lt;/strong>——這是對演化中的世界所做的觀察，不是永久成立的分類，各家的渲染能力隨時可能改變。&lt;/p>
&lt;p>這件事有三個後續影響。其一，這套統計看不見的機器流量遠多於看得見的——伺服器日誌裡的爬蟲比例與這裡的數字不可比較。其二，下一節那份具名清單接住的是「會執行 JS」與「誠實申報身分」的交集，比清單本身小得多；清單裡多數條目預期永遠不會命中，保留它們是為了接住少數會渲染的變體。&lt;/p>
&lt;p>其三會影響上一章的判讀：&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">來源網址空白的成因&lt;/a>把「自動化抓取」列為其中一類，而這一節說明了那一類的實際佔比比直覺低——真正大量抓取的工具不執行 JS、根本不在這份資料裡。空白列偏向真人的機率因此比乍看之下高。&lt;/p>
&lt;h2 id="訊號分層與信心度">訊號分層與信心度&lt;/h2>
&lt;p>四個訊號的可靠度不同，分開記錄而不是合併成單一結論。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>信心&lt;/th>
 &lt;th>主要誤差方向&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>webdriver&lt;/code>&lt;/td>
 &lt;td>&lt;code>navigator.webdriver === true&lt;/code>&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>幾乎不誤判真人；規避工具會蓋掉&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>ua&lt;/code>&lt;/td>
 &lt;td>UA 字串比對自我申報名稱&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>不誤判真人；抓不到偽裝者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>lang&lt;/code>&lt;/td>
 &lt;td>&lt;code>navigator.languages&lt;/code> 為空&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>隱私擴充的真人誤中；新版無頭已不命中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>size&lt;/code>&lt;/td>
 &lt;td>&lt;code>window.outerWidth&lt;/code> 為 0&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>嵌入式 webview 的真人誤中；同上&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>這個信心排序由誤差方向推導而來，不是本站資料測得的命中率。&lt;/strong> 沒有標註任何誤判比例是刻意的——那需要一份已知組成的對照流量，而本文的每個判準都停在「這個訊號可能怎麼錯」的層次。既然正確率無從得知，處置方式就不是「先用著、之後校正」，而是&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/#%e6%a8%99%e8%a8%98%e8%80%8c%e9%9d%9e%e4%b8%9f%e6%a3%84" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">讓判定與使用分離&lt;/a>：訊號照實記下、判定留在彙總端，改了規則之後可以拿同一批舊資料重跑比對。&lt;/p>
&lt;p>&lt;strong>&lt;code>navigator.webdriver&lt;/code> 是自動化框架的預設標記。&lt;/strong> Playwright、Puppeteer、Selenium 啟動的瀏覽器這個值都是 &lt;code>true&lt;/code>，一般瀏覽器是 &lt;code>false&lt;/code>。它的判別力強而誤判極少，代價是刻意規避偵測的工具第一件事就是把它蓋掉——因此它抓得到的是「沒有隱藏身分意圖」的自動化訪客。&lt;/p>
&lt;p>&lt;strong>UA 比對抓的是自我申報。&lt;/strong> 搜尋引擎與正派的抓取服務會在 &lt;code>User-Agent&lt;/code> 裡寫明自己是誰，這是它們與網站經營者之間的慣例。比對這個字樣不會誤判真人，因為真人的瀏覽器不會自稱是機器。&lt;/p>
&lt;p>&lt;strong>空語言清單與零視窗尺寸原本是無頭環境的特徵，而這兩條正在失效。&lt;/strong> Chrome 112 起的新無頭模式會建立一個不顯示的平台視窗，並帶有正常的語言偏好，兩項特徵大多不再成立。它們的誤差方向因此從「誤判真人」偏向「漏判機器」——保留是因為舊版工具仍在使用中，但不能當作主力。&lt;/p>
&lt;p>因為誤差方向不同，&lt;strong>記錄下來的是命中了哪些訊號、而不是一個布林結論&lt;/strong>：&lt;/p>





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">=IFS(
</span></span><span class="line"><span class="ln">2</span><span class="cl">  AND(事件=&#34;leave&#34;, 同session同路徑的view筆數=0), &#34;待查-孤兒離開事件&#34;,
</span></span><span class="line"><span class="ln">3</span><span class="cl">  事件&lt;&gt;&#34;view&#34;, &#34;&#34;,
</span></span><span class="line"><span class="ln">4</span><span class="cl">  REGEXMATCH(訊號欄, &#34;webdriver|ua&#34;), &#34;機器-高信心&#34;,
</span></span><span class="line"><span class="ln">5</span><span class="cl">  AND(同session的leave筆數=0, 裝置&lt;&gt;&#34;mobile&#34;), &#34;待查-無離開事件&#34;,
</span></span><span class="line"><span class="ln">6</span><span class="cl">  訊號欄&lt;&gt;&#34;&#34;, &#34;訊號弱-待觀察&#34;,
</span></span><span class="line"><span class="ln">7</span><span class="cl">  同session的view筆數=1, &#34;單頁即走&#34;,
</span></span><span class="line"><span class="ln">8</span><span class="cl">  AND(同session的leave筆數&gt;0, 訊號欄=&#34;&#34;), &#34;真人&#34;,
</span></span><span class="line"><span class="ln">9</span><span class="cl">  TRUE, &#34;未分類&#34;)</span></span></code></pre></div><p>條件的順序承載語意。<strong>孤兒離開事件排在最前面</strong>，否則第二條會把所有離開事件留白，那一整類記錄就在統計裡消失了。<strong>非進入事件留白</strong>避免同一次瀏覽被計數兩次。</p>
<p>分類名稱用「待查」而非「爬蟲」是刻意的：行為訊號給的是需要進一步確認的候選。孤兒離開事件要對照<a href="/blog/automation/knowledge-cards/executions-page/" data-link-title="Executions（執行項目）" data-link-desc="Apps Script 平台記錄每一次函式執行的時間、耗時與狀態的頁面，用來判斷請求究竟有沒有抵達接收端">執行項目</a>同時段有沒有失敗紀錄——有的話那是配額問題而非機器。無離開事件那一條排除了行動裝置，因為真人在該環境的缺席率本來就高。</p>
<p>最後兩條分支的分工是刻意的。<strong>「真人」寫成顯式條件、catch-all 標成「未分類」</strong>，而不是反過來讓沒被接住的列直接落進「真人」——後者是這份報表最被消費、最不會被追問的類別，新的資料形狀靜默落進去只會讓數字變好看。<code>IFS</code> 在所有條件都不成立時回傳 <code>#N/A</code>，而滿欄的錯誤值與滿欄的留白一樣不會被追問，所以 catch-all 要給一個會被看見的標籤。這條防線的完整推導見<a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a>。</p>
<p>用總量守恆檢查這份分類時，<strong>作用域是進入事件那些列</strong>——離開事件被第二條留白，把它們算進分母會讓差額永遠等於離開事件的列數，而一個恆常的巨大差額只會訓練出忽略它的習慣。</p>
<h2 id="下一步">下一步</h2>
<p>判定規則到位了，接下來要問的是這些規則吃進去的資料本身可不可信。判定寫對了但公式算錯、程式改了但端點沒生效、錯誤訊息指向的位置不是問題的位置——<a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a>處理這三類。</p>
<p>爆量時段的漏記是另一回事，它來自配額而非判定，見<a href="/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">模組五的配額、防濫用與隱私邊界</a>。</p>
]]></content:encoded></item><item><title>doGet / doPost</title><link>https://tarrragon.github.io/blog/automation/knowledge-cards/doget-dopost/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/knowledge-cards/doget-dopost/</guid><description>&lt;p>&lt;code>doGet&lt;/code> 與 &lt;code>doPost&lt;/code> 是 Apps Script web app 的兩個進入點函式：端點收到 GET 請求時平台呼叫 &lt;code>doGet(e)&lt;/code>，收到 POST 請求時呼叫 &lt;code>doPost(e)&lt;/code>。它們是 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署&lt;/a>後對外行為的定義處——寫什麼在裡面，端點被呼叫時就執行什麼。參數 &lt;code>e&lt;/code> 帶著請求內容：&lt;code>doGet&lt;/code> 從 &lt;code>e.parameter&lt;/code> 拿 query string，&lt;code>doPost&lt;/code> 從 &lt;code>e.postData.contents&lt;/code> 拿請求主體。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>事件物件只帶請求的內容，&lt;strong>不帶任何 HTTP 標頭&lt;/strong>——&lt;code>User-Agent&lt;/code> 與來源 IP 在這裡都讀不到。伺服器端常見的 UA 比對與 IP 判斷因此不可用，需要那些資訊時只能由前端蒐集後放進請求主體送來（見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量&lt;/a>）。&lt;/p>
&lt;p>兩個函式都必須回傳一個 &lt;code>ContentService&lt;/code> 或 &lt;code>HtmlService&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>doPost&lt;/code>，因為 &lt;code>sendBeacon&lt;/code> 送的是 POST，主體放在 &lt;code>e.postData.contents&lt;/code>（是個字串，需要自己 &lt;code>JSON.parse&lt;/code>）。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>一個影響靜態站的關鍵限制是：Apps Script &lt;strong>沒有&lt;/strong> &lt;code>doOptions&lt;/code>。跨網域請求的 CORS preflight 會送 &lt;code>OPTIONS&lt;/code>，但它打不到你的程式、平台的預設回應也不帶 CORS 許可標頭，於是 preflight 失敗、真正的請求送不出去。&lt;/p>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>這是「用 &lt;code>fetch&lt;/code> 送 &lt;code>application/json&lt;/code> 打 Apps Script 得到 CORS 錯誤」的根因，繞法是讓請求成為不觸發 preflight 的 simple request，見&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><code>doGet</code> 與 <code>doPost</code> 是 Apps Script web app 的兩個進入點函式：端點收到 GET 請求時平台呼叫 <code>doGet(e)</code>，收到 POST 請求時呼叫 <code>doPost(e)</code>。它們是 <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>後對外行為的定義處——寫什麼在裡面，端點被呼叫時就執行什麼。參數 <code>e</code> 帶著請求內容：<code>doGet</code> 從 <code>e.parameter</code> 拿 query string，<code>doPost</code> 從 <code>e.postData.contents</code> 拿請求主體。</p>
<h2 id="概念位置">概念位置</h2>
<p>事件物件只帶請求的內容，<strong>不帶任何 HTTP 標頭</strong>——<code>User-Agent</code> 與來源 IP 在這裡都讀不到。伺服器端常見的 UA 比對與 IP 判斷因此不可用，需要那些資訊時只能由前端蒐集後放進請求主體送來（見<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>）。</p>
<p>兩個函式都必須回傳一個 <code>ContentService</code> 或 <code>HtmlService</code> 的輸出，這是平台的硬性要求；不回傳會被當成執行沒有正常結束。接收 <a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon</a> 這類「送資料進來」的場景用 <code>doPost</code>，因為 <code>sendBeacon</code> 送的是 POST，主體放在 <code>e.postData.contents</code>（是個字串，需要自己 <code>JSON.parse</code>）。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>一個影響靜態站的關鍵限制是：Apps Script <strong>沒有</strong> <code>doOptions</code>。跨網域請求的 CORS preflight 會送 <code>OPTIONS</code>，但它打不到你的程式、平台的預設回應也不帶 CORS 許可標頭，於是 preflight 失敗、真正的請求送不出去。</p>
<h2 id="判讀方式">判讀方式</h2>
<p>這是「用 <code>fetch</code> 送 <code>application/json</code> 打 Apps Script 得到 CORS 錯誤」的根因，繞法是讓請求成為不觸發 preflight 的 simple request，見<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 實作</title><link>https://tarrragon.github.io/blog/automation/02-analytics-beacon/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/02-analytics-beacon/</guid><description>&lt;p>把模組零的 client beacon 架構真的做出來：前端在頁面載入時送一則瀏覽事件，Apps Script 接住它、寫進 Google Sheet。這一章的終點是「打開試算表，看到自己剛剛的瀏覽出現在第一列」。&lt;/p>
&lt;p>實作拆成兩半，剛好對應架構的兩端。前端這半的重點是用對送出方式，避開靜態站打 Apps Script 最常見的 CORS 障礙。接收端這半的重點是把請求解析出來、安全地 append 進試算表。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙&lt;/a>&lt;/td>
 &lt;td>用 &lt;code>sendBeacon&lt;/code> 送 &lt;code>text/plain&lt;/code> 避開 preflight、beacon 該送什麼、放進 Hugo 哪裡&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">接收端 handler：寫進第一筆&lt;/a>&lt;/td>
 &lt;td>&lt;code>doPost&lt;/code> 解析請求、&lt;code>appendRow&lt;/code> 寫進 Sheet、部署後收到第一筆瀏覽&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基&lt;/a>：&lt;code>doGet&lt;/code>/&lt;code>doPost&lt;/code> 與部署模型的完整說明&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫&lt;/a>：資料進來後的並發、資料模型與容量&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後&lt;/a>：這裡的 payload 只描述單次瀏覽，補上訪客識別、停留時間與自動化訊號之後才讀得出「幾個人來過、有沒有真的在讀」&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>把模組零的 client beacon 架構真的做出來：前端在頁面載入時送一則瀏覽事件，Apps Script 接住它、寫進 Google Sheet。這一章的終點是「打開試算表，看到自己剛剛的瀏覽出現在第一列」。</p>
<p>實作拆成兩半，剛好對應架構的兩端。前端這半的重點是用對送出方式，避開靜態站打 Apps Script 最常見的 CORS 障礙。接收端這半的重點是把請求解析出來、安全地 append 進試算表。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/02-analytics-beacon/frontend-beacon/" data-link-title="前端 beacon 與 CORS 障礙" data-link-desc="靜態站用瀏覽器送瀏覽事件到 Apps Script 時，為什麼要用 sendBeacon 送 text/plain 才不會被 CORS preflight 擋下">前端 beacon 與 CORS 障礙</a></td>
          <td>用 <code>sendBeacon</code> 送 <code>text/plain</code> 避開 preflight、beacon 該送什麼、放進 Hugo 哪裡</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">接收端 handler：寫進第一筆</a></td>
          <td><code>doPost</code> 解析請求、<code>appendRow</code> 寫進 Sheet、部署後收到第一筆瀏覽</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a>：<code>doGet</code>/<code>doPost</code> 與部署模型的完整說明</li>
<li>→ <a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫</a>：資料進來後的並發、資料模型與容量</li>
<li>→ <a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a>：這裡的 payload 只描述單次瀏覽，補上訪客識別、停留時間與自動化訊號之後才讀得出「幾個人來過、有沒有真的在讀」</li>
</ul>
]]></content:encoded></item><item><title>Execution Quota（執行配額）</title><link>https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/</guid><description>&lt;p>執行配額是 Apps Script 對免費個人帳號設的一組執行上限，決定膠水層能承受多大的量。對個人（gmail.com）帳號，關鍵的三條是：單次執行最長 6 分鐘、同時併發最多 30 個執行、觸發器每日總執行時間 90 分鐘。理解這些上限才能判斷免費夠不夠，選型的完整討論見&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;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署&lt;/a> 端點的併發與&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/time-driven-trigger/" data-link-title="Time-Driven Trigger（時間觸發器）" data-link-desc="讓 Apps Script 在固定時間自動執行的排程機制，把被動等呼叫的膠水層變成主動定時跑的任務">時間觸發器&lt;/a>的總執行時間。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>觸發器的 90 分鐘每日總時間是另一條線，影響的是&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/time-driven-trigger/" data-link-title="Time-Driven Trigger（時間觸發器）" data-link-desc="讓 Apps Script 在固定時間自動執行的排程機制，把被動等呼叫的膠水層變成主動定時跑的任務">時間觸發器&lt;/a>做的排程彙總，不是接收 beacon。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>估算容量時該看的是&lt;strong>併發&lt;/strong>而非總量。接收 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 的膠水層，每次執行只寫一筆、幾百毫秒結束，遠用不到 6 分鐘；binding 的限制是「同一瞬間最多 30 個 beacon 在處理」。個人 blog 的流量分散在整天、任何瞬間的併發都遠低於 30，所以不會撞牆；會逼近上限的是「某篇文章短時間爆量」這種尖峰。&lt;/p>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>值得注意的是個人帳號的配額比 Google Workspace 帳號低，所以同一段程式在測試帳號能過、在別的帳號可能撞限。碰到配額上限的處理見&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>執行配額是 Apps Script 對免費個人帳號設的一組執行上限，決定膠水層能承受多大的量。對個人（gmail.com）帳號，關鍵的三條是：單次執行最長 6 分鐘、同時併發最多 30 個執行、觸發器每日總執行時間 90 分鐘。理解這些上限才能判斷免費夠不夠，選型的完整討論見<a href="/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">免費額度的思考方式與工具選型</a>。配額同時限制 <a href="/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署</a> 端點的併發與<a href="/blog/automation/knowledge-cards/time-driven-trigger/" data-link-title="Time-Driven Trigger（時間觸發器）" data-link-desc="讓 Apps Script 在固定時間自動執行的排程機制，把被動等呼叫的膠水層變成主動定時跑的任務">時間觸發器</a>的總執行時間。</p>
<h2 id="概念位置">概念位置</h2>
<p>觸發器的 90 分鐘每日總時間是另一條線，影響的是<a href="/blog/automation/knowledge-cards/time-driven-trigger/" data-link-title="Time-Driven Trigger（時間觸發器）" data-link-desc="讓 Apps Script 在固定時間自動執行的排程機制，把被動等呼叫的膠水層變成主動定時跑的任務">時間觸發器</a>做的排程彙總，不是接收 beacon。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>估算容量時該看的是<strong>併發</strong>而非總量。接收 <a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon</a> 的膠水層，每次執行只寫一筆、幾百毫秒結束，遠用不到 6 分鐘；binding 的限制是「同一瞬間最多 30 個 beacon 在處理」。個人 blog 的流量分散在整天、任何瞬間的併發都遠低於 30，所以不會撞牆；會逼近上限的是「某篇文章短時間爆量」這種尖峰。</p>
<h2 id="判讀方式">判讀方式</h2>
<p>值得注意的是個人帳號的配額比 Google Workspace 帳號低，所以同一段程式在測試帳號能過、在別的帳號可能撞限。碰到配額上限的處理見<a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五</a>。</p>
]]></content:encoded></item><item><title>假故障與靜默失效的診斷</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/diagnosing-silent-failures/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/06-reading-the-data/diagnosing-silent-failures/</guid><description>&lt;p>這一章處理的故障有一個共同結構：&lt;strong>症狀出現的位置不是問題所在的位置&lt;/strong>。錯誤訊息指向的那一行是無辜的、程式碼改對了卻沒有生效、公式完全合法卻算出錯誤的數字。三者的診斷路徑不同，但都要求先放下「訊息說哪裡壞就修哪裡」這個預設。&lt;/p>
&lt;p>這套統計的執行環境被切成四段——頁面上的 JavaScript、Apps Script 的部署層、接收端程式碼、Google 試算表——而每一段的狀態各自獨立。任何一段沒跟上都不會產生跨段的錯誤訊號，這是免伺服器架構特別容易產生假故障的結構原因。&lt;/p>
&lt;h2 id="錯誤訊息指向的位置不是問題的位置">錯誤訊息指向的位置不是問題的位置&lt;/h2>
&lt;p>Google 服務類別的查詢方法在找不到目標時回傳 &lt;code>null&lt;/code>，而不是拋出例外。錯誤因此延後到使用回傳值的那一刻才發生：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">inspectHeaders&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">sheet&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">SpreadsheetApp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getActive&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">getSheetByName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;log&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">lastCol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getLastColumn&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// TypeError: Cannot read properties of null
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>訊息指向 &lt;code>getLastColumn&lt;/code>，而真正的問題在上一行——這個試算表裡沒有名為 &lt;code>log&lt;/code> 的工作表。&lt;strong>判讀規則是：訊息形如「無法讀取 null 的某屬性」時，問題在產生那個 &lt;code>null&lt;/code> 的地方，不在使用它的地方。&lt;/strong>&lt;/p>
&lt;p>診斷方式是把假設換成觀測：先列出實際存在的工作表，再對照程式碼裡寫的名稱。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">listSheets&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">SpreadsheetApp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getActive&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">getSheets&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">sheet&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">lastCol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getLastColumn&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;工作表「%s」 欄數=%s 列數=%s&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getName&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">lastCol&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sheet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getLastRow&lt;/span>&lt;span class="p">());&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式碼的價值在於它不帶任何關於名稱的假設。同樣的模式適用於所有「用名稱或 ID 取得物件」的 API——先列出實際存在的東西，再對照程式碼裡寫的。&lt;/p>
&lt;p>有一個延伸判讀值得記住：&lt;code>getActive()&lt;/code> 回傳的是&lt;strong>這個 Apps Script 專案所綁定的試算表&lt;/strong>。專案綁在 A 表、而資料寫進 B 表（透過 &lt;code>openById&lt;/code>）是完全合法的配置，這時 &lt;code>getActive()&lt;/code> 拿到的是 A 表。工作表名稱與欄位都對不上時，要先確認取得試算表的方式，再懷疑工作表名稱。&lt;/p>
&lt;h2 id="程式改了但端點沒生效">程式改了但端點沒生效&lt;/h2>
&lt;p>Apps Script 把「編輯器裡的程式碼」與「端點服務的版本」設計成兩個獨立狀態。手動執行函式跑的是前者，HTTP 請求打到的是後者——&lt;strong>這代表「我執行某個函式成功了」與「端點跑的是新版」是兩件事，前者成立不推出後者&lt;/strong>。&lt;/p>
&lt;p>這個設計本身是合理的版本控制：可以邊改程式碼邊讓線上端點維持穩定，改完再一次切版。代價是「改了沒生效」成為最常見的假故障。&lt;/p>
&lt;p>判讀的訣竅是問一個問題：&lt;strong>這次觸發是自己按的，還是外面打進來的&lt;/strong>。自己按的走編輯器最新碼，外面打進來的走部署版本。症狀是「手動測試都對、實際收到的資料卻是舊格式」時，答案幾乎必定是部署版本沒更新。&lt;/p>
&lt;p>更新的操作是「管理部署作業 → 編輯 → 版本選新版本 → 部署」，這會保持網址不變（&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署&lt;/a>的更新語意）。用「新增部署作業」會產生新網址，前端設定的端點就對不上了——這一點與&lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">模組一的部署模型&lt;/a>講的是同一件事，只是從故障診斷的方向再看一次。&lt;/p>
&lt;p>驗證用&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/executions-page/" data-link-title="Executions（執行項目）" data-link-desc="Apps Script 平台記錄每一次函式執行的時間、耗時與狀態的頁面，用來判斷請求究竟有沒有抵達接收端">執行項目&lt;/a>頁面：它列出每一次 &lt;code>doPost&lt;/code> 的執行時間、耗時與狀態。這個頁面能回答一個關鍵問題——&lt;strong>請求到底有沒有打到端點&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>有執行紀錄但狀態失敗：請求送達了，接收端出錯&lt;/li>
&lt;li>完全沒有對應時間的執行紀錄：請求根本沒送達，問題在瀏覽器端或網路&lt;/li>
&lt;/ul>
&lt;p>這個分岔把排查範圍砍半，而且它的證據來自平台本身，不依賴任何推測。實際運用的例子是判斷「某些記錄只有離開事件、沒有進入事件」的成因：執行紀錄裡完全找不到進入事件對應時間的執行，因此問題落在接收端之前——請求根本沒送出來。&lt;/p>
&lt;p>這一步能確定的就到這裡。&lt;strong>再往下的「為什麼沒送出來」屬於假說，執行紀錄支撐不了它&lt;/strong>：可能是那個瀏覽器環境攔截了頁面載入期間的請求，也可能是接收端當時併發撞頂而請求被拒（那會留下失敗紀錄，可以排除），或是網路瞬斷。把觀測與解釋分開記錄，之後拿到更多樣本時才知道哪一個假說被推翻了。&lt;/p>
&lt;h2 id="靜默失效合法執行輸出合法數字是錯的">靜默失效：合法執行、輸出合法、數字是錯的&lt;/h2>
&lt;p>第三類故障持續執行、持續產出看起來合理的數字，而數字是錯的——前兩類至少會停下來。&lt;/p>
&lt;p>試算表的分類公式是典型場所，而它最難察覺的形態是「修正一個問題時引入下一個」。&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型&lt;/a>那一章示範過第一次：單事件模型下的計數條件在雙事件模型下永遠不成立，那個分類的計數從此恆為零。&lt;/p>
&lt;p>修法是把事件型別加進條件，而修正版的開頭多了一句「非進入事件的列一律留白」——避免同一次瀏覽被計數兩次。&lt;strong>第二次失效就出在那一句。&lt;/strong> 它預設每次瀏覽都有進入事件，而真實資料裡存在只有離開事件的記錄（成因見上一節）。這些列被開頭那個條件整批留白，於是一整類資料在統計裡完全不存在——這不是算錯，是那一類從報表上消失了。&lt;/p>
&lt;p>兩次的共同形態相同：公式沒有語法錯誤、沒有回傳錯誤值，只有分佈變了。&lt;/p>
&lt;p>&lt;strong>零是這裡最危險的輸出，因為它與「沒有這種流量」完全不可區分。&lt;/strong> 要察覺算錯，得先有一個獨立來源知道這裡本來應該有多少——而分析公式存在的理由，正是因為沒有那個獨立來源。&lt;/p>
&lt;p>三個作法讓這類失效可被發現：&lt;/p>
&lt;p>&lt;strong>用總量守恆檢查涵蓋。&lt;/strong> 分類後各類數量的總和與原始列數的差額應該為零。差額不為零代表有資料形狀沒被任何條件接住，而這個檢查不要求預先知道漏了什麼——這是它比逐條檢視有效的地方。&lt;/p>
&lt;p>&lt;strong>讓未涵蓋的資料顯示成標籤而非留白。&lt;/strong> 分類邏輯的最後放一個 catch-all 分支，把沒被接住的列標成「未分類」。留白與「不適用」在視覺上不可區分，而一個會被看見的標籤才有機會促使人去查。&lt;/p>
&lt;p>&lt;strong>資料模型變更時把公式的重算納入同一次變更。&lt;/strong> 分析邏輯是資料模型的下游依賴，與欄位定義、寫入程式碼屬於同一次變更的範圍——延後處理的代價與重算的方法見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型&lt;/a>那一節的示範。&lt;/p>
&lt;p>抽象層的完整推導見 &lt;a href="https://tarrragon.github.io/blog/report/new-data-shape-silently-changes-analysis/" data-link-title="資料多出一種形狀時，既有分析邏輯靜默換語意" data-link-desc="資料模型新增一種列或事件型別後，判斷既有的公式、查詢與聚合是否還在算同一件事——它們不報錯，只是漏掉或重複整類資料">#250 資料多出一種形狀時，既有分析邏輯靜默換語意&lt;/a>。&lt;/p>
&lt;h2 id="診斷順序">診斷順序&lt;/h2>
&lt;p>三類故障的排查有一個共通的推進方式：&lt;strong>沿著資料的路徑，逐段確認實際狀態，而不是逐段確認程式碼寫了什麼&lt;/strong>。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>位置&lt;/th>
 &lt;th>觀測方式&lt;/th>
 &lt;th>正常代表什麼&lt;/th>
 &lt;th>異常時往哪走&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>瀏覽器&lt;/td>
 &lt;td>開發者工具的網路面板，找送往端點的請求&lt;/td>
 &lt;td>事件有產生、也送出去了&lt;/td>
 &lt;td>查網域判斷與退出標記（見下方說明）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>傳輸途中&lt;/td>
 &lt;td>網路面板看該請求的狀態碼，或關掉擴充再試&lt;/td>
 &lt;td>請求離開瀏覽器、也沒被攔下&lt;/td>
 &lt;td>被攔截或逾時，換一個瀏覽器環境重試以確認&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>平台部署層&lt;/td>
 &lt;td>管理部署作業，比對網址與版本&lt;/td>
 &lt;td>端點跑的是預期的那份程式碼&lt;/td>
 &lt;td>用同一個部署更新版本，不新增部署&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>接收端執行&lt;/td>
 &lt;td>執行項目頁面，看時間與狀態&lt;/td>
 &lt;td>請求送達了、程式跑完了&lt;/td>
 &lt;td>有失敗紀錄查例外，無紀錄代表沒抵達、回上一列&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>試算表&lt;/td>
 &lt;td>直接看最新一列的欄位&lt;/td>
 &lt;td>資料寫進去了、位置正確&lt;/td>
 &lt;td>欄位錯位查 &lt;code>appendRow&lt;/code> 的順序與表頭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>彙總排程&lt;/td>
 &lt;td>觸發器的執行紀錄、日報最新一列的日期&lt;/td>
 &lt;td>定時彙總跑過、輸出有更新&lt;/td>
 &lt;td>查欄數與事件型別這兩個常數是否跟上資料模型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分析公式&lt;/td>
 &lt;td>進入事件列的總量守恆與未分類計數&lt;/td>
 &lt;td>統計涵蓋了所有實際存在的形狀&lt;/td>
 &lt;td>差額不為零時找沒被任何條件接住的資料形狀&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三段的觀測值需要補充。&lt;strong>瀏覽器那一段&lt;/strong>看的是請求有沒有發出，而它沒發出通常有兩個良性成因：beacon 前端有一道網域判斷（只在正式站送、本機預覽不送），以及讀者可能設過&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">退出標記&lt;/a>——兩者都會讓程式在送出前就返回，看起來與故障相同。&lt;/p>
&lt;p>&lt;strong>傳輸途中那一段&lt;/strong>是「發出了但沒抵達」的落點。接收端是一個外部網域，廣告與隱私擴充功能的通用規則可能直接擋下這個請求；瀏覽器的網路面板會顯示它被封鎖而非失敗。這一段存在的理由是前後兩段都答不了它：瀏覽器說「我送了」、執行紀錄說「我沒收到」，兩邊都沒說謊。&lt;/p>
&lt;p>&lt;strong>彙總排程那一段&lt;/strong>容易被漏掉，因為它不在 beacon 的即時路徑上。定時彙總讀的是同一張表，而它有自己的一組常數（讀幾欄、事件型別在第幾欄），資料模型變更時它們與分析公式同樣需要重算。&lt;/p>
&lt;p>&lt;strong>試算表那一段&lt;/strong>看的是最新一列的時間戳與欄位對位。&lt;strong>欄位錯位與資料沒寫進去是兩種不同的故障&lt;/strong>——前者每一格都有值、看起來完全正常，成因是寫入端的欄位順序與表頭不同步，而它不會產生任何錯誤。&lt;/p>
&lt;p>每一段都有一個「不看程式碼就能得到的觀測值」，這是這張表的設計重點。讀程式碼只能知道它宣稱會做什麼，而這幾類故障的成因全都是宣稱與實際之間的落差——落差只有實際觀測才看得見。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>收集鏈確認健康之後，資料判讀的環節分別是：識別欄位見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out&lt;/a>、行為欄位見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間&lt;/a>、判定規則見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量&lt;/a>、而這些欄位加總起來涵蓋了誰見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/data-coverage/" data-link-title="這份統計的分母是什麼" data-link-desc="報表上的數字要拿去回答問題之前，先確定它涵蓋了誰、漏掉了誰，以及哪些結論在這個涵蓋範圍下仍然成立">這份統計的分母是什麼&lt;/a>。配額耗盡導致的漏記是另一類成因，見&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">模組五的配額碰撞與防濫用&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>這一章處理的故障有一個共同結構：<strong>症狀出現的位置不是問題所在的位置</strong>。錯誤訊息指向的那一行是無辜的、程式碼改對了卻沒有生效、公式完全合法卻算出錯誤的數字。三者的診斷路徑不同，但都要求先放下「訊息說哪裡壞就修哪裡」這個預設。</p>
<p>這套統計的執行環境被切成四段——頁面上的 JavaScript、Apps Script 的部署層、接收端程式碼、Google 試算表——而每一段的狀態各自獨立。任何一段沒跟上都不會產生跨段的錯誤訊號，這是免伺服器架構特別容易產生假故障的結構原因。</p>
<h2 id="錯誤訊息指向的位置不是問題的位置">錯誤訊息指向的位置不是問題的位置</h2>
<p>Google 服務類別的查詢方法在找不到目標時回傳 <code>null</code>，而不是拋出例外。錯誤因此延後到使用回傳值的那一刻才發生：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">inspectHeaders</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">sheet</span> <span class="o">=</span> <span class="nx">SpreadsheetApp</span><span class="p">.</span><span class="nx">getActive</span><span class="p">().</span><span class="nx">getSheetByName</span><span class="p">(</span><span class="s1">&#39;log&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">var</span> <span class="nx">lastCol</span> <span class="o">=</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getLastColumn</span><span class="p">();</span>   <span class="c1">// TypeError: Cannot read properties of null
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>訊息指向 <code>getLastColumn</code>，而真正的問題在上一行——這個試算表裡沒有名為 <code>log</code> 的工作表。<strong>判讀規則是：訊息形如「無法讀取 null 的某屬性」時，問題在產生那個 <code>null</code> 的地方，不在使用它的地方。</strong></p>
<p>診斷方式是把假設換成觀測：先列出實際存在的工作表，再對照程式碼裡寫的名稱。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">listSheets</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">SpreadsheetApp</span><span class="p">.</span><span class="nx">getActive</span><span class="p">().</span><span class="nx">getSheets</span><span class="p">().</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">sheet</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kd">var</span> <span class="nx">lastCol</span> <span class="o">=</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getLastColumn</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Logger</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;工作表「%s」 欄數=%s 列數=%s&#39;</span><span class="p">,</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getName</span><span class="p">(),</span> <span class="nx">lastCol</span><span class="p">,</span> <span class="nx">sheet</span><span class="p">.</span><span class="nx">getLastRow</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式碼的價值在於它不帶任何關於名稱的假設。同樣的模式適用於所有「用名稱或 ID 取得物件」的 API——先列出實際存在的東西，再對照程式碼裡寫的。</p>
<p>有一個延伸判讀值得記住：<code>getActive()</code> 回傳的是<strong>這個 Apps Script 專案所綁定的試算表</strong>。專案綁在 A 表、而資料寫進 B 表（透過 <code>openById</code>）是完全合法的配置，這時 <code>getActive()</code> 拿到的是 A 表。工作表名稱與欄位都對不上時，要先確認取得試算表的方式，再懷疑工作表名稱。</p>
<h2 id="程式改了但端點沒生效">程式改了但端點沒生效</h2>
<p>Apps Script 把「編輯器裡的程式碼」與「端點服務的版本」設計成兩個獨立狀態。手動執行函式跑的是前者，HTTP 請求打到的是後者——<strong>這代表「我執行某個函式成功了」與「端點跑的是新版」是兩件事，前者成立不推出後者</strong>。</p>
<p>這個設計本身是合理的版本控制：可以邊改程式碼邊讓線上端點維持穩定，改完再一次切版。代價是「改了沒生效」成為最常見的假故障。</p>
<p>判讀的訣竅是問一個問題：<strong>這次觸發是自己按的，還是外面打進來的</strong>。自己按的走編輯器最新碼，外面打進來的走部署版本。症狀是「手動測試都對、實際收到的資料卻是舊格式」時，答案幾乎必定是部署版本沒更新。</p>
<p>更新的操作是「管理部署作業 → 編輯 → 版本選新版本 → 部署」，這會保持網址不變（<a href="/blog/automation/knowledge-cards/web-app-deployment/" data-link-title="Web App Deployment（Web App 部署）" data-link-desc="把 Apps Script 專案掛成一個有公開網址、可被任何 HTTP 請求呼叫的端點時的部署模型與存取設定">web app 部署</a>的更新語意）。用「新增部署作業」會產生新網址，前端設定的端點就對不上了——這一點與<a href="/blog/automation/01-apps-script-basics/web-app-deployment-model/" data-link-title="web app 部署模型與授權" data-link-desc="把 Apps Script 掛成可被 HTTP 呼叫的端點時，doGet/doPost 進入點、exec 與 dev 兩種網址、以及更新部署為什麼要用同一個網址">模組一的部署模型</a>講的是同一件事，只是從故障診斷的方向再看一次。</p>
<p>驗證用<a href="/blog/automation/knowledge-cards/executions-page/" data-link-title="Executions（執行項目）" data-link-desc="Apps Script 平台記錄每一次函式執行的時間、耗時與狀態的頁面，用來判斷請求究竟有沒有抵達接收端">執行項目</a>頁面：它列出每一次 <code>doPost</code> 的執行時間、耗時與狀態。這個頁面能回答一個關鍵問題——<strong>請求到底有沒有打到端點</strong>：</p>
<ul>
<li>有執行紀錄但狀態失敗：請求送達了，接收端出錯</li>
<li>完全沒有對應時間的執行紀錄：請求根本沒送達，問題在瀏覽器端或網路</li>
</ul>
<p>這個分岔把排查範圍砍半，而且它的證據來自平台本身，不依賴任何推測。實際運用的例子是判斷「某些記錄只有離開事件、沒有進入事件」的成因：執行紀錄裡完全找不到進入事件對應時間的執行，因此問題落在接收端之前——請求根本沒送出來。</p>
<p>這一步能確定的就到這裡。<strong>再往下的「為什麼沒送出來」屬於假說，執行紀錄支撐不了它</strong>：可能是那個瀏覽器環境攔截了頁面載入期間的請求，也可能是接收端當時併發撞頂而請求被拒（那會留下失敗紀錄，可以排除），或是網路瞬斷。把觀測與解釋分開記錄，之後拿到更多樣本時才知道哪一個假說被推翻了。</p>
<h2 id="靜默失效合法執行輸出合法數字是錯的">靜默失效：合法執行、輸出合法、數字是錯的</h2>
<p>第三類故障持續執行、持續產出看起來合理的數字，而數字是錯的——前兩類至少會停下來。</p>
<p>試算表的分類公式是典型場所，而它最難察覺的形態是「修正一個問題時引入下一個」。<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型</a>那一章示範過第一次：單事件模型下的計數條件在雙事件模型下永遠不成立，那個分類的計數從此恆為零。</p>
<p>修法是把事件型別加進條件，而修正版的開頭多了一句「非進入事件的列一律留白」——避免同一次瀏覽被計數兩次。<strong>第二次失效就出在那一句。</strong> 它預設每次瀏覽都有進入事件，而真實資料裡存在只有離開事件的記錄（成因見上一節）。這些列被開頭那個條件整批留白，於是一整類資料在統計裡完全不存在——這不是算錯，是那一類從報表上消失了。</p>
<p>兩次的共同形態相同：公式沒有語法錯誤、沒有回傳錯誤值，只有分佈變了。</p>
<p><strong>零是這裡最危險的輸出，因為它與「沒有這種流量」完全不可區分。</strong> 要察覺算錯，得先有一個獨立來源知道這裡本來應該有多少——而分析公式存在的理由，正是因為沒有那個獨立來源。</p>
<p>三個作法讓這類失效可被發現：</p>
<p><strong>用總量守恆檢查涵蓋。</strong> 分類後各類數量的總和與原始列數的差額應該為零。差額不為零代表有資料形狀沒被任何條件接住，而這個檢查不要求預先知道漏了什麼——這是它比逐條檢視有效的地方。</p>
<p><strong>讓未涵蓋的資料顯示成標籤而非留白。</strong> 分類邏輯的最後放一個 catch-all 分支，把沒被接住的列標成「未分類」。留白與「不適用」在視覺上不可區分，而一個會被看見的標籤才有機會促使人去查。</p>
<p><strong>資料模型變更時把公式的重算納入同一次變更。</strong> 分析邏輯是資料模型的下游依賴，與欄位定義、寫入程式碼屬於同一次變更的範圍——延後處理的代價與重算的方法見<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型</a>那一節的示範。</p>
<p>抽象層的完整推導見 <a href="/blog/report/new-data-shape-silently-changes-analysis/" data-link-title="資料多出一種形狀時，既有分析邏輯靜默換語意" data-link-desc="資料模型新增一種列或事件型別後，判斷既有的公式、查詢與聚合是否還在算同一件事——它們不報錯，只是漏掉或重複整類資料">#250 資料多出一種形狀時，既有分析邏輯靜默換語意</a>。</p>
<h2 id="診斷順序">診斷順序</h2>
<p>三類故障的排查有一個共通的推進方式：<strong>沿著資料的路徑，逐段確認實際狀態，而不是逐段確認程式碼寫了什麼</strong>。</p>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>觀測方式</th>
          <th>正常代表什麼</th>
          <th>異常時往哪走</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>瀏覽器</td>
          <td>開發者工具的網路面板，找送往端點的請求</td>
          <td>事件有產生、也送出去了</td>
          <td>查網域判斷與退出標記（見下方說明）</td>
      </tr>
      <tr>
          <td>傳輸途中</td>
          <td>網路面板看該請求的狀態碼，或關掉擴充再試</td>
          <td>請求離開瀏覽器、也沒被攔下</td>
          <td>被攔截或逾時，換一個瀏覽器環境重試以確認</td>
      </tr>
      <tr>
          <td>平台部署層</td>
          <td>管理部署作業，比對網址與版本</td>
          <td>端點跑的是預期的那份程式碼</td>
          <td>用同一個部署更新版本，不新增部署</td>
      </tr>
      <tr>
          <td>接收端執行</td>
          <td>執行項目頁面，看時間與狀態</td>
          <td>請求送達了、程式跑完了</td>
          <td>有失敗紀錄查例外，無紀錄代表沒抵達、回上一列</td>
      </tr>
      <tr>
          <td>試算表</td>
          <td>直接看最新一列的欄位</td>
          <td>資料寫進去了、位置正確</td>
          <td>欄位錯位查 <code>appendRow</code> 的順序與表頭</td>
      </tr>
      <tr>
          <td>彙總排程</td>
          <td>觸發器的執行紀錄、日報最新一列的日期</td>
          <td>定時彙總跑過、輸出有更新</td>
          <td>查欄數與事件型別這兩個常數是否跟上資料模型</td>
      </tr>
      <tr>
          <td>分析公式</td>
          <td>進入事件列的總量守恆與未分類計數</td>
          <td>統計涵蓋了所有實際存在的形狀</td>
          <td>差額不為零時找沒被任何條件接住的資料形狀</td>
      </tr>
  </tbody>
</table>
<p>三段的觀測值需要補充。<strong>瀏覽器那一段</strong>看的是請求有沒有發出，而它沒發出通常有兩個良性成因：beacon 前端有一道網域判斷（只在正式站送、本機預覽不送），以及讀者可能設過<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">退出標記</a>——兩者都會讓程式在送出前就返回，看起來與故障相同。</p>
<p><strong>傳輸途中那一段</strong>是「發出了但沒抵達」的落點。接收端是一個外部網域，廣告與隱私擴充功能的通用規則可能直接擋下這個請求；瀏覽器的網路面板會顯示它被封鎖而非失敗。這一段存在的理由是前後兩段都答不了它：瀏覽器說「我送了」、執行紀錄說「我沒收到」，兩邊都沒說謊。</p>
<p><strong>彙總排程那一段</strong>容易被漏掉，因為它不在 beacon 的即時路徑上。定時彙總讀的是同一張表，而它有自己的一組常數（讀幾欄、事件型別在第幾欄），資料模型變更時它們與分析公式同樣需要重算。</p>
<p><strong>試算表那一段</strong>看的是最新一列的時間戳與欄位對位。<strong>欄位錯位與資料沒寫進去是兩種不同的故障</strong>——前者每一格都有值、看起來完全正常，成因是寫入端的欄位順序與表頭不同步，而它不會產生任何錯誤。</p>
<p>每一段都有一個「不看程式碼就能得到的觀測值」，這是這張表的設計重點。讀程式碼只能知道它宣稱會做什麼，而這幾類故障的成因全都是宣稱與實際之間的落差——落差只有實際觀測才看得見。</p>
<h2 id="下一步">下一步</h2>
<p>收集鏈確認健康之後，資料判讀的環節分別是：識別欄位見<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out</a>、行為欄位見<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間</a>、判定規則見<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>、而這些欄位加總起來涵蓋了誰見<a href="/blog/automation/06-reading-the-data/data-coverage/" data-link-title="這份統計的分母是什麼" data-link-desc="報表上的數字要拿去回答問題之前，先確定它涵蓋了誰、漏掉了誰，以及哪些結論在這個涵蓋範圍下仍然成立">這份統計的分母是什麼</a>。配額耗盡導致的漏記是另一類成因，見<a href="/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">模組五的配額碰撞與防濫用</a>。</p>
]]></content:encoded></item><item><title>Time-Driven Trigger（時間觸發器）</title><link>https://tarrragon.github.io/blog/automation/knowledge-cards/time-driven-trigger/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/knowledge-cards/time-driven-trigger/</guid><description>&lt;p>時間觸發器（time-driven trigger）是讓 Apps Script 在固定時間自動執行某個函式的排程機制。它把膠水層從「被動等 HTTP 請求呼叫」（見 &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 請求，決定端點收到請求時執行什麼">doGet / doPost&lt;/a>）變成「主動定時執行」——不需要有人打網址，到點就自己跑。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>流量統計用它把 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 累積的原始 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>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>時間觸發器可以設成每分鐘、每小時、每天固定時段、或每週執行。典型用法是「每天凌晨把前一天的 raw log 依日期與路徑 group 成彙總表」，讓人打開試算表看到的是整理過的數字、而不是逐筆原始紀錄。&lt;/p>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>觸發器的成本受&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額&lt;/a>約束：個人帳號所有觸發器每天總執行時間上限 90 分鐘。所以彙總邏輯要寫得有效率，資料量大時避免每次都全表掃描——這條線在原始 log 累積到很多列時才會逼近，判讀與最佳化見&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;/p></description><content:encoded><![CDATA[<p>時間觸發器（time-driven trigger）是讓 Apps Script 在固定時間自動執行某個函式的排程機制。它把膠水層從「被動等 HTTP 請求呼叫」（見 <a href="/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">doGet / doPost</a>）變成「主動定時執行」——不需要有人打網址，到點就自己跑。</p>
<h2 id="概念位置">概念位置</h2>
<p>流量統計用它把 <a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon</a> 累積的原始 log 每天彙總成一張日報表，實作見<a href="/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四</a>。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>時間觸發器可以設成每分鐘、每小時、每天固定時段、或每週執行。典型用法是「每天凌晨把前一天的 raw log 依日期與路徑 group 成彙總表」，讓人打開試算表看到的是整理過的數字、而不是逐筆原始紀錄。</p>
<h2 id="判讀方式">判讀方式</h2>
<p>觸發器的成本受<a href="/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額</a>約束：個人帳號所有觸發器每天總執行時間上限 90 分鐘。所以彙總邏輯要寫得有效率，資料量大時避免每次都全表掃描——這條線在原始 log 累積到很多列時才會逼近，判讀與最佳化見<a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 的容量邊界</a>。</p>
]]></content:encoded></item><item><title>模組四：觸發器與排程</title><link>https://tarrragon.github.io/blog/automation/04-triggers-automation/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/04-triggers-automation/</guid><description>&lt;p>回答「原始 log 怎麼變成看得懂的報表、而且不用手動跑」。beacon 一直 append 進來的是一列一列的原始瀏覽紀錄，這種 raw log 直接看沒意義——要的是「昨天每篇文章被看幾次」。這一章用 Apps Script 的時間觸發器（time-driven trigger）自動排程：每天固定時間把前一天的原始 log 彙總成一張日報表，人打開就看得懂。&lt;/p>
&lt;p>觸發器是 Apps Script 從「被動等人呼叫」變成「主動定時執行」的機制。它的成本要放在心上：個人帳號的觸發器每天總執行時間上限是 90 分鐘（見&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;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/04-triggers-automation/time-driven-aggregation/" data-link-title="時間觸發器：把 raw log 彙總成日報" data-link-desc="用時間觸發器每天定時把原始瀏覽 log group 成日報，以及在 90 分鐘觸發配額下只讀增量的效率寫法">時間觸發器：把 raw log 彙總成日報&lt;/a>&lt;/td>
 &lt;td>設定每日定時、group by 彙總邏輯、在 90 分鐘配額內只讀增量的效率寫法&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/04-triggers-automation/form-and-event-triggers/" data-link-title="表單與事件觸發器" data-link-desc="由 Google 檔案事件（表單提交、試算表編輯）觸發的 Apps Script，以及 simple 與 installable 觸發器在權限上的差別">表單與事件觸發器&lt;/a>&lt;/td>
 &lt;td>&lt;code>onFormSubmit&lt;/code> / &lt;code>onEdit&lt;/code>、simple 與 installable 觸發器的權限分界&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/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫&lt;/a>：被彙總的 raw log 從哪來&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Monitoring：漏斗分析&lt;/a>：彙總後的資料能做什麼分析&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「原始 log 怎麼變成看得懂的報表、而且不用手動跑」。beacon 一直 append 進來的是一列一列的原始瀏覽紀錄，這種 raw log 直接看沒意義——要的是「昨天每篇文章被看幾次」。這一章用 Apps Script 的時間觸發器（time-driven trigger）自動排程：每天固定時間把前一天的原始 log 彙總成一張日報表，人打開就看得懂。</p>
<p>觸發器是 Apps Script 從「被動等人呼叫」變成「主動定時執行」的機制。它的成本要放在心上：個人帳號的觸發器每天總執行時間上限是 90 分鐘（見<a href="/blog/automation/00-mental-model/free-tier-and-tool-choice/" data-link-title="免費額度的思考方式與工具選型" data-link-desc="判斷免費膠水層撐不撐得住自己流量時該看哪個限制、以及 Apps Script 與 Cloudflare Workers 各自適合什麼場景">模組零</a>），所以彙總邏輯要寫得有效率，別在觸發器裡做會逼近上限的重活。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/04-triggers-automation/time-driven-aggregation/" data-link-title="時間觸發器：把 raw log 彙總成日報" data-link-desc="用時間觸發器每天定時把原始瀏覽 log group 成日報，以及在 90 分鐘觸發配額下只讀增量的效率寫法">時間觸發器：把 raw log 彙總成日報</a></td>
          <td>設定每日定時、group by 彙總邏輯、在 90 分鐘配額內只讀增量的效率寫法</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/04-triggers-automation/form-and-event-triggers/" data-link-title="表單與事件觸發器" data-link-desc="由 Google 檔案事件（表單提交、試算表編輯）觸發的 Apps Script，以及 simple 與 installable 觸發器在權限上的差別">表單與事件觸發器</a></td>
          <td><code>onFormSubmit</code> / <code>onEdit</code>、simple 與 installable 觸發器的權限分界</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫</a>：被彙總的 raw log 從哪來</li>
<li>→ <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Monitoring：漏斗分析</a>：彙總後的資料能做什麼分析</li>
</ul>
]]></content:encoded></item><item><title>Executions（執行項目）</title><link>https://tarrragon.github.io/blog/automation/knowledge-cards/executions-page/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/knowledge-cards/executions-page/</guid><description>&lt;p>執行項目是 Apps Script 編輯器左側的一個頁面，列出這個專案每一次函式執行的時間、耗時、觸發方式與狀態。它記錄的是平台實際跑過什麼，因此是這類架構裡唯一不依賴推測的觀測點——程式碼只顯示它宣稱會做什麼，執行項目顯示它做了什麼。接收 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 的 &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;strong>有執行紀錄但狀態失敗&lt;/strong>代表請求送達了、接收端出錯；&lt;strong>完全沒有對應時間的執行紀錄&lt;/strong>代表請求根本沒送出來，問題在瀏覽器端或網路。兩者的排查方向相反，而在此之前它們的症狀完全相同——試算表都沒有新資料。&lt;/p>
&lt;p>它與 &lt;code>Logger.log&lt;/code> 的分工也在這裡：&lt;code>Logger&lt;/code> 記錄的是程式自己選擇要說的話，執行項目記錄的是平台觀察到的事實，程式在第一行就爆掉時前者什麼都沒有、後者仍有一列失敗紀錄。判讀失敗紀錄時常要對照&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額&lt;/a>的三條上限，因為逼近上限的症狀（耗時拉長、密集失敗）都先出現在這一頁。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>一次正常的 beacon 接收在這裡是「&lt;code>doPost&lt;/code> / 網頁應用程式 / 一秒出頭 / 已完成」。耗時異常拉長是逼近&lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額&lt;/a>單次上限的前兆；同一時段出現大量失敗則指向併發撞頂。&lt;/p>
&lt;p>版本欄位還洩漏另一件事：它顯示這次執行用的是哪一個部署版本，因此「程式碼改了但端點跑舊版」這個假故障在這裡看得出來。&lt;/p>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>排查「資料沒進來」時先開這一頁，再決定往哪個方向查——這一步的成本是開一個頁面，而它省下的是往錯誤方向排查的整段時間。實際運用見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>執行項目是 Apps Script 編輯器左側的一個頁面，列出這個專案每一次函式執行的時間、耗時、觸發方式與狀態。它記錄的是平台實際跑過什麼，因此是這類架構裡唯一不依賴推測的觀測點——程式碼只顯示它宣稱會做什麼，執行項目顯示它做了什麼。接收 <a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon</a> 的 <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>這個頁面回答的問題是「請求有沒有抵達端點」，而它把故障範圍切成兩半：<strong>有執行紀錄但狀態失敗</strong>代表請求送達了、接收端出錯；<strong>完全沒有對應時間的執行紀錄</strong>代表請求根本沒送出來，問題在瀏覽器端或網路。兩者的排查方向相反，而在此之前它們的症狀完全相同——試算表都沒有新資料。</p>
<p>它與 <code>Logger.log</code> 的分工也在這裡：<code>Logger</code> 記錄的是程式自己選擇要說的話，執行項目記錄的是平台觀察到的事實，程式在第一行就爆掉時前者什麼都沒有、後者仍有一列失敗紀錄。判讀失敗紀錄時常要對照<a href="/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額</a>的三條上限，因為逼近上限的症狀（耗時拉長、密集失敗）都先出現在這一頁。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>一次正常的 beacon 接收在這裡是「<code>doPost</code> / 網頁應用程式 / 一秒出頭 / 已完成」。耗時異常拉長是逼近<a href="/blog/automation/knowledge-cards/execution-quota/" data-link-title="Execution Quota（執行配額）" data-link-desc="Apps Script 個人帳號的執行時間、同時併發與觸發器每日總時間上限，決定免費膠水層能承受多大的量">執行配額</a>單次上限的前兆；同一時段出現大量失敗則指向併發撞頂。</p>
<p>版本欄位還洩漏另一件事：它顯示這次執行用的是哪一個部署版本，因此「程式碼改了但端點跑舊版」這個假故障在這裡看得出來。</p>
<h2 id="判讀方式">判讀方式</h2>
<p>排查「資料沒進來」時先開這一頁，再決定往哪個方向查——這一步的成本是開一個頁面，而它省下的是往錯誤方向排查的整段時間。實際運用見<a href="/blog/automation/06-reading-the-data/diagnosing-silent-failures/" data-link-title="假故障與靜默失效的診斷" data-link-desc="自建的 Apps Script 流量統計看起來壞了、或看起來正常但數字不對時，分辨症狀出現的位置與問題所在的位置">假故障與靜默失效的診斷</a>。</p>
]]></content:encoded></item><item><title>免伺服器自動化實務指南：用免費雲端服務給靜態站補上動態能力</title><link>https://tarrragon.github.io/blog/automation/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/</guid><description>&lt;p>本指南處理一類具體的工程問題：手上有一個沒有後端的靜態站或小專案，需要一點點動態能力（收一筆表單、記一次瀏覽、每天彙總一次資料），但這個需求還不到、也不值得租一台伺服器的程度。核心概念是「膠水層」——用別人已經在跑、且對個人用量免費的雲端服務，補上靜態站缺的那一小塊伺服器端邏輯，讓你不必自己維護一台開機的主機。&lt;/p>
&lt;p>靜態站的能力邊界很明確：瀏覽器把 HTML/JS 抓下來後，所有邏輯都在使用者的瀏覽器裡跑，沒有任何一段程式碼在你控制的伺服器上執行。這代表任何「要記在你這邊」的資料——誰來看過、表單填了什麼、累積計數——都需要一個瀏覽器以外、由你掌握的接收端。這個接收端不必是傳統伺服器；它可以是一段掛在免費平台上、只在被呼叫時才執行的函式。本指南教怎麼用這種函式把靜態站的能力補齊。&lt;/p>
&lt;p>貫穿全指南的案例是「幫這個架在 GitHub Pages 上的 blog 做流量統計」。GitHub Pages 不給 access log、也不能跑伺服器端程式碼，所以流量資料只能靠瀏覽器主動回報。這個案例會從模組零的架構推導、一路實作到模組六的資料判讀，讀者跟著做完會得到一個真的能用、資料存在自己試算表裡的流量統計系統——「自己手上」在這裡指的是資料的控制權與查詢自由，基礎設施仍然是 Google 託管的。&lt;/p>
&lt;p>第一種膠水工具選 Google Apps Script + Google Sheets：它對個人 Google 帳號免費、不需要信用卡、Sheets 直接當資料庫兼儀表板，起步門檻是所有選項裡最低的。指南後續會加入其他膠水工具（例如 Cloudflare Workers）作為對照，說明各自的適用邊界——Apps Script 適合資料量小、需要人可直接讀寫試算表的場景；當量體變大或需要低延遲時，模組五會給出換工具的判準與遷移路徑。&lt;/p>
&lt;h2 id="教材邊界">教材邊界&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>放在本指南&lt;/th>
 &lt;th>不放在本指南&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>心智模型&lt;/td>
 &lt;td>靜態站能力邊界、膠水層架構、client beacon、免費額度的思考方式&lt;/td>
 &lt;td>大型後端架構、微服務拆分&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>工具地基&lt;/td>
 &lt;td>Apps Script web app 部署模型（&lt;code>doGet&lt;/code>/&lt;code>doPost&lt;/code>）、授權模型、V8 runtime&lt;/td>
 &lt;td>Apps Script 在 Workspace 企業版的進階整合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實作&lt;/td>
 &lt;td>beacon 前端、接收端 handler、Sheets 讀寫、觸發器排程、配額與安全&lt;/td>
 &lt;td>商業級 analytics 平台的自建（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料品質&lt;/td>
 &lt;td>訪客識別、事件模型、自動化流量辨識、靜默失效診斷&lt;/td>
 &lt;td>跨裝置身分解析、廣告歸因、第三方資料串接&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>選型&lt;/td>
 &lt;td>自建 vs 現成分析服務、Apps Script vs Workers 的適用邊界、何時該換工具&lt;/td>
 &lt;td>雲端主機比價、Kubernetes&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>流量分析的&lt;strong>概念層&lt;/strong>（事件分類、漏斗、cohort、歸因）不在本指南，在 &lt;a href="https://tarrragon.github.io/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系&lt;/a>。本指南是&lt;strong>動手做&lt;/strong>的那一半：怎麼用免費工具把資料真的收進來、存起來、彙總出報表。兩者互補——先看 Monitoring 想清楚要收什麼，再回本指南把管線搭起來。&lt;/p>
&lt;h2 id="backlog">Backlog&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>類型&lt;/th>
 &lt;th>前置條件&lt;/th>
 &lt;th>規模&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>雙事件配對的彙總實作（平均停留、閱讀深度）&lt;/td>
 &lt;td>主章&lt;/td>
 &lt;td>模組四彙總改寫成事件感知版本&lt;/td>
 &lt;td>1 篇&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cloudflare Workers 對照與遷移路徑&lt;/td>
 &lt;td>主章&lt;/td>
 &lt;td>實機驗證免費額度與部署流程&lt;/td>
 &lt;td>2 篇&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrer-Policy 知識卡&lt;/td>
 &lt;td>知識卡&lt;/td>
 &lt;td>無（模組六已完整展開、判定它是否仍需獨立卡）&lt;/td>
 &lt;td>1 張&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>現成分析服務的實測對照&lt;/td>
 &lt;td>vendor&lt;/td>
 &lt;td>實測 Plausible / Umami / GoatCounter 的免費額度與能力&lt;/td>
 &lt;td>1 篇&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Monitoring 對本指南的反向連結&lt;/td>
 &lt;td>跨模組&lt;/td>
 &lt;td>盤點 monitoring 各模組的實作落點&lt;/td>
 &lt;td>小&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>跨模組那一列是目前唯一的結構性不對稱：本指南對 monitoring 有五條引用，反向零條，而 monitoring 的漏斗與 cohort 分析都預設資料乾淨、可識別、無機器混入——那些前提正是模組六在處理的。&lt;/p>
&lt;p>模組六補上了資料判讀，但彙總層仍停在模組四的單事件版本——那裡目前只標明了前提與修法方向，尚未有一篇把「配對進入與離開事件、算出平均停留與閱讀深度」實作出來。順序上它依賴模組六的事件模型定案，因此排在其後。&lt;/p>
&lt;h2 id="章節">章節&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/00-mental-model/" data-link-title="模組零：心智模型" data-link-desc="判斷一個動態需求要不要伺服器、資料從哪個接收端進來、免費額度撐得住多大量時的思考框架">模組零：心智模型&lt;/a>&lt;/td>
 &lt;td>靜態站能力邊界、膠水層、client beacon 架構、免費額度、自建 vs 現成服務、GAS vs Workers 選型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基&lt;/a>&lt;/td>
 &lt;td>Apps Script 是什麼、V8 runtime、web app 部署模型、授權模型、跟一般伺服器的差異&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作&lt;/a>&lt;/td>
 &lt;td>前端 beacon、接收端 handler、寫進 Sheet、CORS 的雷與解法，從零到第一筆&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫&lt;/a>&lt;/td>
 &lt;td>&lt;code>appendRow&lt;/code>、資料模型、並發與 &lt;code>LockService&lt;/code>、Sheets 的容量邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程&lt;/a>&lt;/td>
 &lt;td>time-driven trigger 每日彙總、&lt;code>onFormSubmit&lt;/code>、把原始 log 變成日報&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全&lt;/a>&lt;/td>
 &lt;td>部署權限、免費配額上限、CORS 設定、防濫用、隱私邊界與同意機制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後&lt;/a>&lt;/td>
 &lt;td>訪客識別與 opt-out、事件模型與停留時間、辨識自動化流量、假故障與靜默失效診斷&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>模組零到五走的是「怎麼把管線接起來」，那條路徑上的每個障礙都能從文件讀出來。模組六的位置不同——它處理管線接好、資料真的開始累積之後才浮現的問題：來源網址大量空白、任兩列之間看不出是不是同一個人、真人與機器抓取混在一起。這些無法在設計階段推導，只有真實流量打進來才會現形。&lt;/p></description><content:encoded><![CDATA[<p>本指南處理一類具體的工程問題：手上有一個沒有後端的靜態站或小專案，需要一點點動態能力（收一筆表單、記一次瀏覽、每天彙總一次資料），但這個需求還不到、也不值得租一台伺服器的程度。核心概念是「膠水層」——用別人已經在跑、且對個人用量免費的雲端服務，補上靜態站缺的那一小塊伺服器端邏輯，讓你不必自己維護一台開機的主機。</p>
<p>靜態站的能力邊界很明確：瀏覽器把 HTML/JS 抓下來後，所有邏輯都在使用者的瀏覽器裡跑，沒有任何一段程式碼在你控制的伺服器上執行。這代表任何「要記在你這邊」的資料——誰來看過、表單填了什麼、累積計數——都需要一個瀏覽器以外、由你掌握的接收端。這個接收端不必是傳統伺服器；它可以是一段掛在免費平台上、只在被呼叫時才執行的函式。本指南教怎麼用這種函式把靜態站的能力補齊。</p>
<p>貫穿全指南的案例是「幫這個架在 GitHub Pages 上的 blog 做流量統計」。GitHub Pages 不給 access log、也不能跑伺服器端程式碼，所以流量資料只能靠瀏覽器主動回報。這個案例會從模組零的架構推導、一路實作到模組六的資料判讀，讀者跟著做完會得到一個真的能用、資料存在自己試算表裡的流量統計系統——「自己手上」在這裡指的是資料的控制權與查詢自由，基礎設施仍然是 Google 託管的。</p>
<p>第一種膠水工具選 Google Apps Script + Google Sheets：它對個人 Google 帳號免費、不需要信用卡、Sheets 直接當資料庫兼儀表板，起步門檻是所有選項裡最低的。指南後續會加入其他膠水工具（例如 Cloudflare Workers）作為對照，說明各自的適用邊界——Apps Script 適合資料量小、需要人可直接讀寫試算表的場景；當量體變大或需要低延遲時，模組五會給出換工具的判準與遷移路徑。</p>
<h2 id="教材邊界">教材邊界</h2>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>放在本指南</th>
          <th>不放在本指南</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>心智模型</td>
          <td>靜態站能力邊界、膠水層架構、client beacon、免費額度的思考方式</td>
          <td>大型後端架構、微服務拆分</td>
      </tr>
      <tr>
          <td>工具地基</td>
          <td>Apps Script web app 部署模型（<code>doGet</code>/<code>doPost</code>）、授權模型、V8 runtime</td>
          <td>Apps Script 在 Workspace 企業版的進階整合</td>
      </tr>
      <tr>
          <td>實作</td>
          <td>beacon 前端、接收端 handler、Sheets 讀寫、觸發器排程、配額與安全</td>
          <td>商業級 analytics 平台的自建（見 <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring</a>）</td>
      </tr>
      <tr>
          <td>資料品質</td>
          <td>訪客識別、事件模型、自動化流量辨識、靜默失效診斷</td>
          <td>跨裝置身分解析、廣告歸因、第三方資料串接</td>
      </tr>
      <tr>
          <td>選型</td>
          <td>自建 vs 現成分析服務、Apps Script vs Workers 的適用邊界、何時該換工具</td>
          <td>雲端主機比價、Kubernetes</td>
      </tr>
  </tbody>
</table>
<p>流量分析的<strong>概念層</strong>（事件分類、漏斗、cohort、歸因）不在本指南，在 <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系</a>。本指南是<strong>動手做</strong>的那一半：怎麼用免費工具把資料真的收進來、存起來、彙總出報表。兩者互補——先看 Monitoring 想清楚要收什麼，再回本指南把管線搭起來。</p>
<h2 id="backlog">Backlog</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>類型</th>
          <th>前置條件</th>
          <th>規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>雙事件配對的彙總實作（平均停留、閱讀深度）</td>
          <td>主章</td>
          <td>模組四彙總改寫成事件感知版本</td>
          <td>1 篇</td>
      </tr>
      <tr>
          <td>Cloudflare Workers 對照與遷移路徑</td>
          <td>主章</td>
          <td>實機驗證免費額度與部署流程</td>
          <td>2 篇</td>
      </tr>
      <tr>
          <td>Referrer-Policy 知識卡</td>
          <td>知識卡</td>
          <td>無（模組六已完整展開、判定它是否仍需獨立卡）</td>
          <td>1 張</td>
      </tr>
      <tr>
          <td>現成分析服務的實測對照</td>
          <td>vendor</td>
          <td>實測 Plausible / Umami / GoatCounter 的免費額度與能力</td>
          <td>1 篇</td>
      </tr>
      <tr>
          <td>Monitoring 對本指南的反向連結</td>
          <td>跨模組</td>
          <td>盤點 monitoring 各模組的實作落點</td>
          <td>小</td>
      </tr>
  </tbody>
</table>
<p>跨模組那一列是目前唯一的結構性不對稱：本指南對 monitoring 有五條引用，反向零條，而 monitoring 的漏斗與 cohort 分析都預設資料乾淨、可識別、無機器混入——那些前提正是模組六在處理的。</p>
<p>模組六補上了資料判讀，但彙總層仍停在模組四的單事件版本——那裡目前只標明了前提與修法方向，尚未有一篇把「配對進入與離開事件、算出平均停留與閱讀深度」實作出來。順序上它依賴模組六的事件模型定案，因此排在其後。</p>
<h2 id="章節">章節</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/automation/00-mental-model/" data-link-title="模組零：心智模型" data-link-desc="判斷一個動態需求要不要伺服器、資料從哪個接收端進來、免費額度撐得住多大量時的思考框架">模組零：心智模型</a></td>
          <td>靜態站能力邊界、膠水層、client beacon 架構、免費額度、自建 vs 現成服務、GAS vs Workers 選型</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a></td>
          <td>Apps Script 是什麼、V8 runtime、web app 部署模型、授權模型、跟一般伺服器的差異</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/02-analytics-beacon/" data-link-title="模組二：流量 beacon 實作" data-link-desc="把「頁面被看了就送一則事件、接收端寫進 Sheet」從零做到收到第一筆真實瀏覽紀錄時的完整實作">模組二：流量 beacon 實作</a></td>
          <td>前端 beacon、接收端 handler、寫進 Sheet、CORS 的雷與解法，從零到第一筆</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/03-sheet-as-database/" data-link-title="模組三：Sheets 當資料庫" data-link-desc="用 Google Sheet 存流量資料時，怎麼處理多個 beacon 同時寫入的並發、設計資料模型、以及判斷資料量到哪會撐不住">模組三：Sheets 當資料庫</a></td>
          <td><code>appendRow</code>、資料模型、並發與 <code>LockService</code>、Sheets 的容量邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/04-triggers-automation/" data-link-title="模組四：觸發器與排程" data-link-desc="用 Apps Script 的時間觸發器把累積的原始瀏覽 log 定時彙總成看得懂的日報，以及觸發器的每日執行配額">模組四：觸發器與排程</a></td>
          <td>time-driven trigger 每日彙總、<code>onFormSubmit</code>、把原始 log 變成日報</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">模組五：部署、配額與安全</a></td>
          <td>部署權限、免費配額上限、CORS 設定、防濫用、隱私邊界與同意機制</td>
      </tr>
      <tr>
          <td><a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a></td>
          <td>訪客識別與 opt-out、事件模型與停留時間、辨識自動化流量、假故障與靜默失效診斷</td>
      </tr>
  </tbody>
</table>
<p>模組零到五走的是「怎麼把管線接起來」，那條路徑上的每個障礙都能從文件讀出來。模組六的位置不同——它處理管線接好、資料真的開始累積之後才浮現的問題：來源網址大量空白、任兩列之間看不出是不是同一個人、真人與機器抓取混在一起。這些無法在設計階段推導，只有真實流量打進來才會現形。</p>
<h2 id="讀者旅程">讀者旅程</h2>
<p>想直接把流量統計做出來：模組零建立架構直覺後，跳模組二照著實作，缺概念再回模組一補。想完整理解這套膠水模式、之後套用到其他專案：模組零到六順讀。已經收到資料、正要開始看報表：直接進<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六</a>。只想查某個 Apps Script 術語：看 <a href="/blog/automation/knowledge-cards/" data-link-title="Automation 知識卡" data-link-desc="免伺服器自動化的術語索引：beacon、web app 部署、doGet/doPost、執行配額、時間觸發器、瀏覽器指紋、執行項目">knowledge-cards</a>。</p>
<hr>
]]></content:encoded></item><item><title>用 Apps Script 幫這個 blog 做流量統計</title><link>https://tarrragon.github.io/blog/posts/apps-script-blog-analytics/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/posts/apps-script-blog-analytics/</guid><description>&lt;h2 id="為什麼這個-blog-需要一段-beacon">為什麼這個 blog 需要一段 beacon&lt;/h2>
&lt;p>這個 blog 架在 GitHub Pages 上，是純靜態託管——瀏覽器把頁面抓下來後所有邏輯都在使用者端跑，GitHub 的伺服器不給 access log、也不能執行我的程式碼。這代表「有人來看過哪一頁」這個事實，預設沒有留在任何我查得到的地方。想知道流量、又不想為此租一台主機，唯一的切入點是頁面裡的 JavaScript：讓它在載入時主動送一則瀏覽事件到一個我掌握的接收端。這種請求叫 beacon，接收端這裡用 Google Apps Script、資料存進 Google Sheet。&lt;/p>
&lt;p>這篇記錄的是「這個 blog 實際怎麼接線」，延續先前 Hugo + Bear Cub 主題設定的 blog 紀錄脈絡。beacon → Apps Script → Sheet 這套架構背後的通用方法論、選型理由、Google 端的完整部署步驟，整理在&lt;a href="https://tarrragon.github.io/blog/automation/" data-link-title="免伺服器自動化實務指南：用免費雲端服務給靜態站補上動態能力" data-link-desc="不租主機、用免費雲端服務給靜態站或個人專案補上收資料、存資料、排程彙總能力時的實作與選型">免伺服器自動化實務指南&lt;/a>；這裡只講落在本 blog Hugo 版型上的那一段，以及實作時撞到、官方文件不會告訴你的一個編碼落差。&lt;/p>
&lt;h2 id="版型注入點baseof-已備好的-custom_body">版型注入點：baseof 已備好的 custom_body&lt;/h2>
&lt;p>Bear Cub 主題的 &lt;code>baseof.html&lt;/code> 在關閉 &lt;code>&amp;lt;/body&amp;gt;&lt;/code> 前留了一個擴充點：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go-html-template" data-lang="go-html-template">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">partial&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;custom_body.html&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">-}}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這正是 beacon 該進的地方——放在 body 結尾、頁面主要內容都載入後才送，不跟關鍵資源搶頻寬。做法是在 &lt;code>layouts/partials/&lt;/code> 建一個 &lt;code>custom_body.html&lt;/code>，主題會自動引入。這樣 beacon 邏輯集中在一個 partial 裡，不必動主題檔、不必改每一個版型。&lt;/p>
&lt;h2 id="beacon-partial-與開關參數">beacon partial 與開關參數&lt;/h2>
&lt;p>beacon 的網址不寫死在版型裡，而是讀 &lt;code>hugo.toml&lt;/code> 的一個站台參數。這樣做有兩個好處：網址換部署時只改 config、不動程式；參數留空時 partial 完全不輸出，等於一個乾淨的開關。&lt;/p>
&lt;p>&lt;code>layouts/partials/custom_body.html&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go-html-template" data-lang="go-html-template">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">with&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.Site.Params.analyticsBeacon&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$host&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">:=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nx">urls&lt;/span>&lt;span class="na">.Parse&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">$.Site.BaseURL&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="na">.Host&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&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">&amp;lt;&lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span 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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 只在正式站送，本機 hugo server 預覽（localhost）不計入統計
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">location&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">hostname&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$host&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 粗粒度分類閱讀裝置，只送 mobile / tablet / desktop，不送完整 userAgent
&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="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"> 9&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">10&lt;/span>&lt;span class="cl"> &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">11&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">12&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">13&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">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="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">16&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>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &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">20&lt;/span>&lt;span class="cl"> &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="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">22&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="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&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">23&lt;/span>&lt;span class="cl"> &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="p">&amp;lt;/&lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">end&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>hugo.toml&lt;/code> 的 &lt;code>[params]&lt;/code> 加一行，部署好 Apps Script 後把 &lt;code>/exec&lt;/code> 網址填進來就啟用，留空字串就停用：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-toml" data-lang="toml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">[&lt;/span>&lt;span class="nx">params&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="c"># 免伺服器流量統計 beacon：部署 Apps Script web app 後，把 /exec 網址填進來即啟用。&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="c"># 留空字串代表停用（partial 不會輸出任何 script）。&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">analyticsBeacon&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個設計決定值得說明。&lt;strong>用 &lt;code>.Site.Params.analyticsBeacon&lt;/code> 當開關&lt;/strong>，讓「啟用統計」變成一個 config 動作而非改程式，也讓公開的版型檔裡不出現部署網址。&lt;strong>hostname guard 從 &lt;code>.Site.BaseURL&lt;/code> 推導&lt;/strong>（&lt;code>urls.Parse&lt;/code> 取 host），而不是寫死字串，換 domain 時不用改 partial；它的作用是讓本機 &lt;code>hugo server&lt;/code> 預覽時的瀏覽不混進統計——&lt;code>localhost&lt;/code> 不等於正式 host，beacon 就不送。&lt;strong>送 &lt;code>text/plain&lt;/code> 而非 &lt;code>application/json&lt;/code>&lt;/strong>，是為了讓這個跨網域請求成為 CORS simple request、不觸發 preflight；Apps Script 的 web app 無法回應 preflight 的 &lt;code>OPTIONS&lt;/code>，用 JSON 送會被擋，這條雷的細節在&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[<h2 id="為什麼這個-blog-需要一段-beacon">為什麼這個 blog 需要一段 beacon</h2>
<p>這個 blog 架在 GitHub Pages 上，是純靜態託管——瀏覽器把頁面抓下來後所有邏輯都在使用者端跑，GitHub 的伺服器不給 access log、也不能執行我的程式碼。這代表「有人來看過哪一頁」這個事實，預設沒有留在任何我查得到的地方。想知道流量、又不想為此租一台主機，唯一的切入點是頁面裡的 JavaScript：讓它在載入時主動送一則瀏覽事件到一個我掌握的接收端。這種請求叫 beacon，接收端這裡用 Google Apps Script、資料存進 Google Sheet。</p>
<p>這篇記錄的是「這個 blog 實際怎麼接線」，延續先前 Hugo + Bear Cub 主題設定的 blog 紀錄脈絡。beacon → Apps Script → Sheet 這套架構背後的通用方法論、選型理由、Google 端的完整部署步驟，整理在<a href="/blog/automation/" data-link-title="免伺服器自動化實務指南：用免費雲端服務給靜態站補上動態能力" data-link-desc="不租主機、用免費雲端服務給靜態站或個人專案補上收資料、存資料、排程彙總能力時的實作與選型">免伺服器自動化實務指南</a>；這裡只講落在本 blog Hugo 版型上的那一段，以及實作時撞到、官方文件不會告訴你的一個編碼落差。</p>
<h2 id="版型注入點baseof-已備好的-custom_body">版型注入點：baseof 已備好的 custom_body</h2>
<p>Bear Cub 主題的 <code>baseof.html</code> 在關閉 <code>&lt;/body&gt;</code> 前留了一個擴充點：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go-html-template" data-lang="go-html-template"><span class="line"><span class="ln">1</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="nx">partial</span><span class="w"> </span><span class="s">&#34;custom_body.html&#34;</span><span class="w"> </span><span class="na">.</span><span class="w"> </span><span class="cp">-}}</span></span></span></code></pre></div><p>這正是 beacon 該進的地方——放在 body 結尾、頁面主要內容都載入後才送，不跟關鍵資源搶頻寬。做法是在 <code>layouts/partials/</code> 建一個 <code>custom_body.html</code>，主題會自動引入。這樣 beacon 邏輯集中在一個 partial 裡，不必動主題檔、不必改每一個版型。</p>
<h2 id="beacon-partial-與開關參數">beacon partial 與開關參數</h2>
<p>beacon 的網址不寫死在版型裡，而是讀 <code>hugo.toml</code> 的一個站台參數。這樣做有兩個好處：網址換部署時只改 config、不動程式；參數留空時 partial 完全不輸出，等於一個乾淨的開關。</p>
<p><code>layouts/partials/custom_body.html</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go-html-template" data-lang="go-html-template"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="k">with</span><span class="w"> </span><span class="na">.Site.Params.analyticsBeacon</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="nx">$host</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">(</span><span class="nx">urls</span><span class="na">.Parse</span><span class="w"> </span><span class="na">$.Site.BaseURL</span><span class="o">)</span><span class="na">.Host</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln"> 3</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"> 4</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"> 5</span><span class="cl">    <span class="c1">// 只在正式站送，本機 hugo server 預覽（localhost）不計入統計
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="k">if</span> <span class="p">(</span><span class="nx">location</span><span class="p">.</span><span class="nx">hostname</span> <span class="o">!==</span> <span class="cp">{{</span><span class="w"> </span><span class="nx">$host</span><span class="w"> </span><span class="cp">}}</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="c1">// 粗粒度分類閱讀裝置，只送 mobile / tablet / desktop，不送完整 userAgent
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>    <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"> 9</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">10</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">11</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">12</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">13</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">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</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">16</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">17</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">18</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">19</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">20</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">21</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">22</span><span class="cl">    <span class="nx">navigator</span><span class="p">.</span><span class="nx">sendBeacon</span><span class="p">(</span><span class="cp">{{</span><span class="w"> </span><span class="na">.</span><span class="w"> </span><span class="cp">}}</span><span class="p">,</span> <span class="nx">blob</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="p">})();</span>
</span></span><span class="line"><span class="ln">24</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">25</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="k">end</span><span class="w"> </span><span class="cp">}}</span></span></span></code></pre></div><p><code>hugo.toml</code> 的 <code>[params]</code> 加一行，部署好 Apps Script 後把 <code>/exec</code> 網址填進來就啟用，留空字串就停用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="nx">params</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c"># 免伺服器流量統計 beacon：部署 Apps Script web app 後，把 /exec 網址填進來即啟用。</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c"># 留空字串代表停用（partial 不會輸出任何 script）。</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">analyticsBeacon</span> <span class="p">=</span> <span class="s1">&#39;&#39;</span></span></span></code></pre></div><p>三個設計決定值得說明。<strong>用 <code>.Site.Params.analyticsBeacon</code> 當開關</strong>，讓「啟用統計」變成一個 config 動作而非改程式，也讓公開的版型檔裡不出現部署網址。<strong>hostname guard 從 <code>.Site.BaseURL</code> 推導</strong>（<code>urls.Parse</code> 取 host），而不是寫死字串，換 domain 時不用改 partial；它的作用是讓本機 <code>hugo server</code> 預覽時的瀏覽不混進統計——<code>localhost</code> 不等於正式 host，beacon 就不送。<strong>送 <code>text/plain</code> 而非 <code>application/json</code></strong>，是為了讓這個跨網域請求成為 CORS simple request、不觸發 preflight；Apps Script 的 web app 無法回應 preflight 的 <code>OPTIONS</code>，用 JSON 送會被擋，這條雷的細節在<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>
<p>payload 除了路徑、來源、語言，還送一個粗粒度的閱讀裝置標籤 <code>dev</code>（<code>mobile</code> / <code>tablet</code> / <code>desktop</code>），讓我知道讀者用什麼裝置看——這對排版決策有用（例如某篇長表格文章若行動裝置佔比高，就值得檢查手機上的可讀性）。分類刻意只送三選一的標籤、不送完整 <code>userAgent</code>，維持不記 PII 的一致立場。實作上有個要補的判斷：iPadOS 的 Safari 會把 <code>userAgent</code> 偽裝成 Mac 桌機，單看字串會把 iPad 誤判成 <code>desktop</code>，得靠 <code>navigator.maxTouchPoints &gt; 1</code> 搭配 <code>Macintosh</code> 字樣補抓成 <code>tablet</code>。</p>
<h2 id="實跑撞到的雷script-context-的雙重編碼">實跑撞到的雷：script context 的雙重編碼</h2>
<p>第一版的 partial 我在插值上多加了 <code>jsonify</code>，想說手動把值轉成 JSON 字串比較保險：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go-html-template" data-lang="go-html-template"><span class="line"><span class="ln">1</span><span class="cl">if (location.hostname !== <span class="cp">{{</span><span class="w"> </span><span class="nx">$host</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span>) return;
</span></span><span class="line"><span class="ln">2</span><span class="cl">navigator.sendBeacon(<span class="cp">{{</span><span class="w"> </span><span class="na">.</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span>, blob);</span></span></code></pre></div><p>build 出來的結果是壞的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">location</span><span class="p">.</span><span class="nx">hostname</span> <span class="o">!==</span> <span class="s2">&#34;\&#34;tarrragon.github.io\&#34;&#34;</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">navigator</span><span class="p">.</span><span class="nx">sendBeacon</span><span class="p">(</span><span class="s2">&#34;\&#34;https://script.google.com/macros/s/.../exec\&#34;&#34;</span><span class="p">,</span> <span class="nx">blob</span><span class="p">);</span></span></span></code></pre></div><p>網址被包成 <code>&quot;\&quot;...\&quot;&quot;</code>——兩層引號。JS 會把「含引號的整串」當成字串值，<code>sendBeacon</code> 打的是一個帶引號的壞網址，beacon 靜默失敗。</p>
<p>根因是 Hugo 底層的 Go <code>html/template</code> 是 <strong>context-aware</strong> 的：當插值出現在 <code>&lt;script&gt;</code> 區塊裡，它會自動把值當 JS 來輸出，字串會被轉成安全的、帶引號且已跳脫的 JS 字串字面量。也就是說 <code>{{ $host }}</code> 在 script context 裡本來就會渲染成 <code>&quot;tarrragon.github.io&quot;</code>。我再套一層 <code>jsonify</code>，等於編碼兩次。正解是<strong>移除 <code>jsonify</code>，直接寫 <code>{{ $host }}</code> 和 <code>{{ . }}</code></strong>，讓 template 的 context-aware escaping 自己處理——這同時比手動加引號更安全，因為它會針對 script context 正確跳脫。</p>
<p>這種落差官方文件的 fact-check 抓不到：<code>jsonify</code> 和 <code>sendBeacon</code> 各自的文件都正確，錯在「兩者疊在 script context」這個組合。只有真的 build 出來、看渲染結果才會發現。</p>
<h2 id="啟用與驗證">啟用與驗證</h2>
<p>blog 端接好後，啟用一次走三步：</p>
<ol>
<li>依<a href="/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">免伺服器自動化實務指南</a>部署 Apps Script web app，拿到 <code>/exec</code> 網址。</li>
<li>把網址填進 <code>hugo.toml</code> 的 <code>analyticsBeacon</code>，push 讓 GitHub Pages 重建。</li>
<li>打開線上任一頁，回 Google Sheet 看第二列是否出現一筆瀏覽紀錄。</li>
</ol>
<p>要在本機先確認 partial 有正確渲染、又不真的送出，可以用環境變數覆蓋參數 build，再 grep 產物：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">HUGO_PARAMS_ANALYTICSBEACON</span><span class="o">=</span><span class="s2">&#34;https://script.google.com/macros/s/TESTID/exec&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  hugo --destination /tmp/hb
</span></span><span class="line"><span class="ln">3</span><span class="cl">grep -n <span class="s2">&#34;sendBeacon&#34;</span> /tmp/hb/automation/index.html</span></span></code></pre></div><p>看到 <code>navigator.sendBeacon(&quot;https://.../exec&quot;, blob)</code> 這種乾淨的字串字面量、而不是帶跳脫引號的版本，就代表注入正確。hostname guard 也會一起出現在同一段，確認 <code>location.hostname !== &quot;tarrragon.github.io&quot;</code> 這條有生效。</p>
<h2 id="部署前用-curl-直接驗接收端">部署前用 curl 直接驗接收端</h2>
<p>blog 的 beacon 只在正式站送，所以「等 push 上線再看 Sheet」的驗證回饋很慢。更快的做法是用 curl 模擬一則 beacon、直接打 <code>/exec</code>，把 Google 端（部署、<code>doPost</code>、寫入）跟 blog 端拆開驗——Google 端通了再談上線：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">URL</span><span class="o">=</span><span class="s1">&#39;https://script.google.com/macros/s/你的部署ID/exec&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl -sS -L -H <span class="s2">&#34;Content-Type: text/plain;charset=UTF-8&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --data <span class="s1">&#39;{&#34;path&#34;:&#34;/test&#34;,&#34;ref&#34;:&#34;&#34;,&#34;lang&#34;:&#34;zh-TW&#34;,&#34;dev&#34;:&#34;desktop&#34;}&#39;</span> <span class="s2">&#34;</span><span class="nv">$URL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 預期輸出：{&#34;ok&#34;:true}，並在 Sheet 多出一筆 /test 列</span></span></span></code></pre></div><p>這段 curl 有兩個容易踩錯、值得記住的點。<strong>用 <code>--data</code> 觸發 POST，不要加 <code>-X POST</code>。</strong> GAS web app 的 POST 成功後會回一個 302、把 <code>doPost</code> 的輸出放在跳轉後的 <code>googleusercontent.com</code> echo 端點；curl 遇到 302 預設會轉成 GET 去取那個輸出，這是對的。一旦加了 <code>-X POST</code>，curl 會強迫連跳轉後都用 POST，打壞 echo 端點、回一個 Google 雲端硬碟的「很抱歉，目前無法開啟這個檔案」錯誤頁——看起來像部署壞了，其實只是測試指令的方法用錯。瀏覽器的 <code>sendBeacon</code> 沒這問題，這是 curl 特有的落差。</p>
<p><strong>302 是成功、不是錯誤。</strong> GAS web app 的 POST 正常就是回 302，<code>doPost</code> 在請求打到 <code>/exec</code> 的當下已經執行、資料已經寫進 Sheet，跳轉只是去取回應。所以就算 curl 那端因為方法用錯而讀不到 <code>{&quot;ok&quot;:true}</code>，那一筆其實也已經寫進去了——診斷時先去 Sheet 看有沒有新列，比盯著 curl 的輸出準。</p>
<p>如果連 GET 打 <code>/exec</code> 都回「無法開啟檔案」（而不是 GAS 的 <code>找不到指令碼函式：doGet</code>），那才是部署層的問題，優先查「誰可以存取」是不是「所有人」。</p>
<h2 id="這個-exec-網址該公開嗎">這個 /exec 網址該公開嗎</h2>
<p>這個網址必然是公開的，而且非公開不可——它會被寫進每一頁的 client-side JS，任何人檢視原始碼都看得到。這是 client beacon 架構的本質，不是設定失誤；GA、Cloudflare 那些的收集端點也全是公開的。所以安全的問題不是「怎麼把它藏起來」，而是「限制別人拿它能做什麼」。</p>
<p>別人拿到這網址讀不到你的 Sheet：<code>doPost</code> 只做 <code>appendRow</code> 然後回 <code>{&quot;ok&quot;:true}</code>，不回傳任何試算表內容，授權範圍也只綁這一張表。真正的風險是騷擾型的兩種——有人直接 POST 垃圾內容污染統計、或狂打端點吃掉執行配額。對沒沒無聞的個人 blog，攻擊者缺乏動機，這兩者機率都很低。要進一步限制破壞範圍（例如 payload 放一個約定 token、<code>doPost</code> 對不上就丟掉），屬於<a href="/blog/automation/05-deploy-quota-security/" data-link-title="模組五：部署、配額與安全" data-link-desc="把匿名可存取的 beacon 接收端上線後，怎麼守住免費配額、擋掉濫用、保持資料乾淨、以及判斷何時該換更重的工具">配額與濫用防護</a>的層次——注意 token 也在 client JS 裡看得到，它擋隨手亂打、擋不了鐵了心的人。</p>
<h2 id="完整方法論">完整方法論</h2>
<p>這篇只涵蓋 Hugo 版型這一段。beacon 該送什麼、Apps Script 接收端怎麼寫、Sheets 當資料庫的並發與容量、觸發器怎麼把原始 log 彙總成日報、以及匿名端點的配額與濫用防護，都在<a href="/blog/automation/" data-link-title="免伺服器自動化實務指南：用免費雲端服務給靜態站補上動態能力" data-link-desc="不租主機、用免費雲端服務給靜態站或個人專案補上收資料、存資料、排程彙總能力時的實作與選型">免伺服器自動化實務指南</a>。想把同一套搬到別的靜態站或別的膠水工具（例如 Cloudflare Workers），也從那裡的模組零選型開始。</p>
]]></content:encoded></item></channel></rss>