不用 Playwright:120 行原生 WebSocket 直连 CDP 的浏览器自动化实践
背景:当自动化框架不好用时
做浏览器自动化,第一反应通常是 Playwright、Puppeteer 或各种 agent-browser 封装。它们省事,但有一个共同的前置条件:框架自己的运行时必须是健康的。一旦守护进程卡死、版本与内核不匹配,你会发现所有 API 调用都变成"调用后无响应,五分钟后超时",而底层 Chrome 明明还开着。
最近我就踩了这个坑:一个自动化脚本里 open 操作全部挂起,最后只能杀掉全部进程重来。于是退回到最底层——直接对着 Chrome DevTools Protocol(CDP)说 WebSocket 协议。结果整个驱动脚本不到 120 行,零第三方依赖,反而比框架更稳。
一、CDP 到底是什么
CDP 是 Chrome 暴露的调试协议,它分两层:
- HTTP 发现层:
/json/version拿浏览器信息与 WebSocket 入口,/json/list列出所有可调试目标(page、iframe、worker……)。 - WebSocket 命令层:连上去之后,用 JSON 发命令,按
id匹配响应,用method接收事件。
请求长这样,id 自己递增、用于配对响应:
{"id":1,"method":"Page.navigate","params":{"url":"https://example.com"}}
{"id":2,"method":"Runtime.evaluate","params":{"expression":"document.title","returnByValue":true}}
只要 Chrome 用 --remote-debugging-port=9333 启动,http://127.0.0.1:9333/json/list 就能列出目标。
二、Node.js 原生 WebSocket 就够了
Node 22 内置了全局 WebSocket,所以连依赖都不用装:
const targets = await (await fetch('http://127.0.0.1:9333/json/list')).json();
const page = targets.find(t => t.type === 'page');
const ws = new WebSocket(page.webSocketDebuggerUrl);
let seq = 0;
const waiters = new Map();
ws.addEventListener('message', ev => {
const msg = JSON.parse(ev.data);
if (msg.id && waiters.has(msg.id)) {
waiters.get(msg.id)(msg);
waiters.delete(msg.id);
}
});
function send(method, params = {}) {
const id = ++seq;
return new Promise((resolve, reject) => {
waiters.set(id, m => (m.error ? reject(new Error(m.error.message)) : resolve(m.result)));
ws.send(JSON.stringify({ id, method, params }));
});
}
基于这一层,几个高频能力一行就出来了:
await send('Page.navigate', { url });
const { result } = await send('Runtime.evaluate',
{ expression: 'document.body.innerText', returnByValue: true });
const shot = await send('Page.captureScreenshot', { format: 'png' });
三、四个真实踩坑
1. 无头模式经常"打开即退出"
用 --headless 启动时,某些站点或内核组合下进程会瞬间退出,连调试端口都来不及监听。判断方法很简单:
netstat -ano | findstr :9333
没有 LISTENING 就是没起来。改成有头模式(去掉 --headless)后立刻稳定,代价是必须能开图形界面。
2. curl 能通,Node 连不上:沙箱拦回环
这个坑很迷惑:curl http://127.0.0.1:9333/json/list 返回正常 JSON,但同一个脚本里 WebSocket 直接 ECONNREFUSED。原因是执行环境把本地回环地址隔离了——curl 走的是宿主网络,而脚本进程被限制。
解决办法不是改代码,而是让脚本在不受限的权限下运行,或者干脆把 CDP 操作放在宿主机侧的脚本里执行。别在协议层浪费时间排查,先确认网络命名空间。
3. UA 与 WAF:改了 UA 反而 403
很多站点把登录态和 UA 绑定。用 CDP 接管一个已登录的浏览器后,如果你顺手 Network.setUserAgentOverride 把 HeadlessChrome 字样去掉,有些带 WAF 的接口会直接返回 403 或"未登录"。
结论:不要自作聪明改 UA。要么原样透传,要么把原浏览器的 UA 完整取出来再设回去:
const { result } = await send('Runtime.evaluate',
{ expression: 'navigator.userAgent', returnByValue: true });
await send('Network.setUserAgentOverride', { userAgent: result.value });
4. 拿不到 httpOnly Cookie
document.cookie 读不到 httpOnly 的会话票据,但 CDP 能:
const { cookies } = await send('Storage.getCookies', {});
返回的是全量 Cookie,含 httpOnly、domain、expiry。做"导出登录态给脚本复用"时这是最干净的路径,比抓包复制干净得多。
四、什么时候该用它
- 框架坏了但仍需自动化:CDP 是最后的稳定层。
- 需要复用已登录的浏览器:直接接管用户态,无需再走登录流程。
- 需要精确控制:cookie 导出、UA 覆盖、请求拦截,都是原生能力。
代价也很明确:没有自动等待、没有选择器引擎、没有重试逻辑,document.querySelector 之后的一切都要自己写。所以我的实践是——框架能跑就用框架,跑不起来再用裸 CDP 兜底,把两者当成两层,而不是二选一。
小结
CDP 的门槛其实只有一个:知道 /json/list 和 webSocketDebuggerUrl 这两个东西。跨过去之后,120 行代码就能覆盖导航、取文本、截图、导出 Cookie 四件事,而且在框架挂掉的时候依然可用。自动化链路里留一层"协议层兜底",比反复重启守护进程划算得多。
- 点赞
- 收藏
- 关注作者
评论(0)