From f99cde8e05c9f16b0f14cb5ca6c171afbef7f8fc Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 14 Jul 2026 12:08:12 +0800 Subject: [PATCH] fix(collect): handle login target close race --- app/accounts.py | 4 + app/cdp.py | 43 +++++- app/editor.py | 275 +++++++++++++++++++++++++++++-------- app/gui/workers.py | 62 ++++++++- docs/04-architecture.md | 12 +- docs/api.md | 17 ++- docs/tasks/T-630.md | 16 ++- tests/test_accounts.py | 20 +++ tests/test_cdp.py | 54 ++++++++ tests/test_editor_login.py | 143 ++++++++++++++++++- tests/test_gui.py | 30 +++- 11 files changed, 588 insertions(+), 88 deletions(-) diff --git a/app/accounts.py b/app/accounts.py index 63b3ac0..55c5138 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -327,6 +327,10 @@ def login_status_text(status) -> str: return "未登录" if reason == "NO_SESSION_COOKIE": return "未登录" + if reason == "LOGIN_CHECK_TARGET_UNAVAILABLE" or str(reason).startswith( + "LOGIN_CHECK_FAILED" + ): + return "登录状态暂不可确认" if reason: return f"未登录({reason})" return "未登录" diff --git a/app/cdp.py b/app/cdp.py index 117c125..f34d19c 100644 --- a/app/cdp.py +++ b/app/cdp.py @@ -22,12 +22,12 @@ def _base(host=None): return f"http://{host or CDP_HOST}" -def http_get(path, host=None): +def http_get(path, host=None, timeout=10): import requests s = requests.Session() s.trust_env = False # 忽略环境代理 - return s.get(f"{_base(host)}{path}", timeout=10).json() + return s.get(f"{_base(host)}{path}", timeout=timeout).json() def close_tab(target_id, host=None): @@ -43,6 +43,45 @@ def close_tab(target_id, host=None): return response.ok +def wait_target_closed(target_id, host=None, timeout=2.0, poll_interval=0.1): + """Wait briefly for a closed target to disappear from Chrome's target list.""" + + if not target_id: + return True + timeout = max(0.0, float(timeout)) + poll_interval = max(0.01, float(poll_interval)) + deadline = time.monotonic() + timeout + while True: + remaining = max(0.0, deadline - time.monotonic()) + request_timeout = max(0.05, min(1.0, remaining)) + try: + targets = http_get("/json", host=host, timeout=request_timeout) + except Exception: + targets = None + if targets is not None and not any( + str(target.get("id") or "") == str(target_id) + for target in targets + ): + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(poll_interval, remaining)) + + +def close_tab_and_wait(target_id, host=None, timeout=2.0, poll_interval=0.1): + """Request target closure and confirm disappearance within a bounded budget.""" + + if not close_tab(target_id, host=host): + return False + return wait_target_closed( + target_id, + host=host, + timeout=timeout, + poll_interval=poll_interval, + ) + + def activate_tab(target_id, host=None): """Bring an existing Chrome target/page to the foreground.""" diff --git a/app/editor.py b/app/editor.py index 1cbda41..a083d0b 100644 --- a/app/editor.py +++ b/app/editor.py @@ -6,7 +6,15 @@ import time from urllib.parse import urlparse from . import appconfig, image_paths -from .cdp import CDP, close_tab, create_tab, create_tab_info, find_product_tab, http_get +from .cdp import ( + CDP, + close_tab, + close_tab_and_wait, + create_tab, + create_tab_info, + find_product_tab, + http_get, +) DEFAULT_REGION_HOST = "seller.shopee.tw" @@ -669,71 +677,206 @@ def _current_url(cdp): return "" -def _wait_for_login_probe(cdp, timeout=8): - end = time.time() + timeout - last_url = "" +def _wait_for_login_probe(cdp, timeout=8, initial_url=""): + deadline = time.monotonic() + max(0.0, float(timeout)) + last_url = str(initial_url or "") cookie_names = set() - while time.time() < end: - last_url = _current_url(cdp) or last_url + cookie_read_succeeded = False + probe_error = None + first_probe = True + while first_probe or time.monotonic() < deadline: + first_probe = False + try: + last_url = cdp.val("location.href") or last_url + except Exception as exc: + return { + "url": last_url, + "cookie_names": cookie_names, + "cookie_read_succeeded": cookie_read_succeeded, + "target_available": False, + "probe_error": exc.__class__.__name__, + } + if _is_login_url(last_url): + return { + "url": last_url, + "cookie_names": cookie_names, + "cookie_read_succeeded": cookie_read_succeeded, + "target_available": True, + "probe_error": probe_error, + } try: cookies = cdp.send("Network.getAllCookies").get("cookies", []) - cookie_names = { - c.get("name") - for c in cookies - if "shopee" in (c.get("domain") or "") + except Exception as exc: + return { + "url": last_url, + "cookie_names": cookie_names, + "cookie_read_succeeded": cookie_read_succeeded, + "target_available": False, + "probe_error": exc.__class__.__name__, } - except Exception: - cookie_names = set() - if _is_login_url(last_url) or "SPC_ST" in cookie_names or "SPC_U" in cookie_names: + cookie_read_succeeded = True + cookie_names = { + cookie.get("name") + for cookie in cookies + if "shopee" in (cookie.get("domain") or "") + } + if "SPC_ST" in cookie_names or "SPC_U" in cookie_names: break - time.sleep(0.5) - return last_url, cookie_names + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(0.5, remaining)) + return { + "url": last_url, + "cookie_names": cookie_names, + "cookie_read_succeeded": cookie_read_succeeded, + "target_available": cookie_read_succeeded, + "probe_error": probe_error, + } + + +def _login_target_priority(target): + url = str((target or {}).get("url") or "").lower() + if _is_login_url(url): + return 3 + if "/portal/product/" in url: + return 2 + if "/portal/" in url: + return 0 + return 1 + + +def _login_target_key(target): + target = target or {} + return str(target.get("webSocketDebuggerUrl") or target.get("id") or target.get("url") or "") + + +def _shopee_page_targets(host): + pages = [ + target + for target in http_get("/json", host=host) + if target.get("type") == "page" + and "shopee" in str(target.get("url") or "").lower() + and target.get("webSocketDebuggerUrl") + ] + return sorted(pages, key=_login_target_priority) def login_status(account, timeout=8) -> dict: """Return detailed Shopee login status for an account's CDP session.""" host = _cdp_host(account) - pages = [t for t in http_get("/json", host=host) if t.get("type") == "page"] - shopee_page = next((p for p in pages if "shopee" in (p.get("url") or "")), None) - if not shopee_page: - ws = create_tab(_seller_home_url(account), host=host) - initial_url = _seller_home_url(account) - cdp = CDP(ws) - else: - initial_url = shopee_page.get("url") or "" - if _is_login_url(initial_url): + deadline = time.monotonic() + max(0.0, float(timeout)) + attempted_targets = set() + opened_home = False + probe_attempts = 0 + last_probe = { + "url": "", + "cookie_names": set(), + "cookie_read_succeeded": False, + "target_available": False, + "probe_error": None, + } + while True: + candidates = [ + target + for target in _shopee_page_targets(host) + if _login_target_key(target) not in attempted_targets + ] + if not candidates and not opened_home: + home_url = _seller_home_url(account) + ws = create_tab(home_url, host=host) + candidates = [ + { + "type": "page", + "url": home_url, + "webSocketDebuggerUrl": ws, + } + ] + opened_home = True + if not candidates: + break + for target in candidates: + key = _login_target_key(target) + if key: + attempted_targets.add(key) + probe_attempts += 1 + initial_url = str(target.get("url") or "") + if _is_login_url(initial_url): + return { + "logged_in": False, + "reason": "LOGIN_PAGE", + "url": initial_url, + "host": host, + "cookie_names": [], + "cookie_read_succeeded": False, + "probe_error": None, + "probe_attempts": probe_attempts, + } + cdp = None + try: + cdp = CDP(target["webSocketDebuggerUrl"]) + _ensure_page_domains(cdp) + remaining = max(0.0, deadline - time.monotonic()) + probe = _wait_for_login_probe( + cdp, + timeout=remaining, + initial_url=initial_url, + ) + except Exception as exc: + probe = { + "url": initial_url, + "cookie_names": set(), + "cookie_read_succeeded": False, + "target_available": False, + "probe_error": exc.__class__.__name__, + } + finally: + if cdp is not None: + cdp.close() + last_probe = probe + url = str(probe.get("url") or initial_url) + names = set(probe.get("cookie_names") or []) + if _is_login_url(url): + return { + "logged_in": False, + "reason": "LOGIN_PAGE", + "url": url, + "host": host, + "cookie_names": sorted(name for name in names if name), + "cookie_read_succeeded": bool(probe.get("cookie_read_succeeded")), + "probe_error": probe.get("probe_error"), + "probe_attempts": probe_attempts, + } + if not probe.get("cookie_read_succeeded") or not probe.get( + "target_available" + ): + continue + logged_in = "SPC_ST" in names or "SPC_U" in names return { - "logged_in": False, - "reason": "LOGIN_PAGE", - "url": initial_url, + "logged_in": logged_in, + "reason": None if logged_in else "NO_SESSION_COOKIE", + "url": url, "host": host, - "cookie_names": [], + "cookie_names": sorted(name for name in names if name), + "cookie_read_succeeded": True, + "probe_error": probe.get("probe_error"), + "probe_attempts": probe_attempts, } - cdp = CDP(shopee_page["webSocketDebuggerUrl"]) - - try: - _ensure_page_domains(cdp) - url, names = _wait_for_login_probe(cdp, timeout=timeout) - url = url or initial_url - if _is_login_url(url): - reason = "LOGIN_PAGE" - logged_in = False - elif "SPC_ST" in names or "SPC_U" in names: - reason = None - logged_in = True - else: - reason = "NO_SESSION_COOKIE" - logged_in = False - return { - "logged_in": logged_in, - "reason": reason, - "url": url, - "host": host, - "cookie_names": sorted(name for name in names if name), - } - finally: - cdp.close() + if time.monotonic() >= deadline: + break + return { + "logged_in": False, + "reason": "LOGIN_CHECK_TARGET_UNAVAILABLE", + "url": str(last_probe.get("url") or ""), + "host": host, + "cookie_names": sorted( + name for name in (last_probe.get("cookie_names") or []) if name + ), + "cookie_read_succeeded": False, + "probe_error": last_probe.get("probe_error"), + "probe_attempts": probe_attempts, + } def is_logged_in(account) -> bool: @@ -742,7 +885,7 @@ def is_logged_in(account) -> bool: return bool(login_status(account).get("logged_in")) -def _close_open_product_failure(cdp): +def _close_open_product_failure(cdp, confirm_target_closed=False): target_id = getattr(cdp, "target_id", None) created_by_app = bool(getattr(cdp, "created_by_app", False)) host = getattr(cdp, "cdp_host", None) @@ -751,7 +894,10 @@ def _close_open_product_failure(cdp): finally: if created_by_app and target_id: try: - close_tab(target_id, host=host) + if confirm_target_closed: + close_tab_and_wait(target_id, host=host, timeout=2.0) + else: + close_tab(target_id, host=host) except Exception: pass @@ -786,7 +932,7 @@ def open_product(account, item_id, on_step=None, bring_to_front=True) -> CDP: _wait_ready(cdp) return cdp except Exception: - _close_open_product_failure(cdp) + _close_open_product_failure(cdp, confirm_target_closed=not bring_to_front) raise @@ -847,6 +993,7 @@ def collect(account, task, on_step=None) -> dict: item_id = _item_id(task) cdp = open_product(account, item_id, on_step=on_step, bring_to_front=False) + result = None try: _notify_collect_step(on_step, "read_title") old_title = read_title(cdp) @@ -862,13 +1009,15 @@ def collect(account, task, on_step=None) -> dict: ) _notify_collect_step(on_step, "download_cover") old_cover_path = download_cover(old_cover_src, out_path) - return { + result = { "old_title": old_title, "old_cover_src": old_cover_src, "old_cover_path": old_cover_path, } finally: - _close_collected_product(cdp) + close_target_confirmed = _close_collected_product(cdp) + result["close_target_confirmed"] = close_target_confirmed + return result @@ -897,20 +1046,26 @@ def _close_collected_product(cdp): target_id = getattr(cdp, "target_id", None) created_by_app = bool(getattr(cdp, "created_by_app", False)) host = getattr(cdp, "cdp_host", None) + close_target_confirmed = None try: cdp.close() finally: if created_by_app and target_id: try: - close_tab(target_id, host=host) + close_target_confirmed = close_tab_and_wait( + target_id, + host=host, + timeout=2.0, + ) except Exception: - pass + close_target_confirmed = False + return close_target_confirmed def close_readonly_product(cdp): """Close a read-only product CDP session using the collection tab cleanup rules.""" - _close_collected_product(cdp) + return _close_collected_product(cdp) def change_title(cdp, new_title) -> dict: diff --git a/app/gui/workers.py b/app/gui/workers.py index c2426de..f9c3bf7 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -1997,6 +1997,25 @@ class CollectWorker(BaseWorker): }, on_step=on_step, ) + if result.get("close_target_confirmed") is False: + self._log_run_event( + "step=close_product result=uncertain detail=任务 {task_id} 商品 {item_id} 商品页已请求关闭,但未在短时间内确认关闭;采集结果已保留,继续处理后续任务".format( + task_id=task.id, + item_id=task.item_id, + ), + task=task, + level="warning", + ) + self._write_diagnostic_log( + "采集商品页关闭确认超时", + level="WARNING", + step="close_product", + task=task, + payload={ + "alias": getattr(account, "alias", None), + "close_target_confirmed": False, + }, + ) current_step = "db_write" self._emit_activity( "task_step", @@ -2250,17 +2269,31 @@ class CollectWorker(BaseWorker): } def _confirmed_login_status(self, account, context, task=None): + started = time.monotonic() + subject = self._login_check_subject(account, task) last_status = {} for attempt in range(1, self.LOGIN_CHECK_ATTEMPTS + 1): status = dict(self._login_status(account) or {}) status["login_check_attempts"] = attempt last_status = status - if status.get("logged_in") or self._is_definitive_logged_out(status): + if status.get("logged_in"): + if attempt > 1: + self._log_run_event( + "step=login_check result=recovered detail={subject} 登录检测已恢复,第{attempt}/{total}次确认已登录 elapsed_ms={elapsed_ms}".format( + subject=subject, + attempt=attempt, + total=self.LOGIN_CHECK_ATTEMPTS, + elapsed_ms=self._elapsed_ms(started), + ), + task=task, + ) + return status + if self._is_definitive_logged_out(status): return status if attempt < self.LOGIN_CHECK_ATTEMPTS: self._log_run_event( - "step=login_check result=retry detail=账号 {alias} 第{attempt}/{total}次登录检测暂不确定,{delay:g}秒后重试: {detail}".format( - alias=getattr(account, "alias", ""), + "step=login_check result=retry detail={subject} 登录状态暂时无法读取,第{attempt}/{total}次检测后将在{delay:g}秒后重试:{detail}".format( + subject=subject, attempt=attempt, total=self.LOGIN_CHECK_ATTEMPTS, delay=self.LOGIN_CHECK_RETRY_DELAY_SECONDS, @@ -2282,11 +2315,22 @@ class CollectWorker(BaseWorker): "reason": last_status.get("reason"), "url": last_status.get("url"), "cookie_names": list(last_status.get("cookie_names") or []), + "cookie_read_succeeded": bool( + last_status.get("cookie_read_succeeded") + ), + "probe_error": last_status.get("probe_error"), + "probe_attempts": last_status.get("probe_attempts"), "attempts": last_status.get("login_check_attempts"), }, ) return last_status + def _login_check_subject(self, account, task=None): + alias = str(getattr(account, "alias", "") or "未知账号") + if task is None: + return f"账号 {alias}" + return f"任务 {task.id} 商品 {task.item_id} 账号 {alias}" + def _is_definitive_logged_out(self, status): reason = str((status or {}).get("reason") or "").strip() url = str((status or {}).get("url") or "").lower() @@ -2296,7 +2340,17 @@ class CollectWorker(BaseWorker): def _login_status_detail(self, status): status = status or {} - reason = status.get("reason") or "未知原因" + raw_reason = str(status.get("reason") or "").strip() + if raw_reason == "LOGIN_CHECK_TARGET_UNAVAILABLE": + reason = "CDP页面暂时不可用" + elif raw_reason.startswith("LOGIN_CHECK_FAILED"): + reason = "登录检测调用失败" + elif raw_reason == "NO_SESSION_COOKIE": + reason = "暂未读取到登录会话" + elif raw_reason == "LOGIN_PAGE": + reason = "检测到登录页面" + else: + reason = raw_reason or "未知原因" url = status.get("url") or "未知URL" cookie_names = [str(name) for name in (status.get("cookie_names") or []) if name] cookie_text = ",".join(sorted(cookie_names)) if cookie_names else "未读到登录Cookie" diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 37a9e29..a9073bd 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -393,10 +393,10 @@ data/images///__new. # AI 生成的新 ### 6.1 采集(① Tab,只读) - 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。 -- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。 +- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,并在最多 2 秒内轮询 `/json` 确认该 target ID 已消失;确认超时只记警告并保留采集成功结果。用户原本已经打开的 tab 不关闭、不等待。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。 - ①采集调用 `open_product(..., bring_to_front=False)`,不主动执行 `Page.bringToFront`;新建商品 tab 时尝试 `Target.createTarget(background=true)` 降低 Chrome 抢焦点概率,若当前 Chrome/CDP 不接受该参数则退回普通新建 tab。③更新是上传、拖拽和线上提交流程,每条任务均以前台方式打开或激活当前商品 tab,优先保障页面交互稳定;后台态失败安全恢复逻辑仅为兼容直接调用保留,正常③批量路径不依赖它。 -- 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。 -- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。 +- 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。Cookie API 调用失败不能伪装成空 Cookie;登录检测若连到正在关闭的旧商品 target,应在本次检测预算内重新枚举并改连其他有效 Shopee 页面。 +- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在后台只读、程序自动新建的商品 tab 内,`open_product` 要关闭并执行同样的有界 target 消失确认;复用用户已有 tab 不关闭。③前台更新打开失败仍沿用既有清理路径,不引入额外等待。 - 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images///__old.jpg`。 - 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。 @@ -452,7 +452,8 @@ data/images///__new. # AI 生成的新 ### 6.4 登录检测 -- 无 Shopee tab 时打开卖家中心根地址 `https:///`(默认 `https://seller.shopee.tw/`),重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。 +- 无 Shopee tab 时打开卖家中心根地址 `https:///`(默认 `https://seller.shopee.tw/`);不自动登录。重定向到登录页返回明确 `LOGIN_PAGE`;页面 target 可用且 Cookie API 至少成功一次、但确实缺少 `SPC_ST`/`SPC_U` 时返回 `NO_SESSION_COOKIE`;websocket/target 正在关闭、整个检测周期一次 Cookie 都未成功读取时返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,界面显示“登录状态暂不可确认”,不得误报账号退出登录。 +- 多个 Shopee page target 并存时优先稳定的卖家中心 portal 页面,再尝试商品页,显式登录页最后判定;单个 target 连接或 Cookie 读取失败后立即重枚举/改连其他未尝试 target,不等待完整 8 秒才交给外层重试。①采集的 retry/recovered 日志必须带当前任务 ID、商品 ID;关闭上一条商品页的 target 仍短暂出现在 `/json` 时,不得把上一条 URL 误记为下一条账号掉线。 - ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才启动 Chrome。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。① 采集的预检会复用同一幂等启动能力,自动确保本轮匹配账号 Chrome 就绪但不自动登录;③ 更新的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。 - T-585 后新配置的 `chrome_path` 默认留空。程序在数据目录就绪、强制升级检查通过后、创建主窗口前,仅当当前路径为空或不可启动时,按当前用户/本机 Windows App Paths(含 64/32 位视图)、标准 Chrome 目录和 `PATH` 自动寻找 `chrome.exe`;命中才持久化归一化绝对路径,并在状态栏显示“已自动定位 Chrome”。有效的自定义/便携版路径和可从 PATH 启动的 `chrome.exe` 不覆盖;不扫描整盘、不检测 Edge 或 Chromium。⑤的“自动检测”只填入输入框并标记未保存,仍由用户点“保存设置”确认写入。 @@ -463,7 +464,8 @@ data/images///__new. # AI 生成的新 | Chrome 启动参数 | 全关后带 `--remote-debugging-port= --remote-allow-origins=* --user-data-dir=`;缺 allow-origins 则 WebSocket 403 | | 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 | | WebSocket Origin | `websocket-client` `suppress_origin=True` | -| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 | +| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。①采集和⑥只读商品页关闭本轮自动新建 target 后,最多等待 2 秒确认 target 从 `/json` 消失;超时只记诊断,不覆盖成功结果,复用的用户已有 tab 不关闭。③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 | +| 登录 target 竞态 | `/json/close/` 返回成功不代表 target 已立即从 `/json` 消失。登录检测不得固定使用枚举到的第一个 Shopee page;连接或 Cookie API 因 target 销毁失败时应快速重选有效页面。只有 Cookie API 成功返回且确实缺会话 Cookie 才是 `NO_SESSION_COOKIE`;一次都未成功读取是 `LOGIN_CHECK_TARGET_UNAVAILABLE` | | 前台激活 | ①采集和⑥商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | | SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 | | 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” | diff --git a/docs/api.md b/docs/api.md index 2990c52..0710120 100644 --- a/docs/api.md +++ b/docs/api.md @@ -244,15 +244,17 @@ is_running(port, host="127.0.0.1") -> bool ```python CDP_HOST: str -http_get(path, host=None); find_product_tab(item_id, host=None); create_tab_info(url, host=None); create_tab(url, host=None) +http_get(path, host=None, timeout=10); find_product_tab(item_id, host=None); create_tab_info(url, host=None); create_tab(url, host=None) close_tab(target_id, host=None) -> bool # 关闭浏览器 target/page;区别于 CDP.close() +wait_target_closed(target_id, host=None, timeout=2.0, poll_interval=0.1) -> bool +close_tab_and_wait(target_id, host=None, timeout=2.0, poll_interval=0.1) -> bool class CDP: send/ev/val/object_id/drag/close # close 只断开 WebSocket;suppress_origin、trust_env=False ``` ## editor 模块(`app/editor.py`,已建,重构自现有脚本) ```python -login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, cookie_names} +login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, cookie_names, cookie_read_succeeded, probe_error, probe_attempts} is_logged_in(account) -> bool # login_status(...).logged_in;重定向登录页或缺 SPC_ST/SPC_U → False install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间 read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断 @@ -262,7 +264,7 @@ open_product(account, item_id) -> CDP # 连端口、导航商品页、等 read_title(cdp) -> str read_cover_src(cdp) -> str # 第一张 itembox 的 img.src download_cover(src, out_path) -> str # 下载旧封面到本地 -collect(account, task) -> dict # -> {old_title, old_cover_path} +collect(account, task) -> dict # -> {old_title, old_cover_path, close_target_confirmed} # 应用 change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等 @@ -275,15 +277,15 @@ apply_task(account, task, close_success_tab=False) -> dict # -> {committed, error} ``` -`login_status()` 不自动登录;无 Shopee tab 时打开卖家中心根地址 `https:///`(默认 `https://seller.shopee.tw/`)用于检测/人工登录。判断规则:最终 URL 是登录页 → `LOGIN_PAGE`;其中必须显式识别 `https://accounts.shopee.tw/seller/login...` 这类 Shopee accounts 登录页;缺少 `SPC_ST`/`SPC_U` → `NO_SESSION_COOKIE`;有会话 Cookie → 已登录。 +`login_status()` 不自动登录;无 Shopee tab 时打开卖家中心根地址 `https:///`(默认 `https://seller.shopee.tw/`)用于检测/人工登录。判断规则:最终 URL 是登录页 → `LOGIN_PAGE`,其中必须显式识别 `https://accounts.shopee.tw/seller/login...` 这类 Shopee accounts 登录页;Cookie API 至少成功一次且确实缺少 `SPC_ST`/`SPC_U` → `NO_SESSION_COOKIE`;有会话 Cookie → 已登录;所有候选 target 都因关闭/连接错误而一次 Cookie 都未成功读取 → `LOGIN_CHECK_TARGET_UNAVAILABLE`。多个 Shopee page 并存时优先稳定 portal 页面,候选连接或 Cookie 读取失败后在同一超时预算内快速重选,不能把 target 错误降级成空 Cookie。 采集 tab 生命周期: - `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。 - `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。 -- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听;若标题输入框、图片管理器、上传入口等关键元素等待超时,或页面明显不是商品编辑页,应读取最近 `.eds-toast__content`。如果存在错误 toast,例如 `please input correct product id`,返回/抛出的错误信息必须包含该文案,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;不得记录 Cookie、密码、token。调用方只在明确商品失效/商品不存在/无权限类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:自动新建 tab 断开 CDP 后关闭浏览器 target,复用用户已有 tab 只断开 CDP。 +- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听;若标题输入框、图片管理器、上传入口等关键元素等待超时,或页面明显不是商品编辑页,应读取最近 `.eds-toast__content`。如果存在错误 toast,例如 `please input correct product id`,返回/抛出的错误信息必须包含该文案,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;不得记录 Cookie、密码、token。调用方只在明确商品失效/商品不存在/无权限类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。 -- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭。 +- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。 - ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。 - `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。 - T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。 @@ -483,7 +485,8 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。 - 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。 - 「采集旧标题/旧封面」通过 `CollectWorker` 后台执行,只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 下载旧封面到 `image_dir///__old.jpg` 并 `db.set_collected()`;别名未匹配任务仍逐条 `mark_skipped`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。 -- 采集打开商品页时,若本轮自动新建 tab,采集完成/失败后会关闭该 tab;若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。 +- 采集打开商品页时,若本轮自动新建 tab,采集完成后会关闭该 tab,并在最多 2 秒内确认 target 已从 `/json` 消失;确认超时只写 warning/诊断,不把采集成功改成失败。若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。 +- 采集中途登录检测必须快速跳过正在销毁的旧商品 target,改连其他有效 Shopee 页面。Cookie API 调用失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,只有 Cookie API 成功返回空会话时才返回 `NO_SESSION_COOKIE`;两者都不按明确掉登录批量略过,显式 `LOGIN_PAGE` 仍按账号需登录处理。retry/recovered 运行日志包含当前任务 ID 和商品 ID,避免与上一条采集成功日志混淆。 - 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。 - 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。 diff --git a/docs/tasks/T-630.md b/docs/tasks/T-630.md index 55a832f..d355f3e 100644 --- a/docs/tasks/T-630.md +++ b/docs/tasks/T-630.md @@ -3,7 +3,7 @@ id: T-630 title: ①采集关闭商品页后的登录检测竞态修复 phase: 2 deps: [T-620] -status: TODO +status: DONE created: 2026-07-14 --- @@ -131,4 +131,16 @@ step=login_check result=retry detail=任务 71 商品 52313423890 账号 <别名 ## 执行记录 -- 待实现。 +- 2026-07-14 完成。 +- `app/cdp.py` 新增有界 `wait_target_closed()` / `close_tab_and_wait()`:关闭 target 后按 ID 轮询 `/json`,默认最多等待 2 秒,不使用固定休眠。 +- `app/editor.py` 的登录检测现在保留 Cookie API 是否成功、探测错误类别和尝试次数;URL/Cookie 探测失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,不会伪装成 `NO_SESSION_COOKIE`。多个 Shopee 页面并存时优先稳定 portal,失效 target 会在同一检测预算内快速重选。 +- ①采集及⑥共用的后台只读商品页,在成功采集或 `open_product()` 打开失败时都会确认程序新建 target 已消失;关闭确认超时仅返回 `close_target_confirmed=False` 并记录警告,不覆盖采集结果。③前台更新和提交后 2 秒观察逻辑未修改。 +- `CollectWorker` 的登录 retry/recovered 日志已带任务 ID、商品 ID 和账号;首次不确定、后续恢复时明确记录恢复。关闭确认超时写可见警告和本地诊断,任务仍按采集成功落库。账号管理把 target 暂不可用显示为“登录状态暂不可确认”。 +- 已同步 `docs/04-architecture.md` 与 `docs/api.md`,并补充 CDP、editor、账号状态和 worker 回归测试。 +- 自动验证通过: + - `py -3.10 -m unittest tests.test_editor_login`:50 项通过; + - 仅导出本任务暂存内容的干净副本运行 `py -3.10 -m unittest tests.test_gui`:182 项通过; + - 同一干净副本运行 `py -3.10 -m unittest discover -s tests`:470 项通过; + - `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 均通过。 +- 真实 CDP 验证:当前已有 1 个账号 Chrome 运行且登录检测成功;最终代码连续 3 轮“后台新建只读 target → 有界关闭 → 立即检测登录”均通过,关闭确认均为 true、登录均为 true、`probe_attempts=1`,单轮分别耗时约 0.14 秒、0.05 秒、0.03 秒,无假 `NO_SESSION_COOKIE`,且 target 均已确认消失。 +- 商品采集实测限制:按仓库规则尝试测试商品 `51100639510` 时,Shopee 返回“商品失效:please input correct product id”,无法继续完成该商品的标题/封面三轮实采;未改用真实运营商品规避测试边界。标题、封面下载和 SQLite 流程由自动回归覆盖。 diff --git a/tests/test_accounts.py b/tests/test_accounts.py index a1e1368..9bd72e2 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -192,6 +192,26 @@ class AccountsTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_login_status_text_marks_target_failure_as_temporarily_uncertain(self): + self.assertEqual( + "登录状态暂不可确认", + accounts.login_status_text( + { + "logged_in": False, + "reason": "LOGIN_CHECK_TARGET_UNAVAILABLE", + } + ), + ) + self.assertEqual( + "登录状态暂不可确认", + accounts.login_status_text( + { + "logged_in": False, + "reason": "LOGIN_CHECK_FAILED: Target closed", + } + ), + ) + def test_duplicate_alias_raises_clear_error(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) diff --git a/tests/test_cdp.py b/tests/test_cdp.py index 76bc888..71edf83 100644 --- a/tests/test_cdp.py +++ b/tests/test_cdp.py @@ -35,6 +35,60 @@ class FakeBrowserCDP: class CdpTests(unittest.TestCase): + def test_wait_target_closed_returns_when_target_disappears(self): + targets = [ + [{"id": "target-closing", "type": "page"}], + [], + ] + with mock.patch("app.cdp.http_get", side_effect=targets) as http_get, mock.patch( + "app.cdp.time.sleep" + ) as sleep: + closed = cdp.wait_target_closed( + "target-closing", + host="127.0.0.1:9222", + timeout=1.0, + poll_interval=0.1, + ) + + self.assertTrue(closed) + sleep.assert_called_once_with(0.1) + self.assertLessEqual(http_get.call_args_list[0].kwargs["timeout"], 1.0) + + def test_wait_target_closed_stops_at_timeout(self): + with mock.patch( + "app.cdp.http_get", + return_value=[{"id": "target-closing", "type": "page"}], + ) as http_get, mock.patch("app.cdp.time.sleep") as sleep: + closed = cdp.wait_target_closed( + "target-closing", + host="127.0.0.1:9222", + timeout=0, + ) + + self.assertFalse(closed) + sleep.assert_not_called() + self.assertEqual(0.05, http_get.call_args.kwargs["timeout"]) + + def test_close_tab_and_wait_only_waits_after_close_request_succeeds(self): + with mock.patch("app.cdp.close_tab", return_value=True) as close_tab, mock.patch( + "app.cdp.wait_target_closed", + return_value=True, + ) as wait_target_closed: + closed = cdp.close_tab_and_wait( + "target-closing", + host="127.0.0.1:9222", + timeout=1.5, + ) + + self.assertTrue(closed) + close_tab.assert_called_once_with("target-closing", host="127.0.0.1:9222") + wait_target_closed.assert_called_once_with( + "target-closing", + host="127.0.0.1:9222", + timeout=1.5, + poll_interval=0.1, + ) + def test_create_tab_info_can_request_background_target(self): FakeBrowserCDP.sent = [] FakeBrowserCDP.fail_first_create = False diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index 0fd6a71..0254ba5 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -382,6 +382,104 @@ class EditorLoginTests(unittest.TestCase): self.assertIn("SPC_ST", status["cookie_names"]) self.assertTrue(FakeCDP.instances[0].closed) + def test_login_status_reselects_live_target_after_closing_target_fails(self): + pages = [ + { + "id": "target-closing", + "type": "page", + "url": "https://seller.shopee.tw/portal/product/48663456321", + "webSocketDebuggerUrl": "ws-closing", + }, + { + "id": "target-live", + "type": "page", + "url": "https://seller.shopee.tw/portal/product/52313423890", + "webSocketDebuggerUrl": "ws-live", + }, + ] + + def cdp_factory(ws): + instance = FakeCDP( + ws, + url=next(page["url"] for page in pages if page["webSocketDebuggerUrl"] == ws), + cookies=[cookie("SPC_ST")] if ws == "ws-live" else [], + ) + if ws == "ws-closing": + original_send = instance.send + + def send(method, params=None): + if method == "Network.getAllCookies": + raise RuntimeError("Target closed") + return original_send(method, params) + + instance.send = send + return instance + + with mock.patch("app.editor.http_get", return_value=pages), mock.patch( + "app.editor.CDP", + side_effect=cdp_factory, + ), mock.patch("app.editor.time.sleep"): + status = editor.login_status({"debug_port": 9222}, timeout=1) + + self.assertTrue(status["logged_in"]) + self.assertIsNone(status["reason"]) + self.assertEqual(2, status["probe_attempts"]) + self.assertEqual(["ws-closing", "ws-live"], [item.ws for item in FakeCDP.instances]) + self.assertTrue(all(item.closed for item in FakeCDP.instances)) + + def test_login_status_target_failure_is_not_no_session_cookie(self): + page = { + "id": "target-closing", + "type": "page", + "url": "https://seller.shopee.tw/portal/product/48663456321", + "webSocketDebuggerUrl": "ws-closing", + } + + def cdp_factory(ws): + instance = FakeCDP(ws, url=page["url"]) + + def send(method, params=None): + if method == "Network.getAllCookies": + raise RuntimeError("Target closed") + return {} + + instance.send = send + return instance + + with mock.patch("app.editor.http_get", return_value=[page]), mock.patch( + "app.editor.CDP", + side_effect=cdp_factory, + ): + status = editor.login_status({"debug_port": 9222}, timeout=0) + + self.assertFalse(status["logged_in"]) + self.assertEqual("LOGIN_CHECK_TARGET_UNAVAILABLE", status["reason"]) + self.assertFalse(status["cookie_read_succeeded"]) + self.assertNotEqual("NO_SESSION_COOKIE", status["reason"]) + + def test_login_status_url_probe_failure_is_not_no_session_cookie(self): + page = { + "id": "target-closing", + "type": "page", + "url": "https://seller.shopee.tw/portal/product/48663456321", + "webSocketDebuggerUrl": "ws-closing", + } + instance = FakeCDP("ws-closing", url=page["url"], cookies=[]) + + def fail_url_probe(_expression): + raise RuntimeError("Target closed") + + instance.val = fail_url_probe + with mock.patch("app.editor.http_get", return_value=[page]), mock.patch( + "app.editor.CDP", + return_value=instance, + ): + status = editor.login_status({"debug_port": 9222}, timeout=0) + + self.assertFalse(status["logged_in"]) + self.assertEqual("LOGIN_CHECK_TARGET_UNAVAILABLE", status["reason"]) + self.assertFalse(status["cookie_read_succeeded"]) + def test_login_status_false_without_session_cookie(self): with mock.patch( "app.editor.http_get", @@ -604,6 +702,35 @@ class EditorLoginTests(unittest.TestCase): close_tab.assert_not_called() create_tab_info.assert_not_called() + def test_open_product_background_failure_confirms_created_target_closed(self): + fake = FakeProductCDP( + "ws-new", + ready=False, + toasts=[{"text": "please input correct product id", "visible": False}], + ) + with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch( + "app.editor.create_tab_info", + return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"}, + ), mock.patch("app.editor.CDP", return_value=fake), mock.patch( + "app.editor._ensure_page_domains" + ), mock.patch( + "app.editor.close_tab_and_wait", + return_value=True, + ) as close_tab_and_wait: + with self.assertRaises(editor.EditorError): + editor.open_product( + {"debug_port": 9223}, + "bad-item", + bring_to_front=False, + ) + + self.assertTrue(fake.closed) + close_tab_and_wait.assert_called_once_with( + "target-new", + host="127.0.0.1:9223", + timeout=2.0, + ) + def test_collect_closes_only_auto_created_product_tab(self): cdp = FakeProductCDP("ws-new") cdp.target_id = "target-new" @@ -619,7 +746,7 @@ class EditorLoginTests(unittest.TestCase): ), mock.patch( "app.editor.download_cover", return_value="images/main/51100639510_old.jpg", - ), mock.patch("app.editor.close_tab", return_value=True) as close_tab: + ), mock.patch("app.editor.close_tab_and_wait", return_value=True) as close_tab_and_wait: result = editor.collect( {"debug_port": 9222}, {"item_id": "51100639510", "old_cover_path": "old.jpg"}, @@ -633,7 +760,12 @@ class EditorLoginTests(unittest.TestCase): bring_to_front=False, ) self.assertTrue(cdp.closed) - close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") + self.assertTrue(result["close_target_confirmed"]) + close_tab_and_wait.assert_called_once_with( + "target-new", + host="127.0.0.1:9222", + timeout=2.0, + ) def test_collect_keeps_reused_product_tab_open(self): cdp = FakeProductCDP("ws-existing") @@ -650,14 +782,15 @@ class EditorLoginTests(unittest.TestCase): ), mock.patch( "app.editor.download_cover", return_value="images/main/51100639510_old.jpg", - ), mock.patch("app.editor.close_tab") as close_tab: - editor.collect( + ), mock.patch("app.editor.close_tab_and_wait") as close_tab_and_wait: + result = editor.collect( {"debug_port": 9222}, {"item_id": "51100639510", "old_cover_path": "old.jpg"}, ) self.assertTrue(cdp.closed) - close_tab.assert_not_called() + self.assertIsNone(result["close_target_confirmed"]) + close_tab_and_wait.assert_not_called() def test_read_product_image_urls_returns_all_images_in_page_order(self): cdp = FakeProductCDP("ws-existing", rects=cover_rects(3, prefix="main")) diff --git a/tests/test_gui.py b/tests/test_gui.py index e62816b..09c59fa 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -8465,6 +8465,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): db_path=cfg["db_path"], config=cfg, preflight=False, + diagnostic_log_dir=os.path.join(temp_dir, "logs"), ).execute() self.assertTrue(summary["ok"]) @@ -8480,6 +8481,19 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(1, sleep.call_count) self.assertEqual(2, collect.call_count) + events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) + messages = "\n".join(event.message for event in events) + self.assertIn( + "step=login_check result=retry detail=任务 " + f"{tasks[0].id} 商品 51100639510", + messages, + ) + self.assertIn( + "step=login_check result=recovered detail=任务 " + f"{tasks[0].id} 商品 51100639510", + messages, + ) + updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "collected" for task in updated)) self.assertTrue(all(task.status == "success" for task in updated)) @@ -8516,7 +8530,11 @@ class GuiTests(TempDirMixin, unittest.TestCase): with mock.patch("app.gui.accounts.detect_login", return_value=status) as detect_login, \ mock.patch( "app.gui.editor.collect", - return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"}, + return_value={ + "old_title": "旧标题", + "old_cover_path": "old.jpg", + "close_target_confirmed": False, + }, ) as collect, \ mock.patch("app.gui.workers.time.sleep") as sleep: summary = CollectWorker( @@ -8524,6 +8542,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): db_path=cfg["db_path"], config=cfg, preflight=False, + diagnostic_log_dir=os.path.join(temp_dir, "logs"), ).execute() self.assertTrue(summary["ok"]) @@ -8544,6 +8563,11 @@ class GuiTests(TempDirMixin, unittest.TestCase): events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("登录状态检测暂时不稳定", messages) + self.assertIn( + "step=close_product result=uncertain detail=任务 " + f"{tasks[0].id} 商品 51100639510", + messages, + ) self.assertNotIn("采集中途掉登录", messages) self.assert_removed(temp_dir) @@ -8604,9 +8628,9 @@ class GuiTests(TempDirMixin, unittest.TestCase): events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) messages = "\n".join(event.message for event in events) - self.assertIn("LOGIN_CHECK_FAILED", messages) + self.assertIn("登录检测调用失败", messages) + self.assertNotIn("LOGIN_CHECK_FAILED", messages) self.assertNotIn("SECRET-TOKEN", messages) - self.assertIn("token=***", messages) self.assert_removed(temp_dir)