# 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 _capture_error_screenshot(site, kind, attempt, error): """安能 CDP 错误截图(best-effort;失败仅告警,绝不外抛)。""" try: import base64, os from datetime import datetime from inbound_verify.paths import SCREENSHOT_DIR os.makedirs(SCREENSHOT_DIR, exist_ok=True) pages = list_pages() if not pages: return cdp = CDP(pages[0]["webSocketDebuggerUrl"]) ts = datetime.now().strftime("%Y%m%d_%H%M%S") err_short = (error or "unknown")[:40].replace("/", "_").replace("\\", "_") fname = f"{site}_{kind}_{ts}_attempt{attempt}_{err_short}.png" path = os.path.join(SCREENSHOT_DIR, fname) result = cdp.call("Page.captureScreenshot", format="png") with open(path, "wb") as f: f.write(base64.b64decode(result["data"])) cdp.close() print(f"📸 【{site}-{kind}】错误截图已保存: {path}") except Exception as se: print(f"📸 【{site}-{kind}】截图失败(不影响任务): {se}") def with_retry(site_name, label, flow, reset, max_attempts=3, page=None): """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 最后一次失败时(重置前)自动截图保存到 logs/screenshots/,供问题排查。 (安能通过 CDP 截图,page 参数忽略;保留为统一签名兼容。) 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}") if attempt == max_attempts: try: _capture_error_screenshot(site_name, label, attempt, str(e)) except Exception: pass 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} 条未取到 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) # 汇总校验:下载成功数必须等于本批提交的任务数,否则判失败 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, date=None): """安能:应到货物数据下载(内部含异常兜底重试,路由层无感)。""" return with_retry( "安能", "应到", lambda: anneng_expected_download_impl(force=force, date=date), anneng_reset, ) def anneng_expected_download_impl(force=False, date=None): """安能:应到货物数据(运单信息)下载,完整流程(单次执行,无重试;供自动化测试用)。""" 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() if date: target = datetime.strptime(date, "%Y-%m-%d") else: target = today - timedelta(days=offset) target_str = f"{target.year}-{target.month:02d}-{target.day:02d}" start_str = target_str today_str = target_str src = f"指定 {date}" if date else f"偏移 {offset},0=今天" print(f">> 查询日期: [{target_str}]({src})") 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 DatePicker,rc-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): """把每页大小改成 500(Ant 分页 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, date=None): """安能:实到数据下载(内部含异常兜底重试,路由层无感)。""" return with_retry( "安能", "实到", lambda: anneng_actual_download_impl(date=date), anneng_reset ) def anneng_actual_download_impl(date=None): """安能:实到数据(网点到件扫描,子单)下载,完整流程(单次执行,无重试;供自动化测试用)。""" 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() if date: target = datetime.strptime(date, "%Y-%m-%d") else: 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" src = f"指定 {date}" if date else f"偏移 {offset},0=今天" print(f">> 扫描日期: [{start_str} 至 {end_str}]({src})") 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()