资讯详情

资讯详情

不装 Playwright:130 行 Node 手搓 CDP 客户端驱动 Chrome 的最小实现

背景与取舍为什么要自己写我在维护一款微信自动回复方向的本地工具日常要驱动一台正在跑真实业务的 Chrome 做自动化自检导航到指定页面、读一下评论框状态、在页面上下文里执行一段 JS 拿结果。这类需求第一反应是上 Playwright 或 Puppeteer但在目标机器上它们并不总是合适的- 目标机器是生产环境PATH 里连 node.exe 都没有npm install 一堆依赖再拖着 Chromium 二进制安装面越大越不好收尾- 要驱动的不是新开的干净浏览器而是一台已经登录、跑着业务、带完整用户数据目录的既有 Chrome——它由别的方式启动带着--remote-debugging-port参数我只想去连它不是去拉起它- 需求其实很窄就是连上去、在某个页面执行 JS、拿回返回值。为此引入一个完整的自动化框架依赖收益比太差。CDPChrome DevTools Protocol本身就是 Chrome 内置的调试协议--remote-debugging-port9301一开浏览器就在本地起了一个 HTTP WebSocket 的控制面。本文记录不依赖任何第三方库、只用node:net/node:http/node:crypto手搓一个最小 CDP 客户端的过程全量代码 130 行左右能完成枚举页面、执行 JS、支持 awaitPromise、可控超时这几件核心事。## 协议面两步握手CDP 的使用分两步先 HTTP 后 WebSocket。第一步HTTP 枚举。浏览器在调试端口上提供了一组只读端点/json/version给版本信息/json/list给当前所有 page 类型的 target 列表每项里关键的是webSocketDebuggerUrl——这就是第二步要连的地址。这一步用node:http的http.get五行就够注意要设 5 秒超时并在错误时返回 null 而不是抛出因为浏览器没开是要作为常态处理的分支不是异常。第二步WebSocket 升级。CDP 的指令全部走 WebSocket。手搓 WS 听起来吓人其实握手就是一次特殊的 HTTP GETGET /devtools/page/XXXX HTTP/1.1Host: 127.0.0.1:9301Upgrade: websocketConnection: UpgradeSec-WebSocket-Key: 16字节的base64Sec-WebSocket-Version: 13服务端返回101 Switching Protocols即升级成功。Sec-WebSocket-Key用crypto.randomBytes(16).toString(base64)生成即可客户端并不需要真的去做 RFC 6455 要求的GUID SHA1应答校验那是服务端的事只看状态码是不是 101。## 帧编解码只有两个绕不开的点握手之后的通信是 WS 帧格式。作为客户端要发帧和收帧各有一个绕不开的细节。发帧必须带掩码。RFC 6455 规定客户端发给服务端的帧必须异或掩码MASK位1掩码值是随机 4 字节负载的每个字节与mask[i % 4]异或。这个步骤漏掉服务端直接断连而且报错信息不一定指向掩码。帧头长度分三档负载 126 字节用 1 字节长度65536 用 2 字节126作哨兵值更大用 8 字节127作哨兵值。执行 JS 的请求带着完整表达式文本很容易超过 65536所以 8 字节档位必须实现。收帧要处理粘包与心跳。TCP 是字节流一次data事件可能带着半帧、一帧或几帧必须维护一个缓冲区按帧头声明的长度切分。此外 Chrome 会周期性发pingopcode 0x9不回pongopcode 0xA就会被断连——最小实现里收到 ping 就地回一个带同样负载的 pong 即可。opcode 0x8 是 close收到就把连接销毁。这两个点处理完剩下的编解码反而是体力活js_raw(op, payload) { const mask crypto.randomBytes(4); const n payload.length; let head; if (n 126) head Buffer.from([0x80 | op, 0x80 | n]); else if (n 65536) { head Buffer.alloc(4); head[0] 0x80 | op; head[1] 0x80 | 126; head.writeUInt16BE(n, 2); } else { head Buffer.alloc(10); head[0] 0x80 | op; head[1] 0x80 | 127; head.writeBigUInt64BE(BigInt(n), 2); } const m Buffer.from(payload); for (let i 0; i n; i) m[i] ^ mask[i 3]; this.sock.write(Buffer.concat([head, mask, m]));}0x80 | op 是把 FIN 位置 1——单帧发完不做分片最小实现不需要分片逻辑。## 执行 JS请求-响应的关联靠自增 idCDP 是 JSON-RPC 风格发出去的每条指令带一个 id回应里带着同一个 id。所以 Runtime.evaluate 的封装就是一个发请求 在回包流里等配对 id的循环jsws.send(JSON.stringify({ id, method: “Runtime.evaluate”, params: { expression, returnByValue: true, awaitPromise: true }}));三个参数各有用途returnByValue: true让返回值走 JSON 序列化而不是抛一个远端对象句柄后者还得再发一次调用去取最小实现不值得awaitPromise: true让页面里的async函数/Promise 直接等到 settle 再回——评论区提交后要等 3.5 秒再验证结果这类时序逻辑全靠它否则就要拆成多次调用在客户端侧拼。错误处理有三层都要接住WS 层的recv超时Chrome 没回协议层的m.error方法名或参数不合法执行层的exceptionDetailsJS 自己抛了。第三层尤其有价值把exception.description截前 200 字符返回给调用方排障时能直接看到页面里的报错栈。另一个实战点page target 的选择。/json/list返回的顺序并不承诺稳定浏览器里开着十几个 tab 时第一个 page未必是你刚操作的那个。我的做法是给选择器加过滤条件——只挑 URL 匹配目标站点的那一个同时每次调用新建连接、用完即断不在客户端侧维护长连接状态。每条指令一次握手的开销在本地回环上大约 3~5 毫秒对分钟级的巡检任务完全可以忽略换来的好处是任何一次异常都不会污染下一次调用。## 两个用真实事故换来的细节其一超时要贯穿到底。早期版本的recv用了固定 8 秒超时但awaitPromise的表达式在页面里等一个 10 秒的定时器结果就是内核先超时、页面后完成返回值丢了。修正为把调用方的超时透传给recv并且每次循环重算剩余额度ws.recv(timeoutMs - (Date.now() - t0))。超时预算从第一次调用起就是一条向下的直线而不是每层重置。其二别把文本当参数穿过 shell。有一次把带中文的判据串作为命令行参数传给 node 子进程在 Git Bash 里被编码转换破坏日志里明明有这条记录、搜出来却是 0 命中顺着日志缺失的错误方向查了半小时。后来改成把整个脚本写成 .mjs 文件再执行文本走文件不走 argv。教训是能走文件的数据别走进程参数Windows 上中文编码在管道两端都可能被动手脚。## 验证与收尾最终这个 130 行的客户端在真机上连续跑了几天承担两类任务每分钟级的页面状态巡检评论框是否就绪、组件是否渲染和低频的内容投递导航 填充 提交 清空判据回验。稳定运行的关键数据零断连每次调用独立连接、零假成功提交类操作全部带回写入前后长度对比的判据、最长的单次调用导航 等待 提交 验证约 40 秒在 25~45 秒可配超时内。回头看这个需求最初被要不要上 Playwright绊了一下。框架的价值在于覆盖场景的全而场景窄的时候协议本身反而不复杂HTTP 枚举 WS 握手 帧编解码 id 配对四件事拼起来就是一个可用的客户端。自己写一遍的额外收益是把 CDP 的排障视角拿到了——后来遇到evaluate 没回包这类问题能直接判断是 WS 层、协议层还是页面执行层的事不用再隔着框架猜。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →