· 更新于

给 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 比如 @e3click @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 分两类,navigatescrollclicktypewaitevaluateinterceptcollect 需要 page 对象,mapfilterlimitselectsort 是纯内存操作。

它的 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 APIpage.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 这个环境变量。这张图看着就是我要的东西。

CDPBridgesrc/browser/cdp.ts)实现了完整的 CDP 直连逻辑,会读 OPENCLI_CDP_ENDPOINT 连接远程 Chrome。实测 v1.8.4 才发现,runtime.jsgetBrowserFactory() 只对注册为 Electron app 的站点返回 CDPBridge,Twitter、Bilibili 这些 Web 站点拿到的始终是 BrowserBridge,而 BrowserBridge.connect() 压根不读 cdpEndpoint,只走 Chrome 扩展桥接。

结论和 autocli 一样,没有扩展就连不上远程节点。

三个工具的定位

agent-browser 是通用浏览器控制层,导航、点击、输入、读快照,站点逻辑有意留给调用方。autocli 和 OpenCLI 在上层做同一件事,把特定网站变成可命令行调用的结构化数据源,实现哲学两个极端。

autocliOpenCLI
工作流定义YAML 声明式 pipelineJS 命令式 adapter
主要获取方式DOM 抓取,intercept 为辅主动调内部 API
扩展门槛低(写 YAML)高(写 JS/TS)
数据完整性受 DOM 结构限制接口级别,更完整
内置 adapter55+,覆盖广较少,但质量高
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-TypeX-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 域。

顺序是这样的。

  1. 访问 CDP HTTP 端点的 /json 接口,取到当前页面的 webSocketDebuggerUrl,建立 WebSocket 连接
  2. 发送 Network.enable,Chrome 开始向客户端推送所有网络事件。必须在导航之前调用,否则页面加载期间的请求就已经错过
  3. 发送 Page.navigate 触发导航
  4. 持续接收事件流,按序处理
    • Network.requestWillBeSent,请求即将发出,可拿到 URL、headers、POST body
    • Network.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 定义navigateinterceptfetchcollecttransform,保留 ${{ 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 环境,行为指纹与真实用户一致。