<?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>Localstorage on Tarragon</title><link>https://tarrragon.github.io/blog/tags/localstorage/</link><description>Recent content in Localstorage 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/localstorage/index.xml" rel="self" type="application/rss+xml"/><item><title>訪客識別與 opt-out</title><link>https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/automation/06-reading-the-data/visitor-identity/</guid><description>&lt;p>訪客識別的責任是回答「這兩列記錄是不是同一個人產生的」。這套統計靠一段頁面上的 JavaScript 把瀏覽事件送到自己架的接收端（一支 Google Apps Script 的 web app），再寫進試算表；送出的欄位是頁面路徑、來源網址、語言與裝置類別（模組二建立的那組 payload）。這些欄位描述的都是&lt;strong>單一次瀏覽的屬性&lt;/strong>，沒有任何一個能把兩列連起來。缺了這個能力，報表只能算出「某頁被打開幾次」，算不出「幾個人來過」「有沒有人回訪」「一個人平均讀幾篇」——而後面這些才是判斷內容有沒有被讀進去的依據。&lt;/p>
&lt;p>補上識別能力之前，先看清楚原本被寄予厚望的那個欄位為什麼不夠用——如果進來的問題是「為什麼來源網址幾乎都是空白」，答案在下一節。&lt;/p>
&lt;h2 id="來源網址為什麼大量空白">來源網址為什麼大量空白&lt;/h2>
&lt;p>來源網址（&lt;code>document.referrer&lt;/code>）記錄的是「進到這一頁之前，讀者在哪一頁」。它的值不由接收端決定、也不由當前頁面決定，而是由&lt;strong>上一頁送出請求時附帶的 &lt;code>Referer&lt;/code> 標頭&lt;/strong>決定——這代表控制權在上一頁手上。&lt;/p>
&lt;p>上一頁能控制到什麼程度，由它的 Referrer-Policy 設定。現代瀏覽器的預設值是 &lt;code>strict-origin-when-cross-origin&lt;/code>：跨網域時只送出來源網站的網域（例如 &lt;code>https://www.google.com/&lt;/code>）、不送完整網址；同網域之間才送完整網址。這個預設有一個直接的推論——&lt;strong>在未覆寫預設政策、且換頁是整頁載入的站上，站內導覽會留下來源網址&lt;/strong>，因為那是同網域的跳轉。&lt;/p>
&lt;p>兩個前提都要成立，而它們都可能不成立。站方能用 meta 標籤或回應標頭覆寫政策，設成 &lt;code>no-referrer&lt;/code> 時連站內導覽都不留來源，部分 CDN 與佈景主題會預設加上這類設定。單頁應用程式（client-side routing）換頁時不重新載入文件，&lt;code>document.referrer&lt;/code> 因此停在最初進站的那個值、不隨導覽更新。&lt;/p>
&lt;p>這裡有一個容易混淆的地方：&lt;strong>站方的 Referrer-Policy 決定的是「離開這個站時送出多少來源」，與「收到的訪客帶著什麼來源」是兩件事&lt;/strong>。後者由上一頁的站決定，自己這端管不到。&lt;/p>
&lt;p>前提成不成立可以直接驗：在自己的站上點兩篇文章，看記錄裡有沒有出現自家網域的來源。沒有出現時，下面所有以站內來源為依據的判讀都不適用。&lt;/p>
&lt;p>從這個推論可以反過來判讀空白：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>空白的成因&lt;/th>
 &lt;th>特徵&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>直接輸入網址或書籤&lt;/td>
 &lt;td>沒有上一頁，自然沒有來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>app 內建瀏覽器&lt;/td>
 &lt;td>通訊軟體與社群 app 開啟連結時常設定為不送出來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>原生程式喚起系統瀏覽器&lt;/td>
 &lt;td>郵件用戶端、PDF 閱讀器、桌面版通訊軟體開啟外部連結&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>連結本身帶 &lt;code>rel=&amp;quot;noreferrer&amp;quot;&lt;/code>&lt;/td>
 &lt;td>由來源頁的作者個別設定，與站方的整體政策是兩回事&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>單頁應用程式的站內換頁&lt;/td>
 &lt;td>不重新載入文件，&lt;code>document.referrer&lt;/code> 停在最初進站的值&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>站方覆寫 Referrer-Policy&lt;/td>
 &lt;td>設成 &lt;code>no-referrer&lt;/code> 時連站內導覽都不留來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自動化抓取&lt;/td>
 &lt;td>多數抓取工具直接請求目標網址，沒有導覽過程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>隱私瀏覽器或擴充功能&lt;/td>
 &lt;td>主動剝除來源標頭&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表沒有窮盡所有可能，但它已經足以說明關鍵：&lt;strong>除了「自動化抓取」那一列，其餘全部是真人&lt;/strong>。單看空白這一件事分不出它們，&lt;strong>因此空白不是「缺資料」，它是一個需要其他欄位才能拆開的分類&lt;/strong>。&lt;/p>
&lt;p>拆開要靠交叉判讀，而每一類留下的欄位組合不同。&lt;strong>app 內建瀏覽器&lt;/strong>的記錄通常伴隨行動裝置分類，部分 webview 回報不到視窗尺寸而語言偏好正常。&lt;strong>自動化抓取&lt;/strong>在裝置欄多半落在桌機、語言偏好常是空的或單一英文，而且&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">自動化訊號欄&lt;/a>會有命中。這一類的佔比通常比預期低——beacon 靠 JavaScript 觸發，而多數抓取工具根本不執行 JS、不會出現在這份資料裡。&lt;strong>隱私瀏覽器與擴充功能&lt;/strong>的特徵最不明顯：裝置與語言都正常，識別碼卻可能每次都是新的。&lt;strong>直接輸入、書籤與原生程式喚起&lt;/strong>在這幾個欄位上與一般真人瀏覽沒有差別。它們是排除掉可辨識的那幾類之後剩下的殘差，而殘差裡也包含這張表沒有列到的成因——把殘差整個當成「直接訪問」會高估那一類。&lt;/p>
&lt;p>站內來源的比例可以當成追蹤指標，但它沒有跨站通用的門檻。真人從搜尋結果進來、看完一篇就離開是常見的，內容型網站的站內跳轉本來就偏低，剛上線只有幾篇文章的站更接近零。&lt;strong>同樣的比例在不同的站代表完全不同的事。&lt;/strong>&lt;/p>
&lt;p>能用的作法是先量出自己的基線：取一批組成已知的記錄（例如設定 opt-out 之前自己走過的閱讀路線），算出其中站內來源佔多少，之後用偏離這個基線的幅度判讀，而不是套一個絕對數字。基線的完整量法見&lt;a href="https://tarrragon.github.io/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量&lt;/a>。&lt;/p>
&lt;h2 id="兩層隨機識別碼">兩層隨機識別碼&lt;/h2>
&lt;p>識別的做法是讓瀏覽器自己產生一個隨機值、存起來、每次送 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon&lt;/a> 時一起帶上。這個值不從任何個人資訊推導出來，它只是一個標籤——&lt;strong>同一個標籤出現在多列上，代表那些列來自同一個瀏覽器&lt;/strong>。&lt;/p>
&lt;p>用兩層而非一層，是因為要回答的是兩個不同的問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>識別碼&lt;/th>
 &lt;th>存放位置&lt;/th>
 &lt;th>生命週期&lt;/th>
 &lt;th>回答的問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>訪客識別碼&lt;/td>
 &lt;td>&lt;code>localStorage&lt;/code>&lt;/td>
 &lt;td>直到使用者清除資料&lt;/td>
 &lt;td>這個人有沒有回訪&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>session 識別碼&lt;/td>
 &lt;td>&lt;code>sessionStorage&lt;/code>&lt;/td>
 &lt;td>分頁關閉即消失&lt;/td>
 &lt;td>這一次來看了幾篇&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>訪客識別碼存在 &lt;code>localStorage&lt;/code>，它跨越分頁與瀏覽器重啟繼續存在。&lt;/strong> 同一個訪客識別碼出現在相隔數天的兩列上，代表這個人回來了。這是判斷內容有沒有留住讀者的直接依據，而它無法從單次瀏覽的任何屬性推導出來。&lt;/p>
&lt;p>&lt;strong>session 識別碼存在 &lt;code>sessionStorage&lt;/code>，分頁一關就消失。&lt;/strong> 同一個 session 識別碼底下有五個不同路徑，代表這次來訪讀了五篇。這個數字回答的是「單次閱讀深度」，跟回訪率是正交的兩件事——有人每天回來但每次只看一篇，也有人只來過一次卻一口氣讀完整個模組。&lt;/p>
&lt;p>實作上兩者共用同一段邏輯，差別只在傳入哪個儲存體：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// storage 在隱私模式或停用 cookie 時會丟例外，統計不該因此讓頁面出錯
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="nx">safeGet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="nx">store&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getItem&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="k">catch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">safeSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">value&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">store&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setItem&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">value&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="k">catch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* 存不了就當作無法識別 */&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">stableId&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">existing&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">safeGet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">existing&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="nx">existing&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">fresh&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">window&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">crypto&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">crypto&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">randomUUID&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="o">?&lt;/span> &lt;span class="nx">crypto&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">randomUUID&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="o">:&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">36&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nb">Math&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">random&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">36&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">safeSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">fresh&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">safeGet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">fresh&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">visitorId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">stableId&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">localStorage&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;blog_visitor_id&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">sessionId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">stableId&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">sessionStorage&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;blog_session_id&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個實作細節各自對應一種會實際發生的狀況。&lt;strong>存取包在 try/catch 裡&lt;/strong>，因為 Safari 的私密瀏覽把配額壓到接近零、&lt;code>setItem&lt;/code> 會丟 &lt;code>QuotaExceededError&lt;/code>，而封鎖網站資料的設定會丟 &lt;code>SecurityError&lt;/code>；統計失敗不該讓頁面的其他 JS 一起中斷。&lt;strong>&lt;code>crypto.randomUUID&lt;/code> 有 fallback&lt;/strong>，因為它需要安全連線環境（secure context，HTTPS 或 localhost），在少數環境下不存在。&lt;strong>寫入後再讀一次確認&lt;/strong>，因為 &lt;code>safeSet&lt;/code> 把例外吞掉了——寫入成功與否在這個結構下只能靠讀回來判斷。讀不回來時回傳空字串、彙總時歸為不可識別，比回傳一個「下次就不見了」的值誠實：假的識別碼會讓每次瀏覽看起來都是新訪客。&lt;/p>
&lt;p>這裡有一個附帶的觀察能力：自動化抓取工具每次都在全新的瀏覽器環境執行，所以&lt;strong>每一列都帶著不同的訪客識別碼、而且每個 session 只有一列&lt;/strong>。這個模式不需要額外偵測就會自己浮現。&lt;/p>
&lt;h2 id="退出機制的選型與邊界">退出機制的選型與邊界&lt;/h2>
&lt;p>排除特定瀏覽的機制有三種候選，選擇的依據是各自的技術可行性與副作用。&lt;/p>
&lt;p>&lt;strong>IP 位址在這個架構下不可行。&lt;/strong> Apps Script 的 &lt;a href="https://tarrragon.github.io/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼">&lt;code>doPost(e)&lt;/code>&lt;/a> 事件物件只提供 &lt;code>parameter&lt;/code>、&lt;code>postData&lt;/code>、&lt;code>pathInfo&lt;/code> 這些欄位，不提供任何 HTTP 標頭——接收端讀不到來源 IP。即使技術上讀得到也不該用：家用寬頻的 IP 會變動，行動網路的多個用戶共用同一個對外 IP，用 IP 排除自己會連帶排除掉一批陌生讀者。&lt;/p></description><content:encoded><![CDATA[<p>訪客識別的責任是回答「這兩列記錄是不是同一個人產生的」。這套統計靠一段頁面上的 JavaScript 把瀏覽事件送到自己架的接收端（一支 Google Apps Script 的 web app），再寫進試算表；送出的欄位是頁面路徑、來源網址、語言與裝置類別（模組二建立的那組 payload）。這些欄位描述的都是<strong>單一次瀏覽的屬性</strong>，沒有任何一個能把兩列連起來。缺了這個能力，報表只能算出「某頁被打開幾次」，算不出「幾個人來過」「有沒有人回訪」「一個人平均讀幾篇」——而後面這些才是判斷內容有沒有被讀進去的依據。</p>
<p>補上識別能力之前，先看清楚原本被寄予厚望的那個欄位為什麼不夠用——如果進來的問題是「為什麼來源網址幾乎都是空白」，答案在下一節。</p>
<h2 id="來源網址為什麼大量空白">來源網址為什麼大量空白</h2>
<p>來源網址（<code>document.referrer</code>）記錄的是「進到這一頁之前，讀者在哪一頁」。它的值不由接收端決定、也不由當前頁面決定，而是由<strong>上一頁送出請求時附帶的 <code>Referer</code> 標頭</strong>決定——這代表控制權在上一頁手上。</p>
<p>上一頁能控制到什麼程度，由它的 Referrer-Policy 設定。現代瀏覽器的預設值是 <code>strict-origin-when-cross-origin</code>：跨網域時只送出來源網站的網域（例如 <code>https://www.google.com/</code>）、不送完整網址；同網域之間才送完整網址。這個預設有一個直接的推論——<strong>在未覆寫預設政策、且換頁是整頁載入的站上，站內導覽會留下來源網址</strong>，因為那是同網域的跳轉。</p>
<p>兩個前提都要成立，而它們都可能不成立。站方能用 meta 標籤或回應標頭覆寫政策，設成 <code>no-referrer</code> 時連站內導覽都不留來源，部分 CDN 與佈景主題會預設加上這類設定。單頁應用程式（client-side routing）換頁時不重新載入文件，<code>document.referrer</code> 因此停在最初進站的那個值、不隨導覽更新。</p>
<p>這裡有一個容易混淆的地方：<strong>站方的 Referrer-Policy 決定的是「離開這個站時送出多少來源」，與「收到的訪客帶著什麼來源」是兩件事</strong>。後者由上一頁的站決定，自己這端管不到。</p>
<p>前提成不成立可以直接驗：在自己的站上點兩篇文章，看記錄裡有沒有出現自家網域的來源。沒有出現時，下面所有以站內來源為依據的判讀都不適用。</p>
<p>從這個推論可以反過來判讀空白：</p>
<table>
  <thead>
      <tr>
          <th>空白的成因</th>
          <th>特徵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>直接輸入網址或書籤</td>
          <td>沒有上一頁，自然沒有來源</td>
      </tr>
      <tr>
          <td>app 內建瀏覽器</td>
          <td>通訊軟體與社群 app 開啟連結時常設定為不送出來源</td>
      </tr>
      <tr>
          <td>原生程式喚起系統瀏覽器</td>
          <td>郵件用戶端、PDF 閱讀器、桌面版通訊軟體開啟外部連結</td>
      </tr>
      <tr>
          <td>連結本身帶 <code>rel=&quot;noreferrer&quot;</code></td>
          <td>由來源頁的作者個別設定，與站方的整體政策是兩回事</td>
      </tr>
      <tr>
          <td>單頁應用程式的站內換頁</td>
          <td>不重新載入文件，<code>document.referrer</code> 停在最初進站的值</td>
      </tr>
      <tr>
          <td>站方覆寫 Referrer-Policy</td>
          <td>設成 <code>no-referrer</code> 時連站內導覽都不留來源</td>
      </tr>
      <tr>
          <td>自動化抓取</td>
          <td>多數抓取工具直接請求目標網址，沒有導覽過程</td>
      </tr>
      <tr>
          <td>隱私瀏覽器或擴充功能</td>
          <td>主動剝除來源標頭</td>
      </tr>
  </tbody>
</table>
<p>這張表沒有窮盡所有可能，但它已經足以說明關鍵：<strong>除了「自動化抓取」那一列，其餘全部是真人</strong>。單看空白這一件事分不出它們，<strong>因此空白不是「缺資料」，它是一個需要其他欄位才能拆開的分類</strong>。</p>
<p>拆開要靠交叉判讀，而每一類留下的欄位組合不同。<strong>app 內建瀏覽器</strong>的記錄通常伴隨行動裝置分類，部分 webview 回報不到視窗尺寸而語言偏好正常。<strong>自動化抓取</strong>在裝置欄多半落在桌機、語言偏好常是空的或單一英文，而且<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">自動化訊號欄</a>會有命中。這一類的佔比通常比預期低——beacon 靠 JavaScript 觸發，而多數抓取工具根本不執行 JS、不會出現在這份資料裡。<strong>隱私瀏覽器與擴充功能</strong>的特徵最不明顯：裝置與語言都正常，識別碼卻可能每次都是新的。<strong>直接輸入、書籤與原生程式喚起</strong>在這幾個欄位上與一般真人瀏覽沒有差別。它們是排除掉可辨識的那幾類之後剩下的殘差，而殘差裡也包含這張表沒有列到的成因——把殘差整個當成「直接訪問」會高估那一類。</p>
<p>站內來源的比例可以當成追蹤指標，但它沒有跨站通用的門檻。真人從搜尋結果進來、看完一篇就離開是常見的，內容型網站的站內跳轉本來就偏低，剛上線只有幾篇文章的站更接近零。<strong>同樣的比例在不同的站代表完全不同的事。</strong></p>
<p>能用的作法是先量出自己的基線：取一批組成已知的記錄（例如設定 opt-out 之前自己走過的閱讀路線），算出其中站內來源佔多少，之後用偏離這個基線的幅度判讀，而不是套一個絕對數字。基線的完整量法見<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>。</p>
<h2 id="兩層隨機識別碼">兩層隨機識別碼</h2>
<p>識別的做法是讓瀏覽器自己產生一個隨機值、存起來、每次送 <a href="/blog/automation/knowledge-cards/beacon/" data-link-title="Beacon" data-link-desc="瀏覽器在頁面事件發生時主動送出、送出後不等回應的一則事件回報請求，用於靜態站把資料回傳給接收端">beacon</a> 時一起帶上。這個值不從任何個人資訊推導出來，它只是一個標籤——<strong>同一個標籤出現在多列上，代表那些列來自同一個瀏覽器</strong>。</p>
<p>用兩層而非一層，是因為要回答的是兩個不同的問題：</p>
<table>
  <thead>
      <tr>
          <th>識別碼</th>
          <th>存放位置</th>
          <th>生命週期</th>
          <th>回答的問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訪客識別碼</td>
          <td><code>localStorage</code></td>
          <td>直到使用者清除資料</td>
          <td>這個人有沒有回訪</td>
      </tr>
      <tr>
          <td>session 識別碼</td>
          <td><code>sessionStorage</code></td>
          <td>分頁關閉即消失</td>
          <td>這一次來看了幾篇</td>
      </tr>
  </tbody>
</table>
<p><strong>訪客識別碼存在 <code>localStorage</code>，它跨越分頁與瀏覽器重啟繼續存在。</strong> 同一個訪客識別碼出現在相隔數天的兩列上，代表這個人回來了。這是判斷內容有沒有留住讀者的直接依據，而它無法從單次瀏覽的任何屬性推導出來。</p>
<p><strong>session 識別碼存在 <code>sessionStorage</code>，分頁一關就消失。</strong> 同一個 session 識別碼底下有五個不同路徑，代表這次來訪讀了五篇。這個數字回答的是「單次閱讀深度」，跟回訪率是正交的兩件事——有人每天回來但每次只看一篇，也有人只來過一次卻一口氣讀完整個模組。</p>
<p>實作上兩者共用同一段邏輯，差別只在傳入哪個儲存體：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// storage 在隱私模式或停用 cookie 時會丟例外，統計不該因此讓頁面出錯
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">safeGet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">try</span> <span class="p">{</span> <span class="k">return</span> <span class="nx">store</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="nx">key</span><span class="p">);</span> <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">function</span> <span class="nx">safeSet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">value</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">try</span> <span class="p">{</span> <span class="nx">store</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="nx">key</span><span class="p">,</span> <span class="nx">value</span><span class="p">);</span> <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* 存不了就當作無法識別 */</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">function</span> <span class="nx">stableId</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="kd">var</span> <span class="nx">existing</span> <span class="o">=</span> <span class="nx">safeGet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">existing</span><span class="p">)</span> <span class="k">return</span> <span class="nx">existing</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kd">var</span> <span class="nx">fresh</span> <span class="o">=</span> <span class="p">(</span><span class="nb">window</span><span class="p">.</span><span class="nx">crypto</span> <span class="o">&amp;&amp;</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">randomUUID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="o">?</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">randomUUID</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="o">:</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">().</span><span class="nx">toString</span><span class="p">(</span><span class="mi">36</span><span class="p">)</span> <span class="o">+</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">random</span><span class="p">().</span><span class="nx">toString</span><span class="p">(</span><span class="mi">36</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">10</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nx">safeSet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">fresh</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="k">return</span> <span class="nx">safeGet</span><span class="p">(</span><span class="nx">store</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span> <span class="o">?</span> <span class="nx">fresh</span> <span class="o">:</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="kd">var</span> <span class="nx">visitorId</span> <span class="o">=</span> <span class="nx">stableId</span><span class="p">(</span><span class="nx">localStorage</span><span class="p">,</span> <span class="s2">&#34;blog_visitor_id&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="kd">var</span> <span class="nx">sessionId</span> <span class="o">=</span> <span class="nx">stableId</span><span class="p">(</span><span class="nx">sessionStorage</span><span class="p">,</span> <span class="s2">&#34;blog_session_id&#34;</span><span class="p">);</span></span></span></code></pre></div><p>三個實作細節各自對應一種會實際發生的狀況。<strong>存取包在 try/catch 裡</strong>，因為 Safari 的私密瀏覽把配額壓到接近零、<code>setItem</code> 會丟 <code>QuotaExceededError</code>，而封鎖網站資料的設定會丟 <code>SecurityError</code>；統計失敗不該讓頁面的其他 JS 一起中斷。<strong><code>crypto.randomUUID</code> 有 fallback</strong>，因為它需要安全連線環境（secure context，HTTPS 或 localhost），在少數環境下不存在。<strong>寫入後再讀一次確認</strong>，因為 <code>safeSet</code> 把例外吞掉了——寫入成功與否在這個結構下只能靠讀回來判斷。讀不回來時回傳空字串、彙總時歸為不可識別，比回傳一個「下次就不見了」的值誠實：假的識別碼會讓每次瀏覽看起來都是新訪客。</p>
<p>這裡有一個附帶的觀察能力：自動化抓取工具每次都在全新的瀏覽器環境執行，所以<strong>每一列都帶著不同的訪客識別碼、而且每個 session 只有一列</strong>。這個模式不需要額外偵測就會自己浮現。</p>
<h2 id="退出機制的選型與邊界">退出機制的選型與邊界</h2>
<p>排除特定瀏覽的機制有三種候選，選擇的依據是各自的技術可行性與副作用。</p>
<p><strong>IP 位址在這個架構下不可行。</strong> Apps Script 的 <a href="/blog/automation/knowledge-cards/doget-dopost/" data-link-title="doGet / doPost" data-link-desc="Apps Script web app 的兩個進入點函式，分別接住 GET 與 POST 請求，決定端點收到請求時執行什麼"><code>doPost(e)</code></a> 事件物件只提供 <code>parameter</code>、<code>postData</code>、<code>pathInfo</code> 這些欄位，不提供任何 HTTP 標頭——接收端讀不到來源 IP。即使技術上讀得到也不該用：家用寬頻的 IP 會變動，行動網路的多個用戶共用同一個對外 IP，用 IP 排除自己會連帶排除掉一批陌生讀者。</p>
<p><strong>Cookie 可行但代價不對等。</strong> Cookie 會隨著每一個同網域的 HTTP 請求自動送出，而靜態站託管平台根本不讀它——頁面、圖片、CSS、字型的每一個請求都多帶一段用不到的資料。單筆負擔很小，但它落在讀者的每一次連線上，換來的功能與存在 <code>localStorage</code> 完全相同。</p>
<p><strong><code>localStorage</code> 只在 JS 主動讀取時才被用到</strong>，不污染任何請求，容量也充裕。整段邏輯放在 beacon 送出之前，讀到標記就直接返回：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">OPTOUT_KEY</span> <span class="o">=</span> <span class="s2">&#34;blog_analytics_optout&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// 設定入口用網址參數，讓不熟悉開發者工具的讀者也能操作
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="kd">var</span> <span class="nx">query</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URLSearchParams</span><span class="p">(</span><span class="nx">location</span><span class="p">.</span><span class="nx">search</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">query</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;noanalytics&#34;</span><span class="p">)</span> <span class="o">===</span> <span class="s2">&#34;1&#34;</span><span class="p">)</span> <span class="nx">safeSet</span><span class="p">(</span><span class="nx">localStorage</span><span class="p">,</span> <span class="nx">OPTOUT_KEY</span><span class="p">,</span> <span class="s2">&#34;1&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">query</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;noanalytics&#34;</span><span class="p">)</span> <span class="o">===</span> <span class="s2">&#34;0&#34;</span><span class="p">)</span> <span class="p">{</span> <span class="k">try</span> <span class="p">{</span> <span class="nx">localStorage</span><span class="p">.</span><span class="nx">removeItem</span><span class="p">(</span><span class="nx">OPTOUT_KEY</span><span class="p">);</span> <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{}</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">// 判定：有標記就不送
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="nx">safeGet</span><span class="p">(</span><span class="nx">localStorage</span><span class="p">,</span> <span class="nx">OPTOUT_KEY</span><span class="p">)</span> <span class="o">===</span> <span class="s2">&#34;1&#34;</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span></span></span></code></pre></div><p>開啟一次 <code>https://網域/?noanalytics=1</code>，該瀏覽器之後就不再送出統計。這段程式碼與前面的 <code>safeGet</code> / <code>safeSet</code> 同屬 beacon 那個立即執行函式，<code>return</code> 讓整個統計流程在此中止。</p>
<p>這個機制的邊界要說清楚，因為它決定了使用方式。<strong>設定綁定「瀏覽器 + 來源」這個組合</strong>，不綁人也不綁裝置——<code>localStorage</code> 依 origin（協定、網域與 port 的組合）分割、路徑不影響，所以同一台電腦的不同瀏覽器要各設定一次，手機要另外設定。<strong>隱私瀏覽視窗的設定活不過該視窗</strong>：Chrome 無痕與 Firefox 隱私視窗寫得進去、當次有效，關閉最後一個私密分頁時清除；Safari 私密瀏覽則因為配額接近零而根本寫不進去。<strong>清除網站資料時會一併消失</strong>，需要重新設定。</p>
<p>還有一個平台特性值得知道：Safari 的追蹤預防機制（Intelligent Tracking Prevention，ITP）會清除網站以指令碼寫入的儲存資料，觸發條件是「連續七個有使用 Safari 的日子裡，沒有在該站發生過使用者互動」。這個天數是廠商政策、改過不只一次（曾經是三十天），引用時要回頭確認現行值。計時單位是使用瀏覽器的天數而非日曆天，重置條件是互動而非單純造訪。這既影響退出設定，也代表 Safari 讀者的回訪率會被系統性低估——同一個人隔一段時間回來會拿到新的訪客識別碼，在資料上看起來是新訪客。判讀回訪數字時要記得這個偏差的方向。</p>
<h2 id="隱私邊界">隱私邊界</h2>
<p>這套識別的性質是<strong>第一方隨機標籤</strong>：值由瀏覽器產生、只送到自己的接收端、不含任何從個人資訊推導出來的成分、也不與任何外部服務交換。它能回答的是「這幾列來自同一個瀏覽器」，回答不了「這個人是誰」。</p>
<p>相對地，有幾件事是刻意不做的。<strong>不送完整的 <code>userAgent</code></strong>，只送 mobile / tablet / desktop 三選一的裝置分類——完整字串帶著版本與系統細節，組合起來足以構成<a href="/blog/automation/knowledge-cards/browser-fingerprint/" data-link-title="Browser Fingerprint（瀏覽器指紋）" data-link-desc="由多個單獨無害的瀏覽器環境屬性組合而成的裝置特徵集合，決定自建統計蒐集判別訊號時的能力上限">瀏覽器指紋</a>。<strong>不送螢幕解析度、時區、字型清單</strong>這類單獨無害但組合起來高度唯一的欄位。<strong>不記錄頁面上的任何輸入內容</strong>。</p>
<p>還有一項刻意的選擇藏在程式碼裡、值得寫出來：<strong>路徑送的是 <code>location.pathname</code> 而不是 <code>location.href</code></strong>，差別在於前者不含 query string。搜尋關鍵字、追蹤參數、以及任何被塞進網址的識別碼都因此不會進入資料。這一行如果哪天被改成 <code>href</code>，上面整段隱私說明會在沒有任何人察覺的情況下失真。</p>
<p>有一個欄位需要如實交代：<strong>來源網址送的是完整值</strong>。同網域導覽時它是站內另一篇文章的完整網址，跨站時多半只有對方的網域。它不是這個站產生的資訊，但它確實被記錄下來。</p>
<p>公開這些界線本身也是設計的一部分：實作是靜態站的一段 JS，任何人都能讀原始碼驗證，所以說明必須與實作一致。退出的入口要放在讀者找得到的地方，而不是只寫在原始碼註解裡。</p>
<h2 id="下一步">下一步</h2>
<p>記錄之間有了關聯，但每列承載的資訊仍然停在「這一頁被打開了」。讀者停留多久、有沒有真的在讀，要靠第二則事件才量得到——<a href="/blog/automation/06-reading-the-data/event-model/" data-link-title="事件模型與停留時間" data-link-desc="每次瀏覽只記一列時看不出讀者停留多久、有沒有真的在讀；補上離開事件之後，那個秒數的語意與既有報表公式的連帶影響">事件模型與停留時間</a>講怎麼拆、以及那個秒數實際量到的是什麼。</p>
<p>已經想處理「哪些記錄是機器產生的」，可以跳過事件模型直接看<a href="/blog/automation/06-reading-the-data/automated-traffic/" data-link-title="辨識自動化流量" data-link-desc="接收端讀不到 HTTP 標頭時在前端蒐集訊號分辨機器與真人；多數 AI 訓練爬蟲不執行 JavaScript，因此不會出現在這份統計裡">辨識自動化流量</a>，但那一章的行為訊號建立在事件模型上，讀起來會少一層。隱私與防濫用的完整討論在<a href="/blog/automation/05-deploy-quota-security/quota-abuse-privacy/" data-link-title="配額、濫用防護、隱私與遷移訊號" data-link-desc="免費配額實際碰撞時會怎樣、擋髒資料與過濾自己瀏覽的做法、不記 PII 的隱私立場、以及量大到該離開 Sheets 的訊號">模組五的配額、防濫用與隱私邊界</a>。</p>
]]></content:encoded></item></channel></rss>