diff --git a/main_router.py b/main_router.py index f9c7765..7dbd250 100644 --- a/main_router.py +++ b/main_router.py @@ -20,12 +20,12 @@ import site_anneng # 应到未到比对(全站点,离线处理 downloads/ 下的应到/实到数据) import expected_undelivered -# 定义网点及对应的初始登录 URL +# 各网页站点首页 URL 统一取自对应站点模块的 HOME_URL(单一来源,不在两处维护)。 SITES_CONFIG = { - "顺心": "https://sxne.sxjdfreight.com", - "百世": "https://v5.800best.com", - "中通": "https://ws.zto56.com/", - "韵达": "https://ky-sso.yunda56.com", + "顺心": site_shunxin.HOME_URL, + "百世": site_baishi.HOME_URL, + "中通": site_zto.HOME_URL, + "韵达": site_yunda.HOME_URL, } # ==================================================================== @@ -104,22 +104,24 @@ def run_automation_test(pages_map): 判定规则:流程函数返回 False 或抛出异常记为 FAIL,其余记为 PASS。 """ # 站点 -> {流程键: (中文名, 流程函数)};百世为单流程,单独处理 + # 自动化测试刻意走各站点的 _impl(单次执行、无兜底重试),以便探测原始失败、 + # 不被模块内部"失败→重置→重试"机制掩盖。 flow_table = { "顺心": { - "expected": ("应到", site_shunxin.shunxin_expected_download), - "actual": ("实到", site_shunxin.shunxin_actual_download), + "expected": ("应到", site_shunxin.shunxin_expected_download_impl), + "actual": ("实到", site_shunxin.shunxin_actual_download_impl), }, "中通": { - "expected": ("应到", site_zto.zto_expected_download), - "actual": ("实到", site_zto.zto_actual_download), + "expected": ("应到", site_zto.zto_expected_download_impl), + "actual": ("实到", site_zto.zto_actual_download_impl), }, "韵达": { - "expected": ("应到", site_yunda.yunda_expected_download), - "actual": ("实到", site_yunda.yunda_actual_download), + "expected": ("应到", site_yunda.yunda_expected_download_impl), + "actual": ("实到", site_yunda.yunda_actual_download_impl), }, "安能": { - "expected": ("应到", site_anneng.anneng_expected_download), - "actual": ("实到", site_anneng.anneng_actual_download), + "expected": ("应到", site_anneng.anneng_expected_download_impl), + "actual": ("实到", site_anneng.anneng_actual_download_impl), }, } @@ -133,7 +135,9 @@ def run_automation_test(pages_map): plan.append((site_name, label, func)) # 百世:单流程,跑一次即可 if "百世" in pages_map: - plan.append(("百世", "应到未到", site_baishi.baishi_download_undelivered_data)) + plan.append( + ("百世", "应到未到", site_baishi.baishi_download_undelivered_data_impl) + ) if not plan: print("\n⚠️ 当前没有已就绪的站点,无法执行自动化测试。") @@ -419,26 +423,33 @@ def run_multi_site_daemon(): try: if choice == "1" and is_site_ready("顺心"): - pages_map["顺心"].bring_to_front() - site_shunxin.shunxin_expected_download(pages_map["顺心"]) + page = pages_map["顺心"] + page.bring_to_front() + site_shunxin.shunxin_expected_download(page) elif choice == "2" and is_site_ready("顺心"): - pages_map["顺心"].bring_to_front() - site_shunxin.shunxin_actual_download(pages_map["顺心"]) + page = pages_map["顺心"] + page.bring_to_front() + site_shunxin.shunxin_actual_download(page) elif choice == "3" and is_site_ready("百世"): - pages_map["百世"].bring_to_front() - site_baishi.baishi_download_undelivered_data(pages_map["百世"]) + page = pages_map["百世"] + page.bring_to_front() + site_baishi.baishi_download_undelivered_data(page) elif choice == "4" and is_site_ready("中通"): - pages_map["中通"].bring_to_front() - site_zto.zto_expected_download(pages_map["中通"]) + page = pages_map["中通"] + page.bring_to_front() + site_zto.zto_expected_download(page) elif choice == "5" and is_site_ready("中通"): - pages_map["中通"].bring_to_front() - site_zto.zto_actual_download(pages_map["中通"]) + page = pages_map["中通"] + page.bring_to_front() + site_zto.zto_actual_download(page) elif choice == "6" and is_site_ready("韵达"): - pages_map["韵达"].bring_to_front() - site_yunda.yunda_expected_download(pages_map["韵达"]) + page = pages_map["韵达"] + page.bring_to_front() + site_yunda.yunda_expected_download(page) elif choice == "7" and is_site_ready("韵达"): - pages_map["韵达"].bring_to_front() - site_yunda.yunda_actual_download(pages_map["韵达"]) + page = pages_map["韵达"] + page.bring_to_front() + site_yunda.yunda_actual_download(page) elif choice == "10" and is_site_ready("安能"): site_anneng.anneng_expected_download() elif choice == "11" and is_site_ready("安能"): diff --git a/site_anneng.py b/site_anneng.py index bc8797a..6863d75 100644 --- a/site_anneng.py +++ b/site_anneng.py @@ -41,6 +41,35 @@ sys.stdout.reconfigure(encoding="utf-8") from paths import DOWNLOAD_DIR, CONFIG_PATH + +def with_retry(site_name, label, flow, reset, max_attempts=3): + """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 + + 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + flow 为零参可调用;返回 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 + 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},放弃(环境已清理)。" + ) + + CDP_PORT = 9222 # 默认端口(独立运行 site_anneng.py 时用);main_router 启动时会用 set_cdp_port 覆盖 POLL_INTERVAL = 0.5 DEFAULT_TIMEOUT = 25.0 @@ -799,7 +828,13 @@ def _load_query_days(): def anneng_expected_download(): - """安能:应到货物数据(运单信息)下载,完整流程。""" + """安能:应到货物数据下载(内部含异常兜底重试,路由层无感)。""" + + return with_retry("安能", "应到", anneng_expected_download_impl, anneng_reset) + + +def anneng_expected_download_impl(): + """安能:应到货物数据(运单信息)下载,完整流程(单次执行,无重试;供自动化测试用)。""" print("\n▶ 开始执行【安能 - 应到货物数据下载】任务 ...") download_dir = DOWNLOAD_DIR os.makedirs(download_dir, exist_ok=True) @@ -1094,7 +1129,13 @@ def _save_actual(rows, download_dir): def anneng_actual_download(): - """安能:实到数据(网点到件扫描,子单)下载,完整流程。""" + """安能:实到数据下载(内部含异常兜底重试,路由层无感)。""" + + 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) @@ -1169,6 +1210,59 @@ def anneng_actual_download(): 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": diff --git a/site_baishi.py b/site_baishi.py index e202da0..168472a 100644 --- a/site_baishi.py +++ b/site_baishi.py @@ -7,6 +7,44 @@ from playwright.sync_api import sync_playwright from paths import DOWNLOAD_DIR, CONFIG_PATH +def with_retry(site_name, label, flow, reset, max_attempts=3): + """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 + + 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + flow 为零参可调用;返回 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 + 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},放弃(环境已清理)。" + ) + + +# 站点首页 URL:异常兜底重置用,也供 main_router 的 SITES_CONFIG 引用(单一来源) +HOME_URL = "https://v5.800best.com" + + +def baishi_reset(page): + """异常兜底:重置百世到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。""" + page.goto(HOME_URL) + page.wait_for_timeout(1500) + + def _close_tab(page, tab_name): """关闭指定名称的百世标签页。 @@ -27,7 +65,18 @@ def _close_tab(page, tab_name): def baishi_download_undelivered_data(page): - """百世:一键提取应到未到(当日未扫)数据""" + """百世:一键提取应到未到(当日未扫)数据(内部含异常兜底重试,路由层无感)。""" + + return with_retry( + "百世", + "应到未到", + lambda: baishi_download_undelivered_data_impl(page), + lambda: baishi_reset(page), + ) + + +def baishi_download_undelivered_data_impl(page): + """百世:一键提取应到未到(当日未扫)数据(单次执行,无重试;供自动化测试用)。""" print("\n▶ 开始执行【百世 - 一键提取应到未到数据】任务...") download_dir = DOWNLOAD_DIR diff --git a/site_shunxin.py b/site_shunxin.py index e055506..5a6df56 100644 --- a/site_shunxin.py +++ b/site_shunxin.py @@ -9,6 +9,44 @@ import pandas as pd from paths import DOWNLOAD_DIR, CONFIG_PATH +def with_retry(site_name, label, flow, reset, max_attempts=3): + """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 + + 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + flow 为零参可调用;返回 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 + 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},放弃(环境已清理)。" + ) + + +# 站点首页 URL:异常兜底重置用,也供 main_router 的 SITES_CONFIG 引用(单一来源) +HOME_URL = "https://sxne.sxjdfreight.com" + + +def shunxin_reset(page): + """异常兜底:重置顺心到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。""" + page.goto(HOME_URL) + page.wait_for_timeout(1500) + + def _close_tab(page, tab_name): """关闭指定名称的标签页(Ant Design Tabs)。 @@ -33,7 +71,18 @@ def _close_tab(page, tab_name): def shunxin_expected_download(page): - """顺心:应到货物数据下载""" + """顺心:应到货物数据下载(内部含异常兜底重试,路由层无感)。""" + + return with_retry( + "顺心", + "应到", + lambda: shunxin_expected_download_impl(page), + lambda: shunxin_reset(page), + ) + + +def shunxin_expected_download_impl(page): + """顺心:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" print("\n▶ 开始执行【顺心 - 应到货物数据下载】任务...") # 初始化并创建下载目录 @@ -308,7 +357,18 @@ def shunxin_expected_download(page): def shunxin_actual_download(page): - """顺心:实到货物数据下载""" + """顺心:实到货物数据下载(内部含异常兜底重试,路由层无感)。""" + + return with_retry( + "顺心", + "实到", + lambda: shunxin_actual_download_impl(page), + lambda: shunxin_reset(page), + ) + + +def shunxin_actual_download_impl(page): + """顺心:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" print("\n▶ 开始执行【顺心 - 实到货物数据下载】任务...") download_dir = DOWNLOAD_DIR diff --git a/site_yunda.py b/site_yunda.py index 3f9dc06..bc3f5fb 100644 --- a/site_yunda.py +++ b/site_yunda.py @@ -9,6 +9,44 @@ import pandas as pd from paths import DOWNLOAD_DIR, CONFIG_PATH +def with_retry(site_name, label, flow, reset, max_attempts=3): + """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 + + 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + flow 为零参可调用;返回 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 + 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},放弃(环境已清理)。" + ) + + +# 站点首页 URL:异常兜底重置用,也供 main_router 的 SITES_CONFIG 引用(单一来源) +HOME_URL = "https://ky-sso.yunda56.com" + + +def yunda_reset(page): + """异常兜底:重置韵达到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。""" + page.goto(HOME_URL) + page.wait_for_timeout(1500) + + def yunda_login(page): """韵达自动登录:未登录则填充表单并提交,已登录则跳过。""" print(">> 正在检查韵达登录状态...") @@ -79,7 +117,18 @@ def yunda_smart_menu_click(page, menu_path): def yunda_expected_download(page): - """韵达:应到货物数据下载""" + """韵达:应到货物数据下载(内部含异常兜底重试,路由层无感)。""" + + return with_retry( + "韵达", + "应到", + lambda: yunda_expected_download_impl(page), + lambda: yunda_reset(page), + ) + + +def yunda_expected_download_impl(page): + """韵达:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" print("\n▶ 开始执行【韵达 - 应到货物数据下载】任务...") download_dir = DOWNLOAD_DIR @@ -318,7 +367,18 @@ def yunda_expected_download(page): def yunda_actual_download(page): - """韵达:实到货物数据下载""" + """韵达:实到货物数据下载(内部含异常兜底重试,路由层无感)。""" + + return with_retry( + "韵达", + "实到", + lambda: yunda_actual_download_impl(page), + lambda: yunda_reset(page), + ) + + +def yunda_actual_download_impl(page): + """韵达:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" print("\n▶ 开始执行【韵达 - 实到货物数据下载】任务...") download_dir = DOWNLOAD_DIR diff --git a/site_zto.py b/site_zto.py index 464c17c..3037092 100644 --- a/site_zto.py +++ b/site_zto.py @@ -9,6 +9,44 @@ import pandas as pd from paths import DOWNLOAD_DIR, CONFIG_PATH +def with_retry(site_name, label, flow, reset, max_attempts=3): + """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 + + 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + flow 为零参可调用;返回 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 + 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},放弃(环境已清理)。" + ) + + +# 站点首页 URL:异常兜底重置用,也供 main_router 的 SITES_CONFIG 引用(单一来源) +HOME_URL = "https://ws.zto56.com/" + + +def zto_reset(page): + """异常兜底:重置中通到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。""" + page.goto(HOME_URL) + page.wait_for_timeout(1500) + + def _wait_and_get_frame(page, text_indicator, timeout_ms=20000): """在主页面与所有 iframe 中查找包含指定文本的窗口""" start_time = datetime.now() @@ -59,7 +97,18 @@ def zto_smart_menu_click(page, menu_path): def zto_expected_download(page): - """中通:应到货物数据下载""" + """中通:应到货物数据下载(内部含异常兜底重试,路由层无感)。""" + + return with_retry( + "中通", + "应到", + lambda: zto_expected_download_impl(page), + lambda: zto_reset(page), + ) + + +def zto_expected_download_impl(page): + """中通:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" print("\n▶ 开始执行【中通 - 应到货物数据下载】任务...") download_dir = DOWNLOAD_DIR @@ -266,7 +315,18 @@ def zto_expected_download(page): def zto_actual_download(page): - """中通:实到货物数据下载""" + """中通:实到货物数据下载(内部含异常兜底重试,路由层无感)。""" + + return with_retry( + "中通", + "实到", + lambda: zto_actual_download_impl(page), + lambda: zto_reset(page), + ) + + +def zto_actual_download_impl(page): + """中通:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" print("\n▶ 开始执行【中通 - 实到货物数据下载】任务...") download_dir = DOWNLOAD_DIR