From 521d363ab2e94ac14d5ac205f218abe94409d12c Mon Sep 17 00:00:00 2001 From: chengma Date: Wed, 8 Jul 2026 18:03:59 +0800 Subject: [PATCH] fix: avoid focusing chrome during collection --- app/cdp.py | 12 ++- app/editor.py | 15 ++-- docs/04-architecture.md | 2 + docs/routes.md | 2 +- docs/tasks/T-562.md | 8 +- tests/test_cdp.py | 152 +++++++++++++++++++++++++++++++++++++ tests/test_editor_login.py | 56 +++++++++++++- 7 files changed, 232 insertions(+), 15 deletions(-) create mode 100644 tests/test_cdp.py diff --git a/app/cdp.py b/app/cdp.py index ca8e43f..117c125 100644 --- a/app/cdp.py +++ b/app/cdp.py @@ -160,12 +160,20 @@ def find_product_tab(item_id, host=None): return None -def create_tab_info(url, host=None): +def create_tab_info(url, host=None, background=False): """用 browser 级 Target.createTarget 新建 tab,返回其 page target 信息。""" ver = http_get("/json/version", host=host) b = CDP(ver["webSocketDebuggerUrl"]) try: - tid = b.send("Target.createTarget", {"url": url})["targetId"] + params = {"url": url} + if background: + params["background"] = True + try: + tid = b.send("Target.createTarget", params)["targetId"] + except RuntimeError: + if not background: + raise + tid = b.send("Target.createTarget", {"url": url})["targetId"] finally: b.close() end = time.time() + 15 diff --git a/app/editor.py b/app/editor.py index d15013c..cca3d90 100644 --- a/app/editor.py +++ b/app/editor.py @@ -753,7 +753,7 @@ def _close_open_product_failure(cdp): pass -def open_product(account, item_id, on_step=None) -> CDP: +def open_product(account, item_id, on_step=None, bring_to_front=True) -> CDP: """Open or reuse a product edit tab, navigate to a clean edit URL, and wait ready.""" _notify_collect_step(on_step, "open_product") @@ -763,7 +763,7 @@ def open_product(account, item_id, on_step=None) -> CDP: tab = find_product_tab(item_id, host=host) created_by_app = tab is None if tab is None: - tab = create_tab_info(url, host=host) + tab = create_tab_info(url, host=host, background=not bring_to_front) ws = tab["webSocketDebuggerUrl"] cdp = CDP(ws) cdp.target_id = tab.get("id") @@ -772,10 +772,11 @@ def open_product(account, item_id, on_step=None) -> CDP: try: _ensure_page_domains(cdp) install_toast_observer(cdp) - try: - cdp.send("Page.bringToFront") - except Exception: - pass + if bring_to_front: + try: + cdp.send("Page.bringToFront") + except Exception: + pass cdp.send("Page.navigate", {"url": url}) install_toast_observer(cdp) _notify_collect_step(on_step, "wait_ready") @@ -826,7 +827,7 @@ def collect(account, task, on_step=None) -> dict: """Collect current title and cover snapshot before any edits.""" item_id = _item_id(task) - cdp = open_product(account, item_id, on_step=on_step) + cdp = open_product(account, item_id, on_step=on_step, bring_to_front=False) try: _notify_collect_step(on_step, "read_title") old_title = read_title(cdp) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index dba3006..1708492 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -389,6 +389,7 @@ data/images///__new. # AI 生成的新 - 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。 - `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`,保持可观察的上传、拖拽和提交行为。 - 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。 - 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images///__old.jpg`。 @@ -452,6 +453,7 @@ data/images///__new. # AI 生成的新 | 代理干扰 | 清除 `*_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 秒 | +| 采集前台激活 | ①采集只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新仍保持默认前台激活 | | 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/routes.md b/docs/routes.md index 321b249..5f06c95 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -65,7 +65,7 @@ - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 -- 采集:点击后先为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;未登录账号的任务整组略过并汇总提示。登录账号用对应 Chrome 只读打开商品页,读旧标题、下载旧封面到 `data/images///__old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 +- 采集:点击后先为本轮匹配账号确保 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」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 diff --git a/docs/tasks/T-562.md b/docs/tasks/T-562.md index 5fcc246..9a18cae 100644 --- a/docs/tasks/T-562.md +++ b/docs/tasks/T-562.md @@ -3,7 +3,7 @@ id: T-562 title: ①采集打开商品页不主动切换 Chrome 前台 phase: 2 deps: [T-203, T-205b, T-560] -status: TODO +status: DONE created: 2026-07-08 --- @@ -29,11 +29,11 @@ created: 2026-07-08 - `cdp.create_tab_info(url, host=None, background=False)` 增加 `background` 参数。 - 当 `background=True` 时,`Target.createTarget` 参数中带 `background: true`。 - `open_product(..., bring_to_front=False)` 新建商品 tab 时传 `background=True`;复用已有 tab 时不激活。 - - 如果 Chrome 版本忽略 `background`,仍至少不再执行 `Page.bringToFront`。 + - 如果 Chrome 版本忽略或拒绝 `background`,则退回普通新建 tab,仍至少不再执行 `Page.bringToFront`。 3. **测试** - editor 单测覆盖:采集调用不发送 `Page.bringToFront`,更新调用仍发送。 - - cdp 单测覆盖:`create_tab_info(background=True)` 会把 `background: true` 传给 `Target.createTarget`。 + - cdp 单测覆盖:`create_tab_info(background=True)` 会把 `background: true` 传给 `Target.createTarget`,并覆盖 `background` 被拒绝后的普通新建回退。 - 保持失败时自动关闭本轮新建 tab、复用用户已有 tab 不关闭的原语义。 ## 验收要点 @@ -50,3 +50,5 @@ created: 2026-07-08 ## 执行记录 +- 2026-07-08:完成。`editor.open_product()` 增加 `bring_to_front` 参数;①采集调用 `bring_to_front=False`,不主动 `Page.bringToFront`;③更新继续默认前台激活。`cdp.create_tab_info()` 增加 `background` 参数,后台创建失败时自动回退普通新建。已同步 `docs/04-architecture.md` 和 `docs/routes.md`。 +- 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`。 diff --git a/tests/test_cdp.py b/tests/test_cdp.py new file mode 100644 index 0000000..76bc888 --- /dev/null +++ b/tests/test_cdp.py @@ -0,0 +1,152 @@ +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(__file__)) + +from _helpers import REPO_ROOT + +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from app import cdp + + +class FakeBrowserCDP: + sent = [] + fail_first_create = False + + def __init__(self, ws_url): + self.ws_url = ws_url + self.closed = False + + def send(self, method, params=None): + self.sent.append((method, params or {})) + if method == "Target.createTarget": + if self.fail_first_create: + type(self).fail_first_create = False + raise RuntimeError("Target.createTarget: Invalid parameters") + return {"targetId": "target-new"} + return {} + + def close(self): + self.closed = True + + +class CdpTests(unittest.TestCase): + def test_create_tab_info_can_request_background_target(self): + FakeBrowserCDP.sent = [] + FakeBrowserCDP.fail_first_create = False + responses = [ + {"webSocketDebuggerUrl": "ws-browser"}, + [ + { + "id": "target-new", + "type": "page", + "url": "https://seller.shopee.tw/portal/product/1", + "webSocketDebuggerUrl": "ws-page", + } + ], + ] + + with mock.patch("app.cdp.http_get", side_effect=responses), \ + mock.patch("app.cdp.CDP", FakeBrowserCDP), \ + mock.patch("app.cdp.time.sleep"): + tab = cdp.create_tab_info( + "https://seller.shopee.tw/portal/product/1", + host="127.0.0.1:9222", + background=True, + ) + + self.assertEqual("ws-page", tab["webSocketDebuggerUrl"]) + self.assertEqual( + [ + ( + "Target.createTarget", + { + "url": "https://seller.shopee.tw/portal/product/1", + "background": True, + }, + ) + ], + FakeBrowserCDP.sent, + ) + + def test_create_tab_info_keeps_foreground_default_params(self): + FakeBrowserCDP.sent = [] + FakeBrowserCDP.fail_first_create = False + responses = [ + {"webSocketDebuggerUrl": "ws-browser"}, + [ + { + "id": "target-new", + "type": "page", + "url": "https://seller.shopee.tw/portal/product/1", + "webSocketDebuggerUrl": "ws-page", + } + ], + ] + + with mock.patch("app.cdp.http_get", side_effect=responses), \ + mock.patch("app.cdp.CDP", FakeBrowserCDP), \ + mock.patch("app.cdp.time.sleep"): + cdp.create_tab_info( + "https://seller.shopee.tw/portal/product/1", + host="127.0.0.1:9222", + ) + + self.assertEqual( + [ + ( + "Target.createTarget", + {"url": "https://seller.shopee.tw/portal/product/1"}, + ) + ], + FakeBrowserCDP.sent, + ) + + def test_create_tab_info_falls_back_when_background_is_rejected(self): + FakeBrowserCDP.sent = [] + FakeBrowserCDP.fail_first_create = True + responses = [ + {"webSocketDebuggerUrl": "ws-browser"}, + [ + { + "id": "target-new", + "type": "page", + "url": "https://seller.shopee.tw/portal/product/1", + "webSocketDebuggerUrl": "ws-page", + } + ], + ] + + with mock.patch("app.cdp.http_get", side_effect=responses), \ + mock.patch("app.cdp.CDP", FakeBrowserCDP), \ + mock.patch("app.cdp.time.sleep"): + cdp.create_tab_info( + "https://seller.shopee.tw/portal/product/1", + host="127.0.0.1:9222", + background=True, + ) + + self.assertEqual( + [ + ( + "Target.createTarget", + { + "url": "https://seller.shopee.tw/portal/product/1", + "background": True, + }, + ), + ( + "Target.createTarget", + {"url": "https://seller.shopee.tw/portal/product/1"}, + ), + ], + FakeBrowserCDP.sent, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index ce8f8e5..7fa08fe 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -442,6 +442,7 @@ class EditorLoginTests(unittest.TestCase): self.assertEqual("127.0.0.1:9222", cdp.cdp_host) find_product_tab.assert_called_once_with("51100639510", host="127.0.0.1:9222") create_tab_info.assert_not_called() + self.assertIn(("Page.bringToFront", {}), fake.sent) self.assertIn( ( "Page.navigate", @@ -469,7 +470,52 @@ class EditorLoginTests(unittest.TestCase): self.assertEqual("target-new", cdp.target_id) self.assertTrue(cdp.created_by_app) self.assertEqual("127.0.0.1:9223", cdp.cdp_host) - create_tab_info.assert_called_once() + create_tab_info.assert_called_once_with( + ( + "https://seller.shopee.tw/portal/product/51100639511" + "?pageEntry=product_list&ignore-html-cache=1" + ), + host="127.0.0.1:9223", + background=False, + ) + + def test_open_product_can_skip_bring_to_front_for_collection(self): + fake = FakeProductCDP("ws-new") + 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"}, + ) as create_tab_info, mock.patch( + "app.editor.CDP", + return_value=fake, + ), mock.patch("app.editor._ensure_page_domains"), mock.patch("app.editor._wait_ready"): + cdp = editor.open_product( + {"debug_port": 9223}, + "51100639511", + bring_to_front=False, + ) + + self.assertIs(fake, cdp) + self.assertNotIn(("Page.bringToFront", {}), fake.sent) + self.assertIn( + ( + "Page.navigate", + { + "url": ( + "https://seller.shopee.tw/portal/product/51100639511" + "?pageEntry=product_list&ignore-html-cache=1" + ) + }, + ), + fake.sent, + ) + create_tab_info.assert_called_once_with( + ( + "https://seller.shopee.tw/portal/product/51100639511" + "?pageEntry=product_list&ignore-html-cache=1" + ), + host="127.0.0.1:9223", + background=True, + ) def test_open_product_installs_toast_observer(self): fake = FakeProductCDP("ws-new", ready=True) @@ -560,7 +606,7 @@ class EditorLoginTests(unittest.TestCase): 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( + with mock.patch("app.editor.open_product", return_value=cdp) as open_product, mock.patch( "app.editor.read_title", return_value="旧标题", ), mock.patch( @@ -576,6 +622,12 @@ class EditorLoginTests(unittest.TestCase): ) self.assertEqual("旧标题", result["old_title"]) + open_product.assert_called_once_with( + {"debug_port": 9222}, + "51100639510", + on_step=None, + bring_to_front=False, + ) self.assertTrue(cdp.closed) close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")