# site_yunda.py import os import re import time import yaml from datetime import datetime, timedelta 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(">> 正在检查韵达登录状态...") 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) # 从配置读取凭证(默认空串;真实凭据仅存于被忽略的 config.yaml) username = "" password = "" if os.path.exists(CONFIG_PATH): with open(CONFIG_PATH, "r", encoding="utf-8") as f: config = yaml.safe_load(f) or {} yd_cfg = config.get("yunda", {}) username = str(yd_cfg.get("username", "")) password = str(yd_cfg.get("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): """韵达:应到货物数据下载(内部含异常兜底重试,路由层无感)。""" 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 if not os.path.exists(download_dir): os.makedirs(download_dir) 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. 从配置文件中解析并计算绝对日期跨度 query_days = 1 try: if os.path.exists(CONFIG_PATH): with open(CONFIG_PATH, "r", encoding="utf-8") as f: config = yaml.safe_load(f) or {} query_days = int(config.get("yunda", {}).get("query_days", 1)) except Exception as e: print(f" ⚠️ 读取 config.yaml 失败,默认查询 1 天: {e}") today = datetime.now() start_date = today - timedelta(days=(query_days - 1)) today_ymd = f"{today.year}-{today.month}-{today.day}" start_date_ymd = f"{start_date.year}-{start_date.month}-{start_date.day}" print(f">> 设置查询时间范围: [{start_date_ymd}] 至 [{today_ymd}]") # 设定起始时间 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) calendar1.locator(f"td[lay-ymd='{start_date_ymd}']").click() 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) calendar2.locator(f"td[lay-ymd='{today_ymd}']").click() 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} 条") # 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() # 跳过已绑定的交接单 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) # 导出弹窗双层重试:外层重新打开面板,内层重新提交。 # 区分字段漏选(补点全选)与字段列表消失(重新打开面板)。 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 = ws_frame.frame_locator('iframe[name="target1"]') try: # 校验字段列表是否加载完成(以“交接单号”为标志) export_frame.get_by_text("交接单号").first.wait_for( state="visible", timeout=3000 ) except Exception: print(" ⚠️ 字段列表未加载,关闭面板后重试...") ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(1000) continue export_frame.locator(".allRight").click() page.wait_for_timeout(500) inner_success = False needs_reopen = False print(" -> 正在提交导出任务...") for attempt in range(4): export_frame.locator("#submitbutton", has_text="导出数据").click() confirm_link = export_frame.get_by_role("link", name="确定") try: confirm_link.wait_for(state="visible", timeout=6000) if export_frame.get_by_text("导出任务建立成功").is_visible(): print(" ✅ 导出任务已建立成功。") confirm_link.click() inner_success = True break elif export_frame.get_by_text( "请选择格式相应的导出字段" ).is_visible(): confirm_link.click() page.wait_for_timeout(500) # 区分:字段漏选 还是 字段列表消失 if export_frame.get_by_text("交接单号").first.is_visible(): print( " ⚠️ 检测到未选择字段(字段列表仍在),重新点击全选..." ) export_frame.locator(".allRight").click() page.wait_for_timeout(500) else: print( " ⚠️ 字段列表异常消失,重新打开导出面板..." ) needs_reopen = True break # 跳出内层循环,重新打开面板 else: confirm_link.click() page.wait_for_timeout(1000) except Exception: page.wait_for_timeout(1000) if inner_success: task_success = True ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(500) break # 跳出外层循环,继续后续步骤 elif needs_reopen: ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(1000) continue # 重新打开面板 else: ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(1000) continue 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", ) except Exception as e: print(f"\n❌ 任务执行过程中发生异常: {e}") return False 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 if not os.path.exists(download_dir): os.makedirs(download_dir) 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("✅ 扫描记录查询页面已初始化") query_days = 1 try: if os.path.exists(CONFIG_PATH): with open(CONFIG_PATH, "r", encoding="utf-8") as f: config = yaml.safe_load(f) or {} query_days = int(config.get("yunda", {}).get("query_days", 1)) except Exception: pass today = datetime.now() start_date = today - timedelta(days=(query_days - 1)) print(f">> 设置实到查询时间范围: [近 {query_days} 天]") print(" >> 正在设定起始时间...") ws_frame.locator("#startDate").click() page.wait_for_timeout(400) box1 = ws_frame.locator("#laydate_box:visible").first box1.locator( f"td[y='{start_date.year}'][m='{start_date.month}'][d='{start_date.day}']" ).click() page.wait_for_timeout(400) print(" >> 正在设定截止时间...") ws_frame.locator("#endDate").click() page.wait_for_timeout(400) box2 = ws_frame.locator("#laydate_box:visible").first box2.locator( f"td[y='{today.year}'][m='{today.month}'][d='{today.day}']" ).click() 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 # 导出弹窗双层重试:外层重新打开面板,内层重新提交。 # 区分字段漏选(补点全选)与字段列表消失(重新打开面板)。 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: # 校验字段列表是否加载完成(以“扫描类型”为标志) export_frame.get_by_text("扫描类型").first.wait_for( state="visible", timeout=3000 ) except Exception: print(" ⚠️ 字段列表未加载,关闭面板后重试...") ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(1000) continue export_frame.locator(".allRight").click() page.wait_for_timeout(500) inner_success = False needs_reopen = False print(" -> 正在提交导出任务...") for attempt in range(4): export_frame.locator("#submitbutton", has_text="导出数据").click() confirm_link = export_frame.get_by_role("link", name="确定") try: confirm_link.wait_for(state="visible", timeout=6000) if export_frame.get_by_text("导出任务建立成功").is_visible(): print(" ✅ 导出任务已建立成功。") confirm_link.click() inner_success = True break elif export_frame.get_by_text( "请选择格式相应的导出字段" ).is_visible(): confirm_link.click() page.wait_for_timeout(500) # 区分:字段漏选 还是 字段列表消失 if export_frame.get_by_text("扫描类型").first.is_visible(): print( " ⚠️ 检测到未选择字段(字段列表仍在),重新点击全选..." ) export_frame.locator(".allRight").click() page.wait_for_timeout(500) else: print(" ⚠️ 字段列表异常消失,重新打开导出面板...") needs_reopen = True break else: confirm_link.click() page.wait_for_timeout(1000) except Exception: page.wait_for_timeout(1000) if inner_success: task_success = True ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(500) break elif needs_reopen: ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(1000) continue else: ws_frame.locator(".layui-layer-close1").click() page.wait_for_timeout(1000) continue 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", ) 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}") print(">> 【导出服务】下载完成,正在关闭标签页...") try: page.locator(".tags-view-item", has_text="导出服务").locator( ".el-icon-close" ).click() print(" ✅ 【导出服务】标签页已关闭。") except: 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: 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(" 临时文件已清理。")