commit 479d02a2b8003b681e0557d936b859dbdf16908f Author: chengma Date: Fri Jun 26 15:30:37 2026 +0800 docs: 初始化 cmshopee 文档、设计与项目骨架 - docs/ 完整 harness coding 文档集(愿景/需求/技术栈/架构/编码规则/任务/api/routes/current-state) - 5 Tab 流水线设计 + UI 效果图 SVG(docs/ui/) - cdp.py CDP 底座;prototypes/ 已验证原型脚本(待 editor.py 移植后清理) - AGENTS.md/CLAUDE.md 入口、progress.md 执行流水、.gitignore(排除凭证/DB/图片) Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..699cd0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# ── 凭证 / 业务数据 / 本地产物(绝不提交)── +# AI 模型清单 ai_models.json(含密钥) +config/ +# 应用配置(路径/模型选择/参数) +config.json +# SQLite(账号/任务/结果,含密码) +cmshopee.db +# 各账号 Chrome 配置(含登录态) +chrome_user_data_dir/ +# 采集的旧封面 / AI 生成的新封面 +images/ + +# ── Python ── +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +env/ + +# ── 编辑器 / 系统 ── +.vscode/ +.idea/ +.DS_Store +Thumbs.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1c3c626 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# AGENTS.md + +> Codex / 通用 AI coding agent 的仓库级入口。进入本仓库后,先读本文,再进入 [`docs/00-ai-start-here.md`](docs/00-ai-start-here.md)。 + +## 项目定位 + +cmshopee 是一个 **Windows 本地桌面自动化工具**:管理多个 Shopee 卖家账号,用 CDP 驱动 Chrome 自动改商品标题、换商品封面。当前单账号流程已验证,正在扩展多账号管理与 GUI。 + +## 文档位置 + +- 项目文档集合在 [`docs/`](docs/),导航见 [`docs/README.md`](docs/README.md)。 +- 编程入口是 [`docs/00-ai-start-here.md`](docs/00-ai-start-here.md),它继续导航到愿景、需求、技术栈、架构、编码规则、任务看板。 +- 执行历史流水在根目录 [`progress.md`](progress.md);当前快照在 [`docs/current-state.md`](docs/current-state.md)。 + +## 必读顺序 + +每次开始工作前,按顺序读取: + +1. 本文 `AGENTS.md`:仓库定位与规则。 +2. [`docs/00-ai-start-here.md`](docs/00-ai-start-here.md):编程入口与流程。 +3. [`docs/04-architecture.md`](docs/04-architecture.md):尤其**第四节 CDP 已验证事实**。 +4. [`docs/05-coding-rules.md`](docs/05-coding-rules.md):硬性编码规则。 +5. [`docs/06-tasks.md`](docs/06-tasks.md) + [`docs/current-state.md`](docs/current-state.md):领取任务、了解现状。 +6. 与当前任务相关的具体文档。 + +## 工作规则 + +- 复用已有 `cdp.py`,遵守架构第四节的选择器、就绪判断、上传/拖拽方式,不另起一套 CDP 交互。 +- 只做当前任务范围内的事;V2(批量、多账号并行)只记录不实现。 +- CDP 选择器 / 流程 / 配置 schema 变化,必须同步更新 `docs/04-architecture.md` 与相关任务。 +- 改完后更新任务状态、追加 `progress.md`、覆盖 `docs/current-state.md`。 + +## 安全红线 + +- 绝不把真实账号、密码、Cookie、token 写进代码、文档或日志;密码加密存、不自动登录。 +- `config.json`、`cmshopee.db`、`chrome_user_data_dir/`(含密码/登录态)绝不提交版本库。 +- 「更新」提交线上、删除满 9 张封面等不可逆动作,必须显式确认/开关,并先在测试商品验证。 +- 不绕过 Shopee 的验证码、风控、限流或权限校验。 + +## 验证 + +```bash +python -m py_compile *.py # 语法检查 +python prototypes/demo.py # 单账号闭环(不提交) +``` + +涉及 CDP 改动,在测试商品(ITEM_ID 51100639510)上实跑确认;如命令不可运行,在回复里如实说明。 + +## 风格 + +- 文档与 UI 文案用中文,标识符用英文。 +- 内容面向 agent 执行:能落到“读什么、改什么、验证什么”。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..973d1f5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,13 @@ +# CLAUDE.md + +> Claude Code 的仓库级薄入口。进入本仓库后,先读本文,再读 [`AGENTS.md`](AGENTS.md)。 + +本仓库的权威 agent 规则、文档入口、工作边界和验证方式统一维护在 [`AGENTS.md`](AGENTS.md)。 + +Claude Code 处理本仓库任务时: + +1. 先读取 [`AGENTS.md`](AGENTS.md)。 +2. 再按 `AGENTS.md` 进入 [`docs/00-ai-start-here.md`](docs/00-ai-start-here.md) 和相关文档(重点 `docs/04-architecture.md` 第四节 CDP 已验证事实)。 +3. 不在本文重复维护任务流程、编码规则或文档清单,避免和 `AGENTS.md` 漂移。 + +安全红线(详见 `AGENTS.md`):不写真实凭证、密码加密存不自动登录;不提交 `config.json`、`cmshopee.db`、`chrome_user_data_dir/`;不可逆操作需显式确认;不绕过 Shopee 风控。 diff --git a/cdp.py b/cdp.py new file mode 100644 index 0000000..81a6688 --- /dev/null +++ b/cdp.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# cdp.py —— 共享 CDP 底座:连接 Chrome、定位/打开商品 tab、执行 JS、模拟拖拽。 +# 被 demo.py 及各能力脚本复用。 +# +# 依赖:pip install websocket-client requests + +import os + +# CDP 局域网直连,绝不走代理(WSL 里 *_proxy 指向 :1080 会导致超时)。 +for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"): + os.environ.pop(_k, None) + +import json +import time +import threading + +import requests +from websocket import create_connection + +CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333") +BASE = f"http://{CDP_HOST}" + + +def http_get(path): + s = requests.Session() + s.trust_env = False # 忽略环境代理 + return s.get(f"{BASE}{path}", timeout=10).json() + + +class CDP: + """单个 target 的 CDP 客户端:命令同步、事件回调异步。""" + + def __init__(self, ws_url): + # suppress_origin=True:不发 Origin 头,避免 Chrome 未开 --remote-allow-origins 时 403。 + self.ws = create_connection(ws_url, max_size=None, suppress_origin=True) + self._id = 0 + self._res = {} + self._cond = threading.Condition() + self._handlers = [] + self._stop = False + threading.Thread(target=self._loop, daemon=True).start() + + def _loop(self): + while not self._stop: + try: + raw = self.ws.recv() + except Exception: + break + if not raw: + continue + m = json.loads(raw) + if "id" in m: + with self._cond: + self._res[m["id"]] = m + self._cond.notify_all() + elif "method" in m: + for h in self._handlers: + try: + h(m) + except Exception: + pass + + def on(self, fn): + self._handlers.append(fn) + + def send(self, method, params=None, timeout=30): + self._id += 1 + mid = self._id + self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}})) + end = time.time() + timeout + with self._cond: + while mid not in self._res: + left = end - time.time() + if left <= 0: + raise TimeoutError(f"CDP {method} 超时") + self._cond.wait(left) + m = self._res.pop(mid) + if "error" in m: + raise RuntimeError(f"{method}: {m['error'].get('message')}") + return m.get("result", {}) + + def ev(self, expr, by_value=True): + r = self.send("Runtime.evaluate", {"expression": expr, "returnByValue": by_value}) + if "exceptionDetails" in r: + raise RuntimeError("JS 异常: " + r["exceptionDetails"].get("text", "")) + return r.get("result", {}) + + def val(self, expr): + return self.ev(expr).get("value") + + def object_id(self, expr): + return self.ev(expr, by_value=False).get("objectId") + + def drag(self, x0, y0, x1, y1, steps=34): + """按住源点 → 多步移动到目标点 → 释放,驱动自定义拖拽排序库。""" + me = "Input.dispatchMouseEvent" + self.send(me, {"type": "mouseMoved", "x": x0, "y": y0}) + self.send(me, {"type": "mousePressed", "x": x0, "y": y0, "button": "left", "buttons": 1, "clickCount": 1}) + for dx in (3, 6, 10): # 小幅抖动,越过拖拽启动阈值 + self.send(me, {"type": "mouseMoved", "x": x0 + dx, "y": y0, "button": "left", "buttons": 1}) + time.sleep(0.03) + for i in range(1, steps + 1): + x = x0 + (x1 - x0) * i / steps + y = y0 + (y1 - y0) * i / steps + self.send(me, {"type": "mouseMoved", "x": x, "y": y, "button": "left", "buttons": 1}) + time.sleep(0.025) + for _ in range(10): # 在目标点停留,让排序库吸附 + self.send(me, {"type": "mouseMoved", "x": x1, "y": y1, "button": "left", "buttons": 1}) + time.sleep(0.05) + self.send(me, {"type": "mouseReleased", "x": x1, "y": y1, "button": "left", "buttons": 0, "clickCount": 1}) + + def close(self): + self._stop = True + try: + self.ws.close() + except Exception: + pass + + +def find_product_tab(item_id): + """在已打开的 tab 里找 URL 同时含 item_id 和 shopee.tw 的页面。""" + for t in http_get("/json"): + if t.get("type") != "page": + continue + url = t.get("url") or "" + if item_id in url and "shopee.tw" in url: + return t + return None + + +def create_tab(url): + """用 browser 级 Target.createTarget 新建 tab,返回其 page websocket。""" + ver = http_get("/json/version") + b = CDP(ver["webSocketDebuggerUrl"]) + try: + tid = b.send("Target.createTarget", {"url": url})["targetId"] + finally: + b.close() + end = time.time() + 15 + while time.time() < end: + for t in http_get("/json"): + if t.get("id") == tid and t.get("webSocketDebuggerUrl"): + return t["webSocketDebuggerUrl"] + time.sleep(0.5) + raise TimeoutError("等待新 tab websocket 超时") diff --git a/demo.bat b/demo.bat new file mode 100644 index 0000000..fbe6834 --- /dev/null +++ b/demo.bat @@ -0,0 +1,6 @@ +@echo off +rem 单账号原型演示:脚本已移到 prototypes/,设 PYTHONPATH 让根目录 cdp.py 可导入 +cd /d %~dp0 +set PYTHONPATH=%~dp0 +python prototypes\demo.py +pause diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md new file mode 100644 index 0000000..ddd04d7 --- /dev/null +++ b/docs/00-ai-start-here.md @@ -0,0 +1,116 @@ +# AI 开发入口 + +> 给 AI coding agent 的项目入口。这里负责导航和流程,硬性编码规则见 [`05-coding-rules.md`](05-coding-rules.md)。 + +## 一句话定位 + +cmshopee 是一个 Windows 本地桌面自动化工具(Tkinter,5 Tab),让运营管理多个 Shopee 账号,并用 CDP 驱动 Chrome + AI 批量改商品标题、换商品封面。 + +5 Tab 流水线(工作流优先顺序): +**① 导入采集 → ② AI生成 → ③ 更新shopee → ④ 账号管理 → ⑤ 设置** + +目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(无人工确认)→ ③ 对已生成任务改标题+换封面并直接点「更新」提交(无开关)→ 结果实时存 SQLite、批量回写原 Excel。 + +存储:应用设置 `config.json`(含 AI Key)+ 业务数据 SQLite `cmshopee.db` + Excel 用 openpyxl + 图片存本地 `images/`。AI 服务商待定。 + +## 必读顺序 + +每次开始写代码前,按这个顺序建立上下文: + +1. [`01-vision.md`](01-vision.md):为什么做、为谁做、什么不做。 +2. [`02-requirements.md`](02-requirements.md):MVP 要什么、怎么算达成。 +3. [`03-tech-stack.md`](03-tech-stack.md):既定技术选型(Python + 自研 CDP + Tkinter 待确认)。 +4. [`04-architecture.md`](04-architecture.md):模块职责、账号数据模型、**第四节 CDP 已验证事实(重点)**。 +5. [`05-coding-rules.md`](05-coding-rules.md):写代码前必须遵守的规则。 +6. [`06-tasks.md`](06-tasks.md):领取本轮唯一任务。 +7. [`../progress.md`](../progress.md):历史执行记录、验证结果、阻塞点和关键决策。 +8. [`current-state.md`](current-state.md):当前代码现实、可运行命令、下一步任务。 + +如果仓库根目录有 `AGENTS.md`、`CLAUDE.md`,也必须先读,仓库级规则优先。 + +## 当前阶段 + +当前项目处于:**单账号流程已验证,正扩展多账号管理 + GUI**。 + +优先路径: + +1. Phase 0:把已验证流程模块化(`editor.py`),定义账号配置。 +2. Phase 1:账号绑定 user-data-dir、Chrome 启动、登录保活、加载商品页。 +3. Phase 2:GUI 跑通单账号改标题 + 换封面闭环。 +4. Phase 3:批量与多账号并行(V2)。 + +## 领取任务规则 + +从 [`06-tasks.md`](06-tasks.md) 领取任务时: + +- 只领取第一个状态为 `TODO` 且依赖均为 `DONE` 的任务。 +- 开始前把该任务状态改为 `DOING`。 +- 本轮只完成这一个任务。 +- 验收通过后把状态改为 `DONE`。 +- 完成后把执行记录追加到 [`../progress.md`](../progress.md),覆盖更新 [`current-state.md`](current-state.md)。 +- 做完即停,汇报验证结果,等待下一步指令。 + +如果代码实际状态和任务看板冲突,先说明冲突,不要擅自跳步。 + +## MVP 边界 + +MVP 只做: + +- 账号配置(增删改 1..N 个 Shopee 账号)。 +- 每账号绑定 `chrome_user_data_dir/` 独立配置目录。 +- 一键启动 Chrome 供首次人工登录、登录态持久化。 +- 用某账号打开指定商品详情页,改标题、换封面。 +- 显式确认后点击「更新」提交(默认不提交)。 + +MVP 不做: + +- 自动登录 / 自动填账号密码。 +- 绕过验证码、风控、限流。 +- 批量、多账号并行、爬取、数据库(属 V2 及之后)。 + +## 事实来源 + +项目事实只信: + +- [`04-architecture.md`](04-architecture.md) 第四节:CDP 交互已验证结论(选择器、就绪判断、上传/拖拽方式)。 +- [`04-architecture.md`](04-architecture.md) 4.1/4.2/4.3:`config.json`、SQLite schema、Excel 模板。 +- 真实页面探查结果(用 `prototypes/inspect_images.py` / `prototypes/cookies.py` 实地确认)。 +- 已验证脚本 `cdp.py`、`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py` 中跑通的逻辑。 + +不要把以下当事实来源: + +- 旧脚本里可能已失效的选择器(Shopee 页面会变)。 +- 未经实测的猜测。 +- 临时探查脚本(`/tmp` 下的一次性脚本)。 + +## 常见任务该看哪里 + +做 GUI: + +- 先看 `02-requirements.md` 的对应验收标准。 +- 再看 `routes.md` 的窗口职责与操作流程。 +- 最后看 `04-architecture.md` 的模块边界(GUI 不写业务逻辑)。 + +做 CDP / 浏览器操作: + +- 先看 `04-architecture.md` 第四节已验证事实。 +- 再看 `api.md` 的 `cdp` / `editor` 模块合约。 +- 复用 `cdp.py`,不重写一套。 + +做账号配置 / Chrome 启动: + +- 先看 `04-architecture.md` 3.1 数据模型与 `api.md` 的 `config` / `chrome` 合约。 +- Chrome 启动参数严格按第四节“Chrome 启动参数”一条。 + +## 验证命令 + +```bash +python -m py_compile *.py # 语法检查 +python prototypes/demo.py # 单账号闭环验证(分步,不提交) +``` + +说明: + +- 改 CDP / editor 逻辑后:在测试商品(ITEM_ID 51100639510)上跑 `prototypes/demo.py` 实测。 +- 改账号配置后:验证 SQLite 账号读写与 user-data-dir 创建;改应用设置后验证 `config.json` 读写。 +- 如果命令当前不可运行(如 GUI 未建),在回复里如实说明。 diff --git a/docs/01-vision.md b/docs/01-vision.md new file mode 100644 index 0000000..b931e66 --- /dev/null +++ b/docs/01-vision.md @@ -0,0 +1,44 @@ +# 项目愿景 + +## 一、核心目标 + +cmshopee 要解决:**运营要在多个 Shopee 卖家账号下,逐个商品手动改标题、换封面,效率低、易出错、来回切换登录痛苦**。 + +> 让**电商运营**能够在一个工具里集中管理多个 Shopee 账号,并自动完成商品标题修改和封面图替换,无需反复登录、手动逐项操作。 + +它不是一个通用爬虫,也不是 Shopee 官方 API 客户端,而是一个**驱动已登录的真实 Chrome 浏览器、在卖家中心页面上代为操作**的本地自动化工具。 + +## 二、目标用户 + +- **电商运营 / 店铺管理员**:管理 1 个或多个 Shopee 卖家账号,需要批量调整商品标题和主图。 +- **小型卖家团队负责人**:希望把重复的商品维护操作标准化、半自动化。 +- **本项目维护者(含 AI agent)**:需要在已验证的单账号流程上,扩展出多账号管理与 GUI。 + +## 三、产品原则 + +遇到取舍时,以这些原则为准: + +- **核心流程优先**:先把「单账号改标题 + 换封面」做稳,再扩展多账号和批量。 +- **真实环境优先**:操作真实的已登录 Chrome,不伪造登录、不绕过风控。 +- **小步交付**:每一步都能运行、能验证、能回退;默认不提交线上,显式开关才落库。 +- **少即是稳**:MVP 不追求全自动,只追求最小可靠闭环;首次登录由人工完成。 +- **凭证安全**:登录态保存在各账号独立的 Chrome user-data-dir 里,不导出、不外传、不写进代码或文档。 + +## 四、核心价值主张 + +| 价值点 | 说明 | +| --- | --- | +| 多账号集中管理 | 一处配置多个 Shopee 账号,各自绑定独立浏览器配置目录,互不串号 | +| 免反复登录 | 每个账号的登录态持久化在自己的 user-data-dir,启动即带登录 Cookie | +| 自动改标题/换封面 | CDP 驱动浏览器自动改标题、上传新图并拖拽设为封面 | +| 可控可回退 | 默认只改不提交,显式确认/开关才点「更新」,演示与试错零风险 | + +## 五、不做什么(非目标) + +- 不做 Shopee 账号的**自动登录 / 自动填账号密码**(首次登录人工完成)。 +- 不**绕过验证码、风控、限流或平台权限校验**。 +- 不做商品数据的**批量爬取 / 导出**。 +- 不做云端服务、数据库、多人协作后台(MVP 为本地单机工具)。 +- 不接管标题/封面以外的商品字段编辑。 + +> MVP 的具体功能范围与验收标准,见 [需求](02-requirements.md)。 diff --git a/docs/02-requirements.md b/docs/02-requirements.md new file mode 100644 index 0000000..b2ef3a3 --- /dev/null +++ b/docs/02-requirements.md @@ -0,0 +1,112 @@ +# 需求 + +> 本文只描述**要什么**与**怎么算达成**,用产品 / 用户语言表达,**不涉及技术实现**。 +> 技术方案、数据结构、CDP 细节见 [架构设计](04-architecture.md)。 + +## 一、业务现状 + +| 项 | 状态 | +| --- | --- | +| 用户 | 电商运营,手动在多个 Shopee 卖家账号下逐个改标题、换封面,效率低 | +| 数据 | 商品数据在 Shopee 卖家中心;本工具不落库,只在浏览器页面上操作 | +| 现有系统 | 已用 CDP 验证单账号流程(改标题、上传图、拖拽换封面、可选更新),见 `current-state.md` | +| 约束 | 依赖真实 Chrome 与已登录态;受 Shopee 页面结构、限流、风控影响;首次登录需人工 | + +## 二、用户角色 + +- **运营操作员**:配置账号、为账号登录、对选定商品执行改标题/换封面。 +- **未登录账号**:已在配置中但其 user-data-dir 尚未登录 Shopee;执行任务前必须先人工登录。 + +## 三、功能清单 + +### 第一版 MVP(最小闭环) + +| 功能 | 用户能做什么 | 优先级 | +| --- | --- | --- | +| 账号配置 | 在 GUI 里新增/编辑/删除 1 个或多个 Shopee 账号(名称、地区、备注) | P0 | +| 绑定配置目录 | 为每个账号在项目 `chrome_user_data_dir/<账号标识>` 下创建并绑定独立 user-data-dir | P0 | +| 启动并登录 | 一键用某账号的 user-data-dir 启动 Chrome(带调试参数),首次人工登录 Shopee 后登录态持久化 | P0 | +| 生成快捷方式 | 可选:为每账号生成桌面快捷方式,目标带该账号 user-data-dir,供手动打开对应账号 Chrome | P1 | +| 加载商品页 | 选定账号,用其已登录 Chrome 打开指定商品详情页 | P0 | +| 改标题 | 对该商品按规则修改标题(如去掉结尾若干字 / 指定新标题) | P0 | +| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时先删原封面再传) | P0 | +| 提交更新 | 在显式确认后点击「更新」,把改动提交到线上(默认不提交) | P0 | + +### 第二版(多账号 + Excel + AI,5 Tab 流水线) + +5 个 Tab,顺序:① 导入采集 → ② AI生成 → ③ 更新shopee → ④ 账号管理 → ⑤ 设置。 + +| 功能 | 用户能做什么 | 优先级 | +| --- | --- | --- | +| 账号管理(④) | 增删改账号:账号名/别名/地区/数据目录/端口/密码(加密仅参考)/备注;登录状态;启动登录、检测登录 | P0 | +| 导入(①) | 导入多个 Excel,按模板解析输入列(账号名/别名/商品id)为任务列表 | P0 | +| 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 | +| 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 | +| AI 生成(②) | 输入标题/封面提示词,AI 据提示词+旧标题生成新标题、据提示词+旧封面生成新封面 | P0 | +| 提示词管理(②) | 标题提示词「保存」到 `title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 | +| 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无人工确认);可选对单行重生成 | P0 | +| 更新 shopee(③) | 按批次/店铺/状态筛选;对筛选出的已生成任务打开编辑页换标题+封面,并直接点「更新」提交线上(无开关);可按状态=失败重试 | P0 | +| 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 | +| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,返回超时随分辨率自动);并发/重试/jpg质量;图片目录/Chrome 路径/端口 | P0 | +| 首次引导保护 | 未配账号/未登录时,① ③ 执行按钮禁用并提示去④ | P0 | + +### 后续迭代 + +| 功能 | 描述 | 阶段 | +| --- | --- | --- | +| 多账号并行 | 多个账号各自端口同时运行 | V2 | +| 运行日志 / dry-run | 操作留痕、可先空跑预览 | V2 | +| 标题规则模板 | 预设多种改标题规则(前缀、替换、截断等) | V3 | + +## 四、核心用户故事(MVP) + +1. 作为运营,我打开工具后能看到已配置的账号列表,并能新增一个账号。 +2. 我为某账号点击「启动并登录」,工具用它专属的配置目录打开 Chrome,我手动登录一次 Shopee 卖家中心。 +3. 之后我选中该账号、填入商品 ID,工具自动打开该商品详情页(带登录态)。 +4. 工具自动把标题改成目标值,并上传我指定的图片、把它拖到第一位设为封面。 +5. 我核对无误后点击「更新」(或让工具在确认后点击),改动提交线上;不确认则刷新即还原。 +6. 当账号尚未登录、商品页加载失败或封面已满 9 张时,工具给出明确提示并安全处理。 + +## 五、验收标准(MVP) + +- **账号配置**:新增/编辑/删除账号后,配置持久化到本地文件,重启工具仍在。 +- **绑定配置目录**:每个账号对应唯一的 `chrome_user_data_dir/<账号标识>` 目录;不同账号互不共用、不串号。 +- **启动并登录**:点击后 Chrome 用对应 user-data-dir 启动并开启调试端口;人工登录一次后,再次启动无需重新登录。 +- **加载商品页**:选定账号执行时,能用该账号登录态打开目标商品详情页,标题框与图片管理器渲染就绪。 +- **改标题**:写入后标题框 `value` 与 `modelvalue` 均等于目标值(确认页面模型已更新)。 +- **换封面**:上传成功后图片张数 +1(满 9 张时先删第一张再传);目标图被拖到第一位成为封面。 +- **提交更新**:默认不点「更新」;开启提交时,按钮可点才点击,禁用态则不点并提示原因。 +- **安全**:任意失败路径不崩溃、不误删、不在无确认下提交线上。 + +## 六、范围边界与决策 + +| 问题 | 决策 | +| --- | --- | +| 第一版平台 | Windows 桌面(Chrome 与各 user-data-dir 同机) | +| 多账号隔离方式 | 每账号独立 user-data-dir(非 Chrome profile);理由见 [架构 3.0](04-architecture.md) | +| 是否需要账号 | 是;管理多个 Shopee 卖家账号,但登录由人工首次完成 | +| 存储 | 应用设置用 `config.json`;账号/任务/结果用 SQLite;Excel 读写用 openpyxl | +| 账号↔任务绑定 | 以 Excel“别名”列为权威(非文件名);匹配不到的略过并最后弹窗汇总 | +| 是否提交更新 | ③ 执行时总是点「更新」提交线上(无人工确认、无开关) | +| 执行方式 | 多账号串行、逐任务执行,单条失败继续 | +| 旧标题/旧封面 | 程序在「采集」阶段改前抓取的快照(输出列),运营不填 | +| 新标题/新封面 | AI 生成(输出列),直接用于更新(无人工确认);本地留档+回写 Excel 供追溯 | +| AI 服务 | 文本+图像生成,服务商/模型待定;Key 加密存于配置;见 [技术栈](03-tech-stack.md) | +| 本地图片 | 旧封面下载、新封面生成存本地图片目录,路径记 DB | +| 结果落库时机 | 各阶段处理完立即写 SQLite;该 Excel 全部完成后批量回写原文件 | +| 原文件被占用 | 回写时若原 Excel 被锁定,提示关闭重试或另存副本(SQLite 为事实来源) | +| 暂不支持 | 自动登录、多账号并行(V2)、爬取 | + +## 七、待确认 / 风险点 + +- **账号 / 凭证风险**:登录态存在各账号 user-data-dir,等同账号凭证;目录不得提交版本库、不得外传。 +- **密码存储风险**:Tab2 的密码加密存于 SQLite,仅供人工参考,**绝不自动登录/自动填**;`cmshopee.db`、`config.json` 必须 gitignore;加密密钥不得与密文同存(见 [任务 T-403](06-tasks.md))。 +- **别名匹配风险**:别名以 Excel 列为准;不匹配的行略过并最后弹窗,执行起始在日志逐条打印「文件→匹配账号」留痕,防误改。 +- **AI 主图风险(高,已知并接受)**:本设计**无人工确认、无提交开关**,AI 生成的标题/封面经 ③ 执行即直接上线;主图若失真/夸大/侵权可能违反 Shopee 规则甚至下架。唯一缓解:新图本地留档 + 回写 Excel 供事后追溯;强烈建议先在测试商品验证再批量。 +- **AI 成本与依赖**:接入文本+图像 AI = 新外部依赖 + API 费用 + Key 管理;服务商/模型/合规待 [技术栈](03-tech-stack.md) 确认。 +- **AI Key 安全**:Key 加密存于 `config.json`,不写日志、不提交版本库。 +- **第三方平台风险**:Shopee 页面结构、class 名、接口随时可能变;限流、风控、封号风险存在,禁止高频批量。 +- **自动化边界风险**:③ 执行会自动改标题、上传图片、拖拽并**直接点更新提交线上**(无确认、无开关);点「开始更新」前需自行确保任务来源与 AI 产出可接受。 +- **合规风险**:仅在自有/授权账号上操作;遵守 Shopee 卖家条款;不绕过任何平台限制。 +- **GUI 选型待确认**:默认 Tkinter(零依赖),是否改用 PySide/Web 由维护者确认,见 [技术栈](03-tech-stack.md)。 +- **多账号并行待确认**:MVP 单账号单端口;并行的端口分配与资源占用在 V2 评估。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md new file mode 100644 index 0000000..bce847b --- /dev/null +++ b/docs/03-tech-stack.md @@ -0,0 +1,68 @@ +# 技术栈(Tech Stack) + +> “用什么”的统一速查表。选型与理由在此集中维护;“怎么把它们搭起来”见 [架构设计](04-architecture.md)。 +> 未定项必须标为待定,不要让 agent 在代码里自行决定。 + +## 一、技术栈一览 + +| 维度 | 选型 | 状态 | 理由 / 说明 | +| --- | --- | --- | --- | +| 语言 | Python 3.10+ | 已定 | 已有脚本均为 Python;标准库够用 | +| 运行平台 | Windows(生产);WSL 可用于开发 | 已定 | Chrome 与各 user-data-dir 在 Windows;GUI 与 Chrome 同机,CDP 走 `localhost` | +| 浏览器自动化 | 自研 CDP 客户端 `cdp.py` | 已定 | 基于 websocket-client + requests 手写;不引入 playwright/selenium,规避代理与 Origin 坑 | +| WebSocket | `websocket-client`(import `websocket`) | 已定 | 讲 CDP 协议;用 `suppress_origin=True` 绕过 403 | +| HTTP | `requests` | 已定 | 读 `/json` 拿 tab 列表;`trust_env=False` 忽略代理 | +| 浏览器 | Google Chrome(已安装) | 已定 | 带 `--remote-debugging-port` 启动 | +| GUI 框架 | Tkinter(标准库,`ttk.Notebook` 5 Tab) | 待定(推荐) | 零依赖、单机够用;如需更现代 UI 再评估 PySide6 / Web | +| 应用配置 | `config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 | +| 业务数据 | SQLite(stdlib `sqlite3`,`cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 | +| Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx | +| AI 模型注册 | `config/ai_models.json` 多模型清单(HTTP 调用) | 已定(结构) | 每模型 name/category(text/image)/url/model/key/api_type/连接超时;⑤ 设置可增删改+测试连接 | +| AI 文本生成 | 选 `default_text_model`(category=text) | 选型在配置 | 提示词+旧标题→新标题 | +| AI 图像生成 | 选 `default_image_model`(category=image,image-to-image) | 选型在配置 | 提示词+旧封面→新封面;分辨率 512/1k/2k/4k,返回超时随分辨率 | +| 并发 | 标准库 `concurrent.futures.ThreadPoolExecutor` | 已定 | 标题/图片分别按并发数并行;可停止、可重试 | +| 图片处理 | `requests`(下载)+ `Pillow`(按分辨率/jpg质量存盘) | 部分待定 | 下载旧封面;新封面按 resolution 生成、jpg_quality 存盘 | +| 测试 | `python -m py_compile` + 手动 CDP 验证 | 待完善 | 纯 UI/浏览器交互难以单测;关键逻辑(配置读写、参数拼装)可加 `pytest` | + +## 二、决策记录与演进 + +- **CDP 自研而非 playwright**:当前手写 `cdp.py`,因为它零重依赖、完全可控,并已在开发环境绕开了代理(`*_proxy` 指向本地 :1080)和 Chrome 的 Origin 403 两个坑。未来若交互复杂度大幅上升,再评估 playwright。 +- **GUI 选 Tkinter**:MVP 是单机配置 + 触发操作的轻量界面,Tkinter 零依赖即可。若后续需要表格、拖拽、复杂布局,再评估 PySide6。**该选型在动代码前需维护者确认。** +- **存储拆两层**:应用设置进 `config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。 +- **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。 +- **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。 +- **快捷方式生成用 PowerShell(无额外依赖)**:用 `WScript.Shell.CreateShortcut` 生成 `.lnk`,不引入 `pywin32` 等依赖。 +- **AI 服务商待定**:需选支持文本生成 + 图像 image-to-image 的服务;选型要权衡能力、合规(电商主图)、计费、Key 管理。**未确认前不在代码里写死某家 SDK**,先在 `ai.py` 留稳定接口(`gen_title`/`gen_cover`)。 +- **AI 产出无人工确认**:生成的新标题/新封面经 ③ 执行即直接上线(无确认、无提交开关);本地留档 + 回写 Excel 供追溯。 +- **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。 +- **生产在 Windows 直跑**:开发期我们用过 WSL→Windows 的 `netsh portproxy`(9333→9222)连 CDP;但 GUI 与 Chrome 都在 Windows 时,直接连 `127.0.0.1:9222`,无需 portproxy。 + +## 三、构建与运行命令 + +| 用途 | 命令 | +| --- | --- | +| 安装依赖 | `pip install websocket-client requests openpyxl` | +| 语法检查 | `python -m py_compile *.py` | +| 跑单账号演示 | `python prototypes/demo.py`(分步)/ `set AUTO=1 && python prototypes/demo.py`(自动) | +| 提交更新(真改线上) | `set UPDATE=1 && python prototypes/demo.py` | +| 读取某账号 Cookie(调试) | `python prototypes/cookies.py` | + +Windows PowerShell 下设置环境变量与 cmd 不同: + +```powershell +# PowerShell +$env:AUTO=1; python prototypes/demo.py +$env:UPDATE=1; python prototypes/demo.py +``` + +```cmd +:: cmd +set AUTO=1 && python prototypes/demo.py +``` + +## 四、依赖纪律 + +- 新增第三方依赖前,先在本文说明用途、替代方案和维护成本。 +- GUI 框架一旦确定(Tkinter 或其他),固定下来,不允许两套 UI 框架并存。 +- 不引入第二套浏览器自动化方案(不要 cdp.py 之外再混入 selenium/playwright)。 +- 不确定的技术选型先更新本文,再进入代码。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md new file mode 100644 index 0000000..073e63b --- /dev/null +++ b/docs/04-architecture.md @@ -0,0 +1,321 @@ +# 架构设计 + +> 本文讲“怎么把技术栈搭起来”:模块职责、存储模型、流水线、CDP 已验证事实、开发顺序。 +> 具体用了哪些库 / 平台,见 [技术栈](03-tech-stack.md)。 + +## 一、系统结构 + +Windows 本地桌面自动化工具,无后端服务,5 Tab GUI 驱动一条流水线。 + +```text +运营(人) + | + v +GUI(Tkinter ttk.Notebook,5 Tab) + ① 导入采集 ② AI生成 ③ 更新shopee ④ 账号管理 ⑤ 设置 + | + v +核心模块(Python) + ├── appconfig 读应用配置 config.json(Chrome 路径、目录根、AI 配置、端口范围…) + ├── db SQLite 读写:账号、任务、各阶段结果(cmshopee.db) + ├── excel openpyxl 导入输入列 / 回写输出列到原 Excel + ├── config 账号 ↔ user-data-dir 绑定、slug、目录创建 + ├── chrome 按账号拼启动参数、启动/探测 Chrome、生成快捷方式 + ├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽) + ├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新 + └── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面) + | + v +Google Chrome(每账号独立 --user-data-dir + --remote-debugging-port) + AI 服务(外部) + | + v +Shopee 卖家中心页面 / 本地图片目录 +``` + +真实组件: + +- GUI 入口:`gui.py`(待建,Tkinter + `ttk.Notebook`,5 Tab)。 +- 核心模块:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`chrome.py`、`editor.py`、`ai.py`(待建);CDP 底座 `cdp.py`(已有)。 +- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。 +- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像,服务商待定);`openpyxl`。 + +## 二、流水线(核心) + +每个任务(Excel 行)依次走过 5 个阶段,状态字段 `stage` 贯穿全程: + +```text +imported → collected → generated → applied +(导入 Excel) (采集旧数据) (AI 生成新数据) (改 Shopee 并提交) + ① Tab ① Tab ② Tab ③ Tab +``` + +- **imported**:openpyxl 解析输入列入库。 +- **collected**:只读打开商品页,读旧标题、下载旧封面到本地,写 `old_title/old_cover_path`,回写 Excel 旧字段。 +- **generated**:AI 用提示词+旧标题生成新标题;用提示词+旧封面生成新封面(image-to-image),新图存本地。**无人工确认环节**,生成完即可被 ③ 执行(新图留档本地 + 回写 Excel 供事后追溯)。 +- **applied**:打开编辑页换标题+封面,**总是点「更新」提交线上**(无开关),回写结果。 + +任意阶段失败 → `stage` 不前进、记 `error`、`result/skipped`,不影响其他任务。 + +## 三、职责划分 + +**GUI(5 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走后台线程。**首次未配账号/未登录时,① ③ 执行按钮禁用并提示去 ④。** + +**核心模块** + +- `appconfig`:读写 `config.json`(Chrome 路径、`chrome_user_data_dir` 根、图片目录、AI 配置、端口、DB 路径、默认是否提交)。 +- `db`:SQLite 读写账号、任务、各阶段结果;建表/迁移。 +- `excel`:openpyxl 读输入列、把输出列回写原 Excel(处理文件锁)。 +- `config`:账号 ↔ user-data-dir 绑定;slug;目录创建。 +- `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。 +- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。 +- `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。 +- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`(外部 AI;服务商待定)。 + +**存储(同一事实只存一处)** + +- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `config.json`。 +- AI 模型清单(url/模型/密钥/类型/连接超时)→ `config/ai_models.json`(含密钥,必须 gitignore)。 +- 业务数据(账号、任务、各阶段结果)→ SQLite `cmshopee.db`。 +- 图片(采集的旧封面、AI 生成的新封面)→ 本地图片目录(路径记在 DB)。 +- 提示词 → 标题提示词存单文件 `title_prompt.txt`;封面提示词存多模板 `prompts/cover/<名称>.txt`。 +- 登录态 → 各账号 `chrome_user_data_dir//`。 + +## 四、多账号隔离方案(决策) + +采用**每账号独立 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`,参数写在「目标」字段)。 + +## 五、数据模型 + +### 5.1 应用配置 `config.json` + +```json +{ + "chrome_path": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "user_data_root": "chrome_user_data_dir", + "image_dir": "images", + "db_path": "cmshopee.db", + "default_debug_port": 9222, + "debug_port_range": [9222, 9260], + "cdp_ready_timeout": 60, + "ai": { + "default_text_model": "GPT-5.5 文本", + "default_image_model": "Nano Banana 2", + "title_concurrency": 4, + "image_concurrency": 4, + "retry": 2, + "jpg_quality": 90, + "resolution": "1k", + "resolution_timeouts": { "512": 180, "1k": 240, "2k": 360, "4k": 600 } + } +} +``` + +`ai` 段只放**选择 + 全局生成参数**: + +- `default_text_model` / `default_image_model`:引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型)。 +- `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。 +- `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。 +- 模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。 +- 密钥不在 `config.json`:每个模型的 `api_key` 存于 `config/ai_models.json`,加密、打码、gitignore。 + +### 5.1b AI 模型清单 `config/ai_models.json` + +模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。 + +```json +{ + "models": [ + { + "name": "Nano Banana 2", // 服务商/模型名,唯一,作下拉显示与引用键 + "category": "image", // text | image —— 决定它出现在“标题/图片大模型”哪个下拉 + "enabled": true, + "url": "https://api.vectorengine.ai/v1/chat/completions", + "model": "gemini-3.1-flash-image-preview", // 模型 ID + "api_key": "***", // 密钥:加密存、UI 打码、不入日志、不进版本库 + "api_type": "auto", // chat | images_edits | auto —— 决定请求构造方式 + "connect_timeout_seconds": 30, // 连接该服务超时(每模型,默认 30) + "timeout_seconds": 0, // 返回超时:0/留空 = 运行时按 resolution_timeouts 取值 + "extra_body": {} + } + ] +} +``` + +关键事实: + +- **`category`(文本/图像)是必需的**:角色下拉据此过滤(标题下拉只列 text、图片下拉只列 image),防止错配。 +- 约束:**至少各有一个 text 与一个 image 模型**;下拉默认最少一项、删到剩一项时禁用「删除」。 +- `connect_timeout_seconds`(连接超时)属于**模型**;返回超时由分辨率映射决定(不在模型上单设)。 +- `api_type` 反映不同 API 形状(`chat`/`images_edits`/`auto`),请求构造按它分支。 +- `name` 唯一;`api_key` 加密存、打码显示。 + +### 5.2 SQLite `cmshopee.db` + +```sql +-- 账号(④ 账号管理) +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + account_name TEXT NOT NULL, -- Shopee 登录账号名(展示/参考) + alias TEXT UNIQUE NOT NULL, -- 别名,Excel 用它匹配 + region_host TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, -- user-data-dir 子目录名 [a-z0-9_] + user_data_dir TEXT NOT NULL, + debug_port INTEGER NOT NULL, + password_enc TEXT, -- 加密存,仅参考,不自动登录 + note TEXT, + created_at TEXT NOT NULL, + last_login_at TEXT +); + +-- 任务 + 各阶段结果(贯穿流水线) +CREATE TABLE tasks ( + id INTEGER PRIMARY KEY, + batch_id TEXT NOT NULL, + source_file TEXT, + -- 输入列(Excel) + account_name TEXT, + alias TEXT NOT NULL, + item_id TEXT NOT NULL, + -- 采集输出(程序写,改前快照) + old_title TEXT, + old_cover_path TEXT, -- 旧封面本地图片路径 + -- AI 输出 + new_title TEXT, + new_cover_path TEXT, -- 新封面本地图片路径 + -- 应用 + committed INTEGER DEFAULT 0, -- 是否成功点「更新」提交 + stage TEXT DEFAULT 'imported', -- imported/collected/generated/applied/failed/skipped + error TEXT, + updated_at TEXT +); +``` + +关键事实: + +- `alias` 是账号↔任务**唯一关联键**;找不到账号 → stage=skipped,error=别名未匹配,最后弹窗汇总。 +- `account_name` 仅展示/参考;匹配以 `alias` 为准。 +- `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。 +- `new_title/new_cover_path`:**AI 生成**结果(输出),图片落本地,**无人工确认**,生成即可应用。 +- `committed`:③ 执行时**总是点「更新」提交**;该字段记录提交是否成功。 +- 各字段**每阶段处理完立即写回 SQLite**(实时落库);阶段结束后批量回写 Excel。 + +### 5.3 Excel 模板 + +| 列 | 含义 | 输入/输出 | +| --- | --- | --- | +| 账号名 | Shopee 账号名(展示) | 输入 | +| 别名 | 关联 accounts.alias(匹配键) | 输入 | +| 商品id | item_id | 输入 | +| 旧标题 | 采集到的旧标题 | 输出,回写 | +| 旧封面图片路径 | 采集到的旧封面本地路径 | 输出,回写 | +| 新标题 | AI 生成的新标题 | 输出,回写 | +| 新封面图片路径 | AI 生成的新封面本地路径 | 输出,回写 | +| 更新状态 | 处理结果(成功/失败/跳过+原因) | 输出,回写 | + +- 输入列(账号名/别名/商品id)运营填;其余为程序各阶段输出,**回写到原 Excel**(被锁→提示重试/另存副本)。 +- 别名以此列为匹配权威,不解析文件名。 + +### 5.4 本地图片目录 + +```text +images//_old. # 采集下载的旧封面 +images//_new. # AI 生成的新封面 +``` + +路径记入 DB;上传新封面用本地 `_new` 文件(Windows 绝对路径传 setFileInputFiles)。 + +## 六、关键流程细节 + +### 6.1 采集(① Tab,只读) + +- 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。 +- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `images//_old`。 +- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。 + +### 6.2 AI 生成(② Tab) + +单个「开始生成」按钮,**两段式、各自并发**(标题快、图片慢,分开并发更高效): + +1. **并发生成标题**:线程池大小 = `title_concurrency`,用 `default_text_model` 调 `gen_title(标题提示词, old_title)` → new_title。 +2. **接着并发生成图片**:线程池大小 = `image_concurrency`,用 `default_image_model` 调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images//_new.jpg`。 + - 连接超时取该模型 `connect_timeout_seconds`;**返回超时取 `resolution_timeouts[resolution]`**(512→180/1k→240/2k→360/4k→600)。 + +- 失败重试:每次调用失败按 `retry` 次重试,仍失败则记 error(不阻塞其余)。 +- 每条/每张完成**立即 `set_generated` 写库**(实时落库,停止或崩溃不丢已生成的)。 +- **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。 +- 进度:`标题 x/n · 封面 x/n · 失败 n`。 +- 生成后 stage=generated;**无人工确认**。双击任务弹窗查看新旧封面(纯查看),可选对某行 `重生成`;新标题直接用 AI 输出(不可编辑)。 +- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`config.json` 的 `ai` 段)。 + +提示词管理: + +- **标题提示词**:单个文本,「保存」写入 `title_prompt.txt`;软件启动时加载该文件回显到输入框(缺失则空)。 +- **封面提示词**:多模板。下拉选模板(读 `prompts/cover/*.txt`),图标工具栏 新建/保存/另存为/重命名/删除;重名校验、删除二次确认、删空给默认。 +- **变量**:封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在光标处插入 `{新标题}`;「预览」= 用某条任务的值替换变量后展示,确认实际发送给 AI 的内容。 + +### 6.3 应用更新(③ Tab) + +- 对**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ **总是 `click_update` 提交**。 +- 串行、单条失败继续;每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。 + +### 6.4 登录检测 + +- 打开卖家中心,重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。 + +## 七、CDP 已验证事实(务必遵守) + +| 难点 | 已验证结论 | +| --- | --- | +| Chrome 启动参数 | 全关后带 `--remote-debugging-port= --remote-allow-origins=* --user-data-dir=`;缺 allow-origins 则 WebSocket 403 | +| 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 | +| WebSocket Origin | `websocket-client` `suppress_origin=True` | +| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 | +| 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` | +| 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 | +| 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 | +| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;`DOM.setFileInputFiles` 传 Windows 路径 | +| 上传成功 | 张数 +1 且新图 src 为 `susercontent` | +| 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` | +| 满 9 张 | 上限 9;换封面先删第一张(`.shopee-image-manager__icon--delete`,确认框待实测)| +| 更新按钮 | `button.eds-button` 中 `更新`;③ 执行即点提交;禁用态(校验未过)记为失败 | +| 登录检测 | 重定向到登录页或缺 `SPC_ST` → 未登录 | + +高风险动作(删满 9 张封面、点更新提交、AI 图直接上线)先在测试商品验证。注意:本设计**无人工确认、无提交开关**,③ 一旦执行即把 AI 标题/封面提交线上,新图本地留档+回写 Excel 是唯一事后追溯手段。 + +## 八、推荐开发顺序 + +1. **地基**:`editor.py`(含采集)、`appconfig.py`+`config.json`、`db.py`、`.gitignore`。 +2. **账号与启动**(④):`config` 建目录、`chrome` 启动器/快捷方式、登录保活与检测、账号 CRUD。 +3. **导入采集**(①):`excel` 导入、采集旧标题/旧封面、回写。 +4. **AI 生成**(②):`ai` 模块、提示词、对照预览与确认。 +5. **更新 shopee**(③):对已生成任务换标题+封面、直接提交、回写。 +6. **设置**(⑤)+ 首次未配账号引导保护。 + +## 九、项目结构建议 + +```text +cmshopee/ +├── docs/ +├── config.json # 应用配置(模型选择/生成参数/路径,gitignore) +├── config/ai_models.json # AI 模型清单(含密钥,必须 gitignore) +├── cmshopee.db # SQLite(账号/任务/结果,gitignore) +├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态,gitignore) +├── images/ # 旧封面/新封面本地图片(建议 gitignore) +├── title_prompt.txt # 标题提示词(单文件,启动回显) +├── prompts/cover/<名称>.txt # 封面提示词模板(多个) +├── appconfig.py / db.py / excel.py / config.py / chrome.py / editor.py / ai.py / prompts.py / gui.py # 待建 +├── cdp.py # CDP 底座(正式模块,已有) +└── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py) + # 逻辑待并入 editor.py 后清理;见 prototypes/README.md +``` + +> `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 含密钥/凭证/业务数据,必须 gitignore。 + +## 十、架构纪律 + +- CDP 交互事实变化同步第七节。 +- 存储边界:应用设置→config.json,账号/任务/结果→SQLite,图片→本地目录并记路径于 DB,登录态→user-data-dir;同一事实只存一处。 +- 别名是账号↔任务唯一关联键。 +- 密码与 AI Key 加密存、不外传、不写日志;不自动登录。 +- AI 生成内容直接用于 ③ 更新(无人工确认);新图本地留档 + 回写 Excel 以备追溯。 +- 高风险模块先单独验证,再接入流水线。 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md new file mode 100644 index 0000000..4780e9b --- /dev/null +++ b/docs/05-coding-rules.md @@ -0,0 +1,84 @@ +# 编码规则(Coding Rules) + +> 每次写代码前先读完本文件。这是让 AI 不跑偏、代码质量稳定的硬约束。 +> 与技术细节冲突时,以 [技术栈](03-tech-stack.md) / [架构设计](04-architecture.md) 的事实为准;与“该不该做”冲突时,以 [需求](02-requirements.md) 为准。 + +## 0. 黄金法则 + +1. **不臆造**:选择器、字段、文件、接口不确定就查证或先用 `prototypes/inspect_images.py` 探查页面,不要猜 class 名。 +2. **守范围**:只做当前任务要求的事,不顺手加批量/并行等后续功能。 +3. **照架构**:复用 `cdp.py`,遵守 `04-architecture.md` 第四节“已验证结论”,不另起一套 CDP 交互。 +4. **小步改**:一次只解决一个问题,不夹带无关重构。 +5. **可验证**:改完必须能 `py_compile`,关键路径能在测试商品上跑通,对得上验收标准。 + +## 1. 动手前 + +- 按链路确认:`vision` -> `requirements` -> `tech-stack` -> `architecture` -> `tasks`。 +- 找到本任务对应的验收标准,写之前就知道“怎么算做对”。 +- 复用优先:已有 `cdp.py` 的 `CDP`、`find_product_tab`、`create_tab`、`drag`;已有 `prototypes/demo.py`/`prototypes/set_*.py` 中验证过的 JS 片段。 +- 需求含糊或改动会偏离已验证事实时,先问。 + +## 2. 事实来源纪律 + +- CDP 交互只信 `04-architecture.md` 第四节的“已验证结论”和真实页面探查结果。 +- 不从旧脚本注释或记忆里推断仍然有效的选择器;Shopee 页面可能已变,必要时重新探查。 +- 不虚构 Shopee 接口、字段、按钮文案。 +- 选择器 / 流程变化必须同步更新 `04-architecture.md` 和相关任务。 + +## 3. 范围纪律 + +- MVP 只做 `02-requirements.md` 中列为 P0 的功能(账号配置、绑定目录、启动登录、加载页、改标题、换封面、可选更新)。 +- V2 / V3 功能(批量、多账号并行、日志、规则模板)只记录,不实现。 +- 需求明确排除的非目标(自动登录、绕风控、爬取、数据库)不得实现。 + +## 4. 架构纪律 + +- 技术栈以 `03-tech-stack.md` 为准;GUI 框架未确认前不要写大量 UI 代码。 +- 新增依赖前先说明理由;优先标准库(Tkinter、json、sqlite3、subprocess),Excel 用 openpyxl。 +- 存储边界:应用设置进 `config.json`,账号/任务/结果进 SQLite,登录态只在 user-data-dir,同一事实只存一处。 +- 模块职责以 `04-architecture.md` 为准:GUI 不写业务逻辑,业务在 `appconfig/db/excel/config/chrome/cdp/editor`。 +- 模块/CLI 合约以 `api.md` 为准。 + +## 5. 代码规范 + +- 标识符使用英文;UI 文案、注释、文档保持中文,与现有脚本一致。 +- 错误必须处理:CDP 超时、tab 找不到、上传失败、按钮禁用都要给明确提示,不吞错。 +- 注释解释“为什么”(尤其 CDP 的坑:代理、Origin、就绪判断、拖拽落点),不复述“做了什么”。 +- 复用现有脚本里已验证的 JS 字符串,不重写出不一致的版本。 + +## 6. 测试与验证 + +完成前至少检查: + +- [ ] `python -m py_compile` 通过。 +- [ ] 涉及 CDP 的改动,在测试商品(ITEM_ID 51100639510)上实跑验证。 +- [ ] 对得上需求验收标准(如标题 `value`+`modelvalue` 双等于、封面在第一位)。 +- [ ] 没有夹带无关改动。 +- [ ] 涉及选择器/流程/配置 schema 变化时,文档已同步。 +- [ ] 回复里如实说明跑了什么命令、结果如何。 + +```bash +python -m py_compile *.py +python prototypes/demo.py # 单账号闭环验证(不提交) +``` + +## 7. 绝不 + +- 绝不把真实账号、密码、Cookie、token 写进代码、文档或日志。 +- 绝不明文存密码;DB 里密码必须加密,且加密密钥不与密文同存。 +- 绝不把 `config.json`、`cmshopee.db`、`chrome_user_data_dir/` 提交版本库。 +- 绝不自动登录 / 自动填账号密码;登录由人工完成,程序只检测登录态。 +- 绝不在没有显式确认/开关的情况下点击「更新」提交线上。 +- 绝不擅自删除用户文件或重置 user-data-dir。 +- 绝不为通过验证而降低验收标准(如不验证 `modelvalue` 就当改成功)。 + +## 8. 安全与合规 + +- 登录凭证只存在于各账号 user-data-dir;不导出、不外传、不写入配置或日志。 +- 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。 +- 高风险动作(删满 9 张的封面、点击更新)必须有显式确认或开关,并先在测试商品验证。 +- 自动化默认支持“只改不提交”(dry-run 思路);提交、删除等不可逆动作要可控、可回退(刷新还原)。 + +## 9. 拿不准就问 + +问题要具体:说明卡在哪、有哪些选项、倾向哪个及原因。尤其 GUI 框架、端口分配、删图确认框结构这类影响后续的决策,先确认再写。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md new file mode 100644 index 0000000..3db0a32 --- /dev/null +++ b/docs/06-tasks.md @@ -0,0 +1,91 @@ +# 任务看板(Tasks) + +> 把目标拆成小步、可独立交付的任务,让 AI 一步一步开发,避免一次生成整个项目。 + +## 使用规则 + +1. **一次只做一个任务**:每轮只领取一个 `TODO` 且依赖均 `DONE` 的任务,取最靠前的。 +2. **做完即停**:完成、自测通过、改 `DONE` 后汇报。 +3. **不跳步**:依赖未完成不开工。 +4. **完成定义**:以 [编码规则](05-coding-rules.md) 验证清单为准。 +5. **动手前**读 `00-ai-start-here.md`、`05-coding-rules.md`、`current-state.md`、`04-architecture.md`(尤其第七节已验证事实、第二节流水线)。 +6. **完成后**同步本文状态,追加 [`../progress.md`](../progress.md),覆盖 [`current-state.md`](current-state.md)。 + +## 状态图例 + +`TODO` · `DOING`(同一时间最多 1 个)· `DONE` · `BLOCKED` + +--- + +## Phase 0 · 地基(模块化 + 存储) + +| ID | 任务 | 依赖 | 验收要点 | 状态 | +| --- | --- | --- | --- | --- | +| T-001 | `editor.py`:改标题/换封面/点更新/登录检测/**采集(读旧标题+旧封面下载)**/apply_task,复用 `cdp.py` | - | 函数可调用,在测试商品跑通;与 `prototypes/demo.py` 行为一致 | TODO | +| T-002 | `appconfig.py` + `config.json`(含 image_dir、ai 段、端口等默认值) | - | 读写正常;不存在则写默认;AI Key 加密占位 | TODO | +| T-003 | `db.py` + SQLite 建表(accounts/tasks,含各阶段字段) | - | `init_db` 幂等;账号/任务/各 set_* 可用;schema 同架构 5.2 | TODO | +| T-004 | `.gitignore`:排除 `config.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` | T-002, T-003 | 凭证/业务/图片不被提交 | TODO | + +## Phase 1 · 账号管理(④) + +| ID | 任务 | 依赖 | 验收要点 | 状态 | +| --- | --- | --- | --- | --- | +| T-101 | `config` 生成 slug + 创建 `chrome_user_data_dir/` | T-003 | 别名→唯一 slug;目录按需建;路径绝对化 | TODO | +| T-102 | `chrome.py` 启动器:拼参数并启动、探测端口 | T-101, T-002 | 含三参数;端口就绪可探测 | TODO | +| T-103 | 首次登录保活 + 登录检测 `is_logged_in` | T-102, T-001 | 关闭再启动免重登;登录/未登录判断准确 | TODO | +| T-104 | 五 Tab 主窗口骨架(`ttk.Notebook`,5 Tab 空壳) | T-002 | 五个 Tab 按顺序可切换 | TODO | +| T-105 | Tab④ 账号增删改(账号名/别名/端口/密码加密)+ 启动登录 + 检测登录 | T-104, T-101, T-103 | 增删改入库、建目录;密码加密默认打码;状态列刷新 | TODO | +| T-106 | 可选:为账号生成桌面快捷方式 | T-102 | `.lnk` 目标含该账号参数;双击进对应账号 | TODO | + +## Phase 2 · 导入采集(①) + +| ID | 任务 | 依赖 | 验收要点 | 状态 | +| --- | --- | --- | --- | --- | +| T-201 | `excel.py` 导入:解析多文件输入列入库 | T-003 | 按模板解析账号名/别名/商品id;脏数据容错;写 tasks(batch_id) | TODO | +| T-202 | Tab① 任务列表 + 导入按钮 + 别名匹配标记 | T-201, T-105 | Treeview 显示账号/别名/商品id/阶段;未匹配标“略过” | TODO | +| T-202b | Tab① 导入汇总栏 | T-202 | 导入后显示 文件数/解析行数/有效/无效/匹配(按账号)/未匹配;未匹配可点击筛出 | TODO | +| T-203 | 采集旧标题+旧封面(只读),下载图片,立即写库 | T-202, T-001 | 逐条 set_collected;旧封面下载到 `images//`;未登录/未匹配略过记原因 | TODO | +| T-204 | 回写旧字段到原 Excel(含文件锁处理) | T-203, T-201 | 旧标题/旧封面回写原文件;被锁提示重试或 export_copy | TODO | +| T-205 | 首次未配账号/未登录的引导保护 | T-105, T-203 | 无账号/未登录时 ① 执行按钮禁用并提示去④ | TODO | + +## Phase 3 · AI 生成(②) + +| ID | 任务 | 依赖 | 验收要点 | 状态 | +| --- | --- | --- | --- | --- | +| T-301 | 确定 AI 服务商/模型并接入 `ai.py`(`gen_title`/`gen_cover`,带重试/分辨率/jpg质量) | T-002 | 可调;Key 加密读取;`gen_cover` 支持 resolution+jpg_quality;失败按 retry 重试;错误明确 | TODO | +| T-302 | Tab② 左右布局:左提示词(标题/封面),右按批次/店铺/状态筛选 + 任务列表 | T-301, T-203 | 左 ~1/4 提示词多行;右筛选+列表(店铺/商品id/旧标题/新标题/状态) | TODO | +| T-302p | `prompts.py` + Tab② 提示词管理 | T-302 | 标题保存/启动回显 title_prompt.txt;封面多模板(下拉+新建/保存/另存为/重命名/删除,存 prompts/cover/);插入 `{新标题}`;预览变量替换;render_prompt 接入生成 | TODO | +| T-303 | Tab② 开始生成(单按钮)+ 停止 + 进度:**先并发标题再并发图片** | T-302 | `generate_batch` 先 title_concurrency 并发标题、再 image_concurrency 并发图片;每条 set_generated 立即写库;停止可取消未开始项;进度 标题/封面/失败 计数;双击弹窗看新旧封面 | TODO | + +## Phase 4 · 更新 shopee(③) + +| ID | 任务 | 依赖 | 验收要点 | 状态 | +| --- | --- | --- | --- | --- | +| T-401 | Tab③ 列出已生成任务 + 按批次/店铺/状态筛选 | T-303 | 顶部批次/店铺/状态筛选;「开始更新」仅作用于当前筛选结果;状态=失败可重试;无提交开关;警示“执行即提交线上” | TODO | +| T-402 | 串行执行 apply:换标题+换封面+**总是点更新提交**,单条失败继续,立即写库 | T-401, T-001 | 逐条 set_applied;恒提交;失败继续;未登录引导保护 | TODO | +| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | TODO | + +## Phase 5 · 设置与收尾 + +| ID | 任务 | 依赖 | 验收要点 | 状态 | +| --- | --- | --- | --- | --- | +| T-501 | Tab⑤ 设置 · AI 模型管理(master-detail):模型下拉 + 新增/删除 + 详情(服务商名/类别/api_type/模型ID/网址/密钥打码/连接超时) + 测试连接 | T-002 | 读写 `config/ai_models.json`;name 唯一;类别 text/image;至少各一个;密钥打码加密;测试连接可用 | TODO | +| T-501b | Tab⑤ 设置 · 角色与生成参数:标题大模型/图片大模型下拉、分辨率(512/1k/2k/4k)、返回超时随分辨率、并发/重试/jpg质量、路径/端口 | T-501 | 角色下拉按类别过滤;分辨率选后返回超时按 resolution_timeouts 自动;持久化 config.json | TODO | +| T-502 | 满 9 张封面:删第一张再上传 | T-001 | 实测删除确认框并处理;满 9 张流程正确 | TODO | +| T-503 | 密码 / AI Key 加密密钥方案落地 | T-105, T-501 | 密钥不与密文同存;说明威胁模型 | TODO | +| T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | TODO | + +## 里程碑 + +- M1:editor(含采集)模块化、config.json + SQLite 就绪(Phase 0)。 +- M2:账号管理可用、能登录与检测(Phase 1)。 +- M3:导入 + 采集 + 回写旧数据(Phase 2)。 +- M4:AI 生成新标题/新封面(Phase 3)。 +- M5:对已生成任务更新 Shopee(直接提交)+ 回写结果(Phase 4)。 + +## 待办池(Backlog) + +- 标题规则/提示词模板库。 +- 上传弹裁剪框商品的自动裁剪。 +- 账号登录态健康巡检。 +- AI 生成图的合规/质量自检。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8051dcd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,36 @@ +# 项目文档导航 + +> cmshopee 的 harness coding 文档集合。agent 开始编程前从 [`00-ai-start-here.md`](00-ai-start-here.md) 进入。 + +## 一句话定位 + +cmshopee 是一个给**电商运营**使用的 Windows 桌面自动化工具,用于**管理多个 Shopee 卖家账号、并用 CDP 驱动 Chrome 批量修改商品标题、替换商品封面图**。第一版先跑通「配置账号 → 绑定独立 Chrome 配置目录(保留登录态)→ 用某账号打开指定商品详情页 → 改标题 + 换封面(可选提交更新)」闭环。 + +## 文档导航 + +- [`../AGENTS.md`](../AGENTS.md):Codex / 通用 AI coding agent 的仓库级入口。 +- [`../CLAUDE.md`](../CLAUDE.md):Claude Code 的薄入口,具体规则以 `AGENTS.md` 为准。 +- [`../progress.md`](../progress.md):执行历史流水,只追加记录任务执行、验证、阻塞和决策。 +- [AI 开发入口](00-ai-start-here.md):agent 每次开始工作的入口、阅读顺序和任务领取规则。 +- [项目愿景](01-vision.md):为什么做、为谁做、产品原则、非目标。 +- [需求](02-requirements.md):要什么、用户故事、验收标准。 +- [技术栈](03-tech-stack.md):语言、运行平台、CDP 方案、GUI 选型和运行命令。 +- [架构设计](04-architecture.md):模块职责、账号与配置目录数据模型、CDP 已验证事实和开发顺序。 +- [编码规则](05-coding-rules.md):写代码前必须遵守的硬约束。 +- [任务看板](06-tasks.md):按依赖拆分的小任务,agent 每轮只做一个。 +- [模块 / CLI 合约](api.md):本地模块接口、Chrome 启动参数、账号配置 schema。 +- [界面与流程结构](routes.md):GUI 窗口、操作流程、按钮职责(无前端路由,用 GUI 流程替代)。 +- [当前实现状态](current-state.md):当前代码现实、可运行命令、下一步可做任务。 + +## 任务 / 进度 / 当前状态 + +- `06-tasks.md` 维护任务看板:任务 ID、依赖、验收要点和状态。 +- `../progress.md` 维护执行进度:每轮实际做了什么、跑了什么验证、遇到什么阻塞、做了什么决策。 +- `current-state.md` 维护当前快照:当前目录、可运行命令、已完成摘要和下一个可领取任务。 + +## 维护原则 + +- 需求变化先改文档,再改代码。 +- CDP 交互的关键事实(选择器、就绪判断、上传/拖拽方式)一旦定稿,写入 `04-architecture.md`,代码不得另起一套。 +- 涉及账号、登录凭证、Cookie 时,文档与代码只用占位符或路径引用,绝不写入真实值。 +- agent 开始新任务前,必须从 `00-ai-start-here.md` 进入。 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..54ed54c --- /dev/null +++ b/docs/api.md @@ -0,0 +1,188 @@ +# 模块 / CLI 合约 + +> 本项目无后端 API,全部为**本地 Python 模块合约**。实现前可细化,但不要另起一套不兼容接口。 + +## 通用约定 + +- 形态:本地函数 + 子进程(Chrome)+ CDP(`127.0.0.1:`)+ SQLite + openpyxl + AI 服务调用。 +- 编码:UTF-8;传 Chrome / `setFileInputFiles` 的路径为 **Windows 绝对路径**。 +- 凭证:登录态在 user-data-dir;密码、AI Key 加密存于 config/DB,不出现在日志/导出明文。 +- 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。 + +## appconfig 模块(`appconfig.py`,待建) + +读写 `config.json`(schema 见 [架构 5.1](04-architecture.md))。 + +```python +load_config(path="config.json") -> dict # 不存在则写默认 +chrome_path() -> str +user_data_root() -> str +image_dir() -> str +db_path() -> str +ai_config() -> dict # default_text_model/default_image_model/ + # title_concurrency/image_concurrency/retry/jpg_quality/ + # resolution/resolution_timeouts +response_timeout() -> int # = resolution_timeouts[resolution](返回超时,随分辨率) + +# AI 模型清单 config/ai_models.json(含密钥;CRUD 由 ⑤ 设置) +list_ai_models(category=None) -> list[dict] # category=text/image 过滤;含 connect_timeout_seconds 等 +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, error} +get_model(name) -> dict # 解密 api_key 供调用 +``` + +## db 模块(`db.py`,待建) + +SQLite 读写,表见 [架构 5.2](04-architecture.md)。 + +```python +init_db(path) +# 账号 +list_accounts() -> list[Account] +get_account_by_alias(alias) -> Account|None +add_account(account_name, alias, region_host, debug_port, password_enc=None, note=None) -> Account +update_account(alias, **fields) -> None +delete_account(alias) -> None +# 任务 / 各阶段结果 +insert_tasks(batch_id, rows) -> int # 写输入列 +list_tasks(batch_id=None, stage=None) -> list[Task] +set_collected(task_id, old_title, old_cover_path) -> None # 采集结果,立即写 +set_generated(task_id, new_title, new_cover_path) -> None # AI 结果,立即写 +set_applied(task_id, committed, error=None) -> None # 更新结果,立即写 +``` + +`stage` 随各 set_* 推进(imported→collected→generated→applied);任意步失败写 `error` 且 stage 标 failed/skipped。无 confirmed 阶段。 + +## excel 模块(`excel.py`,待建,依赖 openpyxl) + +```python +import_tasks(file_paths: list[str]) -> dict +# 只解析【输入列】:账号名、别名、商品id(+ source_file);输出列运行时回写 +# -> {"rows": [...], "stats": {"files": int, "total": int, "valid": int, "invalid": int}} +# invalid = 缺别名/商品id 或脏数据的行 + +match_summary(rows: list[dict], accounts: list) -> dict +# 用 accounts 的别名对 rows 做匹配统计(导入汇总栏用) +# -> {"matched": int, "unmatched": int, "by_account": {别名: 行数}, "unmatched_aliases": [..]} + +write_back(batch_id, excel_path) -> str +# 把【旧标题/旧封面/新标题/新封面/更新状态】批量回写到【原 Excel】 +# 原文件被占用(锁) → 抛错,调用方提示“请关闭后重试”,或改用 export_copy +export_copy(batch_id, out_path) -> str # 退路:另存新结果文件,不动原文件 +``` + +列模板见 [架构 5.3](04-architecture.md);别名以“别名”列为权威。 + +## config 模块(`config.py`,待建) + +```python +make_slug(alias) -> str # 别名→唯一 slug [a-z0-9_] +ensure_user_data_dir(slug) -> str # chrome_user_data_dir/ 绝对路径,按需创建 +``` + +## chrome 模块(`chrome.py`,待建) + +```python +build_launch_args(account) -> list[str] # chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir +launch_chrome(account) -> subprocess.Popen +wait_debug_ready(port, timeout=60) -> bool +is_running(port) -> bool +create_shortcut(account, dest_dir=None) -> str # 可选 .lnk,PowerShell WScript.Shell +``` + +## cdp 模块(`cdp.py`,已实现) + +```python +CDP_HOST: str +http_get(path); find_product_tab(item_id); create_tab(url) +class CDP: send/ev/val/object_id/drag/close # suppress_origin、trust_env=False +``` + +## editor 模块(`editor.py`,待建,重构自现有脚本) + +```python +is_logged_in(account) -> bool # 重定向登录页或缺 SPC_ST → 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张先删第一张 +click_update(cdp) -> dict # {clicked, reason};禁用则记失败 +apply_task(account, task) -> dict # 对已生成任务:换标题+换封面+点「更新」提交(恒提交) + # -> {committed, error} +``` + +## ai 模块(`ai.py`,待建,外部 AI,服务商待定) + +```python +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 模块(`prompts.py`,待建) + +```python +# 标题提示词:单文件 +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` 均为可手改的纯文本文件。 + +## 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 能力与合规。 +- 密码、AI Key 加密的密钥来源(机器派生 / 主口令)。 +- 满 9 张删除封面的确认框选择器(需实测)。 +- Excel 缺列/脏数据容错(整文件拒绝 vs 逐行跳过)。 +- 旧封面下载的图片格式/扩展名处理。 diff --git a/docs/current-state.md b/docs/current-state.md new file mode 100644 index 0000000..76cf777 --- /dev/null +++ b/docs/current-state.md @@ -0,0 +1,85 @@ +# 当前实现状态 + +> 本文是可覆盖的当前快照,记录代码与任务看板的现实状态。 +> 历史执行流水追加到 [`../progress.md`](../progress.md),不在本文重复完整日志。 + +## 当前快照 + +- 日期:2026-06-25 +- 阶段:单账号 CDP 流程已验证;多账号管理 + 5 Tab GUI + Excel/AI 流水线为既定设计,尚未开始编码。 +- 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI Tkinter 5 Tab(待最终确认)。 +- 生产代码:尚无 `appconfig/db/excel/config/chrome/editor/gui`;现有为验证脚本 + `cdp.py`。 +- 测试:以 `py_compile` + 在测试商品上手动跑 `prototypes/demo.py` 为主,无自动化测试。 +- 数据:无 `config.json`、`cmshopee.db`、`chrome_user_data_dir/`(待 Phase 0/1 建立)。 + +## 既定设计要点(文档已定) + +- GUI:**5 Tab 流水线**,顺序 ① 导入采集 → ② AI生成 → ③ 更新shopee → ④ 账号管理 → ⑤ 设置。 +- 流水线阶段:imported → collected(采集旧标题/旧封面+下载+回写)→ generated(AI 提示词生成新标题/新封面)→ applied(改 Shopee 并直接提交)。**无 confirmed、无提交开关。** +- 存储:`config.json`(应用设置含 AI Key)+ SQLite `cmshopee.db`(账号/任务/各阶段结果)+ openpyxl(Excel)+ 本地 `images/`(旧/新封面)。 +- 多账号隔离:每账号独立 user-data-dir(非 profile)。 +- 账号↔任务绑定:以 Excel“别名”列为权威;未匹配略过,结束弹窗汇总。 +- 执行:多账号串行、单条失败继续;③ 执行即点「更新」提交线上(无确认、无开关)。 +- AI:服务商待定;生成内容直接用于更新,本地留档+回写 Excel 供追溯。 +- 登录:人工登录 + 程序检测,不自动登录;首次未配账号/未登录时 ① ③ 禁用并引导去④。 + +## 当前目录要点 + +| 路径 | 状态 | 说明 | +| --- | --- | --- | +| `docs/` | 已有 | 本 harness coding 文档集合 | +| `cdp.py` | 已有 | CDP 底座:连接/找 tab/开 tab/执行 JS/拖拽(正式模块,留根目录) | +| `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),逻辑待并入 `editor.py` 后清理;见 `prototypes/README.md` | +| `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | +| `appconfig.py` / `db.py` / `excel.py` / `config.py` / `chrome.py` / `editor.py` / `gui.py` | 待建 | Phase 0-3 产出 | +| `config.json` / `cmshopee.db` / `chrome_user_data_dir/` | 待建 | 含配置/业务/凭证,须 gitignore | + +## 已验证能力(单账号) + +- CDP 连接 Chrome、遍历 tab、读取 shopee.tw Cookie。 +- 找到/新建商品详情页 tab,等编辑器就绪。 +- 改标题:原生 setter + 派发事件,`value` 与 `modelvalue` 双等于新值。 +- 换封面:`setFileInputFiles` 上传(`.shopee-image-manager__upload input[type=file]`)→ 等 CDN 链接 → `Input.dispatchMouseEvent` 拖到第一位(落点 `first.left - 0.30*w`)。 +- 「更新」按钮:可点才点,禁用态识别;默认不提交。 + +## 任务看板状态 + +任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 + +- 已完成:无(任务均为 TODO;单账号能力以脚本形式存在,待 T-001 模块化)。 +- 正在进行:无。 +- 下一个可领取任务:Phase 0 的 **T-001(`editor.py` 含采集)/ T-002(`appconfig.py`+`config.json`)/ T-003(`db.py`+SQLite)**——三者无相互依赖,可任选其一起步。 + +## 当前可运行内容 + +```bash +# 语法检查 +python -m py_compile *.py + +# 单账号闭环(不提交线上) +python prototypes/demo.py # 分步(需根目录 cdp.py 可导入) +set AUTO=1 && python prototypes/demo.py # 自动(cmd) + +# 真实提交(谨慎) +set UPDATE=1 && python prototypes/demo.py +``` + +前置条件: + +- Chrome 已用某 user-data-dir 带 `--remote-debugging-port` + `--remote-allow-origins=*` 启动并登录 Shopee。 +- 已 `pip install websocket-client requests`。 +- 默认连 `127.0.0.1:9222`(开发期可用 `CDP_HOST` 指向 WSL 转发的 `192.168.0.224:9333`)。 + +## 开始编码前检查 + +1. 读仓库级 `AGENTS.md` / `CLAUDE.md`(如有)。 +2. 读 `docs/00-ai-start-here.md`。 +3. 读 `docs/05-coding-rules.md` 与 `docs/04-architecture.md` 第四节。 +4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖 `DONE` 的任务。 +5. 将该任务状态改为 `DOING`。 + +## 维护规则 + +- 新建 `config/chrome/editor/gui` 或改 CDP 选择器后,更新本文与 `04-architecture.md`。 +- 任务状态变化同步 [`06-tasks.md`](06-tasks.md);执行记录追加 [`../progress.md`](../progress.md)。 +- 本文只保留当前快照,不保留完整历史。 diff --git a/docs/routes.md b/docs/routes.md new file mode 100644 index 0000000..f0858eb --- /dev/null +++ b/docs/routes.md @@ -0,0 +1,145 @@ +# 界面与流程结构 + +> 桌面工具,无前端路由。用 **5 Tab GUI(Tkinter `ttk.Notebook`)+ 流水线** 约定界面职责与导航。 + +## Tab 顺序与职责(工作流优先) + +``` +① 导入采集 │ ② AI生成 │ ③ 更新shopee │ ④ 账号管理 │ ⑤ 设置 +``` + +| Tab | 职责 | 风险 | +| --- | --- | --- | +| ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 | +| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺筛选任务列表;AI 生成新标题/新封面;双击看新旧封面 | 不触线上,中 | +| ③ 更新shopee | 对**已生成**任务打开编辑页换标题+封面并**直接点「更新」提交**;结果回写 Excel | **写线上,高** | +| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码加密/登录状态);启动登录、检测登录、生成快捷方式 | 中 | +| ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口等 | — | + +任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。**无人工确认、无提交开关**。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。 + +## 首次使用引导保护 + +- ① 导入采集 与 ③ 更新shopee 都依赖**账号已配置且已登录**(在 ④ 账号管理)。 +- 当无账号 / 账号未登录时:相关执行按钮**禁用**,并提示「请先到『账号管理』配置账号并登录」。 +- 老用户账号已就绪则无感。 + +## ① 导入采集 + +``` +┌ 导入采集 ─────────────────────────────────────────────────────┐ +│ [导入 Excel…] [移除] [清空] │ +│ ▸ 3 文件 · 128 行 · 有效125/无效3 · 匹配123 · 未匹配5⚠ │ ← 导入汇总栏 +│ 匹配明细:女装店60 · my主店40 · 饰品店23 │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 账号名 别名 商品ID 阶段 旧标题 旧封面 │ │ +│ │ 主店A 女装店 51100639510 待采集 — — │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ [▶ 采集旧标题/旧封面] [■停止] [回写旧数据到 Excel] │ +│ 日志:逐条 文件→匹配账号、采集结果 │ +└───────────────────────────────────────────────────────────────┘ +``` + +- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 +- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 +- 采集:用该账号 Chrome 只读打开商品页,读旧标题、下载旧封面到本地图片目录,写 `old_title/old_cover_path`,stage=collected。 +- 回写:采集完把旧标题/旧封面路径批量回写原 Excel(原文件被锁→提示重试/另存)。 +- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。 + +## ② AI生成 + +左右布局:左侧约 1/4 放提示词,右侧放筛选 + 任务列表。 + +``` +┌ AI生成 ───────────────────────────────────────────────────────┐ +│ ┌─左 ~1/4─┐ ┌──────────────── 右 3/4 ──────────────────────┐ │ +│ │标题提示词│ │ 批次[本次▼] 店铺[全部▼] 状态[全部▼] [筛选] │ │ +│ │[ ]│ │ ┌──────────────────────────────────────────┐ │ │ +│ │[ ]│ │ │ 店铺名 商品id 旧标题 新标题 状态 │ │ │ +│ │ │ │ │ 女装店 511..639 …T恤 …百搭 已生成 │ │ │ +│ │封面提示词│ │ │ 女装店 511..640 … — 待生成 │ │ │ +│ │[ ]│ │ └──────────────────────────────────────────┘ │ │ +│ │[ ]│ │ (双击某行 → 弹窗看 旧封面 | 新封面) │ │ +│ └──────────┘ └───────────────────────────────────────────────┘ │ +│ 进度:标题30/30 · 封面12/30 · 失败1 [▶ 开始生成] [■停止] │ +└───────────────────────────────────────────────────────────────┘ +``` + +- 左侧(提示词管理,上下两块): + - **标题提示词**:多行输入 + 「保存」(写 `title_prompt.txt`);启动时自动加载回显。 + - **封面提示词**:模板下拉(读 `prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。 + - 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。 +- 右上:按导入批次 / 店铺 / 状态筛选任务。 +- 右下:任务列表(店铺名、商品id、旧标题、新标题、状态);**双击某条 → 弹窗展示旧封面 | 新封面**(纯查看)。 +- 底部**单个「开始生成」+「停止」**:开始生成 = **先按 `title_concurrency` 并发生成标题,接着按 `image_concurrency` 并发生成封面**;进度实时显示 标题/封面/失败 计数。 +- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、模型/Key)在 **⑤ 设置**,不在本 Tab 重复。 +- 每条/每张完成即写库;「停止」取消未开始项,可再次「开始生成」对剩余继续。 +- **无人工确认环节**;新标题直接用 AI 输出(不可编辑);可选对单行 `重生成`。生成完即可进入 ③。 + +## ③ 更新shopee + +``` +┌ 更新shopee ───────────────────────────────────────────────────┐ +│ 批次[本次▼] 店铺[全部▼] 状态[已生成▼] [筛选] │ +│ ⚠ 「开始更新」对【当前筛选结果】执行,即改标题+换封面并直接提交 │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ [▶ 开始更新] [■停止] [回写结果到 Excel] │ +└───────────────────────────────────────────────────────────────┘ +``` + +- 顶部**按批次 / 店铺 / 状态筛选**(与 ①②一致);「开始更新」作用于**当前筛选结果**,是一道范围控制。 + - 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。 + - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 +- 对筛选出的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → **总是点「更新」提交**(无开关)。 +- 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error)。 +- 全部完成 → 把新标题/新封面/更新状态批量回写原 Excel;弹窗汇总。 + +## ④ 账号管理 + +``` +┌ 账号管理 ─────────────────────────────────────────────────────┐ +│ 账号名 别名 地区 端口 登录状态 备注 │ +│ 主店A 女装店 seller.shopee.tw 9222 ●已登录 │ +│ [+新增][✎编辑][🗑删除] [▶启动并登录][🔄检测登录][⧉快捷方式] │ +└───────────────────────────────────────────────────────────────┘ +``` + +账号弹窗字段:账号名、别名(唯一,Excel 用它匹配)、地区域名、调试端口、密码(加密仅参考)、备注;配置目录按别名生成 slug 只读显示。 + +## ⑤ 设置 + +- AI:服务商 / 文本模型 / 图像模型 / API Key(加密存)。 +- AI 生成参数:**标题并发数、图片并发数、失败重试次数、分辨率、jpg 质量**。 +- 本地图片目录(旧封面下载、新封面生成的存放根目录)。 +- Chrome 路径、默认调试端口 / 端口范围、超时、DB 路径。 + +## 流程导航 + +```text +④ 账号管理:配账号 + 启动登录(首次必做) + │ +① 导入采集:导入 Excel → 采集旧标题/旧封面 → 回写旧字段 + │ +② AI生成:提示词 → 生成新标题/新封面(无确认) + │ +③ 更新shopee:对已生成任务换标题+封面 → 直接点「更新」提交 → 回写结果 +``` + +- 未配账号/未登录:① ③ 的执行按钮禁用并提示去 ④。 +- 已生成的任务即可进 ③;③ 执行即提交线上(无确认、无开关)。 +- 任意步骤失败:记入该任务、日志标明,不影响其他任务。 + +## 组件建议(Tkinter) + +| 组件 | 归属 | 说明 | +| --- | --- | --- | +| `MainNotebook` | 根窗口 | 5 个 Tab | +| `CollectTab` | ① | 导入、任务表、采集、回写 | +| `GenerateTab` | ② | 左提示词 + 右筛选/任务列表、双击看新旧封面、开始生成 | +| `ApplyTab` | ③ | 已生成任务、换标题+封面+直接提交、回写 | +| `AccountsTab` | ④ | 账号增删改、启动登录 | +| `SettingsTab` | ⑤ | AI/目录/Chrome 配置 | + +> 采集、生成、更新都是耗时操作,放后台线程,避免界面卡死。 diff --git a/docs/ui/README.md b/docs/ui/README.md new file mode 100644 index 0000000..bd4f782 --- /dev/null +++ b/docs/ui/README.md @@ -0,0 +1,14 @@ +# UI 效果图(SVG 线框) + +cmshopee 5 Tab 桌面应用的界面效果图。SVG 可用浏览器直接打开查看。 + +| 文件 | 内容 | +| --- | --- | +| [overview-pipeline.svg](overview-pipeline.svg) | 5 Tab 流水线总览(stage:imported→collected→generated→confirmed→applied) | +| [tab1-import-collect.svg](tab1-import-collect.svg) | ① 导入采集:导入 Excel、任务列表、采集旧标题/旧封面、回写、日志 | +| [tab2-ai-generate.svg](tab2-ai-generate.svg) | ② AI生成:提示词、生成、新旧对照预览、确认/重生成 | +| [tab3-update-shopee.svg](tab3-update-shopee.svg) | ③ 更新shopee:已确认任务、批次提交开关、执行结果、汇总 | +| [tab4-accounts.svg](tab4-accounts.svg) | ④ 账号管理:账号列表、启动登录、检测登录、账号编辑弹窗 | +| [tab5-settings.svg](tab5-settings.svg) | ⑤ 设置:AI 模型/Key、路径、端口 | + +> 仅为线框效果图,最终样式以实现为准。界面职责与流程见 [../routes.md](../routes.md)。 diff --git a/docs/ui/overview-pipeline.svg b/docs/ui/overview-pipeline.svg new file mode 100644 index 0000000..9ce05b5 --- /dev/null +++ b/docs/ui/overview-pipeline.svg @@ -0,0 +1,91 @@ + + + + + + + cmshopee · 5 Tab 流水线总览 + 工作流优先顺序 · 对象是 Excel 里的每一行任务 · 状态字段 stage 贯穿全程 + + + + imported + collected + generated + applied(直接提交) + + + + + + + + ① 导入采集 + 读 Excel 输入列 + 账号名/别名/商品id + 只读采集 + 抓旧标题 + 下载旧封面到本地 + 回写 Excel 旧字段 + → collected + + + + ② AI生成 + 提示词输入 + 标题/封面分开 + AI 生成 + 旧标题→新标题 + 旧封面→新封面 + 双击看新旧封面 + → generated(无确认) + + + + ③ 更新shopee + 已生成任务 + 打开编辑页 + 换标题 + 换封面 + 直接点更新提交 + 串行 · 失败继续 + 实时写库 + 回写Excel + → applied + + + + ④ 账号管理 + 账号名/别名/端口 + 密码加密·仅参考 + 独立 user-data-dir + 启动并登录(人工) + 检测登录状态 + 配一次,少改 + + + + ⑤ 设置 + AI 模型/Key + 本地图片目录 + Chrome 路径 + 端口范围 + 全局配置 + + + + + + + + ① ③ 依赖账号已配置且已登录(④);未配/未登录则执行按钮禁用并提示去④。存储:config.json + SQLite + 本地 images/。 + diff --git a/docs/ui/tab1-import-collect.svg b/docs/ui/tab1-import-collect.svg new file mode 100644 index 0000000..198b921 --- /dev/null +++ b/docs/ui/tab1-import-collect.svg @@ -0,0 +1,117 @@ + + + + + + + + + + cmshopee · Shopee 商品批量改标题换封面 + + + + + + + + ① 导入采集 + ② AI生成 + ③ 更新shopee + ④ 账号管理 + ⑤ 设置 + + + + + 导入 Excel… + 移除 + 清空 + 别名以 Excel“别名”列匹配账号 · 未匹配→略过 + + + + + + ▸ 导入汇总 + 3 文件 · 128 行 · 有效 125 · + 无效 3 + · 匹配 123 · + 未匹配 5 ⚠ + 匹配明细:女装店 60 · my主店 40 · 饰品店 23 (点“无效/未匹配”可在列表筛出定位) + + + + + + + 账号名 + 别名 + 商品ID + 阶段 + 旧标题 + 旧封面 + + + + 主店A女装店 + 51100639510 + 已采集 + …顯瘦百搭T恤old_…01.jpg + + 主店A女装店 + 51100640000 + 待采集 + —— + + + 马来店my主店 + 52000000001 + 已采集 + Korean Loose Teeold_…07.jpg + + 未知店x别名 + 52999999 + 略过·别名未匹配 + + + + + + ▶ 采集旧标题/旧封面 + ■ 停止 + 回写旧数据到 Excel + + + + + 运行日志 + + + [10:21:03] 文件 tw01.xlsx → 匹配账号「女装店」(9222) + [10:21:05] 51100639510 采集旧标题:…顯瘦百搭T恤 + [10:21:07] 下载旧封面 → images/nuzhuang_01/51100639510_old.jpg + [10:21:08] set_collected ✓(已写 SQLite) + [10:21:14] 52999999 别名「x别名」未匹配账号 → 略过 + [10:21:20] 采集完成 3/4,已回写 Excel 旧字段 + + + diff --git a/docs/ui/tab2-ai-generate.svg b/docs/ui/tab2-ai-generate.svg new file mode 100644 index 0000000..a7949df --- /dev/null +++ b/docs/ui/tab2-ai-generate.svg @@ -0,0 +1,105 @@ + + + + + + + cmshopee · Shopee 商品批量改标题换封面 + + + + + ① 导入采集 + ② AI生成 + ③ 更新shopee + ④ 账号管理 + ⑤ 设置 + + + + + + + 标题提示词 + 保存 + + 精简标题、去「T恤」、 + 突出韩版加绒保暖 ≤60字 + 启动自动加载 title_prompt.txt 回显 + + + 封面提示词(模板) + 模板:韩版女装 ▼ + + + + + 💾 + 另存 + 改名 + 删除 + + + 纯白背景、保留主体、 + 呼应标题: + {新标题} + + 插入标题 + 预览 + 变量 {旧标题}{新标题}{商品id}{店铺} + 模型/并发/重试/分辨率/质量 → ⑤设置 + + + + + + 批次:本次 ▼ + 店铺:全部 ▼ + 状态:全部 ▼ + 筛选 + 双击某行 → 弹窗看 旧封面 | 新封面 + + + + + 店铺名商品ID + 旧标题新标题状态 + + + 女装店51100639510 + …顯瘦百搭T恤…顯瘦百搭 + 已生成 + + 女装店51100640000 + …加絨打底衫T恤— + 待生成 + + + my主店52000000001 + Korean Loose TeeKorean Oversize Tee + 已生成 + + 饰品店53000000009 + …百搭耳環飾品生成中… + 处理中 + + 新标题直接用 AI 输出(不可编辑)· 可对单行右键「重生成」· 无人工确认 + + + + + 进度: + 标题 30/30 · 封面 12/30 · + 失败 1 + 先并发生成标题 → 接着并发生成封面 · 每条完成即写库 · 停止可取消未开始项 + ▶ 开始生成 + ■ 停止 + + diff --git a/docs/ui/tab3-update-shopee.svg b/docs/ui/tab3-update-shopee.svg new file mode 100644 index 0000000..3e9107b --- /dev/null +++ b/docs/ui/tab3-update-shopee.svg @@ -0,0 +1,81 @@ + + + + + cmshopee · Shopee 商品批量改标题换封面 + + + + + ① 导入采集② AI生成 + ③ 更新shopee + ④ 账号管理⑤ 设置 + + + + 批次:本次 ▼ + 店铺:全部 ▼ + 状态:已生成 ▼ + 筛选 + + + + + ⚠ 「开始更新」对【当前筛选结果】执行:改标题+换封面并直接提交线上(无确认、无开关) + + + + + + + 别名商品ID + 新标题新封面 + 阶段结果 + + + 女装店51100639510 + …顯瘦百搭new_…01.jpg + 已应用 + 成功·已提交 + + 女装店51100640200 + …加絨上衣new_…02.jpg + 处理中… + — + + + my主店52000000001 + Korean Loose Teenew_…07.jpg + 待应用— + + 饰品店53000000009 + …百搭耳環new_…03.jpg + 失败 + 更新按钮禁用 + + 串行执行 · 单条失败继续 · 每条立即写回 SQLite + + + + + ▶ 开始更新 + ■ 停止 + 回写结果到 Excel + + + + + + 结束汇总 + 成功:2 失败:1(更新按钮禁用 / 校验未过) 略过:0 + 结果已写回原 Excel(旧/新标题、旧/新封面、更新状态)。 + ⚠ 原 Excel 被打开锁定时回写失败 → 提示关闭后重试,或另存副本(SQLite 为事实来源)。 + + diff --git a/docs/ui/tab4-accounts.svg b/docs/ui/tab4-accounts.svg new file mode 100644 index 0000000..bbe22e6 --- /dev/null +++ b/docs/ui/tab4-accounts.svg @@ -0,0 +1,72 @@ + + + + + cmshopee · Shopee 商品批量改标题换封面 + + + + + ① 导入采集② AI生成 + ③ 更新shopee + ④ 账号管理 + ⑤ 设置 + + + + + + 账号名别名 + 地区端口 + 登录状态备注 + + + shop_tw_a女装店 + seller.shopee.tw9222 + 已登录主力店 + + shop_mymy主店 + seller.shopee.my9223 + 未登录 + + + shop_tw_b饰品店 + seller.shopee.tw9224 + 未启动 + + + + + + +新增 + ✎编辑 + 🗑删除 + ▶ 启动并登录 + 🔄 检测登录 + ⧉ 生成快捷方式 + + + + + + + 新增 / 编辑账号 + + 账号名* + 别名* + 地区域名 + 端口/密码 + ••••(加密) + + 配置目录(只读):chrome_user_data_dir\nuzhuang + 取消 + 保存 + + diff --git a/docs/ui/tab5-settings.svg b/docs/ui/tab5-settings.svg new file mode 100644 index 0000000..934b991 --- /dev/null +++ b/docs/ui/tab5-settings.svg @@ -0,0 +1,81 @@ + + + + + cmshopee · Shopee 商品批量改标题换封面 + + + + + ① 导入采集② AI生成 + ③ 更新shopee④ 账号管理 + ⑤ 设置 + + + + + AI 模型管理 + + + + 模型 + Nano Banana 2 ▼ + 新增 + 删除 + 测试连接 + 至少各 1 个 文本/图像模型 + + + 服务商名称Nano Banana 2 + 网址https://api.vectorengine.ai/v1/chat/completions + + 类别图像 ▼ + api_typeauto ▼ + 连接超时30秒 + + 模型IDgemini-3.1-flash-image-preview + 密钥•••••••• + 👁 + + + + + + 角色分配 + 生成参数 + 标题大模型GPT-5.5 文本 ▼ + 图片大模型Nano Banana 2 ▼ + 分辨率1k ▼ + → 返回超时 240s + + 标题并发4 + 图片并发4 + 失败重试2 + jpg质量90 + ▸ 高级 分辨率→返回超时:512:180 1k:240 2k:360 4k:600(可改) + + + + + + 路径与运行 + Chrome 路径 + C:\Program Files\Google\Chrome\Application\chrome.exe + 浏览… + 图片目录images + DBcmshopee.db + 端口范围9222 + –9260 + + + + 保存设置 + 模型清单存 config/ai_models.json(密钥加密);选择与参数存 config.json。均 gitignore。 + + diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..45d31e7 --- /dev/null +++ b/progress.md @@ -0,0 +1,161 @@ +# 执行进度记录 + +> 本文件是只追加的历史流水,记录任务执行过程、验证命令、阻塞点和关键决策。 +> 当前目录、当前命令、下一个可领取任务等可覆盖快照,写入 [`docs/current-state.md`](docs/current-state.md)。 + +## 职责边界 + +- `docs/06-tasks.md`:任务看板,维护任务状态、依赖和验收要点。 +- `progress.md`:历史流水,只追加记录每轮执行发生了什么。 +- `docs/current-state.md`:当前快照,可覆盖更新仓库现实、可运行命令和下一步。 + +## 记录格式 + +```markdown +## 【YYYY-MM-DD】T-【编号】 【任务名】 + +- 状态:【DONE / BLOCKED / PARTIAL】 +- 变更:【修改了哪些文件或模块】 +- 验证:【运行的真实命令和结果】 +- 阻塞:【如有,写明原因和需要谁决策】 +- 决策:【如有,记录本轮确定的关键取舍】 +- 下一步:【建议下一个任务 ID 或待确认事项】 +``` + +## 执行记录 + +## 【2026-06-25】Phase -1 单账号流程验证(建文档前) + +- 状态:DONE +- 变更:新增 `cdp.py`、`demo.py`、`set_title.py`、`set_cover.py`、`get_title.py`、`cookies.py`、`inspect_images.py`、`grab.py`;本轮建立 `docs/` 全套 harness coding 文档与 `progress.md`、`AGENTS.md`、`CLAUDE.md`。 +- 验证: + - CDP 连接 Chrome(开发期经 WSL→Windows portproxy `192.168.0.224:9333`→`127.0.0.1:9222`)成功。 + - `cookies.py`:读到 shopee.tw 标签页 50 个 Cookie(含 csrftoken)。 + - `get_title.py`:读到测试商品(ITEM_ID 51100639510)标题。 + - `set_title.py`:改标题后 `value`==`modelvalue`==新值。 + - `set_cover.py`:`setFileInputFiles` 上传成功(张数 5→6),拖拽落点 `first.left-0.30*w` 后新图到第一位。 + - `demo.py`:在 Windows 端跑通改标题 + 换封面(未提交)。 + - 文档:`python -m py_compile *.py` 通过;`demo.py` 增加 `UPDATE=1` 提交开关并语法校验通过。 +- 决策: + - 浏览器自动化用自研 `cdp.py`(websocket-client + requests),不引入 playwright/selenium。 + - 必须清代理环境变量 + `suppress_origin=True` + `--remote-allow-origins=*`,否则连不上 CDP。 + - SPA 就绪用“标题框+图片 itembox+上传框”三元素存在判断,不用 load 事件。 + - 封面由位置决定,用模拟鼠标拖拽到第一位;「更新」默认不点,`UPDATE=1` 才提交。 + - 下一阶段做多账号管理 + GUI,GUI 暂定 Tkinter(待维护者确认)。 +- 下一步:从 `docs/06-tasks.md` 领取 T-001(抽 `editor.py`)或 T-002(`accounts.json` + `config.py`)。 + +## 【2026-06-25】决策 · 多账号隔离方案 + +- 状态:DONE(仅文档决策,未涉及代码) +- 变更:更新 `docs/04-architecture.md`(新增 3.0 多账号隔离方案决策表)、`docs/02-requirements.md`(功能加“生成快捷方式 P1”、决策表加“多账号隔离方式”)、`docs/03-tech-stack.md`(决策记录)、`docs/api.md`(chrome 模块加 `create_shortcut`)、`docs/06-tasks.md`(新增 T-105 生成快捷方式)。 +- 决策: + - 多账号隔离采用**方案 B:每账号独立 user-data-dir**,否决方案 A(Chrome profile)。理由:`--remote-debugging-port` 绑在 user-data-dir/进程上而非 profile,方案 A 无法每账号独立 CDP 端点、无法并行、串号风险高。 + - 启动主路径用程序 `subprocess` 直接带参数启动;快捷方式为可选便利,用 PowerShell `WScript.Shell.CreateShortcut` 生成,参数须写在快捷方式「目标」字段而非「起始位置」。 +- 下一步:实现 `chrome.py`(T-102 启动器 + T-105 快捷方式),或先做 T-001/T-002 地基。 + +## 【2026-06-25】决策 · 双 Tab + 配置文件 + SQLite + Excel 批量 + 登录检测 + 导出 + +- 状态:DONE(仅文档决策,未涉及代码) +- 变更:系统性更新 `docs/02-requirements.md`(第二版功能/决策表/风险)、`docs/03-tech-stack.md`(config.json+SQLite+openpyxl)、`docs/04-architecture.md`(双 Tab 结构、4.1 config.json、4.2 SQLite schema、4.3 Excel 模板、5.x 批量与登录检测流程、第六节加登录检测事实)、`docs/api.md`(拆 appconfig/db/excel 模块、editor 加登录检测与 run_task)、`docs/06-tasks.md`(重排 Phase 0-4)、`docs/routes.md`(重写为双 Tab 线框)、`docs/00-ai-start-here.md`、`docs/current-state.md`。 +- 决策: + - 存储拆两层:应用设置 `config.json` + 业务数据 SQLite `cmshopee.db`,取代早期 `accounts.json`。 + - GUI 双 Tab:Tab1 Excel 批量(导入/列表/执行/导出),Tab2 账号管理(密码加密仅参考、登录检测)。 + - 账号↔任务绑定以 Excel“别名”列为权威(弃文件名);未匹配略过,结束弹窗汇总。 + - Excel 模板列:别名/商品id/新标题/新封面图片路径/旧标题(留档)/旧封面图片路径(留档)/更新状态(输出结果)。 + - 是否提交更新由 Tab1 批次级开关控制,默认关;多账号串行、单条失败继续。 + - 密码不自动登录、仅参考、加密存;人工登录 + 程序检测登录态。 + - 引入 openpyxl;DB/config/用户数据目录均 gitignore。 +- 下一步:从 Phase 0 起步(T-001 editor.py / T-002 appconfig / T-003 db.py,任选其一)。 + +## 【2026-06-25】修正 · 旧数据为程序输出 + 回写时机 + 文件锁 + +- 状态:DONE(仅文档修正) +- 变更:`docs/04-architecture.md`(4.2 schema 注释、4.3 列性质、3.x 关键事实、5.1 流程)、`docs/api.md`(`run_task` 抓旧值、`update_task_result` 加旧数据参数、`export_results`→`write_back` 回写原文件 + `export_copy` 退路)、`docs/06-tasks.md`(T-303/304/305)、`docs/02-requirements.md`(功能行 + 决策表)。 +- 决策修正: + - 「旧标题/旧封面」由“运营输入/留档”改为 **程序改动前抓取的快照(输出列)**,用于审计/回滚。 + - SQLite **逐条实时写回**(每条任务处理完立即写状态+旧数据),不是批末统一写。 + - Excel **回写到原文件**:该 Excel 全部任务完成后,批量把旧数据+更新状态写回原 .xlsx;原文件被锁则提示关闭重试或 `export_copy` 另存副本。SQLite 为事实来源。 +- 下一步:Phase 0 起步(T-001/T-002/T-003)。 + +## 【2026-06-25】扩展 · 5 Tab 流水线 + AI 生成 + 采集回写 + 首次引导 + +- 状态:DONE(仅文档) +- 变更:重写 `docs/routes.md`(5 Tab + AI生成界面)、`docs/04-architecture.md`(流水线/ai 模块/采集/本地图片目录/扩展 tasks schema)、`docs/api.md`(新增 `ai` 模块、editor 加采集 read_cover/download_cover/collect、db 拆 set_collected/generated/confirmed/applied)、`docs/06-tasks.md`(Phase 0-5 按 5 Tab 重排);增量更新 `docs/02-requirements.md`、`docs/03-tech-stack.md`(AI 依赖待定)、`docs/00-ai-start-here.md`、`docs/current-state.md`。 +- 决策: + - GUI 定为 **5 Tab,工作流优先顺序**:① 导入采集 ② AI生成 ③ 更新shopee ④ 账号管理 ⑤ 设置。 + - 流水线五阶段:imported→collected→generated→confirmed→applied,stage 字段贯穿。 + - 「采集」只读抓旧标题+下载旧封面到本地并回写 Excel;「旧数据」为程序输出(已确认)。 + - 新增 AI:提示词+旧标题→新标题、提示词+旧封面→新封面(image-to-image);**服务商/模型待定**;产出必须人工确认才进更新。 + - Excel 增 `账号名` 输入列;输出列扩为 旧标题/旧封面/新标题/新封面/更新状态。 + - 本地 `images//_old|_new` 存图,路径记 DB;`images/` 加 gitignore。 + - 首次未配账号/未登录:① ③ 执行按钮禁用并提示去④。 +- 待确认:AI 服务商/模型/计费/合规;密码与 AI Key 加密密钥来源。 +- 下一步:Phase 0 起步(T-001/T-002/T-003)。 + +## 【2026-06-25】增补 · Tab① 导入汇总栏 + +- 状态:DONE(文档 + 效果图) +- 变更:`docs/routes.md`(Tab① 加导入汇总栏 + 说明)、`docs/02-requirements.md`(功能加“导入校验汇总”)、`docs/api.md`(`import_tasks` 返回 stats、新增 `match_summary`)、`docs/06-tasks.md`(新增 T-202b)、`docs/ui/tab1-import-collect.svg`(加汇总栏)。 +- 决策:导入多个 Excel 后即时展示 文件数/解析行数/有效/无效/匹配(按账号细分)/未匹配,作为跑采集前的校验关口;未匹配/无效可点击在列表筛出。与执行后的“结束弹窗汇总”互补。 +- 下一步:Phase 0 起步(T-001/T-002/T-003)。 + +## 【2026-06-25】简化 · 去掉人工确认 + 去掉提交开关 + Tab② 左右布局 + +- 状态:DONE(文档;SVG 同步) +- 变更:`docs/04-architecture.md`(流水线去 confirmed、schema 删 confirmed 列、config 删 commit_update_default、5.x/6.x 流程、第七/十节)、`docs/api.md`(删 set_confirmed、apply_task 去 commit 参数恒提交、ai 说明)、`docs/routes.md`(Tab② 左右布局无确认、Tab③ 去开关/改“已生成”、流程/组件)、`docs/06-tasks.md`(T-302/303/401/402、里程碑)、`docs/02-requirements.md`、`docs/03-tech-stack.md`、`docs/00-ai-start-here.md`、`docs/current-state.md`;SVG `tab2/tab3/overview`。 +- 决策(用户拍板): + - **去掉人工确认**:流水线 imported→collected→generated→applied(删 confirmed 阶段)。 + - **去掉提交开关**:③ 执行即点「更新」总是提交线上。 + - Tab② 改左右布局:左 ~1/4 提示词(标题/封面),右 筛选(批次/店铺)+任务列表(店铺/商品id/旧标题/新标题),双击弹窗看新旧封面(纯查看);底部「开始生成」。 + - 新标题不可编辑,直接用 AI 输出。 + - 已知并接受风险:AI 标题/封面经 ③ 直接上线无核对,本地留档+回写 Excel 为唯一追溯。 +- 下一步:Phase 0 起步(T-001/T-002/T-003)。 + +## 【2026-06-25】AI 生成 · 并发参数 + 单按钮两段式 + +- 状态:DONE(文档 + SVG) +- 变更:`docs/04-architecture.md`(config.json ai 段加 title_concurrency/image_concurrency/retry/resolution/jpg_quality;6.2 两段式并发流程)、`docs/api.md`(`gen_title`/`gen_cover` 加重试/分辨率/质量、新增 `generate_batch` 并发编排、`ai_config` 字段)、`docs/routes.md`(Tab② 单按钮+停止+进度、参数移⑤、⑤ 设置补参数)、`docs/06-tasks.md`(T-301/303/501)、`docs/03-tech-stack.md`(concurrent.futures 并发、Pillow 存盘);SVG `tab2`(单按钮+停止+进度、参数移⑤)、`tab5`(AI 段加 5 参数)。 +- 决策(用户): + - 配置(⑤)新增:标题并发数、图片并发数、失败重试次数、分辨率、jpg 质量。 + - Tab② 单个「开始生成」+「停止」;点击后**先按 title_concurrency 并发生成标题,接着按 image_concurrency 并发生成图片**;进度显示 标题/封面/失败。 + - 每条/每张完成即写库;停止取消未开始项,可继续。并发用 `concurrent.futures.ThreadPoolExecutor`。 +- 下一步:Phase 0 起步(T-001/T-002/T-003)。 + +## 【2026-06-25】AI 生成 · 提示词管理 + +- 状态:DONE(文档 + SVG) +- 变更:`docs/04-architecture.md`(存储加 title_prompt.txt + prompts/cover/、6.2 提示词管理与变量、目录结构)、`docs/api.md`(新增 `prompts` 模块:标题存取、封面模板 CRUD、`render_prompt`)、`docs/routes.md`(Tab② 左侧提示词管理说明)、`docs/02-requirements.md`(提示词管理功能行)、`docs/06-tasks.md`(T-302p)、SVG `tab2`(标题加保存、封面加模板下拉+图标工具栏+插入标题/预览)。 +- 决策(用户): + - 标题提示词:单文件 `title_prompt.txt` +「保存」;启动回显。 + - 封面提示词:多模板,存 `prompts/cover/<名>.txt`;下拉 + 图标工具栏(新建/保存/另存为/重命名/删除)+「插入标题」(=插 `{新标题}`)+「预览」。 + - 变量 `{旧标题}/{新标题}/{商品id}/{店铺}`,生成前 `render_prompt` 替换;预览=替换后查看。 +- 下一步:Phase 0 起步(T-001/T-002/T-003)。 + +## 【2026-06-25】更新shopee · 加批次/店铺/状态筛选 + +- 状态:DONE(文档 + SVG) +- 变更:`docs/routes.md`(Tab③ 加筛选栏 + 「开始更新」作用于当前筛选 + 状态=失败重试说明)、`docs/02-requirements.md`(③ 功能行)、`docs/06-tasks.md`(T-401)、SVG `tab3`(顶部加批次/店铺/状态筛选行,表与按钮下移避让)。 +- 决策(用户):③ 与 ①② 一致加筛选;店铺筛选利于逐店铺更新,状态筛选用于失败重试;「开始更新」仅作用于当前筛选结果(兼作范围控制/安全)。 +- 下一步:Phase 0 起步(T-001/T-002/T-003)。 + +## 【2026-06-26】AI 模型配置 · 模型清单 + 角色分配 + 分辨率/超时 + +- 状态:DONE(文档 + SVG) +- 变更:`docs/04-architecture.md`(config.json ai 段改为 default_text/image_model + resolution + resolution_timeouts;新增 5.1b `config/ai_models.json` schema 含 category/api_type/connect_timeout;存储/目录/gitignore;6.2 模型选择与超时派生)、`docs/api.md`(appconfig 加 response_timeout、模型 CRUD/测试连接、get_model;ai 模块超时来源)、`docs/02-requirements.md`(⑤ 功能)、`docs/03-tech-stack.md`(AI 多模型行)、`docs/06-tasks.md`(T-501 模型管理 + T-501b 角色与参数);SVG `tab5` 重画为模型管理(master-detail)+角色下拉+分辨率/超时+路径。 +- 决策(用户 + 分析): + - 模型清单 `config/ai_models.json`:每模型加 `category`(text/image)、唯一 name、api_type、`connect_timeout_seconds`(默认30,每模型)。 + - ⑤ 设置:模型下拉 + 新增/删除/测试连接 + 详情(服务商名/类别/api_type/模型ID/网址/密钥打码);至少各一个 text+image。 + - 角色:标题大模型/图片大模型下拉(按 category 过滤)→ config.json default_text/image_model。 + - 分辨率下拉 512/1k/2k/4k;**返回超时由 resolution_timeouts 映射自动**(180/240/360/600),不单独填。 + - 安全:密钥加密、打码、不入日志;`config/` 必须 gitignore;提醒轮换已暴露的 Key。 +- 下一步:Phase 0 起步(T-001/T-002/T-003)。 + +## 【2026-06-26】整理 · 测试脚本迁入 prototypes/ + +- 状态:DONE +- 变更:`git mv` 把 `demo.py`/`set_title.py`/`set_cover.py`/`get_title.py`/`cookies.py`/`inspect_images.py`/`grab.py`/`1.py` 移入 `prototypes/`;`cdp.py` 留根目录(正式模块);新增 `prototypes/README.md`;批量把文档里的脚本引用改为 `prototypes/` 路径(含 04/00/03/05/06/api/AGENTS、current-state);修掉 `config.json` 里残留的 `api_key_enc` 描述(密钥已移至 ai_models.json)。 +- 决策:这些是已验证原型/探查脚本,逻辑待 `editor.py`(T-001) 移植并实测后清理;现保留为唯一已验证参照。`1.py` 作为 `ai.py` 调用方式参考。 +- 注意:`prototypes/demo.py` 依赖根目录 `cdp.py`,需从项目根运行或设 PYTHONPATH。 +- 下一步:Phase 0 起步(T-001/T-002/T-003)。 + + diff --git a/prototypes/README.md b/prototypes/README.md new file mode 100644 index 0000000..a29fcc4 --- /dev/null +++ b/prototypes/README.md @@ -0,0 +1,25 @@ +# prototypes —— 已验证原型 / 探查脚本 + +这些是单账号阶段写的**已验证脚本**,逻辑与关键事实将被正式模块(尤其 `editor.py`,见任务 T-001)移植。 +**待 `editor.py` 完成并实测通过后,本目录可清理删除。** 在此之前保留它们作为"唯一已验证参照"。 + +## 文件 + +| 文件 | 用途 | 移植去向 | +| --- | --- | --- | +| `demo.py` | 单账号端到端:改标题 + 换封面(UPDATE=1 提交) | `editor.py` 编排 | +| `set_title.py` | 改标题(原生 setter + 事件,value/modelvalue 验证) | `editor.change_title` | +| `set_cover.py` | 上传图片 + 拖到第一位设封面(含选择器、拖拽落点) | `editor.replace_cover` | +| `get_title.py` | 找/开商品 tab 并读取标题 | `editor.open_product` / `read_title` | +| `cookies.py` | 读 shopee.tw 标签页 Cookie/会话 | `editor.is_logged_in` 的依据 | +| `inspect_images.py` | 只读探查图片管理器 DOM(**Shopee 改版时重新探查可复用**) | 工具,保留参考 | +| `grab.py` | 抓商品页 HTML + 列表接口 JSON(早期探查) | 参考 | +| `1.py` | OpenAI SDK 最小调用测试 | `ai.py` 调用方式参考 | + +## 运行注意 + +- **`cdp.py` 在项目根目录**(正式模块),不在本目录。 +- 仅 `demo.py` `import cdp`;其余脚本各自内置了 CDP 类,可独立运行。 + - 跑 `demo.py` 需让根目录的 `cdp.py` 可被导入(从项目根运行,或设 `PYTHONPATH=项目根`)。 +- 这些脚本默认连开发期的 WSL 转发地址 `192.168.0.224:9333`;正式模块用 `127.0.0.1:9222`(可用 `CDP_HOST` 覆盖)。 +- 已验证的关键事实(选择器、SPA 就绪判断、上传/拖拽方式)权威记录见 [`../docs/04-architecture.md`](../docs/04-architecture.md) 第七节。 diff --git a/prototypes/cookies.py b/prototypes/cookies.py new file mode 100644 index 0000000..52e7b03 --- /dev/null +++ b/prototypes/cookies.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# cookies.py —— 连 CDP,遍历所有 tab,找到域名含 shopee.tw 的 tab,取 cookie 并打印。 +# +# 依赖:pip install websocket-client requests +# 运行:python3 cookies.py +# 环境变量:CDP_HOST(默认 192.168.0.224:9333)、DOMAIN(默认 shopee.tw) + +import os + +# CDP 是局域网直连,绝不能走代理。WSL 里设了 *_proxy(指向 :1080), +# requests / websocket-client 都会读它导致超时。启动即清掉,最干净。 +for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"): + os.environ.pop(_k, None) + +import json + +import requests +from websocket import create_connection + +CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333") +BASE = f"http://{CDP_HOST}" +DOMAIN = os.environ.get("DOMAIN", "shopee.tw") + + +def cdp_call(ws_url, method, params=None): + # suppress_origin=True:不发 Origin 头,避免 Chrome 在未开 --remote-allow-origins 时返回 403。 + ws = create_connection(ws_url, max_size=None, suppress_origin=True) + try: + ws.send(json.dumps({"id": 1, "method": method, "params": params or {}})) + while True: + msg = json.loads(ws.recv()) + if msg.get("id") == 1: + if "error" in msg: + raise RuntimeError(msg["error"].get("message")) + return msg.get("result", {}) + finally: + ws.close() + + +def main(): + print(f"[*] 读取目标列表: {BASE}/json") + sess = requests.Session() + sess.trust_env = False # 双保险:忽略环境代理 + targets = sess.get(f"{BASE}/json", timeout=10).json() + pages = [t for t in targets if t.get("type") == "page"] + + print(f"[*] 共 {len(pages)} 个 page tab:") + for i, p in enumerate(pages): + print(f" [{i}] {p.get('url')}") + + matches = [p for p in pages if DOMAIN in (p.get("url") or "")] + if not matches: + print(f"[!] 没有找到 URL 含 '{DOMAIN}' 的 tab。") + return + + for p in matches: + print(f"\n[*] 命中 tab: {p['url']}") + result = cdp_call(p["webSocketDebuggerUrl"], "Network.getCookies") + cookies = result.get("cookies", []) + print(f"[*] 共 {len(cookies)} 个 cookie:\n") + for c in cookies: + print(f" {c['name']} = {c['value']}") + print(f" domain={c.get('domain')} path={c.get('path')} " + f"httpOnly={c.get('httpOnly')} secure={c.get('secure')}") + + # 同时给一份可直接用于请求头的 Cookie 串 + header = "; ".join(f"{c['name']}={c['value']}" for c in cookies) + print(f"\n[*] Cookie 请求头格式:\n{header}") + + +if __name__ == "__main__": + main() diff --git a/prototypes/demo.py b/prototypes/demo.py new file mode 100644 index 0000000..b9929f1 --- /dev/null +++ b/prototypes/demo.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +# demo.py —— 演示编排:打开商品页 → 改标题 → 上传新封面并拖到首位。分步停顿、不保存。 +# +# 给运营同事演示用。每次运行都先把页面导航到干净状态(未保存过,重载即回到原始标题/图片), +# 因此可反复演示。全程不点保存,绝不改动线上商品。 +# +# 依赖:pip install websocket-client requests +# 运行:python3 demo.py (分步,按 Enter 推进——适合讲解) +# AUTO=1 python3 demo.py (一气呵成,自动推进) +# 环境变量:CDP_HOST、ITEM_ID(默认 51100639510)、IMG_WIN(封面图 Windows 路径)、NEW_TITLE + +import os +import sys +import json +import time + +# 确保能 import 同目录的 cdp.py(某些 Windows Python 启动方式不会自动把脚本目录加进 sys.path) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from cdp import CDP, http_get, find_product_tab, create_tab + +ITEM_ID = os.environ.get("ITEM_ID", "51100639510") +IMG_WIN = os.environ.get("IMG_WIN", r"D:\chengma\cmshopee\1_TY030.jpg") +PRODUCT_URL = f"https://seller.shopee.tw/portal/product/{ITEM_ID}?pageEntry=product_list&ignore-html-cache=1" +AUTO = os.environ.get("AUTO") == "1" +DO_UPDATE = os.environ.get("UPDATE") == "1" # 默认不点「更新」;UPDATE=1 才真实提交到线上 + +TITLE_XPATH = "//input[@class='eds-input__input' and string-length(@modelvalue)>24]" +ITEMBOX_XPATH = "//div[@class='container']/div[@class='can-drag shopee-image-manager__itembox' and @data-draggable='true']" + + +# ---------- 小工具 ---------- +def banner(text): + print("\n" + "=" * 60 + f"\n {text}\n" + "=" * 60) + + +def pause(msg): + if AUTO: + print(f"\n>>> {msg}") + time.sleep(1.5) + else: + try: + input(f"\n>>> {msg} (按 Enter 继续)") + except EOFError: + time.sleep(1.5) + + +# ---------- 页面 JS ---------- +def js_read_title(): + return ( + "(function(){" + f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," + "XPathResult.FIRST_ORDERED_NODE_TYPE,null);var el=r.singleNodeValue;" + "return el?el.value:null;})()" + ) + + +def js_read_modelvalue(): + return ( + "(function(){" + f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," + "XPathResult.FIRST_ORDERED_NODE_TYPE,null);var el=r.singleNodeValue;" + "return el?el.getAttribute('modelvalue'):null;})()" + ) + + +def js_write_title(t): + return ( + "(function(){" + f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," + "XPathResult.FIRST_ORDERED_NODE_TYPE,null);var el=r.singleNodeValue;" + "if(!el)return 'NO_INPUT';" + "var s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;" + f"el.focus();s.call(el,{json.dumps(t)});" + "el.dispatchEvent(new Event('input',{bubbles:true}));" + "el.dispatchEvent(new Event('change',{bubbles:true}));el.blur();return el.value;})()" + ) + + +JS_RECTS = ( + "(function(){" + f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);" + "var a=[];for(var i=0;i0 && !!r.singleNodeValue && !!up);})()" +) + + +# 定位「更新」按钮:button.eds-button 中 span 文本为“更新”的可见按钮 +JS_FIND_UPDATE = ( + "(function(){var bs=[].slice.call(document.querySelectorAll('button.eds-button'));" + "var b=bs.find(function(b){var sp=b.querySelector('span');" + "return (sp?sp.innerText:(b.innerText||'')).trim()==='更新' && b.offsetParent!==null;});" + "if(!b)return JSON.stringify({found:false});" + "var dis=b.disabled||/disabled/i.test(b.className);" + "return JSON.stringify({found:true,disabled:dis});})()" +) + +JS_CLICK_UPDATE = ( + "(function(){var bs=[].slice.call(document.querySelectorAll('button.eds-button'));" + "var b=bs.find(function(b){var sp=b.querySelector('span');" + "return (sp?sp.innerText:(b.innerText||'')).trim()==='更新' && b.offsetParent!==null;});" + "if(!b)return 'NO_BTN';if(b.disabled||/disabled/i.test(b.className))return 'DISABLED';" + "b.click();return 'CLICKED';})()" +) + +# 提交后抓页面提示(toast/message),用于判断成功或失败 +JS_TOASTS = ( + "(function(){var es=[].slice.call(document.querySelectorAll(" + "'[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]'));" + "var t=es.filter(function(e){return e.offsetParent!==null;})" + ".map(function(e){return (e.innerText||'').trim();}).filter(Boolean);" + "return JSON.stringify(t.slice(0,5));})()" +) + + +def rects(cdp): + return json.loads(cdp.val(JS_RECTS)) + + +def short(src): + return src.rsplit("/", 1)[-1] if src else src + + +# ---------- 流程 ---------- +def connect_clean_page(): + """定位或新建商品 tab,导航到干净状态并等编辑器就绪。""" + tab = find_product_tab(ITEM_ID) + if tab: + print(f"[*] 复用已打开的商品 tab") + ws = tab["webSocketDebuggerUrl"] + else: + print(f"[*] 未找到商品 tab,新建并打开商品页") + ws = create_tab(PRODUCT_URL) + cdp = CDP(ws) + cdp.send("Page.enable") + cdp.send("Runtime.enable") + cdp.send("DOM.enable") + # 把该 tab 切到前台:演示时同事看得见,也避免后台标签被 Chrome 限流导致迟迟不渲染 + try: + cdp.send("Page.bringToFront") + except Exception: + pass + print(f"[*] 导航到干净状态:{PRODUCT_URL}") + cdp.send("Page.navigate", {"url": PRODUCT_URL}) + + print("[*] 等待编辑器渲染就绪 ...", end="", flush=True) + end = time.time() + 60 + while time.time() < end: + time.sleep(1) + try: + if cdp.val(JS_READY): + print(" 就绪 ✅") + cdp.val("(function(){var m=document.querySelector('.shopee-image-manager');" + "if(m)m.scrollIntoView({block:'center'});return 1;})()") + time.sleep(0.5) + return cdp + except Exception: + pass + print(".", end="", flush=True) + raise TimeoutError("等待编辑器就绪超时") + + +def step_show_title(cdp): + banner("步骤 1 / 读取当前商品标题") + original = cdp.val(js_read_title()) + print(f"当前标题:\n {original}") + return original + + +def step_change_title(cdp, original): + new_title = os.environ.get("NEW_TITLE") or original[:-2] # 默认去掉结尾两个字 + banner("步骤 2 / 修改标题") + print(f"新标题(去掉结尾两字):\n {new_title}") + pause("开始写入新标题") + cdp.val(js_write_title(new_title)) + time.sleep(0.4) + value = cdp.val(js_read_title()) + model = cdp.val(js_read_modelvalue()) + print(f" 写入后 value = {value}") + print(f" 写入后 modelvalue = {model}") + ok = value == new_title and model == new_title + print(" 结果:" + ("✅ 标题已更新(value 与 modelvalue 一致,Vue 模型已绑定)" if ok else "❌ 写入异常")) + return new_title + + +def step_upload_and_cover(cdp): + banner("步骤 3 / 上传新封面图并拖到首位") + before = rects(cdp) + before_srcs = {r["src"] for r in before} + print(f"上传前共 {len(before)} 张图:") + for r in before: + print(f" [{r['i']}] {short(r['src'])}") + + pause(f"开始上传封面图:{IMG_WIN}") + oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')") + if not oid: + print("[!] 没找到上传输入框,终止。"); return + cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [IMG_WIN]}) + + print("[*] 等待上传完成(新图出现且为 CDN 链接)...") + new_src = None + end = time.time() + 90 + while time.time() < end: + time.sleep(1.5) + cur = rects(cdp) + ready = [r for r in cur if r["src"] not in before_srcs + and r["src"] and "susercontent" in r["src"] and "blob:" not in r["src"]] + if len(cur) > len(before) and ready: + new_src = ready[-1]["src"] + print(f"[+] 上传成功:{short(new_src)}(当前 {len(cur)} 张)") + break + if not new_src: + print("[!] 上传未确认成功,请看浏览器。"); return + + pause("开始把新图拖到第一位(封面)") + cur = rects(cdp) + new = next(r for r in cur if r["src"] == new_src) + first = cur[0] + tx = first["left"] - first["w"] * 0.30 # 落点在第一张左缘外侧,才会插到最前 + ty = first["y"] + print(f"[*] 拖拽:新图[{new['i']}] -> 首位前") + cdp.drag(new["x"], new["y"], tx, ty) + time.sleep(1.2) + + after = rects(cdp) + print(f"\n拖拽后顺序({len(after)} 张):") + for r in after: + print(f" [{r['i']}] {short(r['src'])}" + (" <== 新封面" if r["src"] == new_src else "")) + print("\n结果:" + ("✅ 新图已在第一位(封面)" if after and after[0]["src"] == new_src + else "❌ 新图未到第一位")) + + +def step_update(cdp): + banner("步骤 4 / 点击「更新」提交(⚠ 持久化到线上商品)") + info = json.loads(cdp.val(JS_FIND_UPDATE)) + if not info.get("found"): + print("[!] 没找到「更新」按钮,跳过。") + return + if info.get("disabled"): + print("[!] 「更新」按钮当前为【禁用】状态——通常意味着有未通过的必填/校验项,不点击。") + print(" 请到浏览器查看是否有红字校验提示。") + return + print("[*] 找到可点击的「更新」按钮。") + pause("⚠ 即将点击「更新」,这会把标题+封面真实提交到线上商品") + r = cdp.val(JS_CLICK_UPDATE) + print(f"[*] 点击结果:{r}") + if r != "CLICKED": + return + time.sleep(3) + toasts = json.loads(cdp.val(JS_TOASTS)) + if toasts: + print("[*] 页面提示:" + " | ".join(toasts)) + print("[*] 已提交。请到浏览器确认是否出现「更新成功」之类提示。") + + +def main(): + banner("Shopee 商品改标题 + 换封面 自动化演示" + ("(含更新提交)" if DO_UPDATE else "(不保存)")) + cdp = connect_clean_page() + try: + original = step_show_title(cdp) + pause("进入步骤 2:修改标题") + step_change_title(cdp, original) + pause("进入步骤 3:上传并替换封面") + step_upload_and_cover(cdp) + + if DO_UPDATE: + pause("进入步骤 4:点击「更新」提交") + step_update(cdp) + banner("演示结束") + print("已点击「更新」,改动应已提交线上。请到浏览器核对提示。") + else: + banner("演示结束") + print("以上改动全部停留在页面内存,未保存、未提交线上。") + print("如需真正生效:UPDATE=1 python3 demo.py(脚本会自动点「更新」),") + print("或直接到浏览器点「更新」。刷新页面则一切还原。") + finally: + cdp.close() + + +if __name__ == "__main__": + main() diff --git a/prototypes/get_title.py b/prototypes/get_title.py new file mode 100644 index 0000000..f6b4c35 --- /dev/null +++ b/prototypes/get_title.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# get_title.py —— 找到(或新建)商品编辑页 tab,读取标题输入框的当前内容。 +# +# 逻辑: +# 1. 遍历所有 page tab,找 URL 同时含 ITEM_ID 和 shopee.tw 的编辑页; +# 2. 没有就用 Target.createTarget 新建 tab 打开 PRODUCT_URL; +# 3. 等页面渲染,轮询 XPath 取标题输入框,打印其内容。 +# +# 依赖:pip install websocket-client requests +# 运行:python3 get_title.py +# 环境变量:CDP_HOST(默认 192.168.0.224:9333)、ITEM_ID(默认 51100639510) + +import os + +# CDP 局域网直连,绝不走代理(WSL 里设了 *_proxy 指向 :1080,会导致超时)。 +for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"): + os.environ.pop(_k, None) + +import json +import time +import threading + +import requests +from websocket import create_connection + +CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333") +BASE = f"http://{CDP_HOST}" +ITEM_ID = os.environ.get("ITEM_ID", "51100639510") +PRODUCT_URL = f"https://seller.shopee.tw/portal/product/{ITEM_ID}?pageEntry=product_list&ignore-html-cache=1" + +# 标题输入框:eds 输入框且 modelvalue 长度 > 24(排除短输入框) +TITLE_XPATH = "//input[@class='eds-input__input' and string-length(@modelvalue)>24]" + + +def http_get(path): + s = requests.Session() + s.trust_env = False + return s.get(f"{BASE}{path}", timeout=10).json() + + +class CDP: + """极简 CDP 客户端:命令同步、事件回调异步。""" + + def __init__(self, ws_url): + # suppress_origin=True:不发 Origin 头,避免 Chrome 未开 --remote-allow-origins 时 403。 + self.ws = create_connection(ws_url, max_size=None, suppress_origin=True) + self._id = 0 + self._results = {} + self._cond = threading.Condition() + self._handlers = [] + self._stop = False + threading.Thread(target=self._recv_loop, daemon=True).start() + + def _recv_loop(self): + while not self._stop: + try: + raw = self.ws.recv() + except Exception: + break + if not raw: + continue + msg = json.loads(raw) + if "id" in msg: + with self._cond: + self._results[msg["id"]] = msg + self._cond.notify_all() + elif "method" in msg: + for h in self._handlers: + try: + h(msg) + except Exception: + pass + + def on(self, fn): + self._handlers.append(fn) + + def send(self, method, params=None, timeout=30): + self._id += 1 + mid = self._id + self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}})) + deadline = time.time() + timeout + with self._cond: + while mid not in self._results: + left = deadline - time.time() + if left <= 0: + raise TimeoutError(f"CDP {method} 超时") + self._cond.wait(left) + msg = self._results.pop(mid) + if "error" in msg: + raise RuntimeError(f"{method} 失败: {msg['error'].get('message')}") + return msg.get("result", {}) + + def close(self): + self._stop = True + try: + self.ws.close() + except Exception: + pass + + +def find_edit_tab(): + pages = [t for t in http_get("/json") if t.get("type") == "page"] + print(f"[*] 当前 {len(pages)} 个 page tab:") + for i, p in enumerate(pages): + print(f" [{i}] {p.get('url')}") + for p in pages: + url = p.get("url") or "" + if ITEM_ID in url and "shopee.tw" in url: + return p + return None + + +def open_edit_tab(): + """用 browser 级 Target.createTarget 新建 tab,返回其 targetId。""" + ver = http_get("/json/version") + browser_ws = ver["webSocketDebuggerUrl"] + bcdp = CDP(browser_ws) + try: + res = bcdp.send("Target.createTarget", {"url": PRODUCT_URL}) + return res["targetId"] + finally: + bcdp.close() + + +def wait_page_ws(target_id, timeout=15): + """轮询 /json 等到新 tab 的 page websocket 出现。""" + deadline = time.time() + timeout + while time.time() < deadline: + for t in http_get("/json"): + if t.get("id") == target_id and t.get("webSocketDebuggerUrl"): + return t["webSocketDebuggerUrl"] + time.sleep(0.5) + raise TimeoutError("等待新 tab 的 websocket 超时") + + +def read_title(cdp, timeout=40): + """轮询 XPath,取标题输入框内容(value 优先,回退 modelvalue 属性)。""" + expr = ( + "(function(){" + f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," + "XPathResult.FIRST_ORDERED_NODE_TYPE,null);" + "var el=r.singleNodeValue;" + "if(!el)return null;" + "return JSON.stringify({value:el.value,modelvalue:el.getAttribute('modelvalue')});" + "})()" + ) + deadline = time.time() + timeout + while time.time() < deadline: + r = cdp.send("Runtime.evaluate", {"expression": expr, "returnByValue": True}) + val = r.get("result", {}).get("value") + if val: + return json.loads(val) + time.sleep(1) + return None + + +def main(): + tab = find_edit_tab() + if tab: + print(f"\n[*] 已存在编辑页 tab: {tab['url']}") + page_ws = tab["webSocketDebuggerUrl"] + else: + print(f"\n[*] 未找到编辑页 tab,新建并打开:\n {PRODUCT_URL}") + tid = open_edit_tab() + page_ws = wait_page_ws(tid) + print(f"[*] 新 tab 已就绪 (targetId={tid})") + + cdp = CDP(page_ws) + try: + cdp.send("Page.enable") + cdp.send("Runtime.enable") + print("[*] 等待标题输入框渲染...") + title = read_title(cdp) + finally: + cdp.close() + + if not title: + print("[!] 未匹配到标题输入框。页面可能没加载完,或 XPath 需要调整。") + return + content = title.get("value") or title.get("modelvalue") or "" + print("\n========== 商品标题 ==========") + print(content) + print("==============================") + print(f"\n(value={title.get('value')!r} modelvalue={title.get('modelvalue')!r})") + + +if __name__ == "__main__": + main() diff --git a/prototypes/grab.py b/prototypes/grab.py new file mode 100644 index 0000000..149f001 --- /dev/null +++ b/prototypes/grab.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# grab.py —— 通过 CDP 连接已登录的 Chrome,保存“我的商品”第一页渲染后的 HTML, +# 并拦截列表接口的 JSON 响应。 +# +# 前置:Windows 上 Chrome 已带 --remote-debugging-port 启动,且 portproxy 把 +# 0.0.0.0:9333 -> 127.0.0.1:9222(见 chrome-remote-debug-lan.md)。 +# 依赖:pip install websocket-client requests +# 运行:python3 grab.py +# +# 环境变量(可选): +# CDP_HOST CDP 地址,默认 192.168.0.224:9333 +# OUT_DIR 输出目录,默认 ./out + +import os +import re +import json +import time +import base64 +import pathlib +import datetime +import threading + +import requests +from websocket import create_connection + +CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333") +BASE = f"http://{CDP_HOST}" +OUT_DIR = pathlib.Path(os.environ.get("OUT_DIR", pathlib.Path(__file__).parent / "out")) + +# 命中这些关键词的 JSON 响应,视为“商品列表接口”重点保存。 +# 不同站点/版本端点名不一样,先广撒网,跑完看 _all_xhr.log 里的真实端点再收窄。 +LIST_HINTS = [ + "get_product_list", + "search_product", + "product/list", + "mpsku/list", + "search_item", + "product_list", + "get_item_list", +] + +# CDP 是局域网直连,绝不能走代理。requests 默认会读 *_proxy 环境变量,这里强制不用代理。 +NO_PROXY = {"http": None, "https": None} + + +def ts(): + return datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + + +def safe(s): + return re.sub(r"[^a-z0-9._-]+", "_", s, flags=re.I)[:120] + + +class CDP: + """极简 CDP 客户端:单个 page target 的 WebSocket,命令同步、事件回调异步。""" + + def __init__(self, ws_url): + self.ws = create_connection(ws_url, max_size=None) + self._id = 0 + self._results = {} + self._cond = threading.Condition() + self._handlers = [] + self._stop = False + self._t = threading.Thread(target=self._recv_loop, daemon=True) + self._t.start() + + def _recv_loop(self): + while not self._stop: + try: + raw = self.ws.recv() + except Exception: + break + if not raw: + continue + msg = json.loads(raw) + if "id" in msg: + with self._cond: + self._results[msg["id"]] = msg + self._cond.notify_all() + elif "method" in msg: + for h in self._handlers: + try: + h(msg) + except Exception as e: + print(f"[!] handler 出错: {e}") + + def on(self, fn): + self._handlers.append(fn) + + def send(self, method, params=None, timeout=30): + self._id += 1 + mid = self._id + self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}})) + deadline = time.time() + timeout + with self._cond: + while mid not in self._results: + remaining = deadline - time.time() + if remaining <= 0: + raise TimeoutError(f"CDP {method} 超时") + self._cond.wait(remaining) + msg = self._results.pop(mid) + if "error" in msg: + raise RuntimeError(f"{method} 失败: {msg['error'].get('message')}") + return msg.get("result", {}) + + def close(self): + self._stop = True + try: + self.ws.close() + except Exception: + pass + + +def pick_target(pages): + # 选中卖家中心“我的商品”那一页 + for p in pages: + if re.search(r"seller\..*shopee", p["url"], re.I) and re.search(r"product|portal", p["url"], re.I): + return p + for p in pages: + if re.search(r"seller\..*shopee", p["url"], re.I): + return p + return pages[0] if pages else None + + +def main(): + OUT_DIR.mkdir(parents=True, exist_ok=True) + + print(f"[*] 读取目标列表: {BASE}/json") + targets = requests.get(f"{BASE}/json", proxies=NO_PROXY, timeout=10).json() + pages = [t for t in targets if t.get("type") == "page"] + print(f"[*] 共 {len(pages)} 个 page 标签页:") + for i, p in enumerate(pages): + print(f" [{i}] {p['url']}") + + target = pick_target(pages) + if not target: + raise SystemExit("没找到任何 page 标签页,Chrome 是否带调试参数启动?") + print(f"[*] 使用标签页: {target['url']}") + + cdp = CDP(target["webSocketDebuggerUrl"]) + cdp.send("Page.enable") + cdp.send("Network.enable") + + xhr_lines = [] + tracked = {} # requestId -> url(疑似列表接口,待取 body) + state = {"loaded": False, "hits": 0} + + def on_event(msg): + method = msg["method"] + if method == "Page.loadEventFired": + state["loaded"] = True + return + if method == "Network.responseReceived": + p = msg["params"] + resp, rtype = p["response"], p.get("type") + if rtype not in ("XHR", "Fetch"): + return + mime = (resp.get("mimeType") or "").lower() + if "json" not in mime: + return + url = resp["url"] + xhr_lines.append(f"{resp.get('status')} {url}") + if any(h in url.lower() for h in LIST_HINTS): + tracked[p["requestId"]] = url + return + if method == "Network.loadingFinished": + rid = msg["params"]["requestId"] + url = tracked.pop(rid, None) + if url is None: + return + try: + r = cdp.send("Network.getResponseBody", {"requestId": rid}) + body = base64.b64decode(r["body"]).decode("utf-8", "replace") if r.get("base64Encoded") else r["body"] + state["hits"] += 1 + fname = f"list_{state['hits']}_{safe(url.split('?')[0].rsplit('/', 1)[-1] or 'list')}.json" + (OUT_DIR / fname).write_text(body, encoding="utf-8") + print(f"[+] 命中列表接口 -> out/{fname}") + except Exception as e: + print(f"[!] 取 body 失败 ({url}): {e}") + + cdp.on(on_event) + + print("[*] 重新加载页面以触发接口请求...") + cdp.send("Page.reload", {"ignoreCache": False}) + + # 等 load 事件 + 额外等晚到的 XHR + for _ in range(30): + if state["loaded"]: + break + time.sleep(0.5) + time.sleep(4) + + # 保存渲染后的整页 HTML + r = cdp.send("Runtime.evaluate", { + "expression": "document.documentElement.outerHTML", + "returnByValue": True, + }) + html = "\n" + (r.get("result", {}).get("value") or "") + html_path = OUT_DIR / f"my-products-page1_{ts()}.html" + html_path.write_text(html, encoding="utf-8") + print(f"[+] 已保存 HTML -> {html_path}") + + # 落盘全部 XHR 端点清单 + log_path = OUT_DIR / "_all_xhr.log" + log_path.write_text("\n".join(xhr_lines) + "\n", encoding="utf-8") + print(f"[*] 全部 JSON XHR 端点 -> {log_path}") + if state["hits"] == 0: + print("[!] 未命中列表接口。打开 out/_all_xhr.log 找真实端点,把关键词加进 grab.py 的 LIST_HINTS。") + else: + print(f"[*] 共抓到 {state['hits']} 个列表接口响应。") + + cdp.close() # 仅断开 CDP,不会关你的 Chrome + print("[*] 完成。") + + +if __name__ == "__main__": + main() diff --git a/prototypes/inspect_images.py b/prototypes/inspect_images.py new file mode 100644 index 0000000..e8765f5 --- /dev/null +++ b/prototypes/inspect_images.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# inspect_images.py —— 只读探查商品图片管理器的 DOM 结构,为实现换封面提供依据。 + +import os +for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"): + os.environ.pop(_k, None) + +import json +import time +import threading +import requests +from websocket import create_connection + +CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333") +BASE = f"http://{CDP_HOST}" +ITEM_ID = os.environ.get("ITEM_ID", "51100639510") + + +def http_get(path): + s = requests.Session(); s.trust_env = False + return s.get(f"{BASE}{path}", timeout=10).json() + + +class CDP: + def __init__(self, ws): + self.ws = create_connection(ws, max_size=None, suppress_origin=True) + self._id = 0; self._res = {}; self._c = threading.Condition(); self._stop = False + threading.Thread(target=self._loop, daemon=True).start() + def _loop(self): + while not self._stop: + try: raw = self.ws.recv() + except Exception: break + if not raw: continue + m = json.loads(raw) + if "id" in m: + with self._c: self._res[m["id"]] = m; self._c.notify_all() + def send(self, method, params=None, timeout=30): + self._id += 1; mid = self._id + self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}})) + end = time.time() + timeout + with self._c: + while mid not in self._res: + left = end - time.time() + if left <= 0: raise TimeoutError(method) + self._c.wait(left) + m = self._res.pop(mid) + if "error" in m: raise RuntimeError(m["error"].get("message")) + return m.get("result", {}) + def eval(self, expr): + r = self.send("Runtime.evaluate", {"expression": expr, "returnByValue": True}) + return r.get("result", {}).get("value") + def close(self): + self._stop = True + try: self.ws.close() + except Exception: pass + + +def main(): + pages = [t for t in http_get("/json") if t.get("type") == "page"] + tab = next((p for p in pages if ITEM_ID in (p.get("url") or "") and "shopee.tw" in (p.get("url") or "")), None) + if not tab: + print("[!] 没找到编辑页 tab"); return + cdp = CDP(tab["webSocketDebuggerUrl"]) + cdp.send("Runtime.enable") + + expr = r""" +(function(){ + var xp="//div[@class='container']/div[@class='can-drag shopee-image-manager__itembox' and @data-draggable='true']"; + var snap=document.evaluate(xp,document,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null); + var n=snap.snapshotLength; + var first=n>0?snap.snapshotItem(0):null; + + var fileInputs=[].slice.call(document.querySelectorAll('input[type=file]')).map(function(f){ + return {accept:f.accept,multiple:f.multiple,name:f.name,cls:f.className,hidden:(f.offsetParent===null)}; + }); + + // 在图片管理器范围内找“设为封面/删除”等小按钮 + var mgr=document.querySelector('.shopee-image-manager')||document.body; + var btns=[].slice.call(mgr.querySelectorAll('*')).filter(function(e){ + var t=(e.innerText||'').trim(); + return t && t.length<12 && /(封面|設為|设为|cover|刪除|删除|delete|主圖|主图)/i.test(t); + }).slice(0,12).map(function(e){return {tag:e.tagName,cls:String(e.className).slice(0,80),text:(e.innerText||'').trim()};}); + + // 第一个 itembox 的结构(截断),看 hover 操作层/删除按钮 + var firstHTML=first?first.outerHTML.replace(/\s+/g,' ').slice(0,2500):null; + + // 拖拽机制线索:draggable 属性 / 事件库特征 + var dragInfo=null; + if(first){ + dragInfo={ + htmlDraggable:first.getAttribute('draggable'), + dataDraggable:first.getAttribute('data-draggable'), + cls:first.className + }; + } + // 上传按钮(“新增/上傳/+”) + var addBtn=[].slice.call(mgr.querySelectorAll('*')).filter(function(e){ + var t=(e.innerText||'').trim(); + return t && t.length<12 && /(新增|上傳|上传|添加|\+)/.test(t); + }).slice(0,6).map(function(e){return {tag:e.tagName,cls:String(e.className).slice(0,80),text:(e.innerText||'').trim()};}); + + return JSON.stringify({count:n,fileInputs:fileInputs,actionBtns:btns,addBtns:addBtn,dragInfo:dragInfo,firstHTML:firstHTML},null,2); +})() +""" + out = cdp.eval(expr) + print(out) + cdp.close() + + +if __name__ == "__main__": + main() diff --git a/prototypes/set_cover.py b/prototypes/set_cover.py new file mode 100644 index 0000000..c19b3b9 --- /dev/null +++ b/prototypes/set_cover.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# set_cover.py —— 上传一张图片到商品图片管理器,等上传成功后拖到第一位(封面)。不保存。 +# +# 依赖:pip install websocket-client requests +# 运行:python3 set_cover.py +# 环境变量:CDP_HOST(默认 192.168.0.224:9333)、ITEM_ID(默认 51100639510)、 +# IMG_WIN(Chrome 所在 Windows 上的图片路径,默认 D:\chengma\cmshopee\1_TY030.jpg) + +import os +for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"): + os.environ.pop(_k, None) + +import json +import time +import threading +import requests +from websocket import create_connection + +CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333") +BASE = f"http://{CDP_HOST}" +ITEM_ID = os.environ.get("ITEM_ID", "51100639510") +IMG_WIN = os.environ.get("IMG_WIN", r"D:\chengma\cmshopee\1_TY030.jpg") + +XPATH = "//div[@class='container']/div[@class='can-drag shopee-image-manager__itembox' and @data-draggable='true']" + + +def http_get(path): + s = requests.Session(); s.trust_env = False + return s.get(f"{BASE}{path}", timeout=10).json() + + +class CDP: + def __init__(self, ws): + self.ws = create_connection(ws, max_size=None, suppress_origin=True) + self._id = 0; self._res = {}; self._c = threading.Condition(); self._stop = False + threading.Thread(target=self._loop, daemon=True).start() + def _loop(self): + while not self._stop: + try: raw = self.ws.recv() + except Exception: break + if not raw: continue + m = json.loads(raw) + if "id" in m: + with self._c: self._res[m["id"]] = m; self._c.notify_all() + def send(self, method, params=None, timeout=30): + self._id += 1; mid = self._id + self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}})) + end = time.time() + timeout + with self._c: + while mid not in self._res: + left = end - time.time() + if left <= 0: raise TimeoutError(method) + self._c.wait(left) + m = self._res.pop(mid) + if "error" in m: raise RuntimeError(f"{method}: {m['error'].get('message')}") + return m.get("result", {}) + def ev(self, expr, by_value=True): + r = self.send("Runtime.evaluate", {"expression": expr, "returnByValue": by_value}) + if "exceptionDetails" in r: + raise RuntimeError("JS 异常: " + r["exceptionDetails"].get("text", "")) + return r.get("result", {}) + def val(self, expr): + return self.ev(expr).get("value") + def close(self): + self._stop = True + try: self.ws.close() + except Exception: pass + + +# 返回各 itembox 的 [{i,x,y,left,top,w,h,src}] +JS_RECTS = ( + "(function(){" + f"var s=document.evaluate({json.dumps(XPATH)},document,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);" + "var a=[];for(var i=0;i len(before): + # 找出新增的、且 src 已是正式 CDN 链接的那张 + news = [r for r in cur if r["src"] not in before_srcs] + ready = [r for r in news if r["src"] and "susercontent" in r["src"] and "blob:" not in r["src"]] + if ready: + new_src = ready[-1]["src"] + print(f"[+] 上传成功,新图:{new_src} 当前 {len(cur)} 张") + break + else: + print(f" ...已出现新图但还在处理中({len(cur)} 张)") + if not new_src: + print("[!] 等待上传超时/未确认成功。可能弹了裁剪框或需要确认,请看浏览器。") + cdp.close(); return + + # ---- 拖拽:把新图(最后一张)拖到第一位 ---- + time.sleep(1) + cur = rects(cdp) + last = cur[-1] + first = cur[0] + # 目标落点:第一张的左缘【外侧】,越过其左半区,排序库才会插到最前 + tx = first["left"] - first["w"] * 0.30 + ty = first["y"] + print(f"\n[*] 拖拽:从 last[{last['i']}]({last['x']:.0f},{last['y']:.0f}) -> 第一位前({tx:.0f},{ty:.0f})") + drag(cdp, last["x"], last["y"], tx, ty) + time.sleep(1.2) + + # ---- 核对:第一张是不是刚上传的新图 ---- + after = rects(cdp) + print(f"\n[*] 拖拽后顺序({len(after)} 张):") + for r in after: + flag = " <== 新图" if r["src"] == new_src else "" + print(f" [{r['i']}] {r['src']}{flag}") + if after and after[0]["src"] == new_src: + print("\n[*] ✅ 新图已在第一位(封面)。未保存——请到浏览器核对,确认后再走保存。") + else: + idx = next((r["i"] for r in after if r["src"] == new_src), None) + print(f"\n[!] 新图当前在第 {idx} 位,未到第一位。拖拽可能需要微调(步数/落点/事件类型)。未保存。") + + cdp.close() + + +if __name__ == "__main__": + main() diff --git a/prototypes/set_title.py b/prototypes/set_title.py new file mode 100644 index 0000000..f8f5cf2 --- /dev/null +++ b/prototypes/set_title.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +# set_title.py —— 在商品编辑页写入新标题(让 Vue/eds 组件感知),可选点击保存。 +# +# 三种模式: +# 1) 默认(自检):临时改成 原标题+(測試)→ 验证写入生效 → 还原原标题。不点保存,零持久化。 +# 2) 设值不保存: NEW_TITLE="新标题" python3 set_title.py —— 写入但不保存,你手动检查/保存。 +# 3) 设值并保存: NEW_TITLE="新标题" SAVE=1 python3 set_title.py —— 写入并点击保存按钮(真改!)。 +# +# 依赖:pip install websocket-client requests +# 环境变量:CDP_HOST(默认 192.168.0.224:9333)、ITEM_ID(默认 51100639510)、NEW_TITLE、SAVE + +import os + +for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"): + os.environ.pop(_k, None) + +import json +import time +import threading + +import requests +from websocket import create_connection + +CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333") +BASE = f"http://{CDP_HOST}" +ITEM_ID = os.environ.get("ITEM_ID", "51100639510") +PRODUCT_URL = f"https://seller.shopee.tw/portal/product/{ITEM_ID}?pageEntry=product_list&ignore-html-cache=1" +NEW_TITLE = os.environ.get("NEW_TITLE") +DO_SAVE = os.environ.get("SAVE") == "1" + +TITLE_XPATH = "//input[@class='eds-input__input' and string-length(@modelvalue)>24]" + + +def http_get(path): + s = requests.Session() + s.trust_env = False + return s.get(f"{BASE}{path}", timeout=10).json() + + +class CDP: + def __init__(self, ws_url): + self.ws = create_connection(ws_url, max_size=None, suppress_origin=True) + self._id = 0 + self._results = {} + self._cond = threading.Condition() + self._stop = False + threading.Thread(target=self._recv_loop, daemon=True).start() + + def _recv_loop(self): + while not self._stop: + try: + raw = self.ws.recv() + except Exception: + break + if not raw: + continue + msg = json.loads(raw) + if "id" in msg: + with self._cond: + self._results[msg["id"]] = msg + self._cond.notify_all() + + def send(self, method, params=None, timeout=30): + self._id += 1 + mid = self._id + self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}})) + deadline = time.time() + timeout + with self._cond: + while mid not in self._results: + left = deadline - time.time() + if left <= 0: + raise TimeoutError(f"CDP {method} 超时") + self._cond.wait(left) + msg = self._results.pop(mid) + if "error" in msg: + raise RuntimeError(f"{method} 失败: {msg['error'].get('message')}") + return msg.get("result", {}) + + def eval(self, expr): + r = self.send("Runtime.evaluate", {"expression": expr, "returnByValue": True}) + if "exceptionDetails" in r: + raise RuntimeError(f"页面 JS 异常: {r['exceptionDetails'].get('text')}") + return r.get("result", {}).get("value") + + def close(self): + self._stop = True + try: + self.ws.close() + except Exception: + pass + + +def find_edit_tab(): + pages = [t for t in http_get("/json") if t.get("type") == "page"] + for p in pages: + url = p.get("url") or "" + if ITEM_ID in url and "shopee.tw" in url: + return p + return None + + +def open_edit_tab(): + ver = http_get("/json/version") + bcdp = CDP(ver["webSocketDebuggerUrl"]) + try: + tid = bcdp.send("Target.createTarget", {"url": PRODUCT_URL})["targetId"] + finally: + bcdp.close() + deadline = time.time() + 15 + while time.time() < deadline: + for t in http_get("/json"): + if t.get("id") == tid and t.get("webSocketDebuggerUrl"): + return t["webSocketDebuggerUrl"] + time.sleep(0.5) + raise TimeoutError("等待新 tab websocket 超时") + + +# ---- 读取当前标题 ---- +def js_read(): + return ( + "(function(){" + f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," + "XPathResult.FIRST_ORDERED_NODE_TYPE,null);" + "var el=r.singleNodeValue;" + "return el?el.value:null;})()" + ) + + +# ---- 写入新标题:原生 setter + 派发事件,让 Vue v-model 感知 ---- +def js_write(new_title): + return ( + "(function(){" + f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," + "XPathResult.FIRST_ORDERED_NODE_TYPE,null);" + "var el=r.singleNodeValue;" + "if(!el)return 'NO_INPUT';" + "var setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;" + "el.focus();" + f"setter.call(el,{json.dumps(new_title)});" + "el.dispatchEvent(new Event('input',{bubbles:true}));" + "el.dispatchEvent(new Event('change',{bubbles:true}));" + "el.blur();" + "return el.value;})()" + ) + + +def wait_title(cdp, timeout=40): + deadline = time.time() + timeout + while time.time() < deadline: + v = cdp.eval(js_read()) + if v: + return v + time.sleep(1) + return None + + +def click_save(cdp): + # 按钮文字常见为 儲存/保存/Save;找可见且可点击的那个。 + expr = ( + "(function(){" + "var btns=[].slice.call(document.querySelectorAll('button'));" + "var t=btns.filter(function(b){var s=(b.innerText||'').trim();" + "return /儲存|保存|Save/i.test(s) && b.offsetParent!==null && !b.disabled;});" + "if(!t.length)return 'NO_SAVE_BTN';" + "t[0].click();return 'CLICKED:'+(t[0].innerText||'').trim();})()" + ) + return cdp.eval(expr) + + +def main(): + tab = find_edit_tab() + if tab: + print(f"[*] 复用编辑页 tab: {tab['url']}") + page_ws = tab["webSocketDebuggerUrl"] + else: + print(f"[*] 新建 tab 打开: {PRODUCT_URL}") + page_ws = open_edit_tab() + + cdp = CDP(page_ws) + try: + cdp.send("Runtime.enable") + print("[*] 等待标题输入框渲染...") + original = wait_title(cdp) + if not original: + print("[!] 没读到标题输入框,退出。") + return + print(f"[*] 当前标题: {original}") + + if NEW_TITLE is None: + # ---- 自检模式:临时改 → 验证 → 还原,全程不保存 ---- + test = (original + "(測試)")[:255] + print(f"\n[自检] 临时写入: {test}") + cdp.eval(js_write(test)) + time.sleep(0.5) + after = cdp.eval(js_read()) + print(f"[自检] 读回: {after}") + ok = after == test + print(f"[自检] 写入生效: {'✅ 是' if ok else '❌ 否'}") + print(f"\n[自检] 还原原标题: {original}") + cdp.eval(js_write(original)) + time.sleep(0.5) + restored = cdp.eval(js_read()) + print(f"[自检] 读回: {restored}") + print(f"[自检] 已还原: {'✅ 是' if restored == original else '❌ 否(请手动检查)'}") + print("\n[自检] 全程未点保存,页面未持久化任何改动。") + print(" 真改请用: NEW_TITLE=\"新标题\" SAVE=1 python3 set_title.py") + return + + # ---- 真实写入 ---- + print(f"\n[*] 写入新标题: {NEW_TITLE}") + cdp.eval(js_write(NEW_TITLE)) + time.sleep(0.5) + after = cdp.eval(js_read()) + # modelvalue 是 Vue 模型反向绑定到 DOM 的属性,等于新值才说明 v-model 真的更新了 + model = cdp.eval( + "(function(){" + f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," + "XPathResult.FIRST_ORDERED_NODE_TYPE,null);" + "var el=r.singleNodeValue;return el?el.getAttribute('modelvalue'):null;})()" + ) + print(f"[*] 读回 value: {after}") + print(f"[*] 读回 modelvalue: {model}") + if after != NEW_TITLE: + print("[!] value 与新标题不一致,停止(不保存)。") + return + if model != NEW_TITLE: + print("[!] modelvalue 未跟随更新,Vue 模型可能没绑定,保存会提交旧值,停止(不保存)。") + return + print("[*] ✅ value 与 modelvalue 均=新标题,Vue 模型已更新。") + + if not DO_SAVE: + print("\n[*] 已写入但未保存(SAVE!=1)。请到浏览器里核对后手动保存,或加 SAVE=1 重跑。") + return + + print("[*] 点击保存按钮...") + r = click_save(cdp) + print(f"[*] 保存结果: {r}") + if r.startswith("CLICKED"): + print("[*] 已触发保存。请到浏览器确认是否有校验弹窗/成功提示。") + finally: + cdp.close() + + +if __name__ == "__main__": + main()