fix(collect): handle login target close race
Tests / Python 3.11 / Windows (push) Has been cancelled

This commit is contained in:
chengma
2026-07-14 12:08:12 +08:00
parent cbfd36af56
commit f99cde8e05
11 changed files with 588 additions and 88 deletions
+4
View File
@@ -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 "未登录"
+41 -2
View File
@@ -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."""
+215 -60
View File
@@ -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:
+58 -4
View File
@@ -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"
+7 -5
View File
@@ -393,10 +393,10 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # 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/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。
@@ -452,7 +452,8 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
### 6.4 登录检测
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`),重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `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/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
| Chrome 启动参数 | 全关后带 `--remote-debugging-port=<port> --remote-allow-origins=* --user-data-dir=<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_id>` 返回成功不代表 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、未登录、页面超时、风控等其他失败仍显示“失败” |
+10 -7
View File
@@ -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://<region_host>/`(默认 `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://<region_host>/`(默认 `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/<batch_id>/<slug>/<task_id>_<item_id>_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()`,已开始的单条跑到安全边界后结束。
+14 -2
View File
@@ -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 流程由自动回归覆盖。
+20
View File
@@ -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)
+54
View File
@@ -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
+138 -5
View File
@@ -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"))
+27 -3
View File
@@ -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)