606 lines
64 KiB
Markdown
606 lines
64 KiB
Markdown
# 模块 / CLI 合约
|
||
|
||
> 本项目无后端 API,全部为**本地 Python 模块合约**。实现前可细化,但不要另起一套不兼容接口。
|
||
|
||
## 通用约定
|
||
|
||
- 形态:本地函数 + 子进程(Chrome)+ CDP(`127.0.0.1:<port>`)+ SQLite + openpyxl + AI 服务调用。
|
||
- 编码:UTF-8;传 Chrome / `setFileInputFiles` 的路径为 **Windows 绝对路径**。
|
||
- 凭证:登录态在 user-data-dir;密码、AI Key 本地明文存于 config/DB;T-538 后默认落在 `data/config.json`、`data/config/ai_models.json`、`data/config/cmhub.json`、`data/cmshopee.db`、`data/chrome_user_data_dir/`、`data/images/`,`data/` 必须 gitignore;UI 打码显示,不出现在日志/导出。
|
||
- 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。
|
||
|
||
## appconfig 模块(`app/appconfig.py`,已建)
|
||
|
||
读写 `config.json`(schema 见 [架构 5.1](04-architecture.md))。
|
||
|
||
```python
|
||
class ConfigError(RuntimeError): ...
|
||
default_config() -> dict
|
||
load_config(path="data/config.json") -> dict # 不存在则写默认;返回运行时 data_dir/config_path/ai_models_path/cmhub_config_path
|
||
save_config(config, path="data/config.json") -> dict
|
||
update_config(updates, path="data/config.json") -> dict
|
||
chrome_path(config=None) -> str
|
||
user_data_root(config=None) -> str
|
||
image_dir(config=None) -> str
|
||
db_path(config=None) -> str
|
||
data_dir(config=None) -> str
|
||
resolve_data_path(path, config=None) -> str
|
||
prepare_data_dir(...) -> str # 启动时迁移旧布局并检测 data_dir 可写
|
||
default_debug_port(config=None) -> int
|
||
debug_port_range(config=None) -> tuple # (start, end)
|
||
cdp_ready_timeout(config=None) -> int
|
||
ai_config(config=None) -> dict # default_text_model/default_image_model/generate_mode/generate_cover/
|
||
# title_concurrency/image_concurrency/retry/jpg_quality/
|
||
# resolution/resolution_timeouts
|
||
product_suite_last_settings(config=None) -> dict # 最近的平台/站点/语言/比例;非法值回退默认
|
||
ai_backend(config=None) -> str # 默认 cmhub;direct 仅内部兼容/手工回滚
|
||
cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/connect_timeout/download_with_curl
|
||
normalize_cmhub_base_url(base_url) -> str # 规整为 cmhub 网关根:scheme+host(+port)
|
||
cmhub_request_url(base_url, endpoint) -> str # 先规整 base_url,再拼 /api/v1/...
|
||
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
|
||
```
|
||
|
||
`default_config()` / `load_config()` 包含 `shopee_update` 执行配置段:历史/调试兼容测试商品 ID、更新内容模式 `update_mode`、每批最大更新条数、内部兼容 `dry_run`、同时更新蝦皮账号数 `max_parallel_accounts`。普通正式更新不再用测试商品 ID 或旧真实提交开关阻断当前筛选结果;封面是否参与本轮更新由③「更新内容」下拉决定;线上提交前的显式确认边界是③「开始更新」确认弹窗。`product_suite.last_settings` 只保存⑥最近选择的平台/站点/语言/比例,供未绑定商品的新任务初始化;已有项目自己的 `suite_settings_json` 优先。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。
|
||
|
||
敏感信息展示/日志辅助:
|
||
|
||
```python
|
||
mask_secret(secret) -> str # 展示用打码
|
||
sanitize_for_log(value) -> object # 递归打码 api_key/password/token/*_key/*_token/*_password 字段
|
||
redact_secrets(text, secret_values=None) -> str # 用已知明文值替换自由文本中的秘密
|
||
```
|
||
|
||
|
||
cmhub Key 文件(`data/config/cmhub.json`,含本地明文密钥,T-526 已建;UI 由 T-527 接入):
|
||
|
||
```python
|
||
default_cmhub_config() -> dict
|
||
load_cmhub_config(path="data/config/cmhub.json") -> dict # 缺文件返回空 key;默认 cmhub 但生成时会提示补配置
|
||
save_cmhub_config(config, path="data/config/cmhub.json") -> dict
|
||
get_cmhub_api_key(path="data/config/cmhub.json", masked=False) -> str
|
||
```
|
||
|
||
`data/config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/connect_timeout` 等非密钥配置;T-529 后普通设置页固定保存 `ai.backend=cmhub`,不暴露后端切换;`data/config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。
|
||
AI 模型清单(`data/config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用):
|
||
|
||
```python
|
||
default_ai_models_config() -> dict
|
||
load_ai_models_config(path="data/config/ai_models.json") -> dict # 不存在则写默认,至少 text/image 各一个
|
||
save_ai_models_config(config, path="data/config/ai_models.json") -> dict
|
||
list_ai_models(category=None) -> list[dict] # category=text/image 过滤;默认 api_key 打码,含 api_key_set
|
||
add_ai_model(model) -> None # name 唯一校验
|
||
update_ai_model(name, **fields) -> None
|
||
delete_ai_model(name) -> None # 至少各留一个 text+image;删到剩一禁用
|
||
model_request_url(model) -> str # url 为 /v1 或 /api/v1 base 时按 api_type 补 /chat/completions 或 /images/edits
|
||
test_ai_model(name) -> dict # 「测试连接」:用 key/url/model 发最小请求 -> {ok, status?, error?}
|
||
get_model(name) -> dict # 返回模型定义,含 api_key(调用方不得写日志)
|
||
```
|
||
|
||
`list_ai_models()` 用于 UI/API 展示,默认不返回明文 `api_key`;`get_model()` 用于实际调用 AI,返回明文 `api_key`,调用方不得写日志或导出。`url` 可填完整 endpoint,也可填 OpenAI-compatible base URL(如 `https://.../v1` 或 `https://.../api/v1`),请求前由 `model_request_url()` 按 `api_type` 补齐。`test_ai_model()` 不记录密钥,返回给 GUI 前仍由 worker 过 `sanitize_for_log()`;缺少 `url/model/api_key` 时直接返回 `{ok: False, error: ...}`。
|
||
|
||
## db 模块(`app/db.py`,已建)
|
||
|
||
SQLite 读写,表见 [架构 5.2](04-architecture.md)。
|
||
|
||
```python
|
||
class DbError(RuntimeError): ...
|
||
Batch / Account / Task # dataclass,字段同 SQLite schema
|
||
RunLog / RunLogEvent # dataclass,运行日志与逐条事件
|
||
connect(path=None) -> sqlite3.Connection # 设置 foreign_keys/WAL/busy_timeout/synchronous/row_factory
|
||
init_db(path=None)
|
||
create_batch(file_paths, note=None, path=None) -> str
|
||
get_batch(batch_id, path=None) -> Batch|None
|
||
list_batches(status=None, path=None) -> list[Batch]
|
||
update_batch(batch_id, **fields) -> None # 支持 status/note/source_files_json
|
||
# 账号
|
||
list_accounts(path=None) -> list[Account]
|
||
get_account_by_alias(alias, path=None) -> Account|None
|
||
add_account(account_name, alias, region_host, debug_port, password=None, note=None) -> Account
|
||
update_account(alias, **fields) -> None # 支持账号展示字段、端口、密码、note、last_login_at
|
||
delete_account(alias) -> None
|
||
# 任务 / 各阶段结果
|
||
insert_tasks(batch_id, rows, path=None) -> int # 写输入列;rows 含 source_file_abs/source_sheet/source_row/row_key
|
||
list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, include_deleted=False) -> list[Task]
|
||
get_task(task_id, path=None, include_deleted=False) -> Task | None
|
||
# 默认排除已软删除批次;include_deleted 仅供内部诊断/测试使用
|
||
|
||
delete_batch(batch_id, reason=None, path=None) -> dict
|
||
# T-206 软删除:写 batches.deleted_at/deleted_reason,不物理删除 batches/tasks;默认业务列表和执行流程不可见/不可调用;返回任务数、committed 数和关联图片路径
|
||
mark_running(task_id, phase) -> None
|
||
mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1
|
||
mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进
|
||
set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1
|
||
set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1;new_cover_path 可为 None 表示只生成标题
|
||
set_generated_cover(task_id, new_cover_path) -> None # 只写AI封面并保留new_title(含NULL);stage=generated,status=success,generate_attempts+1
|
||
ensure_image_task_key(task_id) -> str # T-564 cmhub 异步生图:无幂等键则生成并写 tasks.image_task_key
|
||
set_image_task_submitted(task_id, image_task_id, image_task_key=None) -> None
|
||
# T-564 submit 202 后立即写 tasks.image_task_id/image_task_key,供重启/重试续查
|
||
clear_image_task(task_id) -> None # poll failed/expired 或重置封面后清空异步生图状态
|
||
update_generated_title(task_id, new_title, path=None) -> None # T-509 本地人工微调新标题;仅 generated/未提交/非运行中;清空 last_error 并回到 pending,不触碰 Shopee/CDP/Excel/封面
|
||
set_applied(task_id, committed, error=None) -> None # 成功 stage=applied;失败 status=failed 且 stage 不前进
|
||
# T-404a 已实现:选中记录重置
|
||
reset_generated(task_id, delete_file=False) -> dict # 本地清空 AI 结果并退回 collected;默认不删新封面文件
|
||
reset_apply_status(task_id) -> dict # 本地退回 generated/pending 供重复更新;保留 new_* 与 committed 历史事实
|
||
# 运行日志
|
||
create_run_log(run_type, dry_run=False, total=0, options=None) -> int
|
||
finish_run_log(run_id, **fields) -> None # status/done/success_count/skipped_count/failed_count/summary_json
|
||
add_run_log_event(run_id, message, task_id=None, alias=None, item_id=None, level="info") -> int
|
||
list_run_logs(limit=50, run_type=None) -> list[RunLog]
|
||
list_run_log_events(run_id=None, limit=200) -> list[RunLogEvent]
|
||
```
|
||
|
||
`stage` 表示最后成功业务阶段(imported→collected→generated→applied);`status` 表示当前处理结果(pending/running/success/failed/skipped/cancelled)。任意步失败写 `last_error` 且 `status=failed`,`stage` 不前进。无 confirmed 阶段。
|
||
|
||
SQLite 连接规则:
|
||
|
||
- 每个 worker/线程使用自己的 `connect()`;禁止跨线程共享 connection。
|
||
- `connect()` 必须设置 `PRAGMA foreign_keys=ON`、`journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL`。
|
||
- DB 写入短事务、单条提交;Excel 回写失败不回滚 DB。
|
||
|
||
## excel 模块(`app/excel.py`,导入与回写已建,依赖 openpyxl)
|
||
|
||
```python
|
||
class ExcelError(RuntimeError): ...
|
||
OPENPYXL_IMPORT_ERROR: Exception | None
|
||
|
||
import_tasks(file_paths, path=None, note=None, write_db=True) -> dict
|
||
# file_paths 可传单个路径或多个路径;标准空模板为根目录 shopee待处理任务模板.xlsx。
|
||
# 只解析【输入列】:账号名(可选)、别名、商品id;推荐表头固定为 账号名 | 别名 | 商品id,并记录
|
||
# source_file/source_file_abs/source_sheet/source_row/row_key。
|
||
# 默认 write_db=True:有有效行时创建 batch 并写入 tasks。
|
||
# -> {
|
||
# "batch_id": str|None,
|
||
# "rows": [...],
|
||
# "stats": {
|
||
# "files": int, "total": int, "valid": int, "invalid": int,
|
||
# "inserted": int, "file_errors": [...], "row_errors": [...]
|
||
# }
|
||
# }
|
||
# 必需列缺失(别名/商品id)= 整个文件拒绝并记录到 file_errors,不导入该文件任何行。
|
||
# invalid = 单行缺别名/商品id 或商品id 格式错误等脏数据;逐行跳过,不阻塞同文件其他有效行。
|
||
# openpyxl 缺失时模块仍可导入,调用 import_tasks 时抛 ExcelError。
|
||
|
||
match_summary(rows: list[dict], accounts: list) -> dict
|
||
# 用 accounts 的别名对 rows 做匹配统计(导入汇总栏用)
|
||
# -> {"matched": int, "unmatched": int, "by_account": {别名: 行数}, "unmatched_aliases": [..]}
|
||
|
||
write_back(batch_id, excel_path=None, path=None) -> dict
|
||
# 把【旧标题/旧封面图片路径】批量回写到【原 Excel】
|
||
# excel_path 为空则按 source_file_abs 分组回写本批次涉及的所有原文件
|
||
# 只写 stage 已到 collected/generated/applied 或已有旧字段值的任务;无可写任务时返回 rows=0
|
||
# 原文件被占用(锁) → 抛 ExcelError,调用方提示“请关闭后重试”,或改用 export_copy
|
||
# -> {"ok": True, "batch_id": str, "files": int, "rows": int, "written_files": [abs_path, ...]}
|
||
write_back_results(batch_id, excel_path=None, path=None) -> dict
|
||
# 把【新标题/新封面图片路径/更新状态】批量回写到【原 Excel】
|
||
# 更新状态:成功 / 失败:原因 / 略过:原因 / 待更新
|
||
# 原文件被占用(锁) → 抛 ExcelError;SQLite 更新结果不回滚,GUI 提示关闭后重试
|
||
# -> {"ok": True, "batch_id": str, "files": int, "rows": int, "written_files": [abs_path, ...]}
|
||
export_copy(batch_id, out_dir_or_path, path=None) -> dict
|
||
# 退路:另存带旧字段的副本,不动原文件;目录输出时生成 *_cmshopee回写.xlsx
|
||
```
|
||
|
||
列模板见 [架构 5.3](04-architecture.md);别名以“别名”列为权威,必须与 ④ 账号管理中的账号别名一致。`shopee待处理任务模板.xlsx` 是可提交的标准空模板;运营填写后的 Excel 副本属于业务数据,不提交。
|
||
|
||
## config 模块(`app/config.py`,已建)
|
||
|
||
```python
|
||
class ConfigPathError(RuntimeError): ...
|
||
make_slug(alias) -> str # 别名→唯一 slug [a-z0-9_]
|
||
ensure_user_data_dir(slug, root=None, config=None) -> str
|
||
# 默认 root = appconfig.user_data_root(config);返回 chrome_user_data_dir/<slug> 绝对路径,按需创建
|
||
```
|
||
|
||
`make_slug()` 使用可读 ASCII 前缀 + 8 位 SHA1 后缀,保证稳定且降低别名冲突;非 ASCII 别名使用 `account_<hash>`。`ensure_user_data_dir()` 拒绝非 `[a-z0-9_]` slug,防止路径穿越。
|
||
|
||
## accounts 模块(`app/accounts.py`,已建)
|
||
|
||
账号管理服务层,供 ④ Tab 调用;不操作 QWidget,不自动登录/填密码。
|
||
|
||
```python
|
||
class AccountError(RuntimeError): ...
|
||
normalize_region_host(value=None) -> str
|
||
normalize_debug_port(value) -> int
|
||
preview_user_data_dir(alias, config=None) -> str
|
||
mask_password(password) -> str # 展示用,永不返回明文
|
||
next_debug_port(config=None, path=None) -> int
|
||
list_accounts(path=None, config=None) -> list[db.Account]
|
||
get_account(alias, path=None, config=None) -> db.Account
|
||
create_account(account_name, alias, region_host=None, debug_port=None,
|
||
password=None, note=None, path=None, config=None) -> db.Account
|
||
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) -> 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
|
||
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()` 是幂等入口。先检查 `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 的复用逻辑只作用于④用户主动点击「启动登录」这一入口。
|
||
- `create_shortcut()` 生成 `.lnk`,目标/参数复用 `chrome.build_launch_args()`,不包含密码。
|
||
- `detect_login()` 复用 `editor.login_status()`;检测为已登录时更新 `last_login_at`。
|
||
|
||
## chrome 模块(`app/chrome.py`,已建)
|
||
|
||
```python
|
||
class ChromeLaunchError(RuntimeError): ...
|
||
build_launch_args(account, config=None, initial_url=None) -> list[str]
|
||
# chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir
|
||
launch_chrome(account, config=None, initial_url=None) -> subprocess.Popen
|
||
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
|
||
is_running(port, host="127.0.0.1") -> bool
|
||
```
|
||
|
||
`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` 迁入)
|
||
|
||
```python
|
||
CDP_HOST: str
|
||
http_get(path, host=None, timeout=10); find_product_tab(item_id, host=None); create_tab_info(url, host=None); create_tab(url, host=None)
|
||
close_tab(target_id, host=None) -> bool # 关闭浏览器 target/page;区别于 CDP.close()
|
||
wait_target_closed(target_id, host=None, timeout=2.0, poll_interval=0.1) -> bool
|
||
close_tab_and_wait(target_id, host=None, timeout=2.0, poll_interval=0.1) -> bool
|
||
class CDP: send/ev/val/object_id/drag/close # close 只断开 WebSocket;suppress_origin、trust_env=False
|
||
```
|
||
|
||
## editor 模块(`app/editor.py`,已建,重构自现有脚本)
|
||
|
||
```python
|
||
login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, cookie_names, cookie_read_succeeded, probe_error, probe_attempts}
|
||
is_logged_in(account) -> bool # login_status(...).logged_in;重定向登录页或缺 SPC_ST/SPC_U → False
|
||
install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间
|
||
read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断
|
||
open_product(account, item_id) -> CDP # 连端口、导航商品页、等业务字段就绪;标记该 tab 是否本轮自动新建;失败时返回缺失组件/有效错误 toast,并清理本轮自动新建的失败 tab
|
||
|
||
# 采集(只读)
|
||
read_title(cdp) -> str
|
||
read_cover_src(cdp) -> str # 第一张 itembox 的 img.src
|
||
download_cover(src, out_path) -> str # 下载旧封面到本地
|
||
collect(account, task) -> dict # -> {old_title, old_cover_path, close_target_confirmed}
|
||
|
||
# 应用
|
||
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
|
||
replace_cover(cdp, image_win_path, old_cover_path=None) -> dict
|
||
# 更新封面统一先确认 old_cover_path 非空且文件存在,再删除当前第一张、上传、等 Shopee CDN 地址、拖第一位;失败返回 upload_state 诊断;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片
|
||
click_update(cdp, confirm_timeout=3) -> dict # {clicked, reason, toasts, confirm?};禁用或 Shopee 二次确认未完成则记失败
|
||
apply_task(account, task, close_success_tab=False) -> dict
|
||
# 对已生成任务:换标题+换封面+点页面「更新」;若出现 Shopee 确认框,只点弹窗主按钮「更新」,不点「立即優化」(调用前必须已经由③确认弹窗确认本轮真实更新)
|
||
# 程序自动新开的商品编辑页成功/失败都关闭,成功提交后关闭前等待 2 秒;复用用户已有页不关闭。close_success_tab 为旧调用兼容参数,不再控制当前行为
|
||
# -> {committed, error}
|
||
```
|
||
|
||
`login_status()` 不自动登录;无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`)用于检测/人工登录。判断规则:最终 URL 是登录页 → `LOGIN_PAGE`,其中必须显式识别 `https://accounts.shopee.tw/seller/login...` 这类 Shopee accounts 登录页;Cookie API 至少成功一次且确实缺少 `SPC_ST`/`SPC_U` → `NO_SESSION_COOKIE`;有会话 Cookie → 已登录;所有候选 target 都因关闭/连接错误而一次 Cookie 都未成功读取 → `LOGIN_CHECK_TARGET_UNAVAILABLE`。多个 Shopee page 并存时优先稳定 portal 页面,候选连接或 Cookie 读取失败后在同一超时预算内快速重选,不能把 target 错误降级成空 Cookie。
|
||
|
||
采集 tab 生命周期:
|
||
|
||
- `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。
|
||
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
|
||
- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听。标题主定位是 `data-product-edit-field-unique-id="name"` 内唯一可见的 `input.eds-input__input`,主图和上传入口共同限定在 `data-product-edit-field-unique-id="images"` 内同一个 `.shopee-image-manager`;只有相应业务字段根不存在时才使用旧 DOM 回退,不得再用标题长度判断主定位。就绪检查返回标题命中数、主图数量/CDN/blob、上传 input 数量和当前 URL;超时必须先说明缺少标题、主图还是上传入口。明确商品失效/不存在/无权限 toast(如 `please input correct product id`)即使已隐藏也上浮,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;普通物流/备货等 toast 只有仍可见且属于当前页面 URL 时才作为“可能无关”的附加提示,不能覆盖就绪快照。不得记录 Cookie、密码、token。调用方只在明确商品失效类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。
|
||
|
||
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。
|
||
- ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。
|
||
- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。
|
||
- T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。
|
||
- T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,在 `images` 业务字段内同一个主图 manager 中先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。
|
||
|
||
## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP)
|
||
|
||
```python
|
||
class AIError(RuntimeError): ...
|
||
|
||
gen_title(title_prompt, old_title, retry=None, config=None, models_path="data/config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="data/config/cmhub.json") -> str
|
||
# 文本生成:按 backend 分流;direct 走 chat JSON,cmhub 走 /generate/title;提示词 + 旧标题 → 新标题
|
||
|
||
gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="data/config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="data/config/cmhub.json") -> str
|
||
# 图像生成(image-to-image):按 backend 分流;direct 走 chat/images_edits;cmhub 单独调用走旧同步 /generate/image 兼容路径;
|
||
# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`,历史 DB 已存路径继续按原路径读取
|
||
|
||
generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict
|
||
fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> list[dict]
|
||
# 编排:按 ai.generate_mode 跑标题和/或封面;title/title_cover 用 title_concurrency,cover/title_cover 用 image_concurrency
|
||
# cmhub 批量生图由 generate_batch 直接走异步 /generate/image/tasks:submit 落库 task_id,再 poll 续查,成功后仍返回本地 JPEG 路径
|
||
# 只生成标题时标题成功即 db.set_generated(task_id, new_title, existing_cover_path);只生成封面不调用生文,标题上下文取new_title or old_title,成功后db.set_generated_cover只写封面;should_stop() 为真则取消未开始项
|
||
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度
|
||
# ai_cfg 可传 on_event/on_error 回调,逐条报告 title/cover 阶段 step/result/detail,供 GUI run_logs 与本地诊断日志使用
|
||
# 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余
|
||
```
|
||
|
||
要点:
|
||
|
||
- `backend=direct`:内部兼容/手工回滚路径;标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。
|
||
- `backend=cmhub`:普通产品默认路径;标题调用 `POST /api/v1/generate/title`;②批量封面生成调用 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `data/config/cmhub.json`。`gen_cover()` 单独调用没有任务/DB 上下文,第一版保留旧同步 `POST /api/v1/generate/image` 兼容路径。
|
||
- 标题提示词组装:`gen_title()` 的 direct 与 cmhub 路径共用标题 prompt 规则。若标题提示词包含 `{旧标题}`,生成前替换为该任务旧标题,不再自动追加旧标题块;若不包含 `{旧标题}`,保持旧行为自动追加“旧标题:...”块。两种情况都会追加“请只返回新标题,不要解释。”输出约束;其它 `{...}` 原样保留。
|
||
- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。GUI 可读取 `display_name/tags/recommended_for/tier/prices` 生成“默认档 / 高质量档 / 省点档”中文说明,但执行层只保存 cmhub alias。
|
||
- `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。
|
||
- direct 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。cmhub 使用 `requests timeout=(connect, read)`,connect 来自 `ai.cmhub.connect_timeout`;cmhub 生文读取等待固定 600s。②批量生图异步 submit 读取等待 36s、poll 单次读取等待 15s、本地总预算 900s,图片下载读取等待 900s;`gen_cover()` 旧同步兼容路径仍用 900s 读取等待。
|
||
- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;标题/图片并发会被夹到 1..5,失败重试次数会被夹到 0..10,兼容旧配置中的超限值;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。
|
||
- 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。cmhub 批量生图使用内部实际并发 `min(image_concurrency, 5)` 约束 submit+poll 在途数;已有 `tasks.image_task_id` 时直接 GET 续查,不二次 submit;submit 前先持久化 `image_task_key`,submit 成功立即持久化 `image_task_id`;poll `failed/expired` 会清空二者,poll 超时/用户停止/程序退出则保留二者供下次续查。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数与实际生图并发一致且最大 5;T-548 后图片下载后端由 `ai.cmhub.download_with_curl` 控制,默认 `auto`:Windows 且检测到系统 curl 时优先用 curl 下载,否则回退 requests;curl 失败也会自动回退 requests。下载前仍执行公网 URL 校验,curl 使用 `-K` 临时配置文件传入 URL,不把带 token 的 `image_url` 放进命令行参数;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。下载失败最多安全重试 3 次,只复用同一个 `image_url`,不会重新调用 cmhub 生图接口;下载总耗时超过 20 秒时写“图片下载较慢”警告;direct 兼容路径暂保持原 `image_concurrency` 语义。
|
||
- cmhub 图片地址兼容:优先递归读取 `image_url` / `image_url.url` / `images[]` / `image_urls[]` 等字段;若返回 `/generated/...` 或 `generated/...` 相对路径,先按 cmhub Base URL 补全为完整 `http(s)` URL,再进入原有公网 URL 安全校验和下载。
|
||
- 调试 cmhub 图片 URL:默认不在日志或 DB 中记录完整 `image_url`。若本机调试需要查看,启动程序前设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1`,②本轮可见运行日志会显示脱敏后的“cmhub 图片 URL”,但该调试行不写入 SQLite `run_log_events`。
|
||
- 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。
|
||
- 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。
|
||
|
||
## prompts 模块(`app/prompts.py`,已建)
|
||
|
||
```python
|
||
class PromptError(RuntimeError): ...
|
||
|
||
# 标题提示词:单文件
|
||
load_title_prompt(path="data/title_prompt.txt") -> str # 启动回显;缺失返回 ""
|
||
save_title_prompt(text, path="data/title_prompt.txt") -> None # 「保存」按钮
|
||
|
||
# 封面提示词:多模板(data/prompts/cover/<名称>.txt)
|
||
list_cover_templates(directory="data/prompts/cover") -> list[str] # 模板名列表(下拉用)
|
||
load_cover_template(name, directory="data/prompts/cover") -> str
|
||
save_cover_template(name, text, directory="data/prompts/cover") -> None
|
||
rename_cover_template(old, new, directory="data/prompts/cover") -> None
|
||
delete_cover_template(name, directory="data/prompts/cover") -> None
|
||
|
||
# 变量替换
|
||
render_prompt(template_text, task) -> str
|
||
# 占位符 {旧标题}/{新标题}/{商品id}/{店铺} → 该任务真实值;预览与生成时调用
|
||
```
|
||
|
||
要点:
|
||
|
||
- 「插入旧标题」在标题提示词光标处插入 `{旧标题}`;「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」对选中任务调用 `render_prompt` 后展示封面提示词。
|
||
- 生成封面时 `gen_cover` 的 prompt = `render_prompt(当前封面模板, task)`。
|
||
- 模板与 `data/title_prompt.txt` 均为可手改的纯文本文件。
|
||
- `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。
|
||
- 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。
|
||
|
||
## 商品套图模块(`app/product_suite.py` + `app/image_studio*.py`,已建)
|
||
|
||
商品套图是 ⑥ Tab 的本地图片项目工作区,不自动上传蝦皮、不修改线上商品。底层继续复用旧 AI工场的数据表与生成服务,避免迁移既有项目数据。
|
||
|
||
```python
|
||
# app/image_studio.py
|
||
ImageStudioProject / ImageStudioAsset / ImageStudioJob / ImageStudioSelection
|
||
create_or_get_project(account_or_fields, item_id, ...) -> ImageStudioProject
|
||
list_projects(path=None) -> list[ImageStudioProject]
|
||
update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject
|
||
project_suite_settings(project) -> dict
|
||
update_project_suite_settings(project_id, settings, path=None) -> ImageStudioProject
|
||
sync_original_asset_urls(project_id, image_urls, path=None) -> list[ImageStudioAsset]
|
||
list_assets(project_id, kind=None, include_missing=True, path=None) -> list[ImageStudioAsset]
|
||
reorder_original_assets(project_id, asset_ids, path=None) -> list[ImageStudioAsset]
|
||
remove_original_assets_if_unused(project_id, asset_ids, path=None) -> list[ImageStudioAsset]
|
||
create_job(project_id, source_asset_id=None, job_type="main", prompt="", ...) -> ImageStudioJob
|
||
list_jobs(project_id, statuses=None, path=None) -> list[ImageStudioJob]
|
||
list_resumable_jobs(project_id=None, include_failed_downloads=False, path=None) -> list[ImageStudioJob]
|
||
replace_selections(project_id, selection_type, asset_ids, path=None) -> list[ImageStudioSelection]
|
||
pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None,
|
||
should_stop=None) -> dict
|
||
|
||
# app/image_studio_images.py
|
||
download_remote_image(url, max_bytes=..., timeout=(connect, read)) -> RemoteImage
|
||
load_thumbnail(url, key=None, max_size=220) -> ThumbnailResult
|
||
download_original_asset(asset_id, path=None, config=None,
|
||
should_stop=None) -> ImageStudioAsset
|
||
import_original_files(project_id, file_paths, path=None, config=None) -> dict
|
||
import_original_bytes(project_id, content, filename_hint="clipboard.png", ...) -> ImageStudioAsset
|
||
trash_generated_asset(asset_id, path=None, config=None) -> dict
|
||
restore_trashed_asset(record, path=None, config=None) -> ImageStudioAsset
|
||
|
||
# app/product_suite.py
|
||
normalize_suite_settings(value=None) -> dict
|
||
suite_total_count(settings, image_count) -> int
|
||
build_suite_prompt(base_prompt, settings, category, item_id, source_index=1) -> str
|
||
build_job_specs(source_assets, base_prompt, settings, item_id) -> list[dict]
|
||
|
||
# app/image_studio_generation.py
|
||
generate_image_jobs(project_id, source_asset_id, prompt, count, job_type="main", aspect_ratio="1:1", ...) -> dict
|
||
resume_image_jobs(project_id=None, aspect_ratio="1:1", ...) -> dict
|
||
|
||
# app/image_studio_export.py
|
||
export_project_selection(project_id, parent_dir, existing_mode="fail", path=None, config=None) -> ExportResult
|
||
```
|
||
|
||
要点:
|
||
|
||
- 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool/<slug>/<item_id>/` 下分 `originals/generated/exports`,删除生成图进入项目内 `.trash` 并可撤销。
|
||
- `image_studio_projects.suite_settings_json` 保存平台/国家/语言/比例/逐图主图/分类数量;`draft_prompt` 保存商品卖点。有效原图上限16张,missing 历史不占名额。
|
||
- 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。
|
||
- 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。
|
||
- `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。
|
||
- cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。商品套图把平台/国家/语言/比例等上下文写入每个 job prompt,并把比例实参传到 cmhub;界面不展示 Provider URL、OpenAI Key 或上游接口路径。
|
||
- `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。
|
||
- 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。
|
||
- 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。
|
||
- 所有真实网络/CDP/图片生成/下载/转码由 GUI worker 调用;Widget slot 不直接执行长耗时操作。
|
||
|
||
## gui 模块(`app/gui/` 包,已建,PySide6)
|
||
|
||
```python
|
||
# GUI 入口
|
||
main() -> int # 创建 QApplication + MainWindow
|
||
class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤⑥;支持注入 db_path/config/config_path/ai_models_path 便于测试
|
||
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
|
||
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
|
||
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志
|
||
class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
|
||
class ProductSuiteTab(QWidget) # ⑥ 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
|
||
class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
|
||
class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed
|
||
class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度
|
||
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
|
||
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
||
class AIModelTestWorker(BaseWorker) # ⑤ 后台测试 AI 模型连接:appconfig.test_ai_model
|
||
class ImageStudioPullImagesWorker(BaseWorker) # ⑥ 后台只读拉蝦皮原主图 URL;安全边界协作停止
|
||
class ImageStudioDownloadOriginalWorker(BaseWorker)# ⑥ 后台下载远程原图
|
||
class ImageStudioGenerateJobsWorker(BaseWorker) # ⑥ 后台提交/查询/下载 cmhub 生图 job
|
||
class ImageStudioResumeJobsWorker(BaseWorker) # ⑥ 后台恢复已有 task_id 的生图 job
|
||
class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG
|
||
class ProductSuiteImportImagesWorker(BaseWorker) # ⑥ 后台校验并复制本地/剪贴板商品原图
|
||
class ProductSuiteGenerateWorker(BaseWorker) # ⑥ 按套图job规划提交/查询/下载
|
||
class ProductSuiteAiWriteWorker(BaseWorker) # ⑥ 后台生成商品卖点与画面要求
|
||
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
|
||
class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑
|
||
class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单
|
||
class AccountsTab(QWidget) # ④ 账号管理:表格 + 增删改 + 启动登录 + 检测登录 + 快捷方式
|
||
class AccountDialog(QDialog) # 账号编辑弹窗;密码 QLineEdit.Password
|
||
TAB_TITLES: list[str] # 固定 Tab 顺序
|
||
TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态
|
||
```
|
||
|
||
T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑥ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。
|
||
|
||
`MainWindow` 已实现六 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ cmhub 设置、⑥ 商品套图生成。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
|
||
|
||
主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:6 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。
|
||
|
||
④ 账号管理要点:
|
||
|
||
- 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。
|
||
- 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示;首次保存/变更非空密码前弹窗提示“本地明文保存”。
|
||
- 「启动登录」必须幂等:新增账号后不自动启动;若该账号 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):
|
||
|
||
- `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 已完成:`save_app_settings()` 返回 bool,成功写 `data/config.json` + `data/config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。
|
||
- `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `data/config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图并过滤未计价别名,显示托管档位、展示名、扣点和需参考图提示;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `data/config.json` + `data/config/cmhub.json` 并回填控件。
|
||
- T-532 要求 `SettingsTab._on_cmhub_finished()` 从 worker payload 的 `balance` / user/account 字段提取 cmhub 账号身份,成功文案优先显示 `cmhub 账号「<账号名>」连接成功:...`;当前 `/balance` 结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,显示名优先 `account.display_name`,再兜底 `account.username` / `user` / 顶层常见字段;账号字段缺失时保持 `cmhub 连接成功:...`。显示名必须脱敏处理邮箱,且不得把 API Key、token 或完整敏感响应写入 GUI、run log 或诊断日志。
|
||
- `MainWindow` 已负责⑤设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到⑤。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。
|
||
- 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。
|
||
- 模型数据读写复用 `appconfig.list_ai_models(..., reveal_api_key=True)`、`add_ai_model()`、`update_ai_model()`、`delete_ai_model()`;保存时保留现有 `extra_body` 与 `timeout_seconds`。
|
||
- 密钥字段使用 `QLineEdit.Password` 打码显示;首次保存/变更非空 Key 前弹窗提示“本地明文保存”;明文只写入已 gitignore 的 `data/config/ai_models.json`,不得进入日志/导出。
|
||
- 删除按钮在当前类别只剩 1 个模型时禁用;后端仍以“至少启用一个 text 和 image 模型”为硬约束。
|
||
- 「测试连接」创建 `AIModelTestWorker` 后台调用 `appconfig.test_ai_model()`,GUI 主线程不直接发网络请求。
|
||
- 角色与生成参数读写 `config.json`,并按 3 个组件一组排列:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。
|
||
- 分辨率下拉固定 `512/1k/2k/4k`;普通默认 cmhub 模式下,返回超时标签只读展示实际等待口径「标题 600 秒 / 图片 900 秒」,分辨率只控制生成图片尺寸。direct 兼容路径仍使用 `resolution_timeouts[resolution]`。
|
||
- 路径与端口读写 `config.json`,并按 3 个组件一组排列:默认调试端口、调试端口范围、Chrome 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。
|
||
- 蝦皮更新执行读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:每批最大更新条数、同时更新蝦皮账号。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。
|
||
- 更新内容默认只更新标题。③ 左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面,开始前必须先通过缺失内容校验,并弹窗确认后才会创建更新 worker。
|
||
|
||
① 导入采集当前要点(T-202/T-202b):
|
||
|
||
- 「导入 Excel...」按钮调用 `excel.import_tasks(file_paths, path=db_path)`,导入成功后刷新当前批次任务。
|
||
- 导入汇总栏显示:文件数、解析行数、有效/无效、匹配、未匹配;匹配明细按账号展示。
|
||
- 「未匹配(n)」可点击筛出别名未匹配账号的任务;「全部」恢复完整列表。
|
||
- 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。
|
||
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
|
||
- 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。
|
||
- 「采集旧标题/旧封面」通过 `CollectWorker` 后台执行,只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 下载旧封面到 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg` 并 `db.set_collected()`;别名未匹配任务仍逐条 `mark_skipped`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。
|
||
- 采集打开商品页时,若本轮自动新建 tab,采集完成后会关闭该 tab,并在最多 2 秒内确认 target 已从 `/json` 消失;确认超时只写 warning/诊断,不把采集成功改成失败。若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
|
||
- 采集中途登录检测必须快速跳过正在销毁的旧商品 target,改连其他有效 Shopee 页面。Cookie API 调用失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,只有 Cookie API 成功返回空会话时才返回 `NO_SESSION_COOKIE`;两者都不按明确掉登录批量略过,显式 `LOGIN_PAGE` 仍按账号需登录处理。retry/recovered 运行日志包含当前任务 ID 和商品 ID,避免与上一条采集成功日志混淆。
|
||
- 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。
|
||
|
||
- 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。
|
||
- 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。
|
||
- 采集前由 `CollectWorker` 做账号就绪预检:无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;不无提示批量启动所有账号 Chrome。匹配账号未登录属于预检阻断,不是逐条 skipped。
|
||
|
||
② AI生成当前要点(T-302/T-302p/T-303/T-303b + 诊断日志补丁):
|
||
|
||
- 左右 `QSplitter`:左侧约 1/4 为标题提示词、封面提示词两个多行输入;右侧为筛选栏 + 任务列表。
|
||
- 标题提示词启动时从 `data/title_prompt.txt` 回显;点击「保存标题提示词」写回该文件;点击「插入旧标题」在光标处插入 `{旧标题}`。标题生成时若提示词含 `{旧标题}` 则替换且不重复追加旧标题,否则保持旧行为自动追加旧标题块。
|
||
- 封面提示词模板下拉读取 `data/prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。
|
||
- 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」使用当前选中任务(无选择则用第一条)调用 `prompts.render_prompt()` 并弹窗展示。
|
||
- 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。
|
||
- 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。
|
||
- 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
|
||
- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面。只生成封面时不调用生文,任务有新标题则使用新标题,否则使用已采集旧标题作为封面prompt回退;新旧标题都为空才不纳入。点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;通过 `GenerateWorker` 调 `ai.generate_batch()`,按当前 `ai.generate_mode` 并发标题和/或封面。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。
|
||
- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` / `db.set_generated_cover()` 组件结果保留,可再次开始生成剩余任务。
|
||
- 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, existing_cover_path)` 并进入 generated;封面成功立即调用 `db.set_generated_cover(task_id, new_cover_path)`,只更新封面、不覆盖或伪造新标题。只生成封面得到“有封面、无新标题”时,②标题状态继续待生成、图片状态已生成。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。
|
||
- T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。
|
||
|
||
③ 更新蝦皮当前要点(T-303b/T-401/T-402/T-403):
|
||
|
||
- `ApplyTab` 顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部。
|
||
- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前再按③「更新内容」模式校验是否缺少 `new_title` 或 `new_cover_path`。
|
||
- 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
|
||
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
|
||
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。
|
||
- 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、更新内容、会更新字段和略过原因。
|
||
- 点击「开始更新」先按③「更新内容」模式校验当前筛选任务:只更新标题必须有 `new_title`,只更新封面必须有 `new_cover_path`,更新标题和封面必须两者都有;缺失时弹「更新内容未生成」并阻断,不打开 Chrome、不写失败状态。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/任务总数、每批最大条数、预计批次数、提交线上风险和当前执行设置。
|
||
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
|
||
- `ApplyWorker` 只处理当前筛选结果里 `stage=generated`、状态为 `success/pending/failed`,且满足当前 `update_mode` 所需内容的任务;只更新标题时不替换封面,只更新封面时不改标题。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
|
||
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
|
||
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
|
||
- 预检通过后默认串行;若 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
|
||
- 真实更新打开商品页失败时,`ApplyWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案上浮到③可见运行日志、`run_log_events` 和任务失败原因;若失败发生在 `open_product()` 内部,本轮自动新建 tab 要关闭,复用用户已有 tab 不关闭;本地 `data/logs/` 可保存失败现场 HTML/toast JSON 片段供开发排查,但必须脱敏。
|
||
|
||
- 检查和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;点击「检查本轮更新」或「开始更新」时先清空 `ApplyTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;③ 页面不自动把上一轮历史日志混入当前运行界面。
|
||
- `editor.apply_task()` 对本轮自动新开的商品页成功/失败都关闭,成功提交后关闭前等待 2 秒;复用用户已有 tab 不关闭。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。
|
||
- 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。
|
||
- T-404a/T-508 已实现:「重置更新状态」从底部批处理按钮移到任务表右键菜单,读取当前选中单条,运行中禁用;确认后保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 供重复更新;`committed=1` 时必须提示线上已提交过且不回滚蝦皮,并保留 committed 历史事实/运行日志。
|
||
- ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。
|
||
- 更新完成后自动调用 `WriteBackWorker(mode="results")` → `excel.write_back_results()`,把新标题、新封面路径、更新状态写回原 Excel;原文件被占用时提示关闭后点击「回写结果到 Excel」手动重试,SQLite 更新结果不回滚。
|
||
- 自动结果回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数;若没有可回写批次,也会弹出更新汇总。
|
||
|
||
## workers 模块(`app/workers.py`,已建,PySide6)
|
||
|
||
```python
|
||
QT_IMPORT_ERROR: Exception|None
|
||
class WorkerError(RuntimeError): ...
|
||
|
||
# worker 约定
|
||
class BaseWorker(QObject):
|
||
progress = Signal(dict) # {"done": int, "total": int, ...}
|
||
row_updated = Signal(int, dict) # task_id, changed fields
|
||
log = Signal(str)
|
||
failed = Signal(int, str) # task_id, error
|
||
finished = Signal(dict) # summary
|
||
cancelled = Signal(dict)
|
||
def cancel(self) -> None: ...
|
||
def is_cancelled(self) -> bool: ...
|
||
def should_cancel(self) -> bool: ...
|
||
def execute(self) -> dict|None: ... # 子类实现;不要直接操作 QWidget
|
||
def run(self) -> None: ... # QThread.started 触发,统一发 terminal signal
|
||
|
||
run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread
|
||
# moveToThread + 绑定 started/finished/cancelled + 收尾 deleteLater;
|
||
# 默认立即 start;测试或调用方需要先连额外信号时可 start=False 后手动 thread.start()
|
||
```
|
||
|
||
要点:
|
||
|
||
- GUI 线程只操作 Qt widget;后台 worker 不直接访问 QWidget。
|
||
- `workers.py` 不导入 `QtWidgets`;业务 worker 子类只通过 signal 回传 UI 所需数据。
|
||
- 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。
|
||
- 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。
|
||
- ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。
|
||
- `ApplyWorker` 支持检查、默认串行和按账号并行;真实更新按 `max_items_per_run` 分批调用 `editor.apply_task(...)`,逐条 `set_applied()`,失败继续;账号未就绪或端口冲突时整体阻断并引导④,不进入逐条提交,也不静默启动账号 Chrome。点击停止为协作式停止:当前商品完成后不再开始新商品或下一批。
|
||
- `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。
|
||
- `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。
|
||
|
||
## 启动入口
|
||
|
||
```python
|
||
# main.py
|
||
from app.gui import main
|
||
|
||
raise SystemExit(main())
|
||
|
||
# app/__main__.py
|
||
from .gui import main
|
||
|
||
raise SystemExit(main())
|
||
```
|
||
|
||
正式运行入口:`python main.py`;开发/包入口:`python -m app`。
|
||
|
||
## CLI / 触发合约(现有脚本,过渡期保留)
|
||
|
||
```bash
|
||
python prototypes/demo.py # 单账号闭环:改标题+换封面,不提交
|
||
set AUTO=1 && python prototypes/demo.py
|
||
set UPDATE=1 && python prototypes/demo.py # 走完点击「更新」提交
|
||
```
|
||
|
||
环境变量:`CDP_HOST`、`ITEM_ID`、`IMG_WIN`、`NEW_TITLE`、`AUTO`、`UPDATE`。
|
||
|
||
## 待实现时确认
|
||
|
||
- AI 服务商/模型/计费;图像 image-to-image 能力与合规。
|
||
- 删除线上第一张封面已在测试商品 `29671243750` 上实测不提交流程;当前代码只在可见 dialog/modal/popover 内点击删除/确认类按钮,并保留“旧封面备份缺失则拒绝删除”的保护。
|
||
- 旧封面下载的图片格式/扩展名处理。
|