用 CDP 直接驱动 Chrome:零依赖做浏览器自动化,附 6 个非踩不可的坑

举报
yd_254048033 发表于 2026/10/27 00:15:32 2026/10/27
【摘要】 不装 Playwright、不装 Selenium,用 Node 内置 WebSocket 直连 Chrome DevTools Protocol。附 6 个实战坑:定时器不 unref 导致进程退不出、cookie 都在登录态却失效、后台标签页被限速、跨域 iframe 拿不到 DOM、合成点击静默失效、SPA 切页组件状态不重置,以及 /json/new 为什么必须用 PUT。

为什么会想自己写这一层

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 建议提前处理,它们不会让程序立刻崩,而是让它在某个深夜悄悄停掉。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。