diff --git a/CLAUDE.md b/CLAUDE.md index 9213739..6aa0f79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,9 @@ playwright install 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` 以 `--remote-debugging-port=<动态空闲端口>` 启动 exe(`launch_anneng`), 通过 `site_anneng.set_cdp_port` 告知模块;`site_anneng.py` 用裸 CDP(websocket) @@ -49,7 +51,7 @@ playwright install chromium ### 分层:路由纯调度,站点模块自洽 - `main_router.py` **只调度**:启动浏览器/安能、就绪轮询、登录检测、菜单分发。 - 菜单项直接调 `site_xxx.xxx_download(page)`,**不关心**重试/重置。 + 菜单项直接调 `site_xxx.xxx_download(page)`(顺心传 page 列表),**不关心**重试/重置。 - 每个 `site_xxx.py` 对外只暴露"把任务做了"的入口,内部自洽: - `xxx_download(...)` —— 公开入口,= `with_retry(站点, 标签, xxx_download_impl, xxx_reset)` - `xxx_download_impl(...)` —— 单次执行、**无重试**(自动化测试刻意调它以探测原始失败) @@ -67,6 +69,20 @@ playwright install chromium - 例外:**百世**是同步下载(`expect_download`,无队列);**安能-实到**页面无导出按钮, 直接抓表格 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, 比对生成 `output/应到未到数据.xlsx`(汇总 + 各站明细)。 @@ -79,6 +95,8 @@ playwright install chromium - **登录是手动的**:`main_router` 启动后会停在就绪轮询(`READY_SELECTORS` / `anneng_ready`), 直到检测到所有站点进入工作台才进菜单。仅韵达支持凭 `config.yaml` 凭据自动登录。 + **顺心需登录两个账号**:同一窗口的两个标签页分别登录两个不同归属地账号,两个标签页 + 都进主页后才算就绪(顺心站点支持同浏览器双账号并存,故用同 context 标签页而非独立窗口)。 - **安能单实例**:启动前必须先关闭已打开的安能窗口,否则调试端口起不来。 - **安能重置绝不能用 `Page.reload`**:reload 会让已开业务 webContents 失去 app 引用、 变成 `Target.closeTarget` / `window.close` 都杀不掉的僵尸。重置只能走 tab 条 X 关 tab + 收菜单。 diff --git a/README.md b/README.md index e281b53..4dbcde3 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,10 @@ python main_router.py 程序会: -1. 用 Playwright 打开 4 个网页站点(韵达若配了凭据会尝试自动登录,其余手动登录); +1. 用 Playwright 打开各网页站点(**顺心为双账号**:同一窗口开两个标签页,分别登录两个 + 不同归属地账号;韵达若配了凭据会尝试自动登录,其余手动登录); 2. 以调试模式启动安能 Electron 应用(动态空闲端口),**需在应用内手动登录**; -3. **轮询各站点登录就绪状态**——全部登录完成后自动进入主菜单; +3. **轮询各站点登录就绪状态**——全部登录完成后自动进入主菜单(顺心需两个标签页都进主页); 4. 弹出主菜单,按编号选择任务。 主菜单: @@ -140,6 +141,10 @@ python main_router.py 站点的 `xxx_reset` 回到初始态 → 重试,**最多 3 次(含首次)**;每次失败都重置 (含最终放弃那次),确保环境不残留脏状态。 +> **顺心是双账号特例**:同一窗口开两个标签页(两个归属地账号),`shunxin_download` +> 接收 page 列表,内部读各账号归属地 → 去重校验 → 顺序下载 → `pd.concat` 融合成 +> 统一的 `顺心-{应到/实到}货物数据.xlsx`,比对层无感(仍读同名文件)。 + > 安能是 Electron,由 CDP(远程调试端口)驱动而非 Playwright page。其重置**不用 > `Page.reload`**——reload 会让已开的业务 webContents 失去引用、变成无法清理的僵尸, > 故改用"走 tab 条 X 关 tab + 收起菜单"。 diff --git a/main_router.py b/main_router.py index 7dbd250..9e54cd1 100644 --- a/main_router.py +++ b/main_router.py @@ -125,18 +125,38 @@ def run_automation_test(pages_map): }, } - # 构建测试计划:[(站点, 流程中文名, 流程函数), ...] + # 构建测试计划:[(站点, 流程中文名, 流程函数, 绑定page, 归属标签), ...] + # 顺心为双账号:两个 page 各自读归属地后跑一遍交叉序列;其余站点单 page。 plan = [] for site_name, flows in flow_table.items(): + if site_name == "顺心": + if "顺心" not in pages_map: + 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: + label, func = flows[flow_key] + 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)) + plan.append((site_name, label, func, bound_page, "")) # 百世:单流程,跑一次即可 if "百世" in pages_map: plan.append( - ("百世", "应到未到", site_baishi.baishi_download_undelivered_data_impl) + ( + "百世", + "应到未到", + site_baishi.baishi_download_undelivered_data_impl, + pages_map["百世"], + "", + ) ) if not plan: @@ -150,7 +170,7 @@ def run_automation_test(pages_map): print("====================================================") 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(f"[步骤 {idx}/{total}] 站点【{site_name}】流程【{label}】") print("----------------------------------------------------") @@ -162,9 +182,9 @@ def run_automation_test(pages_map): # 安能:Electron 应用,无 Playwright page,函数不收 page 参数 ret = func() else: - page = pages_map[site_name] - page.bring_to_front() - ret = func(page) + bound_page.bring_to_front() + # 顺心 _impl 带 out_tag(归属地);其余站点 _impl 仅收 page + ret = func(bound_page, out_tag=out_tag) if out_tag else func(bound_page) if ret is False: status = "FAIL" err = "流程返回失败状态" @@ -252,10 +272,21 @@ def run_multi_site_daemon(): print("====================================================") for site_name, url in active_sites.items(): - print(f">> 正在启动【{site_name}】页面: {url}") - page = context.new_page() - page.goto(url) - pages_map[site_name] = page + 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}") + page = context.new_page() + page.goto(url) + pages_map[site_name] = page # 安能:以调试模式启动 Electron 应用(非 Playwright 网页) anneng_proc = None @@ -296,6 +327,12 @@ def run_multi_site_daemon(): if site_name == "安能": # 安能走 CDP 判断主页是否就绪(连不上返回 False,不抛异常) 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: # 0.5 秒轻量探测,避免阻塞主循环 ok = ( @@ -321,20 +358,20 @@ def run_multi_site_daemon(): print("【准备】所有站点已就绪,正在清理初始弹窗...") print("====================================================") - # 顺心:处理初始弹窗 + # 顺心:处理初始弹窗(双账号两个 page 各处理一遍) if "顺心" in pages_map: - try: - sx_page = pages_map["顺心"] - sx_page.bring_to_front() - print(">> 正在处理【顺心】弹窗与遮罩...") - sx_page.locator("a").nth(4).click(timeout=2000) - sx_page.wait_for_timeout(500) - sx_page.get_by_role("button", name="Close").click(timeout=2000) - sx_page.wait_for_timeout(500) - sx_page.get_by_role("button", name="不再询问").click(timeout=2000) - print(" ✅ 【顺心】初始弹窗处理完成。") - except Exception: - pass # 环境可能很干净没有弹窗,无视报错 + for acct, sx_page in enumerate(pages_map["顺心"], start=1): + try: + sx_page.bring_to_front() + print(f">> 正在处理【顺心】账号{acct}弹窗与遮罩...") + sx_page.locator("a").nth(4).click(timeout=2000) + sx_page.wait_for_timeout(500) + sx_page.get_by_role("button", name="Close").click(timeout=2000) + sx_page.wait_for_timeout(500) + sx_page.get_by_role("button", name="不再询问").click(timeout=2000) + print(f" ✅ 【顺心】账号{acct}初始弹窗处理完成。") + except Exception: + pass # 环境可能很干净没有弹窗,无视报错 # 百世:循环关闭初始弹窗 if "百世" in pages_map: @@ -423,13 +460,9 @@ def run_multi_site_daemon(): try: if choice == "1" and is_site_ready("顺心"): - page = pages_map["顺心"] - page.bring_to_front() - site_shunxin.shunxin_expected_download(page) + site_shunxin.shunxin_expected_download(pages_map["顺心"]) elif choice == "2" and is_site_ready("顺心"): - page = pages_map["顺心"] - page.bring_to_front() - site_shunxin.shunxin_actual_download(page) + site_shunxin.shunxin_actual_download(pages_map["顺心"]) elif choice == "3" and is_site_ready("百世"): page = pages_map["百世"] page.bring_to_front() diff --git a/site_shunxin.py b/site_shunxin.py index e33d009..e93dfc5 100644 --- a/site_shunxin.py +++ b/site_shunxin.py @@ -71,19 +71,109 @@ def _close_tab(page, tab_name): 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( - "顺心", - "应到", - lambda: shunxin_expected_download_impl(page), - lambda: shunxin_reset(page), + +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}。") + 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_impl(page): - """顺心:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" +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), + ) + + shunxin_merge_final("应到", tags) + + +def shunxin_expected_download_impl(page, out_tag=""): + """顺心:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。 + + out_tag 为归属地标签时,合并产物命名为「顺心-{out_tag}-应到货物数据.xlsx」, + 作为双账号融合前的各账号中间文件;为空时退化为「顺心-应到货物数据.xlsx」。 + """ print("\n▶ 开始执行【顺心 - 应到货物数据下载】任务...") # 初始化并创建下载目录 @@ -344,7 +434,10 @@ def shunxin_expected_download_impl(page): if all_data_frames: 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) print(f"====================================================") print(f" 合并完成,输出文件: {final_output_path}") @@ -364,19 +457,42 @@ def shunxin_expected_download_impl(page): return False -def shunxin_actual_download(page): - """顺心:实到货物数据下载(内部含异常兜底重试,路由层无感)。""" +def shunxin_actual_download(pages): + """顺心:实到货物数据下载(双账号/双归属地,内部含异常兜底重试与数据融合)。 - return with_retry( - "顺心", - "实到", - lambda: shunxin_actual_download_impl(page), - lambda: shunxin_reset(page), - ) + 与 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): + 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), + ) + + shunxin_merge_final("实到", tags) -def shunxin_actual_download_impl(page): - """顺心:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。""" +def shunxin_actual_download_impl(page, out_tag=""): + """顺心:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。 + + out_tag 为归属地标签时,合并产物命名为「顺心-{out_tag}-实到货物数据.xlsx」, + 作为双账号融合前的各账号中间文件;为空时退化为「顺心-实到货物数据.xlsx」。 + """ print("\n▶ 开始执行【顺心 - 实到货物数据下载】任务...") download_dir = DOWNLOAD_DIR @@ -608,7 +724,10 @@ def shunxin_actual_download_impl(page): if all_data_frames: 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) print(f"====================================================") print(f" 合并完成,输出文件: {final_output_path}")