辨識自動化流量
辨識自動化流量的責任是在統計裡把機器抓取與真人閱讀分開。未分離的統計會高估閱讀量,而高估的方向是單向的——沒有人會因為數字太好看而去查證。
這一章的作法受兩個限制支配:接收端拿不到判別素材,以及會出現在這份統計裡的機器比想像中少。先講清楚這兩件事,它們決定了訊號從哪裡來、以及能期待抓到什麼。
接收端讀不到請求標頭
Apps Script 的 doPost(e) 事件物件提供 parameter、parameters、queryString、contentLength、postData、pathInfo 這幾個欄位,不提供任何 HTTP 標頭。這代表接收端拿不到 User-Agent,也拿不到來源 IP。
傳統的伺服器端過濾(比對 UA 字串、查 IP 反解、比對已知爬蟲的 IP 區段)在這個架構下全部不可用。所有判斷素材必須由前端蒐集之後放進 payload 送出——這既是限制,也界定了設計空間:能用的訊號只有瀏覽器環境暴露給 JavaScript 的那些。
這個限制連帶產生一條必須守住的界線。前端能取得完整的 userAgent、螢幕尺寸、時區、字型清單,把它們全部送出確實能提高辨識率,但那正好構成瀏覽器指紋——為了分辨機器而蒐集足以識別個人的資料,取捨方向是錯的。後面的每個訊號都在「有判別力」與「不構成指紋」之間取值。
會被 beacon 記錄到的機器只有一小部分
beacon 靠 JavaScript 觸發,所以只有會執行 JS 的抓取工具才會進入這份統計。這一條大幅縮小要辨識的對象範圍,而它與直覺相反:
| 抓取工具 | 執行 JS | 出現在統計裡 |
|---|---|---|
| Googlebot | 會 | 會 |
| 無頭瀏覽器驅動的抓取工具(Playwright / Puppeteer) | 會 | 會 |
| 監控與可用性檢查服務 | 多數會 | 會 |
| GPTBot / ClaudeBot / PerplexityBot 等 AI 訓練爬蟲 | 不會 | 不會 |
| facebookexternalhit 等連結預覽 | 不會 | 不會 |
| CCBot / AhrefsBot / SemrushBot | 不會 | 不會 |
AI 訓練爬蟲不執行 JavaScript 是其中最反直覺的一項。Vercel 在 2025 年對自家平台流量的量測顯示,GPTBot 與 ClaudeBot 都會抓取 JS 檔案卻從不執行;連結預覽服務只讀 HTML 的 meta 標籤。在主流爬蟲裡,目前已知具備完整 JS 渲染的只有 Googlebot——這是對演化中的世界所做的觀察,不是永久成立的分類,各家的渲染能力隨時可能改變。
這件事有三個後續影響。其一,這套統計看不見的機器流量遠多於看得見的——伺服器日誌裡的爬蟲比例與這裡的數字不可比較。其二,下一節那份具名清單接住的是「會執行 JS」與「誠實申報身分」的交集,比清單本身小得多;清單裡多數條目預期永遠不會命中,保留它們是為了接住少數會渲染的變體。
其三會影響上一章的判讀:來源網址空白的成因把「自動化抓取」列為其中一類,而這一節說明了那一類的實際佔比比直覺低——真正大量抓取的工具不執行 JS、根本不在這份資料裡。空白列偏向真人的機率因此比乍看之下高。
訊號分層與信心度
四個訊號的可靠度不同,分開記錄而不是合併成單一結論。
| 訊號 | 來源 | 信心 | 主要誤差方向 |
|---|---|---|---|
webdriver | navigator.webdriver === true | 高 | 幾乎不誤判真人;規避工具會蓋掉 |
ua | UA 字串比對自我申報名稱 | 高 | 不誤判真人;抓不到偽裝者 |
lang | navigator.languages 為空 | 中 | 隱私擴充的真人誤中;新版無頭已不命中 |
size | window.outerWidth 為 0 | 中 | 嵌入式 webview 的真人誤中;同上 |
這個信心排序由誤差方向推導而來,不是本站資料測得的命中率。 沒有標註任何誤判比例是刻意的——那需要一份已知組成的對照流量,而本文的每個判準都停在「這個訊號可能怎麼錯」的層次。既然正確率無從得知,處置方式就不是「先用著、之後校正」,而是讓判定與使用分離:訊號照實記下、判定留在彙總端,改了規則之後可以拿同一批舊資料重跑比對。
navigator.webdriver 是自動化框架的預設標記。 Playwright、Puppeteer、Selenium 啟動的瀏覽器這個值都是 true,一般瀏覽器是 false。它的判別力強而誤判極少,代價是刻意規避偵測的工具第一件事就是把它蓋掉——因此它抓得到的是「沒有隱藏身分意圖」的自動化訪客。
UA 比對抓的是自我申報。 搜尋引擎與正派的抓取服務會在 User-Agent 裡寫明自己是誰,這是它們與網站經營者之間的慣例。比對這個字樣不會誤判真人,因為真人的瀏覽器不會自稱是機器。
空語言清單與零視窗尺寸原本是無頭環境的特徵,而這兩條正在失效。 Chrome 112 起的新無頭模式會建立一個不顯示的平台視窗,並帶有正常的語言偏好,兩項特徵大多不再成立。它們的誤差方向因此從「誤判真人」偏向「漏判機器」——保留是因為舊版工具仍在使用中,但不能當作主力。
因為誤差方向不同,記錄下來的是命中了哪些訊號、而不是一個布林結論:
1function botSignal() {
2 var hits = [];
3 try {
4 if (navigator.webdriver === true) hits.push("webdriver");
5 // UA 比對見下一節
6 if (!navigator.languages || navigator.languages.length === 0) hits.push("lang");
7 if (!window.outerWidth || !window.outerHeight) hits.push("size");
8 } catch (err) {
9 // 偵測本身出錯不該影響統計,當作沒有訊號
10 }
11 return hits.join(",");
12}這個設計讓判定閾值留在彙總端——也就是試算表那一側,資料寫進去之後用公式分類的地方。日後發現某條訊號誤判太多,改公式就好,不必改前端重新部署;而前端一旦部署出去,快取的頁面還會用舊版跑一段時間。
送出的字串寫進試算表的一個欄位,後面稱它為訊號欄。
具名比對與通用退回
UA 比對要回答的問題有兩層:「這是不是機器」以及「是哪一隻機器」。後者的價值在於處置方向不同——搜尋引擎索引是內容被收錄的證據,監控服務是自己設定的,而不具名的抓取工具才是需要留意的那一類。
直覺的作法是列一份已知名單做精確比對。這個作法單獨使用時有一個不會顯現的失效模式:名單會過時,而過時的表現是新出現的抓取工具完全不被標記——統計上呈現為自動化流量比例逐漸下降,而這個下降與「內容吸引到更多真人」在數字上不可區分。
因此比對分成兩層,讓過時的代價落在精度而非覆蓋:
1// 以下兩個常數與比對邏輯接在上一段 botSignal() 的 try 區塊內,共用同一個 hits 陣列
2// 已知抓取工具的自我申報名稱,命中時送出具體名稱
3var KNOWN_BOTS = /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;
4// 通用字樣,接住誠實申報但不在具名清單裡的自動化訪客
5var GENERIC_BOT = /bot\b|crawler|spider|headless|slurp|bingpreview|python-requests|curl\//i;
6
7var ua = navigator.userAgent || "";
8var named = ua.match(KNOWN_BOTS);
9var generic = named ? null : ua.match(GENERIC_BOT);
10if (named) hits.push("ua:" + named[0].toLowerCase());
11else if (generic) hits.push("ua:" + generic[0].toLowerCase().replace(/[^a-z]+$/, ""));兩層的比對來源不同是關鍵:具名層比對的是身分(清單裡的名字),通用層匹配的是類別詞(bot、crawler、spider、headless),後者不需要知道任何一隻抓取工具的名字就能運作。清單過時時,新工具仍然被通用層接住——失去的是「知道是誰」,不是「知道有」。
上面的 GENERIC_BOT 裡混了 slurp、bingpreview、python-requests、curl/ 四個具體名稱,嚴格說它們屬於具名層——把產品名放進退回層,退回層就變成清單的延伸而不是獨立的一層。實際運作上它們是無害的殘留,但這個混用會讓「通用層純靠類別詞」這個保證打折:wget、Go-http-client、node-fetch、axios 這些同樣不含類別詞的 UA,兩層都接不住。
真正獨立於 UA 的那一層是 webdriver / lang / size ——它們讀的是瀏覽器環境而非字串,UA 比對整個失效時仍然運作。三層的關係值得明說:具名層給身分、通用層給類別、環境訊號層給「這個執行環境不像人用的」,而只有第三層完全不依賴任何列舉。
這個結構要能被觀測,標記本身得分得出層級。上面的寫法送出的是 ua:googlebot 與 ua:bot,而 ua:slurp 這種看起來像具名層、其實來自通用層——不比對兩條 regex 就分不出來。加一個前綴讓它自證:
1if (named) hits.push("ua:named:" + named[0].toLowerCase());
2else if (generic) hits.push("ua:generic:" + generic[0].toLowerCase().replace(/[^a-z]+$/, ""));分得出層級之後,COUNTIF(訊號欄, "*ua:generic*") / COUNTIF(訊號欄, "*ua:*") 就是清單的過時指標:這個比例上升代表有新工具在被通用層接住、該更新具名清單了。這把「清單過時」從一件不可見的事變成一個可以看的數字,而且不需要有人事先知道漏了哪些新工具。抽象層的原則見 #251 清單過時的代價要落在精度、不落在覆蓋。
清單維護有一個實際邊界值得先講明。Anthropic 目前把不同用途拆成 ClaudeBot、Claude-User、Claude-SearchBot 多個 token,這類拆分會持續發生,而拆分後的新名稱在被加進清單前由通用層接住——那正是這個設計在運作。
另一個邊界是命名系統的混淆,而這份清單踩過一次。它上線時含有 google-extended 這個條目——那其實是 robots.txt 的控制 token,不會出現在任何請求的 User-Agent 裡,因此它從第一天起就是死的。系統不會回報「某個條目從未命中」,這個缺陷是靠一次外部查核才浮現的。robots.txt 的控制 token 與 UA 字串是兩套獨立的命名,這正是「訊號只能來自瀏覽器暴露給 JavaScript 的東西」這條限制的具體案例。
只送出命中的關鍵字、不送完整 userAgent,是前面那條界線的具體落實——那個關鍵字是抓取工具主動公開申報的身分,不構成指紋特徵。
行為訊號對沒有規避意圖的工具有效,代價是誤判方向指向真人
行為留下的痕跡比環境屬性稍微難改,但它們擋不住有規避意圖的工具。前面對 webdriver 說過的那句讓步在這裡同樣適用,而且成本更低:讓抓取工具在關閉前觸發一次 visibilitychange 是三行程式碼,隨機化頁面存活時間是一行,重用瀏覽器的 storage state 讓識別碼保持穩定是兩行——最後這項甚至提升效率,因為它本來就是自動化測試處理登入狀態的標準做法。
這一節的三個訊號在對象沒有隱藏意圖時判別力最強,同時誤判代價也最高——誤差方向一致指向「把真人判成機器」,而這個方向最難察覺,因為結果看起來是流量變乾淨了。
離開事件缺席是其中最直接的一個。抓取工具取得頁面內容後直接終止瀏覽器環境,不會經歷「頁面轉為不可見」這個狀態轉換,因此不產生離開事件。
真人這一側同樣會缺席,而且不罕見。事件模型列過那份丟失清單:記憶體回收、強制關閉、斷電、網路中斷、擴充功能攔截、接收端併發撞頂。行動流量佔比高的站,真人的離開事件缺席率本來就不低,把缺席直接當成機器訊號會系統性誤判這一群人。
只有離開事件、沒有進入事件這個相反的形態也一樣。可觀測的只有一件事:接收端在進入事件的時間點沒有收到任何請求。抓取工具的網路攔截設定是一種相容的成因——許多框架只放行文件與必要資源,等內容抓完、進入關閉流程時攔截器已經解除——但它只是幾個候選之一。
真人也會產生孤兒離開事件,至少三條路徑:接收端併發撞頂(執行配額的併發上限,見模組五的配額碰撞——爆量時同時湧入的進入事件被拒,離開事件數秒後送達時併發已消退,正好剩下孤兒)、廣告或隱私擴充功能攔截第一次請求、以及網路瞬斷。第一條特別值得注意,因為它與爆量時段重疊,會把流量高峰的真人整批劃給機器。
停留時間的一致性洩漏的是排程。抓取工具的頁面存活時間由設定決定,因此相隔數小時、來自不同識別碼的多列可能有精確相同的秒數。這一條需要區間限定:dur 經過取整為秒,而真人的跳出流量集中在最短的那幾秒,在那個區間裡精確重複是常態而非訊號。分界落在哪一秒由下一節的短停留分佈量出來,不是一個可以跨站沿用的固定值。只有落在跳出區間之外的精確重複才有判別力。
三個訊號各自的觀察都只有個位數的記錄撐著,所以它們指出的是「該往哪裡看」,不是「這一列是什麼」。
先量本站的基線
前面所有訊號都描述「機器可能長什麼樣」,而判定需要的是「這一列與本站的真人有多不一樣」。判準寫成絕對條件(缺席、為零、幾乎沒有)在換一個站之後就不成立:行動流量佔八成的站與桌機為主的站,真人的離開事件缺席率差距很大;剛上線五篇文章的站,真人的站內導覽比例天然接近零。
基線要用一批組成已知的流量來量,而唯一穩定可得的那批是自己的瀏覽:在設定 opt-out 之前刻意走幾條完整的閱讀路線,那批記錄裡每一列是誰產生的都確定。
| 基線指標 | 量法 | 已知的偏差 |
|---|---|---|
| 離開事件缺席率 | 已知樣本裡沒有配對離開事件的進入事件佔比 | 站主多用桌機,行動裝置的缺席率會被低估 |
| 站內來源比例 | 已知樣本裡來源網址為自家網域的佔比 | 站主知道站的結構、走站內導覽,這個值會系統性偏高 |
| 短停留分佈 | 已知樣本的 dur 分佈,找出跳出區間落在哪幾秒 | 站主讀自己的文章,短停留佔比偏低 |
| 孤兒離開事件比例 | 已知樣本裡找不到配對進入事件的比例 | 同一台裝置、同一個網路環境,攔截與斷線情境不足 |
第三欄不是免責聲明,它決定判讀的方向:站內來源比例的基線偏高,代表真實讀者低於這個值是正常的,拿它當「低於就可疑」的門檻會系統性誤判。四個指標的偏差方向都指向同一側——真人看起來比基線「差」。
有一種量法要避開:用「訊號欄乾淨」的記錄當已知樣本。那等於用被校正的偵測器定義校正用的樣本,結論會自我印證。同理,量離開事件缺席率時不能只取互動旗標為 1 的記錄——那個條件蘊含離開事件已經送達,算出來必然是零。
樣本規模也要誠實看待:刻意走幾條路線得到的是數十列,那個量足以看出量級差異(缺席率是一成還是六成),不足以做統計顯著性判斷。判準因此寫成「明顯偏離基線的量級」而非某個百分點門檻。
基線本身要重量:改版換了頁面結構、流量來源組成變化、行動裝置佔比上升,都會讓它漂移。
這一節的限制會累積到哪裡
前面每一節都交代了自己的限制:只有執行 JavaScript 的訪客會被記錄、離開事件會在幾種情境下丟失、訊號的誤差方向一致指向誤判真人。這些限制加上其他篇的(退出機制、配額漏記、儲存清除)共同界定了這份資料的母體,而母體決定了哪些結論下得出來——完整的總帳與外部對照方法見這份統計的分母是什麼。
標記而非丟棄
偵測到疑似自動化流量時有兩種處置:前端直接不送,或照常記錄並加上標記欄位。
這一章從頭到尾談的是標記,不是阻擋。靜態站託管層沒有可以攔請求的地方,這些訊號只在資料進來之後才產生作用——真的要擋抓取工具需要 CDN 或反向代理層的規則,那是另一個主題。
照常記錄買到的是三件不需要 ground truth 的事。 判定的正確率本身無從得知——這套統計沒有一份標好答案的對照資料,前面每個訊號都只能談誤差方向、談不了誤判率。真正的理由在別處:
改了規則之後可以重跑舊資料,比對同一批記錄在新舊規則下的分佈差多少。這回答不了「哪一個對」,但看得出「改動影響了多少列」,而那是決定要不要改的實際依據。
日後認出新的抓取工具時可以回溯標記。今天標成 ua:bot 的那些列,等它被加進具名清單之後仍在表裡,過去半年的歷史因此跟著變得可讀。丟掉的列沒有這個機會。
總量守恆檢查的前提就是列沒有被丟掉。前端過濾會讓分母本身失真,而分母失真時所有比例都不可信。
代價是試算表多存一些列,以及彙總時必須記得過濾。
對免費配額敏感、或流量大到列數成為問題時,取捨會反過來——那時應該只丟棄高信心訊號(webdriver 與具名 UA)命中的部分,中信心與行為訊號仍然照常記錄。
彙總端的分類與條件順序
分類邏輯把訊號與行為組合起來。以下是虛擬碼——欄位名要換成試算表的實際範圍參照,而每個閾值都該換成前面量出的基線值,寫成固定條件只是為了讓結構清楚:
1=IFS(
2 AND(事件="leave", 同session同路徑的view筆數=0), "待查-孤兒離開事件",
3 事件<>"view", "",
4 REGEXMATCH(訊號欄, "webdriver|ua"), "機器-高信心",
5 AND(同session的leave筆數=0, 裝置<>"mobile"), "待查-無離開事件",
6 訊號欄<>"", "訊號弱-待觀察",
7 同session的view筆數=1, "單頁即走",
8 AND(同session的leave筆數>0, 訊號欄=""), "真人",
9 TRUE, "未分類")條件的順序承載語意。孤兒離開事件排在最前面,否則第二條會把所有離開事件留白,那一整類記錄就在統計裡消失了。非進入事件留白避免同一次瀏覽被計數兩次。
分類名稱用「待查」而非「爬蟲」是刻意的:行為訊號給的是需要進一步確認的候選。孤兒離開事件要對照執行項目同時段有沒有失敗紀錄——有的話那是配額問題而非機器。無離開事件那一條排除了行動裝置,因為真人在該環境的缺席率本來就高。
最後兩條分支的分工是刻意的。「真人」寫成顯式條件、catch-all 標成「未分類」,而不是反過來讓沒被接住的列直接落進「真人」——後者是這份報表最被消費、最不會被追問的類別,新的資料形狀靜默落進去只會讓數字變好看。IFS 在所有條件都不成立時回傳 #N/A,而滿欄的錯誤值與滿欄的留白一樣不會被追問,所以 catch-all 要給一個會被看見的標籤。這條防線的完整推導見假故障與靜默失效的診斷。
用總量守恆檢查這份分類時,作用域是進入事件那些列——離開事件被第二條留白,把它們算進分母會讓差額永遠等於離開事件的列數,而一個恆常的巨大差額只會訓練出忽略它的習慣。
下一步
判定規則到位了,接下來要問的是這些規則吃進去的資料本身可不可信。判定寫對了但公式算錯、程式改了但端點沒生效、錯誤訊息指向的位置不是問題的位置——假故障與靜默失效的診斷處理這三類。
爆量時段的漏記是另一回事,它來自配額而非判定,見模組五的配額、防濫用與隱私邊界。
#automation #beacon #bot-detection #user-agent #ai-crawler #apps-script #data-quality