顺心站点支持双账号(双归属地)下载与融合

- main_router: 顺心在同一窗口开两个标签页登录两个归属地账号;就绪轮询、
  初始弹窗、菜单 [1][2]、自动化测试均改为按 page 列表处理
- site_shunxin: download 入口改为接收 page 列表;新增 shunxin_belonging
  读归属地、shunxin_merge_final 融合两账号数据;impl 加 out_tag 参数化
  各账号产物文件名;两账号同归属地时去重校验中止以防数据翻倍
- expected_undelivered: 零改动(融合后产物仍为同名文件)
- 更新 CLAUDE.md / README.md 文档说明

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-07-15 22:36:58 +08:00
parent 75abbdf14c
commit 55e9e9e931
4 changed files with 230 additions and 55 deletions

View File

@@ -40,7 +40,9 @@ playwright install chromium
### 两套驱动模态 —— 这是理解全局的关键 ### 两套驱动模态 —— 这是理解全局的关键
- **网页 4 站**(顺心/百世/中通/韵达):`main_router` 用 Playwright 开 chromium - **网页 4 站**(顺心/百世/中通/韵达):`main_router` 用 Playwright 开 chromium
每站一个 `page`,流程函数签名为 `xxx_download_impl(page)` 每站一个 `page`,流程函数签名为 `xxx_download_impl(page)`**例外:顺心是双账号**
——同一窗口开两个标签页(两个归属地账号),`pages_map["顺心"]` 存为 page **列表**
`shunxin_download(pages)` 接收列表(详见下文「顺心双账号」)。
- **安能**Electron 桌面应用,**不走 Playwright**。`main_router` - **安能**Electron 桌面应用,**不走 Playwright**。`main_router`
`--remote-debugging-port=<动态空闲端口>` 启动 exe`launch_anneng` `--remote-debugging-port=<动态空闲端口>` 启动 exe`launch_anneng`
通过 `site_anneng.set_cdp_port` 告知模块;`site_anneng.py` 用裸 CDPwebsocket 通过 `site_anneng.set_cdp_port` 告知模块;`site_anneng.py` 用裸 CDPwebsocket
@@ -49,7 +51,7 @@ playwright install chromium
### 分层:路由纯调度,站点模块自洽 ### 分层:路由纯调度,站点模块自洽
- `main_router.py` **只调度**:启动浏览器/安能、就绪轮询、登录检测、菜单分发。 - `main_router.py` **只调度**:启动浏览器/安能、就绪轮询、登录检测、菜单分发。
菜单项直接调 `site_xxx.xxx_download(page)`**不关心**重试/重置。 菜单项直接调 `site_xxx.xxx_download(page)`(顺心传 page 列表)**不关心**重试/重置。
- 每个 `site_xxx.py` 对外只暴露"把任务做了"的入口,内部自洽: - 每个 `site_xxx.py` 对外只暴露"把任务做了"的入口,内部自洽:
- `xxx_download(...)` —— 公开入口,= `with_retry(站点, 标签, xxx_download_impl, xxx_reset)` - `xxx_download(...)` —— 公开入口,= `with_retry(站点, 标签, xxx_download_impl, xxx_reset)`
- `xxx_download_impl(...)` —— 单次执行、**无重试**(自动化测试刻意调它以探测原始失败) - `xxx_download_impl(...)` —— 单次执行、**无重试**(自动化测试刻意调它以探测原始失败)
@@ -67,6 +69,20 @@ playwright install chromium
- 例外:**百世**是同步下载(`expect_download`,无队列);**安能-实到**页面无导出按钮, - 例外:**百世**是同步下载(`expect_download`,无队列);**安能-实到**页面无导出按钮,
直接抓表格 DOM 翻页。 直接抓表格 DOM 翻页。
### 顺心双账号(双归属地)
顺心业务上要同时处理**两个归属地网点**(两个账号)。程序在同一窗口开两个标签页,
人工分别登录两个账号(顺心站点支持同浏览器双账号并存,无需独立 context/窗口)。
- `main_router` 启动时为顺心开 2 个 `context.new_page()``pages_map["顺心"]` 为列表;
就绪轮询要求**两个标签页都进主页**才算就绪;初始弹窗对两个标签页各处理一遍。
- `shunxin_expected_download(pages)` / `shunxin_actual_download(pages)` 接收 page 列表:
先用 `shunxin_belonging(page)` 读各账号归属地(首页「切换网点」控件 `.site___3o7nH`
**去重校验**(两账号同归属地则报错中止,防数据翻倍),再顺序对各账号跑一遍
`xxx_download_impl(page, out_tag=归属地)`(产物 `顺心-{归属}-{应到/实到}货物数据.xlsx`
最后 `shunxin_merge_final` 把两份 `pd.concat` 成统一的 `顺心-{应到/实到}货物数据.xlsx`
并删中间文件。比对层 `expected_undelivered` **零改动**(仍读同名文件)。
- 导出队列不串扰:双账号**顺序执行**,账号 A 走完完整下载流程(远超 40s后 B 才提交,
配合每账号独立 `export_times` + ≤40s 容差B 不会误匹配 A 的任务。
### 比对 ### 比对
`expected_undelivered.py`(菜单 [9])纯离线:读 `downloads/` 下各站应到/实到 xlsx `expected_undelivered.py`(菜单 [9])纯离线:读 `downloads/` 下各站应到/实到 xlsx
比对生成 `output/应到未到数据.xlsx`(汇总 + 各站明细)。 比对生成 `output/应到未到数据.xlsx`(汇总 + 各站明细)。
@@ -79,6 +95,8 @@ playwright install chromium
- **登录是手动的**`main_router` 启动后会停在就绪轮询(`READY_SELECTORS` / `anneng_ready` - **登录是手动的**`main_router` 启动后会停在就绪轮询(`READY_SELECTORS` / `anneng_ready`
直到检测到所有站点进入工作台才进菜单。仅韵达支持凭 `config.yaml` 凭据自动登录。 直到检测到所有站点进入工作台才进菜单。仅韵达支持凭 `config.yaml` 凭据自动登录。
**顺心需登录两个账号**:同一窗口的两个标签页分别登录两个不同归属地账号,两个标签页
都进主页后才算就绪(顺心站点支持同浏览器双账号并存,故用同 context 标签页而非独立窗口)。
- **安能单实例**:启动前必须先关闭已打开的安能窗口,否则调试端口起不来。 - **安能单实例**:启动前必须先关闭已打开的安能窗口,否则调试端口起不来。
- **安能重置绝不能用 `Page.reload`**reload 会让已开业务 webContents 失去 app 引用、 - **安能重置绝不能用 `Page.reload`**reload 会让已开业务 webContents 失去 app 引用、
变成 `Target.closeTarget` / `window.close` 都杀不掉的僵尸。重置只能走 tab 条 X 关 tab + 收菜单。 变成 `Target.closeTarget` / `window.close` 都杀不掉的僵尸。重置只能走 tab 条 X 关 tab + 收菜单。

View File

@@ -101,9 +101,10 @@ python main_router.py
程序会: 程序会:
1. 用 Playwright 打开 4 个网页站点(韵达若配了凭据会尝试自动登录,其余手动登录); 1. 用 Playwright 打开网页站点(**顺心为双账号**:同一窗口开两个标签页,分别登录两个
不同归属地账号;韵达若配了凭据会尝试自动登录,其余手动登录);
2. 以调试模式启动安能 Electron 应用(动态空闲端口),**需在应用内手动登录** 2. 以调试模式启动安能 Electron 应用(动态空闲端口),**需在应用内手动登录**
3. **轮询各站点登录就绪状态**——全部登录完成后自动进入主菜单; 3. **轮询各站点登录就绪状态**——全部登录完成后自动进入主菜单(顺心需两个标签页都进主页)
4. 弹出主菜单,按编号选择任务。 4. 弹出主菜单,按编号选择任务。
主菜单: 主菜单:
@@ -140,6 +141,10 @@ python main_router.py
站点的 `xxx_reset` 回到初始态 → 重试,**最多 3 次(含首次)**;每次失败都重置 站点的 `xxx_reset` 回到初始态 → 重试,**最多 3 次(含首次)**;每次失败都重置
(含最终放弃那次),确保环境不残留脏状态。 (含最终放弃那次),确保环境不残留脏状态。
> **顺心是双账号特例**:同一窗口开两个标签页(两个归属地账号),`shunxin_download`
> 接收 page 列表,内部读各账号归属地 → 去重校验 → 顺序下载 → `pd.concat` 融合成
> 统一的 `顺心-{应到/实到}货物数据.xlsx`,比对层无感(仍读同名文件)。
> 安能是 Electron由 CDP远程调试端口驱动而非 Playwright page。其重置**不用 > 安能是 Electron由 CDP远程调试端口驱动而非 Playwright page。其重置**不用
> `Page.reload`**——reload 会让已开的业务 webContents 失去引用、变成无法清理的僵尸, > `Page.reload`**——reload 会让已开的业务 webContents 失去引用、变成无法清理的僵尸,
> 故改用"走 tab 条 X 关 tab + 收起菜单"。 > 故改用"走 tab 条 X 关 tab + 收起菜单"。

View File

@@ -125,18 +125,38 @@ def run_automation_test(pages_map):
}, },
} }
# 构建测试计划:[(站点, 流程中文名, 流程函数), ...] # 构建测试计划:[(站点, 流程中文名, 流程函数, 绑定page, 归属标签), ...]
# 顺心为双账号:两个 page 各自读归属地后跑一遍交叉序列;其余站点单 page。
plan = [] plan = []
for site_name, flows in flow_table.items(): for site_name, flows in flow_table.items():
if site_name not in pages_map: if site_name == "顺心":
if "顺心" not in pages_map:
continue continue
for sx_idx, sx_page in enumerate(pages_map["顺心"], start=1):
try:
tag = site_shunxin.shunxin_belonging(sx_page)
except Exception:
tag = f"账号{sx_idx}" # 读不到归属地时用序号占位,不阻断测试
for flow_key in CROSS_TEST_SEQUENCE: for flow_key in CROSS_TEST_SEQUENCE:
label, func = flows[flow_key] label, func = flows[flow_key]
plan.append((site_name, label, func)) plan.append((f"顺心·{tag}", label, func, sx_page, tag))
continue
if site_name not in pages_map:
continue
bound_page = pages_map[site_name]
for flow_key in CROSS_TEST_SEQUENCE:
label, func = flows[flow_key]
plan.append((site_name, label, func, bound_page, ""))
# 百世:单流程,跑一次即可 # 百世:单流程,跑一次即可
if "百世" in pages_map: if "百世" in pages_map:
plan.append( plan.append(
("百世", "应到未到", site_baishi.baishi_download_undelivered_data_impl) (
"百世",
"应到未到",
site_baishi.baishi_download_undelivered_data_impl,
pages_map["百世"],
"",
)
) )
if not plan: if not plan:
@@ -150,7 +170,7 @@ def run_automation_test(pages_map):
print("====================================================") print("====================================================")
results = [] # [(站点, 流程, 状态, 耗时秒, 错误信息)] results = [] # [(站点, 流程, 状态, 耗时秒, 错误信息)]
for idx, (site_name, label, func) in enumerate(plan, start=1): for idx, (site_name, label, func, bound_page, out_tag) in enumerate(plan, start=1):
print("\n----------------------------------------------------") print("\n----------------------------------------------------")
print(f"[步骤 {idx}/{total}] 站点【{site_name}】流程【{label}") print(f"[步骤 {idx}/{total}] 站点【{site_name}】流程【{label}")
print("----------------------------------------------------") print("----------------------------------------------------")
@@ -162,9 +182,9 @@ def run_automation_test(pages_map):
# 安能Electron 应用,无 Playwright page函数不收 page 参数 # 安能Electron 应用,无 Playwright page函数不收 page 参数
ret = func() ret = func()
else: else:
page = pages_map[site_name] bound_page.bring_to_front()
page.bring_to_front() # 顺心 _impl 带 out_tag归属地其余站点 _impl 仅收 page
ret = func(page) ret = func(bound_page, out_tag=out_tag) if out_tag else func(bound_page)
if ret is False: if ret is False:
status = "FAIL" status = "FAIL"
err = "流程返回失败状态" err = "流程返回失败状态"
@@ -252,6 +272,17 @@ def run_multi_site_daemon():
print("====================================================") print("====================================================")
for site_name, url in active_sites.items(): for site_name, url in active_sites.items():
if site_name == "顺心":
# 顺心:两个归属地账号在同一窗口各开一个标签页(顺心站点支持
# 同浏览器双账号并存pages_map["顺心"] 存为 page 列表。
sx_pages = []
for acct in range(1, 3):
print(f">> 正在启动【顺心】账号{acct}标签页: {url}")
sx_page = context.new_page()
sx_page.goto(url)
sx_pages.append(sx_page)
pages_map["顺心"] = sx_pages
else:
print(f">> 正在启动【{site_name}】页面: {url}") print(f">> 正在启动【{site_name}】页面: {url}")
page = context.new_page() page = context.new_page()
page.goto(url) page.goto(url)
@@ -296,6 +327,12 @@ def run_multi_site_daemon():
if site_name == "安能": if site_name == "安能":
# 安能走 CDP 判断主页是否就绪(连不上返回 False不抛异常 # 安能走 CDP 判断主页是否就绪(连不上返回 False不抛异常
ok = site_anneng.anneng_ready() ok = site_anneng.anneng_ready()
elif site_name == "顺心":
# 顺心双账号:两个 page 都进主页才算就绪
ok = all(
pg.locator(READY_SELECTORS["顺心"]).is_visible(timeout=500)
for pg in pages_map["顺心"]
)
else: else:
# 0.5 秒轻量探测,避免阻塞主循环 # 0.5 秒轻量探测,避免阻塞主循环
ok = ( ok = (
@@ -321,18 +358,18 @@ def run_multi_site_daemon():
print("【准备】所有站点已就绪,正在清理初始弹窗...") print("【准备】所有站点已就绪,正在清理初始弹窗...")
print("====================================================") print("====================================================")
# 顺心:处理初始弹窗 # 顺心:处理初始弹窗(双账号两个 page 各处理一遍)
if "顺心" in pages_map: if "顺心" in pages_map:
for acct, sx_page in enumerate(pages_map["顺心"], start=1):
try: try:
sx_page = pages_map["顺心"]
sx_page.bring_to_front() sx_page.bring_to_front()
print(">> 正在处理【顺心】弹窗与遮罩...") print(f">> 正在处理【顺心】账号{acct}弹窗与遮罩...")
sx_page.locator("a").nth(4).click(timeout=2000) sx_page.locator("a").nth(4).click(timeout=2000)
sx_page.wait_for_timeout(500) sx_page.wait_for_timeout(500)
sx_page.get_by_role("button", name="Close").click(timeout=2000) sx_page.get_by_role("button", name="Close").click(timeout=2000)
sx_page.wait_for_timeout(500) sx_page.wait_for_timeout(500)
sx_page.get_by_role("button", name="不再询问").click(timeout=2000) sx_page.get_by_role("button", name="不再询问").click(timeout=2000)
print(" ✅ 【顺心】初始弹窗处理完成。") print(f" ✅ 【顺心】账号{acct}初始弹窗处理完成。")
except Exception: except Exception:
pass # 环境可能很干净没有弹窗,无视报错 pass # 环境可能很干净没有弹窗,无视报错
@@ -423,13 +460,9 @@ def run_multi_site_daemon():
try: try:
if choice == "1" and is_site_ready("顺心"): if choice == "1" and is_site_ready("顺心"):
page = pages_map["顺心"] site_shunxin.shunxin_expected_download(pages_map["顺心"])
page.bring_to_front()
site_shunxin.shunxin_expected_download(page)
elif choice == "2" and is_site_ready("顺心"): elif choice == "2" and is_site_ready("顺心"):
page = pages_map["顺心"] site_shunxin.shunxin_actual_download(pages_map["顺心"])
page.bring_to_front()
site_shunxin.shunxin_actual_download(page)
elif choice == "3" and is_site_ready("百世"): elif choice == "3" and is_site_ready("百世"):
page = pages_map["百世"] page = pages_map["百世"]
page.bring_to_front() page.bring_to_front()

View File

@@ -71,19 +71,109 @@ def _close_tab(page, tab_name):
print(f" ⚠️ 关闭标签页【{tab_name.strip()}】时出错: {e}") print(f" ⚠️ 关闭标签页【{tab_name.strip()}】时出错: {e}")
def shunxin_expected_download(page): def _sanitize_for_filename(name):
"""顺心:应到货物数据下载(内部含异常兜底重试,路由层无感)""" """剔除 Windows 文件名非法字符,避免归属地名含特殊字符导致落盘失败"""
return re.sub(r'[\\/:*?"<>|]', "", str(name)).strip()
return with_retry(
"顺心", def shunxin_belonging(page):
"应到", """读取顺心当前账号的归属网点名(仅在首页可见,须在导航离开首页前调用)。
lambda: shunxin_expected_download_impl(page),
lambda: shunxin_reset(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}")
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):
"""顺心:应到货物数据下载(双账号/双归属地,内部含异常兜底重试与数据融合)。
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):
pg.bring_to_front()
print(f"\n========== 顺心 · 账号{idx}{tag})应到数据下载 ==========")
with_retry(
f"顺心-{tag}",
"应到",
lambda p=pg, t=tag: shunxin_expected_download_impl(p, out_tag=t),
lambda p=pg: shunxin_reset(p),
)
def shunxin_expected_download_impl(page): shunxin_merge_final("应到", tags)
"""顺心:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。"""
def shunxin_expected_download_impl(page, out_tag=""):
"""顺心:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。
out_tag 为归属地标签时,合并产物命名为「顺心-{out_tag}-应到货物数据.xlsx」
作为双账号融合前的各账号中间文件;为空时退化为「顺心-应到货物数据.xlsx」。
"""
print("\n▶ 开始执行【顺心 - 应到货物数据下载】任务...") print("\n▶ 开始执行【顺心 - 应到货物数据下载】任务...")
# 初始化并创建下载目录 # 初始化并创建下载目录
@@ -344,7 +434,10 @@ def shunxin_expected_download_impl(page):
if all_data_frames: if all_data_frames:
combined_df = pd.concat(all_data_frames, ignore_index=True) combined_df = pd.concat(all_data_frames, ignore_index=True)
final_output_path = os.path.join(download_dir, "顺心-应到货物数据.xlsx") 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) combined_df.to_excel(final_output_path, index=False)
print(f"====================================================") print(f"====================================================")
print(f" 合并完成,输出文件: {final_output_path}") print(f" 合并完成,输出文件: {final_output_path}")
@@ -364,19 +457,42 @@ def shunxin_expected_download_impl(page):
return False return False
def shunxin_actual_download(page): def shunxin_actual_download(pages):
"""顺心:实到货物数据下载(内部含异常兜底重试,路由层无感)。""" """顺心:实到货物数据下载(双账号/双归属地,内部含异常兜底重试与数据融合)。
return with_retry( 与 shunxin_expected_download 同构:读归属地 → 去重校验 → 顺序各账号下载 →
"顺心", 融合成统一的「顺心-实到货物数据.xlsx」。
"实到", """
lambda: shunxin_actual_download_impl(page), tags = []
lambda: shunxin_reset(page), 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):
pg.bring_to_front()
print(f"\n========== 顺心 · 账号{idx}{tag})实到数据下载 ==========")
with_retry(
f"顺心-{tag}",
"实到",
lambda p=pg, t=tag: shunxin_actual_download_impl(p, out_tag=t),
lambda p=pg: shunxin_reset(p),
)
def shunxin_actual_download_impl(page): shunxin_merge_final("实到", tags)
"""顺心:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。"""
def shunxin_actual_download_impl(page, out_tag=""):
"""顺心:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。
out_tag 为归属地标签时,合并产物命名为「顺心-{out_tag}-实到货物数据.xlsx」
作为双账号融合前的各账号中间文件;为空时退化为「顺心-实到货物数据.xlsx」。
"""
print("\n▶ 开始执行【顺心 - 实到货物数据下载】任务...") print("\n▶ 开始执行【顺心 - 实到货物数据下载】任务...")
download_dir = DOWNLOAD_DIR download_dir = DOWNLOAD_DIR
@@ -608,7 +724,10 @@ def shunxin_actual_download_impl(page):
if all_data_frames: if all_data_frames:
combined_df = pd.concat(all_data_frames, ignore_index=True) combined_df = pd.concat(all_data_frames, ignore_index=True)
final_output_path = os.path.join(download_dir, "顺心-实到货物数据.xlsx") 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) combined_df.to_excel(final_output_path, index=False)
print(f"====================================================") print(f"====================================================")
print(f" 合并完成,输出文件: {final_output_path}") print(f" 合并完成,输出文件: {final_output_path}")