1. 从「重复登录」这个痛点说起
如果你最近半年深度用过 Claude Code、Codex 这类命令行 AI Agent,大概率遇到过同一个尴尬场景:Agent 帮你写好了自动化脚本,脚本要访问某个内部管理后台抓数据,结果一跑就卡在登录页——因为 Agent 运行在终端里,它没有你浏览器里那份已经登录好的会话状态。你只能退而求其次,要么手动导出 Cookie 塞进脚本,要么让 Agent 走一套独立的账号密码登录流程,要么干脆放弃自动化,自己动手点。
这个项目标题说的「给 AI Agent 做个油猴」,本质上就是在解决这件事。油猴(Tampermonkey)是浏览器里最经典的脚本注入工具,它能在页面上下文里执行自定义 JS,天然拥有当前页面的登录态、Cookie、LocalStorage、甚至已经加载好的前端 SDK。作者把这个思路平移到了 AI Agent 身上:既然 Agent 缺的是「已登录的浏览器环境」,那就给它造一个桥,让 Agent 能直接调用你浏览器里那个已经登录好的会话,而不是从零开始模拟登录。
一句话概括这个项目能做什么:让终端里的 AI Agent 复用你浏览器中已登录的会话,直接对目标站点发起请求或操作页面,省掉重复认证和会话搬运。它适合三类人参考:一是天天用 Claude Code / Codex 做自动化的开发者;二是被内部系统登录态折磨的运维和测试同学;三是对浏览器扩展与本地服务通信机制感兴趣、想自己造轮子的技术爱好者。哪怕你只是想搞明白「浏览器登录态到底能不能被外部程序安全复用」,这篇也值得往下看。
需要先说明一点:下面涉及的具体实现细节,是基于这类项目最常见的工程实践做的合理补全,因为原始标题只给了方向,没有给代码。我会把「为什么这么设计」讲透,你照着思路可以自己落地一套。
2. 整体设计思路:为什么是「油猴 + 本地桥」而不是别的方案
2.1 先想清楚:Agent 到底缺什么
很多人第一反应是「让 Agent 直接带 Cookie 请求不就行了」。这个思路能跑通一部分场景,但很快会撞墙。原因在于现代站点的登录态早就不只是一段 Cookie 了:
- HttpOnly Cookie:JS 读不到,你没法从浏览器里导出,只能靠浏览器自己带着发请求。
- 前端加密签名:不少后台的请求头里带
X-Sign、X-Timestamp,签名逻辑写在前端 JS 里,你外部脚本复刻成本极高。 - 动态 Token 刷新:Access Token 短时效,Refresh Token 又绑定了设备指纹,外部环境一换就失效。
- CSRF Token:藏在页面 DOM 或 meta 标签里,每次会话还可能变。
所以「搬运 Cookie」这条路,在简单站点上能用,在复杂站点上基本是死路。真正稳的做法,是让请求从浏览器内部发出去——浏览器自己会处理 Cookie、签名、Token 刷新,你只需要把「要请求什么」告诉它,把「返回什么」拿回来。
2.2 三种可选架构的取舍
我把这类需求常见的三种实现方式列出来对比一下,你就明白为什么「油猴 + 本地桥」是性价比最高的:
| 方案 | 原理 | 优点 | 致命缺点 |
|---|---|---|---|
| 直接搬运 Cookie | 从浏览器导出 Cookie 给 Agent | 实现简单 | HttpOnly 读不到、签名无法复刻、Token 易失效 |
| 无头浏览器(Headless) | Agent 启动一个独立浏览器实例 | 环境干净可控 | 需要重新登录、有风控识别风险、资源占用高 |
| 油猴脚本 + 本地桥 | 在已登录页面注入脚本,通过本地服务与 Agent 通信 | 复用真实登录态、签名天然可用、无需重新认证 | 需要浏览器保持打开、需要设计通信协议 |
第三种方案的核心洞察是:你浏览器里那个已经登录的标签页,本身就是一个「带完整认证能力的 HTTP 客户端」。它什么都有——Cookie、Token、签名函数、CSRF,全都是现成的、活的。你要做的不是复制它,而是「借用」它。
2.3 通信链路怎么搭
整条链路是这样走的:
AI Agent (终端) ↓ 写入请求文件 / 调用本地 HTTP 接口 本地桥接服务 (localhost) ↓ WebSocket / 长轮询 浏览器扩展 (油猴脚本) ↓ 在页面上下文执行 fetch 目标站点 (已登录) ↓ 返回数据 原路返回给 Agent这里有几个关键设计决策,我逐个解释为什么这么选。
为什么用本地服务做中转,而不是让 Agent 直接连浏览器?因为浏览器扩展和油猴脚本没法直接监听一个 TCP 端口等外部连接(安全模型不允许),必须有一个中间人。本地服务跑在127.0.0.1上,只监听本机,天然隔离外网。
为什么用 WebSocket 而不是 HTTP 轮询?轮询有延迟,Agent 发一个请求要等一个轮询周期才有响应,体验很差。WebSocket 是全双工,请求一发浏览器立刻收到,响应一回 Agent 立刻拿到,延迟基本在毫秒级。如果环境限制不能用 WebSocket,退而求其次用 Server-Sent Events 加一个 POST 回传通道也能凑合,但复杂度上去了。
为什么注入点选油猴而不是自己写扩展?油猴脚本开发调试极快,改完刷新页面就生效,不用打包、不用重载扩展、不用处理 manifest 版本兼容。对于这种「注入一段逻辑、跟本地服务通信」的需求,油猴的@grant和@match机制刚好够用。当然如果你要分发给团队用,打包成正式扩展会更规范,但原型阶段油猴是无敌的。
3. 核心细节拆解:通信协议与注入时机
3.1 通信协议怎么定才不容易崩
Agent 和浏览器之间传的消息,本质上是「一个请求 + 一个响应」。我建议协议设计成下面这样,字段不多但够用:
{ "id": "req-20240101-001", "action": "fetch", "payload": { "url": "https://internal.example.com/api/list", "method": "POST", "headers": { "Content-Type": "application/json" }, "body": "{\"page\":1}" }, "timeout": 15000 }响应则带上id做关联:
{ "id": "req-20240101-001", "status": "ok", "data": { "code": 0, "list": [] }, "elapsed": 342 }为什么一定要带id?因为 Agent 可能并发发多个请求,浏览器返回的顺序不保证和发送顺序一致。没有id关联,你根本不知道哪个响应对应哪个请求。这是很多人第一次写这类桥接时踩的坑——单请求测试没问题,一并发就串数据。
为什么要有timeout?浏览器里的 fetch 可能因为页面卡顿、网络挂起而永远不返回。Agent 侧必须有个超时兜底,否则整个自动化流程会卡死。我一般设 15 秒,内部系统够用了,外部站点可以放宽到 30 秒。
为什么action要留扩展位?因为除了fetch,你迟早会需要click(点击某个按钮触发前端逻辑)、read_dom(读取页面渲染后的数据)、eval(执行一段页面上下文代码)。协议一开始就把action设计成可扩展的,后面加功能不用改通信层。
3.2 注入时机:别在 DOM 没准备好时就动手
油猴脚本的@run-at参数决定了脚本什么时候注入,这个选择直接影响稳定性:
document-start:DOM 还没构建就注入,适合拦截请求、改写全局函数,但此时页面元素都不存在。document-end:DOM 构建完成但资源可能没加载完,适合大多数操作 DOM 的场景。document-idle:页面完全空闲后注入,最稳但最晚。
对于这个项目,我建议用document-idle。因为你要复用的是「已登录会话」,而登录态相关的 Token 往往是在页面 JS 执行过程中才写入内存或 Storage 的。注入太早,Token 还没生成,你发请求照样 401。等页面 idle 了再动手,该有的都有了。
注意:如果你的目标站点是 SPA(单页应用),路由切换不会触发页面重载,油猴脚本只注入一次。这时候你需要在脚本里监听路由变化(比如
history.pushState的 hook 或popstate事件),确保切换页面后通信通道依然有效。
3.3 会话复用的边界:哪些能做,哪些别碰
这里必须划一条线。复用登录态做自动化,和「越权访问」是两回事。我的原则是:
- 只操作当前登录账号本来就有权限的接口,不尝试提权、不遍历他人数据。
- 不绕过任何前端可见的权限校验,如果页面上按钮是灰的,脚本也不该去调那个接口。
- 控制请求频率,别把内部系统打挂,加个 200ms 的最小间隔是基本礼貌。
这些不是技术限制,是工程伦理。你造这个工具是为了提效,不是为了搞事情。
4. 实操落地:从零搭一套可用的桥
4.1 本地桥接服务怎么写
用 Node.js 写最省事,因为 WebSocket 库成熟,而且和前端生态一致。核心逻辑不到 100 行:
// bridge-server.js const { WebSocketServer } = require('ws'); const http = require('http'); const pending = new Map(); // id -> resolve let browserSocket = null; const server = http.createServer(); const wss = new WebSocketServer({ server }); wss.on('connection', (ws) => { browserSocket = ws; ws.on('message', (raw) => { const msg = JSON.parse(raw); const resolver = pending.get(msg.id); if (resolver) { resolver(msg); pending.delete(msg.id); } }); }); // 给 Agent 调用的 HTTP 接口 server.on('request', (req, res) => { if (req.method !== 'POST' || req.url !== '/call') return; let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { const payload = JSON.parse(body); const id = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const timeout = payload.timeout || 15000; const timer = setTimeout(() => { pending.delete(id); res.writeHead(504); res.end(JSON.stringify({ id, status: 'timeout' })); }, timeout); pending.set(id, (msg) => { clearTimeout(timer); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(msg)); }); browserSocket.send(JSON.stringify({ ...payload, id })); }); }); server.listen(8765, '127.0.0.1');这段代码有几个细节值得说。第一,pending用 Map 存回调,每个请求一个 resolver,响应回来按id找到对应回调。第二,超时用setTimeout兜底,超时后从 Map 里删掉,防止内存泄漏。第三,只监听127.0.0.1,不监听0.0.0.0,这样局域网里其他机器连不上,安全边界清晰。
4.2 油猴脚本怎么接
油猴脚本这边要做两件事:连上本地 WebSocket,收到消息后在页面上下文执行 fetch。
// ==UserScript== // @name Agent Bridge // @match https://internal.example.com/* // @run-at document-idle // @grant none // ==/UserScript== (function () { let ws; let retry = 0; function connect() { ws = new WebSocket('ws://127.0.0.1:8765'); ws.onopen = () => { retry = 0; console.log('[bridge] connected'); }; ws.onmessage = async (event) => { const msg = JSON.parse(event.data); const start = Date.now(); try { const resp = await fetch(msg.payload.url, { method: msg.payload.method || 'GET', headers: msg.payload.headers || {}, body: msg.payload.body, credentials: 'include', // 关键:带上 Cookie }); const text = await resp.text(); let data; try { data = JSON.parse(text); } catch { data = text; } ws.send(JSON.stringify({ id: msg.id, status: 'ok', data, elapsed: Date.now() - start, })); } catch (err) { ws.send(JSON.stringify({ id: msg.id, status: 'error', error: String(err), elapsed: Date.now() - start, })); } }; ws.onclose = () => { retry++; const delay = Math.min(1000 * Math.pow(2, retry), 30000); setTimeout(connect, delay); }; } connect(); })();credentials: 'include'是整段代码的灵魂,没有它,fetch 不会带上 Cookie,前面所有设计都白搭。重连用指数退避,本地服务重启时脚本能自动恢复,不用手动刷新页面。@grant none表示不申请油猴的特殊权限,脚本运行在页面上下文,能直接访问页面的 fetch 和 Storage。
4.3 Agent 侧怎么调用
Agent 侧最简单,就是往本地服务发个 HTTP 请求:
curl -X POST http://127.0.0.1:8765/call \ -H "Content-Type: application/json" \ -d '{ "action": "fetch", "payload": { "url": "https://internal.example.com/api/list", "method": "POST", "headers": {"Content-Type": "application/json"}, "body": "{\"page\":1}" }, "timeout": 15000 }'如果你在 Claude Code 或 Codex 里用,直接让 Agent 生成这段 curl 调用即可。更优雅的做法是封装成一个 CLI 工具,比如bridge call --url ... --method ...,Agent 调用起来更顺手。
4.4 参数选择背后的计算
超时时间怎么定?我的经验公式是:timeout = 基准延迟 × 3 + 缓冲。内部系统基准延迟一般 200-500ms,乘 3 是 1.5s,加缓冲取 5s 足够。但如果你要抓的是报表导出这类慢接口,得单独放宽到 60s。别用一个全局超时打天下,按接口类型分级配置。
重连退避为什么用指数而不是固定间隔?固定间隔在服务长时间不可用时会产生大量无效重连,指数退避能把重连频率压下来,同时保证服务恢复后能较快重连。上限设 30s 是经验值,再长用户会觉得「怎么还没连上」。
5. 常见问题与排查技巧实录
5.1 请求发出去了但返回 401
这是最高频的问题。排查顺序如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 401 且响应头无 Set-Cookie | fetch 没带 Cookie | 检查credentials: 'include'是否漏了 |
| 401 且 Cookie 存在 | Token 过期 | 手动刷新页面重新登录,看是否恢复 |
| 401 且只在部分接口出现 | 该接口需要额外签名头 | 在页面 Network 面板对比正常请求的 headers |
| 401 且跨域 | 目标接口和当前页面不同源 | 确认@match覆盖了接口域名 |
我踩过最坑的一次是:脚本注入在 A 域名页面,但请求发往 B 域名接口,浏览器因为跨域策略没带 Cookie。解决办法是让脚本注入到 B 域名页面,或者确认两个域名在同一 Cookie 域下。
5.2 WebSocket 连不上
先确认本地服务真的在跑:curl http://127.0.0.1:8765应该有响应(哪怕是 404)。如果服务正常但脚本连不上,大概率是:
- 端口被占用:换个端口,同时改脚本里的地址。
- 浏览器拦截混合内容:如果页面是 HTTPS,连
ws://可能被拦,需要把本地服务也套上wss://(自签证书),或者确认浏览器允许 localhost 例外。 - 油猴脚本没生效:打开控制台看有没有
[bridge] connected日志,没有就是脚本没注入,检查@match是否匹配当前 URL。
5.3 响应数据是乱码或截断
如果接口返回的是二进制(比如文件下载),resp.text()会把它当文本处理,直接损坏。这种情况要改成resp.arrayBuffer(),然后 base64 编码后传回 Agent。判断依据:看响应头Content-Type,如果是application/octet-stream或application/pdf之类,就必须走二进制通道。
5.4 页面切换后通道失效
SPA 站点路由切换不重载页面,但有些框架会重建全局对象,导致你之前 hook 的东西失效。解决办法是在脚本里监听路由变化,重新建立必要的引用。最简单的做法是监听popstate和重写history.pushState:
const origPush = history.pushState; history.pushState = function (...args) { origPush.apply(this, args); // 路由变化后的处理逻辑 }; window.addEventListener('popstate', () => { // 同上 });5.5 独家避坑心得
第一,永远先手动跑一遍目标接口。在浏览器控制台里直接fetch一次,确认能通,再去搭桥。很多人一上来就搭整套链路,结果 401 排查半天,最后发现是接口本身就要特殊权限。
第二,给每个请求打日志。本地服务和油猴脚本两侧都打,带id和时间戳。出问题时把两侧日志一对,立刻知道卡在哪一段。我习惯把日志写到文件,方便回溯。
第三,别在脚本里硬编码业务逻辑。脚本只做「转发请求」这一件事,所有业务判断放在 Agent 侧。这样脚本足够通用,换个站点改个@match就能复用。
第四,注意浏览器标签页的生命周期。如果目标标签页被切到后台,某些浏览器会节流定时器甚至冻结页面,导致 WebSocket 心跳断掉。解决办法是让本地服务主动发心跳,或者提醒用户别关那个标签页。
6. 这套东西还能怎么扩展
搭好基础桥之后,能玩的花样比想象中多。我列几个已经验证过方向:
扩展一:DOM 操作能力。在协议里加action: "click"和action: "read_dom",Agent 就能操作那些「没有 API、只能点按钮」的老系统。实现上就是document.querySelector(selector).click()和element.innerText,但要注意等元素出现,加个waitForSelector的轮询。
扩展二:请求拦截与改写。用document-start注入,hookwindow.fetch和XMLHttpRequest,把页面自己发的请求也记录下来传给 Agent。这样 Agent 能「观察」用户在页面上的操作,学习真实请求长什么样。
扩展三:多标签页协同。本地服务维护多个 WebSocket 连接,每个连接对应一个标签页,Agent 可以指定「用哪个标签页发请求」。适合需要同时操作多个系统的场景。
扩展四:结果缓存。相同 URL 和参数的请求,短时间内直接返回缓存,减少对目标系统的压力。缓存 key 用url + method + body的哈希,TTL 设 30 秒左右。
我个人在实际操作中的体会是,这套桥的价值不在于技术多复杂,而在于它把「浏览器已登录会话」这个稀缺资源变成了可编程的接口。一旦打通,你会发现以前很多「只能手动做」的重复劳动,突然都能交给 Agent 了。唯一要克制的是别滥用——工具越顺手,越要记得给它划边界。