一个 Chrome 扩展加一个 CLI,让 agent 操作你正在用的那个浏览器——登录态、扩展、Cookie 全都现成的。在 GitHub 上 Star

别的方案要么新开一个干净的 Chrome:什么都没登录,扩展没了,反爬一眼就认出来;要么把你的 Cookie 导出来存成文件再重放,只要网站多查一样东西就失效。RunBrowser 两样都不做。你的代码跑在页面里面,在网站自己的源上,浏览器自己把会话带上。全程不导出、不保存任何凭据——这个工具没有东西可泄露,因为它从来不持有。

快速开始

装 CLI。单个自包含二进制,不需要 Node、Bun 或 npm。

curl -fsSL https://runbrowser.com/install | sh

装扩展,然后在想操作的标签页上点一下扩展图标,图标变绿就接上了。

验证一下

runbrowser status runbrowser eval 'document.title'

工作原理

三个部分,浏览器永远不是你启动的那个。

扩展通过 chrome.debugger 接到你选的那个标签页。中继跑在你本机,用 Chrome DevTools Protocol 通过本地 WebSocket 和扩展通信。CLI 只跟中继说话。

没有任何东西离开你的机器,没有任何东西被存下来。标签页是你自己点的,而且 Chrome 那条"正在被调试"的提示条全程都在——RunBrowser 从不隐藏它。

命令

页面能做的一切都是一个 CDP 方法,所以 cdp 直接够到整个协议。这里没有 click、没有 snapshot、没有元素句柄系统——每包一层动词就多一处会出错的地方,而 Chrome 的协议本来就完整、有文档,还跟着 Chrome 一起版本化。

runbrowser cdp <Method> [params-json] # 整个协议 runbrowser eval '<js>' # 在页面里跑 JavaScript runbrowser exec # 带 helper 的代码片段,从 stdin 读 runbrowser tab list|new|<index>|close # 当前绑定的是哪个标签页 runbrowser session new|list|delete # 隔离状态,一个 agent 一个 runbrowser plugin list|install # 站点插件 runbrowser mcp # stdio 上的 MCP server runbrowser serve # 中继,用于远程访问

cdp 输出 JSON,直接管道接走:

runbrowser cdp Accessibility.getFullAXTree \ | jq '.nodes[] | select(.role.value=="button") | .name.value'

eval 的行为和 DevTools 控制台一致——值就是最后一个表达式,顶层 await 可用:

runbrowser eval 'document.title' runbrowser eval 'const r = await fetch("/api/me"); (await r.json()).name'

exec

cdpeval 一次一个调用。一旦需要循环、判断,或者需要根据实际发生的事情来等待,就用 exec——helper 已经在作用域里的代码片段。

runbrowser exec <<'JS' await cdp('Page.navigate', { url: 'https://example.com' }) await waitFor(async () => (await evaluate('document.readyState')) === 'complete') const links = await evaluate('[...document.querySelectorAll("a")].map(a => a.href)') return { title: await evaluate('document.title'), links } JS

作用域里有:cdpevaluatepageInfotabsnewTabswitchTabcloseTabdrainEventssetEventFilterwaitwaitFor

你从 ~/.runbrowser/workspace/helpers.ts 导出的东西也会进这个作用域,改了会自动重新加载——所以哪次调通了一段序列,就把它留下来,下次不用再推一遍。

事件

CDP 既有命令也有事件。命令返回结果,事件按会话缓冲,你想要的时候再取。这样才能等那些没有可轮询副作用的东西——弹窗、下载、新开窗口、target 挂载。

runbrowser exec <<'JS' await setEventFilter('^Page\\.') await drainEvents() await cdp('Page.navigate', { url: 'https://example.com' }) const { events, dropped } = await drainEvents() return events.map(e => e.method) JS

缓冲区有上限,并且会报告丢了多少条,所以页面再忙也不会把它撑爆——与其让 Network.* 淹掉,不如先设个过滤。

插件

RunBrowser 自带 50 个站点、144 个插件——reddit、twitter、知乎、微博、bilibili、V2EX、github、hackernews、领英、小红书等等。

runbrowser plugin list runbrowser plugin install v2ex runbrowser v2ex hot --count 5

一个插件就是一段 JSON 头加一个裸的 async 函数,在页面里求值——一次往返,拿得到那个站点的 Cookie、源和它自己的 JavaScript:

/* @meta { "name": "v2ex/hot", "domain": "www.v2ex.com", "args": { "count": { "type": "number", "description": "取几条" } } } */ async function(args) { const resp = await fetch('/api/topics/hot.json', { credentials: 'include' }) const topics = await resp.json() return topics.slice(0, args.count || 20).map((t, i) => ({ rank: i + 1, title: t.title, replies: t.replies, })) }

domain 是最关键的一个字段:它决定函数跑在哪个源上,也就决定了拿到谁的 Cookie。也可以装别人仓库里的:

runbrowser plugin install <site> --repo owner/name

怎么写一个插件

一个插件就是一个文件。丢到 ~/.runbrowser/plugins/<site>/<name>.js,它立刻就是 runbrowser <site> <name>——不用构建,不用注册。

第一步:找到这个站点自己发的请求。 打开页面,看 Network 面板,找它前端自己调的那个 JSON 接口。这个接口几乎总是比扒 DOM 更靠谱:class 名每次发版都可能变,站点自己依赖的接口不会。

第二步:写头和函数。

/* @meta { "name": "hackernews/top", "description": "Hacker News 首页", "domain": "news.ycombinator.com", "args": { "limit": { "type": "number", "description": "取几条" } }, "columns": ["rank", "title", "points"] } */ async function(args) { const limit = args.limit || 20 return [...document.querySelectorAll('tr.athing')].slice(0, limit).map((tr, i) => ({ rank: i + 1, title: tr.querySelector('.titleline a')?.textContent ?? '', points: tr.nextElementSibling?.querySelector('.score')?.textContent ?? '', })) }

函数体是在页面里跑的,所以 documentfetch 和站点自己的 JavaScript 都能直接用。它是一个裸的 async function,不是模块——不要 import,不要 export。

第三步:跑它。

runbrowser hackernews top --limit 5 runbrowser hackernews top --limit 5 --json # 原始返回值

实际会踩的坑

写了绝对 URL。 导航到站点之后 fetch('/api/items') 是同源的,会带上用户的 Cookie;fetch('https://别的域名/api') 不是,浏览器会直接拒绝——这是插件返回空最常见的原因。

忘了 credentials: 'include' 不加的话 fetch 不带 Cookie,你拿到的是未登录版本的页面。

返回的东西没法用。 返回一个扁平对象的数组,表格自己就渲染出来了。像 { count, items } 这种信封也行——里面的数组会被找出来——但扁平的行更好读。

靠猜而不是靠看。 先用 runbrowser exec 在页面上试,跑通了再挪进插件。整个循环就是这样:搞明白一次,写下来,以后不用再推一遍。

分享出去

把文件按 <site>/<name>.js 放进一个 GitHub 仓库,别人就能装:

runbrowser plugin install <site> --repo 你/你的仓库

下划线开头的文件(_helper.js)表示它本身不是一个命令,只是同一个站点的插件们共用的代码。

会话

一个会话是绑定到一个标签页的隔离状态。标签页是共享的,状态不是——所以多个 agent 可以在同一个浏览器里干活而不互相踩。

runbrowser session new # → 输出一个 id runbrowser -s 3 tab list # 在会话 3 里操作

不带 -s 时,CLI 会复用已有会话,没有就新建一个。

MCP

MCP server 是同一个二进制上的一个动词,不是另一个包:

{ "mcpServers": { "runbrowser": { "command": "runbrowser", "args": ["mcp"] } } }

如果你的 agent 读 skill 而不是工具 schema:

runbrowser skill install # → ./.claude/skills 和 ./.agents/skills

远程访问

浏览器在你身边,agent 可以在别处。把中继绑到 agent 能访问的网卡上,加个 token:

runbrowser serve --host 0.0.0.0 --token <secret>

然后让 CLI 指过去:

RUNBROWSER_HOST=my-machine RUNBROWSER_TOKEN=<secret> runbrowser status

服务器上的浏览器没有价值——没有 Cookie、没有 SSO、没有二次验证。有用的安排正好反过来:浏览器留在人这边,agent 从远处连回来。

对比

对比 Playwright / Puppeteer
Playwright / PuppeteerRunBrowser
浏览器新开的 headless你正在用的那个
登录态没有,每次重新登现成的
扩展没有你平时用的那些
反爬一眼认出它本来就是真浏览器
请求签名自己重新实现一遍页面自己做
需要浏览器开着吗不需要需要
对比导 Cookie 的工具
导 CookieRunBrowser
会话存在哪导到磁盘上,加密保存一直在 Chrome 里
存了凭据吗存了,你自己保管和刷新没有,什么都不导出
会过期吗会,得自己写刷新逻辑页面自己保持新鲜
指纹、TLS 校验过不了它就是 Chrome
页面自己做的 token 刷新正常发生
能在服务器上无头跑吗不能

如果站点有正经 API,而且你想跑长时间的无头任务,那就用凭据注入类的工具——那是另一个问题,别人解得挺好。RunBrowser 面向的是那些没有 API、签名逻辑全在前端的站点。

安全

全程不导出、不保存任何凭据。 中继根本没有读 Cookie 的能力——没有 getCookies,没有 cookie jar,磁盘上什么都没有。插件代码跑在页面里,浏览器用它自己的 TLS 栈带上你的会话。

中继默认只监听 localhost,扩展端点只接受本机、已知扩展源的连接。远程访问是显式开启的,必须带 token,并且直接拒绝跨源的浏览器请求。

Chrome 自己的提示条一直在。 "正在被调试"那条通知从不被隐藏——有东西在操作你的浏览器,你看得见。

只有一个标签页,还是你自己点的。 扩展接的是你点了图标的那个标签页,不是你整个浏览器配置。

RunBrowser - 让 Agent 控制你的真实浏览器的 Chrome 扩展和 CLI