<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>模組二：流量 beacon 實作 on Tarragon</title><link>https://tarrragon.github.io/blog/automation/02-analytics-beacon/</link><description>Recent content in 模組二：流量 beacon 實作 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 06 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/automation/02-analytics-beacon/index.xml" rel="self" type="application/rss+xml"/><item><title>前端 beacon 與 CORS 障礙</title><link>https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/02-analytics-beacon/frontend-beacon/</guid><description>&lt;p>前端 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 的責任是：在頁面載入時偵測「這一頁被看了」，把一則最小事件送到 Apps Script 的接收端，而且不能拖慢頁面、不能因為送失敗而影響閱讀。做對這件事的關鍵，是選一種&lt;strong>不會觸發 CORS preflight&lt;/strong> 的送出方式——這是靜態站打 Apps Script 最容易卡住的地方，先講清楚為什麼。&lt;/p>
&lt;h2 id="cors-preflight-為什麼會擋住-beacon">CORS preflight 為什麼會擋住 beacon&lt;/h2>
&lt;p>瀏覽器對跨網域的請求有一套安全機制。當網頁（你的 blog，網域 A）要送一個請求到另一個網域（Apps Script，網域 B），而這個請求「不夠單純」時，瀏覽器會先偷偷送一個 &lt;code>OPTIONS&lt;/code> 請求去問網域 B：「我等一下要送這種請求，你允許嗎？」這個預先詢問叫 &lt;strong>preflight&lt;/strong>。要等網域 B 回答允許，真正的請求才會送出。&lt;/p>
&lt;p>Apps Script 在這裡有一個結構性的問題：它的 web app &lt;strong>無法回應 &lt;code>OPTIONS&lt;/code> 請求&lt;/strong>。原因是 Apps Script 只有 &lt;code>doGet&lt;/code> 和 &lt;code>doPost&lt;/code> 兩個進入點，沒有 &lt;code>doOptions&lt;/code>；preflight 送來的 &lt;code>OPTIONS&lt;/code> 打不到你的程式，Apps Script 平台直接用預設方式回應，而那個回應不帶「我允許」的 CORS 標頭。於是 preflight 失敗、真正的請求根本沒送出。這就是「我照 API 教學寫了 &lt;code>fetch&lt;/code> 打 Apps Script，瀏覽器 console 一片 CORS 紅字」的根本原因——問題不在你的程式，在於 preflight 發生在你的程式被執行&lt;strong>之前&lt;/strong>，你沒有機會處理它。&lt;/p>
&lt;p>避開的方法是讓請求「夠單純」，單純到瀏覽器判定不需要 preflight。這種請求叫 &lt;strong>simple request&lt;/strong>，條件之一是 &lt;code>Content-Type&lt;/code> 必須是 &lt;code>text/plain&lt;/code>、&lt;code>application/x-www-form-urlencoded&lt;/code> 或 &lt;code>multipart/form-data&lt;/code> 其中之一。關鍵就在這：&lt;strong>只要用 &lt;code>text/plain&lt;/code> 送，就不會有 preflight&lt;/strong>，請求直接送達，Apps Script 的 &lt;code>doPost&lt;/code> 正常收到。用 &lt;code>application/json&lt;/code> 送則會觸發 preflight、然後失敗。&lt;/p>
&lt;h2 id="用-sendbeacon-送-textplain">用 sendBeacon 送 text/plain&lt;/h2>
&lt;p>&lt;code>navigator.sendBeacon&lt;/code> 是瀏覽器為「送出後就不管」這種場景設計的 API，正好適合流量 beacon。它有三個特性剛好對上需求：送出後不阻塞頁面、即使使用者馬上關頁面也保證在背景送完、而且它送出的 &lt;code>Content-Type&lt;/code> 預設就是 &lt;code>text/plain&lt;/code>（當你傳字串或 text 型別的 Blob 時）——自動滿足 simple request 條件，不觸發 preflight。&lt;/p>
&lt;p>最小可用的 beacon 長這樣：&lt;/p>





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- layouts/_default/baseof.html，在 &lt;/body&gt; 前 --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">{{ partial &#34;beacon.html&#34; . }}</span></span></code></pre></div><p>放在 <code>&lt;/body&gt;</code> 前、而不是 <code>&lt;head&gt;</code> 裡，是為了讓 beacon 在頁面主要內容都載入後才送，不跟關鍵資源搶頻寬。<code>sendBeacon</code> 本身就不阻塞，這個位置只是讓它更晚一點、更不影響體驗。</p>
<p>一個上線前要注意的邊界：本機 <code>hugo server</code> 預覽時 beacon 也會照送，會把開發時的瀏覽混進統計。實務上會加一個判斷，只在正式網域才送——例如檢查 <code>location.hostname</code> 是不是正式網域，是才執行。這個 hostname guard 擋掉本機預覽；「在正式站上自己重整」則需要另一套機制，見<a href="/blog/automation/06-reading-the-data/visitor-identity/" data-link-title="訪客識別與 opt-out" data-link-desc="流量記錄裡的來源網址大量空白、任兩列之間看不出是不是同一個人時，補上識別欄位並保留退出機制">訪客識別與 opt-out</a>。防濫用與資料乾淨度的整體討論在模組五。</p>
<h2 id="下一步">下一步</h2>
<p>前端會送了，但現在 beacon 送出去還沒有人接。接收端要怎麼解析這個 <code>text/plain</code> 的 JSON、怎麼安全寫進 Sheet、部署後怎麼確認收到第一筆——見<a href="/blog/automation/02-analytics-beacon/receiver-handler/" data-link-title="接收端 handler：寫進第一筆" data-link-desc="Apps Script 這端怎麼解析 text/plain 的 beacon、用伺服器時間補上時間戳、append 進 Sheet，並在部署後確認收到第一筆真實瀏覽">接收端 handler：寫進第一筆</a>。想先補齊 <code>doPost</code> 跟部署模型的背景，回<a href="/blog/automation/01-apps-script-basics/" data-link-title="模組一：Apps Script 地基" data-link-desc="搞懂 Apps Script 的 web app 部署模型與授權模型，才不會在做 beacon 時卡在網址打不通或權限被擋">模組一：Apps Script 地基</a>。資料開始累積之後怎麼讀出意義，見<a href="/blog/automation/06-reading-the-data/" data-link-title="模組六：收到資料之後" data-link-desc="流量統計上線、資料開始累積，但欄位讀不出「這是誰、是不是同一個人、是不是機器」時的判讀與補強">模組六：收到資料之後</a>。</p>
]]></content:encoded></item><item><title>接收端 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></channel></rss>