# sites/zto.py import os import re import time import yaml from datetime import datetime import pandas as pd 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 # 站点首页 URL:异常兜底重置用,也供 runtime 的 SITES_CONFIG 引用(单一来源) HOME_URL = "https://ws.zto56.com/" def zto_reset(page): """异常兜底:重置中通到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。""" page.goto(HOME_URL) page.wait_for_timeout(1500) def _remove_if_exists(path): """删除文件(若存在):流程开头清理上次的最终文件,避免无数据/失败时残留旧数据。""" try: if os.path.exists(path): os.remove(path) except Exception: pass def _wait_and_get_frame(page, text_indicator, timeout_ms=20000): """在主页面与所有 iframe 中查找包含指定文本的窗口""" start_time = datetime.now() while (datetime.now() - start_time).total_seconds() * 1000 < timeout_ms: try: if page.get_by_text(text_indicator).count() > 0: return page except Exception: pass for frame in page.frames: try: if frame.get_by_text(text_indicator).count() > 0: return frame except Exception: pass page.wait_for_timeout(300) raise TimeoutError(f"超时:未找到包含 [{text_indicator}] 的窗口。") def _dom_click(locator): """直接在元素上派发 mousedown+mouseup+click 事件,不走 Playwright 的坐标命中测试, 避免被遮挡元素(如日期控件的 hover 提示气泡)拦截。事件直接发到目标元素并冒泡, 兼容绑定 mousedown 或 click 的控件。""" locator.evaluate( "el => { const o = { bubbles: true, cancelable: true, view: window, button: 0 };" " el.dispatchEvent(new MouseEvent('mousedown', o));" " el.dispatchEvent(new MouseEvent('mouseup', o));" " el.dispatchEvent(new MouseEvent('click', o)); }" ) def _zto_flip_to_target_month(frame_locator, page, target_time, max_flips=12): """中通日历(jQuery-Date-Range-Picker 双月视图)跨月导航:目标日期不在当前视窗时, 循环点 .prev 把目标月翻进 month1 视窗。offset 恒指向过去,故只往前翻;步长 1 月/次。 用 JS 派发点击(.evaluate("el=>el.click()"))避开 .date-range-length-tip 等 hover 遮挡。 返回 True 若目标格子最终可见。""" sel = f"td div.day[time='{target_time}']" for _ in range(max_flips): if frame_locator.locator(sel).first.is_visible(): return True frame_locator.locator(".date-picker-wrapper .prev").first.evaluate( "el => el.click()" ) page.wait_for_timeout(450) return frame_locator.locator(sel).first.is_visible() 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, force=False): """中通:应到货物数据下载(内部含异常兜底重试,路由层无感)。""" return with_retry( "中通", "应到", lambda: zto_expected_download_impl(page, force=force), lambda: zto_reset(page), ) def zto_expected_download_impl(page, force=False): """中通:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" print("\n▶ 开始执行【中通 - 应到货物数据下载】任务...") download_dir = DOWNLOAD_DIR if not os.path.exists(download_dir): os.makedirs(download_dir) # 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对 _remove_if_exists(os.path.join(download_dir, "中通-应到货物数据.xlsx")) 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) # 读取服务端日期偏移(0=今天,1=昨天…),单日:起止同日 offset = state_store.get_offset("中通") print(f">> 正在设定查询日期: 偏移 {offset}(0=今天)...") 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) target_time = today_time - offset * 86400000 target_cell = ewb_frame.locator(f"td div.day[time='{target_time}']").first # 偏移日期跨月时目标格子不在当前双月视窗 → 向前翻月把它带进视窗,不再降级为当天 if not target_cell.is_visible(): print(" ℹ️ 偏移日期跨月,正在向前翻月导航到目标 ...") if not _zto_flip_to_target_month(ewb_frame, page, target_time): raise RuntimeError( f"翻月后仍无法定位目标日期格子(time={target_time})" ) # 日期格子用 _dom_click 直接派发事件(同实到):.click() 会先 hover 格子,触发 # “范围长度”提示气泡(.date-range-length-tip)盖住格子导致点击被遮挡超时, # 跨月选中非今日格子时尤为明显。 _dom_click(target_cell) page.wait_for_timeout(300) _dom_click(target_cell) else: today_cell.click() page.wait_for_timeout(300) today_cell.click() page.wait_for_timeout(500) # 3. 触发查询并判断数据状态 print(">> 正在点击【查询】按钮并等待数据响应...") old_count = ewb_frame.locator("#inEwbCount").inner_text().strip() 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) # 轮询判定统计面板更新 wait_cycles = 0 while wait_cycles < 20: current_count = ewb_frame.locator("#inEwbCount").inner_text().strip() if current_count != old_count: break page.wait_for_timeout(500) wait_cycles += 1 ticket_count_str = ewb_frame.locator("#inEwbCount").inner_text().strip() print(f" ✅ 数据已加载,进站实际票数: [{ticket_count_str}]") # 数据分流 if ( ticket_count_str == "0" or not ticket_count_str.isdigit() or int(ticket_count_str) == 0 ): print(" >> 票数为 0,正在确认是否为空数据...") empty_flag = ewb_frame.locator("#datagrid1").get_by_text( "没有搜索到符合条件的数据记录" ) if empty_flag.is_visible(): print(" ⚠️ 确认为空数据,正在关闭当前标签页...") # 即使无数据也关闭已打开的标签页 try: page.locator(".mini-tab", has_text="进站交接单查询").locator( ".mini-tab-close" ).click() except Exception: pass return else: print(" ⚠️ 票数为 0 但未出现空记录提示,页面状态异常,判失败。") try: page.locator(".mini-tab", has_text="进站交接单查询").locator( ".mini-tab-close" ).click() except Exception: pass raise RuntimeError("票数为 0 但未出现空记录提示,页面状态异常") else: print(" >> 票数校验通过,等待主表格渲染数据行...") ewb_frame.locator( "#datagrid1 .mini-grid-rows-view .mini-grid-row" ).first.wait_for(state="visible", timeout=15000) # 4. 提取主表记录并循环双击 main_rows = ewb_frame.locator("#datagrid1 .mini-grid-rows-view .mini-grid-row") count = main_rows.count() print(f">> 共发现 {count} 个交接单需要导出。") # 【去重】加载本站已落库交接单号;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}") 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}") # 【去重】已落库的交接单号不再提交导出任务(不双击、不 append export_times) if handover_no in existing: print(f" ⏭️ 交接单号 {handover_no} 已落库,跳过提交导出。") continue 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(" >> 正在等待服务器建立后台离线任务...") # 提示「生成离线导出任务成功」出现在导出列 iframe(/comm/download) 中; # 提交完成后该 iframe 会被站点销毁,此时 wait_for 会抛 "Frame was detached" # ——这恰恰说明提示已随 iframe 消失、任务已建立,属正常,不视为失败。 ctx_tips = _wait_and_get_frame( page, "生成离线导出任务成功", timeout_ms=10000 ) try: ctx_tips.locator(".mini-tips-success").wait_for( state="hidden", timeout=15000 ) except Exception as e: if "detached" in str(e).lower(): print(" ℹ️ 提示框所在 iframe 已随提交关闭,任务已建立。") else: raise print(" ✅ 成功提示框已消失。") export_times.append(datetime.now()) print(" >> 切换回【交接单信息】标签页...") ewb_frame.locator("#ewbsListNo").click() page.wait_for_timeout(1000) # ==================================================================== # 完成所有交接单导出提交后,关闭当前标签页 # ==================================================================== print(">> 【进站交接单查询】已完成,正在关闭标签页...") try: page.locator(".mini-tab", has_text="进站交接单查询").locator( ".mini-tab-close" ).click() page.wait_for_timeout(500) print(" ✅ 【进站交接单查询】标签页已关闭。") except Exception as e: print(f" ⚠️ 关闭【进站交接单查询】标签页时出错: {e}") # 【去重兜底】全部已落库/无数据 → 无导出任务,标签页已关,直接结束不进轮询 if not export_times: print(">> 本次无新交接单需导出(全部已落库或无数据),结束。") return True # 交由统一的轮询下载流程处理 _zto_poll_and_download_tasks( page, export_times, download_dir, "中通-应到货物数据.xlsx", ) return True except Exception as e: print(f"\n❌ 任务执行过程中发生异常: {e}") return False def zto_actual_download(page, force=False): """中通:实到货物数据下载(内部含异常兜底重试,路由层无感)。""" 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 if not os.path.exists(download_dir): os.makedirs(download_dir) # 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对 _remove_if_exists(os.path.join(download_dir, "中通-实到货物数据.xlsx")) export_times = [] try: # 1. 菜单导航 zto_smart_menu_click(page, ["运营管理", "扫描操作与监控", "到件扫描监控"]) arr_frame = page.frame_locator('iframe[src*="ArriveScan"]') print(">> 正在检测主页面 (#daterange)...") arr_frame.locator("#daterange").wait_for(state="attached", timeout=15000) # 2. 读取服务端日期偏移(0=今天,1=昨天…),单日:起止同日 offset = state_store.get_offset("中通", "actual") print(f">> 正在设定查询日期: 偏移 {offset}(0=今天)...") arr_frame.locator("#daterange").click() page.wait_for_timeout(500) today_cell = arr_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) target_time = today_time - offset * 86400000 target_cell = arr_frame.locator(f"td div.day[time='{target_time}']").first # 日期格子用 _dom_click 直接派发事件:Playwright 的 .click() 会先 hover 格子, # 触发“范围长度”提示气泡(.date-range-length-tip)盖住格子,导致点击被判遮挡而超时。 # 偏移日期跨月时目标格子不在当前双月视窗 → 向前翻月把它带进视窗,不再降级为当天。 if not target_cell.is_visible(): print(" ℹ️ 偏移日期跨月,正在向前翻月导航到目标 ...") if not _zto_flip_to_target_month(arr_frame, page, target_time): raise RuntimeError( f"翻月后仍无法定位目标日期格子(time={target_time})" ) _dom_click(target_cell) page.wait_for_timeout(300) _dom_click(target_cell) else: _dom_click(today_cell) page.wait_for_timeout(300) _dom_click(today_cell) page.wait_for_timeout(500) # 3. 设定单号类型 print(">> 正在设定单号类型为【子单】...") arr_frame.locator('[id="bandEwbType$text"]').click() page.wait_for_timeout(500) arr_frame.locator(".mini-tree-nodeshow").filter( has_text=re.compile(r"^子单$") ).locator(".mini-tree-checkbox").click() page.wait_for_timeout(300) # 4. 触发查询并判断数据状态 print(">> 正在点击【查询】按钮并等待数据响应...") arr_frame.locator("#searchbtn").click() page.wait_for_timeout(1000) loading_mask = arr_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 = arr_frame.locator("#datagrid1").get_by_text( "没有搜索到符合条件的数据记录" ) if empty_flag.is_visible(): print(" ⚠️ 当前查询范围内没有数据,终止并关闭标签页。") try: page.locator(".mini-tab", has_text="到件扫描监控").locator( ".mini-tab-close" ).click() except Exception: pass return arr_frame.locator("#page1").wait_for(state="visible", timeout=15000) print(" ✅ 数据已加载。(底部分页控件已就绪)") # 5. 执行导出流程 arr_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(" >> 正在等待服务器建立后台离线任务...") # 提示「生成离线导出任务成功」出现在导出列 iframe(/comm/download) 中; # 提交完成后该 iframe 会被站点销毁,此时 wait_for 会抛 "Frame was detached" # ——这恰恰说明提示已随 iframe 消失、任务已建立,属正常,不视为失败。 ctx_tips = _wait_and_get_frame(page, "生成离线导出任务成功", timeout_ms=10000) try: ctx_tips.locator(".mini-tips-success").wait_for( state="hidden", timeout=15000 ) except Exception as e: if "detached" in str(e).lower(): print(" ℹ️ 提示框所在 iframe 已随提交关闭,任务已建立。") else: raise print(" ✅ 成功提示框已消失。") export_times.append(datetime.now()) # ==================================================================== # 完成实到数据导出提交后,关闭当前标签页 # ==================================================================== print(">> 【到件扫描监控】已完成,正在关闭标签页...") try: page.locator(".mini-tab", has_text="到件扫描监控").locator( ".mini-tab-close" ).click() page.wait_for_timeout(500) print(" ✅ 【到件扫描监控】标签页已关闭。") except Exception as e: print(f" ⚠️ 关闭【到件扫描监控】标签页时出错: {e}") # 交由统一的轮询下载流程处理 _zto_poll_and_download_tasks( page, export_times, download_dir, "中通-实到货物数据.xlsx", ) return True except Exception as e: print(f"\n❌ 任务执行过程中发生异常: {e}") return False def _zto_poll_and_download_tasks(page, export_times, download_dir, final_filename): """中通离线任务的轮询与下载""" 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(1000) print(">> 列表已加载,开始匹配并检查任务状态...") total_expected = len(export_times) poll_deadline = ( time.monotonic() + 300 ) # 5 分钟上限:任务卡死/匹配不上时超时失败,交由上层重置重试 last_total_found = -1 stall_rounds = 0 # 连续无进展轮数:刷新后 total_found 不增长则累计,超阈值快速失败 while True: if time.monotonic() > poll_deadline: raise RuntimeError( "轮询导出任务超时(5 分钟未全部完成/匹配),疑似任务卡死" ) task_rows = taskdone_frame.locator( "#taskdoneDatagrid .mini-grid-rows-view .mini-grid-row" ) row_count = task_rows.count() ready_timestamps = set() processing_timestamps = set() # 单账号无并发:仅按提交时间容差(40s)认领本批任务,不再校验任务标题 # (站点可能调整标题名,写死标题会导致匹配失败、任务一直查不到)。 for i in range(row_count): tds = task_rows.nth(i).locator("td") if tds.count() < 10: continue submit_time_str = tds.nth(4).inner_text().strip() status_str = tds.nth(6).inner_text().strip() try: row_time = datetime.strptime(submit_time_str, "%Y-%m-%d %H:%M:%S") matched = any( abs((row_time - et).total_seconds()) <= 40 for et in export_times ) if matched: if status_str == "成功执行": ready_timestamps.add(submit_time_str) else: processing_timestamps.add(submit_time_str) except Exception: pass total_found = len(ready_timestamps) + len(processing_timestamps) print( f" 📊 状态统计:期望 [{total_expected}],已入表 [{total_found}] (就绪 [{len(ready_timestamps)}],处理中 [{len(processing_timestamps)}])" ) if total_found < total_expected or len(processing_timestamps) > 0: # 连续无进展即快速失败:避免页面被遮挡/任务卡住时干等到 5 分钟超时 if total_found == last_total_found: stall_rounds += 1 else: stall_rounds = 0 last_total_found = total_found if stall_rounds >= 4: raise RuntimeError( f"连续 {stall_rounds} 轮刷新无进展(仍 {total_found}/{total_expected})," "疑似页面被遮挡或任务异常,触发重试" ) print(" ⏳ 任务尚未齐全或仍在生成,点击查询刷新...") # 查询按钮加短超时;失败则菜单刷新,两者都失败直接报错触发重试 try: taskdone_frame.locator( ".mini-button-text", has_text="查询" ).first.click(timeout=8000) except Exception as e: print(f" ⚠️ 查询按钮不可用({e}),改用菜单刷新...") try: page.locator( "li.leaf span.menu-name", has_text="导出任务管理" ).click(timeout=8000) except Exception as e2: raise RuntimeError(f"查询与菜单刷新均失败,疑似页面被遮挡: {e2}") page.wait_for_timeout(3000) else: target_task_timestamps = list(ready_timestamps) 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}')" ) ) .first ) print(f" 开始下载任务 [{time_str}] ...") 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}") # 汇总校验:下载成功数必须等于本批提交的任务数,否则判失败 # (防"部分/全失败却被判成功") if len(downloaded_files) < total_expected: raise RuntimeError( f"仅成功下载 {len(downloaded_files)}/{total_expected} 个任务,数据不完整" ) # ==================================================================== # 所有目标文件下载完成后,关闭“导出任务管理”标签页 # ==================================================================== print(">> 【导出任务管理】下载完成,正在关闭标签页...") try: page.locator(".mini-tab", has_text="导出任务管理").locator( ".mini-tab-close" ).click() page.wait_for_timeout(500) print(" ✅ 【导出任务管理】标签页已关闭。") except Exception as e: print(f" ⚠️ 关闭【导出任务管理】标签页时出错: {e}") # 9. 合并数据 if downloaded_files: print("\n>> 正在合并下载的数据...") all_data_frames = [] for file_path in downloaded_files: try: df = pd.read_excel(file_path, dtype=str) if not df.empty: all_data_frames.append(df) except Exception: pass if all_data_frames: combined_df = pd.concat(all_data_frames, ignore_index=True) final_output_path = os.path.join(download_dir, final_filename) combined_df.to_excel(final_output_path, index=False) print(f"====================================================") print(f" 合并完成。") print(f" 📁 输出路径: {final_output_path}") print(f"====================================================") for file_path in downloaded_files: os.remove(file_path) print(" 临时文件已清理。")