refactor: move flat modules into inbound_verify package (Tier 1, behavior-identical)

Relocate 12 root .py modules into inbound_verify/ (sites/, cli/ subpackages). Rewrite all internal imports to package-qualified; drop the site_ prefix on the 5 site modules and their 28 call sites. Fix paths.py BASE_DIR to anchor at the project root. Add main() entry wrappers (cli/router, cli/server, store). No behavior change.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-07-23 12:21:31 +08:00
parent 0174e64a04
commit 7065b88269
12 changed files with 134 additions and 93 deletions

View File

@@ -0,0 +1,744 @@
# 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异常兜底重置用也供 main_router 的 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_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):
"""韵达:应到货物数据下载(内部含异常兜底重试,路由层无感)。"""
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)
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
_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()
target = today - timedelta(days=offset)
target_ymd = f"{target.year}-{target.month}-{target.day}"
start_date_ymd = target_ymd
today_ymd = target_ymd
print(f">> 设置查询日期: [{target_ymd}](偏移 {offset}0=今天)")
# 设定起始时间
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",
)
return True
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)
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
_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()
target = today - timedelta(days=offset)
start_date = target # 单日范围:起止同日
today = target # 让下方"截止时间"选择器也指向 target
print(
f">> 设置实到查询日期: [{target.year}-{target.month}-{target.day}]"
f"(偏移 {offset}0=今天)"
)
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",
)
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(" 临时文件已清理。")