用 200 行 Node.js 写一个极简 CDP 驱动:不装 Playwright 的浏览器自动化

举报
yd_283294818 发表于 2026/10/27 18:00:07 2026/10/27
【摘要】 不装 Playwright,用 200 行 Node.js 直接对接 Chrome DevTools Protocol:合成 click 为什么点不动按钮、跨域 iframe 怎么进、编辑器为什么不吃 value,以及三个实测踩坑的解决办法。

用 200 行 Node.js 写一个极简 CDP 驱动:不装 Playwright 的浏览器自动化

背景

手里的浏览器自动化需求其实很"小":每天重复点几个按钮、填几个输入框、抓一个列表页的文本。按常规做法装 Playwright,一条 npm i 拉下来两百多兆,还得配套下载一份 Chromium。机器的内存只有 8G,为了每天跑三十秒的脚本常驻这么一坨依赖,性价比不高。

换个思路:浏览器本身就带调试协议(Chrome DevTools Protocol)。开一个调试端口,用 WebSocket 发 JSON-RPC,Node 22 内置了 WebSocket,一个依赖都不用装。

先说明:这不是要替代 Playwright。要跨浏览器、要网络拦截模拟、要复杂等待策略,还是老实上框架。但如果你的场景就是"在已经登录的浏览器里点几下",自己写一个 200 行的驱动更省心。

一、协议入口只有两个接口

启动浏览器时带一个参数:

chrome --remote-debugging-port=9222 --user-data-dir=/path/to/profile

然后:

  • GET http://127.0.0.1:9222/json/list 返回所有标签页,每个带 webSocketDebuggerUrl
  • 连上那个 WebSocket,之后全是 {id, method, params} 的请求 / 响应
async function targets() {
  const r = await fetch('http://127.0.0.1:9222/json/list');
  return (await r.json()).filter(t => t.type === 'page');
}

type === 'page' 这一句要留,列表里还有 service worker、iframe 之类的条目,混进来会导致选错目标。选中标签页后,按 URL 子串匹配即可——因为复用用户真实配置目录,你没法预知标签页顺序。

二、会话封装:30 行足够

class Session {
  constructor(wsUrl) { this.wsUrl = wsUrl; this.id = 0; this.pending = new Map(); }
  async open() {
    this.ws = new WebSocket(this.wsUrl);
    await new Promise((res, rej) => {
      this.ws.addEventListener('open', res, { once: true });
      this.ws.addEventListener('error', rej, { once: true });
    });
    this.ws.addEventListener('message', ev => {
      const msg = JSON.parse(ev.data);
      const p = this.pending.get(msg.id);
      if (p) { this.pending.delete(msg.id); msg.error ? p.rej(new Error(JSON.stringify(msg.error))) : p.res(msg.result); }
    });
  }
  send(method, params = {}) {
    const id = ++this.id;
    this.ws.send(JSON.stringify({ id, method, params }));
    return new Promise((res, rej) => this.pending.set(id, { res, rej }));
  }
}

要点只有一个:响应可能乱序,也可能夹杂事件推送(Log.entryAdded、Network.responseReceived),必须按 id 查表,不能一次 send 配一个一次性监听。

三、坑一:合成事件点不动某些按钮

最开始的写法很自然:

document.querySelector(sel).click()

大部分按钮能点,但总有几个死活没反应——尤其是那种"鼠标悬停才展开"的菜单。原因在于 element.click() 派发的是 isTrusted === false 的合成事件,前端框架里如果挂了 onMouseEnter 或者依赖 pointerdown 的判定逻辑,就会直接忽略。

解决办法是发真实的鼠标事件序列:

await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved',   x, y, button: 'none' });
await s.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1, buttons: 1 });
await s.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });

坐标要从元素自身算,用 getBoundingClientRect() 取中心点,并且先 scrollIntoView,再等一帧。滚动是异步的,写完立刻取坐标,拿到的是滚动前的位置,点下去就偏了。

有个细节值得记住:mouseMoved 的 button 必须是 'none',clickCount 给 0。参数写错不会报错,只是事件不生效,排查起来很费时间。

四、坑二:iframe 里的脚本进不去

跨域的 iframe,主文档的 Runtime.evaluate 拿不到它的 DOM。调试协议其实留了后门——Page.getFrameTree 拿到整棵 iframe 树,给目标 frame 建一个独立执行环境:

const tree = await s.send('Page.getFrameTree');
let target = null;
const walk = n => {
  if (n.frame.url.includes(frameUrlSubstr)) target = n.frame;
  (n.childFrames || []).forEach(walk);
};
walk(tree.frameTree);

const ctx = await s.send('Page.createIsolatedWorld', { frameId: target.id, worldName: 'cdp' });
const r = await s.send('Runtime.evaluate', {
  expression: js, contextId: ctx.executionContextId, returnByValue: true,
});

createIsolatedWorld 建出来的是隔离环境,跟页面自身的 JS 变量互不可见,但 DOM 是同一份。也就是说,能被页面脚本改的东西(输入框的 value),你改完必须手动派发 input / change 事件,否则框架的状态层不知道值变了。

改原生输入框的值还有一层:直接 el.value = x 在 React 之类的框架里不生效,因为它在内部劫持了 setter。要用原型上的原始 setter:

const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
set.call(el, '新值');
el.dispatchEvent(new Event('input', { bubbles: true }));

五、坑三:富文本编辑器不吃 value

需要往内容编辑器里塞一大段文字时,innerHTML 和 value 都不可靠。最稳的做法是:真点一下编辑区拿到焦点,然后 Input.insertText:

await s.send('Input.insertText', { text: 长文本 });

这条命令走的是浏览器输入法通道,效果等价于粘贴,编辑器会老老实实走自己的 input 流程。前提是先真点一下——focus() 有时不够,得有真实的 mousedown。

顺带一个容易踩的:编辑器会自动保存草稿。第二次跑脚本时不清空就插入,内容会叠加成两份。清空用 Ctrl/Cmd + A 后按 Delete,而不是 innerHTML = '',后者在部分编辑器里只改了视图、模型没变,一保存又写回去了。

六、值得顺手加的两个命令

调试期最有用的是"监听"模式:挂 8 秒,把 console 输出、网络失败、请求列表全打出来。

s.ws.addEventListener('message', ev => {
  const m = JSON.parse(ev.data);
  if (m.method === 'Network.loadingFailed')
    console.log('[NETFAIL]', m.params.errorText, m.params.type);
});

前端"点了没反应"的情况,八成能在 Network.loadingFailed 里看到 net::ERR_BLOCKED_BY_CLIENT 或者超时。比肉眼盯着页面猜快得多。

另一个是截图。Page.captureScreenshot 出来的图默认可能被缩放,如果后续要按图定位坐标,记得把缩放比算进去,否则横竖对不上。

七、什么时候该停手

自己写驱动的边界要清楚:

  • 需要等元素出现、等网络空闲 → 自己写轮询,能写但容易漏
  • 需要跨浏览器或移动端 → 直接上框架
  • 需要处理下载、文件选择、权限弹窗 → 调试协议能做,但代码量会翻几倍
  • 需要稳定的 CI 回归 → 框架的 trace 和录像省下的调试时间远超安装成本

我的用法是个折中:日常小脚本用这套 200 行的东西,连的是常年开着的、已登录的浏览器配置目录,省掉了每次启动和登录;要写正经测试还是交给 Playwright。

小结

  • CDP 的门槛比想象低:/json/list 拿地址,WebSocket 发 JSON-RPC,核心就这两个
  • 合成 click() 会被框架忽略,关键操作一律走 Input.dispatchMouseEvent
  • 跨域 iframe 用 Page.createIsolatedWorld 进
  • 往编辑器写长文本用 Input.insertText,清空用键盘而不是改 DOM
  • 出问题先看 Network.loadingFailed,多数"没反应"是请求被拦或超时

整个驱动不到 200 行,零依赖,挂在 cron 里跑也不占什么资源。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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