WorkBuddy 浏览器自动化基础教学:从「看不到浏览器」到全自动登录
本文由一次真实的 AI 协作过程整理而成:我让 AI 助手打开浏览器访问博客园,然后自动完成登录。整个过程踩了几个坑,也沉淀出一套可复用的方案。全文按实际操作顺序记录。
一、开场:一个看起来很简单的需求
我给 AI 下了一句很普通的话:
打开浏览器,进入博客园,让我可以看到浏览器。
注意最后半句——"让我可以看到浏览器"。这半句决定了后面的全部技术路线。
二、第一层:装工具
浏览器自动化用的是 agent-browser(vercel-labs 出品,一个面向 AI Agent 的浏览器 CLI)。
npm install -g agent-browser
agent-browser install
第二条命令会下载 Chrome 内核(约 196 MB),装到:
C:\Users\<用户>\.agent-browser\browsers\chrome-153.0.8010.36\chrome.exe
这一步没什么坑,但有个前置检查值得记住:必须确认你的 Node 能真正执行代码,而不只是能打印版本号。
node -e "console.log('ok')"
如果这行没输出 ok,后面所有操作都会以各种莫名其妙的形态失败。
三、第二层:第一个坑——浏览器窗口活不过三秒
agent-browser 默认是 headless(无头)模式。要看到窗口,加 --headed:
agent-browser --headed open "https://www.cnblogs.com"
命令返回成功了,日志也显示页面标题是「博客园 - 开发者的网上家园」,URL 正确。
但窗口只出现了一瞬间就没了。
原因在于 agent-browser 的会话模型:它有一个常驻后台 daemon,浏览器是挂在 daemon 下面的。在受限的执行环境(沙箱、Agent 调用链)里,父命令一结束,整个进程组会被回收,daemon 和浏览器一起被杀掉。
验证:
agent-browser session list
# → No active sessions
页面确实加载过,但进程没了,所以你什么也看不到。换成 shell 后台启动(nohup ... &)结论一样——照样被杀。
四、第三层:让浏览器脱离进程树
要窗口长期存活,必须让 Chrome 不属于当前进程树。
在 Windows 上,用 WMI 创建进程可以让新进程的父进程变成 WmiPrvSE,从而完全脱离调用方的进程树:
$exe = 'C:\Users\<用户>\.agent-browser\browsers\chrome-153.0.8010.36\chrome.exe'
$cmdline = '"' + $exe + '" --remote-debugging-port=9222 ' +
'--user-data-dir="C:\Users\<用户>\.agent-browser\profile-cnblogs" ' +
'--no-first-run --no-default-browser-check "https://www.cnblogs.com"'
([wmiclass]'Win32_Process').Create($cmdline)
三个关键参数:
| 参数 | 作用 |
|---|---|
--remote-debugging-port=9222 |
开 CDP 调试端口,后续所有自动化都走它 |
--user-data-dir=... |
独立用户目录,登录态持久化,不会污染你日常用的 Chrome |
--no-first-run --no-default-browser-check |
跳过首次启动向导,省事 |
验证窗口是否真的活着:
tasklist /v /fi "IMAGENAME eq chrome.exe"
输出里能看到:
chrome.exe 6280 Console 1 ... 博客园 - 开发者的网上家园 - Google Chrome for Testing
会话名是 Console、会话号是 1,说明窗口运行在用户的交互桌面上——肉眼可见,可以手动操作。
五、第四层:第三个坑——--cdp 接管模式会挂死
agent-browser 提供了 --cdp <port> 来接管已运行的浏览器:
agent-browser --cdp 9222 get title # ✅ 正常返回页面标题
agent-browser --cdp 9222 open <url> # ❌ 挂住不动,直到超时
agent-browser --cdp 9222 screenshot # ❌ 同样挂住
短查询命令能用,会改变页面状态或需要等事件的命令会挂死。这个模式在实际使用中不够可靠。
六、第五层:自己写一个最小 CDP 驱动
绕开封装,直接用 Chrome DevTools Protocol。Node 22 内置了全局 WebSocket,所以不需要任何第三方依赖:
const fs = require('fs');
const wsUrl = process.argv[2];
const cmds = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
const ws = new WebSocket(wsUrl);
let seq = 0;
const pending = new Map();
function send(method, params) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params: params || {} }));
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const p = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result);
}
};
ws.onopen = async () => {
const out = [];
for (const c of cmds) {
if (typeof c.sleep === 'number') { await sleep(c.sleep); continue; }
if (c.evalFile) {
out.push(await send('Runtime.evaluate', {
expression: fs.readFileSync(c.evalFile, 'utf8'),
awaitPromise: true, returnByValue: true
}));
continue;
}
if (c.screenshotFile) {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(c.screenshotFile, Buffer.from(r.data, 'base64'));
continue;
}
out.push(await send(c.method, c.params));
}
console.log(JSON.stringify(out, null, 2));
process.exit(0);
};
它读一个 JSON 指令文件,支持四类动作:
{"method": "...", "params": {...}}— 任意 CDP 方法透传{"evalFile": "expr.js"}— 在页面里执行 JS{"sleep": 毫秒}— 等待{"screenshotFile": "out.png"}— 截图存盘
拿到目标页面的调试地址:
curl -s http://127.0.0.1:9222/json/list
# 取 type=page 的那一项的 webSocketDebuggerUrl
顺带一个 Git Bash 的坑:在 Windows 的 Git Bash 里调 tasklist 这类命令,参数会被 MSYS 做路径转换。加 MSYS_NO_PATHCONV=1 前缀,或者干脆别用 cmd //c。
七、实战:自动登录博客园
登录页 https://account.cnblogs.com/signin 是 Angular Material 表单。先探查结构:
// 返回所有 input / button 的信息
Array.from(document.querySelectorAll('input')).map(e =>
({ type: e.type, id: e.id, ph: e.placeholder }))
拿到关键选择器:
| 元素 | 选择器 |
|---|---|
| 用户名/邮箱 | #mat-input-0 |
| 密码 | #mat-input-1 |
| 登录按钮 | .action-button |
关键技巧:Angular 表单必须用 native setter
直接 input.value = 'xxx' 是无效的,Angular 的响应式表单感知不到。正确写法:
const setVal = (el, v) => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
setter.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('blur', { bubbles: true }));
};
setVal(document.querySelector('#mat-input-0'), '用户名或邮箱');
setVal(document.querySelector('#mat-input-1'), '密码');
document.querySelector('.action-button').click();
原理:React / Angular / Vue 这类框架会劫持 input 元素的原生 value setter 做状态追踪。必须从原型链上取原始 setter,绕开劫持,再手动派发事件,框架才会更新内部状态。
结果验证
登录成功的判断依据(不要只看有没有报错):
- 页面跳回
https://www.cnblogs.com/ - 导航栏出现「我的博客 / 账号设置 / 退出登录」
- 个人主页
https://home.cnblogs.com/显示「欢迎你,XXX」
八、安全提醒
这次协作里有一个反面教材:账号密码是以明文形式贴在对话里的。
正确做法:
- 凭据不要写进会被保存的脚本文件(用完立刻删)
- 更不要用明文形式发在聊天记录里
- 优先用
--user-data-dir持久化登录态,一次人工登录,后续复用 - 或者用环境变量 / 专门的凭据管理器传递
九、总结
整套方案的骨架其实很清晰:
装 Chrome 内核
↓
用 WMI 脱离进程树启动带 --remote-debugging-port 的 Chrome(窗口可见、长存活)
↓
自写最小 CDP 驱动接管(evalFile / navigate / screenshot)
↓
在页面上下文用 native setter 操作框架表单
↓
按页面状态而非返回值判定结果
四个坑,四条经验:
- headless 看不见,headed 活不久 — 长存活要靠脱离进程树,不是换参数
- 封装不一定省事 — 官方
--cdp模式挂死,自写 60 行 CDP 反而更稳 - 框架表单要绕开 value 劫持 — 从原型链取原始 setter
- 判定结果要看页面状态 — 命令返回成功 ≠ 业务成功
浏览器自动化的难点从来不在"点按钮",而在环境、生命周期和状态判定这三件事上。
本文由一次真实的人机协作过程整理,文中账号信息已做脱敏处理。
本文来自博客园,作者:戴维冀,转载请注明原文链接:https://www.cnblogs.com/jichengwei/p/22921718
浙公网安备 33010602011771号