用 CDP 直接驱动 Chrome:零依赖做浏览器自动化,附 6 个非踩不可的坑
为什么会想自己写这一层
Playwright 和 Selenium 都很好用,但它们解决的是「从零启动一个干净的浏览器、跑一遍测试、验证结果」这个场景。如果要做的是另一类事——复用自己已经登录的浏览器、对内部后台做重复操作、每天定时跑一批固定动作——那套东西反而成了负担:
- 自带的 Chromium 和你日常用的 Chrome 不是同一个,登录态要重新搞一遍
- 一次
npm install拉起几百 MB 依赖,只是为了发几个 HTTP 请求 - 各版本 API 变动,脚本放半年再跑,先花两小时修依赖
而浏览器本身早就把遥控器挂在外面了:Chrome DevTools Protocol。开一个调试端口,用 WebSocket 说 JSON-RPC,就完事。整层驱动我最后只写了 200 多行,零依赖。
先让它能被遥控
chrome.exe --remote-debugging-port=9333 \
--user-data-dir="D:/profile/work" \
--no-first-run --no-default-browser-check
两个参数值得说明。--remote-debugging-port 只在启动时生效,运行中的浏览器没法中途开启。--user-data-dir 指定独立的 profile 目录,好处是不影响你自己日常那个窗口,而且登录一次之后 cookie 会留在里面,下次直接复用。
起来之后先探一下端口:
curl -s http://127.0.0.1:9333/json/version
curl -s http://127.0.0.1:9333/json/list
/json/list 会返回所有 tab 的 webSocketDebuggerUrl。拿其中一个连上去:
const tabs = await (await fetch('http://127.0.0.1:9333/json/list')).json();
const tab = tabs.find(t => t.type === 'page' && t.url.includes('example.com'));
const ws = new WebSocket(tab.webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r));
let id = 0;
const pending = new Map();
ws.addEventListener('message', e => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) {
pending.get(m.id)(m.result);
pending.delete(m.id);
}
});
const send = (method, params = {}) => new Promise(res => {
const i = ++id;
pending.set(i, res);
ws.send(JSON.stringify({ id: i, method, params }));
});
有了 send,执行 JS 就是一个方法:
const ev = async expr => {
const r = await send('Runtime.evaluate', {
expression: expr,
awaitPromise: true,
returnByValue: true,
userGesture: true,
});
return r.result.value;
};
console.log(await ev('document.title'));
userGesture: true 这个参数不要省。有些页面会检查有没有用户手势(navigator.userActivation),没这个标记,某些交互会被拦。
坑 1:脚本「卡住」了,其实是退出不了
第一版跑完,终端光标一直不动,看着像某个请求超时。查了半天发现是这行代码:
// 错误写法
const send = (method, params = {}, timeout = 90000) => new Promise(res => {
const i = ++id;
pending.set(i, res);
ws.send(JSON.stringify({ id: i, method, params }));
setTimeout(() => { // ← 每个请求都挂一个 90 秒定时器
if (pending.has(i)) { pending.delete(i); res(null); }
}, timeout);
});
正常返回之后,res 已经调用过了,但那个 setTimeout 还挂在事件循环里,要等 90 秒才触发。一天跑几百个请求,就是几百个待触发的定时器,Node 认为事件循环非空,进程永远不退。
表现非常误导:看起来是页面慢,其实是进程在等自己的定时器。
// 正确写法
const timer = setTimeout(() => { /* ... */ }, timeout);
timer.unref(); // 不阻止进程退出
unref() 让这个定时器不参与「事件循环是否还有活」的判定,触发照样触发,但不会把进程钉住。加上之后,脚本做完最后一件事自然退出。如果还想更干脆,结尾显式 process.exit(0),但要放在清理之后。
同理,setInterval 的轮询、fetch 的 keep-alive 连接,都可能造成同样的现象。判断方法很土但有效:在最后一行 console.log 之后打一行时间戳,如果打印了时间戳但进程不退,问题一定在定时器或句柄,不在业务逻辑。
坑 2:cookie 都在,登录态却不认了
这个坑最贵,也最容易误判。昨天在一个 profile 里登录好,今天打开同一个 profile,页面直接显示「登录 / 注册」。
第一反应是 cookie 丢了或者坏了。查一下发现不是:
const r = await send('Network.getAllCookies', {});
r.cookies
.filter(c => /huaweicloud/.test(c.domain))
.forEach(c => console.log(c.name, c.expires || 'SESSION'));
输出里 98 条 cookie 一条不少,值读出来长度正常,SessionID、CASTGC 全都在。文件在、数据在、能解开——那问题就不在本地,在服务端那一侧。
看 expires 就明白了:关键会话 cookie 全是 SESSION,也就是没有过期时间、跟着浏览器会话走的那一类。这类 cookie 的寿命不由浏览器决定,而由服务端决定:
- 服务端会话超时(通常几小时),直接失效
- 浏览器干净退出时,这类 cookie 也可能被清掉
这次是 15:48 登录,次日 00:05 失效,约 8 小时,中间浏览器被关过一次。测下来登录态的寿命是「一个工作日」,不是「一次扫码永久有效」。
所以自动化里必须做登录态探活,而且要放在跑任何任务之前:
const loggedIn = await ev(`!document.body.innerText.includes('登录 / 注册')`);
if (!loggedIn) {
notify('登录态失效,需要人工介入');
process.exit(2);
}
这条容易被忽略:大家习惯把「登录」当成一次性的准备工作,实际上它是一个需要持续监控的前置条件,和网络、磁盘一样会随时失效。失效之后如果硬跑,得到的是一堆「元素找不到」的假故障,排查方向全错。
顺带一条工程建议:profile 目录最好和那一份浏览器二进制绑定。Windows 上 cookie 用 DPAPI 加密,密钥在 profile 根目录的 Local State 里,跨安装实例混用容易出奇怪问题。这次故障不是这个原因(cookie 能正常读出),但脚本里挑浏览器路径仍然应该写死,别靠 PATH 里第一个碰到的。
坑 3:后台标签页会被限速
Chrome 对不可见的标签页有节流策略:定时器最小间隔被拉长、requestAnimationFrame 暂停、部分媒体解码也会降速。如果你的自动化靠 setTimeout 轮询页面状态,切到后台之后会发现进度几乎不动。
await send('Page.bringToFront', {});
每处理一个新页面,先把它带到前台。如果任务多、想并行,就别指望后台 tab,老老实实串行——串行的稳定性远超并行带来的那点时间收益。
坑 4:跨域 iframe 里什么都没有
页面里嵌的富文本编辑器,很多是放在 iframe 里的。如果这个 iframe 跨域,iframe.contentDocument 就是 null,CSS 选择器一个都进不去,控制台里没有任何报错,你就是拿不到东西。
这时候只有两条路:
1. 用宿主页面暴露的 API。比如编辑器把自己挂在 window.tinymce 上,就可以直接 tinymce.activeEditor.setContent(html)。但有坑:某些定制版本里 tinymce.editors 是 undefined,只有 activeEditor 可用。写代码时不要假设标准 API 都在。
2. 走 postMessage。给 iframe 发消息,让它自己处理。这条路要读对方源码,成本高,但最稳。
判断顺序建议:先 Object.keys(window) 里找有没有现成的桥,再考虑 postMessage,最后才想「我怎么模拟键盘输入」。
坑 5:合成点击没反应,但 DOM 点击可以
用 Input.dispatchMouseEvent 发真实鼠标事件是最像人的方式:
for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased']) {
await send('Input.dispatchMouseEvent', {
type, x, y, button: 'left', clickCount: 1,
});
}
但它有时候就是没反应,而且不报错。我遇到过三种原因:
1. 坐标算错:元素在视口外,getBoundingClientRect() 给的是负值或超出窗口,点到了别处。先 scrollIntoView({block:'center'}) 再取坐标。
2. 被浮层挡住:看起来在点按钮,实际点在盖住它的浮层上。诊断用一行就够:
```js
document.elementFromPoint(x, y) // 看命中的到底是谁
```
我遇到过一次,加完标签后弹出一个 61px 高的候选列表,正好压在复选框上,点了七八次都勾不上,elementFromPoint 一打就现形了。
3. 按钮绑的不是 click:有些组件监听 mousedown 或者自己实现了拖拽判定,合成事件序列不完整就不触发。这种情况用 el.click()(真实 DOM 方法,isTrusted 为 false 但会走完整事件流)往往能过去。
排查顺序:先看坐标和遮挡,再试 DOM .click(),最后才怀疑元素本身。
坑 6:同一标签页里切来切去,组件状态不重置
这个坑只在 SPA 里出现。页面是单页应用,左侧点目录、右侧换内容,URL 不变、DOM 也不重建。连续切几次之后,右侧的视频播放器起不来了:进度条停在 0,点播放没反应,等几分钟也不动。
打开 Network 面板看,只发了一个 m3u8 请求,后面的分片一个都没有;播放器的 readyState 一直是 0,既不报错也不重试。这种「静默失败」最难查,因为控制台干干净净,看起来像网络问题,换网络、重开浏览器都没用。
根因是上一个实例没被销毁。组件复用了旧实例,旧的没 destroy(),新的 loadSource() 就没生效,事件监听还挂在已经废弃的 player 上。
三个解法,按成本排:
1. 整页 reload(最土、最有效)。代价是页面回到初始状态,要重新导航到目标位置。
2. 让容器强制重建。框架里给容器一个会变的值当 key,或者手动 innerHTML = '' 后再初始化。
3. 手动 destroy。能拿到实例引用的话,切换前先 destroy() 再 create()。
另外建议给这类初始化加超时兜底:超过 5 秒还没拿到第一个分片,就显示「加载失败,点击重试」,而不是让用户对着黑屏怀疑自己的网络。
判断是不是这个坑,只看一个指标就够:同样的操作,reload 之后正常、不 reload 就不正常——那一定是状态没清干净。
还有一个小的:/json/new 必须用 PUT
想开新标签页:
curl http://127.0.0.1:9333/json/new?https://example.com # 405
curl -X PUT http://127.0.0.1:9333/json/new?https://example.com # 200
用 GET 会返回 405,而且文档里不太显眼,第一次碰很容易以为是端口有问题。
什么情况该用它,什么情况不该
| 场景 | 建议 |
|---|---|
| 复用已登录的浏览器做重复操作 | CDP 直连,最省事 |
| 定时跑一批固定页面动作 | CDP 直连 + systemd timer |
| 需要完整的测试框架、断言、报告 | Playwright,别自己造 |
| 需要跨浏览器(Firefox/Safari) | Playwright,CDP 只有 Chromium 系 |
| 需要截图对比、视频录制 | Playwright 现成的能力更全 |
我的判断标准是一条:如果这个浏览器是你自己的、登录态是你自己的、动作是固定的,就用 CDP;如果是给别人跑、环境要干净、结果要可重复,就用框架。
最后
自己写这一层最大的收获不是省了几个依赖,而是把「浏览器到底在干什么」这件事看清楚了——限速策略、cookie 的生命周期、事件是不是可信、iframe 的边界,这些在框架里都被封装掉了,出问题时只能靠猜。裸着用一次,反而知道该往哪看。
上面这些坑都是真跑出来的,代码在本地跑了几百次,不是推演。如果你也在写类似的长期运行的脚本,坑 1 和坑 2 建议提前处理,它们不会让程序立刻崩,而是让它在某个深夜悄悄停掉。
- 点赞
- 收藏
- 关注作者
评论(0)