Files
InboundVerify/inbound_verify/sites/anneng.py
Misaka_Company f710622a3d feat: dedup expected data by handover_no before export submit
- store.get_existing_handover_nos: query PG expected_record.handover_no

- inject dedup skip before submitting export in zto/yunda/anneng/shunxin

- shunxin reads RTS handover_no from waybill-list view (method 1)

- force-redownload switch threaded via task_spec -> dispatch -> impl

- schema: add idx_expected_handover index

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 10:57:07 +08:00

1413 lines
57 KiB
Python
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.
# sites/anneng.py
#
# 安能全网门户Electron 应用)—— 应到货物数据下载。
#
# 与其他站点(网页、由 runtime 用 Playwright 驱动)不同,安能是一个 Electron
# 桌面应用:由 runtime 以调试模式启动(动态空闲端口,经 set_cdp_port 告知本模块),
# 本模块通过该端口的 CDP 驱动它;「进站交接单查询」「导出下载」等右侧 tab 是
# **独立 webContents**(远程网页),在 /json 里是独立目标。
# 也可脱离 runtime 独立运行(此时用默认端口 9222需已自行启动应用
# python -m inbound_verify.sites.anneng expected # 或 actual
#
# 应到 = 运单信息导出(每张交接单下应到的运单明细)。整体流程:
# 主页(重庆鱼洞镇) → 运营管理 → 进站管理 → 进站交接单查询(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 inbound_verify.paths import DOWNLOAD_DIR, CONFIG_PATH
from inbound_verify import state_store
def with_retry(site_name, label, flow, reset, max_attempts=3):
"""异常兜底flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。
每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。
flow 为零参可调用;返回 False 视为失败,其余视为成功。
返回 True=最终成功False=重试耗尽放弃(供调度层判断任务成败)。
"""
for attempt in range(1, max_attempts + 1):
try:
ret = flow()
if ret is False:
raise RuntimeError("流程返回失败状态")
if attempt > 1:
print(f">> 【{site_name}-{label}】第 {attempt} 次尝试成功 ✅")
return True
except Exception as e:
print(f"⚠️ 【{site_name}-{label}】第 {attempt}/{max_attempts} 次失败: {e}")
print(f" → 重置【{site_name}】到初始态,清理环境 ...")
try:
reset()
except Exception as re:
print(f" ⚠️ 重置异常: {re}")
if attempt < max_attempts:
continue
print(
f"❌ 【{site_name}-{label}】已达最大尝试次数 {max_attempts},放弃(环境已清理)。"
)
return False
CDP_PORT = 9222 # 默认端口独立运行python -m inbound_verify.sites.anneng时用runtime 启动时会用 set_cdp_port 覆盖
POLL_INTERVAL = 0.5
DEFAULT_TIMEOUT = 25.0
def set_cdp_port(port):
"""由 runtime 在启动安能应用后调用,告知本模块实际使用的调试端口。"""
global CDP_PORT
CDP_PORT = int(port)
FINAL_FILENAME = "安能-应到货物数据.xlsx"
# 进站交接单查询页 / 导出下载页的 URL 特征(用于在 /json 里定位/复用 tab 目标)。
JIAOJIEDAN_URL_HINT = (
"ewbs_manage" # 进站交接单查询页 URL 含 center_outsite_ewbs_manage_mgr.zul
)
EXPORT_TAB_URL_HINT = "exportAllRecords"
# 实到(网点到件扫描查询)页相关
SCAN_TAB_URL_HINT = "scan/scan/toSend"
ACTUAL_FINAL_FILENAME = "安能-实到货物数据.xlsx"
# ====================================================================
# 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):
# socket 级超时Electron 业务 tab 偶发不回包时recv 最多卡 15s 即抛
# WebSocketTimeoutException让上层 wait_until/with_retry 能失败→重试,
# 而不是无限阻塞(曾导致安能下载卡死 ~22 分钟、轮询 300s 截止也无法触发)。
self.ws = websocket.create_connection(ws_url, timeout=15)
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():
"""安能主页是否就绪(供 runtime 的就绪轮询调用)。
判据:能连上 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):
"""从行里挑出本批任务:仅按导出时间容差(40s)匹配,不校验导出功能名称。
单账号无并发,本批提交的任务必为我们创建;而站点可能调整导出功能名称,
写死名称反而会导致匹配失败、任务一直查不到。
"""
matched = []
for row in rows:
cell = row.get("cellMap", {})
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()) <= 40 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} 个)...")
poll_deadline = (
time.monotonic() + 300
) # 5 分钟上限:任务卡死/匹配不上时超时失败,交由上层重置重试
while True:
if time.monotonic() > poll_deadline:
raise RuntimeError(
"轮询导出任务超时5 分钟未全部完成/匹配),疑似任务卡死"
)
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)
# 汇总校验:下载成功数必须等于本批提交的任务数,否则判失败
if len(downloaded_files) < total_expected:
raise RuntimeError(
f"仅成功下载 {len(downloaded_files)}/{total_expected} 个任务,数据不完整"
)
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 _remove_if_exists(path):
"""删除文件(若存在):流程开头清理上次的最终文件,避免无数据/失败时残留旧数据。"""
try:
if os.path.exists(path):
os.remove(path)
except Exception:
pass
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(force=False):
"""安能:应到货物数据下载(内部含异常兜底重试,路由层无感)。"""
return with_retry(
"安能", "应到", lambda: anneng_expected_download_impl(force=force), anneng_reset
)
def anneng_expected_download_impl(force=False):
"""安能:应到货物数据(运单信息)下载,完整流程(单次执行,无重试;供自动化测试用)。"""
print("\n▶ 开始执行【安能 - 应到货物数据下载】任务 ...")
download_dir = DOWNLOAD_DIR
os.makedirs(download_dir, exist_ok=True)
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
_remove_if_exists(os.path.join(download_dir, FINAL_FILENAME))
offset = state_store.get_offset("安能")
today = datetime.now()
target = today - timedelta(days=offset)
target_str = f"{target.year}-{target.month:02d}-{target.day:02d}"
start_str = target_str
today_str = target_str
print(f">> 查询日期: [{target_str}](偏移 {offset}0=今天)")
main_cdp = find_main_page_cdp()
export_times = []
# 【去重】加载本站已落库交接单号force=True 或查询失败时 existing=空集(不去重)
if force:
existing = set()
print(">> [去重] 强制重下,跳过去重。")
else:
try:
from inbound_verify import store
existing = store.get_existing_handover_nos("安能")
except Exception as _e:
existing = set()
print(f">> [去重] 加载已落库交接单号失败,本次不去重: {_e}")
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}")
# 【去重】已落库则跳过:不双击、不导出、不 append export_times
if ewbs_no in existing:
print(f" ⏭️ 交接单号 {ewbs_no} 已落库,跳过。")
continue
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)
# 【去重兜底】全部已落库/无数据 → 无导出任务,查询 tab 已关,跳过下载段
if not export_times:
print(">> 本次无新交接单需导出(全部已落库或无数据),结束。")
return True
# 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("✅ 安能应到数据下载流程完成。")
return True
except Exception as e:
print(f"\n❌ 任务执行过程中发生异常: {e}")
return False
finally:
main_cdp.close()
# ====================================================================
# 实到数据:网点到件扫描查询(综合查询 → 扫描查询)
# 该页是 Ant Design且无导出功能——直接从表格 DOM 抽数据,逐页翻页取完。
# ====================================================================
def set_scan_date(cdp, placeholder, value, target_ymd=None):
"""向扫描时间输入框写入日期时间并回车接受Ant Design DatePickerrc-picker 受控组件)。
关键rc-picker 是 React 受控组件,原生 setter 直接改 .value 会被其重渲染冲掉;且控件里
往往已有默认日期,必须先「全选」再整体替换为新值。正确做法对齐人工操作:
进入控件(焦点激活) → 全选既有文本 → insertText 整体替换(派发 input 事件, onChange 受理) → 回车提交。
target_ymd 可显式传入 (y,m,d) 用于写后回读校验;不传则从 value 解析。
返回 True 表示写入并校验成功,否则 False。
"""
import re as _re
ph = json.dumps(placeholder)
val_json = json.dumps(value)
if target_ymd is None:
m = _re.search(r"(\d{4})[-/](\d{1,2})[-/](\d{1,2})", value)
target_ymd = (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None
def _norm(s):
if not s:
return None
mm = _re.search(r"(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})", s)
return (int(mm.group(1)), int(mm.group(2)), int(mm.group(3))) if mm else None
for attempt in range(6):
# 1) 进入控件focus + click 让光标处于激活状态)
cdp.eval(
"(() => {const inp=[...document.querySelectorAll('input')]"
f".find(i=>i.placeholder==={ph}); if(!inp) return false; inp.focus(); inp.click(); return true;}})()"
)
time.sleep(0.35)
# 2) 全选控件里既有的旧日期 → insertText 整体替换为新值rc-picker 的 onChange 会受理);
# execCommand 不可用时回退原生 setter + input 事件。
wrote = cdp.eval(
"(() => {const inp=[...document.querySelectorAll('input')]"
f".find(i=>i.placeholder==={ph}); if(!inp) return false; inp.focus();"
"let sel=true; try{ sel=document.execCommand('selectAll'); }catch(e){ try{inp.select();}catch(_){ sel=false; } }"
"let done=false; try{ done=document.execCommand('insertText',false,"
+ val_json
+ "); }catch(e){ done=false; }"
"if(!done){ const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;"
f" s.call(inp,{val_json}); inp.dispatchEvent(new Event('input',{{bubbles:true}})); }}"
"return inp.value;})()"
)
# 3) 回车提交(文本输入完成按回车即完成设定)
cdp.eval(
"(() => {const inp=[...document.querySelectorAll('input')]"
f".find(i=>i.placeholder==={ph}); if(!inp) return false;"
"inp.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,which:13,bubbles:true}));"
"inp.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',code:'Enter',keyCode:13,which:13,bubbles:true}));"
"return true;})()"
)
# 4) 写后回读校验rc-picker 提交后 input.value 会被清空,故只校验「写后瞬间」的值)
if target_ymd is not None and _norm(wrote) == target_ymd:
return True
time.sleep(0.4)
# 全部尝试失败:告警,避免静默下成「今天」
print(
f" ⚠ set_scan_date 未能将 {placeholder} 设为目标日期 {target_ymd}(请检查 DatePicker 是否就绪)"
)
return False
def select_zidan(cdp):
"""选择扫描记录类型「子单」,并确认其 radio 被选中(表单未就绪/点击未生效则重试)。"""
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
cdp.eval(
"(() => {const t = [...document.querySelectorAll('label')]"
".find(l => l.textContent.trim() === '子单');"
"if (t) t.click(); return true;})()"
)
time.sleep(0.2)
checked = cdp.eval(
"(() => {const t = [...document.querySelectorAll('label')]"
".find(l => l.textContent.trim() === '子单');"
"if (!t) return null; const r = t.querySelector('input[type=radio]');"
"return r ? r.checked : null;})()"
)
if checked:
return True
return False
def click_scan_query(cdp):
cdp.eval(
"(() => {const b = [...document.querySelectorAll('button.ant-btn-primary')]"
".find(b => b.textContent.trim().includes('查询'));"
"if (b) b.click(); return !!b;})()"
)
def set_page_size_500(cdp):
"""把每页大小改成 500Ant 分页 size-changer需 mousedown 才能展开下拉)。"""
cdp.eval(
"(() => {const sel = document.querySelector("
" '.ant-pagination .ant-select-selector');"
"if (!sel) return false; sel.dispatchEvent(new MouseEvent('mousedown',{bubbles:true}));"
"sel.dispatchEvent(new MouseEvent('click',{bubbles:true})); return true;})()"
)
wait_until(
cdp,
"[...document.querySelectorAll('.ant-select-item-option')]"
".some(o => o.textContent.includes('500'))",
"每页大小下拉出现",
timeout=5.0,
)
cdp.eval(
"(() => {const opt = [...document.querySelectorAll('.ant-select-item-option')]"
".find(o => o.textContent.includes('500')); if (opt) opt.click(); return !!opt;})()"
)
time.sleep(0.5)
def read_scan_total(cdp):
"""读取“总共: N 条”里的 N。"""
txt = cdp.eval(
"document.querySelector('.ant-pagination-total-text')?.textContent?.trim() || ''"
)
m = "".join(ch for ch in (txt or "") if ch.isdigit())
return int(m) if m else 0
def extract_scan_rows(cdp):
"""抽取当前页所有数据行(按表头建 dict丢弃空表头列与“操作”列"""
return (
cdp.eval(
"(() => {"
" const headers = [...document.querySelectorAll('.ant-table-thead th')]"
" .map(th => th.textContent.trim());"
" const rows = [...document.querySelectorAll("
" '.ant-table-tbody tr.ant-table-row')];"
" return rows.map(tr => {"
" const cells = [...tr.querySelectorAll('td')].map(td => td.textContent.trim());"
" const o = {};"
" headers.forEach((h, i) => {"
" if (h && h !== '操作' && cells[i] !== undefined) o[h] = cells[i];"
" });"
" return o;"
" });"
"})()"
)
or []
)
def _has_next_page(cdp):
return cdp.eval(
"(() => {const n = document.querySelector('.ant-pagination-next');"
"return !!(n && !n.classList.contains('ant-pagination-disabled'));})()"
)
def _active_page(cdp):
return cdp.eval(
"document.querySelector('.ant-pagination-item-active')?.textContent?.trim() || ''"
)
def goto_next_page(cdp):
"""点下一页,并等到 active 页号 +1、行重新出现。"""
cur = _active_page(cdp)
cdp.eval(
"(() => {const n = document.querySelector('.ant-pagination-next');"
"if (n) n.click(); return true;})()"
)
target = str(int(cur) + 1) if cur.isdigit() else None
if target:
wait_until(
cdp,
f"document.querySelector('.ant-pagination-item-active')?.textContent?.trim()"
f" === {json.dumps(target)}",
f"翻到第 {target}",
timeout=20.0,
)
time.sleep(0.6)
def wait_scan_form_ready(cdp, timeout=20.0):
"""等扫描查询表单渲染完(日期输入框 + 子单 选项出现),并预热 DatePicker 使其完全挂载。
新开的 tab 里 URL 一匹配就返回,但 Ant 表单还在渲染,此时设日期/选子单会落空。
这里除了等元素出现,再额外 settle 一段时间,并依次 focus 两个 DatePicker 打开面板再
Esc 关闭,强制 rc-picker 完成挂载,避免随后 set_scan_date 写入的值被重渲染冲掉。
"""
wait_until(
cdp,
"(() => !!document.querySelector(\"input[placeholder='开始日期']\")"
" && [...document.querySelectorAll('label')].some(l => l.textContent.trim()==='子单'))()",
"扫描查询表单加载",
timeout=timeout,
)
# 额外 settle等 SPA 把表单真正挂载稳定(避免出现后又重渲染把值刷掉)
time.sleep(1.2)
# 预热:依次 focus 两个 DatePicker 打开面板再 Esc 关闭,强制 rc-picker 完全挂载
for ph in ("开始日期", "结束日期"):
cdp.eval(
"(() => {const inp=[...document.querySelectorAll('input')]"
f".find(i=>i.placeholder==={json.dumps(ph)}); if(!inp) return false; inp.focus(); return true;}})()"
)
try:
wait_until(
cdp,
"!!document.querySelector('.ant-picker-panel')",
"预热面板打开",
timeout=4.0,
)
except Exception:
pass
cdp.eval(
"(() => {const inp=[...document.querySelectorAll('input')]"
f".find(i=>i.placeholder==={json.dumps(ph)}); if(!inp) return false;"
"inp.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',code:'Escape',keyCode:27,which:27,bubbles:true}));"
"return true;})()"
)
time.sleep(0.3)
def wait_query_settled(cdp, timeout=20.0):
"""点查询后等结果稳定:先等“总共: N 条”出现(查询完成),再等表格行刷新。"""
try:
wait_until(
cdp,
"!!document.querySelector('.ant-pagination-total-text')",
"扫描查询完成",
timeout=timeout,
)
except TimeoutError:
pass
try:
wait_until(
cdp,
"(() => {const b = document.querySelector('.ant-table-tbody');"
"return !!b && (b.querySelectorAll('tr.ant-table-row').length > 0 "
"|| (b.textContent || '').includes('暂无数据'));})()",
"扫描表格行刷新",
timeout=10.0,
)
except TimeoutError:
pass
time.sleep(0.4)
def wait_total_changed(cdp, old_total, timeout=20.0):
"""等“总共: N 条”里的数值变成与 old_total 不同(用于复用 tab 时避免读到上一次的旧总数)。"""
try:
wait_until(
cdp,
"(() => {const m = (document.querySelector('.ant-pagination-total-text')"
"?.textContent || '').match(/\\d+/);"
f"return !!m && parseInt(m[0]) !== {int(old_total)};}})()",
"扫描查询完成(总数更新)",
timeout=timeout,
)
except TimeoutError:
pass
def _save_actual(rows, download_dir):
out = os.path.join(download_dir, ACTUAL_FINAL_FILENAME)
df = pd.DataFrame(rows)
df.to_excel(out, index=False)
print("====================================================")
print(f" 已保存:{ACTUAL_FINAL_FILENAME}(共 {len(df)} 行)")
print(f" 📁 输出路径: {out}")
print("====================================================")
def anneng_actual_download(force=False):
"""安能:实到数据下载(内部含异常兜底重试,路由层无感)。"""
return with_retry("安能", "实到", anneng_actual_download_impl, anneng_reset)
def anneng_actual_download_impl():
"""安能:实到数据(网点到件扫描,子单)下载,完整流程(单次执行,无重试;供自动化测试用)。"""
print("\n▶ 开始执行【安能 - 实到货物数据下载】任务 ...")
download_dir = DOWNLOAD_DIR
os.makedirs(download_dir, exist_ok=True)
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
_remove_if_exists(os.path.join(download_dir, ACTUAL_FINAL_FILENAME))
offset = state_store.get_offset("安能", "actual")
today = datetime.now()
target = today - timedelta(days=offset)
start_str = f"{target.year}/{target.month:02d}/{target.day:02d} 00:00:00"
end_str = f"{target.year}/{target.month:02d}/{target.day:02d} 23:59:59"
print(f">> 扫描日期: [{start_str}{end_str}](偏移 {offset}0=今天)")
main_cdp = find_main_page_cdp()
try:
# 1) 导航到扫描查询 tab
print(">> 导航菜单:综合查询 → 扫描查询 → 网点到件扫描查询(新)")
open_submenus(main_cdp, ["综合查询", "扫描查询"])
cdp = ensure_tab_open(main_cdp, "网点到件扫描查询(新)", SCAN_TAB_URL_HINT)
try:
wait_scan_form_ready(cdp)
print("✅ 扫描查询页已加载")
# 2) 设扫描时间
print(">> 设置扫描时间范围 …")
set_scan_date(cdp, "开始日期", start_str)
set_scan_date(cdp, "结束日期", end_str)
time.sleep(0.3)
# 3) 选子单
print(">> 选择扫描记录类型:子单 …")
if not select_zidan(cdp):
raise RuntimeError("未能选中扫描类型「子单」")
time.sleep(0.3)
# 4) 首次查询(让分页出现)。复用 tab 时先记下旧总数,查询后等它变化,避免读到旧值。
print(">> 执行查询 …")
old_total = read_scan_total(cdp)
click_scan_query(cdp)
wait_total_changed(cdp, old_total)
wait_query_settled(cdp)
total = read_scan_total(cdp)
print(f" · 总共 {total}")
if total == 0:
print(">> ⚠ 无扫描数据,结束。")
# 无数据:删除残留的旧最终文件(不落空文件),让比对层跳过本站
_remove_if_exists(os.path.join(download_dir, ACTUAL_FINAL_FILENAME))
return True
# 5) 改每页 500 并重新查询
print(">> 设置每页 500 条 …")
set_page_size_500(cdp)
click_scan_query(cdp)
wait_query_settled(cdp)
# 6) 逐页抽取
all_rows = []
page_no = 1
while True:
rows = extract_scan_rows(cdp)
all_rows.extend(rows)
print(f" · 第 {page_no} 页抽取 {len(rows)} 条,累计 {len(all_rows)}")
if not _has_next_page(cdp):
break
goto_next_page(cdp)
page_no += 1
print(f">> 共抽取 {len(all_rows)} 条扫描记录")
if total > 0 and not all_rows:
raise RuntimeError(f"扫描总数 {total} 但抽取到 0 条记录,翻页抽取异常")
_save_actual(all_rows, download_dir)
print("✅ 安能实到数据下载流程完成。")
finally:
cdp.close()
# 7) 关闭扫描查询 tab用完即关
print(">> 关闭【网点到件扫描查询】tab …")
close_tab_by_label(main_cdp, "网点到件扫描查询(新)")
return True
except Exception as e:
print(f"\n❌ 任务执行过程中发生异常: {e}")
return False
finally:
main_cdp.close()
def anneng_reset():
"""异常兜底:把安能重置回刚登录的初始态,供失败重试调用。
关掉所有业务 tab走 tab 条 X由 app 销毁对应 webContents含其上的弹窗+ 收起
展开的子菜单。不用 Page.reload——reload 会让业务 webContents 失去 app 引用、
变成无法清理的僵尸Target.closeTarget / window.close 都杀不掉)。
"""
try:
main_cdp = find_main_page_cdp()
except Exception as e:
print(f">> 安能重置失败:找不到主壳页面: {e}")
return False
try:
labels_json = main_cdp.eval(
"JSON.stringify([...document.querySelectorAll('.w-tabs-draggable-item')]"
".map(t => t.querySelector('span')?.textContent?.trim()).filter(Boolean))"
)
try:
labels = json.loads(labels_json or "[]")
except Exception:
labels = []
closed = 0
for label in labels:
if label and label != "工作台":
try:
close_tab_by_label(main_cdp, label)
closed += 1
time.sleep(0.2)
except Exception as e:
print(f" ⚠️ 关闭 tab「{label}」失败: {e}")
# 收起所有展开的子菜单(循环点掉每个 aria-expanded=true 的标题,含嵌套层级)
loops = 0
while main_cdp.eval(
"(() => {const e = document.querySelector"
"('div.rc-menu-submenu-title[aria-expanded=\"true\"]');"
"if (!e) return false; e.click(); return true;})()"
):
loops += 1
time.sleep(0.15)
if loops > 15:
break
print(f">> 安能已重置到初始态:关闭 {closed} 个业务 tab收起 {loops} 个子菜单")
return True
except Exception as e:
print(f">> 安能重置异常: {e}")
return False
finally:
try:
main_cdp.close()
except Exception:
pass
def main():
flow = sys.argv[1] if len(sys.argv) > 1 else "expected"
if flow == "actual":
anneng_actual_download()
else:
anneng_expected_download()
if __name__ == "__main__":
main()