feat: complete T-105b account login reuse
This commit is contained in:
+87
-2
@@ -7,7 +7,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import appconfig, chrome, db, editor
|
||||
from . import appconfig, cdp, chrome, db, editor
|
||||
from . import config as account_config
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ def _required_text(value, field_name: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _account_value(account, field, default=None):
|
||||
if isinstance(account, dict):
|
||||
return account.get(field, default)
|
||||
return getattr(account, field, default)
|
||||
|
||||
|
||||
def normalize_region_host(value=None) -> str:
|
||||
text = _trim(value) or DEFAULT_REGION_HOST
|
||||
if "://" in text:
|
||||
@@ -208,9 +214,88 @@ def resolve_account(account_or_alias, path=None, config=None):
|
||||
return account_or_alias
|
||||
|
||||
|
||||
def _debug_host(account) -> str:
|
||||
return f"{chrome.CDP_HOST}:{normalize_debug_port(_account_value(account, 'debug_port'))}"
|
||||
|
||||
|
||||
def _seller_portal_url(account) -> str:
|
||||
return f"https://{normalize_region_host(_account_value(account, 'region_host'))}/portal/"
|
||||
|
||||
|
||||
def _find_login_tab(account, host):
|
||||
region_host = normalize_region_host(_account_value(account, 'region_host')).lower()
|
||||
seller_tabs = []
|
||||
login_tabs = []
|
||||
for tab in cdp.http_get("/json", host=host):
|
||||
if tab.get("type") != "page":
|
||||
continue
|
||||
url = str(tab.get("url") or "")
|
||||
lower_url = url.lower()
|
||||
if region_host in lower_url:
|
||||
seller_tabs.append(tab)
|
||||
elif "accounts.shopee." in lower_url and "/seller/login" in lower_url:
|
||||
login_tabs.append(tab)
|
||||
return (seller_tabs or login_tabs or [None])[0]
|
||||
|
||||
|
||||
def _open_or_activate_login_tab(account):
|
||||
host = _debug_host(account)
|
||||
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)
|
||||
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": True,
|
||||
}
|
||||
|
||||
|
||||
def launch_for_login(account_or_alias, path=None, config=None):
|
||||
account = resolve_account(account_or_alias, path=path, config=config)
|
||||
return chrome.launch_chrome(account, config=config)
|
||||
port = normalize_debug_port(_account_value(account, "debug_port"))
|
||||
alias = _account_value(account, "alias")
|
||||
|
||||
if chrome.is_running(port):
|
||||
tab = _open_or_activate_login_tab(account)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "reused",
|
||||
"reused": True,
|
||||
"launched": False,
|
||||
"alias": alias,
|
||||
"debug_port": port,
|
||||
"pid": None,
|
||||
**tab,
|
||||
}
|
||||
|
||||
process = chrome.launch_chrome(account, config=config)
|
||||
timeout = appconfig.cdp_ready_timeout(config)
|
||||
if not chrome.wait_debug_ready(port, timeout=timeout):
|
||||
raise AccountError(f"Chrome 已启动,但 CDP 端口 {port} 未在 {timeout} 秒内就绪")
|
||||
tab = _open_or_activate_login_tab(account)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "launched",
|
||||
"reused": False,
|
||||
"launched": True,
|
||||
"alias": alias,
|
||||
"debug_port": port,
|
||||
"pid": getattr(process, "pid", None),
|
||||
**tab,
|
||||
}
|
||||
|
||||
|
||||
def create_shortcut(account_or_alias, shortcut_path=None, desktop_dir=None, path=None, config=None) -> str:
|
||||
|
||||
+14
@@ -43,6 +43,20 @@ def close_tab(target_id, host=None):
|
||||
return response.ok
|
||||
|
||||
|
||||
def activate_tab(target_id, host=None):
|
||||
"""Bring an existing Chrome target/page to the foreground."""
|
||||
|
||||
if not target_id:
|
||||
return False
|
||||
ver = http_get("/json/version", host=host)
|
||||
b = CDP(ver["webSocketDebuggerUrl"])
|
||||
try:
|
||||
b.send("Target.activateTarget", {"targetId": target_id})
|
||||
finally:
|
||||
b.close()
|
||||
return True
|
||||
|
||||
|
||||
class CDP:
|
||||
"""单个 target 的 CDP 客户端:命令同步、事件回调异步。"""
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ class AccountsTab(QWidget):
|
||||
account=account,
|
||||
)
|
||||
try:
|
||||
process = accounts.launch_for_login(account, config=self.config)
|
||||
launch_result = accounts.launch_for_login(account, config=self.config)
|
||||
except Exception as exc:
|
||||
elapsed_ms = _elapsed_ms(started)
|
||||
safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
|
||||
@@ -344,10 +344,17 @@ class AccountsTab(QWidget):
|
||||
self._show_error(safe_error)
|
||||
return
|
||||
elapsed_ms = _elapsed_ms(started)
|
||||
pid = getattr(process, "pid", None)
|
||||
action = (launch_result or {}).get("action") or "launched"
|
||||
pid = (launch_result or {}).get("pid")
|
||||
target_id = (launch_result or {}).get("target_id") or ""
|
||||
url = (launch_result or {}).get("url") or ""
|
||||
event_result = "reused" if action == "reused" else "launched"
|
||||
_safe_add_run_log_event(
|
||||
run_id,
|
||||
f"step=launch_chrome result=success detail=账号 {account.alias} pid={pid or ''} elapsed_ms={elapsed_ms}",
|
||||
(
|
||||
f"step=launch_chrome result={event_result} detail=账号 {account.alias} "
|
||||
f"pid={pid or ''} target_id={target_id} url={url} elapsed_ms={elapsed_ms}"
|
||||
),
|
||||
db_path=self.db_path,
|
||||
account=account,
|
||||
)
|
||||
@@ -358,10 +365,21 @@ class AccountsTab(QWidget):
|
||||
done=1,
|
||||
success_count=1,
|
||||
failed_count=0,
|
||||
summary_json={"ok": True, "alias": account.alias, "debug_port": account.debug_port, "pid": pid},
|
||||
summary_json={
|
||||
"ok": True,
|
||||
"alias": account.alias,
|
||||
"debug_port": account.debug_port,
|
||||
"action": action,
|
||||
"pid": pid,
|
||||
"target_id": target_id,
|
||||
"url": url,
|
||||
},
|
||||
)
|
||||
self.login_statuses[account.alias] = "已启动"
|
||||
self.refresh_accounts()
|
||||
if action == "reused":
|
||||
self._set_status("该账号 Chrome 已打开,已复用现有窗口")
|
||||
else:
|
||||
self._set_status("Chrome 已启动,请人工登录")
|
||||
|
||||
def check_login(self, checked=False):
|
||||
|
||||
@@ -87,6 +87,8 @@ imported → collected → generated → applied
|
||||
|
||||
采用**每账号独立 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`,参数写在「目标」字段)。
|
||||
|
||||
同一账号重复点击④「启动登录」不得重复执行 `subprocess.Popen`。正确流程是先探测该账号 `debug_port` 的 `/json/version`:端口未响应才按上述启动参数新开 Chrome;端口已响应则复用该账号现有 Chrome/CDP,打开或激活卖家中心登录 tab 供人工登录,并记录为复用,不创建第二个账号窗口。
|
||||
|
||||
## 五、数据模型
|
||||
|
||||
### 5.1 应用配置 `config.json`
|
||||
@@ -435,7 +437,7 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
|
||||
### 6.4 登录检测
|
||||
|
||||
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`),重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。
|
||||
- ④「启动登录」只负责打开该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。①/③ 的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。
|
||||
- ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才启动 Chrome。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。①/③ 的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。
|
||||
|
||||
## 七、CDP 已验证事实(务必遵守)
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
| T-104 | PySide6 五 Tab 主窗口骨架(`QMainWindow` + `QTabWidget`,5 Tab 空壳) | T-002 | 五个 Tab 按顺序可切换;启动不阻塞;基础状态栏可用 | DONE |
|
||||
| T-104b | PySide6 worker 基类与线程启动工具(`BaseWorker` + `QThread` 包装) | T-104 | signals: progress/log/row_updated/failed/finished/cancelled;取消标记可用;worker 不直接操作 QWidget | DONE |
|
||||
| T-105 | Tab④ 账号增删改(账号名/别名/端口/密码本地明文仅参考)+ 启动登录 + 检测登录 + 顶部 Tab 栏防误点样式 | T-104, T-101, T-103 | 增删改入库、建目录;密码字段 UI 打码;状态列刷新;不自动登录/填密码;5 个 Tab 有足够宽度/间距与当前态高亮,不易误点 | DONE |
|
||||
| T-105b | ④「启动登录」复用已打开 Chrome,防止同账号重复开窗口 | T-105, T-102, T-523 | Bug:同一账号已打开 Chrome 后再次点击「启动登录」,当前链路仍从 `AccountsTab.launch_login()` → `accounts.launch_for_login()` → `chrome.launch_chrome()` → `subprocess.Popen()`,没有先检查该账号 `debug_port` 是否已有 CDP 响应;`login_statuses[alias]="已启动"` 只是界面显示,不是幂等保护。方案:把④启动入口改为幂等流程,先调用 `chrome.is_running(account.debug_port)`;若端口已响应,不再 `Popen`,通过现有 CDP 能力复用该账号 Chrome,优先激活/打开 `https://<region_host>/portal/` 或卖家中心登录 tab,并写 `run_type=chrome_launch` 事件 `result=reused`,状态栏提示“该账号 Chrome 已打开,已复用现有窗口”;若端口未响应,才调用 `chrome.launch_chrome()` 并等待 CDP 就绪,事件 `result=launched`。不得自动填写密码、不得绕过登录/验证码、不得影响①/③账号预检“不自动启动 Chrome”的规则。测试覆盖:端口已运行时不调用 `subprocess.Popen`,端口未运行时仍启动;GUI 重复点击同账号不会新增 Chrome 进程,run log/status 文案区分复用与新启动 | DONE |
|
||||
| T-106 | 可选:为账号生成桌面快捷方式 | T-102 | `.lnk` 目标含该账号参数;双击进对应账号 | DONE |
|
||||
|
||||
## Phase 2 · 导入采集(①)
|
||||
|
||||
+4
-4
@@ -201,7 +201,7 @@ create_account(account_name, alias, region_host=None, debug_port=None,
|
||||
update_account(original_alias, account_name, alias, region_host=None, debug_port=None,
|
||||
password=None, note=None, path=None, config=None) -> db.Account
|
||||
delete_account(alias, path=None, config=None) -> None
|
||||
launch_for_login(account_or_alias, path=None, config=None) -> subprocess.Popen
|
||||
launch_for_login(account_or_alias, path=None, config=None) -> dict # 幂等启动/复用入口
|
||||
create_shortcut(account_or_alias, shortcut_path=None, desktop_dir=None,
|
||||
path=None, config=None) -> str
|
||||
detect_login(account_or_alias, timeout=8, path=None, config=None) -> dict
|
||||
@@ -213,8 +213,8 @@ login_status_text(status) -> str
|
||||
- `create_account()` / `update_account()` 负责生成 slug、创建 `chrome_user_data_dir/<slug>` 并写 DB;编辑别名会生成新 slug/目录,但不删除旧 user-data-dir。
|
||||
- `debug_port` 在账号服务层按账号唯一校验;默认端口取 `debug_port_range` 中第一个未占用端口。
|
||||
- `delete_account()` 只删除 DB 账号记录,不删除本地 user-data-dir,避免误删登录态。
|
||||
- `launch_for_login()` 只启动带该账号 user-data-dir 和 CDP 端口的 Chrome;不会读取、填写或提交密码。
|
||||
- ③ 更新 shopee 的账号预检不会自动调用 `launch_for_login()`;Chrome 未启动/端口不可达时只返回阻断原因,由 GUI 提示用户去④手动打开账号浏览器并登录。
|
||||
- `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`。不会读取、填写或提交密码,不绕过验证码。
|
||||
- ③ 更新 shopee 的账号预检不会自动调用 `launch_for_login()`;Chrome 未启动/端口不可达时只返回阻断原因,由 GUI 提示用户去④手动打开账号浏览器并登录。T-105b 的复用逻辑只作用于④用户主动点击「启动登录」这一入口。
|
||||
- `create_shortcut()` 生成 `.lnk`,目标/参数复用 `chrome.build_launch_args()`,不包含密码。
|
||||
- `detect_login()` 复用 `editor.login_status()`;检测为已登录时更新 `last_login_at`。
|
||||
|
||||
@@ -377,7 +377,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
||||
|
||||
- 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。
|
||||
- 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示;首次保存/变更非空密码前弹窗提示“本地明文保存”。
|
||||
- 「启动登录」只启动 Chrome,人工登录;「检测登录」用 worker 跑 `accounts.detect_login()` 并刷新状态列;若当前 URL 跳到 `accounts.shopee.tw/seller/login`,状态必须显示未登录;「快捷方式」调用 `accounts.create_shortcut()` 生成默认桌面 `.lnk`。
|
||||
- 「启动登录」必须幂等:若该账号 Chrome/CDP 已打开,则复用现有窗口并打开/激活卖家中心登录 tab,不重复启动 Chrome;未打开时才新启动 Chrome,仍由用户人工登录。「检测登录」用 worker 跑 `accounts.detect_login()` 并刷新状态列;若当前 URL 跳到 `accounts.shopee.tw/seller/login`,状态必须显示未登录;「快捷方式」调用 `accounts.create_shortcut()` 生成默认桌面 `.lnk`。
|
||||
|
||||
⑤ 设置当前要点(T-501):
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-3
@@ -13,7 +13,7 @@
|
||||
| ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 |
|
||||
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 |
|
||||
| ③ 更新shopee | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
|
||||
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
|
||||
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
|
||||
| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、生成参数、本地图片目录、Chrome 路径、默认端口、Shopee 更新安全开关等 | — |
|
||||
|
||||
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,但点击「开始更新」后必须先通过 ⑤ 的 Shopee 更新安全开关,再弹窗确认当前筛选范围和任务数量。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
|
||||
@@ -32,7 +32,7 @@
|
||||
- ① 导入采集 与 ③ 更新shopee 都依赖**账号已配置且已登录**(在 ④ 账号管理)。
|
||||
- 当无账号 / 对应账号 Chrome 未启动 / 账号未登录时:相关执行按钮**禁用或在执行前汇总拦截**,并提示「请先到『账号管理』配置账号并登录」。
|
||||
- ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。
|
||||
- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;不要无提示批量启动所有账号 Chrome,避免开错账号或启动过多浏览器进程。
|
||||
- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;不要无提示批量启动所有账号 Chrome,避免开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时也必须先检查该账号 CDP 端口,已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen` 新窗口。
|
||||
- 老用户账号已就绪则无感。
|
||||
|
||||
## ① 导入采集
|
||||
@@ -162,7 +162,7 @@
|
||||
## 流程导航
|
||||
|
||||
```text
|
||||
④ 账号管理:配账号 + 启动登录(首次必做)
|
||||
④ 账号管理:配账号 + 启动登录(首次必做;重复点击应复用已打开 Chrome)
|
||||
│
|
||||
① 导入采集:导入 Excel → 采集旧标题/旧封面 → 自动回写旧字段(失败可手动重试)
|
||||
│
|
||||
|
||||
@@ -217,3 +217,13 @@ T-530 已实现:保存和请求前都会把 Base URL 规整为网关根,去
|
||||
- 本地 `logs/cmshopee.log` 保存脱敏后的 traceback、任务 id、alias、item_id、phase 和 step,用于判断卡在模型配置、封面请求、图片解析、保存文件还是写库。
|
||||
|
||||
排查顺序:先看 ② 页面运行日志里的 `phase` / `step` / `detail`;如果只看到简短错误,再查看本地 `logs/cmshopee.log`。不要把 `config/ai_models.json` 或 API Key 发到聊天、文档或提交里。
|
||||
|
||||
## ④启动登录重复打开 Chrome
|
||||
|
||||
现象:在④账号管理中选中同一个账号,第一次点击「启动登录」会打开该账号 Chrome;不关闭该 Chrome 的情况下再次点击「启动登录」,旧版本会再打开一个 Chrome 窗口,而不是复用已打开的账号窗口创建/激活 tab。
|
||||
|
||||
旧原因:原代码路径是 `AccountsTab.launch_login()` → `accounts.launch_for_login()` → `chrome.launch_chrome()` → `subprocess.Popen()`,每次点击都会直接启动进程;已有的 `chrome.is_running(debug_port)` 没有接入启动入口,`login_statuses[alias]="已启动"` 也只是界面状态,不会阻止下一次点击。
|
||||
|
||||
已修复:T-105b 已把④「启动登录」改成幂等流程。先检查该账号 `debug_port` 是否已有 CDP 响应;已响应时不再 `Popen`,而是复用现有 Chrome/CDP 并打开或激活 `https://<region_host>/portal/` 登录/卖家中心 tab;未响应时才新启动 Chrome。该修复不自动登录、不填密码、不绕过验证码,也不改变①/③预检“不自动启动 Chrome”的规则。
|
||||
|
||||
验证方式:同一账号第一次点击「启动登录」应显示“Chrome 已启动,请人工登录”;保持该账号 Chrome 不关闭,再点一次应显示“该账号 Chrome 已打开,已复用现有窗口”,且不会新增 Chrome 进程。
|
||||
|
||||
+18
@@ -1176,9 +1176,27 @@
|
||||
- 文档:同步 `AGENTS.md`、`docs/05-coding-rules.md`、`docs/current-state.md`。
|
||||
- 代码:同步修正 T-531 设置未保存离开确认框,改用自定义中文按钮“保存 / 放弃 / 取消”,避免系统默认按钮显示英文。
|
||||
- 验证:`python -m py_compile app\gui\main_window.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(86 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(195 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。
|
||||
|
||||
## 【2026-07-06】运行修正 · ②AI生成失败状态分阶段提示
|
||||
|
||||
- 现象:在②AI生成选择一个批次,列表都是“失败”状态时点击「开始生成」,状态栏提示“当前筛选结果没有可生成任务”。
|
||||
- 排查:本地最新未删除批次 `20260702_171000_75bd2557` 的 5 条任务均为 `stage=imported/status=failed/collect_attempts=3/generate_attempts=0/apply_attempts=0`,属于①采集失败,不是 AI 生成失败;②没有旧标题/旧封面,不能直接生成。
|
||||
- 修正:新增 `ai.is_generatable_task()` 统一 GUI/Worker/底层批处理口径;②状态列把失败细分显示为“采集失败 / 生成失败 / 更新失败”;「开始生成」无可重试任务时提示先到①完成旧数据采集。AI 生成失败可重试,③更新失败不会被②误重试。文档补充 `docs/troubleshooting.md` 的同名排查条目。
|
||||
- 测试:新增 `test_generate_batch_retries_failed_generation_record_after_existing_result`、`test_generate_batch_does_not_retry_apply_failed_records`、`test_generate_tab_explains_collect_failed_records_are_not_generatable`。
|
||||
|
||||
|
||||
## 【2026-07-06】文档记录 · T-105b 启动登录复用已打开 Chrome
|
||||
|
||||
- 现象:④账号管理同一账号重复点击「启动登录」会再次打开 Chrome 窗口,而不是复用已打开账号 Chrome 创建/激活 tab。
|
||||
- 根因:当前链路 `AccountsTab.launch_login()` → `accounts.launch_for_login()` → `chrome.launch_chrome()` → `subprocess.Popen()` 每次直接启动进程,未使用已有 `chrome.is_running(debug_port)` 做幂等判断;界面上的“已启动”只是显示状态,不是保护逻辑。
|
||||
- 方案:新增 T-105b,要求④启动登录先检查账号 CDP 端口;已运行时不再 Popen,复用现有 Chrome/CDP 并打开/激活卖家中心登录 tab,写 `chrome_launch result=reused`;未运行时才启动并写 `result=launched`。不自动登录、不填密码、不改变①/③预检不自动启动 Chrome 的规则。
|
||||
- 文档:同步 `docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/04-architecture.md`、`docs/troubleshooting.md`、`docs/current-state.md`。
|
||||
- 验证:文档-only 更新,未运行单元测试。
|
||||
## 【2026-07-06】T-105b 完成 · 启动登录复用已打开 Chrome
|
||||
|
||||
- 状态:DONE
|
||||
- 代码:`app/accounts.py` 的 `launch_for_login()` 改为幂等入口,先用 `chrome.is_running(debug_port)` 检查账号 Chrome/CDP 是否已运行;已运行时复用现有 Chrome,通过 CDP 打开或激活卖家中心登录 tab,不再调用 `subprocess.Popen()`;未运行时才启动 Chrome、等待 CDP 就绪并打开登录 tab。`app/cdp.py` 新增 `activate_tab()` 复用浏览器 target 激活能力。
|
||||
- GUI:④账号管理启动登录运行日志区分 `result=launched` 与 `result=reused`;复用时状态栏提示“该账号 Chrome 已打开,已复用现有窗口”。
|
||||
- 边界:不自动登录、不填写密码、不绕过验证码;①/③账号预检仍只检测,不自动启动 Chrome。
|
||||
- 文档:`docs/06-tasks.md` 将 T-105b 标为 DONE;同步 `docs/api.md`、`docs/troubleshooting.md`、`docs/current-state.md`。
|
||||
- 验证:`python -m py_compile app\cdp.py app\accounts.py app\gui\tabs\accounts.py tests\test_accounts.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_accounts.py` 通过(9 tests);`python -m unittest discover -s tests -p test_chrome.py` 通过(6 tests);`python -m unittest discover -s tests -p test_gui.py` 通过(88 tests)。
|
||||
|
||||
+63
-4
@@ -76,17 +76,76 @@ class AccountsTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_launch_for_login_only_launches_chrome(self):
|
||||
def test_launch_for_login_starts_chrome_when_port_not_running(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
account = accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||||
process = object()
|
||||
|
||||
with mock.patch("app.accounts.chrome.launch_chrome", return_value=process) as launch:
|
||||
class FakeProcess:
|
||||
pid = 1234
|
||||
|
||||
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.wait_debug_ready", return_value=True) as wait_ready, \
|
||||
mock.patch("app.accounts.cdp.http_get", return_value=[]), \
|
||||
mock.patch(
|
||||
"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:
|
||||
result = accounts.launch_for_login("alias", config=cfg)
|
||||
|
||||
self.assertIs(process, result)
|
||||
self.assertEqual("launched", result["action"])
|
||||
self.assertTrue(result["launched"])
|
||||
self.assertFalse(result["reused"])
|
||||
self.assertEqual(1234, result["pid"])
|
||||
self.assertEqual("target-new", result["target_id"])
|
||||
is_running.assert_called_once_with(9222)
|
||||
launch.assert_called_once_with(account, config=cfg)
|
||||
wait_ready.assert_called_once_with(9222, timeout=60)
|
||||
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-new", host="127.0.0.1:9222")
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_launch_for_login_reuses_running_chrome_without_starting_process(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||||
existing_tab = {
|
||||
"type": "page",
|
||||
"id": "target-existing",
|
||||
"url": "https://seller.shopee.tw/portal/",
|
||||
"webSocketDebuggerUrl": "ws-existing",
|
||||
}
|
||||
|
||||
with mock.patch("app.accounts.chrome.is_running", return_value=True) as is_running, \
|
||||
mock.patch("app.accounts.chrome.launch_chrome") as launch, \
|
||||
mock.patch("app.accounts.chrome.wait_debug_ready") as wait_ready, \
|
||||
mock.patch("app.accounts.cdp.http_get", return_value=[existing_tab]) as http_get, \
|
||||
mock.patch("app.accounts.cdp.create_tab_info") as create_tab_info, \
|
||||
mock.patch("app.accounts.cdp.activate_tab") as activate_tab:
|
||||
result = accounts.launch_for_login("alias", config=cfg)
|
||||
|
||||
self.assertEqual("reused", result["action"])
|
||||
self.assertTrue(result["reused"])
|
||||
self.assertFalse(result["launched"])
|
||||
self.assertIsNone(result["pid"])
|
||||
self.assertFalse(result["created_tab"])
|
||||
self.assertEqual("target-existing", result["target_id"])
|
||||
is_running.assert_called_once_with(9222)
|
||||
http_get.assert_called_once_with("/json", host="127.0.0.1:9222")
|
||||
activate_tab.assert_called_once_with("target-existing", host="127.0.0.1:9222")
|
||||
launch.assert_not_called()
|
||||
wait_ready.assert_not_called()
|
||||
create_tab_info.assert_not_called()
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
+43
-5
@@ -4284,12 +4284,14 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.addCleanup(tab.close)
|
||||
tab.table.selectRow(0)
|
||||
|
||||
class FakeProcess:
|
||||
pid = 1234
|
||||
|
||||
with mock.patch(
|
||||
"app.gui.accounts.launch_for_login",
|
||||
return_value=FakeProcess(),
|
||||
return_value={
|
||||
"action": "launched",
|
||||
"pid": 1234,
|
||||
"target_id": "target-new",
|
||||
"url": "https://seller.shopee.tw/portal/",
|
||||
},
|
||||
) as launch_for_login:
|
||||
tab.launch_login()
|
||||
|
||||
@@ -4302,8 +4304,44 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||||
messages = "\n".join(event.message for event in events)
|
||||
self.assertIn("step=launch_chrome result=start", messages)
|
||||
self.assertIn("step=launch_chrome result=success", messages)
|
||||
self.assertIn("step=launch_chrome result=launched", messages)
|
||||
self.assertIn("pid=1234", messages)
|
||||
self.assertIn("target_id=target-new", messages)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_accounts_tab_launch_login_reuse_writes_chrome_launch_run_log(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)
|
||||
|
||||
with mock.patch(
|
||||
"app.gui.accounts.launch_for_login",
|
||||
return_value={
|
||||
"action": "reused",
|
||||
"pid": None,
|
||||
"target_id": "target-existing",
|
||||
"url": "https://seller.shopee.tw/portal/",
|
||||
},
|
||||
) as launch_for_login:
|
||||
tab.launch_login()
|
||||
|
||||
launch_for_login.assert_called_once_with(account, config=cfg)
|
||||
self.assertEqual("已启动", tab.login_statuses["alias-a"])
|
||||
self.assertIn("已复用现有窗口", statuses[-1])
|
||||
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(1, run_log.success_count)
|
||||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||||
messages = "\n".join(event.message for event in events)
|
||||
self.assertIn("step=launch_chrome result=start", messages)
|
||||
self.assertIn("step=launch_chrome result=reused", messages)
|
||||
self.assertIn("target_id=target-existing", messages)
|
||||
self.assertIn("url=https://seller.shopee.tw/portal/", messages)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user