用 200 行 Node.js 写一个极简 CDP 驱动:不装 Playwright 的浏览器自动化
用 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 里跑也不占什么资源。
- 点赞
- 收藏
- 关注作者
评论(0)