diff --git a/AGENTS.md b/AGENTS.md index 43df081..7270b6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ ## 工作规则 - 正式代码统一放入 `app/` 包;迁移完成后复用 `app/cdp.py`,遵守架构第七节的选择器、就绪判断、上传/拖拽方式,不另起一套 CDP 交互。 +- ①「导入采集」点击采集后可以为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;只检测登录态,不自动登录、不填密码、不绕过验证码。③「更新蝦皮」仍保持高风险边界:更新前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。 - 只做当前任务范围内的事;T-504 已接入的多账号并行、dry-run、运行日志属于当前范围,其他 V2 / V3 功能只记录不实现。 - CDP 选择器 / 流程 / 配置 schema 变化,必须同步更新 `docs/04-architecture.md` 与相关任务。 - **新任务一任务一文件写进 `docs/tasks/T-<编号>.md`,不再手改 `docs/06-tasks.md`**(已冻结为 T-000~T-549 历史归档);命名/frontmatter/领取规则见 `docs/tasks/README.md`。为避免多 agent 并发抢改同一文件、ID 撞号。 diff --git a/app/gui/tabs/collect.py b/app/gui/tabs/collect.py index 30c6182..d74a80b 100644 --- a/app/gui/tabs/collect.py +++ b/app/gui/tabs/collect.py @@ -200,8 +200,8 @@ class CollectTab(QWidget): def _show_account_guide(self, message): full_message = ( f"{message}\n\n" - "本轮采集已中止,不会自动打开账号 Chrome。\n" - "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录蝦皮。" + "本轮采集已中止。\n" + "请先到「④ 账号管理」检查账号配置、Chrome 路径和登录状态。" ) QMessageBox.warning(self, "账号未就绪", full_message) self._set_status(full_message.replace("\n", " ")) @@ -647,6 +647,7 @@ class CollectTab(QWidget): skipped=payload.get("skipped", 0), failed=payload.get("failed", 0), ) + self._show_collect_account_summary(payload, message) if payload.get("collected", 0) > 0: batch_id = self._active_batch_id() if batch_id and self._start_write_back(batch_id, auto=True): @@ -674,6 +675,12 @@ class CollectTab(QWidget): "以下账号 Chrome 未启动或调试端口不可访问:" + "、".join(self._account_label(item) for item in not_running) ) + launch_failed = payload.get("launch_failed") or [] + if launch_failed: + lines.append( + "以下账号 Chrome 启动失败:" + + "、".join(self._account_label(item) for item in launch_failed) + ) logged_out = payload.get("logged_out") or [] if logged_out: lines.append( @@ -682,6 +689,30 @@ class CollectTab(QWidget): ) self._show_account_guide("\n".join(lines)) + def _show_collect_account_summary(self, payload, message): + launched = payload.get("launched_accounts") or [] + reused = payload.get("reused_accounts") or [] + login_required = payload.get("login_required_accounts") or [] + if not launched and not reused and not login_required: + return + lines = [message] + if launched: + lines.append( + "本轮已自动启动账号 Chrome:" + + "、".join(self._account_label(item) for item in launched) + ) + if login_required: + lines.append( + "以下账号需要补登录:" + + "、".join(self._account_label(item) for item in login_required) + ) + lines.append("采集结束后不会自动关闭账号 Chrome,请按需自行关闭。") + text = "\n".join(lines) + if login_required: + QMessageBox.warning(self, "采集完成", text) + else: + QMessageBox.information(self, "采集完成", text) + def _account_label(self, item): if isinstance(item, dict): name = item.get("account_name") or item.get("alias") or "" diff --git a/app/gui/workers.py b/app/gui/workers.py index 4b64892..2102841 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -1218,6 +1218,9 @@ class CollectWorker(BaseWorker): skipped = 0 failed = 0 done = 0 + login_skip_reasons = {} + login_required_accounts = {} + preflight_info = {} self._run_id = self._create_run_log(eligible, batch_ids) self._log_run_event( @@ -1225,7 +1228,7 @@ class CollectWorker(BaseWorker): ) if self.preflight: - blocked = self._preflight_block(eligible, account_rows, account_by_alias) + blocked, preflight_info = self._preflight_prepare(eligible, account_rows, account_by_alias) if blocked: self._log_preflight_blocked(blocked) summary = self._summary( @@ -1241,7 +1244,13 @@ class CollectWorker(BaseWorker): ) self._finish_run_log("blocked", summary) return summary - self._log_run_event("step=preflight result=success detail=账号检查通过") + for item in preflight_info.get("logged_out") or []: + alias = str(item.get("alias") or "").strip() + reason = item.get("reason") or "账号未登录" + if alias: + login_skip_reasons[alias] = reason + login_required_accounts[alias] = item + self._log_run_event("step=preflight result=success detail=账号就绪检查完成") else: self._log_run_event( "step=preflight result=skipped detail=测试模式跳过采集前检查", @@ -1270,15 +1279,37 @@ class CollectWorker(BaseWorker): self._emit_progress(done, total, collected, skipped, failed) continue - status = self._login_status(account) - if not status.get("logged_in"): + alias = str(task.alias).strip() + if alias in login_skip_reasons: skipped += 1 done += 1 - reason = self._login_skip_reason(status) + reason = login_skip_reasons[alias] db.mark_skipped(task.id, reason, path=self.db_path) self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) self._log_run_event( - "step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( + "step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( + task_id=task.id, + item_id=task.item_id, + reason=reason, + ), + task=task, + level="warning", + ) + self._emit_progress(done, total, collected, skipped, failed) + continue + + status = self._login_status(account) + if not status.get("logged_in"): + alias = str(task.alias).strip() + skipped += 1 + done += 1 + reason = self._midrun_login_skip_reason(status) + login_skip_reasons[alias] = reason + login_required_accounts[alias] = self._account_payload(account, reason) + db.mark_skipped(task.id, reason, path=self.db_path) + self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) + self._log_run_event( + "step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( task_id=task.id, item_id=task.item_id, reason=reason, @@ -1394,16 +1425,23 @@ class CollectWorker(BaseWorker): skipped=skipped, failed=failed, batch_ids=batch_ids, + extra={ + **preflight_info, + "login_required_accounts": list(login_required_accounts.values()), + }, ) self._finish_run_log("cancelled" if self.should_cancel() else "done", summary) return summary - def _preflight_block(self, eligible, account_rows, account_by_alias): + def _preflight_prepare(self, eligible, account_rows, account_by_alias): if not account_rows: - return { - "reason": "NO_ACCOUNTS", - "no_accounts": True, - } + return ( + { + "reason": "NO_ACCOUNTS", + "no_accounts": True, + }, + {}, + ) required_accounts = [] seen_aliases = set() for task in eligible: @@ -1412,24 +1450,40 @@ class CollectWorker(BaseWorker): if account is not None and alias not in seen_aliases: required_accounts.append(account) seen_aliases.add(alias) - not_running = [] + launch_failed = [] logged_out = [] + launched = [] + reused = [] for account in required_accounts: self._log_run_event( - f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", + f"step=ensure_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", level="info", ) - if not chrome.is_running(account.debug_port): + if chrome.is_running(account.debug_port): self._log_run_event( - f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}", - level="warning", + f"step=ensure_chrome result=reused detail=账号 {account.alias} Chrome 已打开,复用现有窗口 debug_port={account.debug_port}", + level="info", ) - not_running.append(self._account_payload(account, "CDP 端口未响应")) - continue - self._log_run_event( - f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}", - level="info", - ) + reused.append(self._account_payload(account, "已复用")) + else: + try: + result = accounts.launch_for_login(account, path=self.db_path, config=self.config) + except Exception as exc: + reason = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + self._log_run_event( + f"step=ensure_chrome result=blocked detail=账号 {account.alias} Chrome 启动失败: {reason}", + level="error", + ) + launch_failed.append(self._account_payload(account, f"Chrome 启动失败: {reason}")) + continue + action = "launched" if result.get("launched") else "reused" + detail = "已启动" if action == "launched" else "已复用" + self._log_run_event( + f"step=ensure_chrome result={action} detail=账号 {account.alias} {detail} debug_port={account.debug_port}", + level="info", + ) + target = launched if result.get("launched") else reused + target.append(self._account_payload(account, detail)) self._log_run_event( f"step=login_check result=start detail=账号 {account.alias}", level="info", @@ -1449,13 +1503,20 @@ class CollectWorker(BaseWorker): f"step=login_check result=success detail=账号 {account.alias}", level="info", ) - if not_running or logged_out: - return { - "reason": "ACCOUNT_NOT_READY", - "not_running": not_running, - "logged_out": logged_out, - } - return None + info = { + "launched_accounts": launched, + "reused_accounts": reused, + "logged_out": logged_out, + } + if launch_failed: + return ( + { + "reason": "CHROME_LAUNCH_FAILED", + "launch_failed": launch_failed, + }, + info, + ) + return None, info def _account_payload(self, account, reason=None): payload = { @@ -1491,6 +1552,10 @@ class CollectWorker(BaseWorker): reason = status.get("reason") return f"账号未登录: {reason}" if reason else "账号未登录" + def _midrun_login_skip_reason(self, status): + reason = self._login_skip_reason(status) + return f"采集中途掉登录: {reason}" + def _old_cover_path(self, account, task): image_root = appconfig.image_dir(self.config) return image_paths.task_image_path(image_root, task, account, "old") diff --git a/docs/04-architecture.md b/docs/04-architecture.md index c5432ee..dba3006 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -59,7 +59,7 @@ imported → collected → generated → applied ## 三、职责划分 -**GUI(5 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用或执行前拦截,并提示去 ④;③ 不静默自动打开账号 Chrome。** +**GUI(5 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**① 采集点击后会为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后只检测登录态,未登录账号的任务跳过并汇总提示去 ④人工登录。③ 更新蝦皮仍是线上提交高风险链路:执行前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。** **核心模块** @@ -442,7 +442,7 @@ data/images///__new. # AI 生成的新 ### 6.4 登录检测 - 无 Shopee tab 时打开卖家中心根地址 `https:///`(默认 `https://seller.shopee.tw/`),重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。 -- ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才启动 Chrome。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。①/③ 的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。 +- ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才启动 Chrome。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。① 采集的预检会复用同一幂等启动能力,自动确保本轮匹配账号 Chrome 就绪但不自动登录;③ 更新的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。 ## 七、CDP 已验证事实(务必遵守) diff --git a/docs/current-state.md b/docs/current-state.md index a4aa665..51bf54b 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -32,9 +32,9 @@ - 存储:`data/config.json`(应用设置和 AI backend 内部字段,普通默认 cmhub)+ `data/config/ai_models.json`(direct AI 模型清单与本地明文 Key)+ `data/config/cmhub.json`(cmhub 本地明文 Key)+ SQLite `data/cmshopee.db`(账号/任务/各阶段结果,密码本地明文仅参考)+ openpyxl(Excel)+ 本地 `data/images/`(旧/新封面);密码/API Key 保存或变更时提示,展示和日志/导出必须脱敏。 - 多账号隔离:每账号独立 user-data-dir(非 profile)。 - 账号↔任务绑定:以 Excel“别名”列为权威;未匹配略过,结束弹窗汇总。 -- 执行:默认多账号串行、单条失败继续;③ 提供「检查本轮更新」按钮,检查只写运行日志和汇总,不打开 Shopee、不提交线上、不改任务状态;⑤ 可开启多账号并行(不同账号并行、同账号内串行)并设置每批最大更新条数。③ 点击「开始更新」后先按真实更新安全设置弹窗确认当前筛选范围、任务数量、每批大小和预计批次;确认后做本轮账号 Chrome/CDP/登录态预检,未启动或未登录则弹窗列出账号并中止、不自动打开 Chrome;账号就绪后按批逐条点「更新」提交线上,点击停止后不再开始下一条或下一批。 +- 执行:默认多账号串行、单条失败继续;① 点击「采集旧标题/旧封面」后会为本轮匹配账号自动确保 Chrome 就绪(已开复用、未开启动),再检测登录态;未登录账号任务整组略过并汇总提示,采集结束不关闭账号 Chrome。③ 提供「检查本轮更新」按钮,检查只写运行日志和汇总,不打开 Shopee、不提交线上、不改任务状态;⑤ 可开启多账号并行(不同账号并行、同账号内串行)并设置每批最大更新条数。③ 点击「开始更新」后先按真实更新安全设置弹窗确认当前筛选范围、任务数量、每批大小和预计批次;确认后做本轮账号 Chrome/CDP/登录态预检,未启动或未登录则弹窗列出账号并中止、不自动打开 Chrome;账号就绪后按批逐条点「更新」提交线上,点击停止后不再开始下一条或下一批。 - AI:普通产品默认 `backend=cmhub`,由 `data/config.json` 的 Base URL/别名和 `data/config/cmhub.json` Key 配置,Base URL 会规整为网关根;`app/ai.py` 仍支持 `backend=direct` 作为内部兼容/手工回滚路径,direct 由 `data/config/ai_models.json` 配置服务商/模型/Key。生成返回值保持不变,计费 metadata 通过事件传出。生成内容直接用于更新,本地留档+回写 Excel 供追溯。 -- 登录:人工登录 + 程序检测,不自动登录;④「启动登录」会打开或复用对应账号浏览器供人工登录,④「检测登录」和①/③预检只验证登录态;无 Shopee tab 时检测入口为 `https:///`(默认 `https://seller.shopee.tw/`);页面跳到 `accounts.shopee.tw/seller/login` 时已明确判为未登录;首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 应禁用或执行前预检提示,并引导去④,③ 不静默启动缺失账号 Chrome。 +- 登录:人工登录 + 程序检测,不自动登录;④「启动登录」会打开或复用对应账号浏览器供人工登录,④「检测登录」只验证登录态;无 Shopee tab 时检测入口为 `https:///`(默认 `https://seller.shopee.tw/`);页面跳到 `accounts.shopee.tw/seller/login` 时已明确判为未登录;①采集预检会自动确保匹配账号 Chrome 就绪但不自动登录,未登录账号略过并引导去④;③更新预检只检测 Chrome/CDP/登录态,缺失或未登录时中止,不静默启动缺失账号 Chrome。 ## 当前目录要点 @@ -92,9 +92,9 @@ ## 当前已知限制 - T-505 已完成:①采集、②AI生成、Excel 导入/回写、③更新蝦皮、④Chrome 启动/登录检测、⑤AI模型测试连接均已接入业务可读 `run_logs/run_log_events`;异常路径写本地 `data/logs/cmshopee.log` 脱敏 traceback,业务日志不记录 Cookie、密码、API Key、token。 -- T-105b 已完成:④账号管理「启动登录」改为幂等入口。同一账号再次点击时会先检查 `debug_port` 是否已有 CDP 响应;已运行则复用现有账号 Chrome 并打开/激活卖家中心登录 tab,状态栏提示“该账号 Chrome 已打开,已复用现有窗口”,不会再次 `subprocess.Popen()`;未运行才新启动 Chrome 并等待 CDP 就绪。不自动登录、不填密码、不改变①/③预检不自动启动 Chrome 的规则。 +- T-105b 已完成:④账号管理「启动登录」改为幂等入口。同一账号再次点击时会先检查 `debug_port` 是否已有 CDP 响应;已运行则复用现有账号 Chrome 并打开/激活卖家中心登录 tab,状态栏提示“该账号 Chrome 已打开,已复用现有窗口”,不会再次 `subprocess.Popen()`;未运行才新启动 Chrome 并等待 CDP 就绪。不自动登录、不填密码。T-560 后①采集复用该幂等启动能力自动确保匹配账号 Chrome 就绪,③更新预检仍不自动启动 Chrome。 -- ① 采集和③真实更新都依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205/T-402 已在执行前拦截未配置账号、Chrome 未启动、CDP 端口不可访问、未登录,并引导去④账号管理。③ 点击「开始更新」后若本轮需要账号未就绪,必须中止本轮更新,不自动打开账号 Chrome、不提交任何商品。 +- ① 采集和③真实更新都依赖对应账号 Chrome 专属 user-data-dir、CDP 端口和登录态。T-560 后①采集会在点击采集时自动确保本轮匹配账号 Chrome 就绪:已开复用、未开启动;开 Chrome 失败才中止,预检未登录或中途掉登录的账号任务整组略过并汇总提示补登录。③ 点击「开始更新」后若本轮需要账号未就绪,必须中止本轮更新,不自动打开账号 Chrome、不提交任何商品。 - T-501/T-501b/T-501c 已完成 `data/config/ai_models.json` 模型清单 UI,以及 `data/config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 蝦皮更新安全开关。 - T-506 已完成:③ 已将用户可见 `dry-run` 改为「检查本轮更新」按钮;真实更新对当前筛选结果按每批最大更新条数自动分批,确认弹窗显示任务数和预计批次,停止为当前商品安全结束后不再开始新任务;⑤ 设置页已改为居中内容区,左右留白已缩短为 T-506 初始实现约 40%,模型详情/角色与生成参数/路径与端口/蝦皮更新安全使用三列布局,长字段跨列;「多账号并行更新」与「最大并行账号数」已合并为同一个横向组件,最大并行账号数紧跟其后且不换行。 - T-507 已完成:普通正式更新移除 `test_item_id` 商品 ID 限制;③ 确认弹窗不再显示测试商品 ID;⑤ 普通设置页隐藏测试商品 ID,只作为历史/调试兼容字段保留;仍保留允许真实提交、允许更新封面、每批最大条数、二次确认、账号就绪预检、多账号并行上限、运行日志和 Excel 回写。 diff --git a/docs/routes.md b/docs/routes.md index 782833d..321b249 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -42,9 +42,9 @@ ## 首次使用引导保护 - ① 导入采集 与 ③ 更新蝦皮 都依赖**账号已配置且已登录**(在 ④ 账号管理)。 -- 当无账号 / 对应账号 Chrome 未启动 / 账号未登录时:相关执行按钮**禁用或在执行前汇总拦截**,并提示「请先到『账号管理』配置账号并登录」。 +- ① 点击「采集旧标题/旧封面」后,会为本轮匹配到账号的店铺自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后逐账号检测蝦皮登录态,未登录账号的任务整组略过并在结束汇总中提示去④人工登录。 - ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。 -- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;不要无提示批量启动所有账号 Chrome,避免开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时也必须先检查该账号 CDP 端口,已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen` 新窗口。 +- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;③ 不要无提示批量启动所有账号 Chrome,避免在提交线上前开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时也必须先检查该账号 CDP 端口,已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen` 新窗口。 - 老用户账号已就绪则无感。 ## ① 导入采集 @@ -65,11 +65,11 @@ - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 -- 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到 `data/images///__old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。 +- 采集:点击后先为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;未登录账号的任务整组略过并汇总提示。登录账号用对应 Chrome 只读打开商品页,读旧标题、下载旧封面到 `data/images///__old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 - 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 -- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `data/logs/`,用于定位失败卡在哪个步骤。 +- 别名未匹配账号 → 该行 skipped 并记原因,不为未匹配别名启动 Chrome。账号预检未登录或采集中途掉登录 → 该账号剩余任务整组 skipped 并记原因,结束汇总列出需补登录的账号。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `data/logs/`,用于定位失败卡在哪个步骤。 - 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚蝦皮。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。 ## ② AI生成 @@ -187,7 +187,7 @@ ③ 更新蝦皮:对已生成任务点击开始更新 → 安全开关检查 → 弹窗确认 → 账号就绪预检(未启动/未登录则中止) → 换标题/允许时换封面 → 点「更新」提交 → 回写结果 → 弹窗汇总 ``` -- 未配账号 / Chrome 未启动 / 未登录:① ③ 的执行按钮禁用或执行前提示去 ④;③ 不静默启动缺失账号 Chrome。 +- 未配账号 / 未登录:① 会自动确保匹配账号 Chrome 就绪但不自动登录,未登录账号略过并提示去④;③ 更新前仍只检测账号 Chrome/CDP/登录态,未启动或未登录则中止,不静默启动缺失账号 Chrome。 - 已生成的任务即可进 ③;③ 通过 ⑤ 安全开关并经用户确认批量弹窗后提交线上,无常驻提交开关。 - 任意步骤失败:记入该任务、日志标明,不影响其他任务。 diff --git a/docs/tasks/T-560.md b/docs/tasks/T-560.md index 83cdccf..aefa0d7 100644 --- a/docs/tasks/T-560.md +++ b/docs/tasks/T-560.md @@ -3,7 +3,7 @@ id: T-560 title: ① 采集前批量确保账号 Chrome 就绪 + 登录预检(自动开、不自动登录) phase: 2 deps: [T-203, T-205, T-105b, T-103b, T-523] -status: TODO +status: DONE created: 2026-07-08 --- @@ -51,4 +51,9 @@ created: 2026-07-08 ## 执行记录 -(做完在此记录:改了哪些文件、跑的验证命令与结果、决策) +- 2026-07-08:同步 `AGENTS.md`、`docs/04-architecture.md`、`docs/routes.md`、`docs/current-state.md`,明确 T-560 后 ①采集会自动确保匹配账号 Chrome 就绪,③更新仍保持只检测不自动启动的高风险边界。 +- 2026-07-08:`CollectWorker` 采集预检从“Chrome 未启动即 blocked”改为“先幂等确保 Chrome 就绪”:已运行账号复用,未运行账号调用 `accounts.launch_for_login()` 启动;启动失败才 blocked 并弹窗中止。 +- 2026-07-08:预检未登录账号不再 blocked,改为把该账号任务整组 `skipped` 并记原因;采集过程中若二次登录检测发现掉登录,则把该账号当前和剩余任务整组 `skipped` 为“采集中途掉登录”,不逐条重复失败。 +- 2026-07-08:①采集完成后新增账号汇总弹窗:列出自动启动账号、需要补登录账号,并提示采集结束不会自动关闭账号 Chrome;未匹配别名仍逐行略过且不启动 Chrome。 +- 2026-07-08:更新 GUI 单测,覆盖 Chrome 未运行时自动启动、启动失败阻断、预检未登录账号级略过、中途掉登录账号级略过、结束汇总弹窗;③更新侧不自动启动 Chrome 的测试保持不变。 +- 验证通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 2ca0bf1..2d741a0 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -5053,7 +5053,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_collect_worker_preflight_blocks_when_chrome_not_running(self): + def test_collect_worker_preflight_launches_when_chrome_not_running(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) @@ -5075,20 +5075,71 @@ class GuiTests(TempDirMixin, unittest.TestCase): tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) with mock.patch("app.gui.chrome.is_running", return_value=False) as is_running, \ + mock.patch( + "app.gui.accounts.launch_for_login", + return_value={"ok": True, "launched": True, "reused": False}, + ) as launch_for_login, \ + mock.patch( + "app.gui.accounts.detect_login", + return_value={"logged_in": True, "reason": None}, + ) as detect_login, \ + mock.patch( + "app.gui.editor.collect", + return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"}, + ) as collect: + summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() + + self.assertTrue(summary["ok"]) + self.assertEqual(1, summary["collected"]) + self.assertEqual("alias-a", summary["launched_accounts"][0]["alias"]) + is_running.assert_called_once_with(9222) + launch_for_login.assert_called_once() + self.assertEqual(2, detect_login.call_count) + collect.assert_called_once() + + self.assert_removed(temp_dir) + + def test_collect_worker_preflight_blocks_when_chrome_launch_fails(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"]) + + with mock.patch("app.gui.chrome.is_running", return_value=False), \ + mock.patch( + "app.gui.accounts.launch_for_login", + side_effect=RuntimeError("Chrome 路径错误"), + ) as launch_for_login, \ mock.patch("app.gui.accounts.detect_login") as detect_login, \ mock.patch("app.gui.editor.collect") as collect: summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() self.assertTrue(summary["blocked"]) - self.assertEqual("ACCOUNT_NOT_READY", summary["reason"]) - self.assertEqual("alias-a", summary["not_running"][0]["alias"]) - is_running.assert_called_once_with(9222) + self.assertEqual("CHROME_LAUNCH_FAILED", summary["reason"]) + self.assertEqual("alias-a", summary["launch_failed"][0]["alias"]) + self.assertIn("Chrome 启动失败", summary["launch_failed"][0]["reason"]) + launch_for_login.assert_called_once() detect_login.assert_not_called() collect.assert_not_called() self.assert_removed(temp_dir) - def test_collect_worker_preflight_blocks_when_account_not_logged_in(self): + def test_collect_worker_preflight_skips_logged_out_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) @@ -5116,14 +5167,96 @@ class GuiTests(TempDirMixin, unittest.TestCase): ), mock.patch("app.gui.editor.collect") as collect: summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() - self.assertTrue(summary["blocked"]) + self.assertFalse(summary.get("blocked")) + self.assertTrue(summary["ok"]) + self.assertEqual(1, summary["skipped"]) self.assertEqual("alias-a", summary["logged_out"][0]["alias"]) self.assertIn("账号未登录", summary["logged_out"][0]["reason"]) + self.assertEqual("alias-a", summary["login_required_accounts"][0]["alias"]) collect.assert_not_called() blocked_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("imported", blocked_task.stage) - self.assertEqual("pending", blocked_task.status) - self.assertIsNone(blocked_task.last_error) + self.assertEqual("skipped", blocked_task.status) + self.assertIn("账号未登录", blocked_task.last_error) + + self.assert_removed(temp_dir) + + def test_collect_worker_skips_remaining_tasks_when_login_drops_midrun(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) + accounts.create_account("副店", "alias-b", debug_port=9223, 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", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 4, + "account_name": "Excel副店", + "alias": "alias-b", + "item_id": "51100639512", + }, + ], + path=cfg["db_path"], + ) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + login_calls = [] + + def fake_login(account, path=None, config=None): + login_calls.append(account.alias) + if login_calls == ["alias-a"]: + return {"logged_in": True, "reason": None} + if login_calls == ["alias-a", "alias-b"]: + return {"logged_in": True, "reason": None} + if account.alias == "alias-a": + return {"logged_in": False, "reason": "LOGIN_PAGE"} + return {"logged_in": True, "reason": None} + + def fake_collect(account, task, on_step=None): + self.assertEqual("alias-b", account.alias) + return {"old_title": "副店旧标题", "old_cover_path": "old-b.jpg"} + + with mock.patch("app.gui.chrome.is_running", return_value=True), \ + mock.patch("app.gui.accounts.detect_login", side_effect=fake_login) as detect_login, \ + mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect: + summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() + + self.assertTrue(summary["ok"]) + self.assertEqual(3, summary["done"]) + self.assertEqual(1, summary["collected"]) + self.assertEqual(2, summary["skipped"]) + self.assertEqual("alias-a", summary["login_required_accounts"][0]["alias"]) + self.assertIn("采集中途掉登录", summary["login_required_accounts"][0]["reason"]) + self.assertEqual(["alias-a", "alias-b", "alias-a", "alias-b"], login_calls) + self.assertEqual(4, detect_login.call_count) + collect.assert_called_once() + + updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + by_item = {task.item_id: task for task in updated} + self.assertEqual("skipped", by_item["51100639510"].status) + self.assertEqual("skipped", by_item["51100639511"].status) + self.assertIn("采集中途掉登录", by_item["51100639510"].last_error) + self.assertIn("采集中途掉登录", by_item["51100639511"].last_error) + self.assertEqual("collected", by_item["51100639512"].stage) + self.assertEqual("success", by_item["51100639512"].status) self.assert_removed(temp_dir) @@ -5155,7 +5288,6 @@ class GuiTests(TempDirMixin, unittest.TestCase): message = warning.call_args[0][2] self.assertIn("Chrome 未启动", message) self.assertIn("本轮采集已中止", message) - self.assertIn("不会自动打开账号 Chrome", message) self.assertIn("④ 账号管理", message) self.assertEqual([True], opened) self.assertIn("本轮采集已中止", statuses[-1]) @@ -5163,6 +5295,44 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_collect_tab_completion_shows_login_summary_and_keep_chrome_hint(self): + with self.make_temp_dir() as temp_dir: + tab = CollectTab( + config=self.make_config(temp_dir), + status_callback=lambda _message: None, + ) + self.addCleanup(tab.close) + payload = { + "collected": 0, + "skipped": 2, + "failed": 0, + "login_required_accounts": [ + { + "account_name": "主店", + "alias": "alias-a", + "reason": "采集中途掉登录: 账号未登录: LOGIN_PAGE", + } + ], + "launched_accounts": [ + { + "account_name": "副店", + "alias": "alias-b", + "reason": "已启动", + } + ], + } + + with mock.patch("app.gui.QMessageBox.warning") as warning: + tab._on_collect_finished(payload) + + message = warning.call_args[0][2] + self.assertIn("以下账号需要补登录", message) + self.assertIn("主店(alias-a)", message) + self.assertIn("已自动启动账号 Chrome", message) + self.assertIn("不会自动关闭账号 Chrome", message) + + self.assert_removed(temp_dir) + def test_write_back_worker_calls_excel_write_back(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "db.sqlite")