☰
不装 Playwright:130 行 Node 手搓 CDP 客户端驱动 Chrome 的最小实现
2026/10/4 17:07:10 网站建设 项目流程

背景与取舍:为什么要自己写我在维护一款微信自动回复方向的本地工具,日常要驱动一台正在跑真实业务的 Chrome 做自动化自检:导航到指定页面、读一下评论框状态、在页面上下文里执行一段 JS 拿结果。这类需求第一反应是上 Playwright 或 Puppeteer,但在目标机器上它们并不总是合适的:- 目标机器是生产环境,PATH 里连 node.exe 都没有,npm install 一堆依赖再拖着 Chromium 二进制,安装面越大越不好收尾;- 要驱动的不是"新开的干净浏览器",而是一台已经登录、跑着业务、带完整用户数据目录的既有 Chrome——它由别的方式启动,带着--remote-debugging-port参数,我只想去连它,不是去拉起它;- 需求其实很窄:就是"连上去、在某个页面执行 JS、拿回返回值"。为此引入一个完整的自动化框架,依赖收益比太差。CDP(Chrome DevTools Protocol)本身就是 Chrome 内置的调试协议,--remote-debugging-port=9301一开,浏览器就在本地起了一个 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 GET:GET /devtools/page/XXXX HTTP/1.1Host: 127.0.0.1:9301Upgrade: websocketConnection: UpgradeSec-WebSocket-Key: <16字节的base64>Sec-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 会周期性发ping(opcode 0x9),不回pong(opcode 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(方法名或参数不合法);执行层的exceptionDetails(JS 自己抛了)。第三层尤其有价值,把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 层、协议层还是页面执行层的事,不用再隔着框架猜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询