用 CDP 把浏览器变成自动化终端:从零搭建无头操作框架
写自动化脚本时,你是否遇到过这些场景:元素抓不准、登录态每次都要重登、按钮点了没反应? 这篇笔记记录我最近搭建的一套基于 Chrome DevTools Protocol(CDP)的浏览器自动化方案。不依赖 Selenium 或 Puppeteer,只用 Node.js 内置的 WebSocket 和 fetch 就能跑起来,代码量小到可以放进一个脚本文件里。 一、为什么直接用 CDP 传统的 Selenium / Playwright 提供了丰富的选择器、等待机制和上下文管理,是很好的工程化选择。但当需求只是"打开页面、点几个按钮、读几个值"时,这些框架的抽象反而是负担:依赖体积大、安装慢,元素选择器 API 需要额外学习,想复用已有的浏览器登录态也比较麻烦。 CDP 本身是浏览器暴露的一组调试协议,所有自动化框架底层都在用它。直接对话 CDP,你只需要两个动作:发指令、收结果。 二、启动带调试端口的浏览器 自动化要能控制浏览器,第一步是让它开一个调试端口。Chrome 和 Edge 都支持: chrome --remote-debugging-port=9222 --user-data-dir=/tmp/automation-profile --user-data-dir 指向一个独立目录,这样自动化用的登录态和日常浏览互不干扰;更重要的是,登录一次之后状态就持久化在磁盘上了,第二天直接复用,不用重复扫码登录。 三、连上目标页面 启动后访问 http://127.0.0.1:9222/json/list 会拿到所有可调试目标的 JSON 数组,每个目标里都有 webSocketDebuggerUrl。Node 18+ 已经内置了全局 WebSocket,所以整个连接层只需要几十行: async function connect(wsUrl) { const ws = new WebSocket(wsUrl); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); let id = 0; const pending = new Map(); ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); const task = pending.get(msg.id); if (!task) return; pending.delete(msg.id); msg.error ? task.reject(new Error(JSON.stringify(msg.error))) : task.resolve(msg.result); }; const send = (method, params = {}) => new Promise((resolve, reject) => { const mid = ++id; pending.set(mid, { resolve, reject }); ws.send(JSON.stringify({ id: mid, method, params })); }); return { ws, send }; } 核心就三步:发消息带自增 id,回来时用 id 找到对应的 Promise 兑现。剩下的都是细节。 四、执行 JavaScript 与读取结果 真正好用的是 Runtime.evaluate —— 你可以直接在页面上下文里执行 JS,并把结果原样拿回来: const r = await send('Runtime.evaluate', { expression: "(() => ({ title: document.title, text: document.body.innerText.slice(0, 500) }))()", awaitPromise: true, returnByValue: true, userGesture: true }); console.log(r.result.value); 注意 returnByValue: true,它让结果可以被序列化传回来;awaitPromise: true 则让你在页面里直接 await 异步操作。 这个能力几乎覆盖了所有"读取页面状态"的需求:抓列表、读表格、翻页、提取正文,都不需要写选择器。 五、点击与输入:信任事件 有个坑值得单独说:用 element.click() 触发的点击不算"用户手势",很多站点会认为这是自动化行为而拒绝响应,或者干脆忽略。 解决办法是走 CDP 的输入事件,用真实坐标派发鼠标事件: const rect = await evaluate("(() => { const el = [...document.querySelectorAll('button')] .find(b => b.innerText.trim() === '提交'); el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()"); await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'left', clickCount: 1 }); await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x, y: rect.y, button: 'left', clickCount: 1 }); 对于文本输入,Input.insertText 比逐字符敲 key 事件更省事,一次就能填入长文本: await send('Input.insertText', { text: '这里是要输入的整段文字' }); 六、几个实战经验 第一,先读后写,不要盲点。拿到元素坐标前,先用 evaluate 确认元素存在、可见、文案符合预期,再去点。多花一次查询,能省掉一整轮的"点了没反应"。 第二,页面元素位置会变。我遇到过滚动导致坐标偏移的情况——元素在视口里的位置变了,点击就落到别的地方。稳妥做法是每次点击前都重新取一次 getBoundingClientRect(),不要缓存坐标。 第三,加超时保护。evaluate 在页面卡死时会一直挂着。我通常包一层 Promise.race: const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))]); 第四,监听网络请求来发现接口。有时候页面数据是 XHR 拉来的,直接看接口比解析 DOM 稳定得多。开 Network.enable,把 Network.requestWillBeSent 事件打出来,接口路径和参数一览无余。 第五,截图做兜底。Page.captureScreenshot 可以随时抓一张当前界面的图。自动化跑挂了、页面出现意外弹窗时,一张截图比读十行日志更有用。 七、安全提醒 自动化会涉及账号操作,有几条底线要守住:不要把密码、Token 写进脚本或提交到仓库,用环境变量或系统钥匙串;涉及资金、删除、发布的动作,保留人工确认环节;不要在自动化里绕过验证码,遇到就停下来交给人处理。 小结 CDP 是一套被低估的接口。如果你对浏览器自动化有需求,又不想被框架的抽象束缚,不妨直接从 CDP 起步:启动浏览器、连上 WebSocket、evaluate 读状态、dispatch 输入事件做操作 —— 四步就能跑通一个完整流程。 后续我打算在这个基础上补一篇"用 CDP 加 Node.js 做定时任务"的实战,把流程串成每天自动执行的脚本。
- 点赞
- 收藏
- 关注作者
评论(0)