Web Log:把 Chrome 的 HTTP / WebSocket 流量灌進 ring buffer,讓 Claude Code 直接查
做 Schat 反向工程 Google Chat 私有協定時,DevTools Network panel 完全不夠用——它跟 chrome.debugger 互斥、查詢不了、留不下來。所以做了 web-log:Chrome extension 持續攔截 HTTP(S) 與 WebSocket → in-memory ring buffer → Claude Code skill 自然語言查詢。本文記錄它的設計、與 Schat 開發時的實際搭配,以及 chrome.debugger API 那些討厭的硬性限制。
從 Schat 的反向工程說起
上一篇寫了 Schat——一個用 Chrome extension 橋接、把 Google Chat 包成 Slack 風格的本地前端。那個專案最痛的不是寫前端,是反向工程 Google Chat 的私有協定 Dynamite。
每一個你想實作的功能——送訊息、加 reaction、列頻道、訂閱即時推送——都要先在 Google 原生網頁裡實際操作一次,看 Network panel 它送了什麼、收了什麼,把 payload 形狀抓出來、再去 inject-main 裡面照著重現。
聽起來只是「打開 DevTools 看一下」,但實際做起來會撞到一連串問題:
- DevTools Network panel 跟
chrome.debugger互斥——Schat 的 extension 自己也要用 chrome.debugger 看 chat 分頁的請求(為了學 session 參數),結果我每次開 DevTools 想看流量,extension 那邊就斷線。 - 每次重整就清空。在 Google Chat 點來點去、Network 累積幾百筆,重整一次重來。
- 不能程式化查詢。我想跟 Claude Code 講「剛才那筆
/messages/scheduled的 response 是什麼形狀」,總不能截圖貼進去。 - WebSocket-like 的 streaming POST。Google Chat 的即時推送不是真正的 ws,是一條長時間開著的 POST,內容是切片進來的 nested-array 訊息。DevTools 對這種看得到,但沒有任何方式可以 export 出來。
於是同時做 Schat 的時候,順手做了 Web Log——一個專門解決這些痛點的工具。這篇就是它的設計筆記。
它在做什麼
一句話:把 Chrome 的 HTTP(S) 與 WebSocket 流量持續灌進一個本地 ring buffer,給 Claude Code 用自然語言查。
組成有三塊:
Chrome extension (MV3)
├ webRequest API 攔 HTTP 請求 metadata(method, headers, request body)
└ chrome.debugger API 抓 HTTP response body 與 WebSocket frame(含 payload)
│
│ POST /ingest
▼
Node.js collector (localhost:9999)
└ in-memory ring buffer 預設 2 GiB,丟最舊
│
│ HTTP query
▼
Claude Code skills
├ web-log-parse 「找剛剛 api.foo.com 的 POST」
└ web-log-watch 「等 /login response 出現再繼續」
設計目的不是當第二個 DevTools,是當一個留得下來、查得到、Claude 看得懂的流量黑盒子。你在前面操作網頁,Claude 在後面隨時可以撈。
為什麼一定要 chrome.debugger
chrome.webRequest API 看似全能,但有兩件事它死也做不到:
- 看不到 HTTP response body——只能拿 status、headers,不能讀 body。
- 看不到 WebSocket frame——連有沒有 frame 都不知道。
這兩件事 Chrome 故意不開給普通 extension,因為太敏感。要拿到,必須走 chrome.debugger API——也就是 Chrome DevTools Protocol(CDP)的程式介面。Web Log 在 background.js 裡會對被監聽的 tab attach 一個 debugger session:
chrome.debugger.attach({ tabId }, '1.3', () => {
chrome.debugger.sendCommand({ tabId }, 'Network.enable')
chrome.debugger.sendCommand({ tabId }, 'Page.enable')
})
chrome.debugger.onEvent.addListener((source, method, params) => {
if (method === 'Network.responseReceived') { /* 配對 requestId */ }
if (method === 'Network.loadingFinished') { /* 抓 body */ }
if (method === 'Network.webSocketFrameReceived') { /* server → client */ }
if (method === 'Network.webSocketFrameSent') { /* client → server */ }
})
代價是兩件事:
- Chrome 上方會跳一條「DevTools 正在 debug 此分頁」的橫條。沒辦法消,CDP 的設計就是這樣,避免使用者不知道。
- 同一個 tab 同時只能有一個 debugger client。如果你打開 DevTools,Web Log 就斷;反過來如果 Web Log 在抓,你點開 DevTools 它就被踢掉。
這個互斥就是為什麼開頭那段「Schat 的 extension 自己也要用 chrome.debugger,DevTools 一開就壞」會發生——CDP 是獨佔的。Web Log 在 popup 裡會列出哪些 tab 已被它 attach、可以一鍵 detach 換回 DevTools。
Ring buffer 為什麼放記憶體
Collector 是純 Node.js(連 npm install 都不用,只用內建 module),listen 127.0.0.1:9999,buffer 直接放記憶體:
- process 結束就清空,不落地。Authorization header、Cookie、Set-Cookie 都會原樣存進來,我不想讓這些東西意外躺到硬碟上。
- 大小用環境變數
WEB_LOG_MAX_BYTES控制,預設 2 GiB。我之前抓滿一晚的 Google Chat 流量大概 700 MB,2 GiB 給普通開發 session 夠用。 - 滿了就丟最舊,
/status.totalDropped會持續累加,方便你知道有沒有開始丟資料。 - 單筆 response/payload 超過 256 KiB 在 extension 端就先 truncate,避免一個 video stream 把整個 buffer 占掉。
不寫硬碟、不做永續化的決定回頭看是對的——它讓「重啟一切就乾淨」變成一個簡單動作。對 debug 工具來說重要的不是歷史紀錄,是「我剛才那個動作的封包」。
Capture filter:你不會想全收
我第一版沒做 filter,直接全收。打開 chat.google.com 五分鐘就吃掉 600 MB——Google 自己的 telemetry、Hangouts 心跳、auth 續期、各種 chrome.googleapis 的雜訊比有用的 Dynamite 流量多 10 倍。
於是有了 rules:
每條 rule 的欄位:
action—allow或denyhost— host 子字串pathPrefix— pathname 前綴method— HTTP method(WebSocket 用WS)urlRegex— 完整 URL regex
Rules 從上往下檢查,第一個所有條件都命中的決定 allow / deny;都沒命中走 defaultAction。
調 Schat 時最常用的 preset 是「只抓 Google Chat 的 mutation 與 streaming POST」:
default: deny
1. allow host=chat.google.com pathPrefix=/u/0/api/ method=POST
2. allow host=chat.google.com urlRegex=batchexecute
這樣 buffer 裡只剩我要看的東西,2 GiB 撐好幾天都沒問題。
順帶一提,rules 可以用 CLI 改:
curl -X POST http://127.0.0.1:9999/config \
-H 'Content-Type: application/json' \
-d '{
"rules": [
{"action":"allow","host":"chat.google.com","pathPrefix":"/u/0/api/","method":"POST"}
],
"defaultAction":"deny"
}'
extension 每 5 秒會 poll 回來 sync。所以你可以叫 Claude 直接幫你改 filter——它讀過 SKILL.md,知道這個 API 怎麼打。
Skill:web-log-parse 與 web-log-watch
Buffer 有了之後,剩下的問題是怎麼讓 Claude 用得順手。
裝 skills 是兩個 symlink:
ln -s ~/project/web-log/skills/web-log-parse ~/.claude/skills/web-log-parse
ln -s ~/project/web-log/skills/web-log-watch ~/.claude/skills/web-log-watch
兩個 skill 解的問題不一樣:
web-log-parse:事後查詢
最普通的「我剛才做了一個動作,看看那個封包」。對話像這樣:
我:剛剛我在 Google Chat 點了 reaction,幫我把那個 mutation request 找出來 Claude:(call
GET /events?host=chat.google.com&method=POST&limit=20)找到了,是這筆… 我:response body 完整貼給我 Claude:(callGET /event/847){ ... }
Skill 知道有 collector,會自己組 query。我做 Schat 時這個流程大概一天跑 50 次以上。
另一個它幫我省下的力氣是 mask:Authorization、Cookie 預設展示時會被 mask,除非我明確說「展開那個 header」。一不小心把 session cookie 貼進對話的事不會發生。
一個沒明講但很重要的小細節:collector 沒在跑的時候,skill 不會默默失敗——它會偵測到 offline,問你要不要它幫你在背景把
node collector/server.js跑起來。同意了它就跑、確認起來之後再繼續查詢。所以日常用根本不會記得「我要先去開 collector」這件事。
web-log-watch:阻塞等待
這個比較好玩。它的場景是:
我:等使用者登入完,拿到
/login的 200 response 之後,再開始實作下一段 Claude:(callwait-for.sh --host api.foo.com --method POST --path /login --status 2xx --timeout 180) [Claude 卡住等] [我手動去 app 登入] Claude:(拿到 matched event)OK,response 是{ token: ..., expiresAt: ... },繼續往下做 X、Y、Z
底層是一個 shell 腳本去 poll collector:
skills/web-log-watch/scripts/wait-for.sh \
--host chat.google.com --method POST --path /batchexecute --timeout 180
# exit 0 + matched events JSON / exit 124 timeout / exit 2 collector offline
Flags 支援 --host --method --path --url --status --source --kind --direction --contains(payload 文本 grep)。
對於需要「使用者實際操作後才會發生的事件」這類任務特別有用——traditional 寫 code 你會等不到,因為事件還沒發生;用 web-log-watch 就是「Claude 等你動,你動完它接著做」。
跟 Schat 的實際搭配
把這兩個工具放在一起用,做 Schat 的時候真實的工作流是這樣:
情境 1:實作一個新的 op
我想做「列出排程訊息」。Google Chat 網頁有這個畫面,但我不知道它打哪個 endpoint:
- 打開 Web Log popup,filter 設成
chat.google.comPOST。 - 在原生 Google Chat 點「排程訊息」分頁。
- 跟 Claude 說:看剛剛那個動作打的 endpoint 跟 response 形狀。
- Claude 用
web-log-parse撈,把batchexecute的f.reqpayload 拆開,告訴我這個 op 的 RPC ID 是什麼、回傳的 nested array 第 N 個元素是排程列表。 - 我去 Schat 的 inject-main.js 照著實作
listScheduled,跑起來測。
如果做得不對,就再 trigger 一次原生網頁,再撈一次比對。整個 loop 從「貼截圖給 Claude 一筆筆讀」變成「Claude 自己拿」。
情境 2:debug 一個壞掉的 mutation
Schat 送排程訊息突然開始 500。
- 在 Schat 觸發一次送出 → fail。
- 跟 Claude 說:找剛剛 5 分鐘內 chat.google.com 的 5xx。
- Claude 撈出來:是
batchexecute的某條 RPC 收到INVALID_ARGUMENT。 - 比對「我送的 payload」跟「上一次原生網頁送的 payload」(buffer 裡都有),發現某個欄位的型別變了。
- 修。
這種 diff 工作要不是 Web Log,得開兩個 DevTools 視窗、手動 copy-paste、肉眼比對。
情境 3:訂閱事件 stream 改了 wire format
Google Chat 每隔一陣子會調整推送格式。某天 Schat 收不到新訊息了:
- 跟 Claude 說:等下次有任何 chat.google.com 的 streaming POST 收到 frame 就告訴我。
- Claude
web-log-watch --host chat.google.com --kind frame --direction received,卡在那等。 - 我去原生網頁讓別人發個訊息過來。
- Claude 拿到那筆 frame,跟舊版本對比,告訴我 nested array 結構少了一層 wrapper。
- 改 inject-main 對應的 parser。
邊界與不會做的事
- 不會抓到
chrome://、devtools://、Web Store 等特權頁面——Chrome 不准。 - 已經有人 attach DevTools 的 tab,Web Log 就上不去(CDP 獨佔)。要切換時去 popup 按 detach / re-attach。
- 原生 TCP/UDP 看不到——瀏覽器本來就摸不到。
- WebRTC 的 media frame 看不到,只看得到 signaling。
還有一個比較重要的:Cookie 與 Authorization 是原樣記錄到 buffer。Skill 展示時預設 mask,但實體還是在 RAM 裡。不要對著有正式環境敏感資料的分頁長時間開著;要清就按 Clear 或重啟 collector。
一些感想
Web Log 一開始只是 Schat 開發過程的副產品——做著做著發現「我需要一個可以叫 Claude 幫我看 Chrome 流量的工具」,就停下來把它獨立成一個 repo。
它特別適合的場景,是反向工程任何 web app 的私有介面——這在我這個 Schat 的例子最明顯,但任何「我想做一個第三方 client 串某某網站」的情境都會撞上同樣的問題:DevTools 不夠用、需要持續觀察、需要程式化查詢、需要 Claude 看得懂封包。
兩個專案的關係:
- Schat:把 Google Chat 換掉。用反向工程的私有協定。
- Web Log:做反向工程時的眼睛和耳朵。把流量留下來給 Claude 查。
兩個都 MV3 Chrome extension + 本地起服務 + 跟 Claude 緊密整合的 pattern——這可能就是我近期的開發風格。
專案連結:github.com/gpwork4u/web-log
MIT。安裝指令、capture rules 範例、API spec 完整放在 README 裡。
關於作者

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。