一句话概括:当一个 AI 助手需要"动手操作网页"而目标网站没有任何开放接口时,与其装一堆重量级框架,不如直接跟 Chrome DevTools Protocol 对话。这篇文章记录我从零打通这条路的全过程,包括 5 个真实踩过的坑和它们的解法。
脚本驱动 Monaco 编辑器自动灌入 6702 字正文的实况截图
我最近在用一个 AI 助手完成一系列需要在网页上完成的任务:查积分、领奖励、写稿、投稿。很快遇到了一层天花板——
模型能思考,但没有"手"。
所有需要真实点击、拖拽、文件上传、跨页面跳转的操作,光靠对话都完不成。目标网站既没有开放 API,也不允许用脚本伪造请求(这是合规红线)。唯一被允许的路径,就是像人一样操作浏览器界面。
于是问题变成了:怎么给浏览器装一个可靠的遥控器?
常规答案有三个,我逐一试过:
方案 | 优点 | 我放弃它的原因 |
|---|---|---|
Selenium | 生态成熟、资料多 | 需要匹配版本的 WebDriver,依赖重,启动慢,日志噪音大 |
Playwright | API 友好、自带等待策略 | 首次安装要下载多套浏览器内核,环境受限时容易卡住 |
agent-browser 类封装 CLI | 命令行直达 | 在目标站点页面频繁崩溃,且不可控、难调试 |
而我的需求其实很朴素:在一个已经登录了账号的 Chrome 实例里,执行一些精确的 DOM 操作。
这个场景下,最合适的其实是浏览器自己暴露的那套调试协议 —— Chrome DevTools Protocol(CDP)。它是 Chrome 原生支持的,不需要装任何东西,一个 WebSocket 就能对话,而且能力是"官方级"的:DOM 操作、网络拦截、输入模拟、文件注入,全都在协议里。
唯一的门槛:它偏底层,需要自己封装一层薄薄的客户端。但这个成本,比装一整套框架低得多。
第一步是带调试端口启动 Chrome 实例。关键是一定要指定一个独立的 user-data-dir,这样它会复用你已有的登录态(Cookie、LocalStorage),省去重复扫码登录的麻烦。
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 服务,列出所有可调试的页面:
curl http://127.0.0.1:9222/json/list返回体里每个页面对象都有一个 webSocketDebuggerUrl —— 连上这个地址,你就拥有了这个页面的完整控制权。
⚠️ 这里有个让我卡了半小时的坑:如果在受限的沙箱环境里启动 Chrome,页面会一片空白,控制台报网络服务崩溃。症状极具迷惑性——请求发出去了,页面就是不渲染。解决办法是在沙箱外启动进程。判断标准很简单:新开标签页能正常渲染,才算真的通了。
CDP 的本质是 JSON-RPC over WebSocket。核心逻辑就三件事:发消息、按 id 配对响应、超时兜底。
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 注入页面执行,这是整套方案的主力武器。
const result = await send('Runtime.evaluate', {
expression: 'document.querySelector("h1").innerText',
returnByValue: true,
awaitPromise: true,
});到这里,你已经可以对任意页面执行任意脚本了。但真正难的部分才刚开始。
我第一次用 Input.dispatchMouseEvent 派发真实鼠标事件时,点了个寂寞。原因是浏览器存在页面缩放比例,截图坐标系和 CSS 坐标系不是一回事。
解法是统一用 CSS 坐标(也就是 getBoundingClientRect() 返回的那套),而不是截图算出来的物理像素坐标。判断标准:拿到元素 rect 之后,先做一次 document.elementFromPoint(x, y) 自检——如果返回的栈顶元素不是你的目标,说明坐标体系错了,别急着点。
顺带一提,elementFromPoint 本身还有一个更实用的用途:先查栈顶,再决定怎么点。 它直接救了我后面那个更隐蔽的坑。
网页上的输入框大多是 React 受控组件。你直接 el.value = 'xxx',页面上看不出任何变化——因为框架压根不知道值变了。
正确姿势是走原型链上的原生 setter,再手动派发 input 事件:
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,别搞混。
在线写作平台的正文编辑器很多是 Monaco(VS Code 同内核)。这类编辑器压根不是 textarea,用 DOM 操作完全无效。
但它把模型对象暴露在了 window 上:
monaco.editor.getModels()[0].setValue(你的Markdown全文);一句话搞定,几千字的稿子瞬间灌进去。前提是得先切到 Markdown 编辑器模式,富文本模式下这个对象是拿不到的。
用 DOM.setFileInputFiles 可以直接给 input[type=file] 注入本地文件,不需要系统级的文件选择对话框。流程是:
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。
这是本次最难的一个。流程走到了最后一步:发布确认弹窗里,"确认发布"按钮清晰可见,坐标也完全正确,但点击后毫无反应,没有任何网络请求。
一度以为是脚本写错了。直到我用 elementsFromPoint 查了一下那个坐标上面的元素栈——栈顶根本不是按钮,而是一个 z-index: 9000 的透明遮罩层,尺寸恰好是整屏 1334×698,把点击事件全吃掉了。
解法是把遮罩设为"不可命中",同时把按钮顶上来:
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 参与的自动化里尤其关键。模型很容易把"没看到变化"解读为"操作失败",然后自作主张重试,把一次成功变成三次事故。
踩完这些坑之后,我整理了一个自己的执行清单,供参考:
elementFromPoint 返回的栈顶是不是目标元素?input 事件;Monaco 用 getModels()[0].setValue前三条是"能不能点到",第四条是"能不能填进去",最后一条是"有没有做错"。绝大多数自动化事故都发生在最后一条上。
最后说一句合规。这套能力本身是中性的,能用来大幅提升效率——批量整理素材、自动化发布内容、定时巡检页面。但有两件事我不会做:
工具的能力边界,就是使用者的边界。
回头看,CDP 这条路真正的门槛不在技术上——协议本身很直白,一个 WebSocket 就能聊起来。难点全在工程细节:坐标体系、受控组件、编辑器内核、z-index 层级,以及最关键的结果校验意识。
有意思的是,这套"较真"的流程恰好也是 AI 助手最需要被约束的地方。它可以在几分钟内写出一版像样的自动化脚本,但会因为一次视觉误判把事情搞砸三次。技术的部分可以交给它,30 秒一次的自检意识和"先查后做"的习惯,得写进流程里由人来把关。 人机协作的价值,大概就在这互相补位的地方。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。