Files

95 KiB
Raw Permalink Blame History

name, description, version
name description version
hermes-chrome-bridge Chrome Extension + 本地Relay + Electron Desktop App — 让Agent读取/操控客户Chrome浏览器。A+B混合架构(Extension只读+CDP操控)Desktop App当桥梁连接本地Relay和远程Agent API。 0.5.0

Hermes Chrome Bridge

Chrome Extension + 本地Relay + Electron Desktop App — 让Agent读取/操控客户Chrome浏览器。

架构

Chrome Extension ──POST/WS──→ Relay(:3928) ←──GET── Desktop App ──→ Agent API(:8642)
     (自动推送快照)           (快照池)         (拉取+桥接)        (推理+工具调用)

CDP操控方向(v3.4.1+,直连DevTools Protocol):
Agent API ←── Desktop App ←── Relay ──WS──→ Chrome DevTools Protocol(:9222)
(工具结果)    (桥接回传)      (CDP命令)     (直接CDP协议)

Extension只读推送方向(与CDP完全独立):
Chrome Extension ──WS──→ Relay(:3928/ws) → Desktop App → Agent
(snapshot/URL/DOM)      (无单引用覆盖)    (桥接)        (读取)

v3.4.1 关键架构变化Extension 和 CDP 完全独立互不干扰。

  • Extension:只负责推送快照(snapshot/URL/DOM)→ Relay → Agent
  • CDP:直连 DevTools Protocol 操控(click/type/scroll/navigate/evaluate
  • CDP 浏览器不加载 Extension,不连 Relay WS,不会覆盖 Extension 连接

A+B 混合架构

路径A (Extension) 路径B (CDP)
触发 自动,Tab切换/页面变化即推送 用户主动开启,或Agent判断需要操作
能力 读:DOM/截图/选中文本/URL 读写:点击/填表/滚动/导航
配置 装Extension就行 Chrome需加 --remote-debugging-port=9222
安全 只读,不碰页面 可操作页面,需确认
连接 WS到Relay(:3928/ws) 直连DevTools Protocol WS(:9222)
独立性 不受CDP启停影响(v3.4.1+) 不加载Extension,不连Relay WS(v3.4.1+)

为什么 Desktop App 当桥梁

  • 客户电脑的 Relay (localhost:3928) 对服务器不可达
  • Desktop App 同时能访问本地 Relay 和远程 Agent API
  • API Key 集中在 Desktop App 管理,不暴露给 Extension
  • Desktop App 才能做 CDP 本地操控

项目位置与下载

Desktop App 安装包

  • v3.6.8 Setup.exe: https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/atomk-desktop-3.6.8-setup.exe
    • Cookie 提取改用 IPC 直连 CDP:根因是 handleLoadCookies 和 Restore 都走 relayFetch (localhost:3928 HTTP) 做 CDP 调用,Relay HTTP 挂了全失败。用户跑了1天多没看到cookie,但 Settings 页面显示 Relay Server Running。修复:新增 extractCookiesViaCDP() 主进程直接调 sendCDP()10分钟定时器自动提取;新增 chrome-bridge:get-cookies / chrome-bridge:set-cookie IPC;前端改用 IPC 不再依赖 Relay HTTP;自动提取条件去掉 cloudConnected 依赖
    • A2A Inbox 面板:Tools 页面第一个可折叠板块,显示 A2A Gateway 收件箱消息、Agent ID、未读 badge、标记已读/删除。主进程模块 a2a-inbox.ts6个 IPC handler),preload 3处注册,30秒自动刷新
    • Relay 健康检查8秒定时 ping /health,检测失败自动标记 relayRunning=false,恢复后标回 true
    • 前端 relayFetch 重试:2次重试(递增间隔)+ 8秒超时 + 中文友好错误
    • 客户端标识win32win32-x64 格式
    • 修复 Sessions.tsxTrash2 图标导入错误(预存 bug
  • v3.6.7 Setup.exe: https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/atomk-desktop-3.6.7-setup.exe
    • Chat 120s 超时提示逻辑 + UI
    • 子 Agent 设置区域(预置中国AI提供商下拉框 + 自定义 base_url)
    • 移除 Gateway/Messaging 侧栏项
    • CSP 修复(img-src/connect-src 扩展)
    • 自定义品牌图标
  • v3.6.6 Setup.exe: https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/atomk-desktop-3.6.6-setup.exe
    • Products 页面 i18n6语言)+ CSP 修复
  • v3.5.1 Setup.exe (203MB): https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/releases/atomk-desktop-3.5.1-setup.exe
    • Cloud Bridge 页面重写:标题改为"Cloud Bridge";按钮/输入框风格对齐Settings页(CSS变量var(--text-primary), .btn .btn-primary等);服务器地址placeholder改为ws://xxx.xxx.xxx.xxx:9228不硬编码IP;新增Cookie管理板块(按域名查询/展示/删除)和自动化脚本管理板块(添加/展示/删除/执行代码片段);图标改为绿色调
    • 构建+发布流程npm run build:windist/atomk-desktop-3.5.1-setup.execoscmd upload dist/atomk-desktop-3.5.1-setup.exe releases/atomk-desktop-3.5.1-setup.exe
  • v3.4.5 Setup.exe (203MB): https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/atomk-desktop-3.4.5-setup.exe
    • Cloud Bridge 系统提示词自动注入:当 Cloud Bridge 已连接时,自动在发送给 Hermes API 的 messages 数组最前面注入 {role: "system", content: CLOUD_BRIDGE_SYSTEM_PROMPT}。提示词包含完整 CDP 操控协议(健康检查→attach→CDP操作流程、页面跳转防断线、React/Vue表单输入、连接稳定性、故障速查)。未连接时不注入,不影响正常对话。代码在 src/main/hermes.tssendMessageViaApi 函数中,通过 getCloudBridgeState().connected 检测连接状态。
    • Quick Prompt Bar 快捷提示词:输入框上方新增一行按钮栏,一键注入预定义上下文到聊天。默认包含8个提示词(AtomK/店小秘/妙手/Ozon/通途/163邮箱登录信息 + Cloud Bridge操控协议 + 产品上架流程)。按分类颜色区分:🔵蓝色=login 🟡黄色=workflow 🟢绿色=context。超过5个自动折叠,▼展开。用户可点击「+ 添加」自定义提示词,支持编辑/删除,存 localStorage 持久化。代码:src/renderer/src/screens/Chat/QuickPromptBar.tsx(组件)+ quickPrompts.ts(数据定义)+ main.css(样式)。集成在 Chat.tsxchat-input-area div 中,通过 handleInjectPrompt 回调将提示词作为用户消息注入并发送给 Agent。
  • v3.4.4 Setup.exe (203MB): https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/atomk-desktop-3.4.4-setup.exe
    • /cdp/navigate 三步修复:原来单行 Page.navigate 在跨域导航时 CDP session 崩溃返回 500。新实现三步走:① Page.enable 启用事件域 → ② 注册 Page.loadEventFired 监听 → ③ Page.navigate 导航 → ④ 等待 loadEventFired15s 超时兜底)。返回 {ok, result, loadEvent} 结构,失败时 {ok:false, error} 而非 500
    • 心跳策略改进App 层 ping 从 25s 改为 15s(与服务器 WS ping_interval=20s 错开避免竞争);新增 lastPongTime 追踪,45s 无 pong 主动触发重连(不等 WS ping_timeout 到期);重连退避上限从 30s 降至 15s。⚠️ Pong Race 修复:服务端 ping_interval 从 25s→20s,确保客户端 45s 窗口内收到 ≥2 次 pong,容错1次丢包,根除 close(4000, 'pong timeout')
    • 修复 TS 编译lastPongTime 变量从 ws.on('open') 回调作用域提升到 doConnect() 函数作用域,解决 TS2304 "Cannot find name" 错误
  • v3.4.3 Setup.exe (203MB): https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/atomk-desktop-3.4.3-setup.exe
    • WS重连循环修复:真正根因是注册成功后又开第二条WS连接被4001拒绝→重连循环。修复:cloudBridgeConnecting并发锁 + 等旧WS真正关闭再开新连接(2s超时兜底) + 4001拒绝码不重试 + WS引用安全比较
    • registered确认 + 25s心跳:服务端register回复确认 + 客户端25s应用层心跳
    • electron-log日志系统electron-log/main + Object.assign(console, log.functions) — 所有console自动写入 %APPDATA%\atomk-desktop\logs\main.log5MB轮转
    • UI日志查看器ChromeBridge页面底部终端风格日志面板,5秒自动刷新,清空按钮
    • 起始画面版本号:动态注入 __APP_VERSION__,改 package.json 即可
    • WS服务器地址默认留空cloudServerUrl 默认值改为空字符串,需用户手动输入
  • WS服务器地址持久化 + 默认推断cloudServerUrl 初始化从 localStorage("cb_cloud_url") 读取;连接成功后自动保存。若 localStorage 无记录,从 getConnectionConfig().remoteUrl 提取 hostname 拼端口 9228 作为默认值(如 API Server 为 http://49.51.249.171:8642,自动填入 ws://49.51.249.171:9228
  • Cookie Auto-Save 默认开启localStorage.getItem("cb_cookie_autosave") !== "false",只有用户主动关闭才 off,新安装默认 on
  • v3.4.2 Setup.exe (203MB): https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/atomk-desktop-3.4.2-setup.exe
    • 修复 useChatActions.tsAttachmentInfo 缺少 ext 字段的 TS 编译错误
    • Route B 纯CDP端点已集成到 Cloud Bridge server.py/cdp/snapshot、/cdp/click-ref、/cdp/fill-ref
  • v3.4.1 Setup.exe (199MB): https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/hermes-desktop/atomk-desktop-3.4.1-setup.exe
    • Extension与CDP完全独立CDP浏览器不再加载Extension(去掉--load-extension),CDP命令直连DevTools Protocol WS(ws://localhost:9222/devtools/page/TARGET_ID),不再通过Extension的chrome.debugger中转。修复启用CDP后Extension断连的Bug(根因:CDP浏览器的Extension也连Relay WS覆盖了extensionWs单引用,断连后清空导致原始Extension连接丢失)
  • v3.0.2 Setup.exe (200MB): https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/atomk-desktop-3.0.2-setup.exe
    • 修复CDP控制完全失效/cdp/*端点发送了Extension不认识的消息类型(cdp-click等)Extension只认cdp_command。新增sendCDP()函数正确发送cdp_command+等待cdp_response;新增/cdp/attach/cdp/detach端点;Extension新增cdp_attach/cdp_detach消息处理;所有CDP端点自动attach debugger
    • Cloud Bridge远程操控:Desktop App主动WS连云端服务器,Agent通过HTTP代理隧道操控用户浏览器
  • v3.2.1 Setup.exe (199MB): https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/atomk-desktop-3.2.1-setup.exe
    • 修复3个关键BugExtension POST路径不匹配(/page-snapshot vs /push-snapshot)、WS消息类型不识别(extension_connect vs tab-update)、快照数据字段丢失
    • 内置浏览器改用用户真实Chrome profile(保留扩展/书签/登录状态)
  • v3.2.0/v3.1.1/v3.1.0: 已废弃,存在Extension断连Bug
  • 安装后目录结构(extension/ 和 relay/ 直接可见,方便手动加载扩展或调试):
    C:\Users\...\hermes-agent\
      ├── hermes-agent.exe
      └── resources\
          ├── extension\   ← Chrome扩展(手动加载到chrome://extensions)
          ├── relay\       ← 独立Relay服务(含node_modules,可直接 node relay.js)
          └── icon.png
    
  • 无代码签名,Windows会弹"未知发布者"警告,点继续即可
  • 源码包(不含exe: https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/hermes-chrome-bridge.tar.gz

使用步骤(最小可用:Relay + Extension

快速启动(Windows PowerShell 一键脚本)

Invoke-WebRequest -Uri "https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/hermes-chrome-bridge/start-relay.ps1" -OutFile "$env:USERPROFILE\Downloads\start-relay.ps1"
cd $env:USERPROFILE\Downloads
.\start-relay.ps1

脚本自动:检查 Node.js → 找解压目录 → npm install(仅首次) → 启动 Relay。 默认解压路径 Downloads\hermes-chrome-bridge,路径不对改脚本第6行 $BridgeRoot

手动步骤

  1. 安装 Chrome Extension: chrome://extensions → 开发者模式 → 加载 extension/ 文件夹
  2. 启动 Relay: cd relay && npm install && node relay.js (→ :3928)
  3. 浏览网页 → Extension 自动推送快照 → Agent 可读取
  4. (可选)启动 Desktop App: cd desktop && npm install && npm start

Agent 工具 (agent-tools/hermes_page_tools.py)

  • extract_current_page(mode) — 从 Relay 取页面快照 (text/html/screenshot/all)
  • extract_page_summary() — 格式化摘要
  • cdp_snapshot() — CDP 截屏
  • cdp_click(x, y) — 点击坐标
  • cdp_click_element(selector) — CSS选择器点击
  • cdp_type(text) — 键盘输入
  • cdp_fill_input(selector, value) — 填充输入框
  • cdp_navigate(url) — 导航
  • cdp_evaluate(expression) — 执行JS
  • cdp_scroll(direction) — 滚动
  • cdp_get_element_text(selector) — 获取元素文本
  • cdp_wait_for_element(selector, timeout) — 等待元素出现

通信协议

Agent → Desktop App(工具调用下发)

通过 SSE stream 下发:

{ "type": "tool_call", "tool": "extract_current_page", "args": {"mode": "text"}, "call_id": "call_abc123" }

Desktop App → Agent(结果回传)

POST 回 API

{ "type": "tool_result", "call_id": "call_abc123", "result": {"url": "...", "title": "...", "text": "..."} }

安全护栏

  • 只读模式(路径A):无需确认
  • 操控模式(路径B)Desktop App 弹出确认框
  • 白名单域名:自动通过(如公司ERP
  • 陌生域名:必须确认
  • 敏感操作(支付/删除):二次确认

Extension 关键实现

  • Manifest V3,权限:activeTab/tabs/debugger/storage/scripting(需要scripting用于自动注入content_script
  • content_script.js: MutationObserver 监听 SPA + URL变化,自动推送
  • background.js: chrome.tabs.onActivated 监听 Tab 切换,captureVisibleTab 截图
  • popup.html: 暗色UI,显示连接状态/提取按钮/CDP开关
  • Extension fetch 不受 CORS 限制
  • background.js 必须建立 WebSocket 连接到 Relayws://localhost:3928/ws,连接后发送 {type:"extension_connect"} 消息标识身份。⚠️ 浏览器 WebSocket 构造函数只有2个参数,不可传 headers(那是 Node.js ws 库的 API,会导致 Service Worker 崩溃)
  • CDP 命令通过 WebSocket 双向传递Relay 收到 /cdp/send 请求后,通过 extWs 发送 {type:"cdp_command", requestId, method, params} 给 ExtensionExtension 用 chrome.debugger.sendCommand 执行后将结果通过 WS 回传 {type:"cdp_response", requestId, success, result/error}。注意:字段名必须是 requestId(不是 id)和 cdp_response(不是 cdp_result),否则 Relay 无法匹配 pending 请求

content_script 自动注入机制(关键修复)

Extension 安装/重新加载后,已打开的标签页不会自动注入 content_scriptmanifest 的 content_scripts 只对新打开的页面生效)。 如果直接 chrome.tabs.sendMessage 会报错 "Receiving end does not exist"。

修复方法:在 background.js 中增加 ensureContentScript(tabId) 函数:

  1. chrome.tabs.sendMessage({action:'ping'}) 探测
  2. 如果失败,用 chrome.scripting.executeScript({target:{tabId}, files:['content_script.js']}) 手动注入
  3. 等待 200ms 让脚本初始化
  4. 需要 manifest.json 中有 scripting 权限

跨境电商核心场景

场景 路径 体验
客户浏览1688,说「分析这个产品」 A 秒出分析,不用复制粘贴
客户看Temu竞品,说「提取卖点写listing」 A 直接拿到页面内容生成
客户在通途ERP,说「试算运费加备注」 B Agent自己点鼠标操作ERP
客户在AtomK,说「认领并生成上架资料」 B Agent自动填表提交

Cloud Bridge 远程操控架构(v3.0.2+

Desktop App 主动 WebSocket 连到云端 Cloud Bridge ServerAgent 通过云端转发请求到 Desktop 本地 Relay。

Agent → Cloud Bridge HTTP(:9229) → WS隧道(:9228) → Desktop App → 本地Relay(:3928) → Extension → Chrome
  • Cloud Bridge Server: /home/ubuntu/.hermes/cloud-bridge/server.py
  • WS端口 9228 (0.0.0.0,外网可达), HTTP代理端口 9229 (127.0.0.1 only)
  • Server心跳参数(v2.2): ping_interval=20s, ping_timeout=30s(原10s太短), app_ping=15s, pong_timeout=45s, reconnect_grace=30s
  • Cloud Bridge v2.2.0 当前版本(2026-05-20),修复了 server.py 第1386行缩进错误(log.info 缺少4空格缩进导致 banner 块语法错误)
  • Desktop App心跳参数(v3.4.4+): 应用层ping 15s, lastPongTimeout 45s, 重连退避上限15s
  • ⚠️ Pong Race 根因:服务端 ping_interval 必须 ≤ 20s,否则客户端 45s 窗口内只收到 1 次 WS pong,丢包即断连。确保 45s 内至少 2 次 pong20s间隔 × 2 = 40s < 45s
  • Server公网IP: 本机有两个公网IP — 49.51.249.171(主出口)和 43.154.57.178(弹性EIP),都能访问。⚠️ 源码和Desktop App不应硬编码IP,应配置化或动态获取
  • Desktop App 默认 WS URL: ws://<本机公网IP>:9228(当前可用 49.51.249.171 或 43.154.57.178
  • Hermes API Server: http://<本机公网IP>:8642OpenAI兼容 /v1/chat/completions
  • API Key (Cloud Bridge): Bing2026Cao$$$
  • API Key (Hermes API Server): 814b419943afdd74701d11866f1497a1def8beb49188be58fef1b98950d1782f
  • API Key 认证: 请求需 Authorization: Bearer <key> header

CDP 控制端点(v3.4.1+,直连 DevTools Protocol

所有端点 POST 到 http://127.0.0.1:9228/cdp/*(Agent侧),通过隧道到达用户 Desktop Relay(:3928)再由 Relay 直连 ws://localhost:9222/devtools/page/TARGET_ID 发送 CDP 命令(不再通过 Extension 的 chrome.debugger):

端点 功能 CDP 方法
/cdp/attach 确保 CDP 直连已建立 获取 target list + 建立 WS 连接
/cdp/detach 断开 CDP 直连 关闭 WS 连接
/cdp/navigate 导航到URL Page.enable + Page.navigate + 等待Page.loadEventFired(v3.4.4三步修复)
/cdp/click 点击坐标(x,y) Input.dispatchMouseEvent (pressed+released)
/cdp/type 输入文本 Input.insertText
/cdp/scroll 滚动(deltaX,deltaY) Input.dispatchMouseEvent (mouseWheel)
/cdp/evaluate 执行JS Runtime.evaluate
/cdp/send 通用CDP命令 任意CDP method

CDP 消息流(v3.4.1+,直连 DevTools Protocol

1. Agent → Cloud Bridge → Relay /cdp/* 端点
2. Relay ensureCdpDirectConnection() → fetch http://localhost:9222/json/list → 找到 page target
3. Relay → new WebSocket(ws://localhost:9222/devtools/page/TARGET_ID)
4. Relay sendCDP(method, params) → WS发送 {"id":N,"method":"...","params":{...}}
5. Chrome DevTools Protocol 直接响应 → {"id":N,"result":{...}}
6. Relay cdpDirectQueue 匹配 id → resolve promise → 返回给 Agent

关键变量

  • cdpDirectWs — 直连 DevTools Protocol 的 WebSocket 实例
  • cdpDirectTargetId — 当前连接的 page target ID
  • cdpDirectQueue — Map<id, {resolve, timer}> 用于匹配请求/响应

连接管理

  • ensureCdpDirectConnection() — 自动发现 target 并建立 WS 连接,失败时重试
  • 每次 CDP 端点调用前自动调用 ensureCdpDirectConnection
  • WS 断开时自动清空 cdpDirectWs,下次调用会重新连接
  • 超时:每个 CDP 命令 15 秒超时

⚠️ v3.4.0 及更早版本 CDP 架构(已废弃)

v3.4.0 及之前,CDP 命令通过 Extension 的 chrome.debugger API 中转:

Agent → Relay /cdp/* → extWs WS发送 {type:"cdp_command"} → Extension chrome.debugger.sendCommand → 结果回传

此架构有致命缺陷CDP 浏览器也加载了 Extension,其 WS 连接覆盖了用户原始 Chrome 的 extensionWs 单引用,CDP 浏览器 Extension 断连后清空 extensionWs = null,导致原始 Extension 连接也丢失。v3.4.1 改为直连 DevTools Protocol 彻底解决。

⚠️ v3.0.1 CDP 完全失效的根因(v3.0.2 已修复)

v3.0.1 的 /cdp/click/cdp/type/cdp/navigate 等端点直接发 WS 消息:

// ❌ 错误:发了 Extension 不认识的消息类型
extensionWs.send(JSON.stringify({ type: 'cdp-click', ...req.body }))
extensionWs.send(JSON.stringify({ type: 'cdp-type', ...req.body }))
extensionWs.send(JSON.stringify({ type: 'cdp-navigate', ...req.body }))

Extension 的 handleWSMessage() 只处理 cdp_command 类型,不认识 cdp-click/cdp-type/cdp-navigate/cdp-evaluate/cdp-scroll,所以所有 CDP 操作全部静默失败(消息发出去了但 Extension 完全忽略)。

v3.0.2 修复:

  1. 新增 sendCDP() 函数 — 统一发送 {type:"cdp_command", requestId, method, params} 格式
  2. 新增 /cdp/attach/cdp/detach 端点 — 通过 WS 发 cdp_attach/cdp_detach 消息
  3. Extension 新增 cdp_attach/cdp_detach 消息处理 — 支持 WS 触发 chrome.debugger.attach/detach
  4. 所有 CDP 端点使用 sendCDP() 而非直接 extensionWs.send()
  5. 所有 CDP 端点操作前自动检查/attach debugger
  6. WS 消息处理新增 cdp_response(匹配 requestId resolve promise)和 cdp_status(更新 cdpAttachedTab 状态)

重要:Electron 主进程就是 Relay

resources/relay/relay.js 是旧版独立 RelayElectron 不会启动它。Electron 主进程的 Expresschrome-bridge.ts 中的 startCBridge)直接监听 3928 端口,包含所有 HTTP 路由和 WebSocket 服务器。修改 CDP 行为应改 src/main/chrome-bridge.ts,不是 resources/relay/relay.js

更新 Extension 到用户电脑

Extension 修改后需要同步到用户 Windows 电脑,流程:

方法1:Git 推送 + COS 打包(推荐,有版本追踪)

  1. 修改本地源码 ~/hermes-chrome-bridge/extension/
  2. Git 提交推送:cd ~/hermes-chrome-bridge && git add -A && git commit -m "fix: 描述" && git push
  3. 上传到腾讯云 COScoscmd upload /tmp/hermes-page-bridge.zip hermes-page-bridge.zip
  4. 生成临时下载链接(24h有效):coscmd signurl -t 86400 hermes-page-bridge.zip
  5. 用户下载 zip → 解压 → 覆盖原 extension 目录 → chrome://extensions/ 点刷新按钮
  6. 重要:刷新 Extension 后,已打开的标签页需要刷新页面才能自动注入 content_script(或依赖 ensureContentScript 自动注入)

Service Worker 崩溃排查

chrome://extensions 中 Service Worker 显示错误:

  • 最常见原因background.jsnew WebSocket(url, protocols, {headers:...}) 传了3个参数。浏览器 WebSocket 构造函数只接受 (url, protocols) 2个参数,第3个参数是 Node.js ws 库的 API,会导致 Service Worker 崩溃。
  • 修复:移除第3个参数,改为连接后发送 {type: "extension_connect"} 消息体标识身份。
  • 验证Service Worker 状态应显示 "Active" 而非错误;访问 http://localhost:3928/health 应返回 extensionConnected: true

方法2:仅 COS 打包(快速,无版本追踪)

  1. 在服务器打包:cd ~/hermes-chrome-bridge && zip -r /tmp/hermes-page-bridge.zip extension/

Agent 服务器端连接 Cloud Bridge 实操

从 Agent 服务器连接用户 Chrome Bridge 的实际可用方法(本机公网IP: 49.51.249.171 或 43.154.57.178):

API Key 获取

# API Key 在进程启动参数中
ps aux | grep cloud-bridge | grep -v grep
# 当前 CB Key: --key "Bing2026Cao\\$\\$\\$"
# Hermes API Server Key: 814b419943afdd74701d11866f1497a1def8beb49188be58fef1b98950d1782f

或从进程 cmdline 读取:cat /proc/$(pgrep -f cloud-bridge)/cmdline | tr '\0' ' '

可用端点(经实测验证)

⚠️ 认证方式关键说明

  • 唯一有效的认证方式是 Authorization: Bearer <key> header
  • X-Bridge-KeyX-API-Key、Basic Auth 等均返回 401 Unauthorized
  • 所有端点(包括 /cloud-bridge/health)都需要 Bearer 认证
  • Shell 中 $ 变量展开问题key 含 $$$bash 中会被展开。用 Python requests 或单引号包裹

两个访问入口(功能不同!)

  • localhost:9228 — Cloud Bridge 主服务(WS+HTTP+CDP所有 /cdp/* 端点只能用9228
  • localhost:9229 — 仅WS隧道和部分APIv1/health等),/cdp/* 端点在此端口返回404
import requests, json
KEY = 'Bing2026Cao$$$'
HEADERS = {'Authorization': f'Bearer {KEY}', 'Content-Type': 'application/json'}

# ✅ Cloud Bridge 健康检查
r = requests.get('http://localhost:9228/cloud-bridge/health', headers=HEADERS, timeout=10)
print(r.json())  # connected_slots, uptime, version等

# ✅ CDP attach(获取 targetId)— 只能用92289229返回404
r = requests.post('http://localhost:9228/cdp/attach', headers=HEADERS, json={}, timeout=10)
target_id = r.json()['targetId']

# ✅ CDP evaluate(执行JS,读取页面URL/标题等)
r = requests.post(f'http://localhost:9228/cdp/evaluate?targetId={target_id}',
    headers=HEADERS, json={'expression': 'JSON.stringify({url:location.href,title:document.title})'}, timeout=10)

# ✅ CDP navigate / click / type / scroll / send / snapshot 等同理
# 都走 http://localhost:9228/cdp/<action>?targetId=<id>

⚠️ 已知不可用端点

# ❌ 9229端口所有 /cdp/* 端点返回404
requests.post('http://localhost:9229/cdp/attach', ...)  # 404 Not Found

# ❌ 通过代理的 /health 会超时挂起
requests.get('http://localhost:9229/health', headers=H, timeout=10)  # 会超时!
# 改用 /cloud-bridge/health
requests.get('http://localhost:9228/cloud-bridge/health', headers=H, timeout=10)  # ✅

快速连接验证三步曲

import requests
KEY = 'Bing2026Cao$$$'
H = {'Authorization': f'Bearer ***', 'Content-Type': 'application/json'}

# 1. 检查 Desktop App 是否在线
r = requests.get('http://localhost:9228/cloud-bridge/health', headers=H, timeout=10)
print(r.json())

# 2. 连接 CDP target
r = requests.post('http://localhost:9228/cdp/attach', headers=H, json={}, timeout=10)
tid = r.json()['targetId']

# 3. 读取当前页面
r = requests.post(f'http://localhost:9228/cdp/evaluate?targetId={tid}',
    headers=H, json={'expression': 'JSON.stringify({url:location.href,title:document.title})'}, timeout=10)
print(r.json())

⚠️ 认证唯一方式Authorization: Bearer ***X-Bridge-Key 等 header 均返回 401。 ⚠️ 不要用 curlkey 含 $$$,shell 变量展开导致认证失败。必须用 Python requests。 ⚠️ /cdp/* 端点只能用92289229端口返回404。

CDP 定时数据采集(后台轮询模式)

通过 CDP 隧道定时采集用户浏览器中的页面数据,适合长时间后台监控场景(如竞品价格追踪)。

关键技术点

  1. Tab 枚举Target.getTargets 获取所有页面,按域名过滤:
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"method":"Target.getTargets"}' http://127.0.0.1:9229/cdp/send
  1. Tab 切换:先 detach 当前 target,再 attach 到目标 targetId
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"targetId":"<TARGET_ID>"}' http://127.0.0.1:9229/cdp/detach
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"targetId":"<NEW_TARGET_ID>"}' http://127.0.0.1:9229/cdp/attach
  1. ⚠️ attach 可能返回 openerId 对应的 targetId:Ozon 的产品页是从首页打开的(openerId 指向首页),attach 产品页 targetId 可能实际连到首页。解决方案:attach 后用 /cdp/navigate 直接导航到目标 URL。

  2. URL 去重:同一页面可能出现在多个 target 中(首页 + 子页面),用 url.split("?")[0] 去重避免重复采集。

  3. Ozon 特殊 DOMOzon 没有 data-testid 属性,价格提取需搜索 innerText 中含 Unicode 8381)的行,跳过促销行(如"Товары за 1₽"),取第2-3行作为真实价格。卖家信息通过文本匹配 Магазин 关键字。

后台采集脚本模板

# 完整示例: /home/ubuntu/ozon_collector.py
# 核心流程:
while True:
    targets = get_ozon_targets()  # Target.getTargets + 域名过滤
    seen_urls = set()
    for t in targets:
        url = t["url"].split("?")[0]  # 去重
        if url in seen_urls: continue
        seen_urls.add(url)
        detach_current()  attach(t["targetId"])  extract_data()  detach()
        record = {"timestamp": now, "cycle": cycle, **data}
        append_jsonl(record)
    delay = random.randint(180, 300)  # 3-5分钟随机
    time.sleep(delay)

运行管理

# 启动(-u 禁用缓冲)
python3 -u /home/ubuntu/ozon_collector.py  # background=true

# 查看数据
wc -l /home/ubuntu/ozon_collection.jsonl
tail -1 /home/ubuntu/ozon_collection.jsonl | python3 -m json.tool

# 停止:通过 Hermes process kill 或 pkill

数据格式(JSONL

{
  "timestamp": "2026-05-19T01:20:53",
  "cycle": 3,
  "run_id": "20260519_011000",
  "target_id": "FD4CEA49...",
  "title": "Накладные ногти с дизайном...",
  "url": "https://www.ozon.ru/product/...",
  "h1": "商品名",
  "price": "85₽",
  "allPrices": ["Товары за 1₽", "85₽", "86₽", "2 146₽"],
  "rating": "4.8 • 746 отзыв",
  "seller": "YX Art Fashion",
  "sku": "4267896148",
  "stock": "99 единиц осталось",
  "delivery": "с 4 июня",
  "imgs": ["https://cdn..."]
}

Tab 枚举(CDP evaluate 方式)

除了 Target.getTargets(通过 /cdp/send),还可以通过 /cdp/evaluate 获取标签页信息:

# 获取所有标签页(需要在有 Chrome 扩展 API 权限的上下文中)
js = """JSON.stringify(
  Array.from(document.querySelectorAll ? [{url: location.href, title: document.title}] : [])
)"""
# 但更可靠的方式是通过 Target.getTargets
# POST /cdp/send {"method": "Target.getTargets"}

注意Target.getTargets 返回的是浏览器级别所有 target(含 Service Worker、扩展页面等),需按 type: "page" 过滤。

1688 爬取要点(CDP实操验证)

搜索页触发验证码

  • s.1688.com/selloffer/offer_search.htm?keywords=xxx 搜索页对自动化检测敏感,直接导航大概率触发验证码拦截(标题变为"验证码拦截"URL含punish
  • 即使通过JS window.location.href 导航也会触发,与用户手动搜索行为不同
  • 1688首页搜索框填词后按Enterform submit 也不跳转(SPA拦截)
  • 结论:不要通过搜索页获取商品列表

1688首页商品卡片在AX树中不可点击

  • 1688首页推荐商品在 AX 树中只有 StaticText(商品名),没有包裹它们的 link 元素
  • 1688首页用虚拟列表/Shadow DOM 渲染商品卡片,<a href> 不出现在 DOM 中
  • 1688首页的 <a> 链接只有分类/频道/店铺链接,没有 /offer/ 商品详情链接
  • 正确获取商品链接:去店铺页(shop*.1688.com),店铺页有标准 /offer/ 链接

1688搜索结果页可能为空

  • 未完整登录时搜索结果页显示"哎呦喂,这里空空如也"
  • 即使首页显示已登录(用户名可见),搜索页仍可能不显示结果
  • 解决方案:用店铺页代替搜索页获取商品列表

正确获取商品链接的方式

  • 使用类目频道页(如 home.1688.com 家居百货、3c.1688.com 数码等),这些页面包含大量 /offer/ 链接
  • home.1688.com 实测有155+个商品链接,滚动后更多
  • 商品链接格式:https://detail.1688.com/offer/{offerId}.htmlofferId为12位数字

商品详情页可正常访问

  • detail.1688.com/offer/XXX.html 页面可正常加载和提取数据
  • AX 树提取:商品标题在 heading 元素或 RootWebArea.name(格式:"商品名 - 阿里巴巴");价格含 ¥;销量含"已售"
  • JS 提取[class*="price"] 元素含 ¥ 符号;含"已售"/"成交"文本行;含"运费"/"发货"文本行
  • 注意AX 树中店铺名和商品名混在同一 link 元素的 name 中,需用 heading 角色或 RootWebArea 精确提取商品标题
  • 价格:[class*="price"] 元素,含 ¥ 符号的行
  • 起批量:含"起批"/"起订"的文本行
  • 供应商信息:页面顶部公司名+店铺指标(回头率/服务分/好评率/发货率)
  • 销量:含"已售"/"成交"的行
  • 运费:含"运费"/"发货"的行
  • 产品参数:页面中段的结构化属性列表(材质/款式/品牌等)

Cloud Bridge v2.2 API 端点

健康与诊断(需要 Bearer 认证)

  • GET /cloud-bridge/health — 状态概览(版本、运行时间、连接数、心跳配置、metrics摘要)
  • GET /cloud-bridge/diagnostics — 完整诊断(CDP session、pending请求、连接时间线、配置)
  • GET /cloud-bridge/metrics — 详细指标(CDP延迟/错误率、WS发送/重试、断连统计)
  • 认证方式:唯一有效 — Authorization: Bearer ***(其他header如...Key均401
  • 端口9228(主服务,WS+HTTP)或 9229HTTP代理隧道)均可

CDP 操作跨域 Iframe(关键技巧)

163邮箱、店小秘等网站将登录表单放在跨域 iframe 中(如 dl.reg.163.com vs mail.163.com),主页面 JS 和 CDP Runtime.evaluate 默认无法访问 iframe 内部 DOM。

解决方案:Page.createIsolatedWorld

# Step 1: 获取跨域 iframe 的 frameId
frames = cdp_post('/cdp/send', {'method': 'Page.getFrameTree'})
# 遍历 childFrames,找 url 包含目标域名的 frame
# 例:frameId = '8E4061472A22A6FD6D8B27F15BA645E9'

# Step 2: 在 iframe 中创建隔离执行环境
world = cdp_post('/cdp/send', {
    'method': 'Page.createIsolatedWorld',
    'params': {
        'frameId': frame_id,      # Step 1 获取的 iframe frameId
        'worldName': 'iframe_work',
        'grantUniveralAccess': True
    }
})
ctx_id = world['result']['executionContextId']  # 例: 47

# Step 3: 用 contextId 在 iframe 内执行 JS
result = cdp_post('/cdp/send', {
    'method': 'Runtime.evaluate',
    'params': {
        'expression': 'document.querySelector("input[name=email]").value',
        'contextId': ctx_id,           # ← 关键:指定 iframe 上下文
        'returnByValue': True
    }
})

163 邮箱登录完整示例

⚠️ 多账号冲突Chrome profile 可能保存了另一个163账号的 session cookie,访问 mail.163.com 会自动登录到错误账号。必须先清除 163.com 相关 cookie 再导航:

import urllib.request, json, time

KEY = 'Bing2026Cao$$$'
BASE = 'http://127.0.0.1:9229'

def cdp_post(path, data=None, timeout=20):
    body = json.dumps(data or {}).encode()
    req = urllib.request.Request(f'{BASE}{path}', data=body, headers={
        'Authorization': f'Bearer {KEY}', 'Content-Type': 'application/json'
    })
    return json.loads(urllib.request.urlopen(req, timeout=timeout).read())

def cdp_send(method, params=None, timeout=20):
    return cdp_post('/cdp/send', {'method': method, 'params': params or {}}, timeout=timeout)

# 0. 清除 163.com cookies(防止自动登录到错误账号)
cookies_result = cdp_send('Network.getAllCookies')
for cookie in cookies_result.get('result', {}).get('cookies', []):
    if '163.com' in cookie.get('domain', ''):
        cdp_send('Network.deleteCookies', {'name': cookie['name'], 'domain': cookie['domain']})
cdp_post('/cdp/evaluate', {'expression': 'localStorage.clear(); sessionStorage.clear();'})

# 1. 导航到 163 邮箱
cdp_post('/cdp/navigate', {'url': 'https://mail.163.com/'})
time.sleep(3)

# 2. 切换到密码登录模式
cdp_post('/cdp/evaluate', {
    'expression': 'document.getElementById("changemode-mobile")?.click()'
})
time.sleep(2)

# 3. 获取 URS iframe frameId
frames = cdp_post('/cdp/send', {'method': 'Page.getFrameTree'})
frame_id = None
for child in frames['result']['frameTree'].get('childFrames', []):
    if 'reg.163' in child['frame']['url']:
        frame_id = child['frame']['id']
        break

# 4. 创建隔离世界
world = cdp_post('/cdp/send', {
    'method': 'Page.createIsolatedWorld',
    'params': {'frameId': frame_id, 'worldName': 'urs', 'grantUniveralAccess': True}
})
ctx_id = world['result']['executionContextId']

# 5. 填入账号密码 + 点击登录
cdp_post('/cdp/send', {
    'method': 'Runtime.evaluate',
    'params': {
        'expression': '''
        (function() {
          var emailInput = document.querySelector('input[name="email"]');
          emailInput.value = 'smthzqjone@163.com';
          emailInput.dispatchEvent(new Event('input', {bubbles: true}));

          var pwdInput = document.querySelector('input[name="password"]');
          pwdInput.value = 'Bingo2025';
          pwdInput.dispatchEvent(new Event('input', {bubbles: true}));

          document.getElementById('dologin').click();
        })()
      ''',
        'contextId': ctx_id,
        'returnByValue': True
    }
})

⚠️ 跨域 Iframe Pitfalls

  • 直接导航到 iframe URL 不一定有效:URS 组件页面独立打开时只显示"继续/放弃"确认框,没有登录表单(JS 检测不在 iframe 中则渲染逻辑不同)
  • contentDocument 跨域返回 null:主页面 JS 无法通过 iframe.contentDocument 访问跨域 iframe
  • Page.getFrameTree 返回所有 frame:包括跨域的,CDP 协议层面可见,只是 JS 层面被隔离
  • contextId 可能失效:页面导航后 iframe 重新加载,需重新 createIsolatedWorld 获取新 contextId
  • grantUniveralAccess: True 很重要:否则在 isolated world 中可能无法访问某些 DOM 属性
  • 返回值格式Runtime.evaluate + returnByValue: True 时,JS 对象可能返回空 {},建议用 JSON.stringify() 包裹返回值再在 Python 端 json.loads()

CDP Accessibility Snapshot + @e Ref 交互模型

通过 Cloud Bridge CDP 隧道可实现与 Hermes 内置 browser_snapshot/browser_click 相同的交互模式:生成带 @e 引用ID的快照 → 按引用点击/填表。

1. 生成 @e Ref 快照(JS 注入)

通过 /cdp/evaluate 注入 JS 脚本,遍历 DOM 生成带坐标的 @e 引用列表:

// 核心脚本 — 注入到页面后返回 compact snapshot
(() => {
  const interactive = new Set(['A','BUTTON','INPUT','SELECT','TEXTAREA','SUMMARY','DETAILS']);
  const skip = new Set(['SCRIPT','STYLE','NOSCRIPT','SVG','HEAD','META','LINK']);
  let refs = {}, idx = 1;
  function walk(el) {
    if (skip.has(el.tagName) || el.offsetParent === null && el.tagName !== 'BODY') return;
    const tag = el.tagName;
    const isInter = interactive.has(tag) || el.getAttribute('role') === 'button'
      || (tag === 'DIV' && el.onclick) || el.getAttribute('contenteditable') === 'true';
    const text = (el.innerText || '').trim().slice(0, 60);
    const href = el.href || '';
    const placeholder = el.placeholder || '';
    const value = el.value || '';
    const type = el.type || '';
    if (isInter || (text && tag !== 'DIV' && tag !== 'SPAN')) {
      const r = el.getBoundingClientRect();
      const ref = `@e${idx++}`;
      refs[ref] = {
        selector: tag + (el.id ? '#'+el.id : '') + (el.className ? '.'+String(el.className).split(' ').slice(0,2).join('.') : ''),
        bbox: { x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2), w: Math.round(r.width), h: Math.round(r.height) }
      };
      // 标记元素供后续查找
      el.setAttribute('data-hermes-ref', ref);
    }
    for (const c of el.children) walk(c);
  }
  walk(document.body);
  return JSON.stringify({ totalRefs: idx-1, refs });
})()

返回示例

{
  "totalRefs": 42,
  "refs": {
    "@e1": { "selector": "INPUT#alisearch-input", "bbox": {"x": 611, "y": 140, "w": 400, "h": 36} },
    "@e4": { "selector": "BUTTON.sbtn", "bbox": {"x": 1020, "y": 140, "w": 80, "h": 36} },
    "@e12": { "selector": "A.title", "bbox": {"x": 200, "y": 350, "w": 180, "h": 20} }
  }
}

2. 按 @e Ref 点击

# 通过 Cloud Bridge HTTP 代理
import urllib.request, json

def click_ref(ref, refs_dict):
    """按 @e ref 点击 — 查找坐标 → CDP click"""
    bbox = refs_dict[ref]["bbox"]
    data = json.dumps({"x": bbox["x"], "y": bbox["y"]}).encode()
    req = urllib.request.Request(
        "http://127.0.0.1:9229/cdp/click",
        data=data,
        headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
    )
    return json.loads(urllib.request.urlopen(req, timeout=10).read())

3. 按 @e Ref 填表(React/Vue 兼容)

⚠️ CDP Input.insertText 对中文和 React/Vue 受控组件有兼容问题。正确方式:evaluate + nativeSetter

def fill_ref(ref, refs_dict, value):
    """按 @e ref 填表 — nativeSetter 触发 React/Vue change 检测"""
    selector = refs_dict[ref]["selector"]
    js = f"""
    (() => {{
      const el = document.querySelector('{selector}');
      if (!el) return {{error: 'element not found'}};
      const nativeSetter = Object.getOwnPropertyDescriptor(
        window.HTMLInputElement.prototype, 'value'
      ).set;
      nativeSetter.call(el, {json.dumps(value)});
      el.dispatchEvent(new Event('input', {{bubbles: true}}));
      el.dispatchEvent(new Event('change', {{bubbles: true}}));
      return {{success: true}};
    }})()
    """
    data = json.dumps({"expression": js}).encode()
    req = urllib.request.Request(
        "http://127.0.0.1:9229/cdp/evaluate",
        data=data,
        headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
    )
    return json.loads(urllib.request.urlopen(req, timeout=10).read())

4. 1688 完整交互示例

# 搜索1688商品:snapshot → fill搜索框 → click搜索按钮
KEY = 'Bing2026Cao$$$'

# Step 1: 生成快照
snap_js = "/* 上面的注入脚本 */"
snap = cdp_evaluate(snap_js)  # 返回 refs dict

# Step 2: 找到搜索框 ref 并填词
search_refs = {k:v for k,v in snap["refs"].items() if "alisearch" in v["selector"]}
fill_ref("@e1", snap["refs"], "手机壳")

# Step 3: 点击搜索按钮
btn_refs = {k:v for k,v in snap["refs"].items() if "sbtn" in v["selector"]}
click_ref("@e4", snap["refs"])

5. Route B:纯 CDP 智能端点(v2.1 已实现,反爬安全)

端点清单:

  • POST /cdp/snapshot — 无障碍树快照 → @e ref 映射
  • POST /cdp/click-ref — 按 @e ref 点击(拟人轨迹)
  • POST /cdp/fill-ref — 按 @e ref 填充输入(拟人打字)
  • POST /cdp/wait — 等待元素出现(AX轮询)
  • POST /cdp/scroll-ref — 滚动元素到可视区
  • POST /cdp/navigate — 三步导航(Page.enable → loadEventFired监听 → Page.navigate

为让 Cloud Bridge API 与 agent-browser 一致,在 server.py 中实现了 Route B 端点——全程零 JS 注入、零 DOM 污染,对 Ozon 等强反爬站点安全。

架构决策:纯 CDP vs JS 注入

方案 反爬风险 实现难度
JS 注入 + 坐标点击 🔴 高(DOM 变更可被 MutationObserver 检测)
纯 CDP + 瞬移点击 🟡 中(行为模式异常)
纯 CDP + 拟真轨迹 🟢 低(与真人操作一致) 中高

Route B 选择纯 CDP + 拟真轨迹方案,全程不调用 Runtime.evaluate(除 snapshot 取 URL/title),不改 DOM。

端点列表

端点 功能 核心CDP方法 反爬安全
POST /cdp/snapshot AX 树快照 + @e ref Accessibility.getFullAXTree 零注入
POST /cdp/click-ref 按 ref 拟真点击 DOM.getBoxModel + 贝塞尔轨迹 + Input.dispatchMouseEvent 拟真
POST /cdp/fill-ref 按 ref 拟真输入 Input.dispatchKeyEvent(Ctrl+A) + Input.insertText/逐字 拟真
POST /cdp/wait 等待元素出现 AX 树轮询 零注入
POST /cdp/scroll-ref 滚动元素到可视区 DOM.scrollIntoViewIfNeeded 零注入

关键实现细节

1. Snapshot 不取 bbox(性能关键!)

❌ 旧方案:snapshot 时逐个取 DOM.getBoxModel → 1700元素 × 2次CDP ≈ 数分钟超时
✅ 新方案:snapshot 只取 AX 树(~1.5秒)bbox 在 click/fill 时按需获取(单次<100ms)
  • Accessibility.getFullAXTree 一次返回全部节点
  • _get_bbox_for_ref(ref) 按需取单个 bbox,结果缓存到 ref_map
  • click/fill 时先 scrollIntoViewIfNeeded,再取 bbox

2. CDP session 管理

  • Browser-level 命令(Target.getTargets, Target.attachToTarget不能带 sessionId
  • Page-scoped 命令必须带 sessionId
  • cdp_ensure_session() 自动检测和复用 session

3. Desktop Relay 响应双序列化 bug

Relay 的 /cdp/send 返回 {"ok": true, "result": {...}},但 result 有时是 JSON 字符串而非 dict:

# 防御性解析(在 cdp_send 中)
cdp_result = resp_body.get('result', {})
if isinstance(cdp_result, str):
    cdp_result = json.loads(cdp_result)  # 解除双序列化

4. 拟真鼠标轨迹

# 贝塞尔曲线 + 随机抖动 + 按压延迟
points = bezier_points(current_x, current_y, target_x, target_y)  # 12步贝塞尔
for x, y in points:
    cdp_send('Input.dispatchMouseEvent', {type: 'mouseMoved', x, y})
    await asyncio.sleep(0.005)  # 5ms/步
# 按下 → 随机延迟(50-150ms) → 抬起
cdp_send('Input.dispatchMouseEvent', {type: 'mousePressed', ...})
await asyncio.sleep(random.uniform(0.05, 0.15))
cdp_send('Input.dispatchMouseEvent', {type: 'mouseReleased', ...})

5. 拟真键盘输入

# 逐字输入:每个字符 30-120ms 随机间隔
for char in value:
    cdp_send('Input.dispatchKeyEvent', {type: 'keyDown', key: char, ...})
    await asyncio.sleep(random.uniform(0.03, 0.12))
    cdp_send('Input.dispatchKeyEvent', {type: 'keyUp', key: char, ...})

调用示例

KEY='Bing2026Cao$$$'

# 1. Snapshot1.5秒完成,返回 @e ref 列表)
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"refresh": true}' http://127.0.0.1:9229/cdp/snapshot

# 2. 点击 @e ref
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"ref": "@e5"}' http://127.0.0.1:9229/cdp/click-ref

# 3. 填表 @e ref
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"ref": "@e3", "value": "遮阳伞", "clear": true}' http://127.0.0.1:9229/cdp/fill-ref

# 4. 等待元素
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"role": "button", "name": "提交", "timeout": 10}' http://127.0.0.1:9229/cdp/wait

Route B 已验证可用(2026-05-19 实测)

  • /cdp/snapshot 1700+元素,~1.5秒
  • /cdp/click-ref 贝塞尔轨迹+拟真点击,在新浪财经、1688等站点验证通过
  • /cdp/fill-ref 🔄 待验证

关键修复记录

  1. DOM.getDocument 前置要求:调用 DOM.pushNodesByBackendIdsToFrontend必须先调用 DOM.getDocument,否则报错 "Document needs to be requested first"。修复:新增 _ensure_dom_document() 函数,在所有 DOM.pushNodesByBackendIdsToFrontend 调用前自动调用。

  2. WS tunnel timeout 调优CDP 命令通过 WS tunnel 往返约 0.3s/次,Bézier 轨迹 10-16 步需 3-5s。将 timeout 从 5s → 10s,步数从 10-16 → 5-8,确保稳定不超时。

  3. AX 树无 bounds 数据Chrome 的 Accessibility.getFullAXTree 默认不返回 bounds 字段(与 Playwright 不同)。必须用 DOM.getBoxModel fallback 按需获取坐标。

  4. CDP session 不能手动切换/cdp/snapshot 使用服务器缓存的 cdp_session_id,通过 /cdp/send 手动 Target.attachToTarget 不会切换 snapshot 的目标 tab。需要用 Page.navigate 导航当前 tab,或重启 Cloud Bridge 让它重新发现 tab。

与服务器端 agent-browser 的对比

能力 Cloud Bridge Route B (纯CDP) agent-browser (Playwright)
DOM快照 Accessibility.getFullAXTree → @e ref 内置Accessibility树 → @e ref
点击 贝塞尔轨迹 + Input.dispatchMouseEvent Playwright mouse.click
填表 Input.insertText/逐字 + 拟真间隔 Playwright fill/type
中文输入 Input.insertText 原生支持
反爬安全 🟢 真实Chrome + 拟真轨迹 + 零注入 🔴 Playwright 标记 + 行为模式
截图 cdp/screenshot page.screenshot
Tab管理 Target.getTargets browser.pages()
最大区别 跨网络操控用户真实Chrome(无自动化标记) 操控服务器本地Chromium(有 webdriver 标记)

Desktop App Chat API 适配(⚠️ 必须修复)

Desktop App 的 agent:chat handler 当前发 POST /chat,但 Hermes API Server 只暴露 OpenAI 兼容端点:

Desktop 当前( 错误) Hermes API 实际( 正确)
端点 POST {apiUrl}/chat POST {apiUrl}/v1/chat/completions
请求体 { "message": "...", "stream": false } { "model": "hermes-agent", "messages": [{"role":"user","content":"..."}], "stream": false }
Headers 无认证 Authorization: Bearer <api_key>
响应 直接文本 { "choices": [{ "message": { "content": "..." } }] }

修复文件:desktop/src/main.js 第 121-127 行已修改为 OpenAI 格式(在用户电脑 /opt/atomk/atomk-page-bridge/desktop/src/main.js)。

ECONNRESET 间歇性断连排查

服务端验证正常(TCP可达/WS握手101/keepalive正常/无nginx/无重启),问题在 Desktop App 侧:

  • 用户网络代理/VPN/企业防火墙是否断开空闲 TCP?
  • axios 是否配置了 keepalive + 自动重试(推荐 2-3 次)?
  • 本地 Relay (3928) 是否间歇性崩溃?

Cloud Bridge 系统提示词(⚠️ v3.9.17 已修正)

v3.4.5-v3.9.16 的提示词有严重错误:教 Agent 用 browser_navigate/browser_type/browser_click 等 Hermes 内置工具,但这些操作服务器 headless Chromium,用户 Desktop 上看不到。结果 Desktop Chat "打开XX"完全没反应。

v3.9.17 修正PR #9, 2026-06-07):重写提示词,明确禁止 browser_navigate,引导 Agent 加载 bridge-cdp-agent skill 走 Bridge CDP API。代码:src/main/hermes.tsCLOUD_BRIDGE_SYSTEM_PROMPT

机制

当 Desktop 连接 Cloud Bridge 时,sendMessageViaApi() 在 messages 最前面注入系统提示词。未连接时不注入。

设计决策

  • 条件注入而非全局常驻:避免未连接时提示词占用 token
  • 注入在最前面:确保所有轮次都有系统提示词
  • v3.9.17+:提示词引导 Agent 加载 bridge-cdp-agent skill,走 Bridge CDP API(不再引用 browser_navigate

Ozon Seller SSO 邮箱登录(CDP 实操经验)

Ozon Seller 中国站 (seller.ozon.ru/seller/china/) 使用 Nuxt (Vue3) 框架渲染 SSO 登录页。常规 CDP click 和 JS click() 完全无效,需要特殊处理。

关键发现

  1. SSO 登录 URLhttps://sso.ozon.ru/auth/ozonid?localization_language_code=zh-hans&namespace=sc&token=...
  2. "使用邮箱登录"按钮Input.dispatchMouseEvent (mousePressed + mouseReleased) 有效,JS click() 无效
  3. 登录按钮误点:页面有多个 buttonidx:0 是 24×24 的清除图标,真正的"登录"按钮是 idx:1344×56),必须先 getBoundingClientRect 确认尺寸再点击
  4. Input.insertText 填邮箱有效Input.insertText 可以正确填入邮箱地址,但 nativeSetter + dispatchEvent 可能更可靠
  5. 邮箱输入后登录按钮点击:邮箱模式下登录按钮坐标变化,必须重新获取位置
  6. 验证码邮件:Ozon 发送"Подтверждение учетных данных Ozon"邮件到注册邮箱,含6位数字验证码

CDP 操作 Ozon SSO 流程

# 1. 导航到 Ozon Seller 中国站
cdp_post('/cdp/navigate', {'url': 'https://seller.ozon.ru/seller/china/'})

# 2. 等待页面加载 + 点击"登录"按钮(首页的登录按钮)
# 需要用 mousePressed + mouseReleased + JS click fallback

# 3. SSO 页面点击"使用邮箱登录"
# 获取按钮位置 → dispatchMouseEvent (mousePressed + mouseReleased)
btn_pos = get_btn_pos("使用邮箱登录")  # getBoundingClientRect
cdp_post('/cdp/send', {'method': 'Input.dispatchMouseEvent', 'params': {
    'type': 'mousePressed', 'x': btn_pos['x'], 'y': btn_pos['y'],
    'button': 'left', 'clickCount': 1, 'pointerType': 'mouse'
}})
cdp_post('/cdp/send', {'method': 'Input.dispatchMouseEvent', 'params': {
    'type': 'mouseReleased', 'x': btn_pos['x'], 'y': btn_pos['y'],
    'button': 'left', 'clickCount': 1, 'pointerType': 'mouse'
}})

# 4. 填入邮箱
cdp_post('/cdp/send', {'method': 'Input.insertText', 'params': {'text': 'smthzqjone@163.com'}})

# 5. 点击"登录"(注意:重新获取按钮坐标,因为页面已切换到邮箱模式)
# ⚠️ 必须确认按钮尺寸 > 100px(排除24x24的清除图标)
btns = get_all_buttons()  # 遍历 document.querySelectorAll("button")
# 选择 text.trim() === "登录" && width > 100 的那个

# 6. 验证码页面 (sso.ozon.ru/otp)
# Ozon 发送6位数字验证码到邮箱,需切到163邮箱提取
# ⚠️ 验证码有时效(约5-10分钟),需快速操作
# ⚠️ 如果验证码过期,点击"获取新验证码"按钮重新发送

# 7. 切换到163邮箱标签页提取验证码
# 用 Target.getTargets 找163标签 → attach → 刷新收件箱 → 找Ozon邮件 → 点击Flink → 读iframe
# 验证码在邮件正文iframe中,格式:"Для подтверждения ваших учетных данных используйте код: XXXXXX"

# 8. 输入验证码
cdp_post('/cdp/send', {'method': 'Input.insertText', 'params': {'text': 'XXXXXX'}})
# 自动提交或需点击确认按钮

163 邮箱读取邮件内容(CDP 跨域 iframe 技巧)

163 邮箱的邮件正文在跨域 iframe (readhtml.jsp?mid=...) 中渲染,必须用 Page.createIsolatedWorld 读取。

⚠️ 邮件列表点击陷阱

163 邮箱的邮件行不是标准链接,多种方式均无法触发打开:

  • 单击/双击邮件行 → 无反应
  • dispatchEvent (mousedown/mouseup/click) → 无反应
  • 163 内部 API (mail.163.com/js6/) → 返回空或乱码
  • XHR + cookies → Failed to fetch / 163.com cookies 为 0 个

成功方案:需要找到邮件行中的 <a> 链接子元素(AX 树中的 Flink),点击该链接元素而非整行。例如 Ozon 验证码邮件在 AX 树中显示为 @e99 [Flink] "Подтверждение учетных данных Ozon...",点击此 Flink 元素可打开邮件。

流程

# 1. 获取所有 frame
frames = cdp_post('/cdp/send', {'method': 'Page.getFrameTree'})

# 2. 找到邮件正文 iframeURL 含 readhtml.jsp
frame_id = None
for child in frames['result']['frameTree'].get('childFrames', []):
    if 'readhtml.jsp' in child['frame'].get('url', ''):
        frame_id = child['frame']['id']
        break

# 3. 创建隔离执行环境
world = cdp_post('/cdp/send', {
    'method': 'Page.createIsolatedWorld',
    'params': {'frameId': frame_id, 'worldName': 'mail_body', 'grantUniveralAccess': True}
})
ctx_id = world['result']['executionContextId']

# 4. 在 iframe 中读取邮件正文
result = cdp_post('/cdp/send', {
    'method': 'Runtime.evaluate',
    'params': {
        'expression': 'document.body.innerText',
        'contextId': ctx_id,
        'returnByValue': True
    }
})
mail_text = result['result']['result']['value']

163 邮箱关键信息

  • 收件箱模块 hashmbox.ListModule
  • 点击收件箱链接后需等待模块加载
  • 邮件列表通过 JS 操作(非标准链接),需要找到正确的 DOM 元素并点击
  • 邮件正文在 readhtml.jsp?mid=<邮件ID> 的跨域 iframe 中
  • "安全浏览模式"可能过滤邮件 HTML 内容,但 iframe 中 innerText 仍可获取完整文本

163 邮箱批量操作(标记已读 + 分页收集)

场景:331封未读邮件需要全部标记已读并收集摘要。

标记全部已读(DOM 操作三步曲)

# Step 1: 导航到收件箱
cdp_eval('window.location.hash = \'module=list.ListModule|{"filter":"inbox","folderId":1}\'')
time.sleep(3)

# Step 2: 点击全选 checkbox(第一个 checkbox 通常是"全选"
cdp_eval("""(() => {
    const cb = document.querySelector('.nui-chk, [class*=checkbox]');
    if (cb) { cb.click(); return 'clicked'; }
    return 'not found';
})()""")

# Step 3: 点击"标记为" → 点击"已读"
cdp_eval("""(() => {
    const btns = document.querySelectorAll('button, span, a, div');
    for (const b of btns) {
        if (b.textContent.trim() === '标记为' && b.clientHeight > 0) { b.click(); return 'clicked'; }
    }
    return 'not found';
})()""")
time.sleep(1)
cdp_eval("""(() => {
    const items = document.querySelectorAll('li, a, span, div');
    for (const item of items) {
        if (item.textContent.trim() === '已读' && item.clientHeight > 0) { item.click(); return 'clicked'; }
    }
    return 'not found';
})()""")

分页收集邮件摘要(snapshot + JS 翻页)

⚠️ 关键发现/cdp/click-ref 对"下一页"按钮经常超时(15s+),改用 /cdp/evaluate + JS 点击更可靠。

all_emails = []
for page in range(1, 20):  # 最多19页
    snap = api_call("/cdp/snapshot", {}, timeout=25)
    # 从 snapshot 提取邮件行(发件人、主题、日期)
    page_emails = parse_snapshot(snap)
    all_emails.extend(page_emails)

    # JS 点击"下一页"(比 click-ref 可靠得多)
    result = cdp_eval("""(() => {
        const btns = document.querySelectorAll('button, a, span');
        for (const b of btns) {
            if (b.textContent.trim() === '下一页' && b.clientHeight > 0) {
                b.click(); return 'clicked';
            }
        }
        return 'not found';
    })()""")
    if 'not found' in str(result):
        break
    time.sleep(2)  # 等待下一页加载

完整脚本模板

/tmp/163_v3.py(可复用模板),核心流程:

  1. location.hash 导航到收件箱
  2. 全选 checkbox → 标记为 → 已读
  3. 循环:snapshot 提取当前页邮件 → JS 点击下一页 → 重复
  4. 去重(subject + date 组合键)+ 分类统计

⚠️ Pitfalls

  • 不要用 click-ref 翻页:163 的分页按钮在 SPA 内,click-ref 经常超时。用 /cdp/evaluate + JS element.click() 替代
  • Shell 转义地狱:复杂 JS 通过 Python subprocess + curl 调用时,引号嵌套极其痛苦。直接写 Python 脚本/tmp/urllib.request 调 API,比 curl 可靠100倍
  • snapshot 解析用正则163 snapshot 中邮件行格式 @eN [Flink] "主题发件人XXX时间:XXX",用正则 发件人时间: 分割提取字段
  • snapshot 对 iframe 页面返回空163 邮箱主体在 iframe 中,/cdp/snapshot 可能返回空。但邮件列表通常在主 frame 的 AX 树中可见

Desktop App 双通道架构(≠ 钉钉 Stream)

Desktop App 有两条完全独立的通信通道,与钉钉的单一长连接模型不同:

通道 协议 作用 断连风险 Keepalive
Cloud Bridge WS WebSocket (Desktop ↔ 云端9228) CDP 命令隧道 有(网络波动/NAT超时) 已有 15s ping/pong + 45s 超时检测
聊天消息 HTTP POST /v1/chat/completions (Desktop ↔ Hermes API 8642) AI 对话 无(请求-响应式) 不需要

关键区别:钉钉用 DingTalk Stream SDK 维持单一 WebSocket 长连接接收服务端推送消息,连接断开时推送消息丢失在间隙。Desktop App 的聊天走 HTTP 请求-响应,不存在"断连丢消息"问题——用户按回车发请求,Agent 返回响应,无需保持连接。

因此:钉钉需要 cronjob 定时发 /status 保持 WS 活跃;Desktop App 不需要类似机制。

Desktop App "Agent 没反应" 的排查方向

  1. Hermes Agent 卡住(上游 LLM API 超时/错误)→ HTTP 请求挂着不返回 → Chat UI 显示 loading
  2. Cloud Bridge WS 断了 → Agent 无法操控浏览器(但聊天消息本身还能发)
  3. API Server 异常/health 检查失败

可选改进:Chat 加超时指示器(如 60s 无响应显示"Agent 思考较久…"按钮),让用户可中止等待。

Pitfalls

  • ⚠️ Desktop sendCDP 永久挂起 bugv3.9.22 已修复)chrome-bridge.tssendCDP 原用 new Promise(async (resolve) => {...}) 反模式,超时定时器在 await ensureCdpDirectConnection() 之后才设置——连接函数一旦 throwasync executor 吞掉异常、Promise 永不 settle,调用方无限等待(表现为 CDP 操作"卡死无响应无超时")。修复模式:改为普通 async 函数,try/catch 包裹连接 await 并返回 {error},只把发送+等待响应那段包进 Promise,ws.send 失败时也要 clearTimeout + 清队列。排查任何 Promise 封装代码时 grep new Promise(async
  • ⚠️ Cloud Bridge 认证唯一方式:Authorization: Bearer ***:经实测验证,X-Bridge-KeyX-API-KeyBasic Auth 等 header 全部返回 401。所有端点(包括 /cloud-bridge/health)都需要 Bearer 认证,不存在"无需认证"的端点。key 含 $$$shell curl 中 $$$ 会被变量展开导致失败,必须用 Python requests
  • ⚠️ 两个端口 9228/9229 都可访问 CDP 端点9228 是主服务 HTTP 端口,9229 是代理隧道。从 Agent 服务器可直接访问 9228(无需走代理),attach/evaluate/navigate 等都通过 ?targetId= query param 指定目标
  • CDP 模式需要 Chrome 启动参数 --remote-debugging-port=9222,普通用户不会加,需要引导或 Desktop App 自动重启 Chrome
  • SPA 页面(Temu/Amazon)需要 MutationObserver 而非只是 onLocationChange
  • 截图 base64 可能很大(几MB),Relay 需限制大小或压缩
  • 多 Tab 时只推送 active + currentWindow 的 Tab
  • Relay 无 Extension 连接时,Agent 工具应优雅降级返回提示
  • Extension 必须建立 WebSocket 连接,否则 extensionConnected 永远为 false、CDP 命令无法转发
  • ⚠️ 浏览器 WebSocket 不支持自定义 headernew WebSocket(url, protocols) 只有2个参数,第三个参数(options/headers)是 Node.js ws 库的 API,浏览器会静默忽略或报错导致 Service Worker 崩溃。Extension 必须通过连接后发送 {type: "extension_connect"} 消息体来标识身份,Relay 同时支持 header 和消息体两种方式识别
  • CDP 响应必须用 cdp_response + requestId,不是 cdp_result + id,否则 Relay 无法匹配 pending 请求
  • Extension 安装/重载后已打开标签页无 content_script,需要 chrome.scripting.executeScript 自动注入或刷新页面
  • 弹窗"已连接"≠ Relay 的 extensionConnected:前者基于 HTTP health 可达,后者基于 WS 连接状态
  • CDP 不可在 chrome:// 内部页面使用,必须切换到普通网页后再开启
  • ⚠️ v3.4.0及更早:启用CDP后Extension断连(v3.4.1已修复):根因是CDP浏览器启动时带--load-extension,其Extension也连ws://localhost:3928/ws覆盖了extensionWs单引用;CDP浏览器Extension断连后触发close事件清空extensionWs=null,原始Extension连接也被丢弃。v3.4.1修复:CDP浏览器不加载ExtensionCDP命令直连DevTools Protocol WS
  • CDP浏览器不需要Extension:它有--remote-debugging-port=9222提供完整的DevTools Protocol能力,加载Extension反而会造成WS连接冲突
  • CDP Input.insertText 中文编码问题:通过 /cdp/type 输入中文可能乱码或失败。解决方案:用 /cdp/evaluate 执行 JS,通过 nativeSetter.call(el, value) 设值 + dispatchEvent(new Event('input', {bubbles:true})) 触发 React/Vue change 检测。这在1688等SPA上验证可用
  • ⚠️ Snapshot 批量取 bbox 会超时:对 DOM.getBoxModel 逐个串行调用,1700+元素需要数分钟。Route B 方案:snapshot 只取 AX 树(1.5秒),bbox 在 click/fill 时按需取(单次<100ms
  • ⚠️ Desktop Relay /cdp/send 双序列化 bugRelay 返回 {"ok":true,"result":"{\"nodes\":[...]}}"}result 可能是 JSON 字符串而非 dict,导致 'str' object has no attribute 'get' 错误。必须在 cdp_send 中加防御性 json.loads
  • ⚠️ Browser-level CDP 命令不能带 sessionIdTarget.getTargetsTarget.attachToTarget 等浏览器级命令如果带了 sessionId 会被忽略或报错。必须区分 browser-level(无 sessionId)和 page-scoped(需 sessionId)命令
  • ⚠️ /cdp/evaluate 返回值三层嵌套:通过 Cloud Bridge 代理调用 /cdp/evaluate 时,返回结构为 {"ok": true, "result": {"result": {"type": "string", "value": "..."}}}。即实际值在 resp['result']['result']['value'] 路径下(Cloud Bridge 包装层 + CDP Runtime.evaluate 响应层 + RemoteObject 层)。写解析代码时务必注意这三级嵌套,不能只取 resp['result']。如果 JS 表达式返回 JSON 字符串,需要再 json.loads(value) 才能得到 Python 对象
  • ⚠️ _get_bbox_for_ref 可能返回 NoneDOM.pushNodesByBackendIdsToFrontend + DOM.getBoxModel 在某些元素上失败(如 display:none、off-screen、iframe 内、StaticText 内联节点无 bbox)。click/fill 前应先 scrollIntoViewIfNeeded,且必须有 fallback
  • ⚠️ 1688首页商品卡片 AX 树无可点击 link:1688首页推荐商品在 AX 树中商品名只有 StaticTextrole=InlineTextBox),没有包裹它们的 link 元素(虚拟列表/Shadow DOM 渲染)。无法通过 click-ref 点击商品名。正确做法:去店铺页(shop*.1688.com)获取标准 /offer/ 链接,或用 JS fallback document.querySelectorAll('a[href*="/offer/"]') 提取链接后 Page.navigate 直接访问
  • ⚠️ AX 树文本含控制字符破坏 JSONAX 节点的 name/value 可能含 tab(\t)、newline(\n)、零宽字符等,导致 json.dumps() 生成的 JSON 在客户端解析失败。必须在 snapshot 返回前 _sanitize(text) 清洗所有控制字符
  • ⚠️ Cloud Bridge server.py 双副本/home/ubuntu/atomk-page-bridge/cloud-bridge/server.py/home/ubuntu/.hermes/cloud-bridge/server.py 是两份。运行的是 .hermes/cloud-bridge/ 下的。修改后必须 cp 同步两份,否则重启后改动不生效
  • ⚠️ 服务端重启流程kill $(pgrep -f 'server.py.*cloud-bridge')cp /home/ubuntu/atomk-page-bridge/cloud-bridge/server.py /home/ubuntu/.hermes/cloud-bridge/cd /home/ubuntu/.hermes/cloud-bridge && python3 -u server.py --key 'Bing2026Cao$$$' --ws-port 9228 --proxy-port 9229(后台运行)。验证:curl -s http://127.0.0.1:9229/cloud-bridge/health
  • ⚠️ 之前只改运行时内存没写文件:用 Python 在运行中的 server.py 进程改参数(如 ping_timeout 10→30),进程重启后改动丢失。必须修改源文件并重启
  • 1688搜索框SPA拦截1688首页搜索框 #alisearch-input 填词后按 Enterform submit 不跳转(SPA拦截了submit事件)。解决方案:找到搜索按钮的 @e ref 直接 click,或用 /cdp/navigate 直接导航到搜索结果URL(但搜索页容易触发验证码)
  • Ozon DOM无标准属性Ozon.ru不使用data-testid等标准属性,价格/评分等无法用CSS选择器提取。可靠方案:搜索innerText中含(U+8381)的行提取价格,用正则匹配\d+\.\d+\s*[•·]\s*\d+\s*отзыв提取评分
  • ⚠️ Vue/Nuxt 按钮 JS click() 无效Ozon SSO (Nuxt框架) 的按钮对 JS element.click() 无响应,DOM.dispatchEvent 也无效。必须用 Input.dispatchMouseEvent (mousePressed + mouseReleased)。点击前用 getBoundingClientRect 获取精确坐标
  • ⚠️ 多按钮误点Ozon SSO 页面 document.querySelectorAll("button")[0] 可能是 24×24 的图标(如清除按钮),真正的操作按钮是 idx:1+。点击前务必检查按钮尺寸width < 50 的大概率不是目标按钮
  • ⚠️ Ozon SSO 邮箱模式切换后按钮坐标变化:从手机号切换到邮箱模式后,所有按钮位置重新排列,必须重新 getBoundingClientRect 获取坐标,不能用切换前的位置
  • ⚠️ 163 邮件正文在跨域 iframe 中readhtml.jsp?mid=... 是跨域 iframe,主页面 JS 无法访问。必须用 Page.getFrameTree 找 frameId → Page.createIsolatedWorld 创建隔离上下文 → Runtime.evaluate + contextId 读取。直接在主页 innerText 只能看到邮件标题列表
  • ⚠️ 163 邮件行点击无效:邮件整行不是标准链接,单击/双击/dispatchEvent 均无反应。必须点击邮件行内的 <a> 子元素(AX 树中的 Flink 角色),而非整行容器
  • ⚠️ /cdp/snapshot 对 iframe 重度页面返回空:163 邮箱等页面大量使用跨域 iframeAX 树 snapshot 返回 {refs: {}, url: "", title: ""} 完全为空。必须用 /cdp/evaluate + Page.getFrameTree + Page.createIsolatedWorld 手动提取
  • ⚠️ contextId 过期极快Page.createIsolatedWorld 返回的 executionContextId 在几秒内可能失效(尤其 iframe 仍在加载时)。必须在同一次操作链中获取 contextId 后立即使用,不要缓存复用。如果 Runtime.evaluate + contextId 返回空结果,重新 Page.getFrameTreecreateIsolatedWorld 获取新 ID
  • ⚠️ Frame ID 会过期Page.getFrameTree 返回的 frame ID 在页面导航、iframe 重载后会变化。缓存旧的 frame ID 调 createIsolatedWorld 会报 "No frame for given id found"。每次操作前都应重新获取 frame tree
  • ⚠️ /cdp/click-ref 经常超时:对 SPA 页面(163邮箱、Ozon等)的分页/导航按钮,click-ref 超时概率很高(15s+无响应)。根因可能是 AX 树 ref 定位后 DOM.getBoxModel 慢或元素不在视口。可靠替代:用 /cdp/evaluate 执行 JS element.click(),速度更快且不会超时。只在需要拟真轨迹(反爬场景)时才用 click-ref
  • ⚠️ 多账号163自动登录冲突Chrome profile 可能保存了另一个163账号的 session cookie,访问 mail.163.com 会自动登录到错误账号(如 smthzqjtwo 而非 smthzqjone)。必须先通过 Network.getAllCookies + Network.deleteCookies 清除所有 163.com cookie + localStorage.clear(),再导航才能得到干净的登录页。
  • ⚠️ 绝对禁止在 /cdp/evaluate 中使用同步 XMLHttpRequestnew XMLHttpRequest(); xhr.open('POST', url, false) 会永久阻塞 Chrome 标签页的 JS 线程!后续所有 cdp/evaluatecdp/navigate(需页面JS响应)全部超时。即使 cdp/detach + cdp/attach 也无法恢复(旧执行上下文仍在阻塞)。唯一恢复方式:/cdp/send + Page.reload(CDP 协议命令不走JS线程,可以强制刷新页面恢复)。刷新后需等待 ~15秒 让页面完全加载后再操作。正确替代:用页面内 async fetch() 或从页面提取 Cookie+SID 后在服务器端用 curl 调 API。
  • ⚠️ 复杂 CDP 操作不要用 curl:多层引号嵌套(Python→shell→curl→JSON→JS)极易出错。写 Python 脚本到 /tmp/requestsurllib.request 直接调 API,可读性和可靠性都更好。推荐 requestspip install requests),params= 自动 URL 编码、json= 自动序列化,完全避免 shell 转义地狱:
    import requests, json
    KEY = 'Bing2026Cao$$$'
    BASE = 'http://127.0.0.1:9229'
    TID = ''  # 从 /cdp/attach 获取
    
    def cdp_post(path, data=None, params=None, timeout=20):
        r = requests.post(f'{BASE}{path}', json=data,
            params={"key": KEY, "targetId": TID, **(params or {})}, timeout=timeout)
        return r.json()
    
    def cdp_eval(expr):
        r = cdp_post('/cdp/evaluate', {'expression': expr})
        return r.get('result', {}).get('result', {}).get('value', '')
    
    注意requestsparams={"key": KEY} 会自动对 $ 做 URL 编码(→ %24),而 curl/shell 中 $ 会被变量展开导致 Unauthorized。如果必须用 curl,需手动 urllib.parse.quote(KEY, safe='') 编码后拼 URL。
  • ⚠️ 同域导航 location.href 不会断 CDPCloud Bridge 文档建议用 window.open() 避免导航断连,但实测同域名内(如 Ozon Seller 内切换筛选器 ?filter=partial_approved)用 location.href = url 完全安全,CDP 不断连。只有跨域导航(如从163邮箱跳到Ozon)才需要 window.open()/cdp/navigate 三步法。
  • ⚠️ /cdp/evaluate 长字符串返回空值:复杂 JS 表达式返回较长字符串时,Cloud Bridge 代理可能返回空 ""。根因:CDP 序列化长字符串时截断或 Cloud Bridge 中间层丢失。解决方案(1) 用 IIFE (function(){...})() 包裹而非裸表达式 (2) 大数据存到 window.__tempVar,分段 substring() 取回 (3) 用 /cdp/send + Runtime.evaluate + returnByValue:true 绕过 Cloud Bridge evaluate 端点
  • ⚠️ 163 邮件正文 innerText 被 CSS 污染readhtml.jsp iframe 中 innerText 会包含大量 CSS media query 文本。正确做法innerHTML → 正则删除 <style>...</style><script>...</script> → 正则删除所有 HTML 标签 → &nbsp;/&amp; 实体替换 → 压缩空白。示例:
    # 在 iframe isolated world 中执行
    js = '''
    (function() {
      var body = document.body;
      var html = body.innerHTML;
      var cleaned = html.replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, '');
      cleaned = cleaned.replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, '');
      cleaned = cleaned.replace(/<[^>]+>/g, ' ');
      cleaned = cleaned.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&');
      cleaned = cleaned.replace(/\\s+/g, ' ').trim();
      window.__emailText = cleaned;
      return cleaned.substring(0, 2000);
    })()
    '''
    part1 = cdp_eval_in_iframe(js, ctx_id)  # 第一段
    part2 = cdp_eval_in_iframe('window.__emailText.substring(2000, 4000)', ctx_id)  # 后续段
    
  • ⚠️ 163 邮箱分页 UI 细节:分页下拉框 class 是 js-component-select,选项链接 class 是 nui-menu-item-link,选项文本格式 1/192/19 等。必须先点击下拉框展开,再点击选项链接。直接搜索选项链接点击无效(下拉未展开时选项不可见)
  • ⚠️ Ozon 验证码有时效:6位验证码约5-10分钟有效。如果提示"代码不正确",需点击"获取新验证码"重新发送,再去163邮箱读取新验证码。验证码倒计时页面会显示剩余时间
  • ⚠️ /cdp/navigate 端点返回500v3.4.3及更早):原实现单行 Page.navigate 在跨域导航时 CDP session 崩溃导致 500。v3.4.4 修复为三步:① Page.enable 启用事件域 → ② 注册 Page.loadEventFired 监听 → ③ Page.navigate → ④ 等待 loadEventFired(15s超时兜底)。临时替代方案:用 /cdp/send + Page.navigate 绕过
  • CDP attach可能连到openerId页面:对于从其他页面打开的子标签页,attach其targetId时实际可能连到父页面。解决:attach后用/cdp/navigate导航到目标URL,或在evaluate中检查location.href确认
  • ⚠️ stopCdpBrowser() 不重置外部浏览器状态(v3.6.4及更早):当 startCdpBrowser() 检测到 9222 端口已有 CDP 响应时,设置 cdpBrowserRunning=truecdpBrowserProcess 仍为 nullstopCdpBrowser()!cdpBrowserProcess 处直接 return 不重置状态 → UI 卡在 "Stop Browser" 永远切不回 Start。修复:null check 分支中检测并重置 cdpBrowserRunning/cdpEnabled/cdpBrowserPath/cdpBundled 四个状态字段 + notifyState()
  • ⚠️ electron-builder 不支持4位版本号package.jsonversion 必须是 semver 三段式 major.minor.patch,填 3.6.4.1 会报 Invalid version 错误导致 build 失败。小更新应升 patch 号(如 3.6.5
  • ⚠️ build/icon.ico 与 build/icon.png 可能不同步:更新品牌图标时,PNG(用于Linux/Mac)和 ICO(用于Windows标题栏/exe图标)必须同步重新生成。只更新 PNG 忘记重新生成 ICO,Windows 标题栏图标仍显示旧图标
  • ⚠️ Hermes 安全拦截器会拦截 shell 明文 tokenterminal() 中带 Authorization: Bearer xxx?key=xxx 的 curl 命令会被拦截返回 401。必须改用 execute_code 在 Python 中 import requests 发请求,或用 urllib.request。此规则已写入 Cloud Bridge 系统提示词第 7 节(v3.6.3+),远端 AI 也会遵守
  • ⚠️ relayRunning 状态会变臭(stalestate.relayRunning 只在 server.listen() 回调时设为 truechrome-bridge.ts 第1035行),此后即使 Express HTTP 服务因未捕获异常/端口冲突/事件循环阻塞导致无法响应请求,状态永远保持 true。前端 fetch localhost:3928 失败就抛原生 "Failed to fetch" 无任何上下文。修复(v3.6.8+):①主进程新增每8秒 ping /health3秒超时),检测失败自动标记 relayRunning=false,恢复后自动标回 true;②前端替换3处直接 fetch localhost:3928relayFetch() 辅助函数,内置2次重试(500ms/1000ms递增)+8秒超时 + 中文友好错误 Relay (port X) 无响应 — 请尝试重启 Relay Server。
  • ⚠️ 前端 CDP 操作优先用 IPC 而非 relayFetchCookie 提取(Network.getCookies)、Cookie 恢复(Network.setCookie)等 CDP 操作,如果走前端 relayFetch(/cdp/send) → Relay HTTP → sendCDP(),一旦 Relay HTTP 挂了就全部失败(用户跑了1天没看到cookie的原因)。正确做法(v3.6.8+):通过 IPC 调主进程的 sendCDP() 直接走 CDP WebSocket,完全绕过 Relay HTTP。新增 chrome-bridge:get-cookies / chrome-bridge:set-cookie IPC handlerpreload 暴露 window.hermesAPI.chromeBridgeGetCookies() / chromeBridgeSetCookie()。前端 handleLoadCookies() 和 Restore 按钮都改用 IPC。
  • ⚠️ 自动提取条件不要依赖 cloudState:旧版 Auto-Save cookie 定时器条件是 cdpActive || cloudConnected,本地模式用内置浏览器时 cloudConnected 永远 false,导致定时器空转什么都不做。修复(v3.6.8+):自动提取只需判断 cdpEnabled || cdpBrowserRunning,不再依赖 Cloud Bridge 连接状态。
  • ⚠️ 主动进程定时提取 cookie 模式:在 chrome-bridge.tsstartRelay()server.listen 回调中启动 setInterval(10分钟),每次判断 CDP 可用后调 extractCookiesViaCDP()sendCDP('Network.getCookies'),结果写入 state.lastCookieCount / lastCookieExtractAt + notifyState()。首次提取延迟5秒等待 CDP browser 就绪。stopRelay 时 clearInterval(cookieExtractTimer)
  • ⚠️ 新增主进程模块必须在 index.ts 中注册 IPC handler:创建 src/main/xxx.ts 后,必须在 src/main/index.tsimport 并注册全部 ipcMain.handle() 调用,否则渲染进程调用 window.hermesAPI.xxx() 会报 "No handler registered" 错误。同时要更新 src/preload/index.d.ts(类型声明)和 src/preload/index.ts(API 暴露)三处同步。漏任何一处前端都调不通。
  • 客户端标识显示架构Cloud Bridge 注册时 platform 字段用 ${process.platform}-${process.arch}(如 win32-x64darwin-arm64),而非纯 process.platform(只显示 win32,无法区分32/64位)。修改位置:chrome-bridge.tsws.send(JSON.stringify({type:'register', info:{...platform...}}))

Cloud Bridge WS 重连循环修复(v3.4.3

根因(两阶段发现)

第一阶段:服务端 register 处理器不回复确认 → 客户端无 registered 状态 → 连接超时被踢 → 无条件重连循环

第二阶段(真正的根因)Desktop App 注册成功后又开第二条 WS 连接 → 服务端单连接锁拒绝 (4001) → 客户端把拒绝当致命错误 → 又重连 → 又被拒 → 循环,最终放弃重试。 日志表现:17:44:10 连上+注册成功 → 17:44:13 又开第二条→被拒(4001) → 17:44:18 第三条→被拒 → ... → 17:44:30 主连接也断 → 20分钟无声无息

为什么开第二条? connectCloudBridgeoldWs.close() 是异步的(只发起关闭,不等待 TCP 真正断开),紧接着 new WebSocket() 立刻创建新连接。此时旧连接还在,服务端同时看到两条连接。

修复内容(4个文件)

1. 服务端 server.py(行964-975register 处理器新增回复确认消息

elif msg_type == 'register':
    client_meta[client_id].update(data.get('info', {}))
    # ✅ 新增:发送注册确认
    await websocket.send(json.dumps({
        'type': 'registered',
        'status': 'ok',
        'client_id': client_id
    }))

2. 客户端 chrome-bridge.ts5个关键修复

  • cloudBridgeConnecting:防止并发调用 connectCloudBridge 创建多条连接
  • 等待旧 WS 真正关闭oldWs.close() 后监听 close 事件,等旧连接断开再开新连接(2s 安全超时兜底)
  • 4001 不重试:收到服务端拒绝码 4001 时清除 config,不再重连循环
  • WS 引用安全:回调中用 cloudBridgeWs === ws 比较而非 cloudBridgeWs &&,防止旧 WS 回调影响新连接
  • registered 消息处理 + 25s 应用层心跳 + cloudBridgeIntentionalClose 标志(同前)

3. UI ChromeBridge.tsx:状态可视化

  • 三色状态徽章:🟢 Registered / 🟡 Registering... / 🟠 Reconnecting(N) / 🔴 Disconnected

4. v3.4.3 其他改动

  • electron-log 日志系统import log from 'electron-log/main' + log.initialize() + Object.assign(console, log.functions) — 所有 console 自动写文件,5MB 轮转
  • UI 日志查看器ChromeBridge 页面底部终端风格日志面板,5秒自动刷新
  • WS 服务器地址默认留空cloudServerUrl 默认值改为空字符串,不再硬编码服务器地址
  • 起始画面版本号__APP_VERSION__ 动态注入,改 package.json 即可

WS 连接管理关键变量

let cloudBridgeConnecting: boolean = false  // 防并发连接锁
let cloudBridgeHeartbeatTimer: ReturnType<typeof setInterval> | null = null
let cloudBridgeIntentionalClose: boolean = false  // true = user clicked disconnect

心跳策略(v3.4.4+

服务端 server.py 和 Desktop App 的心跳参数需配合:

  • 服务端ping_interval=20s, ping_timeout=30s(原10s太短,跨网络偶尔丢包就断)
  • ⚠️ Pong Race 根因ping_interval 必须 ≤ 20s。若设 25-45s,客户端 45s pong 窗口内只能收到 1 次 WS pong,一旦该次丢包,客户端判定死亡主动 close(4000, 'pong timeout')。缩短到 20s 确保窗口内 ≥2 次 pong,容错1次丢包。
  • Desktop App:应用层 ping 15s(与 WS 层 20s 错开,避免同时发送),lastPongTime 追踪,45s 无 pong 主动重连
  • 重连退避:指数退避上限 15s(原 30s 太慢恢复)
  • CDP session grace period:服务端收到 WS close 后延迟清除 CDP session(避免闪断导致 CDP 状态立即丢失),闪断重连后 CDP 操作可无缝恢复
  • /cdp/navigate 三步修复:原来单行 Page.navigate 在跨域导航时 CDP session 崩溃返回 500。新实现三步走:① Page.enable 启用事件域 → ② 注册 Page.loadEventFired 监听 → ③ Page.navigate 导航 → ④ 等待 loadEventFired15s 超时兜底)。返回 {ok, result, loadEvent} 结构,失败时 {ok:false, error} 而非 500

等待旧连接关闭的模式

// 在 connectCloudBridge 开头
if (cloudBridgeConnecting) return  // 并发锁
cloudBridgeConnecting = true

// 关闭旧连接时等待真正断开
if (cloudBridgeWs) {
  await new Promise<void>((resolve) => {
    const oldWs = cloudBridgeWs!
    const timeout = setTimeout(() => resolve(), 2000)  // 2s 安全超时
    oldWs.once('close', () => { clearTimeout(timeout); resolve() })
    cloudBridgeIntentionalClose = true
    oldWs.close()
    cloudBridgeWs = null
  })
}
cloudBridgeConnecting = false
// 现在安全地创建新连接

4001 拒绝不重试

cloudBridgeWs.on('close', (code, reason) => {
  if (code === 4001) {
    // 服务端单连接锁拒绝 → 清除配置,不再重试
    cloudBridgeConfig = null
    return
  }
  // 正常重连逻辑...
})

electron-log 集成细节(v3.4.3+

// src/main/index.ts
import log from 'electron-log/main'
log.initialize()
Object.assign(console, log.functions)  // 零代码改动:所有 console.log 自动写文件
// 配置轮转
log.transports.file.maxSize = 5 * 1024 * 1024  // 5MB

IPC 日志 API

// 主进程
ipcMain.handle('getLogs', (_, tail = 200) => log.transports.file.readLog()?.split('\n').slice(-tail).join('\n'))
ipcMain.handle('getLogPath', () => log.transports.file.getFile().path)
ipcMain.handle('clearLogs', () => { /* truncate file */ })

// preload
contextBridge.exposeInMainWorld('api', {
  getLogs: (tail) => ipcRenderer.invoke('getLogs', tail),
  getLogPath: () => ipcRenderer.invoke('getLogPath'),
  clearLogs: () => ipcRenderer.invoke('clearLogs'),
})

UI 日志查看器(ChromeBridge.tsx):终端风格深色面板 + 5秒自动刷新 + 清空按钮。

日志文件位置

  • Windows: %APPDATA%\atomk-desktop\logs\main.log
  • 自动轮转:5MB 上限,超限归档为 main.old.log

故障排除(基于真实使用经验)

扩展未连接(extensionConnected: false

  1. 确认 Relay 正在运行:终端应显示 Relay server listening on port 3928
  2. 检查端口占用:netstat -ano | findstr :3928 应仅有一个 LISTENING。
  3. 确认扩展已加载:在 chrome://extensions 中找到名称为 "Hermes Page Bridge" 的扩展并确保已启用(ID 不固定,随加载路径变化)。
  4. 打开扩展弹出窗口,应看到 "已连接"(绿色)。若显示未连接:
    • 暂时关闭广告拦截器、VPN 或其他可能阻止 http://localhost:3928 请求的插件。
    • 重新加载扩展(在扩展管理页点击刷新图标)。
    • 关闭并重新打开 Chrome(确保无后台进程)。
  5. 若弹窗显示"已连接"但 health 返回 extensionConnected: false
    • 这说明 HTTP 通了但 WebSocket 没建立。检查 background.js 是否有 WS 连接逻辑。
    • 打开 Chrome DevTools → Service Worker → Console,看是否有 [Hermes] WS connected to Relay 日志。
    • 如果没有 WS 连接代码,需要更新 background.js 添加 WS 客户端。
  6. 若仍无法连接,检查防火墙是否阻止了 localhost:3928(通常不需要,但在企业环境中可能需要放行)。

没有页面快照(No page snapshot available

  1. 扩展仅在以下情况自动推送快照:
    • 切换 Tab(激活另一个标签页)
    • 页面 URL 发生变化(导航或刷新)
    • SPA 页面通过 MutationObserver 检测到 DOM 变化
  2. 因此,只需在 Chrome 中切换到另一个标签页再切回来,或刷新当前页面即可触发快照推送。
  3. 若 Relay 日志显示 Extension connectedconnectedTabs 为空且无快照,说明扩展已连接但尚未推送,执行上述切换或刷新即可。
  4. ⚠️ 关键Bug(v3.0.1-v3.2.0已修复于v3.2.1)Extension 的 content_script.js 通过 POST /page-snapshot 推送快照,但 Relay 只注册了 POST /push-snapshot 路由 → 所有快照全部 404 修复:Relay 必须同时监听 /push-snapshot/page-snapshot 两个路径。
  5. ⚠️ 关键Bug(v3.0.1-v3.2.0已修复于v3.2.1)Extension 通过 WebSocket 发送 {type:"extension_connect"} 消息,但 Relay 只处理 {type:"tab-update"}connectedTabs 永远为空。修复:Relay 必须同时处理 extension_connect 消息类型。
  6. ⚠️ 数据丢失Bug(v3.0.1-v3.2.0已修复于v3.2.1)Extension POST body 包含 text/html/screenshot/meta/selection 字段,但 Relay 只存了 content 字段,其余全部丢弃。修复:Relay 的 PageSnapshot 接口和存储逻辑必须包含所有字段。

提取失败 "Receiving end does not exist"

根因Extension 安装/重新加载后,已打开的标签页没有 content_script 注入。manifest 的 content_scripts 只对新打开页面生效。 修复background.js 中 ensureContentScript() 会先 ping,失败则用 chrome.scripting.executeScript 自动注入。确保 manifest.json 有 scripting 权限。 临时解决:刷新目标页面即可触发 content_script 自动注入。

extensionConnected: false 但弹窗显示"已连接"

根因Extension 弹窗的"已连接"基于 HTTP /health 可达,但 Relay 的 extensionConnected 由 WebSocket 连接状态 (extWs?.readyState === 1) 决定。两者是不同概念。 修复background.js 必须建立 WebSocket 连接到 ws://localhost:3928/ws,连接后发送 {type:"extension_connect"} 消息标识身份。⚠️ 浏览器 WebSocket 不支持自定义 header,不能像 Node.js ws 库那样传第三个参数(会导致 Service Worker 崩溃),必须通过消息体标识。Relay 同时支持 header(供 Node.js 客户端)和消息体(供浏览器 Extension)两种识别方式。 验证:浏览器访问 http://localhost:3928/health,检查 extensionConnected 字段。

CDP 操控模式无法启用

v3.4.1+CDP 命令直连 DevTools Protocol,不需要 Extension 参与。确保:

  1. CDP 浏览器已启动(cdpBrowserRunning: true
  2. Chrome 带了 --remote-debugging-port=9222 参数
  3. http://localhost:9222/json/list 返回 target 列表
  4. Relay 的 ensureCdpDirectConnection() 能自动建立 WS 连接

注意CDP 不可在 chrome:// 内部页面上使用,必须切换到普通网页。

v3.4.0 及更早(已废弃)CDP 命令通过 Extension 的 chrome.debugger 中转,需要 WS 连接。没有 WS 连接时 /cdp/send 无法转发。

Desktop App 内置浏览器 Extension 不加载 / Page Snapshot 为空

根因(v3.0.1-v3.2.0):三个 Bug 叠加导致快照永远传不回:

  1. API路径不匹配Extension content_script.jsPOST /page-snapshot,但 Relay 只注册了 POST /push-snapshot → 所有快照 404
  2. WebSocket 消息类型不识别Extension 发 {type:"extension_connect"}Relay 只处理 {type:"tab-update"} → connectedTabs 永远空
  3. 数据字段丢失Extension 发 text/html/screenshot/meta/selectionRelay 只存 content → 快照数据不完整

修复(v3.2.1+)

  • Relay 同时监听 /push-snapshot/page-snapshot
  • Relay 处理 extension_connect 消息类型
  • PageSnapshot 接口加 selection/meta/screenshot 字段

内置浏览器 Extension 断连的另一个根因--user-data-dir 创建空 profile,用户已安装的扩展(含 Relay 连接的那个)不会出现在空 profile 里。v3.2.1 改为复用用户真实 Chrome profile。

内置浏览器启动策略(v3.2.1+

  • 不再用 --user-data-dir 创建空 profile(丢失所有用户扩展/书签/登录)
  • 改为:杀掉现有 Chrome → 用用户真实 profile + --remote-debugging-port=9222 + --restore-last-session 重启
  • 这样用户的扩展(包括连接 Relay 的那个)会自动加载
  • ⚠️ startCdpBrowser() 中 "CDP browser already running" 的 early return 会跳过扩展加载逻辑,必须确保扩展已加载才返回 success

relayRunning 显示 Running 但实际 "Failed to fetch"

根因state.relayRunning 仅在 server.listen() 回调时设为 truechrome-bridge.ts 第1035行),此后永不更新。当 Express HTTP 服务不可达时(事件循环阻塞、未捕获异常等),前端直接 fetch localhost:3928 抛原生 TypeError "Failed to fetch"。 修复(v3.6.8+):双管齐下——

  1. 主进程健康检查chrome-bridge.ts):在 server.listen 回调后启动 setInterval8秒),每次 fetch http://127.0.0.1:{port}/health3秒超时)。连续失败则 state.relayRunning=false + notifyState(),恢复后自动标回 true。这样 Settings 页面状态指示器会真实反映 Relay 是否可用。
  2. 前端 relayFetch 重试ChromeBridge.tsx):新增 relayFetch(path, options) 辅助函数,替换3处直接 fetch http://localhost:3928/... 调用。内置 2次重试(递增间隔 500ms/1000ms)+ 8秒超时 + 失败后中文友好错误提示 Relay (port X) 无响应 — 请尝试重启 Relay Server。
// relayFetch 辅助函数模式
async function relayFetch(path: string, options?: RequestInit, retries = 2): Promise<Response> {
  const port = relayPort || 3928;
  for (let i = 0; i <= retries; i++) {
    try {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), 8000);
      const resp = await fetch(`http://localhost:${port}${path}`, {
        ...options,
        signal: controller.signal,
      });
      clearTimeout(timer);
      return resp;
    } catch (e) {
      if (i === retries) {
        // 刷新 relayRunning 状态避免过时
        window.electronAPI?.getCBridgeState?.();
        throw new Error(`Relay (port ${port}) 无响应 — 请尝试重启 Relay Server。`);
      }
      await new Promise(r => setTimeout(r, 500 * (i + 1))); // 递增重试间隔
    }
  }
  throw new Error('unreachable');
}

CDP 操作失败(Could not establish connection. Receiving end does not exist.

  1. CDP 工具(如 cdp_click, cdp_navigate 等)要求 Chrome 带参数启动:
    chrome.exe --remote-debugging-port=9222
    
  2. 可创建快捷方式或使用桌面应用自动重启 Chrome。
  3. 确认只有一个 Chrome 实例在使用该调试端口:关闭所有 Chrome 窗口后仅用带参数的方式启动一个新窗口。
  4. 测试方法:访问 http://localhost:9222/json,应返回一个 JSON 列表(包含当前 Tab 的描述信息)。

桌面应用

  • Hermes Desktop v2.1.0 提供 NSIS 一键安装包(104MB),安装后 extension/ 和 relay/ 在 resources/ 目录下直接可见。
  • Desktop App 内嵌了 RelayExpress+WS 在 Electron 主进程中运行),同时也有独立的 relay/ 目录可手动启动。
  • 仅读取页面(路径ARelay + Extension 即可,无需 Desktop App。
  • 需要写入操作(路径B:必须启用 CDP 模式(Chrome --remote-debugging-port=9222)。
  • Desktop App 主要作用是桥接本地 Relay 和远程 Agent API;本地环境可直接用 Relay。
  • 构建注意electron-builder 打包 NSIS 时,若 setup.exe 只有几百KB说明打包失败(正常应80-110MB)。需检查 win-unpacked/ 目录是否完整、extraResources 是否正确配置。