訪客識別的責任是回答「這兩列記錄是不是同一個人產生的」。這套統計靠一段頁面上的 JavaScript 把瀏覽事件送到自己架的接收端(一支 Google Apps Script 的 web app),再寫進試算表;送出的欄位是頁面路徑、來源網址、語言與裝置類別(模組二建立的那組 payload)。這些欄位描述的都是單一次瀏覽的屬性,沒有任何一個能把兩列連起來。缺了這個能力,報表只能算出「某頁被打開幾次」,算不出「幾個人來過」「有沒有人回訪」「一個人平均讀幾篇」——而後面這些才是判斷內容有沒有被讀進去的依據。

補上識別能力之前,先看清楚原本被寄予厚望的那個欄位為什麼不夠用——如果進來的問題是「為什麼來源網址幾乎都是空白」,答案在下一節。

來源網址為什麼大量空白

來源網址(document.referrer)記錄的是「進到這一頁之前,讀者在哪一頁」。它的值不由接收端決定、也不由當前頁面決定,而是由上一頁送出請求時附帶的 Referer 標頭決定——這代表控制權在上一頁手上。

上一頁能控制到什麼程度,由它的 Referrer-Policy 設定。現代瀏覽器的預設值是 strict-origin-when-cross-origin:跨網域時只送出來源網站的網域(例如 https://www.google.com/)、不送完整網址;同網域之間才送完整網址。這個預設有一個直接的推論——在未覆寫預設政策、且換頁是整頁載入的站上,站內導覽會留下來源網址,因為那是同網域的跳轉。

兩個前提都要成立,而它們都可能不成立。站方能用 meta 標籤或回應標頭覆寫政策,設成 no-referrer 時連站內導覽都不留來源,部分 CDN 與佈景主題會預設加上這類設定。單頁應用程式(client-side routing)換頁時不重新載入文件,document.referrer 因此停在最初進站的那個值、不隨導覽更新。

這裡有一個容易混淆的地方:站方的 Referrer-Policy 決定的是「離開這個站時送出多少來源」,與「收到的訪客帶著什麼來源」是兩件事。後者由上一頁的站決定,自己這端管不到。

前提成不成立可以直接驗:在自己的站上點兩篇文章,看記錄裡有沒有出現自家網域的來源。沒有出現時,下面所有以站內來源為依據的判讀都不適用。

從這個推論可以反過來判讀空白:

空白的成因特徵
直接輸入網址或書籤沒有上一頁,自然沒有來源
app 內建瀏覽器通訊軟體與社群 app 開啟連結時常設定為不送出來源
原生程式喚起系統瀏覽器郵件用戶端、PDF 閱讀器、桌面版通訊軟體開啟外部連結
連結本身帶 rel="noreferrer"由來源頁的作者個別設定,與站方的整體政策是兩回事
單頁應用程式的站內換頁不重新載入文件,document.referrer 停在最初進站的值
站方覆寫 Referrer-Policy設成 no-referrer 時連站內導覽都不留來源
自動化抓取多數抓取工具直接請求目標網址,沒有導覽過程
隱私瀏覽器或擴充功能主動剝除來源標頭

這張表沒有窮盡所有可能,但它已經足以說明關鍵:除了「自動化抓取」那一列,其餘全部是真人。單看空白這一件事分不出它們,因此空白不是「缺資料」,它是一個需要其他欄位才能拆開的分類

拆開要靠交叉判讀,而每一類留下的欄位組合不同。app 內建瀏覽器的記錄通常伴隨行動裝置分類,部分 webview 回報不到視窗尺寸而語言偏好正常。自動化抓取在裝置欄多半落在桌機、語言偏好常是空的或單一英文,而且自動化訊號欄會有命中。這一類的佔比通常比預期低——beacon 靠 JavaScript 觸發,而多數抓取工具根本不執行 JS、不會出現在這份資料裡。隱私瀏覽器與擴充功能的特徵最不明顯:裝置與語言都正常,識別碼卻可能每次都是新的。直接輸入、書籤與原生程式喚起在這幾個欄位上與一般真人瀏覽沒有差別。它們是排除掉可辨識的那幾類之後剩下的殘差,而殘差裡也包含這張表沒有列到的成因——把殘差整個當成「直接訪問」會高估那一類。

站內來源的比例可以當成追蹤指標,但它沒有跨站通用的門檻。真人從搜尋結果進來、看完一篇就離開是常見的,內容型網站的站內跳轉本來就偏低,剛上線只有幾篇文章的站更接近零。同樣的比例在不同的站代表完全不同的事。

能用的作法是先量出自己的基線:取一批組成已知的記錄(例如設定 opt-out 之前自己走過的閱讀路線),算出其中站內來源佔多少,之後用偏離這個基線的幅度判讀,而不是套一個絕對數字。基線的完整量法見辨識自動化流量

兩層隨機識別碼

識別的做法是讓瀏覽器自己產生一個隨機值、存起來、每次送 beacon 時一起帶上。這個值不從任何個人資訊推導出來,它只是一個標籤——同一個標籤出現在多列上,代表那些列來自同一個瀏覽器

用兩層而非一層,是因為要回答的是兩個不同的問題:

識別碼存放位置生命週期回答的問題
訪客識別碼localStorage直到使用者清除資料這個人有沒有回訪
session 識別碼sessionStorage分頁關閉即消失這一次來看了幾篇

訪客識別碼存在 localStorage,它跨越分頁與瀏覽器重啟繼續存在。 同一個訪客識別碼出現在相隔數天的兩列上,代表這個人回來了。這是判斷內容有沒有留住讀者的直接依據,而它無法從單次瀏覽的任何屬性推導出來。

session 識別碼存在 sessionStorage,分頁一關就消失。 同一個 session 識別碼底下有五個不同路徑,代表這次來訪讀了五篇。這個數字回答的是「單次閱讀深度」,跟回訪率是正交的兩件事——有人每天回來但每次只看一篇,也有人只來過一次卻一口氣讀完整個模組。

實作上兩者共用同一段邏輯,差別只在傳入哪個儲存體:

 1// storage 在隱私模式或停用 cookie 時會丟例外,統計不該因此讓頁面出錯
 2function safeGet(store, key) {
 3  try { return store.getItem(key); } catch (e) { return null; }
 4}
 5function safeSet(store, key, value) {
 6  try { store.setItem(key, value); } catch (e) { /* 存不了就當作無法識別 */ }
 7}
 8
 9function stableId(store, key) {
10  var existing = safeGet(store, key);
11  if (existing) return existing;
12  var fresh = (window.crypto && crypto.randomUUID)
13    ? crypto.randomUUID()
14    : Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
15  safeSet(store, key, fresh);
16  return safeGet(store, key) ? fresh : "";
17}
18
19var visitorId = stableId(localStorage, "blog_visitor_id");
20var sessionId = stableId(sessionStorage, "blog_session_id");

三個實作細節各自對應一種會實際發生的狀況。存取包在 try/catch 裡,因為 Safari 的私密瀏覽把配額壓到接近零、setItem 會丟 QuotaExceededError,而封鎖網站資料的設定會丟 SecurityError;統計失敗不該讓頁面的其他 JS 一起中斷。crypto.randomUUID 有 fallback,因為它需要安全連線環境(secure context,HTTPS 或 localhost),在少數環境下不存在。寫入後再讀一次確認,因為 safeSet 把例外吞掉了——寫入成功與否在這個結構下只能靠讀回來判斷。讀不回來時回傳空字串、彙總時歸為不可識別,比回傳一個「下次就不見了」的值誠實:假的識別碼會讓每次瀏覽看起來都是新訪客。

這裡有一個附帶的觀察能力:自動化抓取工具每次都在全新的瀏覽器環境執行,所以每一列都帶著不同的訪客識別碼、而且每個 session 只有一列。這個模式不需要額外偵測就會自己浮現。

退出機制的選型與邊界

排除特定瀏覽的機制有三種候選,選擇的依據是各自的技術可行性與副作用。

IP 位址在這個架構下不可行。 Apps Script 的 doPost(e) 事件物件只提供 parameterpostDatapathInfo 這些欄位,不提供任何 HTTP 標頭——接收端讀不到來源 IP。即使技術上讀得到也不該用:家用寬頻的 IP 會變動,行動網路的多個用戶共用同一個對外 IP,用 IP 排除自己會連帶排除掉一批陌生讀者。

Cookie 可行但代價不對等。 Cookie 會隨著每一個同網域的 HTTP 請求自動送出,而靜態站託管平台根本不讀它——頁面、圖片、CSS、字型的每一個請求都多帶一段用不到的資料。單筆負擔很小,但它落在讀者的每一次連線上,換來的功能與存在 localStorage 完全相同。

localStorage 只在 JS 主動讀取時才被用到,不污染任何請求,容量也充裕。整段邏輯放在 beacon 送出之前,讀到標記就直接返回:

1var OPTOUT_KEY = "blog_analytics_optout";
2
3// 設定入口用網址參數,讓不熟悉開發者工具的讀者也能操作
4var query = new URLSearchParams(location.search);
5if (query.get("noanalytics") === "1") safeSet(localStorage, OPTOUT_KEY, "1");
6if (query.get("noanalytics") === "0") { try { localStorage.removeItem(OPTOUT_KEY); } catch (e) {} }
7
8// 判定:有標記就不送
9if (safeGet(localStorage, OPTOUT_KEY) === "1") return;

開啟一次 https://網域/?noanalytics=1,該瀏覽器之後就不再送出統計。這段程式碼與前面的 safeGet / safeSet 同屬 beacon 那個立即執行函式,return 讓整個統計流程在此中止。

這個機制的邊界要說清楚,因為它決定了使用方式。設定綁定「瀏覽器 + 來源」這個組合,不綁人也不綁裝置——localStorage 依 origin(協定、網域與 port 的組合)分割、路徑不影響,所以同一台電腦的不同瀏覽器要各設定一次,手機要另外設定。隱私瀏覽視窗的設定活不過該視窗:Chrome 無痕與 Firefox 隱私視窗寫得進去、當次有效,關閉最後一個私密分頁時清除;Safari 私密瀏覽則因為配額接近零而根本寫不進去。清除網站資料時會一併消失,需要重新設定。

還有一個平台特性值得知道:Safari 的追蹤預防機制(Intelligent Tracking Prevention,ITP)會清除網站以指令碼寫入的儲存資料,觸發條件是「連續七個有使用 Safari 的日子裡,沒有在該站發生過使用者互動」。這個天數是廠商政策、改過不只一次(曾經是三十天),引用時要回頭確認現行值。計時單位是使用瀏覽器的天數而非日曆天,重置條件是互動而非單純造訪。這既影響退出設定,也代表 Safari 讀者的回訪率會被系統性低估——同一個人隔一段時間回來會拿到新的訪客識別碼,在資料上看起來是新訪客。判讀回訪數字時要記得這個偏差的方向。

隱私邊界

這套識別的性質是第一方隨機標籤:值由瀏覽器產生、只送到自己的接收端、不含任何從個人資訊推導出來的成分、也不與任何外部服務交換。它能回答的是「這幾列來自同一個瀏覽器」,回答不了「這個人是誰」。

相對地,有幾件事是刻意不做的。不送完整的 userAgent,只送 mobile / tablet / desktop 三選一的裝置分類——完整字串帶著版本與系統細節,組合起來足以構成瀏覽器指紋不送螢幕解析度、時區、字型清單這類單獨無害但組合起來高度唯一的欄位。不記錄頁面上的任何輸入內容

還有一項刻意的選擇藏在程式碼裡、值得寫出來:路徑送的是 location.pathname 而不是 location.href,差別在於前者不含 query string。搜尋關鍵字、追蹤參數、以及任何被塞進網址的識別碼都因此不會進入資料。這一行如果哪天被改成 href,上面整段隱私說明會在沒有任何人察覺的情況下失真。

有一個欄位需要如實交代:來源網址送的是完整值。同網域導覽時它是站內另一篇文章的完整網址,跨站時多半只有對方的網域。它不是這個站產生的資訊,但它確實被記錄下來。

公開這些界線本身也是設計的一部分:實作是靜態站的一段 JS,任何人都能讀原始碼驗證,所以說明必須與實作一致。退出的入口要放在讀者找得到的地方,而不是只寫在原始碼註解裡。

下一步

記錄之間有了關聯,但每列承載的資訊仍然停在「這一頁被打開了」。讀者停留多久、有沒有真的在讀,要靠第二則事件才量得到——事件模型與停留時間講怎麼拆、以及那個秒數實際量到的是什麼。

已經想處理「哪些記錄是機器產生的」,可以跳過事件模型直接看辨識自動化流量,但那一章的行為訊號建立在事件模型上,讀起來會少一層。隱私與防濫用的完整討論在模組五的配額、防濫用與隱私邊界