Files
InboundVerify/docs/安能门户CDP连接指南.md
Misaka 9d3d3cbb78 Add Anneng (Electron) site: expected-data download + main_router integration
安能站点第一阶段:把应到数据(运单信息)下载流程接入主路由。

- site_anneng.py:用页面级 CDP 驱动「安能全网门户」Electron 应用(Playwright
  connect_over_cdp 被 Electron 浏览器域 CDP 拦截,故走页面级)。完整应到流程:
  菜单导航 → 进站交接单查询 → 设日期查询(“带小数点的 0”为加载完成判据)
  → 逐条交接单双击进运单信息并核对单号 → 导出(选中字段+→箭头转移全部列)
  → 关闭查询 tab → 导出下载页轮询 → 免对话框下载(x-auth+TGC 直连 GET)
  → 合并为 安能-应到货物数据.xlsx。CDP 端口可配;anneng_ready() 供路由就绪轮询。
- main_router.py:以调试模式启动安能(动态空闲端口,避免端口冲突),经 CDP
  判断首页就绪,菜单加 [10] 应到下载,退出时关闭应用;网页站点逻辑不变。
- config.example.yaml:补充 anneng.query_days 与 anneng.app_path 说明。
- docs/安能门户CDP连接指南.md:连接方案、独立 webContents/tab 处理、免对话框下载。
- .gitignore:忽略 Archive/(早期探查脚本归档)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 19:38:44 +08:00

431 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 安能全网门户 CDP 连接与驱动指南
> 目标:连接到一个**已经以调试模式启动**的「安能全网门户」Electron 应用,像 Playwright 一样查看和操作界面元素。
>
> 实测环境Windows 11、Electron 19 / Chrome 102、Python 3.13、`playwright 1.60`、`websocket-client 1.9`。
---
## 1. 启动应用
用 Electron/Chromium 的远程调试参数启动可执行文件:
```bash
"安能全网门户.exe" --remote-debugging-port=9222
```
启动后,应用会在 `http://localhost:9222` 暴露一套 **Chrome DevTools Protocol (CDP)** 的 HTTP + WebSocket 接口。
快速自检(任意浏览器或 curl
```bash
curl http://localhost:9222/json/version # 确认端口在线、看 Electron/Chrome 版本
curl http://localhost:9222/json # 列出所有「页面目标」
```
`/json/version``User-Agent` 会带 `Electron/19.0.4 Chrome/102.0.5005.63`,据此判断 Chromium 内核版本(影响后续兼容性判断)。
---
## 2. 技术路线选型(重要)
### ❌ 路线 APlaywright `connect_over_cdp` —— 不可用
直觉上最省事,但实测在这个 Electron 构建上**握手后立刻断开**
```
playwright._impl._errors.Error: BrowserType.connect_over_cdp:
Protocol error (Browser.setDownloadBehavior): Browser context management is not supported.
```
**原因**Playwright 在 CDP 连接建立后,会调用浏览器域的 `Browser.setDownloadBehavior`
来初始化默认下载上下文;而这个 Electron 19 / Chrome 102 构建的 CDP **只部分实现了浏览器域**
对“浏览器上下文管理”一类命令直接返回 not supported于是连接被关闭。
**为什么不能靠降级 Playwright 绕过**:该行为由来已久,需要降到 2022 年的 Playwright 才可能规避;
但本机 venv 是 **Python 3.13**,而 `playwright < 1.48` 没有提供 3.13 的 wheel装不上。
### ✅ 路线 B页面级 CDP本项目采用
关键观察:**Electron 的浏览器域受限,但页面域(`Page` / `DOM` / `Runtime`)完全正常。**
因此绕开 Playwright 的高层连接,直接:
1. `GET http://localhost:9222/json` 拿到所有页面目标的 `webSocketDebuggerUrl`
2. 用 WebSocket 直连某个**页面目标**(不是 browser 目标);
3. 在该 WebSocket 上收发 CDP 消息(`Runtime.evaluate` / `DOM.*` / `Input.*` …)。
在上面再封装一层 Playwright 风格的薄封装(`Page.eval` / `Page.query_all` / `Page.click` …),
用起来接近 Playwright又不受 Electron 浏览器域限制。
> 备选:也可用 [`pychrome`](https://github.com/mineking0175/pychrome) 这类 CDP 客户端,它同样是页面级、不做上下文管理,
> 同样不会踩这个坑。本项目为减少依赖,直接用已安装的 `websocket-client` 手写。
---
## 3. 连接原理
### 3.1 发现目标
`GET /json` 返回一个数组,每个元素形如:
```json
{
"id": "4E4584692AF9A17D12E70E024F6DC938",
"type": "page",
"title": "安能全网门户",
"url": "file:///.../app.asar/website/index.html#/ai-button",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/4E458469..."
}
```
- `type == "page"` 的才是普通页面(另有 `background_page``service_worker``webview` 等,按需过滤)。
- **`id` / `webSocketDebuggerUrl` 每次启动都会变**,所以一定要动态发现,**绝不能硬编码**(早期 `site_anneng.py` 硬编码 WS URL重启就失效
-`title``url` 来挑选你真正要操作的那一页。
### 3.2 CDP 消息往返
CDP 是 JSON-RPC 风格:客户端发 `{id, method, params}`,服务端回 `{id, result}``{id, error}`
服务端也会**主动推送事件** `{method, params}`(没有 `id`)。
所以收消息时要**过滤掉事件**,只取 `id` 与本次请求匹配的那条回复:
```python
while True:
msg = json.loads(ws.recv())
if msg.get("id") == my_id:
return msg.get("result", {})
# 否则是事件,忽略
```
### 3.3 取值:`Runtime.evaluate` + `returnByValue`
直接执行 JS让浏览器把结果序列化成 JSON 带回来:
```python
result = call("Runtime.evaluate",
expression="document.title",
returnByValue=True, awaitPromise=True)
title = result["result"]["value"]
```
- `returnByValue=True`:把 JS 返回值按值序列化为 JSON适合结构化数据
- `awaitPromise=True`:表达式中若返回 Promise 会被自动 await写普通表达式也无副作用
绝大多数“查询/读取”需求都能用一段 JS + `Runtime.evaluate` 解决,比走 `DOM.*` 更直观。
---
## 4. 可运行的最小封装
下面是项目里 `_probe_anneng.py` 的核心结构(精简版),可直接复用:
```python
"""页面级 CDP连接 安能全网门户(:9222Playwright 风格的薄封装。"""
import json
import sys
import urllib.request
import websocket
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台是 GBK否则打印中文/emoji 会崩
CDP_URL = "http://localhost:9222"
def list_pages():
with urllib.request.urlopen(f"{CDP_URL}/json") as resp:
data = json.load(resp)
return [p for p in data if p.get("type") == "page"]
class CDP:
"""绑定到单个页面目标的同步 CDP 客户端。"""
def __init__(self, ws_url):
self.ws = websocket.create_connection(ws_url)
self._id = 0
def call(self, method, **params):
self._id += 1
self.ws.send(json.dumps({"id": self._id, "method": method, "params": params}))
while True:
msg = json.loads(self.ws.recv())
if msg.get("id") == self._id:
if "error" in msg:
raise RuntimeError(f"{method} failed: {msg['error']}")
return msg.get("result", {})
def eval(self, expression):
res = self.call("Runtime.evaluate", expression=expression,
returnByValue=True, awaitPromise=True)
return res.get("result", {}).get("value")
def close(self):
self.ws.close()
class Page:
def __init__(self, cdp):
self.cdp = cdp
cdp.call("Page.enable")
cdp.call("Runtime.enable")
@property
def title(self):
return self.cdp.eval("document.title")
def query_all(self, selector, limit=20):
return self.cdp.eval(
"(() => [...document.querySelectorAll(%r)].slice(0,%d).map(e => ({"
"tag:e.tagName.toLowerCase(), text:(e.innerText||'').trim().slice(0,40),"
"id:e.id||'', cls:(e.className||'').toString().slice(0,40),"
"title:e.getAttribute('title')||'' })))()" % (selector, limit)
) or []
```
用法:
```python
pages = list_pages()
# 挑你要的那一页(示例:取标题里含“鱼洞”的那页,即应用外壳)
target = next(p for p in pages if "鱼洞" in p["title"])
page = Page(CDP(target["webSocketDebuggerUrl"]))
print(page.title)
print(page.query_all("div.rc-menu-submenu-title"))
```
---
## 5. 关键发现:界面结构与定位
实测两个页面目标:
| 页面 title | url 片段 | 说明 |
|---|---|---|
| `安能全网门户` | `…/index.html#/ai-button` | 仅 AI 按钮落地页,几乎是空的,**不是主界面** |
| `重庆鱼洞镇` | `…/index.html` | **真正的应用外壳**,左侧整棵导航菜单都在这里 |
主界面(“重庆鱼洞镇”页)的左侧一级菜单(实测抓取):
```
基础数据 / 客服 / 运单管理 / 运营管理 / 扫描操作 / 物料管理 /
网点金融 / 自营客户管理 / 综合查询 / 新闻与问卷 / 问题反馈
```
**定位特征**React + styled-components + rc-menu 技术栈):
- 一级菜单项:`div.rc-menu-submenu-title[title='运营管理']`
- 站点信息(顶部):`div[title='02330019400049']`
- 菜单搜索框:`input[placeholder*='菜单搜索']`
- 折叠按钮:`div[title='折叠菜单']`
> 这是 React 应用,**class 名是 styled-components 生成的散列**(如 `sc-dntSTA ginXyJ`
> **不要**用这些散列 class 做定位——换一次构建就变。优先用 `[title=…]`、`[role=…]`、
> 稳定的 `rc-menu-*` 类名或文本内容来定位。
---
## 6. 独立 webContentstab 页(独立网页)的元素操作
应用里有些功能(如「导出下载」)点击导航菜单后,会在右侧打开一个 **tab 页**
这类 tab 的内容是**独立的 webContents一个远程网页**,而不是主页面里的普通 iframe。
**判据**(出现以下现象即说明是独立 webContents
- 用主窗口 DevToolsCtrl+Shift+I的元素拾取能抓导航栏却**抓不到 tab 内的元素**
- 在 tab 内点右键只有「检查」一项,点开后会**另开一个 DevTools 窗口**,在那里才能拾取 tab 元素。
**关键结论**:这种 tab 会作为 `/json` 里一个**独立的 page 目标**出现(`url` 是远程 https 地址)。
所以**不需要任何特殊 API** —— 把它当成“又一个页面”:按 URL 从 `/json` 里挑出来、直连它的
`webSocketDebuggerUrl`,用同一套 `Runtime.evaluate` / `DOM` 操作即可,和主页面毫无区别。
**打开并定位 tab 目标**的通用做法(已打开就按 URL 复用,没打开就点菜单触发后轮询新目标):
```python
import time
def find_or_open_tab(url_hint, menu_text=None, timeout=20):
"""按 url_hint 找到 tab 目标;没打开就点 menu_text 菜单触发,再轮询新目标。"""
# 1) 已打开则直接复用(不必重复点击)
for p in list_pages():
if url_hint in p.get("url", ""):
return CDP(p["webSocketDebuggerUrl"])
# 2) 未打开 → 点导航菜单触发,再轮询新出现的目标
assert menu_text, "tab 未打开且未提供 menu_text"
main = next(CDP(p["webSocketDebuggerUrl"]) for p in list_pages()
if p.get("title") and "鱼洞" in p["title"]) # 主应用外壳页
before = {p["id"] for p in list_pages()}
main.eval(
"[...document.querySelectorAll('li.rc-menu-item')]"
f".find(e => e.textContent.trim() === {json.dumps(menu_text)})?.click()"
)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for p in list_pages():
if url_hint in p.get("url", "") and p["id"] not in before:
return CDP(p["webSocketDebuggerUrl"])
time.sleep(0.5)
raise TimeoutError("tab 目标未出现")
```
> 实测「导出下载」tab 的 URL 含 `exportAllRecords`,打开后即是一个远程 https 页面目标,
> 连上后读表格、点按钮等都与主页面完全一样。
---
## 7. 实战:免对话框下载导出文件
目标:触发导出下载并自动存到默认下载目录,**不弹 Windows 保存对话框**。
**核心经验:下载不要靠点页面按钮,直接 GET 下载接口即可。**
(点按钮会走 Electron 主进程的下载流程并弹原生保存对话框CDP 无法从外部抑制;
而直接发 HTTP 请求取回文件字节,根本不经过那条流程,自然无对话框。)
**下载接口与鉴权**实测自「导出下载」tab
| 项 | 值 |
|---|---|
| 接口 | `GET https://uep.ane56.com/uep/foreign/api/fileDownloadRecord/download` |
| 参数 | `fileId=<文件路径>``appId=LB``aneFile=false``index=<同 fileId>` |
| 鉴权头 | `x-auth` = `sessionStorage["x-auth"]`base64 JWT |
| cookie | `TGC`httpOnly`Network.getCookies` 读取;`document.cookie` 取不到) |
| 响应 | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`xlsx |
**文件名**:用表格「文件名」列的可读名(如 `交接单明细-20260621_153527.xlsx`
服务端 `Content-Disposition` 里给的是 UUID 名,不要用。
**完整步骤**CDP 取鉴权 + Python `urllib` 下载):
```python
import urllib.parse
import urllib.request
# 1) 连到「导出下载」tab 目标,取出鉴权信息
cdp = find_or_open_tab("exportAllRecords", menu_text="导出下载")
x_auth = cdp.eval('sessionStorage.getItem("x-auth")')
ua = cdp.eval("navigator.userAgent")
referer = cdp.eval("location.href")
tgc = next(c["value"] for c in
cdp.call("Network.getCookies", urls=["https://uep.ane56.com"])["cookies"]
if c["name"] == "TGC")
# 2) 直接 GET 下载接口(不点按钮 → 不弹对话框)
params = {"fileId": file_id, "appId": "LB", "aneFile": "false", "index": file_id}
url = ("https://uep.ane56.com/uep/foreign/api/fileDownloadRecord/download?"
+ urllib.parse.urlencode(params))
req = urllib.request.Request(url, headers={
"x-auth": x_auth, "Cookie": f"TGC={tgc}",
"Referer": referer, "User-Agent": ua, "Accept": "*/*",
})
data = urllib.request.urlopen(req, timeout=30).read()
assert data[:2] == b"PK", "非 xlsx" # ZIP 魔数校验
open(r"C:\Users\pengq\Downloads\交接单明细-xxx.xlsx", "wb").write(data)
```
实测GET 返回 `200` / 21508 字节 / `PK` 头合法,全程**不点按钮、不弹对话框、不留 `<uuid>.tmp`**。
完整可运行脚本见项目 `download_clean.py`
> **关于 `fileId`**:下载按钮的 `<a>` 没有 href是 JS 点击),`fileId` 不在可见 DOM 里。
> `download_clean.py` 当前用抓包所得的 `fileId` 验证通过;要批量下载多行,需从表格行的
> React record 里取每行的 `fileId`(路径模式 `/task/YYYYMMDD/<uuid>.xlsx`)。
---
## 8. 常见坑
1. **页面 ID 会变**:每次重启应用,`/json` 里的 `id` / `webSocketDebuggerUrl` 都不同 → 必须运行时动态发现。
2. **Windows 控制台编码**:默认 GBK打印中文/emoji 抛 `UnicodeEncodeError` → 脚本里加
`sys.stdout.reconfigure(encoding="utf-8")`,或设环境变量 `PYTHONUTF8=1` 运行。
3. **必须过滤 CDP 事件**recv 循环里要跳过没有匹配 `id` 的事件消息,否则会把事件当成回复解析出错。
4. **直连 page 目标,不是 browser 目标**`/json/version` 里的 `webSocketDebuggerUrl` 是 browser 级 WS
连它同样会触发上下文管理类命令的坑;要用 `/json` 里各 page 的 WS。
5. **不要关掉用户的进程**:我们只是“附加”到外部进程,`ws.close()` 只断开自己的连接,不会杀掉 Electron。
---
## 9. 扩展:点击与输入
### 9.1 点击JS 触发,最简单)
```python
def click(self, selector):
self.cdp.eval(
"document.querySelector(%r)?.click() || false" % selector
)
```
适合菜单展开、按钮提交这类纯逻辑点击。
### 9.2 点击(真实鼠标事件,需要坐标时)
`Input.dispatchMouseEvent` 模拟真实移动/按下/抬起,坐标来自元素包围盒:
```python
def click_real(self, selector):
box = self.cdp.eval(
"(() => {const r=document.querySelector(%r).getBoundingClientRect();"
"return {x:r.x+r.width/2, y:r.y+r.height/2};})()" % selector
)
for t in ("mouseMoved", "mousePressed", "mouseReleased"):
self.cdp.call("Input.dispatchMouseEvent", type=t,
x=box["x"], y=box["y"], button="left", clickCount=1)
```
### 9.3 文本输入React 应用特别注意)
React 受控组件**不会响应**直接赋的 `el.value = '...'`。要触发它认得的 `input` 事件:
```python
def fill(self, selector, text):
self.cdp.eval(
"(() => {const el=document.querySelector(%r);"
"const s=Object.getOwnPropertyDescriptor(el.constructor.prototype,'value').set;"
"s.call(el, %r);"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"})()" % (selector, text)
)
```
### 9.4 等待元素出现
CDP 没有现成的 `wait_for_selector`,轮询最稳:
```python
import time
def wait_for(self, selector, timeout=10, interval=0.3):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.cdp.eval(f"!!document.querySelector({selector!r})"):
return True
time.sleep(interval)
raise TimeoutError(selector)
```
---
## 10. 运行环境约定
- 所有 Python 脚本在项目 `.venv` 内运行(`python -m venv .venv`,激活后安装依赖)。
- 依赖:`websocket-client`(已装)、`black`(格式化,已装)、可选 `pychrome`
- 相关脚本:`_probe_anneng.py`(探查)、`site_anneng.py`(菜单导航)、`download_clean.py`(免对话框下载)。
- 运行:`.venv/Scripts/python.exe download_clean.py`
---
## 11. 速查清单
| 任务 | 做法 |
|---|---|
| 确认应用在线 | `curl http://localhost:9222/json/version` |
| 列出页面 | `curl http://localhost:9222/json`(或 `list_pages()` |
| 选目标页 | 按 `title` / `url` 过滤,**别用 id** |
| 连接 | 直连该页的 `webSocketDebuggerUrl` |
| 读取元素 | `Runtime.evaluate` + 一段 `querySelector` JS |
| 点击 | JS `.click()``Input.dispatchMouseEvent` |
| 输入React | 走原型 setter + `dispatchEvent('input')` |
| 操作 tab 页(独立网页) | 它是 `/json` 里独立 page 目标,按 URL 取出直连即可 |
| 免对话框下载 | **别点按钮**;用 `x-auth`(sessionStorage)+`TGC`(Network.getCookies) 直接 GET 下载接口 |
| 格式化代码 | `black` |
| 终端乱码 | `sys.stdout.reconfigure(encoding="utf-8")` |