fix: avoid focusing chrome during collection

This commit is contained in:
chengma
2026-07-08 18:03:59 +08:00
parent 8a8d656a58
commit 521d363ab2
7 changed files with 232 additions and 15 deletions
+10 -2
View File
@@ -160,12 +160,20 @@ def find_product_tab(item_id, host=None):
return 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 信息。""" """用 browser 级 Target.createTarget 新建 tab,返回其 page target 信息。"""
ver = http_get("/json/version", host=host) ver = http_get("/json/version", host=host)
b = CDP(ver["webSocketDebuggerUrl"]) b = CDP(ver["webSocketDebuggerUrl"])
try: 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: finally:
b.close() b.close()
end = time.time() + 15 end = time.time() + 15
+8 -7
View File
@@ -753,7 +753,7 @@ def _close_open_product_failure(cdp):
pass 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.""" """Open or reuse a product edit tab, navigate to a clean edit URL, and wait ready."""
_notify_collect_step(on_step, "open_product") _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) tab = find_product_tab(item_id, host=host)
created_by_app = tab is None created_by_app = tab is None
if 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"] ws = tab["webSocketDebuggerUrl"]
cdp = CDP(ws) cdp = CDP(ws)
cdp.target_id = tab.get("id") cdp.target_id = tab.get("id")
@@ -772,10 +772,11 @@ def open_product(account, item_id, on_step=None) -> CDP:
try: try:
_ensure_page_domains(cdp) _ensure_page_domains(cdp)
install_toast_observer(cdp) install_toast_observer(cdp)
try: if bring_to_front:
cdp.send("Page.bringToFront") try:
except Exception: cdp.send("Page.bringToFront")
pass except Exception:
pass
cdp.send("Page.navigate", {"url": url}) cdp.send("Page.navigate", {"url": url})
install_toast_observer(cdp) install_toast_observer(cdp)
_notify_collect_step(on_step, "wait_ready") _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.""" """Collect current title and cover snapshot before any edits."""
item_id = _item_id(task) 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: try:
_notify_collect_step(on_step, "read_title") _notify_collect_step(on_step, "read_title")
old_title = read_title(cdp) old_title = read_title(cdp)
+2
View File
@@ -389,6 +389,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`,保持可观察的上传、拖拽和提交行为。
- 若商品 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`。
@@ -452,6 +453,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
| 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 | | 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 |
| WebSocket Origin | `websocket-client` `suppress_origin=True` | | 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)`,不支持时退回普通新建。③更新仍保持默认前台激活 |
| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 | | 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、未登录、页面超时、风控等其他失败仍显示“失败” | | 商品页错误 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、未登录、页面超时、风控等其他失败仍显示“失败” |
+1 -1
View File
@@ -65,7 +65,7 @@
- 导入: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 就绪(已开复用、未开启动),再检测登录;未登录账号的任务整组略过并汇总提示。登录账号用对应 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」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
+5 -3
View File
@@ -3,7 +3,7 @@ id: T-562
title: ①采集打开商品页不主动切换 Chrome 前台 title: ①采集打开商品页不主动切换 Chrome 前台
phase: 2 phase: 2
deps: [T-203, T-205b, T-560] deps: [T-203, T-205b, T-560]
status: TODO status: DONE
created: 2026-07-08 created: 2026-07-08
--- ---
@@ -29,11 +29,11 @@ created: 2026-07-08
- `cdp.create_tab_info(url, host=None, background=False)` 增加 `background` 参数。 - `cdp.create_tab_info(url, host=None, background=False)` 增加 `background` 参数。
- 当 `background=True` 时,`Target.createTarget` 参数中带 `background: true`。 - 当 `background=True` 时,`Target.createTarget` 参数中带 `background: true`。
- `open_product(..., bring_to_front=False)` 新建商品 tab 时传 `background=True`;复用已有 tab 时不激活。 - `open_product(..., bring_to_front=False)` 新建商品 tab 时传 `background=True`;复用已有 tab 时不激活。
- 如果 Chrome 版本忽略 `background`,仍至少不再执行 `Page.bringToFront`。 - 如果 Chrome 版本忽略或拒绝 `background`,则退回普通新建 tab,仍至少不再执行 `Page.bringToFront`。
3. **测试** 3. **测试**
- editor 单测覆盖:采集调用不发送 `Page.bringToFront`,更新调用仍发送。 - 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 不关闭的原语义。 - 保持失败时自动关闭本轮新建 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`。
+152
View File
@@ -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()
+54 -2
View File
@@ -442,6 +442,7 @@ class EditorLoginTests(unittest.TestCase):
self.assertEqual("127.0.0.1:9222", cdp.cdp_host) self.assertEqual("127.0.0.1:9222", cdp.cdp_host)
find_product_tab.assert_called_once_with("51100639510", host="127.0.0.1:9222") find_product_tab.assert_called_once_with("51100639510", host="127.0.0.1:9222")
create_tab_info.assert_not_called() create_tab_info.assert_not_called()
self.assertIn(("Page.bringToFront", {}), fake.sent)
self.assertIn( self.assertIn(
( (
"Page.navigate", "Page.navigate",
@@ -469,7 +470,52 @@ class EditorLoginTests(unittest.TestCase):
self.assertEqual("target-new", cdp.target_id) self.assertEqual("target-new", cdp.target_id)
self.assertTrue(cdp.created_by_app) self.assertTrue(cdp.created_by_app)
self.assertEqual("127.0.0.1:9223", cdp.cdp_host) 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): def test_open_product_installs_toast_observer(self):
fake = FakeProductCDP("ws-new", ready=True) fake = FakeProductCDP("ws-new", ready=True)
@@ -560,7 +606,7 @@ class EditorLoginTests(unittest.TestCase):
cdp.created_by_app = True cdp.created_by_app = True
cdp.cdp_host = "127.0.0.1:9222" 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", "app.editor.read_title",
return_value="旧标题", return_value="旧标题",
), mock.patch( ), mock.patch(
@@ -576,6 +622,12 @@ class EditorLoginTests(unittest.TestCase):
) )
self.assertEqual("旧标题", result["old_title"]) 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) self.assertTrue(cdp.closed)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")