Files
cmshoppe/docs/api.md
T
chengma 5390debf40 feat: 完成T-205账号就绪引导保护
CollectWorker 增加采集前账号就绪预检:无账号、匹配账号 CDP 端口未响应或未登录时返回 blocked,不进入逐条采集,也不写 skipped/failed。

CollectTab 收到 blocked 后弹窗汇总原因,并跳转/引导到④账号管理配置账号、启动对应 Chrome、人工登录;MainWindow 为①传入打开④的回调。

新增 GUI 测试覆盖无账号、Chrome 未启动、未登录和弹窗引导;同步 T-205 状态、api、current-state 与 progress。
2026-06-27 15:00:15 +08:00

22 KiB
Raw Blame History

模块 / 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;config.json、config/ai_models.json、cmshopee.db、chrome_user_data_dir/、images/ 必须 gitignore;UI 打码显示,不出现在日志/导出。
  • 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。

appconfig 模块(app/appconfig.py,已建)

读写 config.json(schema 见 架构 5.1)。

class ConfigError(RuntimeError): ...
default_config() -> dict
load_config(path="config.json") -> dict      # 不存在则写默认
save_config(config, path="config.json") -> dict
update_config(updates, path="config.json") -> dict
chrome_path(config=None) -> str
user_data_root(config=None) -> str
image_dir(config=None) -> str
db_path(config=None) -> str
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/
                                             # title_concurrency/image_concurrency/retry/jpg_quality/
                                             # resolution/resolution_timeouts
response_timeout(config=None) -> int           # = resolution_timeouts[resolution](返回超时,随分辨率)

config.json 不保存 AI Key;写入 api_key / *_key / token / *_token / password / *_password 等敏感字段时抛 ConfigError。AI Key 留给 config/ai_models.json。

AI 模型清单(config/ai_models.json,含本地明文密钥,已建;UI 由 ⑤ 设置复用):

default_ai_models_config() -> dict
load_ai_models_config(path="config/ai_models.json") -> dict  # 不存在则写默认,至少 text/image 各一个
save_ai_models_config(config, path="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;删到剩一禁用
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,调用方不得写日志或导出。test_ai_model() 不记录密钥;缺少 url/model/api_key 时直接返回 {ok: False, error: ...}。

db 模块(app/db.py,已建)

SQLite 读写,表见 架构 5.2。

class DbError(RuntimeError): ...
Batch / Account / Task                       # dataclass,字段同 SQLite schema
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) -> list[Task]
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
set_applied(task_id, committed, error=None) -> None            # 成功 stage=applied;失败 status=failed 且 stage 不前进

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,导入与旧字段回写已建;结果回写待 T-403,依赖 openpyxl)

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, ...]}
export_copy(batch_id, out_dir_or_path, path=None) -> dict
# 退路:另存带旧字段的副本,不动原文件;目录输出时生成 *_cmshopee回写.xlsx
# T-403 会在此基础上扩展新标题/新封面/更新状态回写。

列模板见 架构 5.3;别名以“别名”列为权威,必须与 ④ 账号管理中的账号别名一致。shopee待处理任务模板.xlsx 是可提交的标准空模板;运营填写后的 Excel 副本属于业务数据,不提交。

config 模块(app/config.py,已建)

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,不自动登录/填密码。

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) -> subprocess.Popen
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() 只启动带该账号 user-data-dir 和 CDP 端口的 Chrome;不会读取、填写或提交密码。
  • create_shortcut() 生成 .lnk,目标/参数复用 chrome.build_launch_args(),不包含密码。
  • detect_login() 复用 editor.login_status();检测为已登录时更新 last_login_at。

chrome 模块(app/chrome.py,已建)

class ChromeLaunchError(RuntimeError): ...
build_launch_args(account, config=None) -> list[str]
# chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir
launch_chrome(account, config=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。端口探测访问 /json/version,显式禁用环境代理。create_shortcut() 使用 PowerShell WScript.Shell.CreateShortcut 生成 .lnk,TargetPath 为 Chrome,Arguments 含 --remote-debugging-port、--remote-allow-origins=*、--user-data-dir=<该账号目录>。

cdp 模块(app/cdp.py,T-000 由根目录 cdp.py 迁入)

CDP_HOST: str
http_get(path, host=None); find_product_tab(item_id, host=None); create_tab(url, host=None)
class CDP: send/ev/val/object_id/drag/close   # suppress_origin、trust_env=False

editor 模块(app/editor.py,已建,重构自现有脚本)

login_status(account, timeout=8) -> dict     # {logged_in, reason, url, host, cookie_names}
is_logged_in(account) -> bool                # login_status(...).logged_in;重定向登录页或缺 SPC_ST/SPC_U → False
open_product(account, item_id) -> CDP        # 连端口、导航商品页、等就绪

# 采集(只读)
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}

# 应用
change_title(cdp, new_title) -> dict         # {ok, value, modelvalue},要求三者相等
replace_cover(cdp, image_win_path) -> dict   # 上传→等CDN→拖第一位;满9张暂返回 FULL_IMAGE_SLOTS,删除流程留给 T-502
click_update(cdp) -> dict                    # {clicked, reason};禁用则记失败
apply_task(account, task) -> dict            # 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已做批量确认)
                                             # -> {committed, error}

login_status() 不自动登录;无 Shopee tab 时打开卖家中心根地址 https://<region_host>/(默认 https://seller.shopee.tw/)用于检测/人工登录。判断规则:最终 URL 是登录页 → LOGIN_PAGE;缺少 SPC_ST/SPC_U → NO_SESSION_COOKIE;有会话 Cookie → 已登录。

ai 模块(app/ai.py,待建,外部 AI,服务商待定)

gen_title(title_prompt, old_title, retry=2) -> str
# 文本生成:提示词 + 旧标题 → 新标题

gen_cover(cover_prompt, old_cover_path, out_path, resolution, jpg_quality, retry=2) -> str
# 图像生成(image-to-image):提示词 + 旧封面 → 新封面,按 resolution 生成、jpg_quality 存盘,返回路径

generate_batch(tasks, prompts, ai_cfg, on_progress, should_stop) -> None
# 编排:先以 title_concurrency 线程池并发跑 gen_title,再以 image_concurrency 并发跑 gen_cover
# 每条完成即 db.set_generated(实时落库);should_stop() 为真则取消未开始项
# on_progress(标题完成数, 封面完成数, 失败数) 回调刷新进度

要点:

  • 标题用 default_text_model、封面用 default_image_model(appconfig.get_model 取定义,含 url/key/api_type)。
  • 连接超时 = 模型 connect_timeout_seconds;返回超时 = appconfig.response_timeout()(随分辨率:512/1k/2k/4k → 180/240/360/600)。
  • 并发数/重试/分辨率/jpg 质量来自 appconfig.ai_config();Key 本地明文存储,但不入日志、不导出。
  • 标题快、图片慢:分两段、各用各自并发数;失败按 retry 重试,仍失败记 error 不阻塞其余。
  • 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。
  • 生成结果直接进入 ③ 更新候选;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。

prompts 模块(app/prompts.py,待建)

# 标题提示词:单文件
load_title_prompt(path="title_prompt.txt") -> str        # 启动回显;缺失返回 ""
save_title_prompt(text, path="title_prompt.txt") -> None # 「保存」按钮

# 封面提示词:多模板(prompts/cover/<名称>.txt)
list_cover_templates() -> list[str]                      # 模板名列表(下拉用)
load_cover_template(name) -> str
save_cover_template(name, text) -> None                  # 保存 / 另存为
rename_cover_template(old, new) -> None                  # 重名校验,重复则报错
delete_cover_template(name) -> None                      # 删除(二次确认由 GUI 负责)

# 变量替换
render_prompt(template_text, task) -> str
# 占位符 {旧标题}/{新标题}/{商品id}/{店铺} → 该任务真实值;预览与生成时调用

要点:

  • 「插入标题」在封面提示词光标处插入 {新标题};「预览」对选中任务调用 render_prompt 后展示。
  • 生成封面时 gen_cover 的 prompt = render_prompt(当前封面模板, task)。
  • 模板与 title_prompt.txt 均为可手改的纯文本文件。

gui 模块(app/gui.py,已建,PySide6)

# GUI 入口
main() -> int                              # 创建 QApplication + MainWindow
class MainWindow(QMainWindow)              # QTabWidget: ①②③④⑤;支持注入 db_path/config 便于测试
class CollectTab(QWidget)                  # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
class CollectWorker(BaseWorker)            # ① 后台采集:登录检测 -> editor.collect -> db.set_collected/mark_skipped/mark_failed
class WriteBackWorker(BaseWorker)          # ① 后台回写:excel.write_back(batch_id) 写旧标题/旧封面到原 Excel
class TaskTableModel(QAbstractTableModel)  # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
class AccountsTab(QWidget)                 # ④ 账号管理:表格 + 增删改 + 启动登录 + 检测登录 + 快捷方式
class AccountDialog(QDialog)               # 账号编辑弹窗;密码 QLineEdit.Password
TAB_TITLES: list[str]                      # 固定 Tab 顺序
TAB_STYLE: str                              # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态

MainWindow 已实现五 Tab、① 导入采集任务列表与 ④ 账号管理;②③⑤ 仍为空壳,业务控件按后续 Tab 任务逐步填充。缺 PySide6 时 main() 返回 1 并输出明确提示。

主 Tab 栏必须在 MainWindow 初始化时应用 TAB_STYLE:5 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。

④ 账号管理要点:

  • 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。
  • 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示。
  • 「启动登录」只启动 Chrome,人工登录;「检测登录」用 worker 跑 accounts.detect_login() 并刷新状态列;「快捷方式」调用 accounts.create_shortcut() 生成默认桌面 .lnk。

① 导入采集当前要点(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 的任务;每条先检测登录,未登录写 mark_skipped,已登录则下载旧封面到 image_dir/<slug>/<item_id>_old.jpg 并 db.set_collected();单条失败 mark_failed(..., "collect", error) 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。
  • 「停止」调用 worker 的协作式 cancel(),已开始的单条跑到安全边界后结束。
  • 「回写旧数据到 Excel」通过 WriteBackWorker 后台调用 excel.write_back(),把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。
  • 采集前由 CollectWorker 做账号就绪预检:无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 blocked=True,GUI 弹窗汇总并跳转/引导去④账号管理;不无提示批量启动所有账号 Chrome。

workers 模块(app/workers.py,已建,PySide6)

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。
  • execute() 未捕获异常会发 failed(-1, error) 与 finished({"ok": False, "error": ...});普通单行失败由业务 worker 自己发 failed(task_id, error) 后继续处理。

启动入口

# 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 / 触发合约(现有脚本,过渡期保留)

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 能力与合规。
  • 满 9 张删除封面的确认框选择器(需实测)。
  • 旧封面下载的图片格式/扩展名处理。