From 3c32720985cfa7a34cfc4667d8abc157cb37e4ef Mon Sep 17 00:00:00 2001 From: Misaka Date: Sun, 2 Aug 2026 11:18:21 +0800 Subject: [PATCH] feat(sites): auto-screenshot on final download failure for debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 所有站点 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 --- .gitignore | 1 + inbound_verify/paths.py | 3 +++ inbound_verify/runtime.py | 18 ++++++++++++++++- inbound_verify/sites/anneng.py | 34 ++++++++++++++++++++++++++++++++- inbound_verify/sites/baishi.py | 11 ++++++++++- inbound_verify/sites/shunxin.py | 12 +++++++++++- inbound_verify/sites/yunda.py | 12 +++++++++++- inbound_verify/sites/zto.py | 28 ++++++++++++++------------- 8 files changed, 101 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 723f4ae..8321f54 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ desktop.ini downloads/ output/ state/ +logs/ *.xlsx *.xls *.log diff --git a/inbound_verify/paths.py b/inbound_verify/paths.py index 22083f4..a115340 100644 --- a/inbound_verify/paths.py +++ b/inbound_verify/paths.py @@ -18,3 +18,6 @@ CONFIG_PATH = os.path.join(BASE_DIR, "config.yaml") # 状态存储(SQLite,阶段0:心跳 / 登录态 / 数据态持久化,重启不丢) STATE_DB_PATH = os.path.join(BASE_DIR, "state", "state.db") + +# 错误截图目录(下载流程失败时自动截取,供问题排查) +SCREENSHOT_DIR = os.path.join(BASE_DIR, "logs", "screenshots") diff --git a/inbound_verify/runtime.py b/inbound_verify/runtime.py index 191f32b..a99582c 100644 --- a/inbound_verify/runtime.py +++ b/inbound_verify/runtime.py @@ -21,7 +21,7 @@ from datetime import date, datetime, timedelta import yaml from playwright.sync_api import sync_playwright -from inbound_verify.paths import CONFIG_PATH +from inbound_verify.paths import CONFIG_PATH, SCREENSHOT_DIR from inbound_verify import state_store from inbound_verify.sites import shunxin, baishi, zto, yunda, anneng @@ -630,6 +630,22 @@ def _apply_ready(site, flags, dates=None): print(f">> [状态] 置就绪态失败 {site}/{k}: {e}") +def capture_error_screenshot(page, site, kind, attempt, error): + """流程失败时截取当前页面,保存到 logs/screenshots/。 + page: Playwright Page 对象(安能传 None 走 CDP 分支,调用方自行处理)。 + 截图失败绝不外抛——只打告警,不干扰任务重试/清场流程。""" + try: + os.makedirs(SCREENSHOT_DIR, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + err_short = (error or "unknown")[:40].replace("/", "_").replace("\\", "_") + fname = f"{site}_{kind}_{ts}_attempt{attempt}_{err_short}.png" + path = os.path.join(SCREENSHOT_DIR, fname) + page.screenshot(path=path, full_page=False) + print(f"📸 【{site}-{kind}】错误截图已保存: {path}") + except Exception as se: + print(f"📸 【{site}-{kind}】截图失败(不影响任务): {se}") + + def _refresh_ready(site): """入库后立即据 PG 派生并写入该站就绪态(省 30s 心跳等待,与心跳同源)。""" flags, dates = _ready_flags(site) diff --git a/inbound_verify/sites/anneng.py b/inbound_verify/sites/anneng.py index 54c0338..284707b 100644 --- a/inbound_verify/sites/anneng.py +++ b/inbound_verify/sites/anneng.py @@ -43,10 +43,37 @@ 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): +def _capture_error_screenshot(site, kind, attempt, error): + """安能 CDP 错误截图(best-effort;失败仅告警,绝不外抛)。""" + try: + import base64, os + from datetime import datetime + from inbound_verify.paths import SCREENSHOT_DIR + + os.makedirs(SCREENSHOT_DIR, exist_ok=True) + pages = list_pages() + if not pages: + return + cdp = CDP(pages[0]["webSocketDebuggerUrl"]) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + err_short = (error or "unknown")[:40].replace("/", "_").replace("\\", "_") + fname = f"{site}_{kind}_{ts}_attempt{attempt}_{err_short}.png" + path = os.path.join(SCREENSHOT_DIR, fname) + result = cdp.call("Page.captureScreenshot", format="png") + with open(path, "wb") as f: + f.write(base64.b64decode(result["data"])) + cdp.close() + print(f"📸 【{site}-{kind}】错误截图已保存: {path}") + except Exception as se: + print(f"📸 【{site}-{kind}】截图失败(不影响任务): {se}") + + +def with_retry(site_name, label, flow, reset, max_attempts=3, page=None): """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + 最后一次失败时(重置前)自动截图保存到 logs/screenshots/,供问题排查。 + (安能通过 CDP 截图,page 参数忽略;保留为统一签名兼容。) flow 为零参可调用;返回 False 视为失败,其余视为成功。 返回 True=最终成功,False=重试耗尽放弃(供调度层判断任务成败)。 """ @@ -60,6 +87,11 @@ def with_retry(site_name, label, flow, reset, max_attempts=3): return True except Exception as e: print(f"⚠️ 【{site_name}-{label}】第 {attempt}/{max_attempts} 次失败: {e}") + if attempt == max_attempts: + try: + _capture_error_screenshot(site_name, label, attempt, str(e)) + except Exception: + pass print(f" → 重置【{site_name}】到初始态,清理环境 ...") try: reset() diff --git a/inbound_verify/sites/baishi.py b/inbound_verify/sites/baishi.py index cff9885..ef15f25 100644 --- a/inbound_verify/sites/baishi.py +++ b/inbound_verify/sites/baishi.py @@ -8,10 +8,11 @@ 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): +def with_retry(site_name, label, flow, reset, max_attempts=3, page=None): """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + 最后一次失败时(重置前)自动截图保存到 logs/screenshots/,供问题排查。 flow 为零参可调用;返回 False 视为失败,其余视为成功。 返回 True=最终成功,False=重试耗尽放弃(供调度层判断任务成败)。 """ @@ -25,6 +26,13 @@ def with_retry(site_name, label, flow, reset, max_attempts=3): 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() @@ -147,6 +155,7 @@ def baishi_download_undelivered_data(page, force=False, date=None): "应到未到", lambda: baishi_download_undelivered_data_impl(page), lambda: baishi_reset(page), + page=page, ) diff --git a/inbound_verify/sites/shunxin.py b/inbound_verify/sites/shunxin.py index 7d45489..4950d74 100644 --- a/inbound_verify/sites/shunxin.py +++ b/inbound_verify/sites/shunxin.py @@ -11,10 +11,11 @@ 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): +def with_retry(site_name, label, flow, reset, max_attempts=3, page=None): """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + 最后一次失败时(重置前)自动截图保存到 logs/screenshots/,供问题排查。 flow 为零参可调用;返回 False 视为失败,其余视为成功。 返回 True=最终成功,False=重试耗尽放弃(供调度层判断任务成败)。 """ @@ -28,6 +29,13 @@ def with_retry(site_name, label, flow, reset, max_attempts=3): 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() @@ -237,6 +245,7 @@ def shunxin_expected_download(pages, foreground=True, force=False, date=None): p, out_tag=t, force=f, date=d ), lambda p=pg: shunxin_reset(p), + page=pg, ) if not ok: return False # 某账号重试耗尽 → 整体失败,不融合(避免部分数据) @@ -602,6 +611,7 @@ def shunxin_actual_download(pages, foreground=True, force=False, date=None): p, out_tag=t, date=d ), lambda p=pg: shunxin_reset(p), + page=pg, ) if not ok: return False diff --git a/inbound_verify/sites/yunda.py b/inbound_verify/sites/yunda.py index 73c18b5..1e912d0 100644 --- a/inbound_verify/sites/yunda.py +++ b/inbound_verify/sites/yunda.py @@ -11,10 +11,11 @@ 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): +def with_retry(site_name, label, flow, reset, max_attempts=3, page=None): """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + 最后一次失败时(重置前)自动截图保存到 logs/screenshots/,供问题排查。 flow 为零参可调用;返回 False 视为失败,其余视为成功。 返回 True=最终成功,False=重试耗尽放弃(供调度层判断任务成败)。 """ @@ -28,6 +29,13 @@ def with_retry(site_name, label, flow, reset, max_attempts=3): 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() @@ -235,6 +243,7 @@ def yunda_expected_download(page, force=False, date=None): "应到", lambda: yunda_expected_download_impl(page, force=force, date=date), lambda: yunda_reset(page), + page=page, ) @@ -492,6 +501,7 @@ def yunda_actual_download(page, force=False, date=None): "实到", lambda: yunda_actual_download_impl(page, date=date), lambda: yunda_reset(page), + page=page, ) diff --git a/inbound_verify/sites/zto.py b/inbound_verify/sites/zto.py index b2104da..08af723 100644 --- a/inbound_verify/sites/zto.py +++ b/inbound_verify/sites/zto.py @@ -11,10 +11,11 @@ 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): +def with_retry(site_name, label, flow, reset, max_attempts=3, page=None): """异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。 每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。 + 最后一次失败时(重置前)自动截图保存到 logs/screenshots/,供问题排查。 flow 为零参可调用;返回 False 视为失败,其余视为成功。 返回 True=最终成功,False=重试耗尽放弃(供调度层判断任务成败)。 """ @@ -28,6 +29,13 @@ def with_retry(site_name, label, flow, reset, max_attempts=3): 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() @@ -163,6 +171,7 @@ def zto_expected_download(page, force=False, date=None): "应到", lambda: zto_expected_download_impl(page, force=force, date=date), lambda: zto_reset(page), + page=page, ) @@ -209,14 +218,10 @@ def zto_expected_download_impl(page, force=False, date=None): if target_cell is None: print(" ℹ️ 目标日期不在当前视窗,正在翻月导航 ...") if not _zto_flip_to_target_month(ewb_frame, page, target_time): - raise RuntimeError( - f"翻月后仍无法定位目标日期格子(time={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})" - ) + raise RuntimeError(f"翻月后仍无法定位目标日期格子(time={target_time})") # 日期格子用 _dom_click 直接派发事件:.click() 会先 hover 格子,触发 # "范围长度"提示气泡(.date-range-length-tip)盖住格子导致点击被遮挡超时。 @@ -413,6 +418,7 @@ def zto_actual_download(page, force=False, date=None): "实到", lambda: zto_actual_download_impl(page, date=date), lambda: zto_reset(page), + page=page, ) @@ -458,14 +464,10 @@ def zto_actual_download_impl(page, date=None): if target_cell is None: print(" ℹ️ 目标日期不在当前视窗,正在翻月导航 ...") if not _zto_flip_to_target_month(arr_frame, page, target_time): - raise RuntimeError( - f"翻月后仍无法定位目标日期格子(time={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})" - ) + raise RuntimeError(f"翻月后仍无法定位目标日期格子(time={target_time})") # 日期格子用 _dom_click 直接派发事件:Playwright 的 .click() 会先 hover 格子, # 触发"范围长度"提示气泡(.date-range-length-tip)盖住格子,导致点击被判遮挡而超时。