diff --git a/app/editor.py b/app/editor.py index 743b764..1c478a2 100644 --- a/app/editor.py +++ b/app/editor.py @@ -1509,19 +1509,22 @@ def apply_task(account, task, close_success_tab=False, on_step=None, bring_to_fr _notify_apply_step(on_step, current_step, "failed", str(exc)) return {"committed": False, "error": str(exc)} finally: - _close_applied_product(cdp, close_success_tab=close_success_tab and committed) + _close_applied_product(cdp, committed=committed, close_success_tab=close_success_tab) -def _close_applied_product(cdp, close_success_tab=False): +def _close_applied_product(cdp, committed=False, close_success_tab=False): target_id = getattr(cdp, "target_id", None) created_by_app = bool(getattr(cdp, "created_by_app", False)) host = getattr(cdp, "cdp_host", None) + should_close_tab = bool(created_by_app and target_id and (not committed or close_success_tab)) + should_wait_before_close = bool(committed and close_success_tab) try: cdp.close() finally: - if close_success_tab and created_by_app and target_id: + if should_close_tab: try: - time.sleep(2) + if should_wait_before_close: + time.sleep(2) close_tab(target_id, host=host) except Exception: pass diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 6bea50d..13a0b94 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -157,7 +157,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 - `update_mode`:③「更新内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只更新标题、只更新封面、更新标题和封面;默认 `title`。 - `allow_cover_update`:旧兼容字段;保存配置时仍写回,值由 `update_mode` 是否包含封面推导。⑤设置页不再展示「允许更新封面」,封面是否参与本轮真实更新由③左下角「更新内容」下拉决定。 - `max_items_per_run`:每批最大更新任务数;默认 `1`,当前筛选结果超过该值时自动分批,不再按总数阻断。 -- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。只关闭 `open_product()` 本轮新建且已提交成功的 tab,失败任务和用户原本打开的 tab 保留。Shopee 确认成功并跳回商品列表页后,关闭前等待 2 秒,让列表页跳转和页面状态稳定。 +- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。成功提交时只在该开关开启且 tab 为 `open_product()` 本轮新建时关闭,并在 Shopee 确认成功跳回商品列表页后等待 2 秒再关。更新失败时不受该开关控制:若 tab 是本轮程序自动新建则直接关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面。 - `dry_run`:内部兼容字段;普通用户界面不展示该开关,③「检查本轮更新」按钮触发检查模式,只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态;默认 `false`。 - `parallel_accounts`:是否按账号并行执行③真实更新;默认 `false`,即保持串行。 - `max_parallel_accounts`:最多同时执行的账号数;同一账号内仍按任务串行,默认 `2`。 @@ -447,7 +447,7 @@ data/images///__new. # AI 生成的新 - 真实更新按每批最大条数分批执行,每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与检查都写运行日志,日志 payload 走脱敏工具。 - 分批更新停止语义为协作式停止:点击停止后设置取消标记;当前正在执行的商品跑到安全边界后写库结束,不再开始新商品,也不进入下一批。未开始任务保持原状态,后续可继续。 - T-404a 已在③提供「重置更新状态」:仅当前选中单条,保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 以便重复测试上传/提交;若 `committed=1`,必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交,并保留 committed 历史事实/运行日志。 -- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;若确认后已跳回商品列表页,关闭前等待 2 秒;进入编辑页后的失败任务和复用的用户已有 tab 不关闭;`open_product` 内部打开失败的新建 tab 要关闭。 +- 若商品页是本轮程序自动新建且更新失败,`apply_task()` 结束时关闭该商品编辑页;复用用户已有 tab 时只断开 CDP,不关闭页面。若已成功提交,则仅在 `close_success_tab=true` 时关闭本轮程序自动新建页;确认后已跳回商品列表页时,关闭前等待 2 秒。`open_product` 内部打开失败的新建 tab 仍由 `open_product` 自行关闭。 ### 6.4 登录检测 @@ -461,7 +461,7 @@ 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 不关闭;③ 仅在设置 `close_success_tab=true`、成功提交、且 tab 为本轮自动新建时关闭;确认成功跳回商品列表页时,关闭前等待 2 秒 | +| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新失败时关闭本轮自动新建的商品页、复用页不关闭;③ 成功提交时仅在设置 `close_success_tab=true` 且 tab 为本轮自动新建时关闭,确认成功跳回商品列表页时关闭前等待 2 秒 | | 前台激活 | ①采集只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交仍需要 UI 交互稳定性,但为降低抢焦点,本轮每账号只在首条任务主动前台一次;后台态封面上传/拖拽遇到疑似遮挡节流失败时,再提前台做一次非破坏性安全恢复,不完整重跑删图上传流程 | | 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 a2663f0..b8ba733 100644 --- a/docs/api.md +++ b/docs/api.md @@ -270,7 +270,7 @@ replace_cover(cdp, image_win_path, old_cover_path=None) -> dict click_update(cdp, confirm_timeout=3) -> dict # {clicked, reason, toasts, confirm?};禁用或 Shopee 二次确认未完成则记失败 apply_task(account, task, close_success_tab=False) -> dict # 对已生成任务:换标题+换封面+点页面「更新」;若出现 Shopee 确认框,只点弹窗主按钮「更新」,不点「立即優化」(调用前必须已通过安全开关和批量确认) -# close_success_tab=True 时,仅成功提交且商品页为本轮自动新开时关闭该 tab;确认后跳回商品列表页时,关闭前等待 2 秒 +# 失败时关闭本轮自动新开的商品编辑页,复用用户已有页不关闭;成功提交时仅 close_success_tab=True 且商品页为本轮自动新开才关闭,确认后跳回商品列表页时关闭前等待 2 秒 # -> {committed, error} ``` @@ -283,8 +283,8 @@ apply_task(account, task, close_success_tab=False) -> dict - `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。 - `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭。 -- ③ 更新流程默认不关闭已进入编辑页的商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,已进入编辑页后的失败任务和用户原本打开的 tab 保留现场;但 `open_product()` 内部打开失败的新建 tab 要关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;若本次会关闭该自动新开 tab,关闭前等待 2 秒。 -- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败并保留现场。 +- ③ 更新流程失败时关闭本轮自动新建的商品编辑页,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。成功提交时若 `close_success_tab=True`,只在商品页为本轮自动新开时关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;若本次成功路径会关闭该自动新开 tab,关闭前等待 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 张商品图也按替换语义先删再上传。 - T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。 @@ -454,7 +454,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 真实更新打开商品页失败时,`ApplyWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案上浮到③可见运行日志、`run_log_events` 和任务失败原因;若失败发生在 `open_product()` 内部,本轮自动新建 tab 要关闭,复用用户已有 tab 不关闭;本地 `data/logs/` 可保存失败现场 HTML/toast JSON 片段供开发排查,但必须脱敏。 - 检查和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;点击「检查本轮更新」或「开始更新」时先清空 `ApplyTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;③ 页面不自动把上一轮历史日志混入当前运行界面。 -- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;确认后跳回商品列表页时,关闭前等待 2 秒;进入编辑页后的失败和复用的用户已有 tab 不关闭。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。 +- `editor.apply_task()` 失败时关闭本轮自动新开的商品页,复用用户已有 tab 不关闭;成功提交时仅在 `close_success_tab=true` 且 tab 为本轮自动新建时关闭,确认后跳回商品列表页时关闭前等待 2 秒。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。 - 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。 - T-404a/T-508 已实现:「重置更新状态」从底部批处理按钮移到任务表右键菜单,读取当前选中单条,运行中禁用;确认后保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 供重复更新;`committed=1` 时必须提示线上已提交过且不回滚蝦皮,并保留 committed 历史事实/运行日志。 - ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。 diff --git a/docs/tasks/T-578.md b/docs/tasks/T-578.md index 7353d2e..f511643 100644 --- a/docs/tasks/T-578.md +++ b/docs/tasks/T-578.md @@ -3,7 +3,7 @@ id: T-578 title: ③更新失败时关闭本次新开的商品编辑页(app 建的 tab),只保留成功页由⑤开关决定 phase: 7 deps: [T-402, T-404b, T-501c, T-571] -status: TODO +status: DONE created: 2026-07-09 --- @@ -70,4 +70,21 @@ created: 2026-07-09 ## 执行记录 -(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。) +- 2026-07-09:已完成。 +- 文档预提交: + - 先按评审建议更新任务定义并单独提交 `7c606e8 T-578 更新任务定义`:补依赖、明确只关闭程序自动新建页、实现时传完整 `committed`、同步 `docs/api.md`、细化验收分支。 +- 代码: + - `app/editor.py`:`apply_task()` finally 改为 `_close_applied_product(cdp, committed=committed, close_success_tab=close_success_tab)`;`_close_applied_product()` 内部统一判断 tab 生命周期。 + - 更新失败(`committed=False`,含标题失败、封面失败、更新按钮/确认失败、异常分支)且 `created_by_app=True` 时关闭浏览器 target;复用用户已有 tab 时只断开 CDP。 + - 成功路径保持原语义:`close_success_tab=False` 时保留;`close_success_tab=True` 且 app 新建页时先等 2 秒再关闭。失败关闭不等待 2 秒。 + - `tests/test_editor_login.py`:补/改成功默认保留、失败关闭、复用失败不关、封面失败关闭、异常关闭等单测。 +- 文档: + - `docs/04-architecture.md`:更新 `close_success_tab`、③真实更新、CDP tab 生命周期表述。 + - `docs/api.md`:同步 `apply_task()` 和 ③更新流程接口说明,移除“失败保留现场”的旧口径。 +- 验证: + - `py -3.10 -m unittest tests.test_editor_login`:通过,45 tests。 + - `py -3.10 -m unittest tests.test_editor_login tests.test_gui`:通过,184 tests。 + - `python -m ruff check app tests main.py`:通过。 + - `py -3.10 -m compileall app main.py`:通过。 + - `py -3.10 -m unittest discover -s tests`:通过,316 tests。 + - `git diff --check`:通过,无输出。 diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index d649fc7..6b82254 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -780,12 +780,65 @@ class EditorLoginTests(unittest.TestCase): sleep.assert_called_once_with(2) close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") - def test_apply_task_keeps_product_tab_after_failed_update(self): + def test_apply_task_keeps_auto_created_product_tab_after_success_when_disabled(self): cdp = FakeProductCDP("ws-new") cdp.target_id = "target-new" cdp.created_by_app = True cdp.cdp_host = "127.0.0.1:9222" + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.change_title", + return_value={"ok": True, "reason": None}, + ), mock.patch( + "app.editor.click_update", + return_value={"clicked": True, "reason": None}, + ), mock.patch("app.editor.close_tab") as close_tab, mock.patch( + "app.editor.time.sleep" + ) as sleep: + result = editor.apply_task( + {"debug_port": 9222}, + {"item_id": "51100639510", "new_title": "新标题"}, + close_success_tab=False, + ) + + self.assertTrue(result["committed"]) + self.assertTrue(cdp.closed) + close_tab.assert_not_called() + sleep.assert_not_called() + + def test_apply_task_closes_auto_created_product_tab_after_failed_update(self): + cdp = FakeProductCDP("ws-new") + cdp.target_id = "target-new" + cdp.created_by_app = True + cdp.cdp_host = "127.0.0.1:9222" + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.change_title", + return_value={"ok": True, "reason": None}, + ), mock.patch( + "app.editor.click_update", + return_value={"clicked": False, "reason": "UPDATE_DISABLED"}, + ), mock.patch("app.editor.close_tab") as close_tab, mock.patch( + "app.editor.time.sleep" + ) as sleep: + result = editor.apply_task( + {"debug_port": 9222}, + {"item_id": "51100639510", "new_title": "新标题"}, + close_success_tab=False, + ) + + self.assertFalse(result["committed"]) + self.assertEqual("UPDATE_DISABLED", result["error"]) + self.assertTrue(cdp.closed) + close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") + sleep.assert_not_called() + + def test_apply_task_keeps_reused_product_tab_after_failed_update(self): + cdp = FakeProductCDP("ws-existing") + cdp.target_id = "target-existing" + cdp.created_by_app = False + cdp.cdp_host = "127.0.0.1:9222" + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( "app.editor.change_title", return_value={"ok": True, "reason": None}, @@ -807,6 +860,56 @@ class EditorLoginTests(unittest.TestCase): close_tab.assert_not_called() sleep.assert_not_called() + def test_apply_task_closes_auto_created_product_tab_after_cover_failure(self): + cdp = FakeProductCDP("ws-new") + cdp.target_id = "target-new" + cdp.created_by_app = True + cdp.cdp_host = "127.0.0.1:9222" + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.replace_cover", + return_value={"ok": False, "reason": "UPLOAD_TIMEOUT"}, + ), mock.patch("app.editor.click_update") as click_update, mock.patch( + "app.editor.close_tab", + return_value=True, + ) as close_tab, mock.patch("app.editor.time.sleep") as sleep: + result = editor.apply_task( + {"debug_port": 9222}, + {"item_id": "51100639510", "new_cover_path": "new.jpg"}, + close_success_tab=False, + ) + + self.assertFalse(result["committed"]) + self.assertIn("新封面上传超时", result["error"]) + click_update.assert_not_called() + self.assertTrue(cdp.closed) + close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") + sleep.assert_not_called() + + def test_apply_task_closes_auto_created_product_tab_after_exception(self): + cdp = FakeProductCDP("ws-new") + cdp.target_id = "target-new" + cdp.created_by_app = True + cdp.cdp_host = "127.0.0.1:9222" + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.change_title", + side_effect=RuntimeError("写标题失败"), + ), mock.patch("app.editor.close_tab", return_value=True) as close_tab, mock.patch( + "app.editor.time.sleep" + ) as sleep: + result = editor.apply_task( + {"debug_port": 9222}, + {"item_id": "51100639510", "new_title": "新标题"}, + close_success_tab=False, + ) + + self.assertFalse(result["committed"]) + self.assertEqual("写标题失败", result["error"]) + self.assertTrue(cdp.closed) + close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") + sleep.assert_not_called() + def test_apply_task_passes_old_cover_backup_to_replace_cover(self): cdp = FakeProductCDP("ws-new")