diff --git a/.gitignore b/.gitignore index 1dcd263..3276a10 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,5 @@ ms-playwright/ # Memory memory/ MEMORY.md + +Archive/ \ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml index 4ff7b0d..c339cf3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -16,7 +16,7 @@ debug: enabled: false # 调试模式下要单独启动的网点名称。 - # 仅在 enabled: true 时生效。可选值:顺心 / 百世 / 中通 / 韵达。 + # 仅在 enabled: true 时生效。可选值:顺心 / 百世 / 中通 / 韵达 / 安能。 # 留空或不匹配时将回退为全量模式。 target_site: "" @@ -55,3 +55,17 @@ yunda: # 应到 / 实到数据的查询时间范围(单位:天,向前回溯 N 天至今天)。 query_days: 1 + +# ---------------------------------------------------------------------------- +# 安能全网门户(Electron 桌面应用,非网页) +# ---------------------------------------------------------------------------- +# 与其他站点不同:安能不由 main_router 用浏览器打开,而是以调试模式启动其 +# Electron 可执行文件(自动选取一个空闲端口作为 --remote-debugging-port,避免端口冲突), +# 启动后请在应用内手动登录,main_router 会自动轮询判断是否进入主页。 +anneng: + # 应到(运单信息)数据的查询时间范围(单位:天,向前回溯 N 天至今天)。 + query_days: 1 + + # 安能 Electron 应用的可执行文件路径。 + # 路径含反斜杠/空格/@,用单引号包裹即可(YAML 单引号串按字面解析)。 + app_path: 'D:\SoftWare\SoftWare Installation\@ane-electron-uiapp\安能全网门户.exe' diff --git a/docs/安能门户CDP连接指南.md b/docs/安能门户CDP连接指南.md new file mode 100644 index 0000000..a0096da --- /dev/null +++ b/docs/安能门户CDP连接指南.md @@ -0,0 +1,430 @@ +# 安能全网门户 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. 技术路线选型(重要) + +### ❌ 路线 A:Playwright `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:连接 安能全网门户(:9222),Playwright 风格的薄封装。""" +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. 独立 webContents:tab 页(独立网页)的元素操作 + +应用里有些功能(如「导出下载」)点击导航菜单后,会在右侧打开一个 **tab 页**。 +这类 tab 的内容是**独立的 webContents(一个远程网页)**,而不是主页面里的普通 iframe。 + +**判据**(出现以下现象即说明是独立 webContents): + +- 用主窗口 DevTools(Ctrl+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` 头合法,全程**不点按钮、不弹对话框、不留 `.tmp`**。 +完整可运行脚本见项目 `download_clean.py`。 + +> **关于 `fileId`**:下载按钮的 `` 没有 href(是 JS 点击),`fileId` 不在可见 DOM 里。 +> `download_clean.py` 当前用抓包所得的 `fileId` 验证通过;要批量下载多行,需从表格行的 +> React record 里取每行的 `fileId`(路径模式 `/task/YYYYMMDD/.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")` | diff --git a/main_router.py b/main_router.py index a3f972a..4288473 100644 --- a/main_router.py +++ b/main_router.py @@ -1,7 +1,10 @@ # main_router.py import os +import socket +import subprocess import time +import urllib.request import yaml import pandas as pd from playwright.sync_api import sync_playwright @@ -13,6 +16,7 @@ import site_shunxin import site_baishi import site_zto import site_yunda +import site_anneng # 定义网点及对应的初始登录 URL SITES_CONFIG = { @@ -32,6 +36,49 @@ READY_SELECTORS = { "韵达": '.el-menu-item:has-text("首页")', } +# 安能是 Electron 桌面应用(不是 Playwright 打开的网页),需要单独启动。 +APP_SITES = {"安能"} + + +def _find_free_port(): + """让操作系统分配一个空闲端口,避免固定端口冲突。""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_cdp_up(port, timeout=60.0): + """轮询直到 CDP 调试端口就绪(应用启动需要时间)。""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen( + f"http://localhost:{port}/json/version", timeout=2 + ) as resp: + if resp.status == 200: + return True + except Exception: + pass + time.sleep(1) + return False + + +def launch_anneng(app_path): + """以调试模式启动安能 Electron 应用(自动选取空闲端口),返回子进程对象。 + + 启动后请在应用内手动登录;就绪状态由 run_multi_site_daemon 的就绪轮询判断。 + """ + port = _find_free_port() + print(f">> 以调试模式启动【安能】应用(端口 {port}):{app_path}") + proc = subprocess.Popen([app_path, f"--remote-debugging-port={port}"]) + site_anneng.set_cdp_port(port) + if not _wait_cdp_up(port): + raise RuntimeError( + f"安能应用调试端口 {port} 未就绪——可能应用已在运行(单实例)," + "请先关闭已有的安能窗口再试" + ) + return proc + def task_process_undelivered_data(site_name="顺心"): """应到未到比对:找出应到但未实到的运单 (按站点前缀)""" @@ -178,7 +225,9 @@ def _print_test_report(results): print("====================================================") for i, (site_name, label, status, elapsed, err) in enumerate(results, start=1): mark = "✅" if status == "PASS" else "❌" - print(f" {i:>2}. {mark} {status} {site_name} - {label} (耗时 {elapsed:.1f}s)") + print( + f" {i:>2}. {mark} {status} {site_name} - {label} (耗时 {elapsed:.1f}s)" + ) if err: note = err if len(err) <= 60 else err[:57] + "..." print(f" 说明: {note}") @@ -197,22 +246,37 @@ def run_multi_site_daemon(): # 1. 读取配置文件 debug_mode = False debug_target = "" + anneng_app_path = "" try: if os.path.exists(CONFIG_PATH): with open(CONFIG_PATH, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) - debug_mode = config.get("debug", {}).get("enabled", False) - debug_target = config.get("debug", {}).get("target_site", "") + config = yaml.safe_load(f) or {} + debug_mode = (config.get("debug", {}) or {}).get("enabled", False) + debug_target = (config.get("debug", {}) or {}).get("target_site", "") + anneng_app_path = (config.get("anneng", {}) or {}).get("app_path", "") except Exception as e: print(f"⚠️ 读取 config.yaml 异常,将使用全量模式启动: {e}") - # 动态确定需要挂载启动的网页 - active_sites = {} - if debug_mode and debug_target in SITES_CONFIG: - print(f"\n🛠️ 【调试模式】仅加载目标站点: [{debug_target}]") - active_sites = {debug_target: SITES_CONFIG[debug_target]} + # 动态确定需要挂载启动的网页站点;安能(Electron 应用)单独标记 + anneng_active = False + if debug_mode: + if debug_target in SITES_CONFIG: + print(f"\n🛠️ 【调试模式】仅加载目标站点: [{debug_target}]") + active_sites = {debug_target: SITES_CONFIG[debug_target]} + elif debug_target == "安能": + print(f"\n🛠️ 【调试模式】仅加载目标站点: [安能]") + active_sites = {} + anneng_active = True + else: + active_sites = dict(SITES_CONFIG) + anneng_active = True else: - active_sites = SITES_CONFIG + active_sites = dict(SITES_CONFIG) + anneng_active = True + + if anneng_active and not anneng_app_path: + print("⚠️ 已启用安能但 config.yaml 未配置 anneng.app_path,将跳过安能。") + anneng_active = False with sync_playwright() as p: browser = p.chromium.launch(headless=False) @@ -230,6 +294,16 @@ def run_multi_site_daemon(): page.goto(url) pages_map[site_name] = page + # 安能:以调试模式启动 Electron 应用(非 Playwright 网页) + anneng_proc = None + if anneng_active: + try: + anneng_proc = launch_anneng(anneng_app_path) + pages_map["安能"] = True # 哨兵:表示已启动(无 Playwright page 对象) + except Exception as e: + print(f"⚠️ 启动安能应用失败,已跳过安能:{e}") + anneng_active = False + print("\n====================================================") print("【登录检测】正在准备各站点登录...") print("====================================================") @@ -247,27 +321,38 @@ def run_multi_site_daemon(): # 自动识别各站点登录完成状态,无需手动回车 # ==================================================================== ready_status = {site: False for site in active_sites.keys()} + if anneng_active: + ready_status["安能"] = False print("\n>> 正在轮询各站点就绪状态 (自动登录或手动登录均可)...") while not all(ready_status.values()): - for site_name, page in pages_map.items(): - if not ready_status[site_name]: - try: + for site_name in list(ready_status.keys()): + if ready_status[site_name]: + continue + try: + if site_name == "安能": + # 安能走 CDP 判断主页是否就绪(连不上返回 False,不抛异常) + ok = site_anneng.anneng_ready() + else: # 0.5 秒轻量探测,避免阻塞主循环 - if page.locator(READY_SELECTORS[site_name]).is_visible( - timeout=500 - ): - ready_status[site_name] = True - print(f" ✅ 【{site_name}】已检测到主页,登录就绪。") - except Exception: - pass + ok = ( + pages_map[site_name] + .locator(READY_SELECTORS[site_name]) + .is_visible(timeout=500) + ) + if ok: + ready_status[site_name] = True + print(f" ✅ 【{site_name}】已检测到主页,登录就绪。") + except Exception: + pass pending_sites = [s for s, ready in ready_status.items() if not ready] if pending_sites: print( - f" ⏳ 等待以下站点完成登录: [{', '.join(pending_sites)}] ... (请在浏览器中操作)" + f" ⏳ 等待以下站点完成登录: [{', '.join(pending_sites)}] ... " + "(请在浏览器/应用中操作)" ) - page.wait_for_timeout(3000) # 等待 3 秒后进行下一轮检查 + time.sleep(3) # 等待 3 秒后进行下一轮检查 print("\n====================================================") print("【准备】所有站点已就绪,正在清理初始弹窗...") @@ -326,6 +411,9 @@ def run_multi_site_daemon(): if "韵达" in pages_map: print(" ✅ 【韵达】已就绪。") + if "安能" in pages_map: + print(" ✅ 【安能】已就绪。") + def is_site_ready(site_name): if site_name not in pages_map: print(f"\n🚫 站点 [{site_name}] 未加载(当前为调试模式),已跳过。") @@ -353,6 +441,9 @@ def run_multi_site_daemon(): print(" [6] 执行 - 应到货物数据下载") print(" [7] 执行 - 实到货物数据下载") print("-" * 52) + print(" 模块五:【安能】数据处理流(Electron 应用)") + print(" [10] 执行 - 应到货物数据下载(运单信息)") + print("-" * 52) print(" 自动化测试") print(" [8] 执行 - 全站点下载流程自动化测试 (交叉跑通校验)") print("-" * 52) @@ -386,6 +477,8 @@ def run_multi_site_daemon(): elif choice == "7" and is_site_ready("韵达"): pages_map["韵达"].bring_to_front() site_yunda.yunda_actual_download(pages_map["韵达"]) + elif choice == "10" and is_site_ready("安能"): + site_anneng.anneng_expected_download() elif choice == "8": run_automation_test(pages_map) elif choice == "9": @@ -399,12 +492,30 @@ def run_multi_site_daemon(): print("\n正在关闭浏览器并退出...") break else: - if choice not in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]: + if choice not in [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "0", + ]: print("\n⚠️ 无效输入,请查证后回车。") except Exception as e: print(f"❌ 任务调度异常: {e}") browser.close() + if anneng_proc is not None: + try: + anneng_proc.terminate() + print("已关闭安能应用。") + except Exception: + pass print("程序已退出。") diff --git a/site_anneng.py b/site_anneng.py new file mode 100644 index 0000000..bce874d --- /dev/null +++ b/site_anneng.py @@ -0,0 +1,895 @@ +# site_anneng.py +# +# 安能全网门户(Electron 应用)—— 应到货物数据下载。 +# +# 与其他站点(网页、由 main_router 用 Playwright 驱动)不同,安能是一个 Electron +# 桌面应用:由 main_router 以调试模式启动(动态空闲端口,经 set_cdp_port 告知本模块), +# 本模块通过该端口的 CDP 驱动它;「进站交接单查询」「导出下载」等右侧 tab 是 +# **独立 webContents**(远程网页),在 /json 里是独立目标。 +# 也可脱离 main_router 独立运行(此时用默认端口 9222,需已自行启动应用): +# .venv/Scripts/python.exe site_anneng.py +# +# 应到 = 运单信息导出(每张交接单下应到的运单明细)。整体流程: +# 主页(重庆鱼洞镇) → 运营管理 → 进站管理 → 进站交接单查询(tab) +# → 设日期 → 查询 → 等“带小数点的 0”出现(数据加载完毕) +# → 逐条交接单:记下交接单号 → 双击 → 等运单信息加载并核对单号 +# → 导出 → 双击转移全部待选字段 → 导出数据 → 任务添加成功 → 确认 → 回交接单信息 +# → 关闭进站交接单查询 tab → 打开导出下载(tab) +# → 轮询直到本批“交接单明细”任务全部导出完成 → 逐个免对话框下载(x-auth+TGC 直连 GET) +# → 合并为 安能-应到货物数据.xlsx,清理临时文件 +# +# 说明: +# - 进站交接单查询页是 ZK 框架(z-* 类),元素 id(如 h2YUxx)每次加载都变, +# 全部按 class / 文本定位,绝不硬编码 id。 +# - 下载不走页面按钮(点了会弹 Windows 保存对话框、且 CDP 拦不住),而是用 +# sessionStorage 的 x-auth + TGC cookie 直接 GET 下载接口。 + +import json +import os +import sys +import time +import urllib.parse +import urllib.request +from datetime import datetime, timedelta + +import pandas as pd +import websocket +import yaml + +# Windows 控制台默认 GBK,打印中文/emoji 会崩,强制 UTF-8。 +sys.stdout.reconfigure(encoding="utf-8") + +from paths import DOWNLOAD_DIR, CONFIG_PATH + +CDP_PORT = 9222 # 默认端口(独立运行 site_anneng.py 时用);main_router 启动时会用 set_cdp_port 覆盖 +POLL_INTERVAL = 0.5 +DEFAULT_TIMEOUT = 25.0 + + +def set_cdp_port(port): + """由 main_router 在启动安能应用后调用,告知本模块实际使用的调试端口。""" + global CDP_PORT + CDP_PORT = int(port) + + +# 导出下载页里,本批任务的“导出功能名称”固定为“交接单明细”。 +TARGET_EXPORT_FUNC = "交接单明细" +FINAL_FILENAME = "安能-应到货物数据.xlsx" + +# 进站交接单查询页 / 导出下载页的 URL 特征(用于在 /json 里定位/复用 tab 目标)。 +JIAOJIEDAN_URL_HINT = ( + "ewbs_manage" # 进站交接单查询页 URL 含 center_outsite_ewbs_manage_mgr.zul +) +EXPORT_TAB_URL_HINT = "exportAllRecords" + + +# ==================================================================== +# CDP 基础层 +# ==================================================================== + + +def list_pages(): + """返回 CDP 上所有 page 类型的目标。""" + with urllib.request.urlopen(f"http://localhost:{CDP_PORT}/json") as resp: + return [p for p in json.load(resp) if p.get("type") == "page"] + + +class CDP: + """绑定到单个页面目标的同步 CDP 客户端。""" + + def __init__(self, ws_url): + self.ws = websocket.create_connection(ws_url) + self._id = 0 + self.call("Runtime.enable") + + def call(self, method, **params): + self._id += 1 + self.ws.send(json.dumps({"id": self._id, "method": method, "params": params})) + # 跳过 CDP 主动推送的事件,只取 id 匹配的回复。 + 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): + """执行一段 JS,返回 by-value 的结果(出错/未找到返回 None)。""" + res = self.call( + "Runtime.evaluate", + expression=expression, + returnByValue=True, + awaitPromise=True, + ) + return res.get("result", {}).get("value") + + def close(self): + try: + self.ws.close() + except OSError: + pass + + +def wait_until(cdp, js, desc, timeout=DEFAULT_TIMEOUT, interval=POLL_INTERVAL): + """轮询直到 eval(js) 为真;超时抛 TimeoutError。""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + if cdp.eval(js): + return True + except OSError: + pass + time.sleep(interval) + raise TimeoutError(f"等待超时:{desc}") + + +# ==================================================================== +# 目标发现 / 菜单导航 / tab 控制(均在主页“重庆鱼洞镇”上操作) +# ==================================================================== + + +def _connect(predicate): + """遍历 page 目标,连上第一个 eval(predicate_js) 为真的,返回 CDP。""" + for info in list_pages(): + cdp = CDP(info["webSocketDebuggerUrl"]) + try: + if cdp.eval(f"(() => {{ return !!({predicate}); }})()"): + return cdp + except Exception: + pass + cdp.close() + return None + + +def find_main_page_cdp(): + """主页(应用外壳,含导航菜单“运营管理”)。""" + cdp = _connect( + "document.querySelector(\"div.rc-menu-submenu-title[title='运营管理']\")" + ) + if cdp is None: + raise RuntimeError("未找到安能主页(含“运营管理”菜单),请确认应用已启动并登录") + return cdp + + +def anneng_ready(): + """安能主页是否就绪(供 main_router 的就绪轮询调用)。 + + 判据:能连上 CDP 且主页出现站点名称控件(带 title+fontsizenum 的 div)。 + 连不上或未就绪一律返回 False,不抛异常(就绪轮询会反复调用)。 + """ + try: + cdp = find_main_page_cdp() + except Exception: + return False + try: + return bool(cdp.eval("!!document.querySelector('div[title][fontsizenum]')")) + except Exception: + return False + finally: + cdp.close() + + +def _submenu_state_js(title): + sel = json.dumps(f"div.rc-menu-submenu-title[title='{title}']") + return ( + "(() => {const e = document.querySelector(" + sel + ");" + "return e ? e.getAttribute('aria-expanded') : null;})()" + ) + + +def open_submenu(cdp, title): + """展开某一级子菜单:只有“未展开”才点击,避免把已展开的菜单点收。""" + wait_until(cdp, _submenu_state_js(title) + " !== null", f"菜单项「{title}」出现") + if cdp.eval(_submenu_state_js(title)) == "true": + return + sel = json.dumps(f"div.rc-menu-submenu-title[title='{title}']") + if not cdp.eval( + "(() => {const e = document.querySelector(" + sel + ");" + "if (!e) return false; e.click(); return true;})()" + ): + raise RuntimeError(f"未找到菜单项「{title}」") + wait_until(cdp, _submenu_state_js(title) + " === 'true'", f"「{title}」展开完成") + time.sleep(0.2) + + +def click_leaf(cdp, text): + """点击叶子菜单项(li.rc-menu-item,按文本匹配)。""" + js_text = json.dumps(text) + if not cdp.eval( + "(() => {const e = [...document.querySelectorAll('li.rc-menu-item')]" + f".find(e => e.textContent.trim() === {js_text});" + "if (!e) return false; e.click(); return true;})()" + ): + raise RuntimeError(f"未找到叶子菜单「{text}」") + + +def open_submenus(cdp, titles): + """按顺序展开多层子菜单(每层都做“已展开则跳过”守卫)。只展开,不点叶子。""" + for title in titles: + open_submenu(cdp, title) + + +def open_tab_target(main_cdp, menu_text, url_hint=None, timeout=30.0): + """在主页点击叶子菜单打开 tab,等新目标出现并连上,返回其 CDP。 + + - url_hint 非空:只认 URL 含该关键字的新目标(用于导出下载页)。 + - url_hint 为空:取点击后新出现的“内容目标”(排除 devtools),无论 http/file, + 用于进站交接单查询页(其 URL 特征未知)。 + """ + before = {p["id"] for p in list_pages()} + click_leaf(main_cdp, menu_text) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for info in list_pages(): + if info["id"] in before: + continue + url = info.get("url", "") + if url.startswith("devtools"): + continue + if url_hint: + if url_hint in url: + return CDP(info["webSocketDebuggerUrl"]) + else: + return CDP(info["webSocketDebuggerUrl"]) + time.sleep(POLL_INTERVAL) + raise TimeoutError(f"点击「{menu_text}」后未出现 tab 目标") + + +def find_tab_cdp(url_hint): + """按 URL 关键字复用已打开的 tab 目标,没有则返回 None。""" + for info in list_pages(): + if url_hint in info.get("url", ""): + return CDP(info["webSocketDebuggerUrl"]) + return None + + +def ensure_tab_open(main_cdp, menu_text, url_hint, timeout=30.0): + """打开并连上某个 tab:已打开则复用,否则点菜单等新目标出现。 + + 用 url_hint 既能复用已存在的 tab(避免重复打开/重复点击),也能精准识别新目标。 + """ + existing = find_tab_cdp(url_hint) + if existing: + print(f" · 复用已打开的 tab:{existing.eval('location.href')}") + return existing + return open_tab_target(main_cdp, menu_text, url_hint=url_hint, timeout=timeout) + + +def close_tab_by_label(main_cdp, label, timeout=8.0): + """点主页 tab 条上指定标签的关闭按钮(svg X),并确认 tab 真的关掉。 + + 注意:svg 没有 HTMLElement 的 .click(),必须用 dispatchEvent 派发 click。 + """ + js_label = json.dumps(label) + click_js = ( + "(() => {const tab = [...document.querySelectorAll('.w-tabs-draggable-item')]" + ".find(d => d.querySelector('span')?.textContent.trim() === " + js_label + ");" + "if (!tab) return false; const x = tab.querySelector('svg');" + "(x || tab).dispatchEvent(new MouseEvent('click'," + "{bubbles:true, cancelable:true, view:window})); return true;})()" + ) + gone_js = ( + "![...document.querySelectorAll('.w-tabs-draggable-item')]" + ".some(d => d.querySelector('span')?.textContent.trim() === " + js_label + ")" + ) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if main_cdp.eval(gone_js): + return True + main_cdp.eval(click_js) + time.sleep(0.6) + raise TimeoutError(f"关闭 tab「{label}」失败") + + +# ==================================================================== +# 进站交接单查询页(ZK 框架):设日期 / 查询 / 等加载 / 交接单列表 +# ==================================================================== + + +def wait_home_ready(main_cdp): + """主页加载完毕的判据:出现站点名称控件(带 title + fontsizenum 的 div)。""" + wait_until( + main_cdp, + "!!document.querySelector('div[title][fontsizenum]')", + "安能主页加载", + timeout=60.0, + ) + + +def wait_query_page_ready(tab_cdp): + """进站交接单查询页加载完毕:交接单信息统计信息控件(.lblStatistics)出现。""" + wait_until( + tab_cdp, + "!!document.querySelector('.z-toolbar .lblStatistics')", + "进站交接单查询页加载", + ) + + +def set_zk_datebox(tab_cdp, index, date_str): + """直接向第 index 个 z-datebox-inp 写入日期(YYYY-MM-DD),并触发 change。 + + 安能的日期可直接输入(无需日历控件)。ZK datebox 监听 input 的 change 事件。 + """ + js = ( + "(() => {" + f" const inp = document.querySelectorAll('input.z-datebox-inp')[{index}];" + " if (!inp) return false;" + " inp.focus();" + " const setter = Object.getOwnPropertyDescriptor(" + " window.HTMLInputElement.prototype, 'value').set;" + f" setter.call(inp, {json.dumps(date_str)});" + " inp.dispatchEvent(new Event('input', {bubbles:true}));" + " inp.dispatchEvent(new Event('change', {bubbles:true}));" + " inp.blur();" + " return inp.value;" + "})()" + ) + return tab_cdp.eval(js) + + +def click_query_button(tab_cdp): + """点击查询按钮(z-button-os,文本“查询”)。""" + if not tab_cdp.eval( + "(() => {const b = [...document.querySelectorAll('button.z-button-os')]" + ".find(b => b.textContent.trim() === '查询');" + "if (!b) return false; b.click(); return true;})()" + ): + raise RuntimeError("未找到查询按钮") + + +def wait_query_done(tab_cdp): + """查询完成判据:可见的统计项(进站系列)数值带小数点。 + + 初次进页面时所有统计都是“0”(无小数);设定时间并查询、数据加载完毕后, + 即使数值仍为 0,也会变成“0.00”这种带小数点的形式。 + """ + wait_until( + tab_cdp, + "(() => [...document.querySelectorAll('.lblStatistics')]" + ".some(e => /:[ \\t]*\\d*\\.\\d+/.test(e.textContent)))()", + "查询数据加载完成(统计出现带小数点的 0)", + timeout=60.0, + ) + + +def collect_jiaojie_dan_ids(tab_cdp): + """收集交接单信息表里所有交接单号(从行复选框的 ewbsListNo 解析,按出现顺序)。 + + 交接单号是 19 位纯数字,必须按文本保留,避免精度丢失。 + """ + return ( + tab_cdp.eval( + "(() => {" + " const boxes = [...document.querySelectorAll('.z-listbox')];" + " const box = boxes.find(b => {" + " const h = b.querySelector('.z-listhead');" + " const t = h ? h.textContent : '';" + " return t.includes('交接单号') && !t.includes('运单号');" + " });" + " if (!box) return null;" + " const rows = [...box.querySelectorAll('tr.z-listitem')]" + " .filter(tr => tr.querySelector('input[value*=\"ewbsListNo=\"]'));" + " return rows.map(tr => {" + " const inp = tr.querySelector('input[value*=\"ewbsListNo=\"]');" + " const m = /ewbsListNo=(\\d+)/.exec(inp.value || '');" + " return m ? m[1] : null;" + " }).filter(Boolean);" + "})()" + ) + or [] + ) + + +def dblclick_jiaojie_dan_row(tab_cdp, ewbs_no): + """双击指定交接单号的行,激活运单信息页。""" + js_no = json.dumps(ewbs_no) + return ( + tab_cdp.eval( + "(() => {" + " const boxes = [...document.querySelectorAll('.z-listbox')];" + " const box = boxes.find(b => {" + " const h = b.querySelector('.z-listhead');" + " const t = h ? h.textContent : '';" + " return t.includes('交接单号') && !t.includes('运单号');" + " });" + " if (!box) return false;" + " const tr = [...box.querySelectorAll('tr.z-listitem')].find(tr => {" + " const inp = tr.querySelector('input[value*=\"ewbsListNo=\"]');" + " return inp && inp.value.includes('ewbsListNo=' + " + js_no + ");" + " });" + " if (!tr) return false;" + " tr.dispatchEvent(new MouseEvent('dblclick', {bubbles:true, cancelable:true}));" + " return true;" + "})()" + ) + or False + ) + + +def activate_tab(tab_cdp, tab_text): + """点击指定名称的内部 z-tab(如“交接单信息”),切回该页。""" + js_text = json.dumps(tab_text) + return ( + tab_cdp.eval( + "(() => {const tab = [...document.querySelectorAll('.z-tab')]" + f".find(t => t.querySelector('.z-tab-text')?.textContent.trim() === {js_text});" + "if (!tab) return false; tab.click(); return true;})()" + ) + or False + ) + + +def wait_yundan_loaded(tab_cdp, ewbs_no): + """等运单信息加载并核对:运单信息表(表头含“运单号”)首行的交接单号 == 目标。""" + js_no = json.dumps(ewbs_no) + check_js = ( + "(() => {" + " const box = [...document.querySelectorAll('.z-listbox')].find(b => {" + " const h = b.querySelector('.z-listhead');" + " return h && h.textContent.includes('运单号');" + " });" + " if (!box) return null;" + " const headers = [...box.querySelectorAll('tr.z-listhead th')]" + " .map(th => th.textContent.trim());" + " const idx = headers.indexOf('交接单号');" + " if (idx < 0) return null;" + " const row = box.querySelector('tbody tr.z-listitem');" + " if (!row) return null;" + " const cells = row.querySelectorAll('td.z-listcell');" + " if (!cells[idx]) return null;" + " const inp = cells[idx].querySelector('input');" + " return inp ? inp.value : cells[idx].textContent.trim();" + "})()" + ) + wait_until( + tab_cdp, + f"(() => {{ const v = ({check_js}); return v === {js_no}; }})()", + f"运单信息加载并核对单号 {ewbs_no}", + timeout=30.0, + ) + + +# ==================================================================== +# 导出设置对话框(ZK modal):转移全部字段 → 导出数据 → 确认 +# ==================================================================== + + +def _modal_ready_js(): + return ( + "(() => {const m = document.querySelector('.z-window-modal');" + "if (!m) return false;" + "const cap = m.querySelector('.z-window-modal-header');" + "return !!(cap && cap.textContent.trim() === '导出');})()" + ) + + +def _available_count_js(): + """待选导出列里的字段数。""" + return ( + "(() => {const m = document.querySelector('.z-window-modal');" + "if (!m) return 0; const box = [...m.querySelectorAll('.z-listbox')].find(" + " b => b.querySelector('.z-listheader-cnt')?.textContent.trim() === '待选导出列');" + "return box ? box.querySelectorAll('tbody tr.z-listitem').length : 0;})()" + ) + + +def _selected_count_js(): + """已选导出列里的字段数。""" + return ( + "(() => {const m = document.querySelector('.z-window-modal');" + "if (!m) return 0; const box = [...m.querySelectorAll('.z-listbox')].find(" + " b => b.querySelector('.z-listheader-cnt')?.textContent.trim() === '已选导出列');" + "return box ? box.querySelectorAll('tbody tr.z-listitem').length : 0;})()" + ) + + +def _move_first_available_js(): + """把待选导出列的第一个字段移到已选:点选该字段,再点中间的 → 箭头。 + + (这版 ZK 的字段转移靠选中+箭头,dblclick 不生效。) + """ + return ( + "(() => {const m = document.querySelector('.z-window-modal');" + "if (!m) return false; const box = [...m.querySelectorAll('.z-listbox')].find(" + " b => b.querySelector('.z-listheader-cnt')?.textContent.trim() === '待选导出列');" + "if (!box) return false; const item = box.querySelector('tbody tr.z-listitem');" + "if (!item) return false;" + "item.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true}));" + "const arrow = m.querySelector('img[src*=\"rightarrow\"]');" + "if (!arrow) return false; arrow.click(); return true;})()" + ) + + +def export_current_waybill(tab_cdp): + """对当前运单信息页执行一次导出:打开面板 → 转移全部字段 → 导出数据 → 确认。""" + # 0) 若上一次残留了导出面板(如中途异常),先点“关闭”清掉,保证干净 + if tab_cdp.eval(_modal_ready_js()): + print(" -> 检测到残留导出面板,先关闭") + tab_cdp.eval( + "(() => {const m = document.querySelector('.z-window-modal');" + "const b = [...m.querySelectorAll('button.z-button-os')]" + ".find(b => b.textContent.trim() === '关闭');" + "if (b) b.click(); return true;})()" + ) + wait_until(tab_cdp, f"!({_modal_ready_js()})", "关闭残留导出面板", timeout=5.0) + time.sleep(0.3) + + # 1) 点工具栏“导出”按钮打开面板 + if not tab_cdp.eval( + "(() => {const b = [...document.querySelectorAll('button.z-button-os')]" + ".find(b => b.textContent.trim() === '导出');" + "if (!b) return false; b.click(); return true;})()" + ): + raise RuntimeError("未找到工具栏“导出”按钮") + wait_until(tab_cdp, _modal_ready_js(), "导出设置面板打开", timeout=15.0) + + total = tab_cdp.eval(_available_count_js()) + print(f" -> 待选导出列共 {total} 个字段,逐个转移(选中 + → 箭头)…") + + # 2) 逐个把待选字段移到已选(选中第一个 + 点 → 箭头),直到待选清空 + deadline = time.monotonic() + 120.0 + while True: + avail = tab_cdp.eval(_available_count_js()) + if not avail: + break + if time.monotonic() > deadline: + raise RuntimeError("转移导出字段超时") + tab_cdp.eval(_move_first_available_js()) + wait_until( + tab_cdp, + f"(() => {{ const n = {_available_count_js()}; return n < {int(avail)}; }})()", + "字段转移到已选", + timeout=10.0, + ) + time.sleep(0.1) + + # 3) 校验已选导出列数量与原待选一致 + selected = tab_cdp.eval(_selected_count_js()) + if selected != total: + raise RuntimeError(f"已选导出列字段数 {selected} ≠ 待选 {total},转移不完整") + print(f" -> 已选导出列 {selected} 个字段,校验通过") + + # 4) 点“导出数据” + if not tab_cdp.eval( + "(() => {const m = document.querySelector('.z-window-modal');" + "const b = [...m.querySelectorAll('button.z-button-os')]" + ".find(b => b.textContent.trim() === '导出数据');" + "if (!b) return false; b.click(); return true;})()" + ): + raise RuntimeError("未找到“导出数据”按钮") + + # 5) 等“任务添加成功”提示,点“确认”(提示窗与导出窗都会关闭) + wait_until( + tab_cdp, + "(() => {const m = document.querySelector('.z-messagebox-window');" + "return !!(m && m.textContent.includes('任务添加成功'));})()", + "任务添加成功提示", + timeout=20.0, + ) + tab_cdp.eval( + "(() => {const m = document.querySelector('.z-messagebox-window');" + "if (!m) return false; const b = m.querySelector('button.z-messagebox-btn');" + "if (!b) return false; b.click(); return true;})()" + ) + print(" -> 任务添加成功,已确认") + + # 6) 等导出设置面板关闭 + wait_until(tab_cdp, f"!({_modal_ready_js()})", "导出设置面板关闭", timeout=10.0) + + +# ==================================================================== +# 导出下载页:轮询任务 → 免对话框下载 → 合并 +# ==================================================================== + + +def _read_auth(export_cdp): + """从导出下载 tab 读取免对话框下载所需的鉴权信息。""" + x_auth = export_cdp.eval('sessionStorage.getItem("x-auth")') + ua = export_cdp.eval("navigator.userAgent") + referer = export_cdp.eval("location.href") + cookies = export_cdp.call("Network.getCookies", urls=["https://uep.ane56.com"]).get( + "cookies", [] + ) + tgc = next((c["value"] for c in cookies if c["name"] == "TGC"), None) + if not (x_auth and tgc): + raise RuntimeError("缺少 x-auth 或 TGC,无法免对话框下载") + return x_auth, tgc, ua, referer + + +def _read_export_rows(export_cdp): + """读取导出下载表所有行:可见列文本 + 从 React fiber 提取的 fileId。""" + return ( + export_cdp.eval( + "(() => {" + " const headers = [...document.querySelectorAll('.ant-table-thead th')]" + " .map(th => th.getAttribute('title') || th.textContent.trim());" + " const rows = [...document.querySelectorAll('.ant-table-tbody tr.ant-table-row')];" + " return rows.map(tr => {" + " const cells = [...tr.querySelectorAll('td')];" + " const cellMap = {};" + " headers.forEach((h, i) => { if (cells[i]) cellMap[h] = cells[i].textContent.trim(); });" + " let fileId = null;" + " try {" + " const fk = Object.keys(tr).find(k =>" + " k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));" + " if (fk) {" + " let fiber = tr[fk], seen = new Set();" + " for (let i = 0; i < 40 && fiber && !seen.has(fiber); i++) {" + " seen.add(fiber);" + " const p = fiber.memoizedProps;" + " if (p && p.record && typeof p.record === 'object') {" + " for (const v of Object.values(p.record)) {" + " if (typeof v === 'string' && /\\/task\\/|\\.xlsx$/i.test(v)) { fileId = v; break; }" + " }" + " if (!fileId) fileId = p.record.fileId || p.record.filePath || null;" + " break;" + " }" + " fiber = fiber.return;" + " }" + " }" + " } catch (e) {}" + " return { cellMap, fileId };" + " });" + "})()" + ) + or [] + ) + + +def _click_export_query(export_cdp): + """点击导出下载页的“查询”按钮刷新任务列表。""" + export_cdp.eval( + "(() => {const b = document.querySelector('button.ant-btn-primary');" + "if (!b) return false; const hit = [...document.querySelectorAll('button.ant-btn-primary')]" + ".find(b => b.textContent.trim().includes('查询'));" + "if (hit) hit.click(); else b.click(); return true;})()" + ) + + +def _match_our_tasks(rows, export_times): + """从行里挑出本批任务(导出功能名称=交接单明细 且 导出时间接近某次导出时刻)。""" + matched = [] + for row in rows: + cell = row.get("cellMap", {}) + if cell.get("导出功能名称") != TARGET_EXPORT_FUNC: + continue + time_str = cell.get("导出时间", "") + try: + row_time = datetime.strptime(time_str.strip(), "%Y-%m-%d %H:%M:%S") + except ValueError: + continue + if any(abs((row_time - et).total_seconds()) <= 120 for et in export_times): + row["_time"] = row_time + matched.append(row) + return matched + + +def poll_and_download_tasks(export_cdp, export_times, download_dir): + """轮询导出下载页直到本批任务齐全且全部“导出完成”,再逐个免对话框下载。""" + total_expected = len(export_times) + + print(f">> 轮询导出下载任务(期望 {total_expected} 个“{TARGET_EXPORT_FUNC}”)...") + while True: + wait_until( + export_cdp, + "!!document.querySelector('.ant-table-thead')", + "导出下载表加载", + ) + rows = _read_export_rows(export_cdp) + matched = _match_our_tasks(rows, export_times) + done = [ + r for r in matched if r.get("cellMap", {}).get("导出状态") == "导出完成" + ] + print( + f" 📊 匹配本批 {len(matched)}/{total_expected}," + f"完成 {len(done)},生成中 {len(matched) - len(done)}" + ) + if len(matched) >= total_expected and len(done) == len(matched): + matched = done + break + _click_export_query(export_cdp) + time.sleep(3) + + # 任务就绪 = 页面已完全加载并鉴权;此时 sessionStorage 的 x-auth 与 TGC cookie 必然就位 + # (导出下载页刚打开时这两项尚未写入,必须等表格加载/鉴权完成后再读) + wait_until( + export_cdp, + "!!sessionStorage.getItem('x-auth')", + "导出下载页鉴权信息(x-auth)就绪", + timeout=20.0, + ) + x_auth, tgc, ua, referer = _read_auth(export_cdp) + + print(">> 全部任务就绪,开始免对话框下载 ...") + downloaded_files = [] + for idx, row in enumerate(matched, start=1): + file_id = row.get("fileId") + fname = row.get("cellMap", {}).get("文件名", "") + if not file_id: + print( + f" ⚠ 第 {idx} 条未取到 fileId(React fiber 提取失败),跳过:{fname}" + ) + continue + 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=60).read() + if data[:2] != b"PK": + print(f" ⚠ 第 {idx} 条响应非 xlsx(头 {data[:2]!r}),跳过:{fname}") + continue + stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + save_name = f"安能_temp_{stamp}.xlsx" + save_path = os.path.join(download_dir, save_name) + with open(save_path, "wb") as f: + f.write(data) + downloaded_files.append(save_path) + print(f" 已下载 [{idx}/{len(matched)}]: downloads/{save_name} ({fname})") + time.sleep(0.3) + + return downloaded_files + + +def merge_and_cleanup(downloaded_files, download_dir): + """合并临时下载文件为最终文件并清理(与韵达等站点一致)。""" + if not downloaded_files: + print(">> ⚠ 未下载到任何文件,跳过合并。") + return + print(">> 正在合并下载的数据 ...") + all_dfs = [] + for file_path in downloaded_files: + try: + df = pd.read_excel(file_path, dtype=str) + if not df.empty: + all_dfs.append(df) + except Exception: + pass + if all_dfs: + combined = pd.concat(all_dfs, ignore_index=True) + final_output = os.path.join(download_dir, FINAL_FILENAME) + combined.to_excel(final_output, index=False) + print("====================================================") + print(f" 合并完成:{FINAL_FILENAME}(共 {len(combined)} 行)") + print(f" 📁 输出路径: {final_output}") + print("====================================================") + for file_path in downloaded_files: + try: + os.remove(file_path) + except OSError: + pass + print(" 临时文件已清理。") + + +# ==================================================================== +# 主流程 +# ==================================================================== + + +def _load_query_days(): + """从 config.yaml 读 anneng.query_days,默认 1。""" + days = 1 + try: + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + days = int(cfg.get("anneng", {}).get("query_days", 1)) + except Exception as e: + print(f" ⚠ 读取 config.yaml 失败,默认查询 1 天: {e}") + return max(1, days) + + +def anneng_expected_download(): + """安能:应到货物数据(运单信息)下载,完整流程。""" + print("\n▶ 开始执行【安能 - 应到货物数据下载】任务 ...") + download_dir = DOWNLOAD_DIR + os.makedirs(download_dir, exist_ok=True) + + query_days = _load_query_days() + today = datetime.now() + start = today - timedelta(days=(query_days - 1)) + start_str = f"{start.year}-{start.month:02d}-{start.day:02d}" + today_str = f"{today.year}-{today.month:02d}-{today.day:02d}" + print(f">> 查询时间范围: [{start_str}] 至 [{today_str}](近 {query_days} 天)") + + main_cdp = find_main_page_cdp() + export_times = [] + try: + # 1) 确认主页就绪并导航到“进站交接单查询” + wait_home_ready(main_cdp) + print("✅ 安能主页已加载") + print(">> 导航菜单:运营管理 → 进站管理 → 进站交接单查询") + open_submenus(main_cdp, ["运营管理", "进站管理"]) + + tab_cdp = ensure_tab_open(main_cdp, "进站交接单查询", JIAOJIEDAN_URL_HINT) + try: + wait_query_page_ready(tab_cdp) + print("✅ 进站交接单查询页已加载") + + # 2) 设日期 + 查询 + 等加载 + print(">> 正在设置查询时间范围(直接输入日期)...") + set_zk_datebox(tab_cdp, 0, start_str) + set_zk_datebox(tab_cdp, 1, today_str) + time.sleep(0.3) + print(">> 正在执行查询 ...") + click_query_button(tab_cdp) + wait_query_done(tab_cdp) + print("✅ 查询完成,数据已加载") + + # 3) 收集交接单号并逐条导出 + # 行的渲染可能比统计的小数点晚一拍:轮询等行出现;持续为空才视为无数据。 + target_ids = [] + collect_deadline = time.monotonic() + 15.0 + while time.monotonic() < collect_deadline: + target_ids = collect_jiaojie_dan_ids(tab_cdp) + if target_ids: + break + time.sleep(0.5) + print(f">> 共捕获 {len(target_ids)} 条交接单记录") + if not target_ids: + print(">> ⚠ 没有交接单数据,结束。") + return + + for i, ewbs_no in enumerate(target_ids, start=1): + print(f" ⏳ [{i}/{len(target_ids)}] 交接单号 {ewbs_no}") + activate_tab(tab_cdp, "交接单信息") + time.sleep(0.3) + if not dblclick_jiaojie_dan_row(tab_cdp, ewbs_no): + raise RuntimeError(f"找不到交接单 {ewbs_no} 的行") + wait_yundan_loaded(tab_cdp, ewbs_no) + print(f" -> 运单信息已加载并核对单号一致") + export_current_waybill(tab_cdp) + activate_tab(tab_cdp, "交接单信息") + time.sleep(0.3) + export_times.append(datetime.now()) + finally: + tab_cdp.close() + + # 4) 关闭进站交接单查询 tab,保持下次干净 + print(">> 关闭【进站交接单查询】tab ...") + close_tab_by_label(main_cdp, "进站交接单查询") + time.sleep(0.8) + + # 5) 打开导出下载 tab,轮询并下载 + print(">> 打开【导出下载】tab ...") + export_cdp = ensure_tab_open(main_cdp, "导出下载", EXPORT_TAB_URL_HINT) + try: + downloaded = poll_and_download_tasks(export_cdp, export_times, download_dir) + finally: + export_cdp.close() + + # 6) 关闭导出下载 tab + 合并 + print(">> 关闭【导出下载】tab ...") + close_tab_by_label(main_cdp, "导出下载") + time.sleep(0.5) + merge_and_cleanup(downloaded, download_dir) + print("✅ 安能应到数据下载流程完成。") + + except Exception as e: + print(f"\n❌ 任务执行过程中发生异常: {e}") + return False + finally: + main_cdp.close() + + +def main(): + anneng_expected_download() + + +if __name__ == "__main__": + main()