给 AI 搭一个带登录态的浏览器节点
我想要的东西
AI 工具越来越多,有一类需求始终是痛点,需要在已登录的真实浏览器里操作。
API 解决不了。很多平台根本没有公开 API,有的开了 API 也要付费、限速,真正想干的那几件事恰好不在支持范围里。无头浏览器倒是能自动化,但行为特征和真实用户差距太大,稍微严格一点的反爬系统就能认出来。
我想要的是一个长期在线的真实 Chrome,带着真实的登录 session,对外提供标准接口,让各种 AI 工具都能连进来用。下文管它叫 browser-node。
AI Agent / 自动化脚本(任意位置)
↓ CDP / HTTP API
browser-node(局域网内一台常驻机器)
↓
真实 Chrome(带完整 profile、已登录各平台)
对外暴露两个接口,cdp.<internal-domain> 是 Chrome CDP 入口,供 Playwright、agent-browser、自定义脚本直连;autocli.<internal-domain> 是 autocli 的 HTTP API,供结构化命令调用。
听起来不难。从这儿到能用,我撞了三堵墙。
第一堵墙,Chrome 不肯让外面连
服务搭好之后,从别的机器连过去一直不通,走 Traefik 的那个域名返回 502。
根因是 Chrome 自己。启动时加 --remote-debugging-port=9222,它实际只绑定 127.0.0.1:9222。现代 Chrome 出于安全加固会无视 --remote-debugging-address=0.0.0.0,这是 Chrome 自身的行为,启动参数改不掉。局域网里的其他机器连不上,跑在同一台机器上的容器也连不上,因为容器网络里打回宿主机的回环地址同样打不通。
所以中间必须垫一层端口转发。我的做法是一条常驻的 SSH 反向隧道,把 Chrome 的 127.0.0.1:9222 引到容器网络的网关地址上,再由 Traefik 对局域网暴露成一个域名。
这里有个坑值得单独记一笔。隧道千万别绑 0.0.0.0。 Lima 和 Colima 这类虚拟机会把 guest 里监听 0.0.0.0 的端口自动转发回宿主机的局域网,等于把一个 CDP 端口直接挂到内网上。而 CDP 没有任何鉴权,谁连上谁就能操纵这个带着你全部登录态的浏览器,包括读 cookie、发请求、截屏。隧道只绑到容器网络的网关地址,让 Traefik 够得着就行。
第二堵墙,工具都连不进来
通路修好之后,我没急着往上堆功能,先把手上这几个工具的源码读了一遍。这一读才发现,没有一个是为「浏览器在另一台机器上」这件事设计的。
在”让 AI 控制真实浏览器”这个方向上,当时值得关注的开源项目有三个,agent-browser、autocli、OpenCLI。它们不在同一层,我需要的东西也是两件,一件是通用的浏览器操作能力,一件是每个站点特定的抓取逻辑。
下面对 autocli 和 OpenCLI 的分析基于 2026 年 6 月的源码,两个项目都在活跃迭代。
agent-browser 能连,但只管操作
agent-browser 是 Vercel Labs 出的浏览器控制 CLI,给 AI Agent 提供一个操作浏览器的标准接口。
agent-browser open https://example.com # 导航
agent-browser snapshot # 获取页面可交互元素快照
agent-browser click @e3 # 按快照引用点击
agent-browser type "hello" # 输入文字
agent-browser eval "document.title" # 执行 JS,返回结果
agent-browser connect "http://cdp.host" # 连接远程 CDP 端点
agent-browser batch --json # 批量执行(JSON stdin)
snapshot 返回页面可交互元素的列表,每个元素带一个引用 ID 比如 @e3,click @e3 按这个引用操作,不用写 CSS selector,对 AI 很友好。batch 从 stdin 读一个 JSON 数组顺序执行,适合串联多步操作。
它的 CLI 和 daemon 是同一个二进制,daemon 以环境变量 AGENT_BROWSER_DAEMON=1 启动。
┌─────────────────────────────────────────┐
│ AI Agent / 自动化脚本 │
└──────────────────┬──────────────────────┘
│ CLI 命令(短连接,一问一答)
▼
┌─────────────────────────────────────────┐
│ agent-browser CLI │
│ 解析命令 → 发 JSON → 等结果 │
└──────────────────┬──────────────────────┘
│ Unix socket
│ ~/.agent-browser/default.sock
▼
┌─────────────────────────────────────────┐
│ agent-browser Daemon │
│ (持久进程,常驻内存) │
└──────────────────┬──────────────────────┘
│ CDP WebSocket(长连接)
▼
┌─────────────────────────────────────────┐
│ Chrome / 远程 CDP 端点 │
└─────────────────────────────────────────┘
长连接握在 daemon 手里,CLI 每次通过 Unix socket 发一个 JSON 请求,拿到结果就断开,不重建 WebSocket。这个设计让每条命令都很快,代价写在图上,从 CLI 到 daemon 那一跳是单向的请求响应,没有事件订阅。想监听页面加载时自动发出的网络请求,必须绕开它直连底下那条 CDP WebSocket。这个限制后面还会回来找我。
好消息是 agent-browser connect 原生支持远程 CDP 端点,三个工具里只有它一上来就能连到 browser-node。
autocli 门槛最低,架构却把远程堵死了
autocli 的定位是把网站变成 CLI 工具,内置 55 个以上平台的 adapter,Twitter、Bilibili、GitHub、小红书、知乎都有。
autocli twitter search "rust lang" --limit 10
autocli bilibili hot --limit 20
autocli hackernews top --format json
输出支持 table、json、yaml、csv、md 五种格式,给人看和接脚本都合适。
它的 adapter 用 YAML 定义,这是我最喜欢的部分。
pipeline:
- navigate: "https://twitter.com/search?q=${{ args.query }}"
- scroll: 3
- evaluate: |
Array.from(document.querySelectorAll('article')).map(el => ({
text: el.querySelector('[data-testid="tweetText"]')?.innerText,
}))
- filter: "${{ item.text != null }}"
- limit: "${{ args.limit }}"
${{ args.query }}、${{ item.text }} 这类模板在执行时求值,能访问 CLI 参数、当前数据项和循环索引。执行引擎的核心逻辑很短。
let mut data = Value::Null;
for step in pipeline {
data = handler.execute(page, params, &data, args).await?;
}
Ok(data)
每个 step 拿上一个 step 的输出当 data,返回新的 data,串成管道。最后一个 step 的结果按 columns 裁剪、按 --format 格式化后打印。step 分两类,navigate、scroll、click、type、wait、evaluate、intercept、collect 需要 page 对象,map、filter、limit、select、sort 是纯内存操作。
它的 intercept 不走 CDP 的 Network 域,靠往页面里注入 JS、把 window.fetch 换成自己的版本实现,命中 URL 规则的响应克隆一份存进 window.__autocli_intercepted。
// intercept step 注入的代码(简化)
const origFetch = window.fetch;
window.fetch = async function(...args) {
const resp = await origFetch.apply(this, args);
if (new RegExp(pattern).test(args[0].url ?? args[0])) {
window.__autocli_intercepted.push(await resp.clone().json());
}
return resp;
};
// XMLHttpRequest 同样 patch 一遍
后面的 collect step 再注入一段 JS 把它读出来,用自定义的 parse 函数处理。这个做法很直接,代价是改了页面的 JS 环境,后面讲反爬时会回到这一点。
卡住我的是它的调用链路。
┌─────────────────────────────────────────────────┐
│ AI Agent / 自动化脚本 │
└────────────────────────┬────────────────────────┘
│ autocli twitter search "rust"
▼
┌─────────────────────────────────────────────────┐
│ autocli CLI │
│ 加载 YAML adapter → 顺序执行 pipeline │
└────────────────────────┬────────────────────────┘
│ Unix socket / TCP
▼
┌─────────────────────────────────────────────────┐
│ autocli Daemon │
└────────────────────────┬────────────────────────┘
│ chrome.debugger API(本质是 CDP)
▼
┌─────────────────────────────────────────────────┐
│ Chrome Extension │
│ (持有调试器连接,代理所有命令) │
└────────────────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Chrome │
└─────────────────────────────────────────────────┘
╌╌╌ CDP 直连路径(cdp.rs 已实现,未接线)╌╌╌▶ Chrome
四层。命令要经过 daemon 交给 Chrome 扩展,扩展再用 chrome.debugger API 去控制浏览器,而这是扩展专有能力,不支持远程 CDP 端点。整个架构默认扩展和 daemon 在同一台机器上,我的场景刚好不是。
图底下那条虚线是最让人难受的部分。cdp.rs 里已经有一份完整的 CdpPage 直连实现,但 execution.rs 从来没调用过它。设计文档里这是 Task 21 Step 3,状态框还空着,只差大约 40 行 Rust 接线。路修好了,就是没通车。
OpenCLI 数据质量最高,也一样要装扩展
OpenCLI 的定位和 autocli 类似,实现哲学完全相反。adapter 是 TypeScript 或 JS 文件,用代码直接描述完整的抓取逻辑。
// clis/twitter/search.js(简化)
export default async function({ page, args }) {
const results = await page.evaluate(async (query) => {
const token = document.cookie.match(/ct0=([^;]+)/)[1];
const resp = await fetch(`/i/api/graphql/.../SearchTimeline?variables=...`, {
headers: { 'x-csrf-token': token, 'x-twitter-auth-type': 'OAuth2Session' }
});
return resp.json();
}, args.query);
// 之后是解析 GraphQL 嵌套结构、处理分页 cursor、去重
}
它不抓 DOM,在已登录的浏览器上下文里直接调 Twitter 的内部 GraphQL API。page.evaluate() 把这段 async 函数丢进浏览器里跑,fetch 自动带上当前页面的 cookie 和 session,等于用真实用户身份调接口。这比 DOM 抓取稳定得多,UI 随时可能改版,GraphQL schema 变动要慎重得多。
它的架构也是三个里最短的。
┌──────────────────────────────────────────────────┐
│ AI Agent / 自动化脚本 │
└─────────────────────────┬────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ OpenCLI Runtime(TypeScript) │
│ 加载 JS adapter → 调用 page.evaluate() │
└─────────────────────────┬────────────────────────┘
│ CDP WebSocket
│ OPENCLI_CDP_ENDPOINT=http://cdp.<domain>
▼
┌──────────────────────────────────────────────────┐
│ Chrome(browser-node) │
│ ┌────────────────────────────────────────────┐ │
│ │ page.evaluate(async () => { │ │
│ │ fetch('/i/api/graphql/...', │ │
│ │ { headers: { 'x-csrf-token': ... } } │ │
│ │ ) │ │
│ │ }) │ │
│ └───────────────────────┬────────────────────┘ │
└──────────────────────────┼───────────────────────┘
│ 带 Cookie / Session 的 HTTP 请求
▼
┌──────────────────────────────────────────────────┐
│ 目标网站内部 API(GraphQL 等) │
└──────────────────────────────────────────────────┘
没有 daemon,没有扩展,Runtime 直接通过 CDP WebSocket 连 Chrome,还认 OPENCLI_CDP_ENDPOINT 这个环境变量。这张图看着就是我要的东西。
CDPBridge(src/browser/cdp.ts)实现了完整的 CDP 直连逻辑,会读 OPENCLI_CDP_ENDPOINT 连接远程 Chrome。实测 v1.8.4 才发现,runtime.js 的 getBrowserFactory() 只对注册为 Electron app 的站点返回 CDPBridge,Twitter、Bilibili 这些 Web 站点拿到的始终是 BrowserBridge,而 BrowserBridge.connect() 压根不读 cdpEndpoint,只走 Chrome 扩展桥接。
结论和 autocli 一样,没有扩展就连不上远程节点。
三个工具的定位
agent-browser 是通用浏览器控制层,导航、点击、输入、读快照,站点逻辑有意留给调用方。autocli 和 OpenCLI 在上层做同一件事,把特定网站变成可命令行调用的结构化数据源,实现哲学两个极端。
| autocli | OpenCLI | |
|---|---|---|
| 工作流定义 | YAML 声明式 pipeline | JS 命令式 adapter |
| 主要获取方式 | DOM 抓取,intercept 为辅 | 主动调内部 API |
| 扩展门槛 | 低(写 YAML) | 高(写 JS/TS) |
| 数据完整性 | 受 DOM 结构限制 | 接口级别,更完整 |
| 内置 adapter | 55+,覆盖广 | 较少,但质量高 |
| CDP 直连 | 已实现,未接线 | 仅限 Electron app,Web 命令不支持 |
| 结构化输出 | table/json/yaml/csv/md | 原始数据,需自处理 |
在远程浏览器节点这个场景下,能直接用的只有 agent-browser。另外两个都卡在扩展上,要用就得改源码。
从浏览器里取数据,其实只有三条路
读完这三份源码,最大的收获是把「从浏览器取数据」这件事想清楚了,选型结论反倒在其次。
浏览器自动化分两层,控制浏览器和从浏览器取数据。取数据这层只有两条大路,其中监听接口还能再分。
浏览器数据采集
├── 操作浏览器
│ └── 导航、点击、滚动、输入...
└── 从页面获取数据
├── 监听接口
│ ├── 页面自动请求 — 拦截 fetch/XHR(JS 注入)或订阅 CDP Network 事件流
│ └── 主动构造请求 — 在已登录页面上下文直接 fetch 内部 API,自行携带 auth
└── 读取 DOM — 执行 JS 读取渲染后的 HTML
三条路的取舍。
| 页面自动请求拦截 | 主动构造请求 | DOM 读取 | |
|---|---|---|---|
| 数据完整性 | 接口级别 | 接口级别 | 受 DOM 结构影响 |
| 依赖前提 | 接口 URL,等页面自动触发 | 接口参数 + auth 方式 | 页面结构和 selector |
| 稳定性 | 接口稳定,但触发时机依赖页面 | 最稳定 | UI 改版容易挂 |
| 隐蔽性 | JS 注入可被检测;CDP 事件完全透明 | 完全透明 | 完全透明 |
对照回去,autocli 走的是 DOM 读取为主、intercept 可选,OpenCLI 走的是主动构造请求。两个工具的数据质量差距,根子在这张表上,不在代码写得好不好。
表里「主动构造请求最稳定」这一格要补一个边界,它只在读操作上成立。给 X 发推时实测过,即使 header 带齐 Bearer、ct0 CSRF、X-Twitter-Auth-Type、X-Twitter-Active-User,也就是读请求那一整套,CreateTweet 仍然返回 HTTP 200 加 errors[0].code=226,提示这个请求看起来像自动化,推文根本没发出去。根因是写操作强制校验 x-client-transaction-id,X 前端 JS 按方法、路径和首页下发数据现算的一个反自动化签名,每个请求都不一样,读操作不校验它。碰到这种情况只能退回 UI 自动化,让前端自己生成签名。
顺带一条必记的,HTTP 200 不等于成功,必须解析响应体看有没有 errors[]。只看状态码会把被拦截当成功。
第三堵墙,监听不到页面自己发的请求
三条路里,DOM 读取和主动构造请求都能用 agent-browser eval 或者 Runtime.evaluate 完成。剩下那条不行。
agent-browser 是单向请求响应模型,监听不了页面加载时自动发出的请求。 这是它架构决定的,不是配置问题。要拿这类数据只能直连 CDP 的 WebSocket 端点,用 Network 域。
顺序是这样的。
- 访问 CDP HTTP 端点的
/json接口,取到当前页面的webSocketDebuggerUrl,建立 WebSocket 连接 - 发送
Network.enable,Chrome 开始向客户端推送所有网络事件。必须在导航之前调用,否则页面加载期间的请求就已经错过 - 发送
Page.navigate触发导航 - 持续接收事件流,按序处理
Network.requestWillBeSent,请求即将发出,可拿到 URL、headers、POST bodyNetwork.responseReceived,响应头到达,requestId在这里与 URL 绑定,但 body 还未就绪Network.loadingFinished,body 下载完成,此时调Network.getResponseBody(requestId)取完整响应体
有个概念得掰清楚。Network.enable 的语义是订阅事件推送,不是开启历史记录。CDP 没有查询历史请求的接口,错过了就是错过了。
这条路顺带解决了反爬
JS 注入那条路会改页面的 JS 环境。站点可以检测 fetch.toString() 里还有没有 native code,或者对比函数引用是否被替换,发现了就触发拦截。CDP 的 Network.enable 运行在浏览器进程内部,完全不接触 JS 运行时,页面代码感知不到任何异常,从根本上跳出了这类检测的覆盖范围。
而现代反爬体系(Cloudflare、Akamai、PerimeterX)的核心判断维度是行为特征,工具检测只是其中一小块。它们看的是鼠标轨迹自不自然、点击有没有抖动、操作节奏符不符合人类分布。CDP Input 域注入的鼠标和键盘事件 isTrusted = true,走浏览器底层输入管道,和真实硬件事件完全一致,JS 层区分不了。
配上真实 Chrome 的完整 profile,Cookie 历史、Canvas 指纹、插件列表都是真实用户数据,CDP 直连是目前检测面最小的组合。这也是整篇文章里最值钱的一条。
我理解中的架构
三堵墙撞完,答案已经浮出来了。三个现成工具没有一个能直接用,能用的那部分能力又分散在不同层,所以最后落到自己写一套轻量的 flow 执行引擎,跑在 agent-browser 上面,借鉴 autocli 那套 YAML pipeline 的思路,但不依赖 autocli 本身。
flow 定义(YAML)
│
▼
┌───────────────────┐
│ flow 执行引擎 │ 解析模板、按 step 顺序推进、串接数据
└─────┬───────────┬─┘
│ │ 需要监听网络时绕开上层
操作与取数 │ │
▼ ▼
┌────────────────┐ ┌────────────────────┐
│ agent-browser │ │ CDP WebSocket 直连 │
│ open/eval/click│ │ Network 域事件流 │
└────────┬───────┘ └─────────┬──────────┘
└──────────┬─────────┘
▼
browser-node 的真实 Chrome
分工是这样的。
- flow 用 YAML 定义,
navigate到intercept或fetch到collect到transform,保留${{ args.query }}这类模板语法。站点逻辑写在这一层,改抓取规则不用动代码 - 每个 step 调 agent-browser 的对应子命令,导航、点击、执行 JS 这些通用操作全部交给它,不自己碰 CDP 协议细节
- 需要监听页面自动发出的请求时绕开 agent-browser,直接用 WebSocket 连 CDP 的 Network 域。这是第三堵墙留下的那个缺口,只能在这一层补
三条数据路线在这套结构里各有落点,DOM 读取和主动构造请求走 agent-browser 的 eval,页面自动请求走 CDP 直连。
好处是完全自主,Python 几百行能跑通,想加什么加什么。代价也清楚,没有现成 adapter,每个站点的 flow 得自己写,YAML 解析和模板引擎要从头做。这笔账划不划算,取决于你要抓的站点有多少。站点少的时候直接写脚本更省事,等 adapter 堆到十几个,声明式那一层的价值才显出来。
如果你也要搭一个
- 端口转发是第一件事。Chrome 只绑回环且改不掉,中间必须垫一层。隧道绑到容器网络网关,别绑
0.0.0.0 - 通用操作层用 agent-browser。autocli 和 OpenCLI 都依赖 Chrome 扩展桥接,远程节点场景下用不了
- 需要监听页面自动发出的请求时,直接连 CDP 的 Network 域,记得在导航之前
Network.enable - 别急着造 flow 引擎。站点数量少的时候直接写脚本更省事
- 写操作别指望手搓内部 API,反自动化签名会把你拦在门外,老实走 UI 自动化
这一整套里最值钱的是 CDP 直连加真实 profile 这个组合,不修改任何 JS 环境,行为指纹与真实用户一致。