1492 lines
95 KiB
Markdown
1492 lines
95 KiB
Markdown
---
|
||
name: hermes-chrome-bridge
|
||
description: Chrome Extension + 本地Relay + Electron Desktop App — 让Agent读取/操控客户Chrome浏览器。A+B混合架构(Extension只读+CDP操控),Desktop App当桥梁连接本地Relay和远程Agent API。
|
||
version: 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 本地操控
|
||
|
||
## 项目位置与下载
|
||
|
||
- 本地源码: `~/hermes-chrome-bridge/` (独立Bridge项目)
|
||
- **Desktop 内嵌 Extension 源码**: `/home/ubuntu/AtomK-Desktop/resources/extension/` (manifest/background/content_script/popup/icons 共9文件,名称 "Hermes Page Bridge")
|
||
- **Extension 公开下载 (zip, ~36KB)**: `https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/atomk-desktop/releases/atomk-chrome-extension.zip` — 已挂到 us1.atomk.cn 下载页(page 23, "Chrome 浏览器扩展"区块)和 WooCommerce 产品25下载列表。更新流程: `cd resources/extension && zip -r /tmp/atomk-chrome-extension.zip .` → `coscmd upload` 同名覆盖即可,页面链接无需改
|
||
- **Gitea 仓库**: https://gitea9webs.sh3.ikuai7.com/admin9webs/hermes-page-bridge (main分支,Extension更新务必git push)
|
||
- Desktop App源码: `~/hermes-desktop/` (fathah/hermes-desktop,集成Bridge)
|
||
- 打包: https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/hermes-chrome-bridge.tar.gz
|
||
- PowerShell 一键启动脚本: https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/tools/hermes-chrome-bridge/start-relay.ps1
|
||
- Chrome Extension ID: 会随加载路径变化(如 `dieaelepdobknmhmioidicdblfdfncha`、`fcihgcfchiomkfbgemnemenekodmoflo`),不固定。识别靠名称 "Hermes Page Bridge"
|
||
|
||
## 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.ts`(6个 IPC handler),preload 3处注册,30秒自动刷新
|
||
- **Relay 健康检查**:8秒定时 ping `/health`,检测失败自动标记 `relayRunning=false`,恢复后标回 `true`
|
||
- **前端 relayFetch 重试**:2次重试(递增间隔)+ 8秒超时 + 中文友好错误
|
||
- **客户端标识**:`win32` → `win32-x64` 格式
|
||
- **修复 Sessions.tsx**:`Trash2` 图标导入错误(预存 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 页面 i18n(6语言)+ 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:win` → `dist/atomk-desktop-3.5.1-setup.exe` → `coscmd 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.ts` 的 `sendMessageViaApi` 函数中,通过 `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.tsx` 的 `chat-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` 导航 → ④ 等待 loadEventFired(15s 超时兜底)。返回 `{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.log`,5MB轮转
|
||
- **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.ts` 中 `AttachmentInfo` 缺少 `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个关键Bug**:Extension 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 一键脚本)
|
||
|
||
```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 下发:
|
||
```json
|
||
{ "type": "tool_call", "tool": "extract_current_page", "args": {"mode": "text"}, "call_id": "call_abc123" }
|
||
```
|
||
|
||
### Desktop App → Agent(结果回传)
|
||
POST 回 API:
|
||
```json
|
||
{ "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 连接到 Relay**:`ws://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}` 给 Extension,Extension 用 `chrome.debugger.sendCommand` 执行后将结果通过 WS 回传 `{type:"cdp_response", requestId, success, result/error}`。注意:字段名必须是 `requestId`(不是 `id`)和 `cdp_response`(不是 `cdp_result`),否则 Relay 无法匹配 pending 请求
|
||
|
||
### content_script 自动注入机制(关键修复)
|
||
|
||
Extension 安装/重新加载后,已打开的标签页不会自动注入 content_script(manifest 的 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 Server,Agent 通过云端转发请求到 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 次 pong(20s间隔 × 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>:8642`(OpenAI兼容 `/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 消息:
|
||
```typescript
|
||
// ❌ 错误:发了 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` 是旧版独立 Relay,**Electron 不会启动它**。Electron 主进程的 Express(`chrome-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`
|
||
2. 上传到腾讯云 COS:`coscmd upload /tmp/hermes-page-bridge.zip hermes-page-bridge.zip`
|
||
3. 生成临时下载链接(24h有效):`coscmd signurl -t 86400 hermes-page-bridge.zip`
|
||
4. 用户下载 zip → 解压 → 覆盖原 extension 目录 → chrome://extensions/ 点刷新按钮
|
||
5. **重要**:刷新 Extension 后,已打开的标签页需要刷新页面才能自动注入 content_script(或依赖 ensureContentScript 自动注入)
|
||
|
||
### Service Worker 崩溃排查
|
||
若 `chrome://extensions` 中 Service Worker 显示错误:
|
||
- **最常见原因**:`background.js` 中 `new 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 获取
|
||
```bash
|
||
# 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-Key`、`X-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隧道和部分API(v1/health等),**`/cdp/*` 端点在此端口返回404**
|
||
|
||
```python
|
||
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)— 只能用9228(9229返回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>
|
||
```
|
||
|
||
### ⚠️ 已知不可用端点
|
||
|
||
```python
|
||
# ❌ 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) # ✅
|
||
```
|
||
|
||
### 快速连接验证三步曲
|
||
|
||
```python
|
||
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。
|
||
**⚠️ 不要用 curl**:key 含 `$$$`,shell 变量展开导致认证失败。必须用 Python requests。
|
||
**⚠️ `/cdp/*` 端点只能用9228**:9229端口返回404。
|
||
|
||
## CDP 定时数据采集(后台轮询模式)
|
||
|
||
通过 CDP 隧道定时采集用户浏览器中的页面数据,适合长时间后台监控场景(如竞品价格追踪)。
|
||
|
||
### 关键技术点
|
||
|
||
1. **Tab 枚举**:`Target.getTargets` 获取所有页面,按域名过滤:
|
||
```bash
|
||
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
|
||
```
|
||
|
||
2. **Tab 切换**:先 `detach` 当前 target,再 `attach` 到目标 targetId:
|
||
```bash
|
||
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
|
||
```
|
||
|
||
3. **⚠️ attach 可能返回 openerId 对应的 targetId**:Ozon 的产品页是从首页打开的(openerId 指向首页),attach 产品页 targetId 可能实际连到首页。解决方案:attach 后用 `/cdp/navigate` 直接导航到目标 URL。
|
||
|
||
4. **URL 去重**:同一页面可能出现在多个 target 中(首页 + 子页面),用 `url.split("?")[0]` 去重避免重复采集。
|
||
|
||
5. **Ozon 特殊 DOM**:Ozon 没有 `data-testid` 属性,价格提取需搜索 `innerText` 中含 `₽`(Unicode 8381)的行,跳过促销行(如"Товары за 1₽"),取第2-3行作为真实价格。卖家信息通过文本匹配 `Магазин` 关键字。
|
||
|
||
### 后台采集脚本模板
|
||
|
||
```python
|
||
# 完整示例: /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)
|
||
```
|
||
|
||
### 运行管理
|
||
|
||
```bash
|
||
# 启动(-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)
|
||
|
||
```json
|
||
{
|
||
"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` 获取标签页信息:
|
||
|
||
```python
|
||
# 获取所有标签页(需要在有 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首页搜索框填词后按Enter,form 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}.html`,offerId为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)或 9229(HTTP代理隧道)均可
|
||
|
||
## CDP 操作跨域 Iframe(关键技巧)
|
||
|
||
163邮箱、店小秘等网站将登录表单放在跨域 iframe 中(如 `dl.reg.163.com` vs `mail.163.com`),主页面 JS 和 CDP `Runtime.evaluate` 默认无法访问 iframe 内部 DOM。
|
||
|
||
### 解决方案:`Page.createIsolatedWorld`
|
||
|
||
```python
|
||
# 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 再导航:
|
||
|
||
```python
|
||
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` 引用列表:
|
||
|
||
```javascript
|
||
// 核心脚本 — 注入到页面后返回 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 });
|
||
})()
|
||
```
|
||
|
||
**返回示例**:
|
||
```json
|
||
{
|
||
"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 点击
|
||
|
||
```python
|
||
# 通过 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`:
|
||
|
||
```python
|
||
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 完整交互示例
|
||
|
||
```python
|
||
# 搜索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:
|
||
```python
|
||
# 防御性解析(在 cdp_send 中)
|
||
cdp_result = resp_body.get('result', {})
|
||
if isinstance(cdp_result, str):
|
||
cdp_result = json.loads(cdp_result) # 解除双序列化
|
||
```
|
||
|
||
**4. 拟真鼠标轨迹**
|
||
|
||
```python
|
||
# 贝塞尔曲线 + 随机抖动 + 按压延迟
|
||
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. 拟真键盘输入**
|
||
|
||
```python
|
||
# 逐字输入:每个字符 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, ...})
|
||
```
|
||
|
||
#### 调用示例
|
||
|
||
```bash
|
||
KEY='Bing2026Cao$$$'
|
||
|
||
# 1. Snapshot(1.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.ts` → `CLOUD_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 登录 URL**:`https://sso.ozon.ru/auth/ozonid?localization_language_code=zh-hans&namespace=sc&token=...`
|
||
2. **"使用邮箱登录"按钮**:`Input.dispatchMouseEvent` (mousePressed + mouseReleased) 有效,JS `click()` 无效
|
||
3. **登录按钮误点**:页面有多个 button,`idx:0` 是 24×24 的清除图标,真正的"登录"按钮是 `idx:1`(344×56),必须先 `getBoundingClientRect` 确认尺寸再点击
|
||
4. **Input.insertText 填邮箱有效**:`Input.insertText` 可以正确填入邮箱地址,但 `nativeSetter` + `dispatchEvent` 可能更可靠
|
||
5. **邮箱输入后登录按钮点击**:邮箱模式下登录按钮坐标变化,必须重新获取位置
|
||
6. **验证码邮件**:Ozon 发送"Подтверждение учетных данных Ozon"邮件到注册邮箱,含6位数字验证码
|
||
|
||
### CDP 操作 Ozon SSO 流程
|
||
|
||
```python
|
||
# 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 元素可打开邮件。
|
||
|
||
### 流程
|
||
|
||
```python
|
||
# 1. 获取所有 frame
|
||
frames = cdp_post('/cdp/send', {'method': 'Page.getFrameTree'})
|
||
|
||
# 2. 找到邮件正文 iframe(URL 含 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 邮箱关键信息
|
||
|
||
- 收件箱模块 hash:`mbox.ListModule`
|
||
- 点击收件箱链接后需等待模块加载
|
||
- 邮件列表通过 JS 操作(非标准链接),需要找到正确的 DOM 元素并点击
|
||
- 邮件正文在 `readhtml.jsp?mid=<邮件ID>` 的跨域 iframe 中
|
||
- "安全浏览模式"可能过滤邮件 HTML 内容,但 iframe 中 `innerText` 仍可获取完整文本
|
||
|
||
### 163 邮箱批量操作(标记已读 + 分页收集)
|
||
|
||
**场景**:331封未读邮件需要全部标记已读并收集摘要。
|
||
|
||
#### 标记全部已读(DOM 操作三步曲)
|
||
|
||
```python
|
||
# 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 点击更可靠。
|
||
|
||
```python
|
||
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` 永久挂起 bug(v3.9.22 已修复)**:`chrome-bridge.ts` 的 `sendCDP` 原用 `new Promise(async (resolve) => {...})` 反模式,超时定时器在 `await ensureCdpDirectConnection()` **之后**才设置——连接函数一旦 throw,async executor 吞掉异常、Promise 永不 settle,调用方无限等待(表现为 CDP 操作"卡死无响应无超时")。修复模式:改为普通 async 函数,try/catch 包裹连接 await 并返回 `{error}`,只把发送+等待响应那段包进 Promise,`ws.send` 失败时也要 clearTimeout + 清队列。排查任何 Promise 封装代码时 grep `new Promise(async`。
|
||
- **⚠️ Cloud Bridge 认证唯一方式:`Authorization: Bearer ***`**:经实测验证,`X-Bridge-Key`、`X-API-Key`、`Basic 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 不支持自定义 header**:`new 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浏览器不加载Extension,CDP命令直连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` 双序列化 bug**:Relay 返回 `{"ok":true,"result":"{\"nodes\":[...]}}"}` 时 `result` 可能是 JSON 字符串而非 dict,导致 `'str' object has no attribute 'get'` 错误。必须在 `cdp_send` 中加防御性 `json.loads`
|
||
- **⚠️ Browser-level CDP 命令不能带 sessionId**:`Target.getTargets`、`Target.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` 可能返回 None**:`DOM.pushNodesByBackendIdsToFrontend` + `DOM.getBoxModel` 在某些元素上失败(如 display:none、off-screen、iframe 内、StaticText 内联节点无 bbox)。click/fill 前应先 `scrollIntoViewIfNeeded`,且必须有 fallback
|
||
- **⚠️ 1688首页商品卡片 AX 树无可点击 link**:1688首页推荐商品在 AX 树中商品名只有 `StaticText`(role=InlineTextBox),没有包裹它们的 `link` 元素(虚拟列表/Shadow DOM 渲染)。无法通过 click-ref 点击商品名。**正确做法**:去店铺页(shop*.1688.com)获取标准 `/offer/` 链接,或用 JS fallback `document.querySelectorAll('a[href*="/offer/"]')` 提取链接后 `Page.navigate` 直接访问
|
||
- **⚠️ AX 树文本含控制字符破坏 JSON**:AX 节点的 `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` 填词后按 Enter,form 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 邮箱等页面大量使用跨域 iframe,AX 树 snapshot 返回 `{refs: {}, url: "", title: ""}` 完全为空。必须用 `/cdp/evaluate` + `Page.getFrameTree` + `Page.createIsolatedWorld` 手动提取
|
||
- **⚠️ contextId 过期极快**:`Page.createIsolatedWorld` 返回的 `executionContextId` 在几秒内可能失效(尤其 iframe 仍在加载时)。必须在同一次操作链中获取 contextId 后立即使用,不要缓存复用。如果 `Runtime.evaluate` + contextId 返回空结果,重新 `Page.getFrameTree` → `createIsolatedWorld` 获取新 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` 中使用同步 XMLHttpRequest**:`new XMLHttpRequest(); xhr.open('POST', url, false)` 会永久阻塞 Chrome 标签页的 JS 线程!后续所有 `cdp/evaluate`、`cdp/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/` 用 `requests` 或 `urllib.request` 直接调 API,可读性和可靠性都更好。**推荐 `requests` 库**(`pip install requests`),`params=` 自动 URL 编码、`json=` 自动序列化,完全避免 shell 转义地狱:
|
||
```python
|
||
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', '')
|
||
```
|
||
**注意**:`requests` 的 `params={"key": KEY}` 会自动对 `$` 做 URL 编码(→ `%24`),而 curl/shell 中 `$` 会被变量展开导致 `Unauthorized`。如果必须用 curl,需手动 `urllib.parse.quote(KEY, safe='')` 编码后拼 URL。
|
||
- **⚠️ 同域导航 `location.href` 不会断 CDP**:Cloud 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 标签 → ` `/`&` 实体替换 → 压缩空白。示例:
|
||
```python
|
||
# 在 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(/ /g, ' ').replace(/&/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/19`、`2/19` 等。**必须先点击下拉框展开**,再点击选项链接。直接搜索选项链接点击无效(下拉未展开时选项不可见)
|
||
- **⚠️ Ozon 验证码有时效**:6位验证码约5-10分钟有效。如果提示"代码不正确",需点击"获取新验证码"重新发送,再去163邮箱读取新验证码。验证码倒计时页面会显示剩余时间
|
||
- **⚠️ `/cdp/navigate` 端点返回500(v3.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=true` 但 `cdpBrowserProcess` 仍为 `null`。`stopCdpBrowser()` 在 `!cdpBrowserProcess` 处直接 return 不重置状态 → UI 卡在 "Stop Browser" 永远切不回 Start。修复:null check 分支中检测并重置 `cdpBrowserRunning/cdpEnabled/cdpBrowserPath/cdpBundled` 四个状态字段 + `notifyState()`
|
||
- **⚠️ electron-builder 不支持4位版本号**:`package.json` 的 `version` 必须是 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 明文 token**:`terminal()` 中带 `Authorization: Bearer xxx` 或 `?key=xxx` 的 curl 命令会被拦截返回 401。必须改用 `execute_code` 在 Python 中 `import requests` 发请求,或用 `urllib.request`。此规则已写入 Cloud Bridge 系统提示词第 7 节(v3.6.3+),远端 AI 也会遵守
|
||
- **⚠️ `relayRunning` 状态会变臭(stale)**:`state.relayRunning` 只在 `server.listen()` 回调时设为 `true`(chrome-bridge.ts 第1035行),此后即使 Express HTTP 服务因未捕获异常/端口冲突/事件循环阻塞导致无法响应请求,状态永远保持 `true`。前端 `fetch localhost:3928` 失败就抛原生 "Failed to fetch" 无任何上下文。**修复(v3.6.8+)**:①主进程新增每8秒 ping `/health`(3秒超时),检测失败自动标记 `relayRunning=false`,恢复后自动标回 `true`;②前端替换3处直接 `fetch localhost:3928` 为 `relayFetch()` 辅助函数,内置2次重试(500ms/1000ms递增)+8秒超时 + 中文友好错误 `Relay (port X) 无响应 — 请尝试重启 Relay Server。`
|
||
- **⚠️ 前端 CDP 操作优先用 IPC 而非 relayFetch**:Cookie 提取(`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 handler,preload 暴露 `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.ts` 的 `startRelay()` → `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.ts` 中 `import` 并注册全部 `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-x64`、`darwin-arm64`),而非纯 `process.platform`(只显示 `win32`,无法区分32/64位)。修改位置:`chrome-bridge.ts` 中 `ws.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分钟无声无息`
|
||
|
||
**为什么开第二条?** `connectCloudBridge` 中 `oldWs.close()` 是异步的(只发起关闭,不等待 TCP 真正断开),紧接着 `new WebSocket()` 立刻创建新连接。此时旧连接还在,服务端同时看到两条连接。
|
||
|
||
### 修复内容(4个文件)
|
||
|
||
**1. 服务端 `server.py`(行964-975)**:`register` 处理器新增回复确认消息
|
||
```python
|
||
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.ts`**:5个关键修复
|
||
- **`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 连接管理关键变量
|
||
```typescript
|
||
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` 导航 → ④ 等待 loadEventFired(15s 超时兜底)。返回 `{ok, result, loadEvent}` 结构,失败时 `{ok:false, error}` 而非 500
|
||
|
||
### 等待旧连接关闭的模式
|
||
```typescript
|
||
// 在 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 拒绝不重试
|
||
```typescript
|
||
cloudBridgeWs.on('close', (code, reason) => {
|
||
if (code === 4001) {
|
||
// 服务端单连接锁拒绝 → 清除配置,不再重试
|
||
cloudBridgeConfig = null
|
||
return
|
||
}
|
||
// 正常重连逻辑...
|
||
})
|
||
```
|
||
|
||
### electron-log 集成细节(v3.4.3+)
|
||
|
||
```typescript
|
||
// 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:
|
||
```typescript
|
||
// 主进程
|
||
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 connected` 但 `connectedTabs` 为空且无快照,说明扩展已连接但尚未推送,执行上述切换或刷新即可。
|
||
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.js` 发 `POST /page-snapshot`,但 Relay 只注册了 `POST /push-snapshot` → 所有快照 404
|
||
2. **WebSocket 消息类型不识别**:Extension 发 `{type:"extension_connect"}`,Relay 只处理 `{type:"tab-update"}` → connectedTabs 永远空
|
||
3. **数据字段丢失**:Extension 发 text/html/screenshot/meta/selection,Relay 只存 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()` 回调时设为 `true`(chrome-bridge.ts 第1035行),此后永不更新。当 Express HTTP 服务不可达时(事件循环阻塞、未捕获异常等),前端直接 `fetch localhost:3928` 抛原生 TypeError "Failed to fetch"。
|
||
**修复(v3.6.8+)**:双管齐下——
|
||
1. **主进程健康检查**(`chrome-bridge.ts`):在 `server.listen` 回调后启动 `setInterval`(8秒),每次 fetch `http://127.0.0.1:{port}/health`(3秒超时)。连续失败则 `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。`。
|
||
```typescript
|
||
// 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 内嵌了 Relay(Express+WS 在 Electron 主进程中运行),同时也有独立的 relay/ 目录可手动启动。
|
||
- **仅读取页面(路径A)**:Relay + 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 是否正确配置。
|