Files
InboundVerify/inbound_verify/sites/shunxin.py
Misaka 3c32720985 feat(sites): auto-screenshot on final download failure for debugging
所有站点 with_retry 在最后一次重试失败、reset 之前自动截图,
保存到 logs/screenshots/。网页站点走 Playwright page.screenshot(),
安能走 CDP Page.captureScreenshot。截图失败绝不阻塞任务流程。

- paths.py: 新增 SCREENSHOT_DIR (BASE_DIR/logs/screenshots/)
- runtime.py: 新增 capture_error_screenshot() 工具函数
- .gitignore: 新增 logs/ 忽略规则

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 11:18:21 +08:00

885 lines
38 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# sites/shunxin.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, page=None):
"""异常兜底flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。
每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。
最后一次失败时(重置前)自动截图保存到 logs/screenshots/,供问题排查。
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}")
if attempt == max_attempts and page is not None:
try:
from inbound_verify.runtime import capture_error_screenshot
capture_error_screenshot(page, site_name, label, attempt, str(e))
except Exception:
pass
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://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
通过标签文字定位标签容器,点击其右侧的关闭(×)按钮。
- tab_name 采用包含匹配,对“ 运单列表”这类带空格的标签同样有效。
- 关闭失败不会影响主流程,仅打印提示信息。
"""
try:
tab = page.locator(".ant-tabs-tab").filter(
has=page.get_by_role("tab", name=tab_name)
)
if tab.count() == 0:
print(
f" 未找到标签页【{tab_name.strip()}】(可能尚未打开或已关闭),跳过。"
)
return
tab.first.locator(".ant-tabs-tab-remove").click()
print(f" 🗙 已关闭标签页【{tab_name.strip()}")
page.wait_for_timeout(300)
except Exception as e:
print(f" ⚠️ 关闭标签页【{tab_name.strip()}】时出错: {e}")
def _sanitize_for_filename(name):
"""剔除 Windows 文件名非法字符,避免归属地名含特殊字符导致落盘失败。"""
return re.sub(r'[\\/:*?"<>|]', "", str(name)).strip()
def _remove_if_exists(path):
"""删除文件(若存在):清理上次的本账号中间文件/最终文件,避免残留旧数据。"""
try:
if os.path.exists(path):
os.remove(path)
except Exception:
pass
def _shunxin_navigate_picker_to_target(page, date_str, max_flips=24):
"""顺心 Ant Design 单月面板跨月导航(车辆点到 / 卸车扫描记录 共用同一组件)。
读面板头部 .ant-picker-year-btn / .ant-picker-month-btn 得当前显示的年月,按与目标
年月的差值点 .ant-picker-header-prev-btn上月/ .ant-picker-header-next-btn下月
翻到目标月视窗。返回 True 表示当前视窗已是目标月(目标格子随后可见可点)。
调用前提:开始/结束时间输入已点开,.ant-picker-dropdown:visible 已就绪。
"""
try:
ty, tm = (int(x) for x in date_str.split("-")[:2])
except Exception:
return True # 解析不出就不翻,交由后续 cell.click 自行成败
drop = page.locator(".ant-picker-dropdown:visible")
for _ in range(max_flips):
try:
cur_y = int(
re.search(
r"\d+", drop.locator(".ant-picker-year-btn").first.inner_text()
).group()
)
cur_m = int(
re.search(
r"\d+", drop.locator(".ant-picker-month-btn").first.inner_text()
).group()
)
except Exception:
return False
cur = cur_y * 12 + (cur_m - 1)
tgt = ty * 12 + (tm - 1)
if cur == tgt:
return True
btn_sel = (
".ant-picker-header-prev-btn"
if tgt < cur
else ".ant-picker-header-next-btn"
)
drop.locator(btn_sel).first.click()
page.wait_for_timeout(300)
return False
def _shunxin_pick_date(page, date_str):
"""在已打开的顺心 Ant Design 日期浮层上选中指定日期格子(含跨月翻月)。
目标格子不在当前月视窗(跨月)时,先调 _shunxin_navigate_picker_to_target 翻到目标月,
再点格子;同月则直接点。与中通 _zto_flip_to_target_month 思路对称,适配 Ant Design 面板。
"""
cell = page.locator(f".ant-picker-dropdown:visible td[title='{date_str}']").first
if not cell.is_visible():
print(f" 目标日期 {date_str} 不在当前月视窗,正在翻月导航 ...")
if not _shunxin_navigate_picker_to_target(page, date_str):
raise RuntimeError(f"翻月后仍无法定位目标日期格子 {date_str}")
cell = page.locator(
f".ant-picker-dropdown:visible td[title='{date_str}']"
).first
cell.click()
def shunxin_belonging(page):
"""读取顺心当前账号的归属网点名(仅在首页可见,须在导航离开首页前调用)。
取首页「切换网点」下拉框选中项的 title形如「【SX】重庆巴南龙海大道店」
去掉【XX】前缀得到归属地如「重庆巴南龙海大道店」并做文件名安全处理。
读取失败时抛异常,交由上层处理。
"""
item = page.locator(".site___3o7nH .ant-select-selection-item").first
title = (item.get_attribute("title") or item.inner_text() or "").strip()
if not title:
raise RuntimeError("未能读取顺心归属网点(首页「切换网点」控件为空)")
tag = re.sub(r"^【[^】]*】", "", title).strip() or title
return _sanitize_for_filename(tag)
def shunxin_merge_final(kind, tags):
"""把各归属地的中间产物融合成统一的「顺心-{kind}货物数据.xlsx」。
kind ∈ {"应到","实到"}tags 为各账号归属地列表。逐个读取
「顺心-{tag}-{kind}货物数据.xlsx」缺失则跳过容错空数据账号pd.concat
后写出统一文件,并删除中间带 tag 的文件;全部缺失则仅提示、不产出。
"""
final_name = f"顺心-{kind}货物数据.xlsx"
final_path = os.path.join(DOWNLOAD_DIR, final_name)
frames = []
mid_paths = []
for tag in tags:
mid_name = f"顺心-{tag}-{kind}货物数据.xlsx"
mid_path = os.path.join(DOWNLOAD_DIR, mid_name)
if not os.path.exists(mid_path):
print(f" 归属【{tag}】无{kind}中间文件(可能本次无数据),跳过。")
continue
mid_paths.append(mid_path)
try:
df = pd.read_excel(mid_path, dtype=str)
if not df.empty:
frames.append(df)
except Exception as e:
print(f" ⚠️ 读取中间文件 {mid_name} 失败: {e}")
if not frames:
print(f">> ⚠️ 所有归属地均无{kind}数据,删除残留的 {final_name}(不写空表)。")
_remove_if_exists(final_path)
return
combined = pd.concat(frames, ignore_index=True)
combined.to_excel(final_path, index=False)
print("====================================================")
print(
f" {kind}数据融合完成(共 {len(combined)} 行),输出: downloads/{final_name}"
)
print("====================================================")
for mid_path in mid_paths:
try:
os.remove(mid_path)
except Exception:
pass
def shunxin_expected_download(pages, foreground=True, force=False, date=None):
"""顺心:应到货物数据下载(双账号/双归属地,内部含异常兜底重试与数据融合)。
pages 为该站点的 page 列表(双账号在同一窗口的各一个标签页)。
先在首页读取各账号归属地并去重校验(两账号登同一归属地则中止,防数据翻倍),
再顺序对各账号跑一遍下载impl 用归属地作输出文件后缀),最后融合成统一的
「顺心-应到货物数据.xlsx」。路由层只需传入 page 列表,对双账号无感。
"""
tags = []
for idx, pg in enumerate(pages, start=1):
tag = shunxin_belonging(pg)
print(f">> 【顺心】账号{idx} 归属网点:{tag}")
tags.append(tag)
if len(set(tags)) != len(tags):
raise RuntimeError(
f"顺心两个账号归属地相同({tags}),疑似登录了同一账号,已中止以防数据翻倍。"
)
for idx, (pg, tag) in enumerate(zip(pages, tags), start=1):
if foreground:
pg.bring_to_front()
print(f"\n========== 顺心 · 账号{idx}{tag})应到数据下载 ==========")
ok = with_retry(
f"顺心-{tag}",
"应到",
lambda p=pg, t=tag, f=force, d=date: shunxin_expected_download_impl(
p, out_tag=t, force=f, date=d
),
lambda p=pg: shunxin_reset(p),
page=pg,
)
if not ok:
return False # 某账号重试耗尽 → 整体失败,不融合(避免部分数据)
shunxin_merge_final("应到", tags)
return True
def shunxin_expected_download_impl(page, out_tag="", force=False, date=None):
"""顺心:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。
out_tag 为归属地标签时,合并产物命名为「顺心-{out_tag}-应到货物数据.xlsx」
作为双账号融合前的各账号中间文件;为空时退化为「顺心-应到货物数据.xlsx」。
"""
print("\n▶ 开始执行【顺心 - 应到货物数据下载】任务...")
# 初始化并创建下载目录
download_dir = DOWNLOAD_DIR
if not os.path.exists(download_dir):
os.makedirs(download_dir)
print(f">> 已创建下载目录: {download_dir}")
# 清理上次本账号的中间文件,避免本次无数据/失败时残留旧数据被 merge_final 误读
_mid_suffix = f"-{out_tag}" if out_tag else ""
_remove_if_exists(
os.path.join(download_dir, f"顺心{_mid_suffix}-应到货物数据.xlsx")
)
export_times = []
target_task_timestamps = []
try:
# 1. 导航与页面加载判断
print(">> 正在进入【车辆点到】界面...")
page.locator("span.ant-pro-menu-item-title:has-text('派件管理')").click()
page.locator("div.ant-pro-menu-item:has-text('车辆点到')").click()
page.get_by_role("button", name="点到").wait_for(state="visible")
page.get_by_role("button", name="打印交接单").wait_for(state="visible")
page.get_by_role("button", name="强卸").wait_for(state="visible")
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_str = target.strftime("%Y-%m-%d")
start_date_str = target_str
today_str = target_str
src = f"指定 {date}" if date else f"偏移 {offset}0=今天"
print(f">> 正在设置查询日期: [{target_str}]{src}...")
# 分两步精准呼出和点击时间控件
print(" >> 设置起始时间...")
page.get_by_placeholder("开始时间").click()
page.wait_for_timeout(500)
_shunxin_pick_date(page, start_date_str)
page.wait_for_timeout(300)
print(" >> 设置截止时间...")
page.get_by_placeholder("结束时间").click()
page.wait_for_timeout(500)
_shunxin_pick_date(page, today_str)
page.wait_for_timeout(300)
# 确认日期
page.locator(".ant-picker-dropdown:visible button", has_text="确 定").click()
page.wait_for_timeout(500)
print(">> 正在展开【状态】下拉菜单...")
page.locator(
".ant-select-selection-item", has_text=re.compile(r"已发|已到")
).click()
print(">> 正在选择状态为【已到】...")
page.locator(".ant-select-item-option", has_text="已到").click()
page.wait_for_timeout(500)
# ====================================================================
# 🛡️ 双重校验兜底机制:破除顺心表格“暂无数据”的旧状态遗留陷阱
# ====================================================================
print(">> 正在发起查询与数据状态研判...")
has_data = False
waybill_btns = None
for attempt in range(2):
print(f" -> 第 {attempt + 1} 次点击【查询】按钮...")
page.get_by_role("button", name="search 查询").click()
# 尝试在极短时间内捕获加载小菊花
loading_spinner = page.locator(".ant-spin-dot-spin").first
try:
loading_spinner.wait_for(state="visible", timeout=800)
print(" ⏳ 捕捉到加载动画,等待数据渲染完成...")
loading_spinner.wait_for(state="hidden", timeout=15000)
except Exception:
print(" ⚡ 加载动画闪过过快或未出现强制安全缓冲1秒...")
page.wait_for_timeout(1000)
# 解析查询结果
empty_desc = page.locator(
".ant-empty-description", has_text="暂无数据"
).first
waybill_btns = page.get_by_role("button", name="运单列表")
if waybill_btns.count() > 0:
has_data = True
print(" ✅ 数据已成功加载。")
break
elif empty_desc.is_visible():
print(" ⚠️ 当前表格显示【暂无数据】。")
if attempt == 0:
print(" -> 疑似前端 DOM 旧状态未刷新,触发二次兜底查询...")
page.wait_for_timeout(500)
else:
print(" -> 已二次确认为空数据环境。")
else:
# 没出现暂无数据,也没出现按钮,稳妥判定缓冲完毕
pass
if not has_data:
print(">> ⚠️ 本次查询区间内没有数据记录,提前结束流程。")
_close_tab(page, "车辆点到")
return True
# ====================================================================
count = waybill_btns.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} 个班次...")
waybill_btns.nth(i).click()
page.locator("label[title='运单查询']").wait_for(state="visible")
# 【方式1】运单列表界面已加载读交接单号RTS 开头)→ 已落库则退回列表跳过。
# 交接单号格式 RTS\d{3}WJ\d+(如 RTS023WJ374837用 [A-Z0-9]+ 连续匹配整段。
# 读不到DOM 变动/未渲染)则 handover_no 为空 → 不跳过(安全降级,继续导出)。
handover_no = ""
try:
_txt = page.locator("text=/RTS\\d+/").first.inner_text(timeout=3000)
_m = re.search(r"RTS[A-Z0-9]+", _txt)
if _m:
handover_no = _m.group(0)
except Exception:
pass
print(f" -> 运单列表交接单号:{handover_no or '(未读到,不去重)'}")
if handover_no and handover_no in existing:
print(f" ⏭️ 交接单号 {handover_no} 已落库,跳过提交导出。")
page.get_by_role("tab", name="车辆点到").click()
page.wait_for_timeout(500)
continue
# 4. 执行导出流程
page.get_by_role("button", name="export 导出").click()
page.locator(
"span.ant-transfer-list-header-title:has-text('待选导出列')"
).wait_for(state="visible")
page.locator(".ant-transfer-list").first.locator(
".ant-transfer-list-header label"
).click()
page.get_by_label("导出").get_by_role("button", name="right").click()
page.get_by_role("button", name="export 导出数据").click()
export_times.append(datetime.now())
page.locator("text=任务添加成功!").wait_for(state="visible")
page.get_by_role("button", name="知道了").click()
page.get_by_role("tab", name="车辆点到").click()
page.wait_for_timeout(500)
print("✅ 所有班次的导出任务已成功提交!")
# 5. 关闭标签页
_close_tab(page, "运单列表")
_close_tab(page, "车辆点到")
# 【去重兜底】全部已落库/无数据 → 无导出任务,标签页已关,跳过下载轮询
if not export_times:
print(">> 本次无新班次需导出(全部已落库或无数据),结束。")
return True
# 6. 前往数据导出页面去下载
print(">> 正在前往【数据导出】界面...")
page.locator("a[href='/dataExport']").click()
page.get_by_role("columnheader", name="任务标题").wait_for(state="visible")
page.wait_for_timeout(2000)
page.get_by_role("button", name="search 查询").click()
page.wait_for_timeout(2000)
# 7. 轮询任务状态
print(">> 列表已加载,开始匹配并检查任务状态...")
poll_deadline = (
time.monotonic() + 300
) # 5 分钟上限:任务卡死/匹配不上时超时失败,交由上层重置重试
while True:
if time.monotonic() > poll_deadline:
raise RuntimeError(
"轮询导出任务超时5 分钟未全部完成/匹配),疑似任务卡死"
)
rows = page.locator(".ant-table-tbody > tr.ant-table-row")
row_count = rows.count()
pending_tasks = 0
current_ready_timestamps = []
# 单账号无并发:仅按提交时间容差(40s)认领本批任务,不再校验任务标题
# (站点可能调整标题名,写死标题会导致匹配失败、任务一直查不到)。
for i in range(row_count):
tds = rows.nth(i).locator("td")
if tds.count() < 9:
continue
submit_time_str = tds.nth(2).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 != "执行完成":
pending_tasks += 1
if submit_time_str not in current_ready_timestamps:
print(
f" ⏳ 任务 [{submit_time_str}] 状态为【{status_str}】,数据生成中..."
)
else:
if submit_time_str not in current_ready_timestamps:
current_ready_timestamps.append(submit_time_str)
except Exception as e:
print(f" ⚠️ 解析时间时出错: {e}")
if pending_tasks > 0:
print(
f">> 共有 {pending_tasks} 个匹配任务还在处理中,等待 5 秒后刷新..."
)
page.wait_for_timeout(5000)
page.get_by_role("button", name="search 查询").click()
page.wait_for_timeout(2000)
else:
target_task_timestamps = current_ready_timestamps
if len(target_task_timestamps) > 0:
print(">> 所有目标任务已就绪,开始下载...")
break
# 8. 下载逻辑
downloaded_files = []
for time_str in target_task_timestamps:
try:
target_row = page.locator(".ant-table-tbody > tr.ant-table-row").filter(
has=page.locator(f"td:nth-child(3):has-text('{time_str}')")
)
print(f" 开始下载任务 [{time_str}] ...")
with page.expect_download() as download_info:
target_row.locator("td").nth(8).locator(
"button", has_text=re.compile(r"\s*载")
).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}")
except Exception as e:
print(f" ❌ 下载任务 [{time_str}] 失败: {e}")
# 汇总校验:下载成功数必须等于目标任务数,否则判失败
if len(downloaded_files) < len(target_task_timestamps):
raise RuntimeError(
f"仅成功下载 {len(downloaded_files)}/{len(target_task_timestamps)} 个任务,数据不完整"
)
# 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 as e:
pass
if all_data_frames:
combined_df = pd.concat(all_data_frames, ignore_index=True)
suffix = f"-{out_tag}" if out_tag else ""
final_output_path = os.path.join(
download_dir, f"顺心{suffix}-应到货物数据.xlsx"
)
combined_df.to_excel(final_output_path, index=False)
print(f"====================================================")
print(f" 合并完成,输出文件: {final_output_path}")
print(f"====================================================")
for file_path in downloaded_files:
os.remove(file_path)
print("✅ 临时文件已清理。")
# 10. 关闭标签页
_close_tab(page, "数据导出")
print("\n【顺心 - 应到货物数据下载】流程结束。")
return True
except Exception as e:
print(f"\n❌ 任务执行过程中发生异常: {e}")
return False
def shunxin_actual_download(pages, foreground=True, force=False, date=None):
"""顺心:实到货物数据下载(双账号/双归属地,内部含异常兜底重试与数据融合)。
与 shunxin_expected_download 同构:读归属地 → 去重校验 → 顺序各账号下载 →
融合成统一的「顺心-实到货物数据.xlsx」。
"""
tags = []
for idx, pg in enumerate(pages, start=1):
tag = shunxin_belonging(pg)
print(f">> 【顺心】账号{idx} 归属网点:{tag}")
tags.append(tag)
if len(set(tags)) != len(tags):
raise RuntimeError(
f"顺心两个账号归属地相同({tags}),疑似登录了同一账号,已中止以防数据翻倍。"
)
for idx, (pg, tag) in enumerate(zip(pages, tags), start=1):
if foreground:
pg.bring_to_front()
print(f"\n========== 顺心 · 账号{idx}{tag})实到数据下载 ==========")
ok = with_retry(
f"顺心-{tag}",
"实到",
lambda p=pg, t=tag, d=date: shunxin_actual_download_impl(
p, out_tag=t, date=d
),
lambda p=pg: shunxin_reset(p),
page=pg,
)
if not ok:
return False
shunxin_merge_final("实到", tags)
return True
def shunxin_actual_download_impl(page, out_tag="", date=None):
"""顺心:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。
out_tag 为归属地标签时,合并产物命名为「顺心-{out_tag}-实到货物数据.xlsx」
作为双账号融合前的各账号中间文件;为空时退化为「顺心-实到货物数据.xlsx」。
"""
print("\n▶ 开始执行【顺心 - 实到货物数据下载】任务...")
download_dir = DOWNLOAD_DIR
if not os.path.exists(download_dir):
os.makedirs(download_dir)
# 清理上次本账号的中间文件,避免本次无数据/失败时残留旧数据被 merge_final 误读
_mid_suffix = f"-{out_tag}" if out_tag else ""
_remove_if_exists(
os.path.join(download_dir, f"顺心{_mid_suffix}-实到货物数据.xlsx")
)
export_times = []
target_task_timestamps = []
try:
# 1. 导航与页面加载
print(">> 正在进入【卸车扫描记录】界面...")
page.locator("span.ant-pro-menu-item-title:has-text('派件管理')").click()
page.locator("div.ant-pro-menu-item:has-text('卸车扫描记录')").click()
page.get_by_role("radio", name="1天").wait_for(state="visible")
print("✅ 卸车扫描记录界面加载完毕")
# 2. 读取服务端日期偏移0=今天1=昨天…),单日范围:起止同日
offset = state_store.get_offset("顺心", "actual")
today = datetime.now()
if date:
target = datetime.strptime(date, "%Y-%m-%d")
else:
target = today - timedelta(days=offset)
target_str = target.strftime("%Y-%m-%d")
start_date_str = target_str
today_str = target_str
src = f"指定 {date}" if date else f"偏移 {offset}0=今天"
print(f">> 正在设置查询日期: [{target_str}]{src}...")
# 分两步精准呼出和点击时间控件
print(" >> 设置起始时间...")
page.get_by_placeholder("开始时间").click()
page.wait_for_timeout(500)
_shunxin_pick_date(page, start_date_str)
page.wait_for_timeout(300)
print(" >> 设置截止时间...")
page.get_by_placeholder("结束时间").click()
page.wait_for_timeout(500)
_shunxin_pick_date(page, today_str)
page.wait_for_timeout(300)
# 确认日期
page.locator(".ant-picker-dropdown:visible button", has_text="确 定").click()
page.wait_for_timeout(500)
# ====================================================================
# 🛡️ 卸车扫描记录页面双重校验兜底机制
# ====================================================================
print(">> 正在发起查询与数据状态研判...")
has_data = False
for attempt in range(2):
print(f" -> 第 {attempt + 1} 次点击【查询】按钮...")
page.get_by_role("button", name="search 查询").click()
loading_spinner = page.locator(".ant-spin-dot-spin").first
try:
loading_spinner.wait_for(state="visible", timeout=800)
print(" ⏳ 捕捉到加载动画,等待数据渲染完成...")
loading_spinner.wait_for(state="hidden", timeout=15000)
except Exception:
print(" ⚡ 加载动画闪过过快或未出现强制安全缓冲1秒...")
page.wait_for_timeout(1000)
empty_desc = page.locator(
".ant-empty-description", has_text="暂无数据"
).first
data_rows = page.locator(".ant-table-tbody > tr.ant-table-row")
if empty_desc.is_visible():
print(" ⚠️ 当前表格显示【暂无数据】。")
if attempt == 0:
print(" -> 疑似前端 DOM 旧状态未刷新,触发二次兜底查询...")
page.wait_for_timeout(500)
else:
print(" -> 已二次确认为空数据环境。")
elif data_rows.count() > 0:
has_data = True
print(" ✅ 数据已成功加载。")
break
else:
pass
if not has_data:
print(">> ⚠️ 本次查询未产生任何卸车记录,提前结束流程。")
_close_tab(page, "卸车扫描记录")
return True
# ====================================================================
# 3. 直接发起全局导出
print(">> 正在发起全局数据导出请求...")
page.get_by_role("button", name="export 导出").click()
page.locator(
"span.ant-transfer-list-header-title:has-text('待选导出列')"
).wait_for(state="visible")
page.locator(".ant-transfer-list").first.locator(
".ant-transfer-list-header label"
).click()
page.get_by_label("导出").get_by_role("button", name="right").click()
page.get_by_role("button", name="export 导出数据").click()
export_times.append(datetime.now())
page.locator("text=任务添加成功!").wait_for(state="visible")
page.get_by_role("button", name="知道了").click()
print("✅ 卸车扫描记录导出任务已成功提交!")
# 4. 关闭标签页
_close_tab(page, "卸车扫描记录")
# 5. 前往数据导出页面去下载
print(">> 正在前往【数据导出】界面...")
page.locator("a[href='/dataExport']").click()
page.get_by_role("columnheader", name="任务标题").wait_for(state="visible")
page.wait_for_timeout(2000)
page.get_by_role("button", name="search 查询").click()
page.wait_for_timeout(2000)
# 6. 轮询任务状态
print(">> 列表已加载,开始匹配并检查任务状态...")
poll_deadline = (
time.monotonic() + 300
) # 5 分钟上限:任务卡死/匹配不上时超时失败,交由上层重置重试
while True:
if time.monotonic() > poll_deadline:
raise RuntimeError(
"轮询导出任务超时5 分钟未全部完成/匹配),疑似任务卡死"
)
rows = page.locator(".ant-table-tbody > tr.ant-table-row")
row_count = rows.count()
pending_tasks = 0
current_ready_timestamps = []
# 单账号无并发:仅按提交时间容差(40s)认领本批任务,不再校验任务标题
# (站点可能调整标题名,写死标题会导致匹配失败、任务一直查不到)。
for i in range(row_count):
tds = rows.nth(i).locator("td")
if tds.count() < 9:
continue
submit_time_str = tds.nth(2).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 != "执行完成":
pending_tasks += 1
if submit_time_str not in current_ready_timestamps:
print(
f" ⏳ 任务 [{submit_time_str}] 状态为【{status_str}】,数据生成中..."
)
else:
if submit_time_str not in current_ready_timestamps:
current_ready_timestamps.append(submit_time_str)
except Exception as e:
print(f" ⚠️ 解析时间时出错: {e}")
if pending_tasks > 0:
print(
f">> 共有 {pending_tasks} 个匹配任务还在处理中,等待 5 秒后刷新..."
)
page.wait_for_timeout(5000)
page.get_by_role("button", name="search 查询").click()
page.wait_for_timeout(2000)
else:
target_task_timestamps = current_ready_timestamps
if len(target_task_timestamps) > 0:
print(">> 目标任务已就绪,开始下载...")
break
# 7. 下载逻辑
downloaded_files = []
for time_str in target_task_timestamps:
try:
target_row = page.locator(".ant-table-tbody > tr.ant-table-row").filter(
has=page.locator(f"td:nth-child(3):has-text('{time_str}')")
)
print(f" 开始下载任务 [{time_str}] ...")
with page.expect_download() as download_info:
target_row.locator("td").nth(8).locator(
"button", has_text=re.compile(r"\s*载")
).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}")
except Exception as e:
print(f" ❌ 下载任务 [{time_str}] 失败: {e}")
# 汇总校验:下载成功数必须等于目标任务数,否则判失败
if len(downloaded_files) < len(target_task_timestamps):
raise RuntimeError(
f"仅成功下载 {len(downloaded_files)}/{len(target_task_timestamps)} 个任务,数据不完整"
)
# 8. 合并数据
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 as e:
pass
if all_data_frames:
combined_df = pd.concat(all_data_frames, ignore_index=True)
suffix = f"-{out_tag}" if out_tag else ""
final_output_path = os.path.join(
download_dir, f"顺心{suffix}-实到货物数据.xlsx"
)
combined_df.to_excel(final_output_path, index=False)
print(f"====================================================")
print(f" 合并完成,输出文件: {final_output_path}")
print(f"====================================================")
for file_path in downloaded_files:
os.remove(file_path)
print("✅ 临时文件已清理。")
# 9. 关闭标签页
_close_tab(page, "数据导出")
print("\n【顺心 - 实到货物数据下载】流程结束。")
return True
except Exception as e:
print(f"\n❌ 任务执行过程中发生异常: {e}")
return False