昨天的結論很殘酷:如果你沒有被 Enterprise 方案包養,又想拿到那些絲絲入扣的 AI 爬蟲造訪細節,你唯一能走的自助路線只有一條,就是在邊緣節點 (Edge) 自己寫一支 Worker 把他們記下來。
今天我們就把這支監視器親手寫出來。不到 90 行程式碼,不需要依賴任何外部套件,也不綁死任何特定服務。你要記下什麼特徵、把這些秘密送到哪個房間去,全部由你自己作主。
這支程式我已經在本機用 wrangler dev 搭配 Miniflare 跑過完整的殘酷測試。底下的輸出結果我會原封不動地貼上來,包含那些不太體面的地雷。底下描述的所有 Cloudflare 行為皆截至 2026-09-07,出處全在文末的證據表裡。
這支精簡版的監視器必須死守五條底線,少一條,你的正式環境絕對會出人命:
verifiedBotCategory)。我們刻意先不做的有這些:不驗證 IP/ASN 的真實身分 (那是 Day 4 的深水區,這版還擋不住冒牌貨)、不搞抽樣 (AI 爬蟲的流量通常少得可憐,全記就對了)、絕對不在 Worker 裡面存資料 (第六節會告訴你為什麼)。
就這一個檔案,不到 90 行,乾乾淨淨。
// worker.js
const AI_UA_TOKENS = [
"GPTBot",
"OAI-SearchBot",
"ChatGPT-User",
"ClaudeBot",
"Claude-User",
"Claude-SearchBot",
"PerplexityBot",
"Perplexity-User",
"Google-Extended",
"Bytespider",
"meta-externalagent",
"Applebot-Extended",
"CCBot",
"cohere-ai",
"MistralAI-User",
];
const GUARD_HEADER = "x-crawlerlog-edge";
function matchAiCrawler(ua) {
if (!ua) return null;
const lower = ua.toLowerCase();
for (const token of AI_UA_TOKENS) {
if (lower.includes(token.toLowerCase())) return token;
}
return null;
}
export default {
async fetch(request, env, ctx) {
// 1. 迴圈防護:這個請求已經穿過我們一次了,直接交給源站,不再記一次。
if (request.headers.get(GUARD_HEADER)) {
return fetch(request);
}
// 2. 在往源站的複本上蓋章,讓「又繞回來」這件事認得出來。
const outbound = new Request(request);
outbound.headers.set(GUARD_HEADER, "1");
// 3. 分類。不是爬蟲的流量只付一次字串掃描的成本。
const ua = request.headers.get("user-agent") || "";
const crawler = matchAiCrawler(ua);
if (crawler && env.LOG_ENDPOINT) {
const url = new URL(request.url);
const cf = request.cf || {};
const record = {
ts: new Date().toISOString(),
crawler,
ua,
method: request.method,
host: url.hostname,
path: url.pathname + url.search,
ip: request.headers.get("cf-connecting-ip") || null,
country: request.headers.get("cf-ipcountry") || null,
asn: cf.asn ?? null,
as_org: cf.asOrganization ?? null,
verified_bot_category: cf.verifiedBotCategory ?? null,
colo: cf.colo ?? null,
referer: request.headers.get("referer") || null,
};
// 4. 射後不理。訪客不等我們記帳,收集端死掉也不能拖垮網站。
ctx.waitUntil(
fetch(env.LOG_ENDPOINT, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(record),
}).catch(() => {}),
);
}
return fetch(outbound);
},
};
搭配的設定檔長這樣:
// wrangler.jsonc
{
"name": "ai-crawler-logger",
"main": "worker.js",
"compatibility_date": "2026-05-11",
"vars": {
"LOG_ENDPOINT": "https://你的收集端/collect"
}
}
注意那個 compatibility_date,請填上你安裝的 wrangler 真正支援的日期。我第一次隨手填了一個未來的日期,結果 wrangler dev 直接拒絕啟動,它冷冷地噴了一句:「This Worker requires compatibility date "2026-09-01", but the newest date supported by this server binary is "2026-05-11".」訊息寫得很明白,但還是會讓人當場愣住。
第 1 段,迴圈防護。 把它擋在最前面不是隨便排的。如果請求身上已經帶著我們的印記,代表這傢伙已經穿過我們一次了。這時候什麼廢話都別說,原封不動把它往下送。第七節我會讓你知道少了這四行會付出什麼慘痛代價。
第 2 段,new Request(request)。 很多人不知道,剛進來的 request.headers 是唯讀的,你硬要 set 東西進去它會直接噴錯。你得乖乖複製一份,才能拿到寫入的權力。這是所有 Workers 新手最常撞得頭破血流的一道牆。
第 3 段,分類與扒光細節。 有兩個地方你必須死死盯著:request.cf 是 Cloudflare 在邊緣節點偷偷塞給你的私房錢,裡面藏著 asn (自治系統編號)、asOrganization (該 ASN 的苦主)、colo (命中的資料中心代碼)、還有 verifiedBotCategory (Cloudflare 對他的身分判定)。這些寶貝你在源站的 access log 裡是絕對找不到的,這是你親自站在邊緣攔截才能享受的紅利。至於 verifiedBotCategory 該怎麼解讀,那是 Day 10 的戲份,今天我們先原封不動地存下來。
另外,cf.asn ?? null 這種寫法是出於自我防禦。因為 request.cf 在某些詭異的執行環境下根本不存在,你直接去點 .asn 程式就會當場暴斃。不過這個 ?? 語法其實藏著一個要命的陷阱,我們第七節來算帳。
第 4 段,ctx.waitUntil()。 這是整支程式的靈魂所在。它賦予了 Worker 在把回應送給客人之後,還能繼續留在原地把日記寫完的特權。官方的說法是「extends the lifetime of your Worker, allowing you to perform work without blocking returning a response」。如果沒有這行魔法,你的網站延遲會硬生生加上一次跨網路打 POST 的時間。
但你要知道兩個殘酷的限制:對於 HTTP 觸發的 Worker,waitUntil() 在回應送出後最多只能苟活 30 秒,而且這 30 秒是同一個請求裡所有 waitUntil() 必須共用的配額。後面接著的那個 .catch(() => {}) 絕對不是裝飾品。如果你的收集端掛了、憑證過期了、網路抽風了,所有的例外都必須在這裡被默默吞掉,否則整個 Worker 會跟著陪葬。
wrangler dev 預設是跑在你的本機上。它用 Miniflare 喚醒 workerd,這跟正式環境用的是同一套心臟,絕不是什麼粗糙的模擬器。我們完全不碰線上環境,直接開兩個終端機來對質:
# 終端機 1:啟動 Worker 監視器
npx wrangler dev --port 8787 --ip 127.0.0.1
# 終端機 2:假裝自己是 GPTBot 硬闖進去
curl -sS -A "GPTBot/1.2" http://127.0.0.1:8787/blog/hello
我把收集端也架在本機 (就是一個 20 行的 Node HTTP server,單純把收到的 body 印出來)。跑起來之後,收到的竊聽紀錄長這樣:
{
"ts": "2026-09-06T20:38:29.259Z",
"crawler": "GPTBot",
"ua": "GPTBot/1.2",
"method": "GET",
"host": "127.0.0.1",
"path": "/blog/hello",
"ip": "127.0.0.1",
"country": null,
"asn": 3462,
"as_org": "HiNet Taiwan",
"verified_bot_category": "",
"colo": "TPE",
"referer": null
}
這份輸出結果裡,有三個地雷你必須死死盯著,它們是本機溫室跟外面殘酷世界的差別:
asn: 3462 / as_org: "HiNet Taiwan" / colo: "TPE",這是我自己電腦的網路戶籍地,根本不是爬蟲的。在本機模式下,request.cf 只是個佔位用的假娃娃,長得很像真的,但裡面的數值全是瞎掰的。country: null,因為 CF-IPCountry 這個標頭是 Cloudflare 邊緣節點在真實戰場上才會幫你貼上去的,本機沒有人會幫你貼,curl 當然也沒帶。verified_bot_category: "",看清楚了,**是空字串,不是 null,更不是 undefined**。最後這個地雷是真的會炸爛你的報表的,而且我必須承認,我是把它印出來才發現的,寫程式的當下完全沒意識到。你滿心以為寫了 cf.verifiedBotCategory ?? null,沒值的時候就會乖乖存成 null,結果存進去的居然是 ""。因為 ?? 這個語法只對 null 與 undefined 有反應,對空字串根本視若無睹。等有一天你在資料庫裡下 WHERE verified_bot_category IS NULL,滿心期待想撈出那些「身分不明的傢伙」時,你會發現一列都撈不到。查了半天你還會傻傻懷疑是自己的 SQL 寫錯了。
如果你有潔癖想弄乾淨一點,把寫法改成 cf.verifiedBotCategory || null 就能解決。但我給你的良心建議是:拿到什麼就照原樣存下來,等你要下查詢時再來處理。最原始的證據一旦在寫入時被你擅自竄改過,以後就再也還原不回來了。
為此我還特地寫了一支 Miniflare 驗收腳本,把五種極端情境全跑成斷言測試 (爬蟲 UA 有沒有乖乖記錄、正常瀏覽器 UA 是不是被放過、帶印記的請求有沒有被重複記、收集端死掉時源站是不是照樣活著回 200、印記有沒有確實蓋到往源站的請求上)。寫這東西花了二十分鐘,但之後我每次更新 UA 名單都能安心重跑。敢掛在邊緣擋子彈的程式碼,絕對值得你擁有一組不用部署就能驗證的測試。
本機確認沒被咬死之後,才准上線。
npx wrangler deploy
接著你得把這支 Worker 綁到你的網域上。Cloudflare 的術語是「Routes allow users to map a URL pattern to a Worker」,白話文就是:只要請求踩進這個 URL 樣式的地盤,Worker 就會在那裡動手。
{
"name": "ai-crawler-logger",
"main": "worker.js",
"compatibility_date": "2026-05-11",
"routes": [
{ "pattern": "example.com/*", "zone_name": "example.com" },
{ "pattern": "www.example.com/*", "zone_name": "example.com" }
]
}
兩個主機名稱,你得老老實實寫兩條。 [example.com/](https://example.com/)* 絕對不會好心幫你涵蓋 [www.example.com/](https://www.example.com/)*。這是我看過最多人跌進去的坑:網站流量明明絕大多數都在 www 上,結果設定檔只綁了裸網域 (apex),上線後看著儀表板一片空白,然後開始自我懷疑是不是程式寫爛了。
硬前提跟 Day 6 講的一模一樣:DNS 紀錄必須亮著那朵橘雲。灰雲的主機名稱請求根本不會踏進 Cloudflare 的邊緣節點,Worker 當然連醒都不會醒。
綁定完成後,那行 return fetch(outbound) 就會「trigger a subrequest to your application server, as defined in the DNS settings of your Cloudflare zone」,也就是乖乖照著你 zone 裡的 DNS 設定去找你的老家 (源站)。你的網站外在行為看起來毫無變化,只是骨子裡多了一層冷酷的監視網。
LOG_ENDPOINT 要指向哪裡,是這支程式唯一需要你親自拍板決定的事。三個最常見的歸宿,各有各的殘酷代價:
送進你自己的 HTTP 房間。 這是最直覺也最隨心所欲的玩法。準備一支能吞 JSON 的 API,後面接上你最順手的資料庫。但你必須死守一個鐵律:這個房間絕對不能放在同一個 zone 的同一條 route 底下,否則你的紀錄請求會再度撞上 Worker,然後陷入永劫回歸。請把它放在別的網域,或者至少放在 route 管不著的路徑上。
塞進 Workers KV。 聽起來不用另外架伺服器,很香對吧?但你先看看這血淋淋的數字:免費方案每天只有 1,000 次寫入配額,而且同一個 key 每秒鐘只准你寫 1 次。一個稍微有點名氣的網站,AI 爬蟲一天來個幾千次根本是家常便飯。走這條路,你大概在吃午餐前就會把當天的額度全燒光,剩下的資料就這樣無聲無息地消失在虛空裡。KV 只適合拿來放彙總數字 (例如每小時結算一次),絕對不適合用來記這種一對一的流水帳。
花錢買 Workers Trace Events Logpush。 就像 Day 7 提過的,如果你不是 Enterprise 富豪,這是官方唯一還為你敞開的 log 後門,前提是你得乖乖掏錢訂閱 Workers Paid 方案。這條路工程量最輕,但你拿到的只會是 Worker 執行的殘留軌跡,欄位的形狀根本由不得你作主。
還有一個不花錢但也得記在心上的緊箍咒:Workers 的免費方案限制每天最多 100,000 次請求,每次請求的 CPU 運算時間上限是 10 毫秒。我們這支程式對非爬蟲的正常訪客只做了一次無腦的字串掃描,CPU 絕對不會超標。但請注意,這個請求次數是全站所有的請求一起算的,不是只算爬蟲。如果你網站的日流量超過十萬,免費額度的屋頂是撐不住的。
這是我親手做實驗量出來的,方法極度簡單,你隨時可以重現。
拿同一支 Worker、同一個 wrangler dev 環境、同一條設定 25 秒逾時的 curl 指令。唯一的差別就是,我狠心把那四行迴圈防護給砍了,然後眼睜睜看著 Worker 的 fetch(request) 像發瘋一樣繞回它自己。
| 程式版本 | 一個真實請求最後炸出的紀錄筆數 |
|---|---|
| 有迴圈防護 | 1 |
| 沒有迴圈防護 | 337 |
337 這個數字本身不具任何意義,它只是「curl 苦等了 25 秒終於斷氣」的時間,除以「Worker 繞一圈的耗時」所算出來的結果。你換台電腦重跑,數字絕對不一樣。但最可怕的是,它不是 2,也不是 16,它是一個完全由外界逾時時間來決定的恐怖倍數。
正式環境的防禦機制不太一樣,但下場一樣悽慘。我們自己在 2026 年 7 月就親身踩過這個地雷:有一支結尾寫著 return fetch(request) 的 Worker,遇到「主機名稱沒有被正確接管 加上 源站直接指向 Cloudflare 自家 IP」的詭異組合時,它就開始自我繁殖。實際造成了每個請求被放大 16 倍的災難。那個 16,是 Cloudflare 系統子請求深度的天花板硬擋下來的結果,根本不是我們的設計。
會觸發這種慘劇的情境比你想像的還要多:你的源站本身就掛在 Cloudflare 上、你的 CNAME 指向了另一個也是 CF 代管的服務、或是某個 SaaS 平台把你的網域接走了一半。你絕對不會在部署上線的那天發現異狀,你只會在一個月後看著暴漲的帳單和滿坑滿谷的重複廢資料欲哭無淚。記住,這四行程式碼是保命的保險,絕對不是什麼效能優化。
如果你的整個網站根本就是一支 Worker 跑起來的 (像是 OpenNext 或是 Astro on Workers 這類架構),而且它剛好是用 Route 綁定的。那如果你想在它前面再疊加一支我們今天寫的監視器,絕對會出人命。而且不是漏記紀錄這麼簡單,是你的網站會直接斷氣,完全收不到請求。
Cloudflare 的規章寫得非常絕情:「Routes cannot be the target of a same-zone fetch() call.」在同一個 zone 裡面,一支 Worker 絕對沒有權限用 fetch() 去叫醒另一支綁在 route 上的兄弟。你的那行 return fetch(outbound) 會直接打在空氣上。
我們是在幫真實客戶架設時一頭撞上這面牆的,現在這已經變成我們安裝流程裡,一踩到就會直接拒絕安裝的死線。你要怎麼判斷自己會不會死?
上線前,請務必去 zone 的 Workers Routes 列表裡巡視一眼,確認沒有別人的 route 悄悄蓋在你要綁定的主機名稱根路徑上。
AI_UA_TOKENS 這份名單,從你複製貼上的那一秒起,就已經開始腐敗了。新的 AI 產品每個月都在暗處滋生,而當你漏掉了一隻新爬蟲時,唯一的症狀就是報表上「少了一行紀錄」。沒有錯誤訊息,沒有人會發告警給你。
最務實的生存法則:把那些你不認得的 UA 也偷偷留一份備份。只要它不在名單上,又長得不像正常瀏覽器 (沒有 Mozilla/ 開頭,或者字串裡藏著 bot、crawler、spider、http 這類狐狸尾巴),就用極低的比例把它們抽樣留下來。每個月去翻一次這些黑名單。下一批你該加進防禦陣線的名字,絕對就藏在裡面。
這也是為什麼我一開始就逼你把所有能抓的特徵全記下來:多記一個細節根本不費力,但要是漏記了,那是永遠補不回來的。過去的流量,絕對不會好心重跑一次讓你補考。
這支不到 90 行、不依賴任何人的 Worker,能賞給你 AI Crawl Control 在骨子裡給不了的東西:微觀到事件層級的快感、完全由你作主的私密欄位、你想留多久就留多久的自由、還有能跟你自家資料庫完美契合的靈魂。
如果要從這篇帶走什麼,我希望是這三個教訓:
wrangler dev 用的是跟真實戰場一樣的 workerd 引擎,但本機的 request.cf 是個說謊的假娃娃。看穿它假在哪裡,比天真地以為它是真的好太多了。這版的監視器其實刻意留了一個致命的破綻:它太天真了,完全相信對方遞上的 UA 名片。只要請求字串寫著 GPTBot,它就會乖乖記成 GPTBot,根本不管這傢伙到底從哪裡來。Day 4 的血淚早就證明,光看名片是絕對不夠的。要把這個洞補起來,你必須導入 IP/ASN 的身分驗證,而那需要你把各家大廠的官方 IP 清單生吞活剝拉進來、定期更新、還要在邊緣節點做即時比對。那種工程量,比今天這支 90 行的玩具大上一個量級。
不過,在你想方設法補破網之前,還有一個更讓人絕望的問題等著你處理:你的 Worker 搞不好根本沒機會醒過來。同一個 zone 裡面有好幾個高規格的安全開關,會在爬蟲碰到你的程式邏輯之前,就直接在門外把它們斃了。而且,在免費與 Pro 方案裡,這幾個殺手預設是醒著的。
明天 (Day 9) 我們就來面對這個殘酷的真相:你的 robots.txt 明明寫著放行,Cloudflare 卻冷酷地回了 403。到底是哪一層保鑣在亂殺人、你要怎麼查出兇手、以及一個我們自己親身踩過兩次、摔得鼻青臉腫的判讀陷阱。
本文是「你的第一本 AEO x GEO 教戰手冊」系列第 8 天。系列實測與數據由 arrivl 整理 (Growth Analytics for the Agentic Web)。