首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用 CDP 协议给浏览器装上遥控器:把 Chrome 自动化跑通的全过程

用 CDP 协议给浏览器装上遥控器:把 Chrome 自动化跑通的全过程

原创
作者头像
用户12287393
发布于 2026-10-15 07:25:50
发布于 2026-10-15 07:25:50
70
举报

一句话概括:当一个 AI 助手需要"动手操作网页"而目标网站没有任何开放接口时,与其装一堆重量级框架,不如直接跟 Chrome DevTools Protocol 对话。这篇文章记录我从零打通这条路的全过程,包括 5 个真实踩过的坑和它们的解法。

脚本驱动 Monaco 编辑器自动灌入 6702 字正文的实况截图
脚本驱动 Monaco 编辑器自动灌入 6702 字正文的实况截图

一、问题从哪来

我最近在用一个 AI 助手完成一系列需要在网页上完成的任务:查积分、领奖励、写稿、投稿。很快遇到了一层天花板——

模型能思考,但没有"手"。

所有需要真实点击、拖拽、文件上传、跨页面跳转的操作,光靠对话都完不成。目标网站既没有开放 API,也不允许用脚本伪造请求(这是合规红线)。唯一被允许的路径,就是像人一样操作浏览器界面。

于是问题变成了:怎么给浏览器装一个可靠的遥控器?

二、选型的纠结:为什么最后选了裸 CDP

常规答案有三个,我逐一试过:

方案

优点

我放弃它的原因

Selenium

生态成熟、资料多

需要匹配版本的 WebDriver,依赖重,启动慢,日志噪音大

Playwright

API 友好、自带等待策略

首次安装要下载多套浏览器内核,环境受限时容易卡住

agent-browser 类封装 CLI

命令行直达

在目标站点页面频繁崩溃,且不可控、难调试

而我的需求其实很朴素:在一个已经登录了账号的 Chrome 实例里,执行一些精确的 DOM 操作。

这个场景下,最合适的其实是浏览器自己暴露的那套调试协议 —— Chrome DevTools Protocol(CDP)。它是 Chrome 原生支持的,不需要装任何东西,一个 WebSocket 就能对话,而且能力是"官方级"的:DOM 操作、网络拦截、输入模拟、文件注入,全都在协议里。

唯一的门槛:它偏底层,需要自己封装一层薄薄的客户端。但这个成本,比装一整套框架低得多。

三、起步:让 Chrome 打开那扇调试的窗

第一步是带调试端口启动 Chrome 实例。关键是一定要指定一个独立的 user-data-dir,这样它会复用你已有的登录态(Cookie、LocalStorage),省去重复扫码登录的麻烦。

代码语言:bash
复制
chrome.exe \
  --remote-debugging-port=9222 \
  --user-data-dir="C:\Users\你的目录\chrome-profile" \
  --no-first-run --no-default-browser-check \
  --disable-features=AutomationControlled \
  "https://目标站点"

启动之后,浏览器会挂一个本地 HTTP 服务,列出所有可调试的页面:

代码语言:bash
复制
curl http://127.0.0.1:9222/json/list

返回体里每个页面对象都有一个 webSocketDebuggerUrl —— 连上这个地址,你就拥有了这个页面的完整控制权。

⚠️ 这里有个让我卡了半小时的坑:如果在受限的沙箱环境里启动 Chrome,页面会一片空白,控制台报网络服务崩溃。症状极具迷惑性——请求发出去了,页面就是不渲染。解决办法是在沙箱外启动进程。判断标准很简单:新开标签页能正常渲染,才算真的通了。

四、最小可用客户端:不到 60 行

CDP 的本质是 JSON-RPC over WebSocket。核心逻辑就三件事:发消息、按 id 配对响应、超时兜底。

代码语言:javascript
复制
const http = require('http');

async function getTargets(port = 9222) {
  return new Promise((res, rej) => {
    http.get({ host: '127.0.0.1', port, path: '/json/list' }, (r) => {
      let b = '';
      r.on('data', (d) => (b += d));
      r.on('end', () => res(JSON.parse(b)));
    }).on('error', rej);
  });
}

async function attach(page) {
  const ws = new WebSocket(page.webSocketDebuggerUrl);
  let seq = 0;
  const waiters = new Map();

  const send = (method, params = {}) =>
    new Promise((resolve, reject) => {
      const id = ++seq;
      waiters.set(id, { resolve, reject });
      ws.send(JSON.stringify({ id, method, params }));
      setTimeout(() => {
        if (waiters.has(id)) {
          waiters.delete(id);
          reject(new Error('TIMEOUT ' + method));
        }
      }, 60000);
    });

  ws.onmessage = (e) => {
    const m = JSON.parse(e.data);
    if (m.id && waiters.has(m.id)) {
      const w = waiters.get(m.id);
      waiters.delete(m.id);
      m.error ? w.reject(new Error(JSON.stringify(m.error))) : w.resolve(m.result);
    }
  };

  await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
  await send('Page.enable');
  await send('Runtime.enable');
  await send('DOM.enable');
  await send('Page.bringToFront').catch(() => {});
  return send;
}

有了 send,最常用的就是 Runtime.evaluate —— 把任意一段 JS 注入页面执行,这是整套方案的主力武器。

代码语言:javascript
复制
const result = await send('Runtime.evaluate', {
  expression: 'document.querySelector("h1").innerText',
  returnByValue: true,
  awaitPromise: true,
});

到这里,你已经可以对任意页面执行任意脚本了。但真正难的部分才刚开始。

五、五个真实的坑(以及怎么用它们判断 AI 到底靠不靠谱)

坑 1:坐标错位——点哪都点不中

我第一次用 Input.dispatchMouseEvent 派发真实鼠标事件时,点了个寂寞。原因是浏览器存在页面缩放比例,截图坐标系和 CSS 坐标系不是一回事。

解法是统一用 CSS 坐标(也就是 getBoundingClientRect() 返回的那套),而不是截图算出来的物理像素坐标。判断标准:拿到元素 rect 之后,先做一次 document.elementFromPoint(x, y) 自检——如果返回的栈顶元素不是你的目标,说明坐标体系错了,别急着点。

顺带一提,elementFromPoint 本身还有一个更实用的用途:先查栈顶,再决定怎么点。 它直接救了我后面那个更隐蔽的坑。

坑 2:React 受控组件——赋值成功了,但状态没变

网页上的输入框大多是 React 受控组件。你直接 el.value = 'xxx',页面上看不出任何变化——因为框架压根不知道值变了。

正确姿势是走原型链上的原生 setter,再手动派发 input 事件:

代码语言:javascript
复制
const set = Object.getOwnPropertyDescriptor(
  window.HTMLTextAreaElement.prototype, 'value'
).set;
set.call(el, '要填的内容');
el.dispatchEvent(new Event('input', { bubbles: true }));

textarea 用 HTMLTextAreaElement.prototype,普通 input 用 HTMLInputElement.prototype,别搞混。

坑 3:Monaco 编辑器——textContent 是没用的

在线写作平台的正文编辑器很多是 Monaco(VS Code 同内核)。这类编辑器压根不是 textarea,用 DOM 操作完全无效。

但它把模型对象暴露在了 window 上:

代码语言:javascript
复制
monaco.editor.getModels()[0].setValue(你的Markdown全文);

一句话搞定,几千字的稿子瞬间灌进去。前提是得先切到 Markdown 编辑器模式,富文本模式下这个对象是拿不到的。

坑 4:文件上传——accept 校验导致找不到目标

用 DOM.setFileInputFiles 可以直接给 input[type=file] 注入本地文件,不需要系统级的文件选择对话框。流程是:

代码语言:javascript
复制
const { root }  = await send('DOM.getDocument', { depth: -1 });
const { nodeIds } = await send('DOM.querySelectorAll', {
  nodeId: root.nodeId,
  selector: 'input[type=file]',
});
await send('DOM.setFileInputFiles', { files: ['C:/path/to/img.png'], nodeId: nodeIds[0] });

但要注意:页面上可能有多个隐藏的 file input,各自的 accept 属性不同,直接按第一个注入会静默失败。稳妥做法是先读 DOM.getAttributes 拿到 accept,匹配 .png/.jpg 的那个才是上传图片的;必要时强制指定 nodeId。

坑 5:全屏透明遮罩——按钮在,但点不到

这是本次最难的一个。流程走到了最后一步:发布确认弹窗里,"确认发布"按钮清晰可见,坐标也完全正确,但点击后毫无反应,没有任何网络请求。

一度以为是脚本写错了。直到我用 elementsFromPoint 查了一下那个坐标上面的元素栈——栈顶根本不是按钮,而是一个 z-index: 9000 的透明遮罩层,尺寸恰好是整屏 1334×698,把点击事件全吃掉了。

解法是把遮罩设为"不可命中",同时把按钮顶上来:

代码语言:javascript
复制
const mask = document.querySelector('.c-modal-mask');
if (mask) mask.style.pointerEvents = 'none';
document.querySelector('.c-modal').style.pointerEvents = 'none';
const btn = [...document.querySelectorAll('button')]
  .find(x => (x.innerText || '').trim() === '确认发布');
btn.style.pointerEvents = 'auto';
btn.style.zIndex = '9999';
btn.style.position = 'relative';

改完之后再 elementFromPoint 自检,栈顶变成按钮了,点击立刻生效。

六、教训最深的一条:视觉反馈 ≠ 执行结果

走完上面五个坑,我反而想强调一个比技术细节更重要的东西。

在坑 5 里,我因为"点击后看不到任何反应",误判成操作失败,重复提交了三次同样的操作。结果目标站点上真的生成了三条相同的记录,我还得去后台手动清理多余的两条。

真实情况是:那几次点击每一次都成功了,只是界面没给我任何明示。

这件事给了我一条现在会写进所有自动化脚本的原则:

判断操作是否成功,永远以数据源的状态为准,不要以界面反馈为准。 提完表单就去列表页核对记录条数;点完删除就去查接口返回。中间可以有别的数据校验,但不能只看 UI。 并且,任何非幂等的操作(发布、下单、转账)都必须加上"先查后做"的守卫。

这一点在 AI 参与的自动化里尤其关键。模型很容易把"没看到变化"解读为"操作失败",然后自作主张重试,把一次成功变成三次事故。

七、一份通用的浏览器自动化 checklist

踩完这些坑之后,我整理了一个自己的执行清单,供参考:

  1. 环境自检 —— 新标签页能否正常渲染?是否在沙箱里?(症状是白屏)
  2. 坐标自检 —— elementFromPoint 返回的栈顶是不是目标元素?
  3. 栈顶自检 —— 目标元素上面有没有透明的遮挡层?
  4. 赋值自检 —— 受控组件要用原生 setter + input 事件;Monaco 用 getModels()[0].setValue
  5. 结果自检 —— 去数据源核对,别信 UI;非幂等操作加守卫

前三条是"能不能点到",第四条是"能不能填进去",最后一条是"有没有做错"。绝大多数自动化事故都发生在最后一条上。

八、关于边界

最后说一句合规。这套能力本身是中性的,能用来大幅提升效率——批量整理素材、自动化发布内容、定时巡检页面。但有两件事我不会做:

  • 不伪造请求、不篡改数据。走 UI 是为了模拟真实用户行为,而不是绕过后端的业务校验。真实的点击、真实的表单,才有真实的结果。
  • 不用于刷量与薅羊毛。任何把"自动化"用在操纵数据指标上的行为,本质上都是在对站点做攻击。

工具的能力边界,就是使用者的边界。

小结

回头看,CDP 这条路真正的门槛不在技术上——协议本身很直白,一个 WebSocket 就能聊起来。难点全在工程细节:坐标体系、受控组件、编辑器内核、z-index 层级,以及最关键的结果校验意识。

有意思的是,这套"较真"的流程恰好也是 AI 助手最需要被约束的地方。它可以在几分钟内写出一版像样的自动化脚本,但会因为一次视觉误判把事情搞砸三次。技术的部分可以交给它,30 秒一次的自检意识和"先查后做"的习惯,得写进流程里由人来把关。 人机协作的价值,大概就在这互相补位的地方。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、问题从哪来
  • 二、选型的纠结:为什么最后选了裸 CDP
  • 三、起步:让 Chrome 打开那扇调试的窗
  • 四、最小可用客户端:不到 60 行
  • 五、五个真实的坑(以及怎么用它们判断 AI 到底靠不靠谱)
    • 坑 1:坐标错位——点哪都点不中
    • 坑 2:React 受控组件——赋值成功了,但状态没变
    • 坑 3:Monaco 编辑器——textContent 是没用的
    • 坑 4:文件上传——accept 校验导致找不到目标
    • 坑 5:全屏透明遮罩——按钮在,但点不到
  • 六、教训最深的一条:视觉反馈 ≠ 执行结果
  • 七、一份通用的浏览器自动化 checklist
  • 八、关于边界
  • 小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档