AIGC标识 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 操作框架表单
   ↓
按页面状态而非返回值判定结果

四个坑,四条经验:

  1. headless 看不见,headed 活不久 — 长存活要靠脱离进程树,不是换参数
  2. 封装不一定省事 — 官方 --cdp 模式挂死,自写 60 行 CDP 反而更稳
  3. 框架表单要绕开 value 劫持 — 从原型链取原始 setter
  4. 判定结果要看页面状态 — 命令返回成功 ≠ 业务成功

浏览器自动化的难点从来不在"点按钮",而在环境、生命周期和状态判定这三件事上。


本文由一次真实的人机协作过程整理,文中账号信息已做脱敏处理。

posted @ 2026-09-10 14:55  戴维冀  阅读(24)  评论(0)    收藏  举报