# sites/yunda.py import os import re import time import yaml from datetime import datetime, timedelta 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://ky-sso.yunda56.com" def dismiss_audio_prompt(page): """关闭韵达登录后弹出的阻塞式提示弹窗(如音频设备未授权/未找到)。 不依赖具体文案(不同主机/音频状态下文案不同:未授权/未找到…),只要出现 .ivu-modal-confirm 就点其「确定」。主页加载后调用(初始 + 重试重载)。 """ try: modal = page.locator(".ivu-modal-confirm").first modal.wait_for(state="visible", timeout=2000) modal.locator(".ivu-modal-confirm-footer button.ivu-btn-primary").click() print(" ✅ 【韵达】已关闭提示弹窗。") return True except Exception: return False def yunda_reset(page): """异常兜底:重置韵达到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。""" page.goto(HOME_URL) page.wait_for_timeout(1500) dismiss_audio_prompt(page) # 主页重载后音频授权提示会复现,清理之 def _remove_if_exists(path): """删除文件(若存在):流程开头清理上次的最终文件,避免无数据/失败时残留旧数据。""" try: if os.path.exists(path): os.remove(path) except Exception: pass def _yunda_pick_laydate_new(ws_frame, page, date_ymd): """应到(新版 laydate .layui-laydate):在已打开的面板上选中指定日期(含跨月翻月)。 目标格子 td[lay-ymd='YYYY-M-D'](非补零)不在当前月视窗时,读 .laydate-set-ym 的 当前年月(形如「2026年7月」),按差值点 .laydate-prev-m / .laydate-next-m 翻到目标月, 再点格子;同月则直接点。调用前提:#startTime/#endTime 已点开,.layui-laydate:visible 就绪。 """ cal = ws_frame.locator(".layui-laydate:visible").first cell = cal.locator(f"td[lay-ymd='{date_ymd}']").first if not cell.is_visible(): ty, tm = (int(x) for x in date_ymd.split("-")[:2]) print(f" ℹ️ 目标日期 {date_ymd} 不在当前月视窗,正在翻月导航 ...") for _ in range(24): nums = re.findall(r"\d+", cal.locator(".laydate-set-ym").first.inner_text()) if len(nums) >= 2: cur_y, cur_m = int(nums[0]), int(nums[1]) if cur_y == ty and cur_m == tm: break cur = cur_y * 12 + (cur_m - 1) btn = ( ".laydate-prev-m" if (ty * 12 + (tm - 1)) < cur else ".laydate-next-m" ) cal.locator(btn).first.click() page.wait_for_timeout(300) cell = cal.locator(f"td[lay-ymd='{date_ymd}']").first cell.click() def _yunda_pick_laydate_old(ws_frame, page, date_ymd): """实到(旧版 laydate #laydate_box):在已打开的面板上选中指定日期(含跨月翻月)。 目标格子 td[y][m][d](非补零)不在当前月视窗时,读 #laydate_y/#laydate_m 输入框值 (形如「2026年」「07月」)得当前年月,按差值点 #laydate_MM 内 .laydate_chprev / .laydate_chnext 翻到目标月,再点格子;同月则直接点。调用前提:#startDate/#endDate 已点开(force=True),#laydate_box:visible 就绪。 """ box = ws_frame.locator("#laydate_box:visible").first ty, tm, td = (int(x) for x in date_ymd.split("-")[:3]) cell = box.locator(f"td[y='{ty}'][m='{tm}'][d='{td}']").first if not cell.is_visible(): print(f" ℹ️ 目标日期 {date_ymd} 不在当前月视窗,正在翻月导航 ...") for _ in range(24): yv = box.locator("#laydate_y").first.evaluate("e=>e.value") mv = box.locator("#laydate_m").first.evaluate("e=>e.value") cur_y = int(re.search(r"\d+", yv).group()) cur_m = int(re.search(r"\d+", mv).group()) if cur_y == ty and cur_m == tm: break cur = cur_y * 12 + (cur_m - 1) btn = ".laydate_chprev" if (ty * 12 + (tm - 1)) < cur else ".laydate_chnext" box.locator(f"#laydate_MM {btn}").first.click() page.wait_for_timeout(300) cell = box.locator(f"td[y='{ty}'][m='{tm}'][d='{td}']").first cell.click() def _resolve_export_frame(ws_frame): """定位韵达数据导出面板内嵌的 iframe。 韵达改版后导出面板换用 Element UI(全选/向右转移/导出按钮)。实到流程的面板 iframe 名为 myFrame(已验证);应到流程历史上为 target1。这里短超时轮流探测, 返回首个出现「全选」按钮的 frame;都未命中则 dump 面板内所有 iframe 名便于排查。 """ for name in ("myFrame", "target1"): frame = ws_frame.frame_locator(f'iframe[name="{name}"]') try: frame.locator("button.el-button", has_text="全选").first.wait_for( state="visible", timeout=5000 ) print(f" [导出iframe] 命中 iframe[name={name}]") return frame except Exception: continue try: names = ws_frame.locator("iframe").evaluate_all( "els => els.map(e => e.name || '(无name)')" ) print( f" [导出iframe] myFrame/target1 均未命中全选;面板 iframe 名: {names}" ) except Exception as e: print(f" [导出iframe] dump 失败: {e}") return ws_frame.frame_locator('iframe[name="myFrame"]') def yunda_login(page): """韵达自动登录:未登录则填充表单并提交,已登录则跳过。""" print(">> 正在检查韵达登录状态...") try: # 定位“账号密码登录”切换按钮 switch_btn = page.locator("span", has_text="账号密码登录") # 5 秒内若出现该按钮,说明当前未登录 if switch_btn.is_visible(timeout=5000): print(" -> 检测到未登录界面,正在切换到【账号密码登录】...") switch_btn.click() page.wait_for_timeout(500) # 凭证存 state.db(由前端站点配置) username = state_store.get_setting("韵达", "username") password = state_store.get_setting("韵达", "password") print(f" -> 正在填充登录表单 (账号: {username})...") page.locator("#username").fill(username) page.locator("#password").fill(password) page.wait_for_timeout(300) print(" -> 正在点击【登录】按钮并提交表单...") page.locator('button[type="submit"]', has_text="登录").click() page.wait_for_timeout(1000) else: print(" -> 未发现登录按钮,判定为已登录,跳过。") except Exception as e: print(f" ⚠️ 登录检测出错(可能已在工作台内): {e}") def yunda_smart_menu_click(page, menu_path): """韵达多级菜单导航:展开父级菜单并点击目标项(已展开则跳过,避免误折叠)。""" print(f">> 导航韵达菜单: {' -> '.join(menu_path)}") for item in menu_path: title_locator = page.locator( f"xpath=//div[contains(@class, 'el-submenu__title') and .//span[normalize-space(.)='{item}']]" ).first parent_li = page.locator( f"xpath=//div[contains(@class, 'el-submenu__title') and .//span[normalize-space(.)='{item}']]/.." ).first leaf_locator = page.locator( f"xpath=//li[contains(@class, 'el-menu-item') and .//span[normalize-space(.)='{item}']]" ).first if title_locator.is_visible(): current_class = parent_li.get_attribute("class") or "" is_opened = "is-opened" in current_class if not is_opened: print(f" -> 父菜单 [{item}] 处于收起状态,点击展开") title_locator.click() page.wait_for_timeout(500) else: print(f" -> 父菜单 [{item}] 已展开,跳过点击") elif leaf_locator.is_visible(): print(f" -> 点击菜单项 [{item}]") leaf_locator.click() page.wait_for_timeout(1000) def yunda_expected_download(page, force=False, date=None): """韵达:应到货物数据下载(内部含异常兜底重试,路由层无感)。""" return with_retry( "韵达", "应到", lambda: yunda_expected_download_impl(page, force=force, date=date), lambda: yunda_reset(page), ) def yunda_expected_download_impl(page, force=False, date=None): """韵达:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" 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. 验证首页并导航菜单 page.locator(".el-menu-item", has_text="首页").wait_for( state="visible", timeout=15000 ) print("✅ 韵达工作台首页已加载") yunda_smart_menu_click(page, ["运营管理", "进站管理", "进站交接单查询"]) print(">> 正在定位【进站交接单查询】iframe...") ws_frame = page.frame_locator("section iframe") ws_frame.locator("#startTime").wait_for(state="attached", timeout=15000) print("✅ 进站交接单查询页面就绪") # 2. 读取服务端日期偏移(0=今天,1=昨天…),单日范围:起止同日 offset = state_store.get_offset("韵达") today = datetime.now() if date: target = datetime.strptime(date, "%Y-%m-%d") else: target = today - timedelta(days=offset) target_ymd = f"{target.year}-{target.month}-{target.day}" start_date_ymd = target_ymd today_ymd = target_ymd src = f"指定 {date}" if date else f"偏移 {offset},0=今天" print(f">> 设置查询日期: [{target_ymd}]({src})") # 设定起始时间 print(" >> 设置起始时间...") page.wait_for_timeout(1000) ws_frame.locator("#startTime").click(force=True) calendar1 = ws_frame.locator(".layui-laydate:visible").first calendar1.wait_for(state="visible", timeout=5000) _yunda_pick_laydate_new(ws_frame, page, start_date_ymd) calendar1.locator(".laydate-btns-confirm").click() page.wait_for_timeout(400) # 设定截止时间 print(" >> 设置截止时间...") ws_frame.locator("#endTime").click(force=True) calendar2 = ws_frame.locator(".layui-laydate:visible").first calendar2.wait_for(state="visible", timeout=5000) _yunda_pick_laydate_new(ws_frame, page, today_ymd) calendar2.locator(".laydate-btns-confirm").click() page.wait_for_timeout(500) # 3. 等待数据加载完成 print(">> 正在执行查询...") ws_frame.locator("a.btn-success", has_text="查询").click() page.wait_for_timeout(800) # 将 Loading 蒙层与数据判断限制在 #tab-1 内 loading_mask = ws_frame.locator( "#tab-1 .fixed-table-loading", has_text="正在努力地加载数据中" ).first if loading_mask.is_visible(): print(" ⏳ 检测到数据加载遮罩,等待加载完成...") loading_mask.wait_for(state="hidden", timeout=30000) page.wait_for_timeout(500) sum_panel = ws_frame.locator("#sum").first has_data = False if sum_panel.is_visible(): sum_text = sum_panel.inner_text() match_tickets = re.search(r"进站实际票数:(\d+)", sum_text) if match_tickets and int(match_tickets.group(1)) > 0: has_data = True print(f" ✅ 统计面板已加载,实际票数: [{match_tickets.group(1)}]") if not has_data: if ws_frame.locator( "#tab-1 .no-records-found", has_text="没有找到匹配的记录" ).first.is_visible(): print(" ⚠️ 确认为空数据,正在关闭当前标签页...") page.locator(".tags-view-item", has_text="进站交接单查询").locator( ".el-icon-close" ).click() return # 4. 深度等待表格第一行数据行渲染就绪 ws_frame.locator("#exampleTable1 tbody tr[data-index='0']").wait_for( state="visible", timeout=10000 ) main_rows = ws_frame.locator("#exampleTable1 tbody tr[data-index]") row_count = main_rows.count() print(f">> 当前视窗共捕获到活跃交接单记录: {row_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}") # 5. 逐行双击并提交导出 for i in range(row_count): print(f" ⏳ 正在处理第 {i+1}/{row_count} 个交接单模块...") current_row = ws_frame.locator("#exampleTable1 tbody tr[data-index]").nth(i) raw_no = current_row.locator("td").nth(1).inner_text().strip() # 【去重】已落库的交接单号不再提交导出任务 if raw_no in existing: print(f" ⏭️ 交接单号 {raw_no} 已落库,跳过提交导出。") continue # 跳过已绑定的交接单 bind_status = current_row.locator("td").nth(2).inner_text().strip() print(f" -> 交接单号: {raw_no} [绑定状态: {bind_status}]") if bind_status == "已绑定": print(" ⏭️ 该交接单已绑定,跳过。") continue current_row.dblclick() ws_frame.locator("#docSum").wait_for(state="visible", timeout=15000) page.wait_for_timeout(500) # 导出弹窗重试:外层重新打开面板(最多 3 次)。 # 韵达站点已将导出面板从 jQuery(.allRight/#submitbutton) 改版为 Element UI # (与实到流程同一导出组件,iframe=myFrame): # 全选(button“全选”) → 向右转移(i.el-icon-d-arrow-right) → 导出(i.el-icon-download) # → 正在导出中(.el-loading-mask) → 成功提示(.el-message-box 导出任务建立成功) → 确定 task_success = False for major_attempt in range(3): print(f" >> 正在打开数据导出面板 (尝试 {major_attempt + 1}/3)...") ws_frame.locator('a.btn-info[onclick*="exportFile"]').click() ws_frame.locator(".layui-layer-title", has_text="数据导出").wait_for( state="visible", timeout=15000 ) export_frame = _resolve_export_frame(ws_frame) try: # 校验 Element UI 字段选择区是否加载完成(以「全选」按钮就绪为标志) export_frame.locator( "button.el-button", has_text="全选" ).first.wait_for(state="visible", timeout=8000) except Exception: print(" ⚠️ 导出面板字段区未加载,关闭面板后重试...") ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(1000) continue print(" -> 全选字段并向右转移...") export_frame.locator("button.el-button", has_text="全选").first.click() page.wait_for_timeout(400) export_frame.locator( "button.el-button:has(i.el-icon-d-arrow-right)" ).first.click() page.wait_for_timeout(500) print(" -> 正在提交导出任务...") export_frame.locator( "button.el-button:has(i.el-icon-download)" ).first.click() # 等待「正在导出中」遮罩出现并消失 loading = export_frame.locator(".el-loading-mask").first try: loading.wait_for(state="visible", timeout=5000) loading.wait_for(state="hidden", timeout=60000) except Exception: pass # 等待结果提示并判定 inner_success = False try: msg_box = export_frame.locator(".el-message-box.my-alert").first msg_box.wait_for(state="visible", timeout=30000) if msg_box.get_by_text("导出任务建立成功").is_visible(): print(" ✅ 导出任务已建立成功。") inner_success = True else: print(" ⚠️ 导出结果提示非成功状态,将重试。") msg_box.locator("button.el-button", has_text="确定").first.click() page.wait_for_timeout(500) except Exception: print(" ⚠️ 未检测到导出结果提示,将重试。") ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(500) if inner_success: task_success = True break if not task_success: raise RuntimeError("多次重试后仍未能建立应到数据离线任务。") ws_frame.locator("#myTab a", has_text="交接单信息").click() page.wait_for_timeout(800) export_times.append(datetime.now()) print(">> 任务提交完成,正在关闭【进站交接单查询】标签页...") page.locator(".tags-view-item", has_text="进站交接单查询").locator( ".el-icon-close" ).click() page.wait_for_timeout(500) # 若所有记录都被跳过,export_times 为空,直接结束 if not export_times: print(">> ⚠️ 本次未产生任何离线下载任务(无数据或已全部跳过),结束。") return _yunda_poll_and_download_tasks( page, export_times, download_dir, "韵达-应到货物数据.xlsx", ) return True except Exception as e: print(f"\n❌ 任务执行过程中发生异常: {e}") return False def yunda_actual_download(page, force=False, date=None): """韵达:实到货物数据下载(内部含异常兜底重试,路由层无感)。""" return with_retry( "韵达", "实到", lambda: yunda_actual_download_impl(page, date=date), lambda: yunda_reset(page), ) def yunda_actual_download_impl(page, date=None): """韵达:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" 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: page.locator(".el-menu-item", has_text="首页").wait_for( state="visible", timeout=15000 ) print("✅ 韵达工作台首页已加载") yunda_smart_menu_click(page, ["报表管理", "扫描记录查询"]) print(">> 正在定位【扫描记录查询】iframe...") ws_frame = page.frame_locator("section iframe") ws_frame.locator( ".no-records-found", has_text="没有找到匹配的记录" ).first.wait_for(state="visible", timeout=15000) print("✅ 扫描记录查询页面已初始化") offset = state_store.get_offset("韵达", "actual") today = datetime.now() if date: target = datetime.strptime(date, "%Y-%m-%d") else: target = today - timedelta(days=offset) # 旧版 laydate 的日期格子 td[y][m][d] 用非补零整数值;单日范围起止同日 target_ymd = f"{target.year}-{target.month}-{target.day}" src = f"指定 {date}" if date else f"偏移 {offset},0=今天" print( f">> 设置实到查询日期: [{target.year}-{target.month}-{target.day}]({src})" ) print(" >> 正在设定起始时间...") ws_frame.locator("#startDate").click() page.wait_for_timeout(400) ws_frame.locator("#laydate_box:visible").first.wait_for( state="visible", timeout=5000 ) _yunda_pick_laydate_old(ws_frame, page, target_ymd) page.wait_for_timeout(400) print(" >> 正在设定截止时间...") ws_frame.locator("#endDate").click() page.wait_for_timeout(400) ws_frame.locator("#laydate_box:visible").first.wait_for( state="visible", timeout=5000 ) _yunda_pick_laydate_old(ws_frame, page, target_ymd) page.wait_for_timeout(500) print(" >> 正在变更扫描类型为【到件】...") ws_frame.locator("#scanRecordTyp").select_option(value="03") page.wait_for_timeout(500) print(">> 正在执行查询...") ws_frame.locator('input[type="button"][value="查询"]').click() page.wait_for_timeout(800) loading_mask = ws_frame.locator( ".fixed-table-loading", has_text="正在努力地加载数据中" ).first if loading_mask.is_visible(): print(" ⏳ 检测到数据加载遮罩,等待加载完成...") loading_mask.wait_for(state="hidden", timeout=30000) page.wait_for_timeout(500) pg_info = ws_frame.locator(".pagination-info").first has_records = False if pg_info.is_visible(): info_text = pg_info.inner_text() match_total = re.search(r"总共\s*(\d+)\s*条记录", info_text) if match_total and int(match_total.group(1)) > 0: has_records = True print(f" ✅ 实到数据已加载,总记录数: [{match_total.group(1)}] 条。") if not has_records: if ws_frame.locator( ".no-records-found", has_text="没有找到匹配的记录" ).first.is_visible(): print(" ⚠️ 当前查询范围内为空数据,终止并关闭标签页。") page.locator(".tags-view-item", has_text="扫描记录查询").locator( ".el-icon-close" ).click() return else: print(" ⚠️ 未找到数据,也未出现空数据提示,结束。") page.locator(".tags-view-item", has_text="扫描记录查询").locator( ".el-icon-close" ).click() return # 导出弹窗重试:外层重新打开面板(最多 3 次)。 # 韵达站点已将导出面板从 jQuery(.allRight/#submitbutton) 改版为 Element UI: # 全选(button“全选”) → 向右转移(i.el-icon-d-arrow-right) → 导出(i.el-icon-download) # → 正在导出中(.el-loading-mask) → 成功提示(.el-message-box 导出任务建立成功) → 确定 print(">> 正在发起导出...") task_success = False for major_attempt in range(3): print(f" >> 正在打开数据导出面板 (尝试 {major_attempt + 1}/3)...") ws_frame.locator('input[type="button"][id="export"]').click() ws_frame.locator(".layui-layer-title", has_text="数据导出").wait_for( state="visible", timeout=15000 ) export_frame = ws_frame.frame_locator('iframe[name="myFrame"]') try: # 校验 Element UI 字段选择区是否加载完成(以「全选」按钮就绪为标志) export_frame.locator( "button.el-button", has_text="全选" ).first.wait_for(state="visible", timeout=8000) except Exception: print(" ⚠️ 导出面板字段区未加载,关闭面板后重试...") ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(1000) continue print(" -> 全选字段并向右转移...") export_frame.locator("button.el-button", has_text="全选").first.click() page.wait_for_timeout(400) export_frame.locator( "button.el-button:has(i.el-icon-d-arrow-right)" ).first.click() page.wait_for_timeout(500) print(" -> 正在提交导出任务...") export_frame.locator( "button.el-button:has(i.el-icon-download)" ).first.click() # 等待「正在导出中」遮罩出现并消失 loading = export_frame.locator(".el-loading-mask").first try: loading.wait_for(state="visible", timeout=5000) loading.wait_for(state="hidden", timeout=60000) except Exception: pass # 等待结果提示并判定 inner_success = False try: msg_box = export_frame.locator(".el-message-box.my-alert").first msg_box.wait_for(state="visible", timeout=30000) if msg_box.get_by_text("导出任务建立成功").is_visible(): print(" ✅ 导出任务已建立成功。") inner_success = True else: print(" ⚠️ 导出结果提示非成功状态,将重试。") msg_box.locator("button.el-button", has_text="确定").first.click() page.wait_for_timeout(500) except Exception: print(" ⚠️ 未检测到导出结果提示,将重试。") ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(500) if inner_success: task_success = True break if not task_success: raise RuntimeError("多次重试后仍未能建立实到数据离线任务。") export_times.append(datetime.now()) print(">> 任务提交完成,正在关闭【扫描记录查询】标签页...") page.locator(".tags-view-item", has_text="扫描记录查询").locator( ".el-icon-close" ).click() page.wait_for_timeout(500) # 若未产生导出任务则结束 if not export_times: print(">> ⚠️ 本次未产生离线下载任务,结束。") return # 6. 轮询并下载 _yunda_poll_and_download_tasks( page, export_times, download_dir, "韵达-实到货物数据.xlsx", ) return True except Exception as e: print(f"\n❌ 任务执行过程中发生异常: {e}") return False def _yunda_poll_and_download_tasks(page, export_times, download_dir, final_filename): """韵达离线任务的轮询与下载""" print("\n>> 正在前往【导出服务】界面...") yunda_smart_menu_click(page, ["基础数据", "导出服务"]) export_ws_frame = page.frame_locator("section iframe") export_ws_frame.get_by_role("cell", name="模块名称", exact=True).wait_for( state="visible", timeout=15000 ) page.wait_for_timeout(1000) print(">> 开始轮询离线任务队列,直到全部完成...") total_expected = len(export_times) poll_deadline = ( time.monotonic() + 300 ) # 5 分钟上限:任务卡死/匹配不上时超时失败,交由上层重置重试 while True: if time.monotonic() > poll_deadline: raise RuntimeError( "轮询导出任务超时(5 分钟未全部完成/匹配),疑似任务卡死" ) task_rows = export_ws_frame.locator( ".datagrid-view2 .datagrid-btable tbody tr.datagrid-row" ) row_count = task_rows.count() ready_indices = [] processing_indices = [] # 单账号无并发:仅按创建时间容差(40s)认领本批任务,不再校验模块名称 # (站点可能调整标题名,写死标题会导致匹配失败、任务一直查不到)。 for idx in range(row_count): row = task_rows.nth(idx) status_name = row.locator("td[field='fileStatus']").inner_text().strip() create_time_str = ( row.locator("td[field='createdTime']").inner_text().strip() ) try: row_time = datetime.strptime(create_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_name == "导出完成": ready_indices.append(idx) else: processing_indices.append(idx) except Exception: pass total_found = len(ready_indices) + len(processing_indices) print( f" 📊 状态统计:期望 [{total_expected}],已入表 [{total_found}] (完成 [{len(ready_indices)}],生成中 [{len(processing_indices)}])" ) if total_found < total_expected or len(processing_indices) > 0: print(" ⏳ 队列未齐全,点击查询刷新...") export_ws_frame.locator( "#ydkyimport_basic_export_searchData1_ky_export_common" ).click() page.wait_for_timeout(3000) else: print(">> 所有离线任务已就绪,开始依次下载...") break downloaded_files = [] for row_idx in ready_indices: try: target_row = export_ws_frame.locator( ".datagrid-view2 .datagrid-btable tbody tr.datagrid-row" ).nth(row_idx) time_flag = ( target_row.locator("td[field='createdTime']").inner_text().strip() ) print(f" 开始下载任务 [{time_flag}] ...") with page.expect_download() as download_info: target_row.locator("td[field='extreFile'] a").get_by_text( "下载" ).first.click() download = download_info.value safe_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") custom_filename = f"韵达_temp_{safe_timestamp}.xlsx" save_path = os.path.join(download_dir, custom_filename) download.save_as(save_path) downloaded_files.append(save_path) print(f" 已下载: downloads/{custom_filename}") page.wait_for_timeout(500) except Exception as e: print(f" ❌ 下载失败: {e}") # 汇总校验:下载成功数必须等于本批提交的任务数,否则判失败 if len(downloaded_files) < total_expected: raise RuntimeError( f"仅成功下载 {len(downloaded_files)}/{total_expected} 个任务,数据不完整" ) print(">> 【导出服务】下载完成,正在关闭标签页...") try: page.locator(".tags-view-item", has_text="导出服务").locator( ".el-icon-close" ).click() print(" ✅ 【导出服务】标签页已关闭。") except Exception: pass if downloaded_files: print("\n>> 正在合并下载的数据...") 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_df = pd.concat(all_dfs, ignore_index=True) final_output = os.path.join(download_dir, final_filename) combined_df.to_excel(final_output, index=False) print(f"====================================================") print(f" 合并完成。") print(f" 📁 输出路径: {final_output}") print(f"====================================================") for file_path in downloaded_files: os.remove(file_path) print(" 临时文件已清理。")