裸 CDP 截图永远返回 undefined:你连的是 Browser 端点,不是 Page 端点
为了避开一个已经被占用的 MCP 浏览器实例,我决定手写一个轻量的截图方案:自己拉起 chrome-headless-shell,用原生 WebSocket 发 CDP JSON 消息,完全不依赖 Playwright / Puppeteer。逻辑看起来很简单——打开浏览器、连 WS、发 Page.navigate、发 Page.captureScreenshot,写文件。
结果调试了两个小时:截图文件一直是空的,或者每次写出来的 PNG 字节数完全一样(说明压根没有真实截到内容)。Page.enable、Page.navigate、Page.captureScreenshot 全都正常 resolve,没有任何报错——只是结果里没有 data 字段,Buffer.from(shot.data, "base64") 直接抛 TypeError: Cannot read properties of undefined (reading 'data')。
现象
TypeError: Cannot read properties of undefined (reading 'data')
at captureScreenshot (screenshot.js:42:30)
send() 封装的 Promise 顺利 resolve,但 result 是 undefined 或空对象。Page.enable 似乎成功,Page.navigate 也不报错,但页面实际上从没有跳转过。两次截图产出完全一样大小的文件——这是捕获根本没有发生的铁证。
根因
Chrome 远程调试协议暴露了两类 WebSocket 端点,它们不可互换:
| 端点来源 | 地址 | 接受的 Domain |
|----------|------|--------------|
| GET /json/version | ws://localhost:PORT/json/version | Browser.*、Target.* |
| GET /json(目标列表) | 每个 target 各自的 webSocketDebuggerUrl | Page.*、Runtime.*、DOM.* |
/json/version 是浏览器级端点。把 Page.captureScreenshot 发给它,协议层直接丢弃——既不返回结果,也不返回错误,命令就这样消失了。这就是为什么 Promise resolve 了但 result 是空的:连接本身没问题,只是目标层级不对。
大多数教程示例都用 /json/version 拿 webSocketDebuggerUrl,在 Playwright/Puppeteer 封装里这个细节被隐藏掉了。裸 CDP 时必须区分:
/json/version→ 浏览器级端点,只认Browser.*/Target.*/json(不带/version)→ 返回 target 列表,每个type === "page"的条目才有真正能用Page.*的 WS 地址
向浏览器级端点发 Page.*:静默丢弃,无报错,Promise 正常 resolve。这是最难排查的一类无声失败。
正解
从 GET /json 取目标列表,找 type === "page" 的条目,用它的 webSocketDebuggerUrl 建连接:
// 错误:浏览器端点,Page.* 静默 no-op
// const { webSocketDebuggerUrl } = await fetch(`http://localhost:${PORT}/json/version`).then(r => r.json());
// 正确:page 级端点,Page.* / Runtime.* / DOM.* 全部可用
let pageWs;
for (let i = 0; i < 40; i++) {
try {
const list = await fetch(`http://localhost:${PORT}/json`).then(r => r.json());
const page = list.find((t) => t.type === "page");
if (page?.webSocketDebuggerUrl) { pageWs = page.webSocketDebuggerUrl; break; }
} catch {}
await new Promise((r) => setTimeout(r, 250)); // target 在浏览器启动后稍后才出现
}
const ws = new WebSocket(pageWs);
// 之后 Page.enable / Page.navigate / Runtime.evaluate / Page.captureScreenshot 均正常工作轮询是必要的:chrome-headless-shell 启动后 page target 会在几百毫秒后才出现在列表里,立即查通常拿到空列表。启动时传 about:blank 参数确保一定有初始 page target。
Node 22 内置 WebSocket 和 fetch,无需额外依赖。
完整流程:
- 启动
chrome-headless-shell --remote-debugging-port=PORT ... about:blank - 轮询
GET /json→ 找type === "page"→ 拿其webSocketDebuggerUrl - 连接到该地址后,
Page.*/Runtime.*/DOM.*命令全部生效 - 用两次截图字节数是否相同来快速验证捕获是否真正发生
排查技巧
如果截图"成功"但每次文件大小完全一致——基本可以断定截取根本没发生,两次都是同一张空白初始页。可以用 Runtime.evaluate 查 document.body.innerText.length 来区分"捕获链路坏掉"还是"页面本来就是空的"。
如果 innerText 长时间为 0 且 curl 目标 URL 也拿不到内容,先检查开发服务器是否在监听(lsof -iTCP -sTCP:LISTEN),别把服务器挂掉当成 CDP 问题来排查。
一句话外卖
裸 CDP 里,
/json/version的 WS 是 Browser 端点,/json目标列表里type=page的 WS 才是 Page 端点——混用的代价是静默的无声失败。