fix: tolerate transient collect login checks

This commit is contained in:
chengma
2026-07-08 18:24:07 +08:00
parent 2ff7ef3678
commit 45600c137b
5 changed files with 321 additions and 11 deletions
+80 -6
View File
@@ -1185,6 +1185,9 @@ class ApplyWorker(BaseWorker):
class CollectWorker(BaseWorker): class CollectWorker(BaseWorker):
"""Collect old title and cover for imported tasks.""" """Collect old title and cover for imported tasks."""
LOGIN_CHECK_ATTEMPTS = 3
LOGIN_CHECK_RETRY_DELAY_SECONDS = 2.0
def __init__( def __init__(
self, self,
tasks, tasks,
@@ -1298,8 +1301,8 @@ class CollectWorker(BaseWorker):
self._emit_progress(done, total, collected, skipped, failed) self._emit_progress(done, total, collected, skipped, failed)
continue continue
status = self._login_status(account) status = self._confirmed_login_status(account, context="midrun", task=task)
if not status.get("logged_in"): if self._is_definitive_logged_out(status):
alias = str(task.alias).strip() alias = str(task.alias).strip()
skipped += 1 skipped += 1
done += 1 done += 1
@@ -1319,6 +1322,16 @@ class CollectWorker(BaseWorker):
) )
self._emit_progress(done, total, collected, skipped, failed) self._emit_progress(done, total, collected, skipped, failed)
continue continue
if not status.get("logged_in"):
self._log_run_event(
"step=login_check result=uncertain detail=任务 {task_id} 商品 {item_id} 登录状态检测暂时不稳定,继续尝试采集当前商品: {detail}".format(
task_id=task.id,
item_id=task.item_id,
detail=self._login_status_detail(status),
),
task=task,
level="warning",
)
started = time.monotonic() started = time.monotonic()
current_step = "db_write" current_step = "db_write"
@@ -1488,8 +1501,13 @@ class CollectWorker(BaseWorker):
f"step=login_check result=start detail=账号 {account.alias}", f"step=login_check result=start detail=账号 {account.alias}",
level="info", level="info",
) )
status = self._login_status(account) status = self._confirmed_login_status(account, context="preflight")
if not status.get("logged_in"): if status.get("logged_in"):
self._log_run_event(
f"step=login_check result=success detail=账号 {account.alias}",
level="info",
)
elif self._is_definitive_logged_out(status):
reason = self._login_skip_reason(status) reason = self._login_skip_reason(status)
self._log_run_event( self._log_run_event(
f"step=login_check result=blocked detail=账号 {account.alias} {reason}", f"step=login_check result=blocked detail=账号 {account.alias} {reason}",
@@ -1500,8 +1518,11 @@ class CollectWorker(BaseWorker):
) )
else: else:
self._log_run_event( self._log_run_event(
f"step=login_check result=success detail=账号 {account.alias}", "step=login_check result=uncertain detail=账号 {alias} 登录状态检测暂时不稳定,继续进入采集流程: {detail}".format(
level="info", alias=account.alias,
detail=self._login_status_detail(status),
),
level="warning",
) )
info = { info = {
"launched_accounts": launched, "launched_accounts": launched,
@@ -1548,6 +1569,59 @@ class CollectWorker(BaseWorker):
"reason": f"LOGIN_CHECK_FAILED: {exc}", "reason": f"LOGIN_CHECK_FAILED: {exc}",
} }
def _confirmed_login_status(self, account, context, task=None):
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):
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", ""),
attempt=attempt,
total=self.LOGIN_CHECK_ATTEMPTS,
delay=self.LOGIN_CHECK_RETRY_DELAY_SECONDS,
detail=self._login_status_detail(status),
),
task=task,
level="warning",
)
time.sleep(self.LOGIN_CHECK_RETRY_DELAY_SECONDS)
last_status["login_check_uncertain"] = True
self._write_diagnostic_log(
"采集登录检测暂不确定",
level="WARNING",
step="login_check",
task=task,
payload={
"context": context,
"alias": getattr(account, "alias", None),
"reason": last_status.get("reason"),
"url": last_status.get("url"),
"cookie_names": list(last_status.get("cookie_names") or []),
"attempts": last_status.get("login_check_attempts"),
},
)
return last_status
def _is_definitive_logged_out(self, status):
reason = str((status or {}).get("reason") or "").strip()
url = str((status or {}).get("url") or "").lower()
return reason.startswith("LOGIN_PAGE") or (
"accounts.shopee." in url and "/seller/login" in url
)
def _login_status_detail(self, status):
status = status or {}
reason = status.get("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"
return f"原因={reason},URL={url},Cookie名称={cookie_text}"
def _login_skip_reason(self, status): def _login_skip_reason(self, status):
reason = status.get("reason") reason = status.get("reason")
return f"账号未登录: {reason}" if reason else "账号未登录" return f"账号未登录: {reason}" if reason else "账号未登录"
+2
View File
@@ -320,6 +320,7 @@ CREATE TABLE run_log_events (
2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `data/logs/cmshopee.log` 都写脱敏错误。 2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `data/logs/cmshopee.log` 都写脱敏错误。
3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新蝦皮 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」创建 `run_type=chrome_launch`,④「检测登录」创建 `run_type=login_check`;⑤ AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `data/logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。 3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新蝦皮 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」创建 `run_type=chrome_launch`,④「检测登录」创建 `run_type=login_check`;⑤ AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `data/logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。
4. **T-404b 已接入商品页失败 toast 捕获**:①采集和③更新在 `open_product`/等待详情页就绪失败时,不应只报等待超时。进入/刷新商品编辑页后应捕获 `.eds-toasts .eds-toast__content` 的文本和 `outerHTML`,记录当前 URL、时间、可见状态,并在关键元素超时时把最近错误 toast 作为用户可读失败原因写入 `last_error`、`run_log_events` 与本地诊断日志;例如商品 ID 失效时提示 `please input correct product id`。若 toast 明确属于商品失效/商品不存在/无权限类错误,底层仍写 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,只在①导入采集列表“阶段”显示“商品失效”;其他打开失败仍显示“失败”。若失败发生在 `open_product()` 内部且 `cdp` 尚未返回上层,`open_product()` 必须自行清理本轮自动新建 tab;复用用户已有 tab 只断开 CDP,不关闭页面。失败现场可保存 HTML/toast JSON 片段,但不得记录 Cookie、密码、token。 4. **T-404b 已接入商品页失败 toast 捕获**:①采集和③更新在 `open_product`/等待详情页就绪失败时,不应只报等待超时。进入/刷新商品编辑页后应捕获 `.eds-toasts .eds-toast__content` 的文本和 `outerHTML`,记录当前 URL、时间、可见状态,并在关键元素超时时把最近错误 toast 作为用户可读失败原因写入 `last_error`、`run_log_events` 与本地诊断日志;例如商品 ID 失效时提示 `please input correct product id`。若 toast 明确属于商品失效/商品不存在/无权限类错误,底层仍写 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,只在①导入采集列表“阶段”显示“商品失效”;其他打开失败仍显示“失败”。若失败发生在 `open_product()` 内部且 `cdp` 尚未返回上层,`open_product()` 必须自行清理本轮自动新建 tab;复用用户已有 tab 只断开 CDP,不关闭页面。失败现场可保存 HTML/toast JSON 片段,但不得记录 Cookie、密码、token。
5. **T-563 已接入①采集登录检测容错**:采集前预检和采集中途登录检测遇到 `NO_SESSION_COOKIE`、`LOGIN_CHECK_FAILED`、CDP 短暂读取异常时,应短暂重试并记录 reason、URL、Cookie 名称和重试次数;连续不确定仍不得把同账号后续任务批量标记为 `skipped`。只有明确检测到登录页 URL / `LOGIN_PAGE` 时,才把账号加入需补登录列表并整组略过后续任务。日志不得记录 Cookie 值。
原则:数据库日志给运营和 GUI 看“哪个商品、哪个步骤、为什么失败”;本地 log 文件给开发看完整错误栈。两者都必须脱敏。 原则:数据库日志给运营和 GUI 看“哪个商品、哪个步骤、为什么失败”;本地 log 文件给开发看完整错误栈。两者都必须脱敏。
@@ -390,6 +391,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。 - 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。
- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。 - `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。
- ①采集调用 `open_product(..., bring_to_front=False)`,不主动执行 `Page.bringToFront`;新建商品 tab 时尝试 `Target.createTarget(background=true)` 降低 Chrome 抢焦点概率,若当前 Chrome/CDP 不接受该参数则退回普通新建 tab。③更新仍使用默认 `bring_to_front=True`,保持可观察的上传、拖拽和提交行为。 - ①采集调用 `open_product(..., bring_to_front=False)`,不主动执行 `Page.bringToFront`;新建商品 tab 时尝试 `Target.createTarget(background=true)` 降低 Chrome 抢焦点概率,若当前 Chrome/CDP 不接受该参数则退回普通新建 tab。③更新仍使用默认 `bring_to_front=True`,保持可观察的上传、拖拽和提交行为。
- 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。 - 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。
- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。 - 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
+3 -3
View File
@@ -42,7 +42,7 @@
## 首次使用引导保护 ## 首次使用引导保护
- ① 导入采集 与 ③ 更新蝦皮 都依赖**账号已配置且已登录**(在 ④ 账号管理)。 - ① 导入采集 与 ③ 更新蝦皮 都依赖**账号已配置且已登录**(在 ④ 账号管理)。
- ① 点击「采集旧标题/旧封面」后,会为本轮匹配到账号的店铺自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后逐账号检测蝦皮登录态,未登录账号的任务整组略过并在结束汇总中提示去④人工登录。 - ① 点击「采集旧标题/旧封面」后,会为本轮匹配到账号的店铺自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后逐账号检测蝦皮登录态。明确进入登录页的账号任务整组略过并在结束汇总中提示去④人工登录;`NO_SESSION_COOKIE` 或 CDP 短暂读取异常只视为登录状态暂不确定,重试后仍不确定也继续尝试采集,避免误判批量略过。
- ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。 - ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。
- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;③ 不要无提示批量启动所有账号 Chrome,避免在提交线上前开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时也必须先检查该账号 CDP 端口,已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen` 新窗口。 - 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;③ 不要无提示批量启动所有账号 Chrome,避免在提交线上前开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时也必须先检查该账号 CDP 端口,已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen` 新窗口。
- 老用户账号已就绪则无感。 - 老用户账号已就绪则无感。
@@ -65,11 +65,11 @@
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
- 采集:点击后先为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;未登录账号的任务整组略过并汇总提示。登录账号用对应 Chrome 只读打开商品页,读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,写 `old_title/old_cover_path`,stage=collected。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 - 采集:点击后先为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,写 `old_title/old_cover_path`,stage=collected。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。
- 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。 - 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
- 别名未匹配账号 → 该行 skipped 并记原因,不为未匹配别名启动 Chrome。账号预检未登录或采集中途掉登录 → 该账号剩余任务整组 skipped 并记原因,结束汇总列出需补登录的账号。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `data/logs/`,用于定位失败卡在哪个步骤。 - 别名未匹配账号 → 该行 skipped 并记原因,不为未匹配别名启动 Chrome。账号预检或采集中途明确进入登录页 → 该账号剩余任务整组 skipped 并记原因,结束汇总列出需补登录的账号;`NO_SESSION_COOKIE` 等不确定检测结果只写日志和诊断信息,不触发整组 skipped。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `data/logs/`,用于定位失败卡在哪个步骤。
- 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚蝦皮。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。 - 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚蝦皮。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。
## ② AI生成 ## ② AI生成
+5 -2
View File
@@ -3,7 +3,7 @@ id: T-563
title: ①采集中途登录检测容错,避免 NO_SESSION_COOKIE 误判批量略过 title: ①采集中途登录检测容错,避免 NO_SESSION_COOKIE 误判批量略过
phase: 2 phase: 2
deps: [T-207, T-560, T-562] deps: [T-207, T-560, T-562]
status: TODO status: DONE
created: 2026-07-08 created: 2026-07-08
--- ---
@@ -25,7 +25,7 @@ created: 2026-07-08
- 采集前预检中的 `NO_SESSION_COOKIE` 应增加短暂重试,避免刚启动 Chrome 或页面尚未稳定时误判。 - 采集前预检中的 `NO_SESSION_COOKIE` 应增加短暂重试,避免刚启动 Chrome 或页面尚未稳定时误判。
2. **中途登录检测改为容错判定** 2. **中途登录检测改为容错判定**
- 在 `CollectWorker` 中封装中途登录检测确认逻辑,例如 `_midrun_login_status(account)`。 - 在 `CollectWorker` 中封装登录检测确认逻辑,例如 `_confirmed_login_status(account, context, task=None)`。
- 对 `NO_SESSION_COOKIE`、`LOGIN_CHECK_FAILED`、CDP 读取异常等不确定结果,等待短暂间隔后重试 2-3 次,并把每次 reason、URL、cookie 名称列表写入运行日志或诊断日志;不得记录 Cookie 值。 - 对 `NO_SESSION_COOKIE`、`LOGIN_CHECK_FAILED`、CDP 读取异常等不确定结果,等待短暂间隔后重试 2-3 次,并把每次 reason、URL、cookie 名称列表写入运行日志或诊断日志;不得记录 Cookie 值。
- 只有明确检测到登录页 URL / `LOGIN_PAGE` 时,才把该账号加入 `login_skip_reasons`,并跳过该账号后续任务。 - 只有明确检测到登录页 URL / `LOGIN_PAGE` 时,才把该账号加入 `login_skip_reasons`,并跳过该账号后续任务。
- 连续 `NO_SESSION_COOKIE` 仍视为“不确定”,不批量略过后续任务;继续尝试当前商品采集,让 `open_product()` 的真实页面结果决定成功或失败。 - 连续 `NO_SESSION_COOKIE` 仍视为“不确定”,不批量略过后续任务;继续尝试当前商品采集,让 `open_product()` 的真实页面结果决定成功或失败。
@@ -61,3 +61,6 @@ created: 2026-07-08
## 执行记录 ## 执行记录
- 2026-07-08:完成。`CollectWorker` 增加登录检测重试与明确/不确定判定:`LOGIN_PAGE` 或登录页 URL 才整组略过;`NO_SESSION_COOKIE`、`LOGIN_CHECK_FAILED`、CDP 短暂异常重试后仍不确定时继续采集当前商品,不缓存到同账号后续任务。已同步 `docs/04-architecture.md` 和 `docs/routes.md`。
- 2026-07-08:补充单测覆盖中途 `NO_SESSION_COOKIE` 重试成功、连续 `NO_SESSION_COOKIE` 不略过、`LOGIN_CHECK_FAILED` 不批量略过、预检 `NO_SESSION_COOKIE` 不整组略过、明确 `LOGIN_PAGE` 仍整组略过。
- 2026-07-08:验证通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。
+231
View File
@@ -5260,6 +5260,237 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_collect_worker_retries_midrun_no_session_cookie_then_collects(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks(
batch_id,
[
{
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
"source_sheet": "商品",
"source_row": 2,
"account_name": "Excel主店",
"alias": "alias-a",
"item_id": "51100639510",
},
{
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
"source_sheet": "商品",
"source_row": 3,
"account_name": "Excel主店",
"alias": "alias-a",
"item_id": "51100639511",
},
],
path=cfg["db_path"],
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
responses = [
{"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"},
{"logged_in": True, "reason": None, "url": "https://seller.shopee.tw/portal/"},
{"logged_in": True, "reason": None, "url": "https://seller.shopee.tw/portal/"},
]
collected_items = []
def fake_collect(account, task, on_step=None):
collected_items.append(task["item_id"])
return {"old_title": f"旧标题{task['item_id']}", "old_cover_path": "old.jpg"}
with mock.patch("app.gui.accounts.detect_login", side_effect=responses) as detect_login, \
mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect, \
mock.patch("app.gui.workers.time.sleep") as sleep:
summary = CollectWorker(
tasks,
db_path=cfg["db_path"],
config=cfg,
preflight=False,
).execute()
self.assertTrue(summary["ok"])
self.assertEqual(2, summary["collected"])
self.assertEqual(0, summary["skipped"])
self.assertEqual([], summary["login_required_accounts"])
self.assertEqual(["51100639510", "51100639511"], collected_items)
self.assertEqual(3, detect_login.call_count)
self.assertEqual(1, sleep.call_count)
self.assertEqual(2, collect.call_count)
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))
self.assert_removed(temp_dir)
def test_collect_worker_repeated_midrun_no_session_cookie_does_not_skip(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks(
batch_id,
[
{
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
"source_sheet": "商品",
"source_row": 2,
"account_name": "Excel主店",
"alias": "alias-a",
"item_id": "51100639510",
}
],
path=cfg["db_path"],
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
status = {
"logged_in": False,
"reason": "NO_SESSION_COOKIE",
"url": "https://seller.shopee.tw/portal/",
"cookie_names": [],
}
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"},
) as collect, \
mock.patch("app.gui.workers.time.sleep") as sleep:
summary = CollectWorker(
tasks,
db_path=cfg["db_path"],
config=cfg,
preflight=False,
).execute()
self.assertTrue(summary["ok"])
self.assertEqual(1, summary["collected"])
self.assertEqual(0, summary["skipped"])
self.assertEqual([], summary["login_required_accounts"])
self.assertEqual(3, detect_login.call_count)
self.assertEqual(2, sleep.call_count)
collect.assert_called_once()
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
self.assertEqual("collected", task.stage)
self.assertEqual("success", task.status)
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.assertNotIn("采集中途掉登录", messages)
self.assert_removed(temp_dir)
def test_collect_worker_midrun_login_check_failed_does_not_skip_remaining(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks(
batch_id,
[
{
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
"source_sheet": "商品",
"source_row": 2,
"account_name": "Excel主店",
"alias": "alias-a",
"item_id": "51100639510",
},
{
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
"source_sheet": "商品",
"source_row": 3,
"account_name": "Excel主店",
"alias": "alias-a",
"item_id": "51100639511",
},
],
path=cfg["db_path"],
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
responses = [
RuntimeError("CDP 读取失败 token=SECRET-TOKEN"),
RuntimeError("CDP 读取失败 token=SECRET-TOKEN"),
RuntimeError("CDP 读取失败 token=SECRET-TOKEN"),
{"logged_in": True, "reason": None},
]
with mock.patch("app.gui.accounts.detect_login", side_effect=responses), \
mock.patch(
"app.gui.editor.collect",
return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"},
) as collect, \
mock.patch("app.gui.workers.time.sleep"):
summary = CollectWorker(
tasks,
db_path=cfg["db_path"],
config=cfg,
preflight=False,
).execute()
self.assertTrue(summary["ok"])
self.assertEqual(2, summary["collected"])
self.assertEqual(0, summary["skipped"])
self.assertEqual([], summary["login_required_accounts"])
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("LOGIN_CHECK_FAILED", messages)
self.assertNotIn("SECRET-TOKEN", messages)
self.assertIn("token=***", messages)
self.assert_removed(temp_dir)
def test_collect_worker_preflight_no_session_cookie_does_not_skip_account(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks(
batch_id,
[
{
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
"source_sheet": "商品",
"source_row": 2,
"account_name": "Excel主店",
"alias": "alias-a",
"item_id": "51100639510",
}
],
path=cfg["db_path"],
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
responses = [
{"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"},
{"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"},
{"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"},
{"logged_in": True, "reason": None, "url": "https://seller.shopee.tw/portal/"},
]
with mock.patch("app.gui.chrome.is_running", return_value=True), \
mock.patch("app.gui.accounts.detect_login", side_effect=responses) as detect_login, \
mock.patch(
"app.gui.editor.collect",
return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"},
) as collect, \
mock.patch("app.gui.workers.time.sleep") as sleep:
summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute()
self.assertTrue(summary["ok"])
self.assertEqual(1, summary["collected"])
self.assertEqual(0, summary["skipped"])
self.assertEqual([], summary["logged_out"])
self.assertEqual([], summary["login_required_accounts"])
self.assertEqual(4, detect_login.call_count)
self.assertEqual(2, sleep.call_count)
collect.assert_called_once()
self.assert_removed(temp_dir)
def test_collect_tab_blocked_preflight_guides_to_accounts_tab(self): def test_collect_tab_blocked_preflight_guides_to_accounts_tab(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
opened = [] opened = []