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>
This commit is contained in:
Misaka
2026-06-21 19:38:44 +08:00
parent d4a9fb3b4a
commit 9d3d3cbb78
5 changed files with 1476 additions and 24 deletions

895
site_anneng.py Normal file
View File

@@ -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} 条未取到 fileIdReact 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()