The ZTO jQuery Date Range Picker always displays a dual-month view. When today falls on the 1st (or early days) of a month, the same date appears in both panels: a hidden ghost cell (month1, display:none) and a visible cell (month2). Both carry the real-today CSS class, so .first picks the hidden one, causing wait_for(visible) to timeout. Replace DOM-based real-today time extraction with Python datetime computation. Add _zto_find_visible_day to locate the actually visible date cell (skipping hidden ghost cells, trying both midnight and 23:59:59 time variants). Fix _zto_flip_to_target_month to use the same visibility-aware lookup. Co-Authored-By: Claude <noreply@anthropic.com>
759 lines
32 KiB
Python
759 lines
32 KiB
Python
# sites/zto.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://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_compute_target_time(offset):
|
||
"""直接从 Python datetime 计算目标日期的毫秒级时间戳(本地时区零点)。
|
||
|
||
不再从 DOM 的 real-today 元素读取 time 属性,避免双月视图下 real-today
|
||
同时出现在 month1(隐藏 ghost cell)和 month2(可见)导致 .first 取到隐藏元素。
|
||
"""
|
||
target_date = datetime.now().date() - timedelta(days=offset)
|
||
target_dt = datetime(target_date.year, target_date.month, target_date.day)
|
||
return int(target_dt.timestamp() * 1000)
|
||
|
||
|
||
def _zto_find_visible_day(frame_locator, target_time):
|
||
"""在双月日期控件中查找可见的日期格子。
|
||
|
||
jQuery Date Range Picker 双月视图下,同一天可能出现在两个面板中:
|
||
- month1(左面板)的溢出 ghost cell:display:none,不可见
|
||
- month2(右面板)的正常 cell:可见
|
||
|
||
同一日期在 DOM 中可能有毫秒级差异(零点 vs 23:59:59),遍历匹配并返回
|
||
第一个 visible 的;无可见匹配返回 None。
|
||
"""
|
||
# 尝试两个时间变体:零点 和 23:59:59(部分 checked/selected 格用后者)
|
||
for time_variant in (target_time, target_time + 86399000):
|
||
sel = f"td div.day[time='{time_variant}']"
|
||
cells = frame_locator.locator(sel)
|
||
count = cells.count()
|
||
for i in range(count):
|
||
if cells.nth(i).is_visible():
|
||
return cells.nth(i)
|
||
return None
|
||
|
||
|
||
def _zto_flip_to_target_month(frame_locator, page, target_time, max_flips=12):
|
||
"""中通日历(jQuery-Date-Range-Picker 双月视图)跨月导航:目标日期不在当前视窗时,
|
||
循环点 .prev 把目标月翻进视窗。用 _zto_find_visible_day 判可见(跳过隐藏 ghost cell)。
|
||
返回 True 若目标格子最终可见。"""
|
||
for _ in range(max_flips):
|
||
if _zto_find_visible_day(frame_locator, target_time) is not None:
|
||
return True
|
||
frame_locator.locator(".date-picker-wrapper .prev").first.evaluate(
|
||
"el => el.click()"
|
||
)
|
||
page.wait_for_timeout(450)
|
||
return _zto_find_visible_day(frame_locator, target_time) is not None
|
||
|
||
|
||
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, date=None):
|
||
"""中通:应到货物数据下载(内部含异常兜底重试,路由层无感)。"""
|
||
|
||
return with_retry(
|
||
"中通",
|
||
"应到",
|
||
lambda: zto_expected_download_impl(page, force=force, date=date),
|
||
lambda: zto_reset(page),
|
||
)
|
||
|
||
|
||
def zto_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. 菜单导航
|
||
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("中通")
|
||
if date:
|
||
# 指定日期:折算成相对今天的有效偏移,复用下方 target_time 计算与跨月翻月
|
||
target_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||
offset = (datetime.now().date() - target_date).days
|
||
print(f">> 正在设定查询日期: 指定日期 {date}(折算偏移 {offset})...")
|
||
else:
|
||
print(f">> 正在设定查询日期: 偏移 {offset}(0=今天)...")
|
||
ewb_frame.locator("#beginDate").click()
|
||
page.wait_for_timeout(500)
|
||
|
||
# 直接从 Python datetime 计算目标时间戳,不再依赖 DOM real-today(双月视图
|
||
# 下 real-today 可能同时出现在 month1 隐藏 ghost cell 和 month2 可见 cell,
|
||
# .first 会取到隐藏的那个导致 wait_for(visible) 超时)。
|
||
target_time = _zto_compute_target_time(offset)
|
||
target_cell = _zto_find_visible_day(ewb_frame, target_time)
|
||
|
||
if target_cell is None:
|
||
print(" ℹ️ 目标日期不在当前视窗,正在翻月导航 ...")
|
||
if not _zto_flip_to_target_month(ewb_frame, page, target_time):
|
||
raise RuntimeError(
|
||
f"翻月后仍无法定位目标日期格子(time={target_time})"
|
||
)
|
||
target_cell = _zto_find_visible_day(ewb_frame, target_time)
|
||
if target_cell is None:
|
||
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)
|
||
|
||
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, date=None):
|
||
"""中通:实到货物数据下载(内部含异常兜底重试,路由层无感)。"""
|
||
|
||
return with_retry(
|
||
"中通",
|
||
"实到",
|
||
lambda: zto_actual_download_impl(page, date=date),
|
||
lambda: zto_reset(page),
|
||
)
|
||
|
||
|
||
def zto_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:
|
||
# 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")
|
||
if date:
|
||
target_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||
offset = (datetime.now().date() - target_date).days
|
||
print(f">> 正在设定查询日期: 指定日期 {date}(折算偏移 {offset})...")
|
||
else:
|
||
print(f">> 正在设定查询日期: 偏移 {offset}(0=今天)...")
|
||
arr_frame.locator("#daterange").click()
|
||
page.wait_for_timeout(500)
|
||
|
||
# 直接从 Python datetime 计算目标时间戳,不再依赖 DOM real-today(双月视图
|
||
# 下 real-today 可能同时出现在 month1 隐藏 ghost cell 和 month2 可见 cell,
|
||
# .first 会取到隐藏的那个导致 wait_for(visible) 超时)。
|
||
target_time = _zto_compute_target_time(offset)
|
||
target_cell = _zto_find_visible_day(arr_frame, target_time)
|
||
|
||
if target_cell is None:
|
||
print(" ℹ️ 目标日期不在当前视窗,正在翻月导航 ...")
|
||
if not _zto_flip_to_target_month(arr_frame, page, target_time):
|
||
raise RuntimeError(
|
||
f"翻月后仍无法定位目标日期格子(time={target_time})"
|
||
)
|
||
target_cell = _zto_find_visible_day(arr_frame, target_time)
|
||
if target_cell is None:
|
||
raise RuntimeError(
|
||
f"翻月后仍无法定位目标日期格子(time={target_time})"
|
||
)
|
||
|
||
# 日期格子用 _dom_click 直接派发事件:Playwright 的 .click() 会先 hover 格子,
|
||
# 触发"范围长度"提示气泡(.date-range-length-tip)盖住格子,导致点击被判遮挡而超时。
|
||
_dom_click(target_cell)
|
||
page.wait_for_timeout(300)
|
||
_dom_click(target_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(" 临时文件已清理。")
|