fix(accounts): keep first login launch in one window

This commit is contained in:
chengma
2026-07-14 17:22:52 +08:00
parent a4ac3f8ee5
commit 699625ea0e
10 changed files with 608 additions and 60 deletions
+159 -22
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import time
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -12,6 +13,15 @@ from . import config as account_config
DEFAULT_REGION_HOST = editor.DEFAULT_REGION_HOST DEFAULT_REGION_HOST = editor.DEFAULT_REGION_HOST
INITIAL_LOGIN_TAB_WAIT_SECONDS = 2.0
INITIAL_LOGIN_TAB_POLL_SECONDS = 0.1
SAFE_STARTUP_PAGE_PREFIXES = (
"about:blank",
"chrome://newtab",
"chrome://new-tab-page",
"chrome://welcome",
"chrome://intro",
)
class AccountError(RuntimeError): class AccountError(RuntimeError):
@@ -222,13 +232,19 @@ def _seller_portal_url(account) -> str:
return f"https://{normalize_region_host(_account_value(account, 'region_host'))}/portal/" return f"https://{normalize_region_host(_account_value(account, 'region_host'))}/portal/"
def _find_login_tab(account, host): def _page_tabs(host):
return [
tab
for tab in cdp.http_get("/json", host=host)
if tab.get("type") == "page"
]
def _find_login_tab_in_tabs(account, tabs):
region_host = normalize_region_host(_account_value(account, 'region_host')).lower() region_host = normalize_region_host(_account_value(account, 'region_host')).lower()
seller_tabs = [] seller_tabs = []
login_tabs = [] login_tabs = []
for tab in cdp.http_get("/json", host=host): for tab in tabs:
if tab.get("type") != "page":
continue
url = str(tab.get("url") or "") url = str(tab.get("url") or "")
lower_url = url.lower() lower_url = url.lower()
if region_host in lower_url: if region_host in lower_url:
@@ -238,38 +254,157 @@ def _find_login_tab(account, host):
return (seller_tabs or login_tabs or [None])[0] return (seller_tabs or login_tabs or [None])[0]
def _open_or_activate_login_tab(account): def _find_login_tab(account, host):
host = _debug_host(account) return _find_login_tab_in_tabs(account, _page_tabs(host))
portal_url = _seller_portal_url(account)
tab = _find_login_tab(account, host)
if tab:
target_id = tab.get("id")
if target_id:
cdp.activate_tab(target_id, host=host)
return {
"target_id": target_id,
"url": tab.get("url") or portal_url,
"created_tab": False,
}
tab = cdp.create_tab_info(portal_url, host=host)
def _wait_for_initial_login_tab(account, host, timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS):
timeout = max(0.0, float(timeout))
deadline = time.monotonic() + timeout
last_tabs = []
probe_error = None
first_probe = True
while first_probe or time.monotonic() < deadline:
first_probe = False
try:
last_tabs = _page_tabs(host)
probe_error = None
except Exception as exc:
probe_error = exc.__class__.__name__
tab = _find_login_tab_in_tabs(account, last_tabs)
if tab:
return tab, last_tabs, probe_error
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(INITIAL_LOGIN_TAB_POLL_SECONDS, remaining))
return None, last_tabs, probe_error
def _safe_startup_page(tabs):
if len(tabs) != 1:
return None
tab = tabs[0]
url = str(tab.get("url") or "").strip().lower()
if not url or any(url.startswith(prefix) for prefix in SAFE_STARTUP_PAGE_PREFIXES):
return tab
return None
def _navigate_page_target(tab, url):
websocket_url = tab.get("webSocketDebuggerUrl")
if not websocket_url:
raise AccountError("Chrome 初始页面缺少 CDP 地址,无法复用")
client = cdp.CDP(websocket_url)
try:
result = client.send("Page.navigate", {"url": url})
finally:
client.close()
if result.get("errorText"):
raise AccountError("Chrome 初始页面导航到蝦皮卖家中心失败")
def _activate_login_tab_result(
tab,
host,
portal_url,
*,
created_tab=False,
startup_target_reused=False,
startup_page_navigated=False,
page_target_count=0,
fallback_reason=None,
target_probe_error=None,
):
target_id = tab.get("id") target_id = tab.get("id")
if target_id: if target_id:
cdp.activate_tab(target_id, host=host) cdp.activate_tab(target_id, host=host)
return { return {
"target_id": target_id, "target_id": target_id,
"url": tab.get("url") or portal_url, "url": tab.get("url") or portal_url,
"created_tab": True, "created_tab": bool(created_tab),
"startup_target_reused": bool(startup_target_reused),
"startup_page_navigated": bool(startup_page_navigated),
"page_target_count": int(page_target_count),
"fallback_reason": fallback_reason,
"target_probe_error": target_probe_error,
} }
def _open_or_activate_login_tab(
account,
*,
newly_launched=False,
startup_wait_timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS,
):
host = _debug_host(account)
portal_url = _seller_portal_url(account)
if newly_launched:
tab, tabs, probe_error = _wait_for_initial_login_tab(
account,
host,
timeout=startup_wait_timeout,
)
else:
tabs = _page_tabs(host)
tab = _find_login_tab_in_tabs(account, tabs)
probe_error = None
if tab:
return _activate_login_tab_result(
tab,
host,
portal_url,
startup_target_reused=newly_launched,
page_target_count=len(tabs),
target_probe_error=probe_error,
)
fallback_reason = "existing_chrome_no_login_tab"
if newly_launched:
startup_tab = _safe_startup_page(tabs)
if startup_tab:
try:
_navigate_page_target(startup_tab, portal_url)
except Exception as exc:
fallback_reason = "startup_navigation_failed"
probe_error = probe_error or exc.__class__.__name__
else:
navigated_tab = dict(startup_tab)
navigated_tab["url"] = portal_url
return _activate_login_tab_result(
navigated_tab,
host,
portal_url,
startup_target_reused=True,
startup_page_navigated=True,
page_target_count=len(tabs),
target_probe_error=probe_error,
)
elif tabs:
fallback_reason = "startup_page_not_safe"
else:
fallback_reason = "startup_page_missing"
tab = cdp.create_tab_info(portal_url, host=host)
return _activate_login_tab_result(
tab,
host,
portal_url,
created_tab=True,
page_target_count=len(tabs) + 1,
fallback_reason=fallback_reason,
target_probe_error=probe_error,
)
def launch_for_login(account_or_alias, path=None, config=None): def launch_for_login(account_or_alias, path=None, config=None):
account = resolve_account(account_or_alias, path=path, config=config) account = resolve_account(account_or_alias, path=path, config=config)
port = normalize_debug_port(_account_value(account, "debug_port")) port = normalize_debug_port(_account_value(account, "debug_port"))
alias = _account_value(account, "alias") alias = _account_value(account, "alias")
portal_url = _seller_portal_url(account)
if chrome.is_running(port): if chrome.is_running(port):
tab = _open_or_activate_login_tab(account) tab = _open_or_activate_login_tab(account, newly_launched=False)
return { return {
"ok": True, "ok": True,
"action": "reused", "action": "reused",
@@ -278,14 +413,15 @@ def launch_for_login(account_or_alias, path=None, config=None):
"alias": alias, "alias": alias,
"debug_port": port, "debug_port": port,
"pid": None, "pid": None,
"initial_url": portal_url,
**tab, **tab,
} }
process = chrome.launch_chrome(account, config=config) process = chrome.launch_chrome(account, config=config, initial_url=portal_url)
timeout = appconfig.cdp_ready_timeout(config) timeout = appconfig.cdp_ready_timeout(config)
if not chrome.wait_debug_ready(port, timeout=timeout): if not chrome.wait_debug_ready(port, timeout=timeout):
raise AccountError(f"Chrome 已启动,但 CDP 端口 {port} 未在 {timeout} 秒内就绪") raise AccountError(f"Chrome 已启动,但 CDP 端口 {port} 未在 {timeout} 秒内就绪")
tab = _open_or_activate_login_tab(account) tab = _open_or_activate_login_tab(account, newly_launched=True)
return { return {
"ok": True, "ok": True,
"action": "launched", "action": "launched",
@@ -294,6 +430,7 @@ def launch_for_login(account_or_alias, path=None, config=None):
"alias": alias, "alias": alias,
"debug_port": port, "debug_port": port,
"pid": getattr(process, "pid", None), "pid": getattr(process, "pid", None),
"initial_url": portal_url,
**tab, **tab,
} }
+30 -4
View File
@@ -10,6 +10,7 @@ import subprocess
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from urllib.parse import urlsplit
from . import appconfig from . import appconfig
from . import config as account_config from . import config as account_config
@@ -209,7 +210,27 @@ def _user_data_dir(account, config=None) -> str:
return account_config.ensure_user_data_dir(slug, config=config) return account_config.ensure_user_data_dir(slug, config=config)
def build_launch_args(account, config=None) -> list: def _validated_initial_url(value):
text = str(value or "").strip()
if not text:
return None
try:
parsed = urlsplit(text)
except ValueError as exc:
raise ChromeLaunchError("Chrome 初始页面必须是有效的蝦皮 http/https 地址") from exc
hostname = str(parsed.hostname or "").lower()
is_shopee_host = hostname.startswith("shopee.") or ".shopee." in hostname
if (
parsed.scheme.lower() not in {"http", "https"}
or not is_shopee_host
or parsed.username
or parsed.password
):
raise ChromeLaunchError("Chrome 初始页面必须是有效的蝦皮 http/https 地址")
return text
def build_launch_args(account, config=None, initial_url=None) -> list:
"""Build Chrome command arguments for one account.""" """Build Chrome command arguments for one account."""
chrome_path = appconfig.chrome_path(config) chrome_path = appconfig.chrome_path(config)
@@ -217,12 +238,17 @@ def build_launch_args(account, config=None) -> list:
raise ChromeLaunchError("chrome_path 不能为空") raise ChromeLaunchError("chrome_path 不能为空")
port = _debug_port(account) port = _debug_port(account)
user_data_dir = _user_data_dir(account, config=config) user_data_dir = _user_data_dir(account, config=config)
return [ args = [
chrome_path, chrome_path,
f"--remote-debugging-port={port}", f"--remote-debugging-port={port}",
"--remote-allow-origins=*", "--remote-allow-origins=*",
f"--user-data-dir={user_data_dir}", f"--user-data-dir={user_data_dir}",
] ]
startup_url = _validated_initial_url(initial_url)
if startup_url:
args.extend(("--no-first-run", "--no-default-browser-check"))
args.append(startup_url)
return args
def _safe_shortcut_name(value) -> str: def _safe_shortcut_name(value) -> str:
@@ -300,10 +326,10 @@ def create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, co
return shortcut_path return shortcut_path
def launch_chrome(account, config=None) -> subprocess.Popen: def launch_chrome(account, config=None, initial_url=None) -> subprocess.Popen:
"""Launch Chrome for one account and return the process handle.""" """Launch Chrome for one account and return the process handle."""
args = build_launch_args(account, config=config) args = build_launch_args(account, config=config, initial_url=initial_url)
try: try:
return subprocess.Popen(args) return subprocess.Popen(args)
except OSError as exc: except OSError as exc:
+69 -4
View File
@@ -110,6 +110,9 @@ class AccountsTab(QWidget):
self.account_rows = [] self.account_rows = []
self.login_statuses = {} self.login_statuses = {}
self.threads = [] self.threads = []
self._launching_accounts = set()
self._launch_guard_until = {}
self._launch_run_ids = {}
self.table = QTableWidget(0, len(self.COLUMNS)) self.table = QTableWidget(0, len(self.COLUMNS))
self.table.setHorizontalHeaderLabels(self.COLUMNS) self.table.setHorizontalHeaderLabels(self.COLUMNS)
@@ -173,6 +176,9 @@ class AccountsTab(QWidget):
def _update_button_state(self): def _update_button_state(self):
has_selection = self._selected_account() is not None has_selection = self._selected_account() is not None
launch_in_progress = bool(self._launching_accounts)
self.table.setEnabled(not launch_in_progress)
self.add_button.setEnabled(not launch_in_progress)
for button in ( for button in (
self.edit_button, self.edit_button,
self.delete_button, self.delete_button,
@@ -180,7 +186,33 @@ class AccountsTab(QWidget):
self.check_button, self.check_button,
self.shortcut_button, self.shortcut_button,
): ):
button.setEnabled(has_selection) button.setEnabled(has_selection and not launch_in_progress)
def _launch_guard_seconds(self):
app = QApplication.instance()
if app is None:
return 0.5
return max(0.25, app.doubleClickInterval() / 1000.0)
def _ignore_reentrant_launch(self, account):
alias = account.alias
now = time.monotonic()
if (
alias not in self._launching_accounts
and now >= self._launch_guard_until.get(alias, 0.0)
):
return False
_safe_add_run_log_event(
self._launch_run_ids.get(alias),
(
f"step=launch_chrome result=ignored detail=账号 {alias} "
"reason=launch_in_progress"
),
db_path=self.db_path,
account=account,
)
self._set_status("该账号 Chrome 正在启动,请稍候", level="info")
return True
def refresh_accounts(self): def refresh_accounts(self):
try: try:
@@ -297,12 +329,27 @@ class AccountsTab(QWidget):
account = self._selected_account() account = self._selected_account()
if account is None: if account is None:
return return
if self._ignore_reentrant_launch(account):
return
run_id = _safe_create_run_log( run_id = _safe_create_run_log(
"chrome_launch", "chrome_launch",
db_path=self.db_path, db_path=self.db_path,
total=1, total=1,
options={"alias": account.alias, "debug_port": account.debug_port}, options={"alias": account.alias, "debug_port": account.debug_port},
) )
self._launching_accounts.add(account.alias)
self._launch_run_ids[account.alias] = run_id
self._update_button_state()
try:
self._launch_login_once(account, run_id)
finally:
self._launching_accounts.discard(account.alias)
self._launch_guard_until[account.alias] = (
time.monotonic() + self._launch_guard_seconds()
)
self._update_button_state()
def _launch_login_once(self, account, run_id):
started = time.monotonic() started = time.monotonic()
_safe_add_run_log_event( _safe_add_run_log_event(
run_id, run_id,
@@ -346,13 +393,27 @@ class AccountsTab(QWidget):
action = (launch_result or {}).get("action") or "launched" action = (launch_result or {}).get("action") or "launched"
pid = (launch_result or {}).get("pid") pid = (launch_result or {}).get("pid")
target_id = (launch_result or {}).get("target_id") or "" target_id = (launch_result or {}).get("target_id") or ""
url = (launch_result or {}).get("url") or "" url = diagnostics.redact_log_text((launch_result or {}).get("url") or "")
created_tab = bool((launch_result or {}).get("created_tab"))
startup_target_reused = bool(
(launch_result or {}).get("startup_target_reused")
)
startup_page_navigated = bool(
(launch_result or {}).get("startup_page_navigated")
)
page_target_count = int((launch_result or {}).get("page_target_count") or 0)
fallback_reason = (launch_result or {}).get("fallback_reason") or ""
event_result = "reused" if action == "reused" else "launched" event_result = "reused" if action == "reused" else "launched"
_safe_add_run_log_event( _safe_add_run_log_event(
run_id, run_id,
( (
f"step=launch_chrome result={event_result} detail=账号 {account.alias} " f"step=launch_chrome result={event_result} detail=账号 {account.alias} "
f"pid={pid or ''} target_id={target_id} url={url} elapsed_ms={elapsed_ms}" f"pid={pid or ''} target_id={target_id} url={url} "
f"created_tab={int(created_tab)} "
f"startup_target_reused={int(startup_target_reused)} "
f"startup_page_navigated={int(startup_page_navigated)} "
f"page_target_count={page_target_count} "
f"fallback_reason={fallback_reason} elapsed_ms={elapsed_ms}"
), ),
db_path=self.db_path, db_path=self.db_path,
account=account, account=account,
@@ -372,6 +433,11 @@ class AccountsTab(QWidget):
"pid": pid, "pid": pid,
"target_id": target_id, "target_id": target_id,
"url": url, "url": url,
"created_tab": created_tab,
"startup_target_reused": startup_target_reused,
"startup_page_navigated": startup_page_navigated,
"page_target_count": page_target_count,
"fallback_reason": fallback_reason or None,
}, },
) )
self.login_statuses[account.alias] = "已启动" self.login_statuses[account.alias] = "已启动"
@@ -447,4 +513,3 @@ class AccountsTab(QWidget):
PLAINTEXT_SECRET_TITLE, PLAINTEXT_SECRET_TITLE,
PLAINTEXT_PASSWORD_WARNING, PLAINTEXT_PASSWORD_WARNING,
) )
+3 -3
View File
@@ -92,9 +92,9 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
## 四、多账号隔离方案(决策) ## 四、多账号隔离方案(决策)
采用**每账号独立 user-data-dir**(非 Chrome profile)。`--remote-debugging-port` 绑定在 user-data-dir/进程上,profile 方案无法每账号独立 CDP、串号风险高。启动主路径用程序 `subprocess` 直启(`--remote-debugging-port` + `--remote-allow-origins=*` + `--user-data-dir`);可选生成 `.lnk` 快捷方式(PowerShell `WScript.Shell`,参数写在「目标」字段)。 采用**每账号独立 user-data-dir**(非 Chrome profile)。`--remote-debugging-port` 绑定在 user-data-dir/进程上,profile 方案无法每账号独立 CDP、串号风险高。启动主路径用程序 `subprocess` 直启(`--remote-debugging-port` + `--remote-allow-origins=*` + `--user-data-dir`);冷启动登录入口还追加 `--no-first-run`、`--no-default-browser-check`,并把经过校验的 `https://<region_host>/portal/` 作为唯一启动 URL 放在参数末尾,避免全新 profile 的 `chrome://intro/` 另开首次运行窗口。可选生成 `.lnk` 快捷方式(PowerShell `WScript.Shell`,参数写在「目标」字段),快捷方式不携带上述冷启动专用参数和 URL,保持原行为。
同一账号重复点击④「启动登录」不得重复执行 `subprocess.Popen`。正确流程是先探测该账号 `debug_port` 的 `/json/version`:端口未响应才按上述启动参数新开 Chrome;端口已响应则复用该账号现有 Chrome/CDP,打开或激活卖家中心登录 tab 供人工登录,并记录为复用,不创建第二个账号窗口。 同一账号重复点击④「启动登录」不得重复执行 `subprocess.Popen`。正确流程是先探测该账号 `debug_port` 的 `/json/version`:端口未响应才按上述启动参数新开 Chrome;CDP 就绪后在最多 2 秒的有界预算内等待初始卖家中心 page target,出现后直接激活。若只出现唯一的 `about:blank`、Chrome 新标签页或全新 profile 的 `chrome://intro/`,则导航并复用该 target;只有完全没有安全可复用 page target 时才兜底创建一次,并返回 `fallback_reason`。端口已响应时复用该账号现有 Chrome/CDP,优先激活卖家中心/登录 tab;没有时才新增一个 tab,绝不改写或关闭用户已有普通页面。④ GUI 在启动期间禁用账号选择和操作,同账号连续触发写 `result=ignored reason=launch_in_progress`,不进入第二次底层启动。
## 五、数据模型 ## 五、数据模型
@@ -464,7 +464,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`);不自动登录。重定向到登录页返回明确 `LOGIN_PAGE`;页面 target 可用且 Cookie API 至少成功一次、但确实缺少 `SPC_ST`/`SPC_U` 时返回 `NO_SESSION_COOKIE`;websocket/target 正在关闭、整个检测周期一次 Cookie 都未成功读取时返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,界面显示“登录状态暂不可确认”,不得误报账号退出登录。 - 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`);不自动登录。重定向到登录页返回明确 `LOGIN_PAGE`;页面 target 可用且 Cookie API 至少成功一次、但确实缺少 `SPC_ST`/`SPC_U` 时返回 `NO_SESSION_COOKIE`;websocket/target 正在关闭、整个检测周期一次 Cookie 都未成功读取时返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,界面显示“登录状态暂不可确认”,不得误报账号退出登录。
- 多个 Shopee page target 并存时优先稳定的卖家中心 portal 页面,再尝试商品页,显式登录页最后判定;单个 target 连接或 Cookie 读取失败后立即重枚举/改连其他未尝试 target,不等待完整 8 秒才交给外层重试。①采集的 retry/recovered 日志必须带当前任务 ID、商品 ID;关闭上一条商品页的 target 仍短暂出现在 `/json` 时,不得把上一条 URL 误记为下一条账号掉线。 - 多个 Shopee page target 并存时优先稳定的卖家中心 portal 页面,再尝试商品页,显式登录页最后判定;单个 target 连接或 Cookie 读取失败后立即重枚举/改连其他未尝试 target,不等待完整 8 秒才交给外层重试。①采集的 retry/recovered 日志必须带当前任务 ID、商品 ID;关闭上一条商品页的 target 仍短暂出现在 `/json` 时,不得把上一条 URL 误记为下一条账号掉线。
- ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才启动 Chrome。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。① 采集的预检会复用同一幂等启动能力,自动确保本轮匹配账号 Chrome 就绪但不自动登录;③ 更新的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。 - ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才用卖家中心初始 URL 启动 Chrome,并优先复用本轮初始 page target,避免空白窗口后再建第二页。返回结果用 `created_tab/startup_target_reused/startup_page_navigated/page_target_count/fallback_reason` 说明实际路径,`chrome_launch` 日志同步记录且不得包含密码、Cookie 或 token。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。① 采集的预检会复用同一幂等启动能力,自动确保本轮匹配账号 Chrome 就绪但不自动登录;③ 更新的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。
- T-585 后新配置的 `chrome_path` 默认留空。程序在数据目录就绪、强制升级检查通过后、创建主窗口前,仅当当前路径为空或不可启动时,按当前用户/本机 Windows App Paths(含 64/32 位视图)、标准 Chrome 目录和 `PATH` 自动寻找 `chrome.exe`;命中才持久化归一化绝对路径,并在状态栏显示“已自动定位 Chrome”。有效的自定义/便携版路径和可从 PATH 启动的 `chrome.exe` 不覆盖;不扫描整盘、不检测 Edge 或 Chromium。⑤的“自动检测”只填入输入框并标记未保存,仍由用户点“保存设置”确认写入。 - T-585 后新配置的 `chrome_path` 默认留空。程序在数据目录就绪、强制升级检查通过后、创建主窗口前,仅当当前路径为空或不可启动时,按当前用户/本机 Windows App Paths(含 64/32 位视图)、标准 Chrome 目录和 `PATH` 自动寻找 `chrome.exe`;命中才持久化归一化绝对路径,并在状态栏显示“已自动定位 Chrome”。有效的自定义/便携版路径和可从 PATH 启动的 `chrome.exe` 不覆盖;不扫描整盘、不检测 Edge 或 Chromium。⑤的“自动检测”只填入输入框并标记未保存,仍由用户点“保存设置”确认写入。
## 七、CDP 已验证事实(务必遵守) ## 七、CDP 已验证事实(务必遵守)
+5 -5
View File
@@ -222,7 +222,7 @@ login_status_text(status) -> str
- `create_account()` / `update_account()` 负责生成 slug、创建 `chrome_user_data_dir/<slug>` 并写 DB;编辑别名会生成新 slug/目录,但不删除旧 user-data-dir。 - `create_account()` / `update_account()` 负责生成 slug、创建 `chrome_user_data_dir/<slug>` 并写 DB;编辑别名会生成新 slug/目录,但不删除旧 user-data-dir。
- `debug_port` 在账号服务层按账号唯一校验;默认端口取 `debug_port_range` 中第一个未占用端口。 - `debug_port` 在账号服务层按账号唯一校验;默认端口取 `debug_port_range` 中第一个未占用端口。
- `delete_account()` 只删除 DB 账号记录,不删除本地 user-data-dir,避免误删登录态。 - `delete_account()` 只删除 DB 账号记录,不删除本地 user-data-dir,避免误删登录态。
- `launch_for_login()` 是幂等入口。先检查 `chrome.is_running(account.debug_port)`;若该账号 CDP 端口已响应,不再调用 `subprocess.Popen`,而是复用已打开的账号 Chrome,优先激活/打开 `https://<region_host>/portal/` 或卖家中心登录 tab,并返回 `reused=true`;若端口未响应,才启动带该账号 user-data-dir 和 CDP 端口的 Chrome,等待 CDP 就绪并返回 `launched=true`。不会读取、填写或提交密码,不绕过验证码。 - `launch_for_login()` 是幂等入口。先检查 `chrome.is_running(account.debug_port)`;若该账号 CDP 端口已响应,不再调用 `subprocess.Popen`,而是复用已打开的账号 Chrome,优先激活/打开 `https://<region_host>/portal/` 或卖家中心登录 tab,并返回 `reused=true`,且不会改写已有普通页面。若端口未响应,才启动带该账号 user-data-dir、CDP 端口和唯一卖家中心初始 URL 的 Chrome;CDP 就绪后短时等待并复用初始 target,只有无安全可复用 page target 时才兜底创建一次。返回值除 `action/pid/target_id/url/created_tab` 外,还带 `initial_url/startup_target_reused/startup_page_navigated/page_target_count/fallback_reason/target_probe_error` 供日志诊断。不会读取、填写或提交密码,不绕过验证码。
- ③ 更新蝦皮的账号预检不会自动调用 `launch_for_login()`;Chrome 未启动/端口不可达时只返回阻断原因,由 GUI 提示用户去④手动打开账号浏览器并登录。T-105b 的复用逻辑只作用于④用户主动点击「启动登录」这一入口。 - ③ 更新蝦皮的账号预检不会自动调用 `launch_for_login()`;Chrome 未启动/端口不可达时只返回阻断原因,由 GUI 提示用户去④手动打开账号浏览器并登录。T-105b 的复用逻辑只作用于④用户主动点击「启动登录」这一入口。
- `create_shortcut()` 生成 `.lnk`,目标/参数复用 `chrome.build_launch_args()`,不包含密码。 - `create_shortcut()` 生成 `.lnk`,目标/参数复用 `chrome.build_launch_args()`,不包含密码。
- `detect_login()` 复用 `editor.login_status()`;检测为已登录时更新 `last_login_at`。 - `detect_login()` 复用 `editor.login_status()`;检测为已登录时更新 `last_login_at`。
@@ -231,15 +231,15 @@ login_status_text(status) -> str
```python ```python
class ChromeLaunchError(RuntimeError): ... class ChromeLaunchError(RuntimeError): ...
build_launch_args(account, config=None) -> list[str] build_launch_args(account, config=None, initial_url=None) -> list[str]
# chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir # chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir
launch_chrome(account, config=None) -> subprocess.Popen launch_chrome(account, config=None, initial_url=None) -> subprocess.Popen
create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, config=None) -> str create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, config=None) -> str
wait_debug_ready(port, timeout=60, host="127.0.0.1") -> bool wait_debug_ready(port, timeout=60, host="127.0.0.1") -> bool
is_running(port, host="127.0.0.1") -> bool is_running(port, host="127.0.0.1") -> bool
``` ```
`build_launch_args()` 接受 dict 或对象形式账号;账号需有 `debug_port`,并有 `user_data_dir` 或 `slug/alias`。端口探测访问 `/json/version`,显式禁用环境代理。`create_shortcut()` 使用 PowerShell `WScript.Shell.CreateShortcut` 生成 `.lnk`,`TargetPath` 为 Chrome,`Arguments` 含 `--remote-debugging-port`、`--remote-allow-origins=*`、`--user-data-dir=<该账号目录>`。 `build_launch_args()` 接受 dict 或对象形式账号;账号需有 `debug_port`,并有 `user_data_dir` 或 `slug/alias`。可选 `initial_url` 仅接受不含账号密码的蝦皮 `http/https` 地址;传入时追加 `--no-first-run`、`--no-default-browser-check`,并把该 URL 作为唯一 URL 参数放在末尾,避免全新 profile 的首次运行页占用独立窗口;不传时参数保持兼容。端口探测访问 `/json/version`,显式禁用环境代理。`create_shortcut()` 使用 PowerShell `WScript.Shell.CreateShortcut` 生成 `.lnk`,`TargetPath` 为 Chrome,`Arguments` 含 `--remote-debugging-port`、`--remote-allow-origins=*`、`--user-data-dir=<该账号目录>`,不自动追加上述冷启动专用参数或卖家中心 URL。
## cdp 模块(`app/cdp.py`,T-000 由根目录 `cdp.py` 迁入) ## cdp 模块(`app/cdp.py`,T-000 由根目录 `cdp.py` 迁入)
@@ -460,7 +460,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。 - 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。
- 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示;首次保存/变更非空密码前弹窗提示“本地明文保存”。 - 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示;首次保存/变更非空密码前弹窗提示“本地明文保存”。
- 「启动登录」必须幂等:若该账号 Chrome/CDP 已打开,则复用现有窗口并打开/激活卖家中心登录 tab,不重复启动 Chrome;未打开时才新启动 Chrome,仍由用户人工登录。「检测登录」用 worker 跑 `accounts.detect_login()` 并刷新状态列;若当前 URL 跳到 `accounts.shopee.tw/seller/login`,状态必须显示未登录;「快捷方式」调用 `accounts.create_shortcut()` 生成默认桌面 `.lnk`。 - 「启动登录」必须幂等:新增账号后不自动启动;若该账号 Chrome/CDP 已打开,则复用现有窗口并打开/激活卖家中心登录 tab,不重复启动 Chrome,也不覆盖普通页面;未打开时用卖家中心初始 URL 新启动并复用初始 page target,异常时最多兜底新建一个 tab。启动期间禁用账号选择及增删改/登录相关操作,连续触发只记 `ignored` 日志;成功或失败均恢复控件。`chrome_launch` 日志记录初始 target 是否复用、是否兜底建 tab 和原因。「检测登录」用 worker 跑 `accounts.detect_login()` 并刷新状态列;若当前 URL 跳到 `accounts.shopee.tw/seller/login`,状态必须显示未登录;「快捷方式」调用 `accounts.create_shortcut()` 生成默认桌面 `.lnk`。
⑤ 设置当前要点(T-501): ⑤ 设置当前要点(T-501):
+3 -3
View File
@@ -13,7 +13,7 @@
| ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 | | ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 |
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 | | ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 |
| ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 | | ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;新增账号不自动启动 Chrome,首次启动复用初始卖家中心页,重复操作复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — | | ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — |
| ⑥ 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,按套图分类异步生图,查看历史与重试 | 本地生成,中 | | ⑥ 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,按套图分类异步生图,查看历史与重试 | 本地生成,中 |
@@ -51,7 +51,7 @@
- ① 导入采集 与 ③ 更新蝦皮 都依赖**账号已配置且已登录**(在 ④ 账号管理)。 - ① 导入采集 与 ③ 更新蝦皮 都依赖**账号已配置且已登录**(在 ④ 账号管理)。
- ① 点击「采集旧标题/旧封面」后,会为本轮匹配到账号的店铺自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后逐账号检测蝦皮登录态。明确进入登录页的账号任务整组略过并在结束汇总中提示去④人工登录;`NO_SESSION_COOKIE` 或 CDP 短暂读取异常只视为登录状态暂不确定,重试后仍不确定也继续尝试采集,避免误判批量略过。 - ① 点击「采集旧标题/旧封面」后,会为本轮匹配到账号的店铺自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后逐账号检测蝦皮登录态。明确进入登录页的账号任务整组略过并在结束汇总中提示去④人工登录;`NO_SESSION_COOKIE` 或 CDP 短暂读取异常只视为登录状态暂不确定,重试后仍不确定也继续尝试采集,避免误判批量略过。
- ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。 - ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。
- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;③ 不要无提示批量启动所有账号 Chrome,避免在提交线上前开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时也必须先检查该账号 CDP 端口,已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen` 新窗口。 - 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;③ 不要无提示批量启动所有账号 Chrome,避免在提交线上前开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时必须先检查该账号 CDP 端口:已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen`;未打开则直接携带卖家中心 URL 冷启动并复用初始 page target,不先开空白页再额外创建页面。启动过程中账号列表和相关按钮禁用,连续点击不排队启动第二轮。
- 老用户账号已就绪则无感。 - 老用户账号已就绪则无感。
## ① 导入采集 ## ① 导入采集
@@ -234,7 +234,7 @@
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 | | `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成内容」下拉接入 `GenerateWorker` | | `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成内容」下拉接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 | | `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 | | `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;首次启动复用初始卖家中心页,启动中防重复触发,登录检测把 Shopee accounts 登录页判为未登录 |
| `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 | | `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
| `ProductSuiteTab(QWidget)` | ⑥ | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 | | `ProductSuiteTab(QWidget)` | ⑥ | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 |
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 | | `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
+13 -2
View File
@@ -3,7 +3,7 @@ id: T-633
title: ④新账号首次启动登录只打开一个 Chrome 窗口 title: ④新账号首次启动登录只打开一个 Chrome 窗口
phase: 1 phase: 1
deps: [T-105b] deps: [T-105b]
status: TODO status: DONE
created: 2026-07-14 created: 2026-07-14
--- ---
@@ -20,6 +20,7 @@ created: 2026-07-14
- `chrome.wait_debug_ready()` 等待 CDP; - `chrome.wait_debug_ready()` 等待 CDP;
- `_open_or_activate_login_tab()` 再查找蝦皮页面,未找到时调用 `cdp.create_tab_info(portal_url)`。 - `_open_or_activate_login_tab()` 再查找蝦皮页面,未找到时调用 `cdp.create_tab_info(portal_url)`。
3. `chrome.build_launch_args()` 当前只传 Chrome 路径、调试端口、`remote-allow-origins` 和账号 `user-data-dir`,没有携带 `https://<region_host>/portal/`。因此新 Chrome 会先按自身启动策略创建空白/新标签页;CDP 就绪后代码又执行一次 `Target.createTarget` 打开卖家中心。部分 Chrome 版本或窗口状态下,这会表现为一个初始窗口加一个卖家中心窗口,而不是同一窗口内唯一页面。 3. `chrome.build_launch_args()` 当前只传 Chrome 路径、调试端口、`remote-allow-origins` 和账号 `user-data-dir`,没有携带 `https://<region_host>/portal/`。因此新 Chrome 会先按自身启动策略创建空白/新标签页;CDP 就绪后代码又执行一次 `Target.createTarget` 打开卖家中心。部分 Chrome 版本或窗口状态下,这会表现为一个初始窗口加一个卖家中心窗口,而不是同一窗口内唯一页面。
- Windows 全新临时 profile 实测进一步确认:Chrome 会先创建独立的 `chrome://intro/` 首次运行窗口并忽略命令行 URL;旧判断不把该页视为安全启动页,随后兜底 `Target.createTarget`,最终稳定复现 2 个 page target 和 2 个 `Chrome_WidgetWin_1` 顶层窗口。
4. 现有 `tests/test_accounts.py` 的冷启动测试明确断言 `launch_chrome()` 后还会调用一次 `create_tab_info()`,说明当前自动回归把“双阶段建页”当成了正常行为,未覆盖“首次启动只产生一个可见窗口/页面”的产品要求。 4. 现有 `tests/test_accounts.py` 的冷启动测试明确断言 `launch_chrome()` 后还会调用一次 `create_tab_info()`,说明当前自动回归把“双阶段建页”当成了正常行为,未覆盖“首次启动只产生一个可见窗口/页面”的产品要求。
5. 本地 `chrome_launch` 运行日志中,最近两次启动记录相隔约 25 秒,各自只有一次 `start -> launched`,并记录不同 PID。该证据不支持“单次 GUI 回调调用两次 `Popen`”,但说明还需要防止用户连续点击或首轮 CDP 消失后再次启动。任务必须同时区分: 5. 本地 `chrome_launch` 运行日志中,最近两次启动记录相隔约 25 秒,各自只有一次 `start -> launched`,并记录不同 PID。该证据不支持“单次 GUI 回调调用两次 `Popen`”,但说明还需要防止用户连续点击或首轮 CDP 消失后再次启动。任务必须同时区分:
- 一次启动进程内额外创建页面/窗口; - 一次启动进程内额外创建页面/窗口;
@@ -32,6 +33,7 @@ T-105b 已解决“同账号 Chrome 的 CDP 端口仍在运行时,重复点击
### 1. 冷启动直接携带卖家中心 URL ### 1. 冷启动直接携带卖家中心 URL
- 给 `chrome.build_launch_args()` / `chrome.launch_chrome()` 增加可选 `initial_url` 参数;不传时保持现有调用兼容,传入时把规范化 URL 作为唯一启动页面追加到 Chrome 参数末尾。 - 给 `chrome.build_launch_args()` / `chrome.launch_chrome()` 增加可选 `initial_url` 参数;不传时保持现有调用兼容,传入时把规范化 URL 作为唯一启动页面追加到 Chrome 参数末尾。
- 传入 `initial_url` 的冷启动登录路径同时追加 `--no-first-run` 和 `--no-default-browser-check`,避免全新 profile 的 `chrome://intro/`/默认浏览器提示抢占独立窗口;不传 `initial_url` 的快捷方式和其他入口不追加这些参数。
- `accounts.launch_for_login()` 在确认账号端口未运行后,计算 `https://<region_host>/portal/`,调用: - `accounts.launch_for_login()` 在确认账号端口未运行后,计算 `https://<region_host>/portal/`,调用:
```python ```python
@@ -47,7 +49,7 @@ chrome.launch_chrome(account, config=config, initial_url=portal_url)
- `wait_debug_ready()` 成功后,不立即把“当前没有已完成导航的蝦皮 URL”解释为必须新建 target。 - `wait_debug_ready()` 成功后,不立即把“当前没有已完成导航的蝦皮 URL”解释为必须新建 target。
- 对本轮刚启动的 Chrome,在一个短且有上限的预算内重新枚举 `/json`,等待命令行携带的初始卖家中心页面出现;建议总预算不超过 2~3 秒、短间隔轮询,不使用无条件固定长 `sleep`。 - 对本轮刚启动的 Chrome,在一个短且有上限的预算内重新枚举 `/json`,等待命令行携带的初始卖家中心页面出现;建议总预算不超过 2~3 秒、短间隔轮询,不使用无条件固定长 `sleep`。
- 如果已经出现卖家中心或 `accounts.shopee.<区域>/seller/login` 页面,直接激活该 target,返回 `created_tab=false` / `startup_target_reused=true`,不得再调用 `Target.createTarget`。 - 如果已经出现卖家中心或 `accounts.shopee.<区域>/seller/login` 页面,直接激活该 target,返回 `created_tab=false` / `startup_target_reused=true`,不得再调用 `Target.createTarget`。
- 如果初始 URL 尚未完成,但本轮新 Chrome 已有唯一的普通 page target(如 `about:blank` / `chrome://newtab`),优先通过该 target 导航到卖家中心并激活,避免另建窗口。该行为只适用于“本轮刚启动”的冷启动路径。 - 如果初始 URL 尚未完成,但本轮新 Chrome 已有唯一的普通启动 page target(如 `about:blank` / `chrome://newtab` / `chrome://intro/`),优先通过该 target 导航到卖家中心并激活,避免另建窗口。该行为只适用于“本轮刚启动”的冷启动路径;端口启动前已经运行的 Chrome 不得把 `chrome://intro/` 当作可覆盖页面。
- 只有在有界等待结束后仍没有任何可复用的 page target 时,才允许兜底创建一个卖家中心 target;返回值和运行日志必须标记 `created_tab=true` 与兜底原因。 - 只有在有界等待结束后仍没有任何可复用的 page target 时,才允许兜底创建一个卖家中心 target;返回值和运行日志必须标记 `created_tab=true` 与兜底原因。
- 对“端口启动前已经运行”的 Chrome 继续沿用 T-105b:优先激活现有卖家中心/登录页;若没有,新增一个卖家中心 tab。不得为了单窗口目标导航、覆盖或关闭用户已有的普通页面。 - 对“端口启动前已经运行”的 Chrome 继续沿用 T-105b:优先激活现有卖家中心/登录页;若没有,新增一个卖家中心 tab。不得为了单窗口目标导航、覆盖或关闭用户已有的普通页面。
@@ -129,3 +131,12 @@ chrome.launch_chrome(account, config=config, initial_url=portal_url)
- 不修改 Shopee 商品详情页 CDP 选择器、采集、标题/封面更新、AI/cmhub、Excel 或自动升级流程。 - 不修改 Shopee 商品详情页 CDP 选择器、采集、标题/封面更新、AI/cmhub、Excel 或自动升级流程。
- 不自动登录、不填写或提交密码、不读取 Cookie 值、不绕过验证码、风控或权限校验。 - 不自动登录、不填写或提交密码、不读取 Cookie 值、不绕过验证码、风控或权限校验。
- 实现时同步更新 `docs/04-architecture.md`、`docs/api.md` 和必要的 `docs/routes.md`;不修改冻结的 `docs/06-tasks.md`。 - 实现时同步更新 `docs/04-architecture.md`、`docs/api.md` 和必要的 `docs/routes.md`;不修改冻结的 `docs/06-tasks.md`。
## 执行记录
- 2026-07-14 完成。`chrome.build_launch_args()` / `launch_chrome()` 已支持经过校验的蝦皮 `initial_url`;冷启动登录同时使用 `--no-first-run`、`--no-default-browser-check`,卖家中心 URL 保持为唯一末尾 URL。未传 `initial_url` 的快捷方式和既有启动调用参数不变。
- `accounts.launch_for_login()` 已区分冷启动与已运行 Chrome:冷启动在 2 秒有界预算内复用初始卖家中心/登录 target,唯一 `about:blank`、新标签页或 `chrome://intro/` 可导航复用,完全无安全 target 时才创建一次并返回兜底原因;已运行 Chrome 不改写或关闭用户普通页面。
- ④账号管理已增加启动中状态和连续点击保护;启动期间禁用账号列表及增删改/登录操作,底层调用成功或失败后恢复控件。同账号启动中或系统队列紧接着投递的重复点击不会再次调用 `launch_for_login()`,并写 `result=ignored reason=launch_in_progress`。启动日志新增 `created_tab/startup_target_reused/startup_page_navigated/page_target_count/fallback_reason`,且继续统一脱敏。
- 已同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`;测试覆盖启动 URL/首次运行参数校验、初始 target 立即或延迟出现、空白/intro 复用、无 target 单次兜底、运行中普通页保护、新增账号不自动启动、GUI 重入及成功/失败控件恢复。
- Windows 全新临时 profile 初次探测复现了 `chrome://intro/` 导致 2 个 page target/2 个顶层窗口,补齐首次运行参数和 intro 复用后复测通过:首次启动为 `launched`、`created_tab=false`、1 个 page target、1 个 `Chrome_WidgetWin_1`;再次调用为 `reused` 且 target 不变;关闭后使用同一 profile 再次冷启动仍为 1 个页面、1 个窗口。验证未读取或填写凭据,临时 Chrome 已通过 CDP 关闭并清理。
- 最终干净验证工作树中 `py -3.10 -m unittest discover -s tests` 共 495 项全部通过;`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 全部通过。工作区中用户未提交的提示词、版本号、历史任务归档和 HTML 样本未纳入验证快照或提交。
+186 -14
View File
@@ -84,18 +84,18 @@ class AccountsTests(TempDirMixin, unittest.TestCase):
class FakeProcess: class FakeProcess:
pid = 1234 pid = 1234
initial_tab = {
"type": "page",
"id": "target-initial",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-initial",
}
with mock.patch("app.accounts.chrome.is_running", return_value=False) as is_running, \ with mock.patch("app.accounts.chrome.is_running", return_value=False) as is_running, \
mock.patch("app.accounts.chrome.launch_chrome", return_value=FakeProcess()) as launch, \ mock.patch("app.accounts.chrome.launch_chrome", return_value=FakeProcess()) as launch, \
mock.patch("app.accounts.chrome.wait_debug_ready", return_value=True) as wait_ready, \ mock.patch("app.accounts.chrome.wait_debug_ready", return_value=True) as wait_ready, \
mock.patch("app.accounts.cdp.http_get", return_value=[]), \ mock.patch("app.accounts.cdp.http_get", return_value=[initial_tab]), \
mock.patch( mock.patch("app.accounts.cdp.create_tab_info") as create_tab_info, \
"app.accounts.cdp.create_tab_info",
return_value={
"id": "target-new",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-new",
},
) as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab") as activate_tab: mock.patch("app.accounts.cdp.activate_tab") as activate_tab:
result = accounts.launch_for_login("alias", config=cfg) result = accounts.launch_for_login("alias", config=cfg)
@@ -103,18 +103,190 @@ class AccountsTests(TempDirMixin, unittest.TestCase):
self.assertTrue(result["launched"]) self.assertTrue(result["launched"])
self.assertFalse(result["reused"]) self.assertFalse(result["reused"])
self.assertEqual(1234, result["pid"]) self.assertEqual(1234, result["pid"])
self.assertEqual("target-new", result["target_id"]) self.assertEqual("target-initial", result["target_id"])
self.assertFalse(result["created_tab"])
self.assertTrue(result["startup_target_reused"])
is_running.assert_called_once_with(9222) is_running.assert_called_once_with(9222)
launch.assert_called_once_with(account, config=cfg) launch.assert_called_once_with(
account,
config=cfg,
initial_url="https://seller.shopee.tw/portal/",
)
wait_ready.assert_called_once_with(9222, timeout=60) wait_ready.assert_called_once_with(9222, timeout=60)
create_tab_info.assert_called_once_with( create_tab_info.assert_not_called()
"https://seller.shopee.tw/portal/", activate_tab.assert_called_once_with(
"target-initial",
host="127.0.0.1:9222", host="127.0.0.1:9222",
) )
activate_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_new_chrome_waits_for_delayed_initial_login_target(self):
account = {
"alias": "alias",
"region_host": "seller.shopee.tw",
"debug_port": 9222,
}
blank_tab = {
"type": "page",
"id": "target-blank",
"url": "about:blank",
"webSocketDebuggerUrl": "ws-blank",
}
portal_tab = {
"type": "page",
"id": "target-portal",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-portal",
}
probe_count = 0
def page_targets(*_args, **_kwargs):
nonlocal probe_count
probe_count += 1
return [blank_tab] if probe_count == 1 else [portal_tab]
with mock.patch("app.accounts.cdp.http_get", side_effect=page_targets), \
mock.patch("app.accounts.cdp.create_tab_info") as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab") as activate_tab:
result = accounts._open_or_activate_login_tab(
account,
newly_launched=True,
startup_wait_timeout=0.3,
)
self.assertGreaterEqual(probe_count, 2)
self.assertEqual("target-portal", result["target_id"])
self.assertTrue(result["startup_target_reused"])
self.assertFalse(result["created_tab"])
create_tab_info.assert_not_called()
activate_tab.assert_called_once_with(
"target-portal",
host="127.0.0.1:9222",
)
def test_new_chrome_navigates_its_only_blank_page(self):
account = {
"alias": "alias",
"region_host": "seller.shopee.tw",
"debug_port": 9222,
}
blank_tab = {
"type": "page",
"id": "target-blank",
"url": "about:blank",
"webSocketDebuggerUrl": "ws-blank",
}
client = mock.Mock()
client.send.return_value = {}
with mock.patch("app.accounts.cdp.http_get", return_value=[blank_tab]), \
mock.patch("app.accounts.cdp.CDP", return_value=client) as cdp_client, \
mock.patch("app.accounts.cdp.create_tab_info") as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab") as activate_tab:
result = accounts._open_or_activate_login_tab(
account,
newly_launched=True,
startup_wait_timeout=0,
)
cdp_client.assert_called_once_with("ws-blank")
client.send.assert_called_once_with(
"Page.navigate",
{"url": "https://seller.shopee.tw/portal/"},
)
client.close.assert_called_once_with()
self.assertFalse(result["created_tab"])
self.assertTrue(result["startup_target_reused"])
self.assertTrue(result["startup_page_navigated"])
create_tab_info.assert_not_called()
activate_tab.assert_called_once_with(
"target-blank",
host="127.0.0.1:9222",
)
def test_new_chrome_treats_intro_as_reusable_startup_page(self):
intro_tab = {
"type": "page",
"id": "target-intro",
"url": "chrome://intro/",
"webSocketDebuggerUrl": "ws-intro",
}
self.assertEqual(intro_tab, accounts._safe_startup_page([intro_tab]))
def test_new_chrome_without_page_creates_one_fallback_target(self):
account = {
"alias": "alias",
"region_host": "seller.shopee.tw",
"debug_port": 9222,
}
created_tab = {
"type": "page",
"id": "target-fallback",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-fallback",
}
with mock.patch("app.accounts.cdp.http_get", return_value=[]), \
mock.patch(
"app.accounts.cdp.create_tab_info",
return_value=created_tab,
) as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab") as activate_tab:
result = accounts._open_or_activate_login_tab(
account,
newly_launched=True,
startup_wait_timeout=0,
)
self.assertTrue(result["created_tab"])
self.assertEqual("startup_page_missing", result["fallback_reason"])
create_tab_info.assert_called_once_with(
"https://seller.shopee.tw/portal/",
host="127.0.0.1:9222",
)
activate_tab.assert_called_once_with(
"target-fallback",
host="127.0.0.1:9222",
)
def test_running_chrome_preserves_normal_page_and_opens_login_tab(self):
account = {
"alias": "alias",
"region_host": "seller.shopee.tw",
"debug_port": 9222,
}
normal_tab = {
"type": "page",
"id": "target-user",
"url": "https://example.com/",
"webSocketDebuggerUrl": "ws-user",
}
created_tab = {
"type": "page",
"id": "target-login",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-login",
}
with mock.patch("app.accounts.cdp.http_get", return_value=[normal_tab]), \
mock.patch("app.accounts.cdp.CDP") as cdp_client, \
mock.patch(
"app.accounts.cdp.create_tab_info",
return_value=created_tab,
) as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab"):
result = accounts._open_or_activate_login_tab(
account,
newly_launched=False,
)
self.assertTrue(result["created_tab"])
self.assertEqual("existing_chrome_no_login_tab", result["fallback_reason"])
create_tab_info.assert_called_once()
cdp_client.assert_not_called()
def test_launch_for_login_reuses_running_chrome_without_starting_process(self): def test_launch_for_login_reuses_running_chrome_without_starting_process(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir) cfg = self.make_config(temp_dir)
+51 -1
View File
@@ -236,6 +236,8 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
self.assertIn("--remote-debugging-port=9222", args) self.assertIn("--remote-debugging-port=9222", args)
self.assertIn("--remote-allow-origins=*", args) self.assertIn("--remote-allow-origins=*", args)
self.assertIn(f"--user-data-dir={user_data_dir}", args) self.assertIn(f"--user-data-dir={user_data_dir}", args)
self.assertNotIn("--no-first-run", args)
self.assertNotIn("--no-default-browser-check", args)
self.assertTrue(os.path.isdir(user_data_dir)) self.assertTrue(os.path.isdir(user_data_dir))
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
@@ -255,6 +257,45 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_build_launch_args_appends_valid_initial_url_once(self):
with self.make_temp_dir() as temp_dir:
cfg = {"chrome_path": "chrome.exe", "user_data_root": temp_dir}
account = {"alias": "alias", "debug_port": 9222}
initial_url = "https://seller.shopee.tw/portal/"
args = chrome.build_launch_args(
account,
config=cfg,
initial_url=initial_url,
)
self.assertEqual(initial_url, args[-1])
self.assertEqual(1, args.count(initial_url))
self.assertEqual(1, args.count("--no-first-run"))
self.assertEqual(1, args.count("--no-default-browser-check"))
self.assert_removed(temp_dir)
def test_build_launch_args_rejects_untrusted_initial_url(self):
account = {"alias": "alias", "debug_port": 9222}
cfg = {"chrome_path": "chrome.exe"}
for initial_url in (
"ftp://seller.shopee.tw/portal/",
"https://example.com/portal/",
"https://evilshopee.tw/portal/",
"https://user:secret@seller.shopee.tw/portal/",
"https:///portal/",
):
with self.subTest(initial_url=initial_url), self.assertRaises(
chrome.ChromeLaunchError
):
chrome.build_launch_args(
account,
config=cfg,
initial_url=initial_url,
)
def test_build_launch_args_rejects_invalid_account(self): def test_build_launch_args_rejects_invalid_account(self):
with self.assertRaises(chrome.ChromeLaunchError): with self.assertRaises(chrome.ChromeLaunchError):
chrome.build_launch_args( chrome.build_launch_args(
@@ -268,8 +309,13 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
cfg = {"chrome_path": "chrome.exe", "user_data_root": temp_dir} cfg = {"chrome_path": "chrome.exe", "user_data_root": temp_dir}
process = object() process = object()
initial_url = "https://seller.shopee.tw/portal/"
with mock.patch("app.chrome.subprocess.Popen", return_value=process) as popen: with mock.patch("app.chrome.subprocess.Popen", return_value=process) as popen:
result = chrome.launch_chrome(account, config=cfg) result = chrome.launch_chrome(
account,
config=cfg,
initial_url=initial_url,
)
self.assertIs(process, result) self.assertIs(process, result)
popen.assert_called_once() popen.assert_called_once()
@@ -277,6 +323,7 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
self.assertEqual("chrome.exe", args[0]) self.assertEqual("chrome.exe", args[0])
self.assertIn("--remote-debugging-port=9222", args) self.assertIn("--remote-debugging-port=9222", args)
self.assertIn("--remote-allow-origins=*", args) self.assertIn("--remote-allow-origins=*", args)
self.assertEqual(initial_url, args[-1])
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
@@ -309,6 +356,9 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
self.assertIn("--remote-allow-origins=*", script) self.assertIn("--remote-allow-origins=*", script)
self.assertIn("--user-data-dir=", script) self.assertIn("--user-data-dir=", script)
self.assertIn("profile with space", script) self.assertIn("profile with space", script)
self.assertNotIn("seller.shopee", script)
self.assertNotIn("--no-first-run", script)
self.assertNotIn("--no-default-browser-check", script)
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
+89 -2
View File
@@ -7231,7 +7231,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
with mock.patch("app.gui.AccountDialog", FakeDialog), mock.patch( with mock.patch("app.gui.AccountDialog", FakeDialog), mock.patch(
"app.gui.QMessageBox.warning" "app.gui.QMessageBox.warning"
) as warning: ) as warning, mock.patch(
"app.gui.accounts.launch_for_login"
) as launch_for_login:
tab.add_account() tab.add_account()
warning.assert_called_once() warning.assert_called_once()
@@ -7247,6 +7249,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertNotIn("plain-password", visible_values) self.assertNotIn("plain-password", visible_values)
self.assertIn("账号已新增", statuses[-1]) self.assertIn("账号已新增", statuses[-1])
self.assertNotIn("plain-password", statuses[-1]) self.assertNotIn("plain-password", statuses[-1])
launch_for_login.assert_not_called()
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
@@ -9045,6 +9048,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
"pid": 1234, "pid": 1234,
"target_id": "target-new", "target_id": "target-new",
"url": "https://seller.shopee.tw/portal/", "url": "https://seller.shopee.tw/portal/",
"created_tab": False,
"startup_target_reused": True,
"startup_page_navigated": False,
"page_target_count": 1,
}, },
) as launch_for_login: ) as launch_for_login:
tab.launch_login() tab.launch_login()
@@ -9061,6 +9068,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIn("step=launch_chrome result=launched", messages) self.assertIn("step=launch_chrome result=launched", messages)
self.assertIn("pid=1234", messages) self.assertIn("pid=1234", messages)
self.assertIn("target_id=target-new", messages) self.assertIn("target_id=target-new", messages)
self.assertIn("created_tab=0", messages)
self.assertIn("startup_target_reused=1", messages)
self.assertTrue(tab.table.isEnabled())
self.assertTrue(tab.add_button.isEnabled())
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
@@ -9083,10 +9094,12 @@ class GuiTests(TempDirMixin, unittest.TestCase):
}, },
) as launch_for_login: ) as launch_for_login:
tab.launch_login() tab.launch_login()
tab.table.selectRow(0)
tab.launch_login()
launch_for_login.assert_called_once_with(account, config=cfg) launch_for_login.assert_called_once_with(account, config=cfg)
self.assertEqual("已启动", tab.login_statuses["alias-a"]) self.assertEqual("已启动", tab.login_statuses["alias-a"])
self.assertIn("已复用现有窗口", statuses[-1]) self.assertTrue(any("已复用现有窗口" in message for message in statuses))
run_log = db.list_run_logs(limit=1, run_type="chrome_launch", path=cfg["db_path"])[0] run_log = db.list_run_logs(limit=1, run_type="chrome_launch", path=cfg["db_path"])[0]
self.assertEqual("done", run_log.status) self.assertEqual("done", run_log.status)
self.assertEqual(1, run_log.success_count) self.assertEqual(1, run_log.success_count)
@@ -9096,7 +9109,81 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIn("step=launch_chrome result=reused", messages) self.assertIn("step=launch_chrome result=reused", messages)
self.assertIn("target_id=target-existing", messages) self.assertIn("target_id=target-existing", messages)
self.assertIn("url=https://seller.shopee.tw/portal/", messages) self.assertIn("url=https://seller.shopee.tw/portal/", messages)
self.assertIn("result=ignored", messages)
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_accounts_tab_ignores_reentrant_launch_and_restores_controls(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
statuses = []
tab = AccountsTab(config=cfg, status_callback=statuses.append)
self.addCleanup(tab.close)
tab.table.selectRow(0)
def launch_once(selected_account, config=None):
self.assertEqual(account, selected_account)
self.assertEqual(cfg, config)
self.assertFalse(tab.table.isEnabled())
self.assertFalse(tab.add_button.isEnabled())
self.assertFalse(tab.launch_button.isEnabled())
tab.launch_login()
return {
"action": "launched",
"pid": 1234,
"target_id": "target-initial",
"url": "https://seller.shopee.tw/portal/",
"created_tab": False,
"startup_target_reused": True,
"page_target_count": 1,
}
with mock.patch(
"app.gui.accounts.launch_for_login",
side_effect=launch_once,
) as launch_for_login:
tab.launch_login()
launch_for_login.assert_called_once_with(account, config=cfg)
self.assertTrue(tab.table.isEnabled())
self.assertTrue(tab.add_button.isEnabled())
run_logs = db.list_run_logs(
limit=10,
run_type="chrome_launch",
path=cfg["db_path"],
)
self.assertEqual(1, len(run_logs))
events = db.list_run_log_events(run_logs[0].id, path=cfg["db_path"])
messages = "\n".join(event.message for event in events)
self.assertIn("result=ignored", messages)
self.assertIn("reason=launch_in_progress", messages)
self.assertTrue(any("正在启动" in message for message in statuses))
self.assert_removed(temp_dir)
def test_accounts_tab_launch_failure_restores_controls(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)
tab = AccountsTab(config=cfg)
self.addCleanup(tab.close)
tab.table.selectRow(0)
with mock.patch(
"app.gui.accounts.launch_for_login",
side_effect=RuntimeError("启动失败"),
), mock.patch("app.gui.QMessageBox.warning"):
tab.launch_login()
self.assertTrue(tab.table.isEnabled())
self.assertTrue(tab.add_button.isEnabled())
self.assertTrue(tab.launch_button.isEnabled())
run_log = db.list_run_logs(
limit=1,
run_type="chrome_launch",
path=cfg["db_path"],
)[0]
self.assertEqual("failed", run_log.status)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()