From 9375b6efd6cfeae98700599dc35c7dd34ba5bd4c Mon Sep 17 00:00:00 2001 From: Misaka Date: Sat, 20 Jun 2026 12:46:46 +0800 Subject: [PATCH] Integrate ZTO site module and add debug/single-site mode - Add site_zto.py: ZTO expected-arrival data download with menu navigation, export polling, and Excel merge - Wire ZTO into main_router: site URL, login detection, menu items [5]/[6], and a global offline Left Anti-Join compare entry [9] - Read config.yaml debug flags to launch only the target site for focused debugging; guard each site init and route handler behind site-loaded checks via is_site_ready() - Expand config.example.yaml with documented debug, baishi, and zto config keys Co-Authored-By: Claude --- config.example.yaml | 35 ++++- main_router.py | 229 +++++++++++++++++----------- site_zto.py | 359 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 535 insertions(+), 88 deletions(-) create mode 100644 site_zto.py diff --git a/config.example.yaml b/config.example.yaml index d19f712..f528997 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,4 +1,37 @@ # config.example.yaml -# 复制本文件为 config.yaml 并填入真实凭据后使用 +# ============================================================================ +# 复制本文件为 config.yaml 并填入真实凭据后使用: +# cp config.example.yaml config.yaml +# +# 说明: +# - config.yaml 已被 .gitignore 忽略,不会提交到仓库,可安全存放凭据。 +# - 所有配置项均设有默认值,未填写时会自动回退到默认行为,不会报错。 +# ============================================================================ + +# ---------------------------------------------------------------------------- +# 调试模式:用于单网点联调,仅挂载并启动指定网点,其余网点不加载。 +# ---------------------------------------------------------------------------- +debug: + # 是否启用调试模式(true=仅启动 target_site 一个网点;false=全量启动所有网点)。 + enabled: false + + # 调试模式下要单独启动的网点名称。 + # 仅在 enabled: true 时生效。可选值:顺心 / 百世 / 中通。 + # 留空或不匹配时将回退为全量模式。 + target_site: "" + +# ---------------------------------------------------------------------------- +# 百世快运 (https://v5.800best.com) +# ---------------------------------------------------------------------------- baishi: + # 应到未到数据导出时需要填写的“登录密码”。 + # 留空会导致导出授权校验失败,无法下载数据。 password: "YOUR_PASSWORD_HERE" + +# ---------------------------------------------------------------------------- +# 中通快运 (https://ws.zto56.com/) +# ---------------------------------------------------------------------------- +zto: + # 应到货物数据的查询时间范围(单位:天,向前回溯 N 天至今天)。 + # 建议保持 1。设置过大且超出日历视窗时,会自动降级为仅查询当天。 + query_days: 1 diff --git a/main_router.py b/main_router.py index 84369be..d2bd2bc 100644 --- a/main_router.py +++ b/main_router.py @@ -1,17 +1,20 @@ # main_router.py import os +import yaml import pandas as pd from playwright.sync_api import sync_playwright # 导入抽离出去的各个网点模块 import site_shunxin import site_baishi +import site_zto # 定义网点及对应的初始登录 URL SITES_CONFIG = { "顺心": "https://sxne.sxjdfreight.com", "百世": "https://v5.800best.com", + "中通": "https://ws.zto56.com/", } @@ -20,7 +23,6 @@ def task_process_undelivered_data(site_name="顺心"): print(f"\n▶ 开始执行【{site_name} - 应到未到数据处理】任务...") download_dir = os.path.join(os.getcwd(), "downloads") - # 动态拼接带网点前缀的文件路径 expected_path = os.path.join(download_dir, f"{site_name}-应到货物数据.xlsx") actual_path = os.path.join(download_dir, f"{site_name}-实到货物数据.xlsx") output_path = os.path.join(download_dir, f"{site_name}-应到未到货物数据.xlsx") @@ -57,7 +59,7 @@ def task_process_undelivered_data(site_name="顺心"): df_output.to_excel(output_path, index=False) print(f"====================================================") - print(f" 🎉 异常比采取流完成!独立数据已安全输出。") + print(f" 🎉 异常比对流完成!独立数据已安全输出。") print(f" 📁 成果归档路径: {output_path}") print(f"====================================================") @@ -67,6 +69,27 @@ def task_process_undelivered_data(site_name="顺心"): def run_multi_site_daemon(): """多网点自动化主控引擎""" + + # 1. 读取配置文件 + debug_mode = False + debug_target = "" + try: + if os.path.exists("config.yaml"): + with open("config.yaml", "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + debug_mode = config.get("debug", {}).get("enabled", False) + debug_target = config.get("debug", {}).get("target_site", "") + except Exception as e: + print(f"⚠️ 读取 config.yaml 异常,将使用全量模式启动: {e}") + + # 动态确定需要启动的站点 + active_sites = {} + if debug_mode and debug_target in SITES_CONFIG: + print(f"\n🛠️ 【调试模式激活】当前仅挂载并启动目标站点: [{debug_target}]") + active_sites = {debug_target: SITES_CONFIG[debug_target]} + else: + active_sites = SITES_CONFIG + with sync_playwright() as p: browser = p.chromium.launch(headless=False) context = browser.new_context(viewport={"width": 1920, "height": 1080}) @@ -77,7 +100,7 @@ def run_multi_site_daemon(): print("【初始化阶段】正在构建多网点运行环境...") print("====================================================") - for site_name, url in SITES_CONFIG.items(): + for site_name, url in active_sites.items(): print(f">> 正在打开【{site_name}】页面: {url}") page = context.new_page() page.goto(url) @@ -86,7 +109,7 @@ def run_multi_site_daemon(): print("\n====================================================") print("⚠️ 【等待人工介入】") - print("请在弹出的浏览器中,依次切换标签页,人工完成所有网点的登录!") + print("请在弹出的浏览器中,人工完成已加载网点的登录!") print("====================================================") input(">> 登录全部完成后,请在此处按下【回车键】正式接管中控台...") @@ -96,103 +119,116 @@ def run_multi_site_daemon(): print("====================================================") # 顺心环境净化 - try: - sx_page = pages_map["顺心"] - sx_page.bring_to_front() - print(">> 正在处理【顺心】网点初始状态...") + if "顺心" in pages_map: + try: + sx_page = pages_map["顺心"] + sx_page.bring_to_front() + print(">> 正在处理【顺心】网点初始状态...") - sx_page.wait_for_selector('h1:has-text("盟商门户网")', timeout=30000) - print(" 🎉 登录成功!系统已接管顺心浏览器。") - sx_page.wait_for_timeout(1000) + sx_page.wait_for_selector('h1:has-text("盟商门户网")', timeout=30000) + print(" 🎉 登录成功!系统已接管顺心浏览器。") + sx_page.wait_for_timeout(1000) - sx_page.locator("a").nth(4).click() - sx_page.wait_for_timeout(1000) - sx_page.get_by_role("button", name="Close").click() - sx_page.wait_for_timeout(1000) - sx_page.get_by_role("button", name="不再询问").click() - sx_page.wait_for_timeout(1000) - print(" ✅ 【顺心】弹窗清理完毕,状态就绪!") - - except Exception as e: - print(f" ⚠️ 【顺心】初始化或弹窗清理异常 (如无弹窗可忽略): {e}") + sx_page.locator("a").nth(4).click() + sx_page.wait_for_timeout(1000) + sx_page.get_by_role("button", name="Close").click() + sx_page.wait_for_timeout(1000) + sx_page.get_by_role("button", name="不再询问").click() + sx_page.wait_for_timeout(1000) + print(" ✅ 【顺心】弹窗清理完毕,状态就绪!") + except Exception as e: + print(f" ⚠️ 【顺心】初始化或弹窗清理异常 (如无弹窗可忽略): {e}") # 百世环境检测与“打地鼠”清理引擎 - try: - bs_page = pages_map["百世"] - bs_page.bring_to_front() - print("\n>> 正在处理【百世】网点初始状态...") + if "百世" in pages_map: + try: + bs_page = pages_map["百世"] + bs_page.bring_to_front() + print("\n>> 正在处理【百世】网点初始状态...") - bs_page.wait_for_selector('h1[title="百世快运"]', timeout=30000) - print(" 🎉 登录成功!系统已接管百世浏览器。") + bs_page.wait_for_selector('h1[title="百世快运"]', timeout=30000) + print(" 🎉 登录成功!系统已接管百世浏览器。") - print(" >> 启动无序弹窗清理机制 (打地鼠模式)...") - # 留出一点时间让所有前端请求返回并渲染弹窗 - bs_page.wait_for_timeout(2000) + print(" >> 启动无序弹窗清理机制 (打地鼠模式)...") + bs_page.wait_for_timeout(2000) - # 最多循环 5 轮,每次尝试点掉视野内的所有垃圾弹窗 - for round_idx in range(5): - handled_any = False + for round_idx in range(5): + handled_any = False + try: + read_btns = bs_page.locator("button:has-text('阅读完毕')") + if read_btns.count() > 0: + for i in range(read_btns.count()): + if read_btns.nth(i).is_visible(): + read_btns.nth(i).click() + bs_page.wait_for_timeout(500) + handled_any = True + except Exception: + pass - # 1. 清理【阅读完毕】(可能叠加出现多个) - try: - read_btns = bs_page.locator("button:has-text('阅读完毕')") - if read_btns.count() > 0: - for i in range(read_btns.count()): - if read_btns.nth(i).is_visible(): - read_btns.nth(i).click() - bs_page.wait_for_timeout(500) - handled_any = True - print(" -> 已点击【阅读完毕】") - except Exception: - pass + try: + close_config = bs_page.locator("button:has-text('关 闭')") + if close_config.is_visible(): + close_config.click() + bs_page.wait_for_timeout(500) + handled_any = True + except Exception: + pass - # 2. 清理【配置检查 -> 关 闭】 - try: - close_config = bs_page.locator("button:has-text('关 闭')") - if close_config.is_visible(): - close_config.click() - bs_page.wait_for_timeout(500) - handled_any = True - print(" -> 已关闭【配置检查】") - except Exception: - pass + try: + notice_close = bs_page.locator( + "a.ant-notification-notice-close" + ) + if notice_close.is_visible(): + notice_close.click() + bs_page.wait_for_timeout(500) + handled_any = True + except Exception: + pass - # 3. 清理【通知提示】 - try: - notice_close = bs_page.locator("a.ant-notification-notice-close") - if notice_close.is_visible(): - notice_close.click() - bs_page.wait_for_timeout(500) - handled_any = True - print(" -> 已关闭【通知提示】") - except Exception: - pass + try: + ad_close = bs_page.locator("svg[data-icon='close-circle']") + if ad_close.is_visible(): + ad_close.click() + bs_page.wait_for_timeout(500) + handled_any = True + except Exception: + pass - # 4. 清理【广告】(捕获具有 close-circle 图标的 SVG) - try: - ad_close = bs_page.locator("svg[data-icon='close-circle']") - if ad_close.is_visible(): - ad_close.click() - bs_page.wait_for_timeout(500) - handled_any = True - print(" -> 已关闭【广告弹窗】") - except Exception: - pass + if not handled_any: + break + bs_page.wait_for_timeout(1000) - # 如果这一轮没有任何可见的弹窗被处理,说明页面已经干净了,跳出循环 - if not handled_any: - break + print(" ✅ 【百世】界面净化完毕,状态就绪!") + except Exception as e: + print(f" ⚠️ 【百世】初始化异常: {e}") - bs_page.wait_for_timeout(1000) + # 中通环境检测 + if "中通" in pages_map: + try: + zto_page = pages_map["中通"] + zto_page.bring_to_front() + print("\n>> 正在处理【中通】网点初始状态...") - print(" ✅ 【百世】界面净化完毕,状态就绪!") + zto_page.wait_for_selector('.logo:has-text("网点版")', timeout=30000) + print(" 🎉 登录成功!系统已接管中通浏览器。") + print(" ✅ 【中通】状态就绪!") + except Exception as e: + print(f" ⚠️ 【中通】初始化异常: {e}") - except Exception as e: - print(f" ⚠️ 【百世】初始化异常: {e}") + # 路由拦截器 + def is_site_ready(site_name): + if site_name not in pages_map: + print( + f"\n🚫 【拒绝执行】当前处于单站点调试模式,或【{site_name}】未加载,无法执行此操作!" + ) + return False + return True while True: print("\n====================================================") print(" 物流数据多端提取总枢纽 ") + if debug_mode: + print(f" [ 🛠️ 调试模式已激活 : {debug_target} ]") print("====================================================") print(" 模块一:【顺心】数据处理流") print(" [1] 执行 - 应到货物数据下载") @@ -202,28 +238,47 @@ def run_multi_site_daemon(): print(" 模块二:【百世】数据处理流") print(" [4] 执行 - 一键提取应到未到异常数据") print("-" * 52) + print(" 模块三:【中通】数据处理流") + print(" [5] 执行 - 应到货物数据下载") + print(" [6] 执行 - 实到货物数据下载 (待开发)") + print("-" * 52) + print(" 全局数据引擎") + print(" [9] 执行 - 离线异常数据清洗比对 (Left Anti-Join)") + print("-" * 52) print(" [0] 退出系统") print("====================================================") choice = input("请输入任务编号并回车: ") try: - if choice == "1": + if choice == "1" and is_site_ready("顺心"): pages_map["顺心"].bring_to_front() site_shunxin.shunxin_expected_download(pages_map["顺心"]) - elif choice == "2": + elif choice == "2" and is_site_ready("顺心"): pages_map["顺心"].bring_to_front() site_shunxin.shunxin_actual_download(pages_map["顺心"]) - elif choice == "3": + elif choice == "3" and is_site_ready("顺心"): task_process_undelivered_data("顺心") - elif choice == "4": + elif choice == "4" and is_site_ready("百世"): pages_map["百世"].bring_to_front() site_baishi.baishi_download_undelivered_data(pages_map["百世"]) + elif choice == "5" and is_site_ready("中通"): + pages_map["中通"].bring_to_front() + site_zto.zto_expected_download(pages_map["中通"]) + elif choice == "6" and is_site_ready("中通"): + pages_map["中通"].bring_to_front() + site_zto.zto_actual_download(pages_map["中通"]) + elif choice == "9": + site_name = input("请输入要比对的网点名称 (如 顺心/中通): ").strip() + if not site_name: + site_name = "顺心" + task_process_undelivered_data(site_name) elif choice == "0": print("\n准备退出程序,释放浏览器资源...") break else: - print("\n⚠️ 无效输入,请重新选一下。") + if choice not in ["1", "2", "3", "4", "5", "6", "9", "0"]: + print("\n⚠️ 无效输入,请重新选一下。") except Exception as e: print(f"❌ 调度执行异常: {e}") diff --git a/site_zto.py b/site_zto.py new file mode 100644 index 0000000..373953e --- /dev/null +++ b/site_zto.py @@ -0,0 +1,359 @@ +# site_zto.py + +import os +import re +import yaml +from datetime import datetime +import pandas as pd + + +def _wait_and_get_frame(page, text_indicator, timeout_ms=20000): + """动态雷达探测器:全域扫描所有视窗""" + start_time = datetime.now() + while (datetime.now() - start_time).total_seconds() * 1000 < timeout_ms: + # 1. 探测最外层 + try: + if page.get_by_text(text_indicator).count() > 0: + return page + except: + pass + + # 2. 穿透扫描所有活跃的子 iframe + for frame in page.frames: + try: + if frame.get_by_text(text_indicator).count() > 0: + return frame + except: + pass + + page.wait_for_timeout(300) + raise TimeoutError(f"爆栈超时:全域未死守到包含 [{text_indicator}] 的弹窗视窗。") + + +def _ensure_menu_expanded(page, level1_name, level2_name=None): + """MiniUI 智能菜单展开器""" + print(f">> 正在智能路由菜单...") + if level2_name: + if not page.locator( + "li.shrink span.menu-name", has_text=level2_name + ).is_visible(): + page.locator( + "a.treeview-title span.menu-name", has_text=level1_name + ).click() + page.wait_for_timeout(500) + else: + if not page.locator( + "a.treeview-title span.menu-name", has_text=level1_name + ).is_visible(): + page.locator( + "a.treeview-title span.menu-name", has_text=level1_name + ).click() + page.wait_for_timeout(500) + + +def zto_smart_menu_click(page, menu_path): + """中通智能菜单导航器""" + print(f">> 正在导航: {' -> '.join(menu_path)}") + for i in range(len(menu_path)): + current_menu = menu_path[i] + if i < len(menu_path) - 1: + next_menu = menu_path[i + 1] + next_locator = page.locator("span.menu-name", has_text=next_menu).first + if not next_locator.is_visible(): + page.locator("span.menu-name", has_text=current_menu).first.click() + page.wait_for_timeout(800) + else: + page.locator("span.menu-name", has_text=current_menu).first.click() + page.wait_for_timeout(1000) + + +def zto_expected_download(page): + """中通:应到货物数据下载""" + print("\n▶ 开始执行【中通 - 应到货物数据下载】任务...") + + download_dir = os.path.join(os.getcwd(), "downloads") + if not os.path.exists(download_dir): + os.makedirs(download_dir) + + export_times = [] + + try: + # 1. 智能导航 + zto_smart_menu_click(page, ["运营管理", "进站管理", "进站交接单查询"]) + + ewb_frame = page.frame_locator('iframe[src*="inEwbsListNoSearch"]') + + print(">> 正在探针检测表格统计面板 (#inEwbCount)...") + ewb_frame.locator("#inEwbCount").wait_for(state="attached", timeout=15000) + + # 读取 YAML 配置设定天数 + query_days = 1 + try: + if os.path.exists("config.yaml"): + with open("config.yaml", "r", encoding="utf-8") as f: + config = yaml.safe_load(f) or {} + query_days = int(config.get("zto", {}).get("query_days", 1)) + except Exception as e: + print(f" ⚠️ 读取 config.yaml 失败,默认查询 1 天: {e}") + + print(f">> 正在设定查询时间范围为近【{query_days}】天...") + ewb_frame.locator("#beginDate").click() + page.wait_for_timeout(500) + + today_cell = ewb_frame.locator("td div.day.real-today").first + today_cell.wait_for(state="visible") + + today_time_str = today_cell.get_attribute("time") + if today_time_str: + today_time = int(today_time_str) + start_time = today_time - (query_days - 1) * 86400000 + start_cell = ewb_frame.locator(f"td div.day[time='{start_time}']").first + + if start_cell.is_visible(): + start_cell.click() + page.wait_for_timeout(300) + today_cell.click() + else: + print(" ⚠️ 设定的天数过大,不在当前日历视窗内,自动降级为查询当天。") + today_cell.click() + page.wait_for_timeout(300) + today_cell.click() + else: + today_cell.click() + page.wait_for_timeout(300) + today_cell.click() + + page.wait_for_timeout(500) + + # 3. 触发查询与深度状态机判定 + print(">> 正在点击【查询】按钮并等待数据响应...") + ewb_frame.locator("#searchbtn").click() + + page.wait_for_timeout(1000) + + loading_mask = ewb_frame.locator(".mini-mask-loading", has_text="加载中") + if loading_mask.is_visible(): + print(" ⏳ 检测到数据加载遮罩层,等待系统渲染...") + loading_mask.wait_for(state="hidden", timeout=30000) + + page.wait_for_timeout(1000) + + empty_flag = ewb_frame.locator("span", has_text="没有搜索到符合条件的数据记录") + if empty_flag.is_visible(): + print(" ⚠️ 当前查询范围内【没有搜索到符合条件的数据记录】,终止导出。") + return + + ticket_count_str = ewb_frame.locator("#inEwbCount").inner_text().strip() + print(f" ✅ 数据渲染完毕!当前进站实际票数为: [{ticket_count_str}]") + + # 4. 提取主表记录并循环双击 + main_rows = ewb_frame.locator("#datagrid1 .mini-grid-rows-view .mini-grid-row") + count = main_rows.count() + print(f">> 共发现 {count} 个交接单需要导出。") + + for i in range(count): + print(f" ⏳ 正在处理第 {i+1}/{count} 个交接单...") + + row = ewb_frame.locator( + "#datagrid1 .mini-grid-rows-view .mini-grid-row" + ).nth(i) + + # 正则表达式清洗交接单号 + raw_text = row.locator("td").nth(3).inner_text() + match = re.search(r"\d{18}", raw_text) + handover_no = match.group(0) if match else raw_text.strip() + print(f" -> 锁定提取单号:{handover_no}") + + row.dblclick() + + ewb_frame.locator("#datagrid2").get_by_text("运单号").wait_for( + state="visible" + ) + ewb_frame.locator( + "#datagrid2 .mini-grid-row", has_text=handover_no + ).first.wait_for(state="visible") + + # 5. 执行导出流程 + ewb_frame.locator("#exportExcel").click() + + page.locator(".mini-panel-title", has_text="导出选择列").wait_for( + state="visible" + ) + export_frame = page.frame_locator('iframe[src*="download"]') + + export_frame.locator(".mini-button-text", has_text=">>").click() + page.wait_for_timeout(300) + export_frame.locator(".mini-button-text", has_text="确定").click() + + print(" >> [雷达扫描] 正在跨域动态追踪【温馨提示】所在的隐身视窗...") + ctx_alert = _wait_and_get_frame(page, "温馨提示") + + ctx_alert.locator( + ".mini-messagebox-buttons .mini-button-text", has_text="确定" + ).click() + print(" ✅ 【温馨提示】已成功通过父级过滤并确认!") + + print(" >> 正在等待服务器建立后台离线任务...") + try: + ctx_tips = _wait_and_get_frame( + page, "生成离线导出任务成功", timeout_ms=10000 + ) + ctx_tips.locator(".mini-tips-success").wait_for( + state="hidden", timeout=15000 + ) + print(" ✅ 成功提示框已平滑隐藏。") + except Exception as e: + print(" ✅ 成功提示框及其容器已自动销毁 (触发 Detached 拦截)。") + + export_times.append(datetime.now()) + + # 同域无缝 Tab 切换回交接单信息 + print(" >> 切换回【交接单信息】标签页...") + ewb_frame.locator("#ewbsListNo").click() + page.wait_for_timeout(1000) + + if count > 0: + print("✅ 所有交接单的导出任务已成功提交!") + else: + print("⚠️ 未发现任何数据,直接跳转至下载环节。") + + # 6. 前往【导出任务管理】 + print("\n>> 正在前往【导出任务管理】界面...") + zto_smart_menu_click(page, ["系统配置", "导出任务管理"]) + + taskdone_frame = page.frame_locator('iframe[src*="taskdone"]') + + taskdone_frame.locator("#taskdoneDatagrid").get_by_text("任务标题").wait_for( + state="visible" + ) + page.wait_for_timeout(2000) + + # 7. 轮询任务状态 + print(">> 列表中已渲染,开始匹配并检查后端处理状态...") + while True: + task_rows = taskdone_frame.locator( + "#taskdoneDatagrid .mini-grid-rows-view .mini-grid-row" + ) + row_count = task_rows.count() + pending_tasks = 0 + current_ready_timestamps = [] + + for i in range(row_count): + tds = task_rows.nth(i).locator("td") + if tds.count() < 10: + continue + + title_str = tds.nth(3).inner_text().strip() + submit_time_str = tds.nth(4).inner_text().strip() + status_str = tds.nth(6).inner_text().strip() + + if title_str == "进站交接单查询-运单信息": + try: + row_time = datetime.strptime( + submit_time_str, "%Y-%m-%d %H:%M:%S" + ) + matched = any( + abs((row_time - et).total_seconds()) <= 120 + for et in export_times + ) + + if matched: + if status_str != "成功执行": + pending_tasks += 1 + if submit_time_str not in current_ready_timestamps: + print( + f" ⏳ 任务 [{submit_time_str}] 状态为【{status_str}】,数据生成中..." + ) + else: + if submit_time_str not in current_ready_timestamps: + current_ready_timestamps.append(submit_time_str) + except Exception as e: + print(f" ⚠️ 解析时间时出错: {e}") + + if pending_tasks > 0: + print( + f">> 共有 {pending_tasks} 个匹配任务还在处理中,等待 5 秒后刷新..." + ) + page.wait_for_timeout(5000) + page.locator("li.leaf span.menu-name", has_text="导出任务管理").click() + page.wait_for_timeout(2000) + else: + target_task_timestamps = current_ready_timestamps + if len(target_task_timestamps) > 0: + print(">> ✅ 所有目标任务已就绪!开始并行下载...") + break + + # 8. 下载逻辑 + downloaded_files = [] + for time_str in target_task_timestamps: + try: + target_row = taskdone_frame.locator( + "#taskdoneDatagrid .mini-grid-rows-view .mini-grid-row" + ).filter( + has=taskdone_frame.locator( + f"td:nth-child(5):has-text('{time_str}')" + ) + ) + print(f" 🎯 触发下载 -> 任务 [{time_str}] ...") + + # 闭环 Checkbox 勾选逻辑 + checkbox = target_row.locator(".mini-grid-checkbox") + if checkbox.is_visible(): + checkbox.click() + print(" -> 已勾选当前记录的 Checkbox") + page.wait_for_timeout(500) + + with page.expect_download() as download_info: + target_row.locator("td").nth(10).locator(".ui-btn-download").click() + + download = download_info.value + save_path = os.path.join(download_dir, download.suggested_filename) + download.save_as(save_path) + downloaded_files.append(save_path) + print(f" ⬇️ 文件已落盘: downloads/{download.suggested_filename}") + + if checkbox.is_visible(): + checkbox.click() + print(" -> 已取消勾选,清理现场进入下一条") + page.wait_for_timeout(500) + + except Exception as e: + print(f" ❌ 下载任务 [{time_str}] 失败: {e}") + + # 9. 合并数据 + if downloaded_files: + print("\n>> 🧪 正在开始执行扁平数据高能合并流程...") + all_data_frames = [] + for file_path in downloaded_files: + try: + # ==================================================================== + # 🛡️ 核心修复:强制以字符串类型 (str) 读取所有列,彻底防止长单号变科学计数法或丢失精度 + # ==================================================================== + df = pd.read_excel(file_path, dtype=str) + if not df.empty: + all_data_frames.append(df) + except Exception as e: + pass + + if all_data_frames: + combined_df = pd.concat(all_data_frames, ignore_index=True) + final_output_path = os.path.join(download_dir, "中通-应到货物数据.xlsx") + combined_df.to_excel(final_output_path, index=False) + print(f"====================================================") + print(f" 🎉 恭喜!合并成功!最终输出路径: {final_output_path}") + print(f"====================================================") + + for file_path in downloaded_files: + os.remove(file_path) + print("✅ 临时数据清理完毕。") + print("\n🎉 【中通 - 应到货物数据下载】全流程测试完毕!") + + except Exception as e: + print(f"\n❌ 任务执行过程中发生异常: {e}") + + +def zto_actual_download(page): + """中通:实到货物数据下载""" + print("\n▶ 开始执行【中通 - 实到货物数据下载】任务...") + print("🚧 逻辑开发中 (pass)...") + pass