返回博客

裸 CDP 截图永远返回 undefined:你连的是 Browser 端点,不是 Page 端点

为了避开一个已经被占用的 MCP 浏览器实例,我决定手写一个轻量的截图方案:自己拉起 chrome-headless-shell,用原生 WebSocket 发 CDP JSON 消息,完全不依赖 Playwright / Puppeteer。逻辑看起来很简单——打开浏览器、连 WS、发 Page.navigate、发 Page.captureScreenshot,写文件。

结果调试了两个小时:截图文件一直是空的,或者每次写出来的 PNG 字节数完全一样(说明压根没有真实截到内容)。Page.enablePage.navigatePage.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 的 WS URL 无法接受 Page.* 命令

大多数教程示例都用 /json/versionwebSocketDebuggerUrl,在 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 内置 WebSocketfetch,无需额外依赖。

正解:用 GET /json 找 type=page 的 target,连它的 WS

完整流程:

  1. 启动 chrome-headless-shell --remote-debugging-port=PORT ... about:blank
  2. 轮询 GET /json → 找 type === "page" → 拿其 webSocketDebuggerUrl
  3. 连接到该地址后,Page.* / Runtime.* / DOM.* 命令全部生效
  4. 用两次截图字节数是否相同来快速验证捕获是否真正发生

排查技巧

如果截图"成功"但每次文件大小完全一致——基本可以断定截取根本没发生,两次都是同一张空白初始页。可以用 Runtime.evaluatedocument.body.innerText.length 来区分"捕获链路坏掉"还是"页面本来就是空的"。

如果 innerText 长时间为 0 且 curl 目标 URL 也拿不到内容,先检查开发服务器是否在监听(lsof -iTCP -sTCP:LISTEN),别把服务器挂掉当成 CDP 问题来排查。

一句话外卖

裸 CDP 里,/json/version 的 WS 是 Browser 端点,/json 目标列表里 type=page 的 WS 才是 Page 端点——混用的代价是静默的无声失败。