feat: 开发采集插件
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
# Ozon Seller Kit 总体架构
|
||||
|
||||
> 状态:架构设计(待确认)
|
||||
> 最后更新:2026-08-11
|
||||
> 相关:[插件方案](./extension/plan.md) · [部署](./deployment.md) · [文案后端](./ai-copy-backend-plan.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
围绕 Ozon 跨境上品的一套自用工具链,覆盖**采集 → 编辑 → 发布**全链路,货源来自 Ozon 竞品页(跟卖)与 1688/淘宝/拼多多(补素材)。
|
||||
|
||||
四个组成部分,各自独立可用、通过明确契约衔接:
|
||||
|
||||
| # | 部分 | 形态 | 状态 | 职责 |
|
||||
|---|---|---|---|---|
|
||||
| ① | **工具台 v1** | 静态页(原生 JS + Tailwind CDN) | ✅ 在用 | 计价、上品登记、水印、俄文文案。**冻结维护,不重构** |
|
||||
| ② | **采集插件** | Chrome MV3 扩展 | 🔨 待开发 | Ozon/1688 商品页采集 → 落本地文件夹 |
|
||||
| ③ | **发布工作台** | Web 应用 | 📋 待设计 | 导入本地文件夹 → 编辑/图片处理 → 提交发布 |
|
||||
| ④ | **服务端** | FastAPI | 🔨 部分就绪 | AI 文案、图片处理、Ozon API 代理、凭证保管 |
|
||||
|
||||
**关键边界**:① 与 ③ 并存不互相替代。① 是已验证好用的轻量工具,③ 是面向"整商品发布"的新流程;③ 成熟后 ① 的计价器可能被吸收,但那是以后的事。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```
|
||||
ozon-seller-kit/
|
||||
├── server/ # ④ 服务端(FastAPI)
|
||||
│ ├── main.py
|
||||
│ ├── api/ # ai / image / ozon / collection
|
||||
│ ├── services/
|
||||
│ ├── schemas/ # Pydantic = 数据契约真源
|
||||
│ ├── config/
|
||||
│ └── requirements.txt
|
||||
│
|
||||
├── web/ # ① 工具台 v1 —— 原地冻结,不改目录名
|
||||
│ ├── ozonSeller.html
|
||||
│ ├── js/ css/ imgs/
|
||||
│ └── README.md # 标注「v1,仅修 bug」
|
||||
│
|
||||
├── extension/ # ② 采集插件(WXT + TS)
|
||||
│ ├── entrypoints/
|
||||
│ ├── src/{profiles,collector,export,storage}/
|
||||
│ └── package.json
|
||||
│
|
||||
├── studio/ # ③ 发布工作台
|
||||
│ └── (技术栈待定,见 §8)
|
||||
│
|
||||
├── packages/
|
||||
│ └── schema/ # 跨端共享契约(TS 类型 + JSON Schema)
|
||||
│
|
||||
├── reference/
|
||||
│ └── 1688-extension/ # 反编译参考资料,非本项目代码
|
||||
│
|
||||
├── docs/ # 全部文档集中于此
|
||||
│ ├── architecture.md # ← 本文
|
||||
│ ├── deployment.md
|
||||
│ ├── ai-copy-backend-plan.md
|
||||
│ ├── contracts/ # 数据契约说明
|
||||
│ ├── extension/ # 插件方案
|
||||
│ └── studio/ # 发布工作台方案
|
||||
│
|
||||
├── start.command
|
||||
└── .env / .env.example
|
||||
```
|
||||
|
||||
两处与现状不同,需要迁移(§9):后端从仓库根收进 `server/`;`seller-helper/` 拆解为 `reference/` + `docs/extension/`。
|
||||
|
||||
### 2.1 为什么后端要收进 `server/`
|
||||
|
||||
现状后端散在仓库根(`main.py`、`api/`、`services/`、`schemas/`、`config/`)。四个部分并列后,根目录会同时出现 Python 包目录和三个前端项目目录,`api/` 这种名字看不出属于谁。收进 `server/` 后每个顶层目录一一对应一个部分。
|
||||
|
||||
代价:`start.command` 与 `docs/deployment.md` 里的启动路径要改,`.env` 加载路径 `parents[1]` 要跟着调。Python 内部 import 全是 `from api import ...` 这类顶层相对形式,只要工作目录切到 `server/` 就不受影响。
|
||||
|
||||
**这是唯一有破坏性的改动,建议在动 `studio/` 之前一次做完,不要拖到中途。** 如果你想零风险,也可以让后端留在根目录——架构其余部分不依赖这个决定。
|
||||
|
||||
### 2.2 为什么 `web/` 不改名
|
||||
|
||||
改名会动 `main.py` 的挂载路径、`start.command`、部署文档,收益只是"名字更清楚"。目录名保持 `web/`,在里面放一个 README 标注定位即可。
|
||||
|
||||
路由上两代共存:`studio/` 上线后占 `/studio`,`web/` 继续在 `/ozonSeller.html`。谁占根路径是路由配置问题,跟目录名无关。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心数据契约:商品文件夹
|
||||
|
||||
**这是整个架构最重要的一个决定。** 四个部分之间不直接调用彼此的代码,只认一个约定:磁盘上的「商品文件夹」。
|
||||
|
||||
```
|
||||
儿童保温杯_316不锈钢/
|
||||
├── product.json # 商品数据(唯一真源)
|
||||
├── sources.json # 采集溯源:哪个平台、哪个 URL、什么时候
|
||||
└── images/
|
||||
├── main/ # main-001.jpg …
|
||||
├── sku/ # sku-001-blue.jpg(文件名带规格名)
|
||||
├── detail/ # detail-001.jpg …
|
||||
└── video/
|
||||
```
|
||||
|
||||
数据流因此变成:
|
||||
|
||||
```
|
||||
插件 ──写──> 商品文件夹 ──读──> 发布工作台 ──调用──> 服务端 ──> Ozon API
|
||||
↑
|
||||
1688 插件二次采集追加
|
||||
```
|
||||
|
||||
三个好处:
|
||||
|
||||
1. **插件和工作台完全解耦**。插件不需要知道工作台存在,反之亦然。任一端重写不影响另一端。
|
||||
2. **中间产物可见可改**。采集结果就是普通文件夹,能用 Finder 看、能手动补图、能备份、能在两台机器间拷。
|
||||
3. **跨平台合并天然成立**。Ozon 采完,切到 1688 采同类商品,选同一个文件夹继续写入——就是往同一目录追加文件,不需要任何服务端参与。
|
||||
|
||||
### 3.1 product.json
|
||||
|
||||
结构对齐 Ozon `ProductAPI_ImportProductsV3`,但**不等于**它的请求体。区别在于采集阶段拿不到的字段留空,由工作台补齐:
|
||||
|
||||
| 字段 | 采集阶段 | 工作台补齐 |
|
||||
|---|---|---|
|
||||
| `name` / `description` | ✅ 原文(可能是俄文) | 改写/翻译 |
|
||||
| `images` | 本地相对路径 | 上传图床后换成公网 URL |
|
||||
| `offer_id` | ❌ 空 | **必须填自己的货号**(跟卖场景下不能沿用竞品的) |
|
||||
| `description_category_id` | ❌ 空 | 类目选择/推荐 |
|
||||
| `attributes[].id` | ❌ 空 | 查类目属性字典映射 |
|
||||
| 尺寸重量 | 参数表里有就带上 | 校验补全 |
|
||||
| `price` / `old_price` | 采到竞品价,仅供参考 | 计价器算出 |
|
||||
|
||||
所以 product.json 有个 `_meta.stage` 字段标明它处在哪个阶段:`collected` → `edited` → `published`。
|
||||
|
||||
契约细节见 [`docs/contracts/product-json.md`](./contracts/product-json.md)。
|
||||
|
||||
### 3.2 契约真源与双语言实现
|
||||
|
||||
Pydantic(`server/schemas/`)是真源,因为服务端最终要用它做校验。由此派生:
|
||||
|
||||
```
|
||||
server/schemas/product.py (Pydantic)
|
||||
│
|
||||
├── 导出 JSON Schema ──> packages/schema/product.schema.json
|
||||
│ │
|
||||
│ └── 生成 TS 类型 ──> 插件 / 工作台
|
||||
└── 服务端运行时校验
|
||||
```
|
||||
|
||||
不上 codegen 流水线(单人项目不值得):插件端手写一份对应的 TS interface,配一个 fixture 文件双向跑一遍校验做契约测试。schema 变更时测试会红。
|
||||
|
||||
---
|
||||
|
||||
## 4. 各部分职责与边界
|
||||
|
||||
### ① 工具台 v1(`web/`)
|
||||
|
||||
**冻结。** 只修 bug,不加功能、不重构、不迁技术栈。它当前的价值是「已经好用」,任何改动都是风险。
|
||||
|
||||
新需求一律进 `studio/`。计价逻辑(`web/js/app.js`)后续会被 studio 复用——那时候是**读它、抄它的公式**,不是改它。
|
||||
|
||||
### ② 采集插件(`extension/`)
|
||||
|
||||
只做四件事:识别页面、提取素材、分组、写文件夹。
|
||||
|
||||
不做:LLM 调用、图片处理、Ozon API、持有任何密钥。
|
||||
|
||||
一期只支持 Ozon(跟卖是第一优先级),1688 二期。详见 [`docs/extension/plan.md`](./extension/plan.md)。
|
||||
|
||||
### ③ 发布工作台(`studio/`)
|
||||
|
||||
导入本地文件夹 → 编辑 → 发布。三块能力:
|
||||
|
||||
- **表单编辑**:product.json 各字段,类目选择,属性映射,计价(复用 v1 公式)
|
||||
- **图片处理**:水印、白底、图内翻译、图生图 —— 重活走服务端
|
||||
- **发布**:提交 Ozon 草稿,回填 `product_id`
|
||||
|
||||
### ④ 服务端(`server/`)
|
||||
|
||||
唯一持有密钥的地方。当前有 `/api/ai/*`(文案);待补 `/api/image/*`、`/api/ozon/*`。
|
||||
|
||||
**Ozon 发布有个硬约束值得提前知道**:`ProductAPI_ImportProductsV3` 的 `images` 只接受**公网可访问的 URL**,Ozon 服务器会主动来拉。本地文件夹里的图必须先上传到图床/对象存储才能发布。这决定了服务端必须有图床能力,也决定了「本地文件夹」方案无法绕过服务端直接发布。
|
||||
|
||||
---
|
||||
|
||||
## 5. 端到端数据流
|
||||
|
||||
```
|
||||
① 浏览 Ozon 竞品页
|
||||
│ 点插件按钮 → 侧边栏打开 → 人工确认页面加载完 → 点采集
|
||||
▼
|
||||
② 侧边栏表单化展示采集结果(可二次编辑),图片分组勾选
|
||||
│ 选保存目录 → 导出
|
||||
▼
|
||||
③ 本地商品文件夹(product.json + images/)
|
||||
│ (可选)切到 1688 采同类商品,选同一文件夹追加
|
||||
▼
|
||||
④ 发布工作台:选择文件夹导入
|
||||
│ 编辑字段 / 加水印 / 图生图 / 定类目 / 计价
|
||||
▼
|
||||
⑤ 服务端:图片上传图床 → 属性字典校验 → 提交 Ozon 草稿
|
||||
▼
|
||||
⑥ 回填 product_id 到 product.json,stage 置 published
|
||||
```
|
||||
|
||||
每一步的产物都落盘,中断了可以从任意一步接着来。
|
||||
|
||||
---
|
||||
|
||||
## 6. 技术栈
|
||||
|
||||
| 部分 | 技术栈 | 说明 |
|
||||
|---|---|---|
|
||||
| ① web | 原生 JS + Tailwind CDN | 不动 |
|
||||
| ② extension | WXT + React + TS strict | WXT 比 Plasmo 活跃 |
|
||||
| ③ studio | 待定(见 §8) | |
|
||||
| ④ server | FastAPI + Pydantic | 已有 |
|
||||
| 共享 | pnpm workspace | 只为 `packages/schema` 共享,不上 turborepo |
|
||||
|
||||
pnpm workspace 的唯一目的是让插件和 studio 共用契约类型。`pnpm-workspace.yaml` 三行搞定,不引入构建编排复杂度。
|
||||
|
||||
---
|
||||
|
||||
## 7. 服务端演进
|
||||
|
||||
当前是无状态的:只有 AI 文案代理,没有数据库。按需要逐步加,不要一次上齐:
|
||||
|
||||
| 阶段 | 触发条件 | 要加什么 |
|
||||
|---|---|---|
|
||||
| 现在 | — | 无状态,`/api/ai/*` |
|
||||
| S1 | studio 要处理图片 | `/api/image/*`(水印/白底/翻译),仍无状态:收图返图 |
|
||||
| S2 | 要发布到 Ozon | `/api/ozon/*` + 图床 + 类目字典缓存(SQLite 够用) |
|
||||
| S3 | 图片处理变慢(图生图、视频) | 任务队列 + `/api/job/:id` 轮询 |
|
||||
| S4 | 想要跨设备同步 | 商品库落库,本地文件夹降级为导入导出格式 |
|
||||
|
||||
**S1、S2 都不需要数据库**,类目字典用文件缓存即可。S4 是个大改动,只在真的有多设备需求时才做——本地文件夹方案的一个优点就是单机场景下完全不需要它。
|
||||
|
||||
---
|
||||
|
||||
## 8. 已定决策
|
||||
|
||||
### D1 · 后端迁入 `server/` ✅
|
||||
|
||||
代价是改 `start.command`、`settings.py` 的 `.env` 路径、`deployment.md`。Python 内部 import 不受影响(工作目录切到 `server/` 即可)。**这是唯一有破坏性的改动,在开工 studio 前一次做完。**
|
||||
|
||||
### D2 · studio 用 React + Vite + TS ✅
|
||||
|
||||
studio 的核心是"几十个字段的结构化表单 + 图片批处理",正是原生 JS 最吃力的场景。与插件同栈,图片处理组件和契约类型可两边复用。v1 的计价公式(`web/js/app.js`)抄过来即可,不改原文件。
|
||||
|
||||
### D3 · 保存用 File System Access API ✅
|
||||
|
||||
`chrome.downloads` 的 `filename` 只能是下载目录下的相对路径,不接受绝对路径或 `..`,因此无法满足"用户选择保存目录",也无法读回 `sources.json` 做去重。
|
||||
|
||||
File System Access 在 side panel(扩展页面上下文)可用,`showDirectoryPicker()` 拿到的 handle 存进 IndexedDB 后**跨会话免重复授权**,正好支撑"Ozon 采完切 1688 追加到同一文件夹"。`chrome.downloads` 保留为降级路径(用户拒绝授权时)。
|
||||
|
||||
细节见 [`docs/extension/plan-revision.md`](./extension/plan-revision.md) R1。
|
||||
|
||||
---
|
||||
|
||||
## 9. 迁移计划
|
||||
|
||||
一次性做完,中途不要停在半路:
|
||||
|
||||
```
|
||||
① 后端收拢
|
||||
main.py api/ services/ schemas/ config/ requirements.txt → server/
|
||||
改 server/config/settings.py: parents[1] → parents[1](指向 server/,.env 仍在仓库根则用 parents[2])
|
||||
改 start.command: cd server 后再 uvicorn
|
||||
改 docs/deployment.md 中所有路径
|
||||
|
||||
② 参考资料归位
|
||||
seller-helper/1688-extension/ → reference/1688-extension/
|
||||
|
||||
③ 文档集中
|
||||
seller-helper/docs/方案设计.md → docs/extension/legacy-v1.md
|
||||
seller-helper/docs/方案设计-V2.md → docs/extension/legacy-v2.md
|
||||
seller-helper/docs/插件开发方案.md → docs/extension/plan.md
|
||||
seller-helper/extension-plan/IMPLEMENTATION_PLAN.md → 合并进 docs/extension/plan.md
|
||||
|
||||
④ 上一轮错放的代码归位
|
||||
seller-helper/extension-plan/profiles-ozon.ts → extension/src/profiles/ozon.ts
|
||||
seller-helper/extension-plan/download-implementation.ts → extension/src/export/download.ts
|
||||
(建插件项目时再放,现在先留在 docs/extension/ 作为草案附件)
|
||||
|
||||
⑤ 删空目录
|
||||
seller-helper/
|
||||
|
||||
⑥ web/ 加 README 标注 v1 冻结
|
||||
```
|
||||
|
||||
`reference/1688-extension/` 要不要进 git:它是反编译产物,约 40 个 bundle。建议**加进 `.gitignore`**,保留在本地即可——设计结论已经写进 `docs/extension/plan.md` §2,原始 bundle 只在需要再次查证时才用。
|
||||
|
||||
---
|
||||
|
||||
## 10. 里程碑(已调整优先级)
|
||||
|
||||
**插件先行:1688/淘宝 → Ozon**。理由见 [`docs/extension/1688-taobao-implementation.md`](./extension/1688-taobao-implementation.md)。
|
||||
|
||||
| # | 内容 | 依赖 | 产出 | 工作量 |
|
||||
|---|---|---|---|---|
|
||||
| **M0** | 目录迁移 + 文档集中 | — | ✅ 已完成,服务正常起 | — |
|
||||
| **M1** | 插件:1688 采集引擎 | M0 | Console 里能跑 `scanCurrentPage()` | 4h |
|
||||
| **M2** | 插件:淘宝 profile | M1 | 淘宝页面同样可用 | 1h |
|
||||
| **M3** | 插件:Side Panel + File System Access | M2 | 生成完整商品文件夹到本地 | 4h |
|
||||
| **M4** | 插件:sources.json 去重 | M3 | 二次采集追加不重复 | 1h |
|
||||
| **M5** | 契约真源:product.json Pydantic | M0 | `server/schemas/product.py` | 2h |
|
||||
| **M6** | 插件:Ozon profile 实测 | M4 | Ozon 选择器验证(需真实页面链接) | 2h |
|
||||
| **M7** | studio:导入文件夹 + 表单编辑 | M5 | 能改能存 | 8h |
|
||||
| **M8** | studio + server:图片处理 | M7 | 水印/白底可用 | 6h |
|
||||
| **M9** | server:Ozon 发布 | M8 | 草稿进 Ozon 后台 | 4h |
|
||||
|
||||
**M4 结束时插件功能完整**,可以采集 1688/淘宝商品到本地文件夹,跨平台追加不重复。M9 结束时全链路跑通。
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 契约:商品文件夹与 product.json
|
||||
|
||||
> 状态:设计(待确认)
|
||||
> 上游:[总体架构 §3](../architecture.md)
|
||||
> Ozon API:[ProductAPI_ImportProductsV3](https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3)
|
||||
|
||||
插件、发布工作台、服务端三方唯一的耦合点。改这份文档等于改三端接口。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文件夹结构
|
||||
|
||||
```
|
||||
<商品名>/
|
||||
├── product.json
|
||||
├── sources.json
|
||||
└── images/
|
||||
├── main/ main-001.jpg …
|
||||
├── sku/ sku-001-синий.jpg …
|
||||
├── detail/ detail-001.jpg …
|
||||
└── video/ video-001.mp4
|
||||
```
|
||||
|
||||
命名规则:
|
||||
|
||||
| 项 | 规则 | 理由 |
|
||||
|---|---|---|
|
||||
| 文件夹名 | 商品名清洗后取前 80 字符 | 保留可读性,避开文件系统长度限制 |
|
||||
| 图片文件名 | `<组>-<3位序号>[-<规格名>].<ext>` | 序号补零保证字典序 = 展示序 |
|
||||
| 规格名 | 保留原文(含俄文/中文),清洗非法字符 | 跟卖时规格名要对应回 Ozon 变体 |
|
||||
| 非法字符 | `< > : " / \ | ? *` → `_` | Windows 兼容 |
|
||||
|
||||
序号从 1 开始,**按页面上的出现顺序**,不重排。main-001 即主图第一张,通常就是 Ozon 的封面图。
|
||||
|
||||
---
|
||||
|
||||
## 2. product.json
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_meta": {
|
||||
"schemaVersion": 1,
|
||||
"stage": "collected", // collected | edited | published
|
||||
"createdAt": "2026-08-11T06:12:00Z",
|
||||
"updatedAt": "2026-08-11T06:12:00Z"
|
||||
},
|
||||
|
||||
// ── Ozon 字段(对齐 ImportProductsV3)──
|
||||
"offer_id": "", // 自己的货号,采集阶段必空
|
||||
"name": "Термокружка детская 316",
|
||||
"description": "…",
|
||||
"description_category_id": null,
|
||||
"type_id": null,
|
||||
|
||||
"price": "1290", // 采到的竞品价,仅参考
|
||||
"old_price": "",
|
||||
"currency_code": "RUB",
|
||||
"vat": "0",
|
||||
|
||||
"depth": null, "width": null, "height": null,
|
||||
"dimension_unit": "mm",
|
||||
"weight": null,
|
||||
"weight_unit": "g",
|
||||
|
||||
"images": [], // 发布时才填公网 URL
|
||||
"primary_image": "",
|
||||
"images360": [],
|
||||
"color_image": "",
|
||||
|
||||
"attributes": [], // 需类目字典映射,见 §4
|
||||
"complex_attributes": [],
|
||||
|
||||
// ── 本地扩展字段(下划线前缀,提交 Ozon 前剥离)──
|
||||
"_images": {
|
||||
"main": [{ "file": "images/main/main-001.jpg", "sourceUrl": "https://…", "w": 1200, "h": 1200 }],
|
||||
"sku": [{ "file": "images/sku/sku-001-синий.jpg", "variantName": "синий", "sourceUrl": "https://…" }],
|
||||
"detail": [],
|
||||
"video": []
|
||||
},
|
||||
|
||||
"_raw": {
|
||||
"title": "Термокружка детская 316",
|
||||
"price": "1 290 ₽",
|
||||
"params": [{ "key": "Материал", "value": "Нержавеющая сталь" }],
|
||||
"desc": "…",
|
||||
"sellingPoints": "…"
|
||||
},
|
||||
|
||||
"_pricing": null // studio 计价结果,结构见 §5
|
||||
}
|
||||
```
|
||||
|
||||
### 2.1 为什么分 Ozon 字段 / `_` 扩展字段
|
||||
|
||||
提交 Ozon 时把所有 `_` 开头的键剥掉,剩下的**就是**请求体的 `items[0]`。这样避免了维护两套结构和一层映射代码。
|
||||
|
||||
`_raw` 保留采集原文:`name` 会被工作台改写(翻译/优化),改坏了要能回溯原始值。
|
||||
|
||||
---
|
||||
|
||||
## 3. stage 状态机
|
||||
|
||||
```
|
||||
collected ──(工作台编辑)──> edited ──(发布成功)──> published
|
||||
```
|
||||
|
||||
| stage | 谁写 | 必须满足 |
|
||||
|---|---|---|
|
||||
| `collected` | 插件 | `name` 非空,`_images` 至少一张 main |
|
||||
| `edited` | 工作台 | `offer_id`、`description_category_id`、尺寸重量、`_pricing` 均已填 |
|
||||
| `published` | 服务端 | 追加 `_ozon.product_id`、`_ozon.publishedAt` |
|
||||
|
||||
工作台导入时按 stage 决定界面:`collected` 走完整编辑流程,`edited` 直接进复核,`published` 只读 + 提示"已发布"。
|
||||
|
||||
---
|
||||
|
||||
## 4. attributes 的处理边界
|
||||
|
||||
**插件不碰 `attributes`。** Ozon 的属性需要 `{ id, complex_id, values[{ dictionary_value_id | value }] }`,其中 `id` 和 `dictionary_value_id` 都得查类目属性字典(`/v1/description-category/attribute` 与 `/v1/description-category/attribute/values`),而字典依赖类目——采集阶段还不知道类目。
|
||||
|
||||
所以:
|
||||
|
||||
```
|
||||
插件 → _raw.params 存原始 kv 文本
|
||||
工作台 → 定类目 → 拉字典 → 映射成 attributes
|
||||
服务端 → 提交前按字典校验必填项
|
||||
```
|
||||
|
||||
映射交互(自动匹配 + 人工确认未匹配项)属于 studio 设计范围,见 `docs/studio/`。
|
||||
|
||||
---
|
||||
|
||||
## 5. _pricing
|
||||
|
||||
复用 v1 计价器(`web/js/app.js`)的公式,结构对齐它现有的输出:
|
||||
|
||||
```jsonc
|
||||
"_pricing": {
|
||||
"purchasePrice": 18.5, // 进货价 ¥
|
||||
"profitRate": 30, // 净利率 %
|
||||
"logisticsLevel": "high", // low | high | high2
|
||||
"weightG": 320,
|
||||
"dims": { "l": 12, "w": 8, "h": 20 },
|
||||
"logisticsFee": 0,
|
||||
"fullCommission": 0,
|
||||
"totalCost": 0,
|
||||
"sellingPriceCny": 0,
|
||||
"sellingPriceRub": 0,
|
||||
"discountReserve": 50,
|
||||
"fxRate": 11.8,
|
||||
"calculatedAt": "2026-08-11T06:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
字段名沿用 v1 页面里的 id 命名,方便对照。
|
||||
|
||||
---
|
||||
|
||||
## 6. sources.json
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"platform": "ozon",
|
||||
"itemId": "123456789",
|
||||
"url": "https://www.ozon.ru/product/…",
|
||||
"collectedAt": "2026-08-11T06:12:00Z",
|
||||
"counts": { "main": 6, "sku": 4, "detail": 9, "video": 0 }
|
||||
},
|
||||
{
|
||||
"platform": "1688",
|
||||
"itemId": "987654321",
|
||||
"url": "https://detail.1688.com/offer/987654321.html",
|
||||
"collectedAt": "2026-08-11T07:40:00Z",
|
||||
"counts": { "main": 5, "detail": 12 }
|
||||
}
|
||||
],
|
||||
"dedupeKeys": ["https://cdn1.ozon.ru/…", "…"]
|
||||
}
|
||||
```
|
||||
|
||||
`dedupeKeys` 是已采集图片的归一化 URL 指纹。二次采集时插件读这个文件,命中的图标记「已收集」并默认不勾选——这是跨平台追加采集不重复的机制,纯本地实现,不需要服务端。
|
||||
|
||||
---
|
||||
|
||||
## 7. 双语言实现
|
||||
|
||||
| 端 | 位置 | 角色 |
|
||||
|---|---|---|
|
||||
| Python | `server/schemas/product.py` | **真源**,Pydantic 模型 + 运行时校验 |
|
||||
| TS | `packages/schema/src/product.ts` | 手写 interface,与真源对齐 |
|
||||
| fixture | `packages/schema/fixtures/*.json` | 两端都跑一遍,防漂移 |
|
||||
|
||||
`schemaVersion` 变更时两端同步改,fixture 加一份新版本样例。当前 v1。
|
||||
@@ -0,0 +1,383 @@
|
||||
# 1688/淘宝采集插件实施计划
|
||||
|
||||
> 优先级调整:Ozon 后置,先做 1688/淘宝
|
||||
> 理由:1688 选择器有生产验证基础,可先跑通引擎;淘宝同属阿里系,复用度高
|
||||
> 上游:[总体架构](../architecture.md) · [插件原方案](./plan.md) · [方案修正](./plan-revision.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么先做 1688/淘宝
|
||||
|
||||
| 维度 | 1688/淘宝 | Ozon |
|
||||
|---|---|---|
|
||||
| 选择器来源 | v1.1.8 生产 bundle 反编译,已验证 | 需实测,哈希类名随时失效 |
|
||||
| 技术难度 | 中(同源复用多) | 高(React SSR + 选择器未知) |
|
||||
| 调试价值 | 可作引擎基准——跑通后 Ozon 采不到就一定是选择器问题 | 选择器和引擎同时调,歧义大 |
|
||||
| 业务价值 | 补素材(1688 图多) | 跟卖(第一优先级但技术难) |
|
||||
|
||||
**策略**:先用 1688 跑通引擎和 File System Access 写盘,再用它诊断 Ozon 的选择器问题。
|
||||
|
||||
---
|
||||
|
||||
## 2. 淘宝 Profile 设计
|
||||
|
||||
### 2.1 与 1688 的共同点
|
||||
|
||||
| 项 | 共享原因 |
|
||||
|---|---|
|
||||
| CDN 规则 | 都是 `xxx.jpg_400x400.jpg` 后缀,`getOriginalImageUrl` 通用 |
|
||||
| 懒加载 | `data-lazyload-src` / `data-src` 优先级相同 |
|
||||
| SKU 背景图 | 都用 `backgroundImage` 取 SKU 缩略图 |
|
||||
| 参数表结构 | `<dl>` 嵌套 `<dt>` `<dd>`,解析逻辑相同 |
|
||||
|
||||
可以抽一个 `profiles/alibaba-common.ts` 存共享工具。
|
||||
|
||||
### 2.2 淘宝特有选择器
|
||||
|
||||
**URL 匹配**:
|
||||
```ts
|
||||
urlPatterns: [
|
||||
/^https:\/\/item\.taobao\.com\/item\.htm\?id=\d+/,
|
||||
/^https:\/\/detail\.tmall\.com\/item\.htm\?id=\d+/ // 天猫
|
||||
]
|
||||
|
||||
extractItemId: (url) => {
|
||||
const m = url.match(/[?&]id=(\d+)/);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
**就绪选择器**(淘宝用 React 16,水合较快):
|
||||
```ts
|
||||
readySelectors: [
|
||||
'[class*="ItemHeader"]', // 标题区
|
||||
'[class*="MainPic"]', // 主图画廊
|
||||
'[class*="SkuSelector"]' // SKU 选择器
|
||||
]
|
||||
```
|
||||
|
||||
**文本规则**:
|
||||
```ts
|
||||
textRules: [
|
||||
{
|
||||
kind: 'title',
|
||||
selectors: [
|
||||
'[class*="ItemHeader--title"]',
|
||||
'.tb-detail-hd h1',
|
||||
'h1[data-spm="1000983"]' // 旧版
|
||||
],
|
||||
extract: 'first',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
kind: 'price',
|
||||
selectors: [
|
||||
'[class*="Price--priceText"]',
|
||||
'.tb-rmb-num',
|
||||
'[class*="priceInt"]'
|
||||
],
|
||||
extract: 'first'
|
||||
},
|
||||
{
|
||||
kind: 'params',
|
||||
selectors: [
|
||||
'[class*="Attributes"] dl',
|
||||
'#attributes .tm-clear',
|
||||
'.attributes-list dl'
|
||||
],
|
||||
extract: 'table',
|
||||
tableKeySelector: 'dt',
|
||||
tableValueSelector: 'dd'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**图片规则**:
|
||||
```ts
|
||||
imageGroups: [
|
||||
{
|
||||
key: 'main',
|
||||
name: '主图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[class*="MainPic"] img', // React 版
|
||||
'#J_ImgBooth img', // 旧版画廊
|
||||
'.tb-booth img'
|
||||
],
|
||||
minWidth: 200,
|
||||
minHeight: 200
|
||||
},
|
||||
{
|
||||
key: 'sku',
|
||||
name: 'SKU图片',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[class*="SkuSelector"] li', // React 版
|
||||
'.tb-img li', // 旧版
|
||||
'[class*="skuItem"]'
|
||||
],
|
||||
srcProps: ['backgroundImage'], // 与 1688 同
|
||||
nameSelectors: ['span', '.value'], // 规格名
|
||||
minWidth: 20,
|
||||
minHeight: 20
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
name: '详情图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'#description img',
|
||||
'[class*="Description"] img',
|
||||
'.detail-content img'
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2.3 淘宝特殊处理
|
||||
|
||||
**动态详情图**:淘宝详情常用懒加载模块,需滚动触发。侧边栏提示同 Ozon:
|
||||
```
|
||||
⚠️ 详情图为 0,请滚动到页面底部后重新采集
|
||||
```
|
||||
|
||||
**天猫 vs 淘宝 C 店**:URL 模式不同但 DOM 结构相似,用同一份 profile 即可。主要差异在类名前缀(`tm-` vs `tb-`),多写几套选择器兜底。
|
||||
|
||||
---
|
||||
|
||||
## 3. 更新后的里程碑
|
||||
|
||||
| # | 内容 | 工作量 | 产出 |
|
||||
|---|---|---|---|
|
||||
| **M1** | WXT 项目初始化 | 0.5h | extension/ 目录就位,能 dev |
|
||||
| **M2** | 核心类型与工具 | 1h | profiles/types + collector/url + product.json TS 类型 |
|
||||
| **M3** | 1688 profile + 采集引擎 | 3h | 能在 1688 页面 console 里跑 `scanCurrentPage()` |
|
||||
| **M4** | 淘宝 profile | 1h | 同上,淘宝页面可用 |
|
||||
| **M5** | Side Panel UI(基础) | 2h | 分组展示采集结果,勾选图片 |
|
||||
| **M6** | File System Access 写盘 | 2h | 生成完整商品文件夹到本地 |
|
||||
| **M7** | sources.json 去重 | 1h | 二次采集追加不重复 |
|
||||
| **M8** | 淘宝实测与修正 | 1h | 在真实页面上跑,修选择器 |
|
||||
|
||||
**总计 11.5 小时**。M3 结束时引擎已可用,M6 结束时完整流程跑通。
|
||||
|
||||
---
|
||||
|
||||
## 4. 目录结构(实际代码)
|
||||
|
||||
```
|
||||
extension/
|
||||
├── wxt.config.ts
|
||||
├── package.json
|
||||
├── entrypoints/
|
||||
│ ├── background.ts # 图片代理 fetch(绕 CORS)
|
||||
│ ├── sidepanel/
|
||||
│ │ ├── index.html
|
||||
│ │ └── App.tsx # 采集控制 UI
|
||||
│ └── content/
|
||||
│ └── index.ts # 注入页面,触发采集
|
||||
│
|
||||
├── src/
|
||||
│ ├── profiles/
|
||||
│ │ ├── types.ts # SiteProfile / TextRule / ImageGroupRule
|
||||
│ │ ├── alibaba-common.ts # 阿里系共享工具(URL / CDN)
|
||||
│ │ ├── 1688.ts # ← 从 plan.md §6.2 移植
|
||||
│ │ ├── taobao.ts # ← 上面 §2.2 设计
|
||||
│ │ └── index.ts # matchProfile(url) 路由
|
||||
│ │
|
||||
│ ├── collector/
|
||||
│ │ ├── scan.ts # scanCurrentPage() 入口
|
||||
│ │ ├── text.ts # 文本提取
|
||||
│ │ ├── image.ts # 图片提取 + 分组
|
||||
│ │ ├── url.ts # ← 从 plan.md §6.3 移植
|
||||
│ │ ├── dom.ts # waitForAny / onUrlChange
|
||||
│ │ └── dedupe.ts # dedupeKey()
|
||||
│ │
|
||||
│ ├── export/
|
||||
│ │ ├── filesystem.ts # File System Access API 封装
|
||||
│ │ ├── builder.ts # 构建 product.json / sources.json
|
||||
│ │ └── images.ts # 图片写盘(调 background 代理)
|
||||
│ │
|
||||
│ ├── storage/
|
||||
│ │ ├── keys.ts # SH_ROOT_DIR / SH_CURRENT_FOLDER
|
||||
│ │ └── settings.ts # 配置读写
|
||||
│ │
|
||||
│ └── schema/
|
||||
│ └── product.ts # ProductJson / SourcesJson TS 类型
|
||||
│
|
||||
└── components/ # Side Panel UI 组件
|
||||
├── ScanResult.tsx # 采集结果展示
|
||||
├── ImagePicker.tsx # 分组图片勾选
|
||||
└── FolderSelector.tsx # 文件夹选择/新建
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键技术点
|
||||
|
||||
### 5.1 File System Access API 核心代码
|
||||
|
||||
```ts
|
||||
// src/export/filesystem.ts
|
||||
|
||||
let rootDirHandle: FileSystemDirectoryHandle | null = null;
|
||||
|
||||
export async function selectRootDir(): Promise<void> {
|
||||
rootDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||
// 持久化到 IndexedDB(WXT 有 storage.defineItem 封装)
|
||||
await storage.setItem('local:SH_ROOT_DIR', rootDirHandle);
|
||||
}
|
||||
|
||||
export async function ensureRootDir(): Promise<FileSystemDirectoryHandle> {
|
||||
if (!rootDirHandle) {
|
||||
rootDirHandle = await storage.getItem('local:SH_ROOT_DIR');
|
||||
}
|
||||
if (!rootDirHandle) {
|
||||
throw new Error('请先选择保存目录');
|
||||
}
|
||||
// 验证权限
|
||||
if (await rootDirHandle.queryPermission({ mode: 'readwrite' }) !== 'granted') {
|
||||
await rootDirHandle.requestPermission({ mode: 'readwrite' });
|
||||
}
|
||||
return rootDirHandle;
|
||||
}
|
||||
|
||||
export async function writeProductFolder(
|
||||
folderName: string,
|
||||
data: {
|
||||
product: ProductJson;
|
||||
sources: SourcesJson;
|
||||
images: Array<{ file: string; blob: Blob }>;
|
||||
}
|
||||
): Promise<void> {
|
||||
const root = await ensureRootDir();
|
||||
const productDir = await root.getDirectoryHandle(folderName, { create: true });
|
||||
|
||||
// 写 product.json
|
||||
const productFile = await productDir.getFileHandle('product.json', { create: true });
|
||||
const w1 = await productFile.createWritable();
|
||||
await w1.write(JSON.stringify(data.product, null, 2));
|
||||
await w1.close();
|
||||
|
||||
// 写 sources.json
|
||||
const sourcesFile = await productDir.getFileHandle('sources.json', { create: true });
|
||||
const w2 = await sourcesFile.createWritable();
|
||||
await w2.write(JSON.stringify(data.sources, null, 2));
|
||||
await w2.close();
|
||||
|
||||
// 写图片(分组到子目录)
|
||||
const imagesDir = await productDir.getDirectoryHandle('images', { create: true });
|
||||
for (const img of data.images) {
|
||||
const [group] = img.file.split('/'); // "main/main-001.jpg" → "main"
|
||||
const groupDir = await imagesDir.getDirectoryHandle(group, { create: true });
|
||||
const filename = img.file.split('/')[1];
|
||||
const fh = await groupDir.getFileHandle(filename, { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await w.write(img.blob);
|
||||
await w.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 跨页去重(读 sources.json)
|
||||
|
||||
```ts
|
||||
export async function readExistingSources(
|
||||
folderName: string
|
||||
): Promise<Set<string>> {
|
||||
try {
|
||||
const root = await ensureRootDir();
|
||||
const productDir = await root.getDirectoryHandle(folderName);
|
||||
const sourcesFile = await productDir.getFileHandle('sources.json');
|
||||
const file = await sourcesFile.getFile();
|
||||
const text = await file.text();
|
||||
const sources: SourcesJson = JSON.parse(text);
|
||||
return new Set(sources.dedupeKeys || []);
|
||||
} catch {
|
||||
return new Set(); // 文件夹不存在或首次采集
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Side Panel UI 交互(简化版)
|
||||
|
||||
```
|
||||
┌─ 1688/淘宝 采集助手 ────────────┐
|
||||
│ │
|
||||
│ 保存到: ~/Ozon商品库/ │
|
||||
│ [选择目录] │
|
||||
│ │
|
||||
│ 当前文件夹: 儿童保温杯_316 │
|
||||
│ [新建] │
|
||||
│ │
|
||||
├─────────────────────────────── │
|
||||
│ 本页识别到: │
|
||||
│ │
|
||||
│ 标题: 儿童316不锈钢保温杯… │
|
||||
│ │
|
||||
│ ☑ 主图 (6) [全选] │
|
||||
│ [缩略图缩略图...] │
|
||||
│ │
|
||||
│ ☑ SKU图 (4) [全选] │
|
||||
│ 蓝色 粉色 绿色 白色 │
|
||||
│ │
|
||||
│ ☐ 详情图 (9) [全选] │
|
||||
│ ⚠️ 0张,请滚到底部后重新采集 │
|
||||
│ │
|
||||
│ ☑ 视频 (1) │
|
||||
│ │
|
||||
├─────────────────────────────── │
|
||||
│ 已选 11 项 │
|
||||
│ │
|
||||
│ [开始采集] [追加到文件夹] │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
**交互要点**:
|
||||
- 首次使用提示选择根目录(只需一次)
|
||||
- 文件夹名默认取商品标题(可改)
|
||||
- 详情图为 0 时明确提示原因
|
||||
- "追加到文件夹"按钮读 sources.json,标灰重复项
|
||||
|
||||
---
|
||||
|
||||
## 7. 开工前检查清单
|
||||
|
||||
### 环境
|
||||
- [ ] Node.js 18+ / pnpm 已安装
|
||||
- [ ] Chrome 114+(File System Access 与 Side Panel 最低版本)
|
||||
|
||||
### 技术决策确认
|
||||
- [ ] Side Panel UI 用 React(已定)还是原生 JS? → **React**
|
||||
- [ ] 要不要一期就做淘宝,还是先只做 1688? → **都做,复用度高**
|
||||
- [ ] product.json 的 TS 类型现在就手写,还是等 Pydantic 先写? → **手写,用契约文档**
|
||||
|
||||
### 文件准备
|
||||
- [ ] `docs/extension/drafts/*.ts` 要不要直接搬到 `extension/src/`? → **等项目初始化后再搬**
|
||||
- [ ] `reference/1688-extension/` 的 bundle 要不要进 git? → **已在 .gitignore,不进**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
我可以:
|
||||
|
||||
**A. 立刻初始化项目**(会生成约 20 个文件)
|
||||
```bash
|
||||
cd /Users/joey-xd/sites/seller-store/ozon-seller-kit
|
||||
mkdir extension && cd extension
|
||||
pnpm create wxt@latest .
|
||||
# 选 React + TypeScript
|
||||
```
|
||||
|
||||
**B. 先写 M2 的核心类型和工具**,验证设计
|
||||
- `src/schema/product.ts`(按契约文档)
|
||||
- `src/collector/url.ts`(从 plan.md 移植)
|
||||
- `src/profiles/types.ts`(从 plan.md 移植)
|
||||
|
||||
**C. 分步实现,每个里程碑验收后再进下一个**
|
||||
|
||||
你倾向哪个?还是有其他想法?
|
||||
@@ -0,0 +1,295 @@
|
||||
// ==========================================
|
||||
// 本地导出实现方案 (基于1688插件方式)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 导出配置
|
||||
*/
|
||||
interface ExportConfig {
|
||||
downloadType: '1' | '2'; // 1=平铺, 2=分组到子文件夹
|
||||
includeJson: boolean; // 是否导出product.json
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出素材到本地
|
||||
* 在 background.ts 中实现
|
||||
*/
|
||||
async function exportToLocal(
|
||||
folderName: string,
|
||||
materials: {
|
||||
texts: TextMaterial[];
|
||||
images: ImageMaterial[];
|
||||
},
|
||||
config: ExportConfig
|
||||
) {
|
||||
const downloadTasks: Promise<void>[] = [];
|
||||
|
||||
// 1. 导出图片
|
||||
for (const img of materials.images) {
|
||||
const groupFolder = config.downloadType === '2' ? img.groupName : '';
|
||||
|
||||
// 文件名: 分组key-索引-规格名(可选).扩展名
|
||||
const ext = img.url.split('.').pop()?.split('?')[0] || 'jpg';
|
||||
let filename = `${img.groupKey}-${String(img.index).padStart(3, '0')}`;
|
||||
if (img.variantName) {
|
||||
filename += `-${img.variantName}`;
|
||||
}
|
||||
filename += `.${ext}`;
|
||||
|
||||
// 构建完整路径: 商品名/分组/文件名
|
||||
const path = [folderName, groupFolder, filename]
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
|
||||
downloadTasks.push(
|
||||
chrome.downloads.download({
|
||||
url: img.url,
|
||||
filename: path,
|
||||
conflictAction: 'uniquify',
|
||||
saveAs: false
|
||||
}).then(() => {
|
||||
console.log(`Downloaded: ${path}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 导出product.json (Ozon API格式)
|
||||
if (config.includeJson) {
|
||||
const productData = buildOzonProductJson(materials);
|
||||
const jsonBlob = new Blob(
|
||||
[JSON.stringify(productData, null, 2)],
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
const jsonUrl = URL.createObjectURL(jsonBlob);
|
||||
|
||||
downloadTasks.push(
|
||||
chrome.downloads.download({
|
||||
url: jsonUrl,
|
||||
filename: `${folderName}/product.json`,
|
||||
conflictAction: 'overwrite',
|
||||
saveAs: false
|
||||
}).then(() => {
|
||||
URL.revokeObjectURL(jsonUrl);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 等待所有下载完成
|
||||
await Promise.allSettled(downloadTasks);
|
||||
|
||||
return {
|
||||
total: downloadTasks.length,
|
||||
folder: folderName
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Ozon API格式的JSON
|
||||
* 参考: https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3
|
||||
*/
|
||||
function buildOzonProductJson(materials: {
|
||||
texts: TextMaterial[];
|
||||
images: ImageMaterial[];
|
||||
}): OzonProductImport {
|
||||
const title = materials.texts.find(t => t.kind === 'title')?.content || '';
|
||||
const desc = materials.texts.find(t => t.kind === 'desc')?.content || '';
|
||||
const params = materials.texts.find(t => t.kind === 'params');
|
||||
|
||||
// 图片URL按分组整理
|
||||
const mainImages = materials.images
|
||||
.filter(img => img.groupKey === 'main')
|
||||
.map(img => img.url);
|
||||
|
||||
const skuImages = materials.images
|
||||
.filter(img => img.groupKey === 'sku')
|
||||
.reduce((acc, img) => {
|
||||
if (img.variantName) {
|
||||
acc[img.variantName] = img.url;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return {
|
||||
items: [{
|
||||
// 基础信息
|
||||
name: title,
|
||||
description: desc,
|
||||
offer_id: '', // 需要用户填写
|
||||
|
||||
// 图片
|
||||
images: mainImages,
|
||||
color_image: skuImages[Object.keys(skuImages)[0]] || '',
|
||||
|
||||
// 参数 (简化版,实际需要映射到Ozon类目属性)
|
||||
attributes: params?.pairs?.map(p => ({
|
||||
complex_id: 0,
|
||||
id: 0, // 需要查询Ozon类目属性字典
|
||||
values: [{
|
||||
value: p.value
|
||||
}]
|
||||
})) || [],
|
||||
|
||||
// 尺寸重量 (需要从参数中提取或用户填写)
|
||||
height: 0,
|
||||
width: 0,
|
||||
depth: 0,
|
||||
dimension_unit: 'cm',
|
||||
weight: 0,
|
||||
weight_unit: 'g'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息处理: 导出命令
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.name === 'export-to-local') {
|
||||
exportToLocal(
|
||||
msg.payload.folderName,
|
||||
msg.payload.materials,
|
||||
msg.payload.config
|
||||
)
|
||||
.then(result => sendResponse({ ok: true, data: result }))
|
||||
.catch(error => sendResponse({ ok: false, error: error.message }));
|
||||
|
||||
return true; // 保持异步通道
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Manifest配置
|
||||
// ==========================================
|
||||
/*
|
||||
{
|
||||
"optional_permissions": [
|
||||
"downloads" // 放在optional中,首次导出时才申请
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"https://www.ozon.ru/*",
|
||||
"https://cdn*.ozon.ru/*" // 图片CDN
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
// ==========================================
|
||||
// Side Panel UI - 导出操作
|
||||
// ==========================================
|
||||
/*
|
||||
<div class="export-section">
|
||||
<h3>导出选项</h3>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="exportType" value="2" checked>
|
||||
分组到子文件夹 (推荐)
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="exportType" value="1">
|
||||
全部平铺
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="includeJson" checked>
|
||||
同时导出product.json (Ozon格式)
|
||||
</label>
|
||||
|
||||
<button onclick="handleExport()">
|
||||
导出到本地 (Downloads文件夹)
|
||||
</button>
|
||||
|
||||
<p class="hint">
|
||||
文件将保存到: ~/Downloads/[商品名]/
|
||||
</p>
|
||||
</div>
|
||||
*/
|
||||
|
||||
async function handleExport() {
|
||||
// 1. 首次使用时请求downloads权限
|
||||
const hasPermission = await chrome.permissions.contains({
|
||||
permissions: ['downloads']
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
const granted = await chrome.permissions.request({
|
||||
permissions: ['downloads']
|
||||
});
|
||||
if (!granted) {
|
||||
alert('需要下载权限才能导出文件');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取当前文件夹数据
|
||||
const materials = await getCurrentFolderMaterials();
|
||||
const folderName = cleanFilename(materials.title || '未命名商品');
|
||||
|
||||
// 3. 发送导出消息到background
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
name: 'export-to-local',
|
||||
payload: {
|
||||
folderName,
|
||||
materials,
|
||||
config: {
|
||||
downloadType: document.querySelector('input[name="exportType"]:checked').value,
|
||||
includeJson: document.querySelector('input[name="includeJson"]').checked
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
alert(`成功导出 ${result.data.total} 个文件到:\n~/Downloads/${result.data.folder}/`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件名中的非法字符
|
||||
*/
|
||||
function cleanFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[<>:"/\\|?*]/g, '_') // Windows非法字符
|
||||
.replace(/\s+/g, '_') // 空格替换为下划线
|
||||
.substring(0, 100); // 限制长度
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 类型定义
|
||||
// ==========================================
|
||||
|
||||
interface TextMaterial {
|
||||
kind: 'title' | 'params' | 'desc' | 'price';
|
||||
content: string;
|
||||
pairs?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
interface ImageMaterial {
|
||||
groupKey: 'main' | 'sku' | 'detail' | 'video';
|
||||
groupName: string;
|
||||
variantName?: string; // SKU规格名
|
||||
url: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface OzonProductImport {
|
||||
items: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
offer_id: string;
|
||||
images: string[];
|
||||
color_image: string;
|
||||
attributes: Array<{
|
||||
complex_id: number;
|
||||
id: number;
|
||||
values: Array<{
|
||||
dictionary_value_id?: number;
|
||||
value?: string;
|
||||
}>;
|
||||
}>;
|
||||
height: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
dimension_unit: string;
|
||||
weight: number;
|
||||
weight_unit: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Ozon商品页采集配置
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 人工控制触发,不做复杂等待
|
||||
* 2. 多套选择器并存,应对Ozon的A/B测试
|
||||
* 3. 优先采集跟卖必需的字段
|
||||
*/
|
||||
|
||||
import type { SiteProfile } from './types';
|
||||
|
||||
export const profileOzon: SiteProfile = {
|
||||
id: 'ozon',
|
||||
name: 'Ozon',
|
||||
|
||||
// URL匹配
|
||||
urlPatterns: [
|
||||
/^https:\/\/www\.ozon\.ru\/product\//,
|
||||
/^https:\/\/www\.ozon\.ru\/context\/detail\/id\//
|
||||
],
|
||||
|
||||
// 提取商品ID
|
||||
extractItemId: (url) => {
|
||||
// Ozon URL格式: https://www.ozon.ru/product/name-123456789/
|
||||
const match = url.match(/\/product\/[^\/]+-(\d+)/);
|
||||
return match?.[1] ?? null;
|
||||
},
|
||||
|
||||
// 简单的就绪检测 - 只要关键元素存在即可
|
||||
readySelectors: [
|
||||
'[data-widget="webProductHeading"]', // 标题区
|
||||
'[data-widget="webGallery"]' // 图片画廊
|
||||
],
|
||||
readyTimeoutMs: 5_000, // 快速失败,不等太久
|
||||
|
||||
// 图片来源属性优先级
|
||||
defaultSrcProps: ['data-src', 'currentSrc', 'src'],
|
||||
|
||||
refererOrigin: 'https://www.ozon.ru',
|
||||
|
||||
// ==========================================
|
||||
// 文本素材规则
|
||||
// ==========================================
|
||||
textRules: [
|
||||
// 1. 标题 (必需)
|
||||
{
|
||||
kind: 'title',
|
||||
selectors: [
|
||||
'[data-widget="webProductHeading"] h1',
|
||||
'.tsHeadline500Medium',
|
||||
'h1[itemprop="name"]'
|
||||
],
|
||||
extract: 'first',
|
||||
required: true
|
||||
},
|
||||
|
||||
// 2. 价格
|
||||
{
|
||||
kind: 'price',
|
||||
selectors: [
|
||||
'[data-widget="webPrice"] span[class*="tsBodyControl500"]',
|
||||
'[data-widget="webPrice"] span',
|
||||
'.c2h9_27 span', // 可能的备用类名
|
||||
'span[itemprop="price"]'
|
||||
],
|
||||
extract: 'first'
|
||||
},
|
||||
|
||||
// 3. 参数表 (特性)
|
||||
{
|
||||
kind: 'params',
|
||||
selectors: [
|
||||
'[data-widget="webCharacteristics"] dl',
|
||||
'[data-widget="webDetailedCharacteristics"] dl',
|
||||
'.k1p_27 dl'
|
||||
],
|
||||
extract: 'table',
|
||||
tableKeySelector: 'dt',
|
||||
tableValueSelector: 'dd'
|
||||
},
|
||||
|
||||
// 4. 简介/卖点
|
||||
{
|
||||
kind: 'selling_point',
|
||||
selectors: [
|
||||
'[data-widget="webFeatures"]',
|
||||
'[data-widget="webAO"]',
|
||||
'.h9o_27' // About this item
|
||||
],
|
||||
extract: 'join'
|
||||
},
|
||||
|
||||
// 5. 详细描述
|
||||
{
|
||||
kind: 'desc',
|
||||
selectors: [
|
||||
'[data-widget="webDescription"]',
|
||||
'[data-widget="webRichContent"]',
|
||||
'.RA-a1'
|
||||
],
|
||||
extract: 'join'
|
||||
}
|
||||
],
|
||||
|
||||
// ==========================================
|
||||
// 图片素材规则
|
||||
// ==========================================
|
||||
imageGroups: [
|
||||
// 主图画廊
|
||||
{
|
||||
key: 'main',
|
||||
name: '主图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-widget="webGallery"] img[class*="Image"]',
|
||||
'[data-widget="webGallery"] source', // picture元素
|
||||
'[data-widget="webPhotoGallery"] img',
|
||||
'.b013-a img' // 旧版选择器
|
||||
],
|
||||
minWidth: 200,
|
||||
minHeight: 200
|
||||
},
|
||||
|
||||
// SKU变体图 (颜色/尺寸)
|
||||
{
|
||||
key: 'sku',
|
||||
name: 'SKU图片',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-widget="webDetailSKU"] button img',
|
||||
'[data-widget="webVariants"] img',
|
||||
'[data-widget="webSku"] img',
|
||||
'.k3r_27 img' // SKU容器
|
||||
],
|
||||
// SKU规格名提取
|
||||
nameSelectors: [
|
||||
'span[class*="Value"]',
|
||||
'span[class*="Text"]',
|
||||
'.tsBodyControl400Small'
|
||||
],
|
||||
minWidth: 20,
|
||||
minHeight: 20
|
||||
},
|
||||
|
||||
// 详情图 (描述中的图片)
|
||||
{
|
||||
key: 'detail',
|
||||
name: '详情图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-widget="webDescription"] img',
|
||||
'[data-widget="webRichContent"] img',
|
||||
'[data-widget="webFeatures"] img',
|
||||
'.RA-a1 img'
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100
|
||||
},
|
||||
|
||||
// 视频 (如果有)
|
||||
{
|
||||
key: 'video',
|
||||
name: '视频',
|
||||
type: 'video',
|
||||
selectors: [
|
||||
'[data-widget="webGallery"] video',
|
||||
'[data-widget="webVideo"] video',
|
||||
'video[class*="Video"]'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// ==========================================
|
||||
// 图片URL处理规则
|
||||
// ==========================================
|
||||
originalUrlRules: [
|
||||
{
|
||||
// Ozon CDN缩略图处理
|
||||
// 例: /wc200/xxx.jpg → /wc1200/xxx.jpg (获取更高分辨率)
|
||||
match: /\/wc\d+\//,
|
||||
replace: '/wc1200/'
|
||||
},
|
||||
{
|
||||
// 或者移除尺寸参数
|
||||
// 例: image.jpg?width=200 → image.jpg
|
||||
match: /\?(width|height|size|quality)=[^&]+&?/g,
|
||||
replace: ''
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Ozon特殊处理函数
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Ozon页面额外的数据提取
|
||||
* (可选) 从页面的JSON-LD结构化数据中提取
|
||||
*/
|
||||
export function extractOzonStructuredData(): {
|
||||
brand?: string;
|
||||
sku?: string;
|
||||
availability?: string;
|
||||
} | null {
|
||||
try {
|
||||
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||||
for (const script of scripts) {
|
||||
const data = JSON.parse(script.textContent || '{}');
|
||||
if (data['@type'] === 'Product') {
|
||||
return {
|
||||
brand: data.brand?.name,
|
||||
sku: data.sku,
|
||||
availability: data.offers?.availability
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract structured data:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测Ozon页面是否已就绪
|
||||
* (简化版 - 只检查关键元素存在)
|
||||
*/
|
||||
export function isOzonPageReady(): {
|
||||
ready: boolean;
|
||||
missing: string[];
|
||||
} {
|
||||
const requiredElements = [
|
||||
{ selector: '[data-widget="webProductHeading"]', name: '标题' },
|
||||
{ selector: '[data-widget="webGallery"]', name: '图片画廊' }
|
||||
];
|
||||
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const elem of requiredElements) {
|
||||
if (!document.querySelector(elem.selector)) {
|
||||
missing.push(elem.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: missing.length === 0,
|
||||
missing
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 使用示例 (在content script中)
|
||||
// ==========================================
|
||||
/*
|
||||
import { profileOzon, isOzonPageReady } from './profiles/ozon';
|
||||
|
||||
// 用户点击"采集"按钮时
|
||||
async function handleCollect() {
|
||||
// 1. 快速检查
|
||||
const { ready, missing } = isOzonPageReady();
|
||||
if (!ready) {
|
||||
alert(`页面未完全加载,缺少: ${missing.join(', ')}\n请稍候再试`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 执行采集
|
||||
const result = await scanCurrentPage(); // 使用通用采集引擎
|
||||
|
||||
// 3. 显示结果
|
||||
console.log('采集完成:', result);
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,213 @@
|
||||
# Seller Helper 方案设计
|
||||
|
||||
> 面向 Ozon 跨境电商的 AI 选品搬运工具
|
||||
> 文档状态:方案探讨阶段(未开始编码)
|
||||
> 最后更新:2026-08-05
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
在 Ozon 做跨境电商,货源来自 1688 / 淘宝 / 拼多多。当前痛点:
|
||||
|
||||
- 单个门店的商品图片、信息往往不完善,需要**跨多个网站抓取**补齐。
|
||||
- **图片最麻烦**:图里的中文需要转成俄文,甚至需要 AI 美化重做。
|
||||
- 信息要**一条条手动填进 Ozon 商品编辑页**,耗时。
|
||||
- 部分商品带**视频**,视频里的中文也可能需要替换成俄文。
|
||||
|
||||
### 目标效果
|
||||
|
||||
粘贴一个或多个卖家网站 URL → 程序自动:
|
||||
|
||||
1. 抓取商品信息、图片、包装信息等资源;
|
||||
2. 生成**俄文标题、标签、简介、富文本**(富文本用固定模板,图文内容由程序生成);
|
||||
3. 抓取合适的图片并**翻译中文 / AI 美化**;
|
||||
4. 提供**预览页面**,可点选某区域,通过**与 AI 对话**修改选中内容(文本或图片);
|
||||
5. 满意后提交,调用 **Ozon Seller API** 上传到后台(用户再去 Ozon 后台正式发布)。
|
||||
|
||||
Ozon Seller API 文档:https://docs.ozon.ru/api/seller/zh/
|
||||
|
||||
---
|
||||
|
||||
## 2. 可行性总览
|
||||
|
||||
**结论:能实现,大部分模块是成熟技术。** 全流程真正的难点只有两个:
|
||||
|
||||
- 🔴 **从 1688/淘宝/拼多多稳定抓取**(反爬)
|
||||
- 🔴 **视频里的中文替换**(工程量大、效果不稳定)
|
||||
|
||||
其余模块可靠可控。
|
||||
|
||||
| 模块 | 难度 | 说明 |
|
||||
|---|---|---|
|
||||
| ① 抓取商品信息/图片 | 🔴 高(反爬) | 三个平台都有强反爬,最脆弱的一环 |
|
||||
| ② 生成俄文标题/标签/简介 | 🟢 低 | LLM 直接做,质量高,可顺带做 Ozon SEO |
|
||||
| ③ 图片中文→俄文 | 🟡 中 | OCR 定位 + 抹字 + 重排,或图像编辑模型直改 |
|
||||
| ④ 图片 AI 美化 | 🟡 中 | 图生图,效果好但要控成本 |
|
||||
| ⑤ 富文本内容生成(模板+图文) | 🟢 低 | 模板注入 + LLM 填内容 |
|
||||
| ⑥ 预览 + 点选对话式编辑 | 🟡 中 | 标准 Web 应用 + AI 编辑循环,工程量在交互 |
|
||||
| ⑦ 上传 Ozon | 🟢 低-中 | API 清晰,难在**类目属性字典**要匹配对 |
|
||||
| ⑧ 视频中文替换 | 🔴 高 | 建议放到二期/三期,单独立项评估 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 各模块技术方案
|
||||
|
||||
### ① 抓取(数据入口)
|
||||
|
||||
官方 API(1688 开放平台、淘宝开放平台、多多进宝)对个人几乎不开放。三条可选路线,从稳到脆:
|
||||
|
||||
1. **浏览器插件方案(✅ 已选定)**:Chrome 扩展,用户正常登录浏览时点「抓取」,插件读取当前页面已渲染的 DOM/接口数据发给后端。**借用用户真人登录态与真实环境,最不易被封**,且多网站补充信息的场景天然契合——在哪个页面就抓哪个。
|
||||
2. 第三方"商品详情"数据 API:按次收费,省心但有费用、字段可能不全、依赖第三方稳定性。
|
||||
3. Playwright 后端无头浏览器:灵活但最易触发风控,维护成本高。
|
||||
|
||||
> **决策:MVP 采用浏览器插件抓取,先只支持 1688。**
|
||||
|
||||
### ② 文案生成(俄文标题/标签/简介)
|
||||
|
||||
用 Claude 把抓取的中文信息 → 生成俄文标题/关键词标签/卖点简介,并按 Ozon 习惯做本地化 SEO。质量最有保证,是核心价值区。
|
||||
|
||||
### ③ 图片文字翻译(中文→俄文)
|
||||
|
||||
两条路线,**已决定都接,预览页让用户对比挑选**:
|
||||
|
||||
- **路线 A · 传统管线(版式高度还原)**:OCR 定位中文(如 PaddleOCR)→ inpaint 抹除 → 按原版式重排俄文。可控、可批量,适合规格参数图,排版还原需调试。
|
||||
- **路线 B · 图像编辑模型直改(效果自然美观)**:用多模态图像编辑模型直接"把图里中文换成俄文/美化"。上手快、出图自然,但排版可能与原图有差异,需控成本与一致性。
|
||||
|
||||
### ④ 图片 AI 美化
|
||||
|
||||
图生图,效果好但要控成本。与路线 B 共用图像编辑模型能力。
|
||||
|
||||
### ⑤ 富文本内容生成
|
||||
|
||||
用户提供固定模板(含占位符),程序把生成好的图文填进模板。
|
||||
|
||||
### ⑥ 预览 + 点选对话式编辑
|
||||
|
||||
Web 页面渲染最终商品卡;每个可编辑区域(某段文字、某张图)可点选 → 弹出对话框 → 用户下指令 → 局部重生成。标准的"AI 编辑循环",工程量在交互设计。(Pi SDK 的适用性见第 6 节。)
|
||||
|
||||
### ⑦ Ozon 上传
|
||||
|
||||
Ozon Seller API 有完整的商品导入流程(`/v2/product/import`、图片上传、类目属性)。**核心坑是类目属性字典**:每个类目有强制属性,值必须来自 Ozon 字典(`/v2/category/attribute` 拉取),不能随便填。需做"属性映射 + 字典校验"层。上传后用户在后台正式发布。
|
||||
|
||||
### ⑧ 视频中文替换(二期/三期)
|
||||
|
||||
- 硬字幕(烧进画面的中文):逐帧 OCR + 抹除 + 重排,效果不稳定。
|
||||
- 配音:STT → 翻译 → TTS 重配,相对可控。
|
||||
- **建议一期先不做,或只做"音频重配"子集,单独立项评估。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐架构 / 技术栈
|
||||
|
||||
```
|
||||
┌─ Chrome 扩展(抓取,借用户登录态)
|
||||
│ │ { 标题, 参数, 图片URL[], 价格, 详情图文 }
|
||||
▼
|
||||
Next.js 后端(前后端一体)
|
||||
├─ /extract 规整原始数据
|
||||
├─ /generate Claude → 俄文标题/标签/简介 + 富文本(模板占位)
|
||||
├─ /image 队列任务:路线A(OCR+抹字+重排) / 路线B(图像编辑模型) 两版都出
|
||||
├─ 图片服务 Python 微服务:OCR/inpaint + 图像编辑模型
|
||||
├─ 任务队列 图片/视频异步处理(BullMQ)
|
||||
└─ /ozon 类目属性字典校验 + 上传草稿
|
||||
▼
|
||||
对象存储(图片/视频)
|
||||
▼
|
||||
前端预览页(React):点选区域 + 对话式编辑;图片 A/B 两版切换
|
||||
```
|
||||
|
||||
- **主栈 Next.js**(前后端一体)。
|
||||
- **图像/OCR 用 Python 微服务**(生态最好)。
|
||||
- 长任务用队列(BullMQ)。
|
||||
- 环境:Node 22 + Python 3.14(本机均已就绪)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 分阶段路线
|
||||
|
||||
### 一期 MVP(已选:文案为主链路)
|
||||
|
||||
**主链路目标**:1688 商品页点插件「抓取」→ 后端生成俄文文案+富文本 → 预览页可对话改文案 → 一键推 Ozon 草稿。图片一期先"原样搬运 + 可选翻译/美化",两条路线并存供挑选。
|
||||
|
||||
落地顺序(每步可独立验证):
|
||||
|
||||
1. **脚手架**:Next.js 项目 + 目录结构 + 环境变量(Claude key、Ozon key、图片模型 key)。
|
||||
2. **Chrome 扩展**:先只做 1688,抓取当前页 → POST 到本地后端,打通原始 JSON。
|
||||
3. **文案生成**:Claude 中文数据 → 俄文标题/标签/简介 + 富文本(占位符注入)。
|
||||
4. **预览页 + 对话式改文案**:渲染商品卡,点选文字区域 → 对话局部重生成。
|
||||
5. **Ozon 适配层**:拉类目属性字典 → 校验 → 上传草稿。**先把纯文案(无图)商品推成功。**
|
||||
6. **图片双路线**:接 OCR 抹字重排(A)+ 图像编辑模型(B),预览页出两版供选。
|
||||
|
||||
> 步骤 1、2 无需任何密钥即可先跑起来看到效果。
|
||||
|
||||
### 二期
|
||||
|
||||
多平台抓取(淘宝/拼多多)、图片美化、点选对话式改图、类目属性智能映射。
|
||||
|
||||
### 三期
|
||||
|
||||
视频处理。
|
||||
|
||||
---
|
||||
|
||||
## 6. Pi SDK 适用性评估
|
||||
|
||||
**Pi**(Earendil 出品,MIT 开源,`@earendil-works/pi-coding-agent`)是可嵌入的 **AI 编码 Agent 框架**(TS/JS)。核心:Session + 事件流、自定义工具 `defineTool()`、内置工具 `read/bash/edit/write/grep/find/ls`、消息队列 `steer()`/`followUp()`、Session 树 fork/clone、子 agent、可切换模型(能接 Claude)。文档:https://pi.dev/docs/latest/sdk
|
||||
|
||||
### 判断:外科手术式使用——只用在一个地方
|
||||
|
||||
**✅ 强契合:预览页「对话式编辑」循环**
|
||||
|
||||
该交互本质是带工具的 agent 会话,Pi 能力几乎量身定做:
|
||||
|
||||
| 需求 | Pi 对应能力 |
|
||||
|---|---|
|
||||
| 点选区域后对话改内容 | `AgentSession` + 自定义工具 `editText`/`regenText`/`editImage`/`swapImageVariant` |
|
||||
| 改到一半想换方向 | `steer()` 打断当前回合 |
|
||||
| 改完这段再改那段 | `followUp()` 排队 |
|
||||
| 回退 / 多方案对比 | **Session 树 fork/clone**(天然 undo + A/B 分支) |
|
||||
| 前端实时看 AI 在改什么 | 事件流喂 UI |
|
||||
| 长对话不爆上下文 | 内置 compaction |
|
||||
|
||||
自己用 Anthropic SDK 手撸需实现打断、排队、分支、压缩——Pi 白送。
|
||||
|
||||
**❌ 不建议:抓取→文案→图片→上传 主管线**
|
||||
|
||||
这是确定性 ETL,非开放式 agent 任务。用 agent 框架包会引入不必要的非确定性,**上传 Ozon 那步尤其不能让 agent 自由发挥**。用普通代码 + 直接 Claude API 更可控、好测。
|
||||
|
||||
> 原则:**流程固定的用管线代码;开放式、要来回对话的用 Pi。** 只有编辑循环属于后者。
|
||||
|
||||
### 两个坑
|
||||
|
||||
1. **默认内置工具含 `bash/edit/write`,服务端必须关掉。** 抓取来的网页内容是不可信输入(提示注入风险),若 agent 带 bash/写文件能力,可能被诱导在服务器执行命令。**用 Pi 时禁用全部内置工具,只暴露自定义业务工具,并做沙箱。**
|
||||
2. **依赖成熟度**:Earendil 较新,把产品核心交互绑上去有第三方风险。好在能接 Claude,LLM 层不锁定;真不行可退回自己用 Anthropic SDK 撸编辑循环。
|
||||
|
||||
### 待定决策
|
||||
|
||||
编辑循环实现方式:
|
||||
|
||||
- **A) 用 Pi**:省事,白送打断/分支/压缩。
|
||||
- **B) 用 Anthropic SDK 自撸**:少一个第三方依赖,需自己实现打断/分支。
|
||||
|
||||
**倾向建议**:一期先用 Anthropic SDK 把编辑循环跑通(简单版),Pi 作为二期需要 undo/多方案分支时的增强再引入,避免一上来被新框架卡住。**(未最终拍板)**
|
||||
|
||||
---
|
||||
|
||||
## 7. 已确认决策 / 待办
|
||||
|
||||
### 已确认
|
||||
|
||||
- 抓取方式:**浏览器插件**(先支持 1688)。
|
||||
- 一期 MVP:**文案为主链路**。
|
||||
- 图片文字翻译:**A/B 两条路线都接,预览页挑选**。
|
||||
|
||||
### 待用户提供(不阻塞脚手架)
|
||||
|
||||
- **Ozon Seller API 的 Client-Id / Api-Key**(沙箱或正式)——用于步骤 5。
|
||||
- **富文本模板**(哪怕草稿版)——用于步骤 3 占位符设计。
|
||||
- **图像编辑模型偏好**(无偏好则做成可插拔,A 路线先用开源 OCR 跑通)。
|
||||
|
||||
### 待决策
|
||||
|
||||
- 编辑循环:用 Pi(A)还是 Anthropic SDK 自撸(B)。
|
||||
@@ -0,0 +1,427 @@
|
||||
# Seller Helper 方案设计 V2
|
||||
|
||||
> 面向 Ozon 跨境电商的 AI 选品搬运工具
|
||||
> 文档状态:方案探讨阶段(未开始编码)
|
||||
> 最后更新:2026-08-06
|
||||
> V1 见 [`方案设计.md`](./方案设计.md),本文档为架构调整后的新版,V1 保留作为历史记录。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
在 Ozon 做跨境电商,货源来自 1688 / 淘宝 / 拼多多。当前痛点:
|
||||
|
||||
- 单个门店的商品图片、信息往往不完善,需要**跨多个网站抓取**补齐。
|
||||
- **图片最麻烦**:图里的中文需要转成俄文,需要白底化,甚至需要 AI 美化重做。
|
||||
- 信息要**一条条手动填进 Ozon 商品编辑页**,耗时。
|
||||
- 部分商品带**视频**,视频里的中文也可能需要替换成俄文(三期)。
|
||||
|
||||
### 目标工作流
|
||||
|
||||
```
|
||||
① 浏览 1688/淘宝/拼多多商品页
|
||||
↓ 插件侧边栏点「收集」(可指定文件夹)
|
||||
② 素材汇入同一「文件夹」(文本 + 图片,可来自多个页面多次收集)
|
||||
↓ 打开发布管理系统
|
||||
③ 选择文件夹 → 进入该文件夹的发布页
|
||||
↓ 左侧素材 → 加入对话框 → 写要求 → 调大模型
|
||||
④ 右侧结构化表单被逐字段填充(俄文标题/描述/标签/规格)
|
||||
↓ 图片:白底、图内翻译(按钮式);AI 生图(对话式)
|
||||
⑤ 挑选图片与头图、填价格 → 一键提交 Ozon 草稿
|
||||
```
|
||||
|
||||
Ozon Seller API 文档:https://docs.ozon.ru/api/seller/zh/
|
||||
|
||||
---
|
||||
|
||||
## 2. 与 V1 的核心差异
|
||||
|
||||
| 维度 | V1 | V2 |
|
||||
|---|---|---|
|
||||
| 插件职责 | 抓取当前页 → POST 后端 | **纯采集器**:识别页面 + 归入文件夹 + 去重 |
|
||||
| 采集单位 | 单次抓取 = 一个商品 | **文件夹**:多页面多次收集汇入同一文件夹 |
|
||||
| 发布页宿主 | 独立 Web 预览页 | **后端托管的发布管理系统**,按文件夹进入 |
|
||||
| 编辑交互 | 点选区域 → 弹对话框局部重生成 | **左素材 / 右表单 / 底对话框** 三栏工作台 |
|
||||
| AI 输出形态 | 生成文本填入预览 | **结构化字段补丁**,逐字段接受/丢弃 + 版本历史 |
|
||||
| 图片操作 | 统一走处理管线 | **确定性操作按钮化**,AI 生图走对话 |
|
||||
| 模型选择 | 固定 Claude | **多模型可配置**(文本/视觉/图像三类槽位) |
|
||||
| Ozon 类目 | 上传前匹配 | **进入发布页即先定类目**,用于渲染规格表单 |
|
||||
|
||||
未变化的部分:Python 图片微服务、对象存储、异步任务队列、Ozon 类目属性字典校验层。
|
||||
|
||||
---
|
||||
|
||||
## 3. 整体架构
|
||||
|
||||
```
|
||||
浏览器
|
||||
├─ Chrome 扩展(瘦客户端,只做采集)
|
||||
│ ├─ Content Script 识别商品页 → 提取 {标题, 参数, 卖点, 图片URL[], 价格, 详情图文}
|
||||
│ ├─ Side Panel 当前文件夹 ▾ / 本页抓到什么 / 「收集」按钮 / 最近素材
|
||||
│ └─ Service Worker 唯一出网口:带 token 调后端;图片取不到时兜底抓字节
|
||||
│ │ HTTPS
|
||||
└─ 发布管理系统(普通 Web 应用,新标签页打开)
|
||||
├─ 文件夹列表页 进度状态 / 素材数 / 创建时间
|
||||
└─ 发布页 左素材 · 右表单 · 底对话框
|
||||
│
|
||||
后端服务(Next.js) │
|
||||
├─ /api/folder 文件夹 CRUD、列表
|
||||
├─ /api/material 写入素材(接受 URL 或二进制)+ 排队预下载图片
|
||||
├─ /api/draft 草稿字段读写、字段版本历史、补丁接受/丢弃
|
||||
├─ /api/chat 对话 → 调用可配置 LLM → 返回字段补丁
|
||||
├─ /api/image/op 白底 / 图内翻译 / 生图(异步任务,返回 jobId)
|
||||
├─ /api/job/:id 任务状态与进度(轮询或 SSE)
|
||||
├─ /api/ozon/category 类目推荐 + 属性字典拉取与缓存
|
||||
├─ /api/ozon/publish 属性字典校验 → 图片上传 → 建草稿
|
||||
├─ 任务队列
|
||||
└─ 凭证保管(各模型 key、Ozon Client-Id / Api-Key,加密存储)
|
||||
│
|
||||
├─ Python 图片微服务 OCR / inpaint / 抠图白底 / 图生图编排
|
||||
├─ 对象存储 原图 + 各衍生版本
|
||||
└─ 数据库 文件夹、素材、草稿、任务、Ozon 字典缓存
|
||||
│
|
||||
▼
|
||||
Ozon Seller API
|
||||
```
|
||||
|
||||
**设计原则**:插件不持有任何密钥、不直接访问 Ozon 与模型服务;所有算力与凭证集中在后端。
|
||||
|
||||
---
|
||||
|
||||
## 4. 插件端设计
|
||||
|
||||
### 4.1 职责边界
|
||||
|
||||
插件**只做四件事**:识别页面、提取素材、归入文件夹、去重。不做生成、不做图片处理、不碰 Ozon。
|
||||
|
||||
预计代码量很小(几百行),好处是迭代频率低、无需频繁重新发布扩展;重逻辑全在后端和 Web 端,正常部署即可更新。
|
||||
|
||||
### 4.2 侧边栏(Side Panel)
|
||||
|
||||
用 Chrome Side Panel(Chrome 114+)而非 popup,浏览时可常驻不消失。
|
||||
|
||||
```
|
||||
┌ Seller Helper ──────────────┐
|
||||
│ 当前文件夹:儿童保温杯 ▾ [+新建] │
|
||||
│ ─────────────────────────── │
|
||||
│ 本页识别到: │
|
||||
│ 标题 儿童316不锈钢保温杯… │
|
||||
│ 参数 12 项 │
|
||||
│ 卖点 3 段 │
|
||||
│ 图片 9 张 [预览] │
|
||||
│ ─────────────────────────── │
|
||||
│ [ 全部收集 ] [ 选择性收集 ] │
|
||||
│ ─────────────────────────── │
|
||||
│ 该文件夹已收集:3 个来源 · 12 图 │
|
||||
│ [ 打开发布页 ↗ ] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**「当前文件夹」是载荷概念**:多页收集必须先明确归属,否则容易串。支持在侧边栏直接新建。
|
||||
|
||||
### 4.3 与后端通信
|
||||
|
||||
- **必须走 Service Worker 转发**。MV3 下 Content Script 发出的跨域请求受页面 CORS 约束;Service Worker 只要在 `host_permissions` 声明了后端域名即可直连。
|
||||
- **鉴权**:自用阶段用后端签发的长期 token,插件 options 页填一次存入 `chrome.storage.local`。后续如需多用户再换 OAuth。
|
||||
|
||||
### 4.4 图片获取的两条路
|
||||
|
||||
| 路径 | 场景 | 说明 |
|
||||
|---|---|---|
|
||||
| 优先:只传 URL | 1688 主图 CDN(`cbu01.alicdn.com`)等公开可取 | 后端直接下载,插件负担最小 |
|
||||
| 兜底:传二进制 | 需要 referer / 登录态才能取的图 | 插件在页面上下文 `fetch` 成 blob 后上传 |
|
||||
|
||||
因此 `/api/material` 接口需**同时接受 URL 列表与二进制上传**两种输入。
|
||||
|
||||
### 4.5 去重
|
||||
|
||||
多个来源页大概率有重复图。两级去重:
|
||||
|
||||
1. **URL 归一化去重**(去掉尺寸后缀等 query 参数)——插件侧即可完成。
|
||||
2. **感知哈希去重**(pHash/dHash)——后端下载后计算,相似度超阈值的标记为疑似重复,发布页折叠展示。
|
||||
|
||||
### 4.6 预热
|
||||
|
||||
素材写入后端后**立即排队预下载图片**,并可选预跑一遍白底/OCR。用户还在浏览其他页面时后台就处理完了,进发布页时图片即时可用,不用干等。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
```
|
||||
Folder 文件夹
|
||||
id, name, status(collecting|editing|published), ozonCategoryId, createdAt
|
||||
|
||||
└─ Material 素材
|
||||
id, folderId, type(text|image), sourceUrl, sourcePlatform, capturedAt
|
||||
── type=text : textKind(title|params|selling_point|desc|price), content
|
||||
── type=image : originalUrl, storageKey, width, height, phash, dupOfId
|
||||
|
||||
└─ Variant 图片衍生版本
|
||||
id, materialId, kind(original|whitebg|translated|aigen|upscaled)
|
||||
storageKey, params(JSON), jobId, createdAt
|
||||
|
||||
└─ Draft 发布草稿(每个文件夹一份)
|
||||
id, folderId, fields(JSON), selectedVariantIds[], heroVariantId
|
||||
price, currency, ozonCategoryId, updatedAt
|
||||
|
||||
└─ FieldVersion 字段版本历史
|
||||
id, draftId, field, value(JSON), source(ai|manual), messageId, createdAt
|
||||
|
||||
└─ Message 对话记录
|
||||
id, draftId, role(user|assistant), content
|
||||
attachedMaterialIds[], attachedVariantIds[]
|
||||
modelUsed, resultPatch(JSON), patchStatus(pending|accepted|discarded)
|
||||
|
||||
└─ Job 异步任务
|
||||
id, folderId, type(download|whitebg|translate|aigen|ozon_upload)
|
||||
status(queued|running|done|failed), progress, input(JSON), output(JSON), error
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **一张图 = 一个 Material + 多个 Variant**。右侧挑图时是从所有 Variant 里挑,原图和白底图、翻译图、AI 图平级可选。
|
||||
- **FieldVersion 独立成表**,支撑逐字段回退。
|
||||
- **Message 记录 resultPatch 与其接受状态**,可追溯每个字段值是哪一轮对话产生的。
|
||||
|
||||
---
|
||||
|
||||
## 6. 发布页交互设计
|
||||
|
||||
### 6.1 布局
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ 文件夹:儿童保温杯 · 3个来源 · 12张图 Ozon类目:保温杯 ▾ [发布] │
|
||||
├────────────────────────────┬─────────────────────────────────────────┤
|
||||
│ 左:收集的素材 │ 右:生成结果(= Ozon 字段结构化表单) │
|
||||
│ ┌ 文本 ─────────────────┐ │ 标题(ru) v3 ⟲历史 │
|
||||
│ │ ☑ 标题 · 1688 │ │ ────────────────────────────────── │
|
||||
│ │ ☐ 参数表 · 1688 │ │ 简介/描述(ru) 待生成 │
|
||||
│ │ ☐ 卖点 · 淘宝 │ │ ────────────────────────────────── │
|
||||
│ │ ☐ 详情文案 · 拼多多 │ │ 关键词标签 v1 │
|
||||
│ └───────────────────────┘ │ ────────────────────────────────── │
|
||||
│ ┌ 图片 ─────────────────┐ │ 规格属性(按类目字典渲染) │
|
||||
│ │ [□][□][□][□] │ │ 颜色▾ 材质▾ 容量▾ 尺寸 重量 … │
|
||||
│ │ [□][□][□] … │ │ ────────────────────────────────── │
|
||||
│ │ 选中 → [白底][翻译] │ │ 图片 [头图][2][3][4] 可拖拽排序 │
|
||||
│ │ [加入对话] │ │ ────────────────────────────────── │
|
||||
│ └───────────────────────┘ │ 价格 [____] ₽ │
|
||||
├────────────────────────────┴─────────────────────────────────────────┤
|
||||
│ @标题·1688 @参数表 @img_03 模型: Claude Sonnet 4.5 ▾ │
|
||||
│ 整理成符合 Ozon SEO 的俄文标题和描述… [发送] │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
左侧按「文本在上、图片在下」排列,均可多选并「加入对话」,在对话框上方以 chip 形式展示引用(类似 Cursor 的 `@` 上下文引用)。
|
||||
|
||||
### 6.2 右侧是结构化表单,不是聊天输出区
|
||||
|
||||
**这决定了接口契约**:LLM 不返回自由文本,而返回**字段补丁**。
|
||||
|
||||
```json
|
||||
{
|
||||
"patches": [
|
||||
{ "field": "title_ru", "value": "Детский термос из стали 316...", "reason": "含核心关键词,58字符符合Ozon标题建议长度" },
|
||||
{ "field": "tags", "value": ["термос детский", "поилка", "316 сталь"] },
|
||||
{ "field": "attributes.color", "value": "Синий", "dictValueId": 61234 },
|
||||
{ "field": "attributes.material", "value": "Сталь", "dictValueId": 58901 },
|
||||
{ "field": "attributes.weight_g", "value": 320 }
|
||||
],
|
||||
"notes": "参数表里未给出杯口直径,规格中该必填项仍需补充"
|
||||
}
|
||||
```
|
||||
|
||||
右侧对涉及字段展示**新旧对比 + 逐字段「接受 / 丢弃」**。
|
||||
|
||||
> **为什么必须这样**:如果 AI 每次返回一整份内容整体覆盖右侧,用户手改过的标题会在下一轮对话被抹掉。补丁 + 逐字段接受是保护手工编辑的必要设计。
|
||||
|
||||
配套 **字段级版本历史**:每个字段保留若干版本(含 `source=ai|manual`),随时回退。
|
||||
|
||||
### 6.3 Ozon 类目先行
|
||||
|
||||
**进入发布页的第一步是确认类目**,因为右侧「规格属性」区的字段构成完全由类目字典决定——哪些必填、哪些是枚举、枚举有哪些合法值。
|
||||
|
||||
流程:
|
||||
|
||||
1. 后端根据文件夹内已收集的标题/参数,让 LLM 推荐 3–5 个候选 Ozon 类目。
|
||||
2. 用户确认一个(可手动搜索改选)。
|
||||
3. 拉取该类目的属性字典(`/v2/category/attribute`,结果缓存)。
|
||||
4. 右侧按字典渲染表单:枚举项渲染为下拉、必填项标红。
|
||||
5. 之后 LLM 生成规格时,**候选值被约束在字典内**(见 §7.3),而不是自由生成完再去匹配。
|
||||
|
||||
### 6.4 图片:两类操作分开
|
||||
|
||||
| 类型 | 操作 | 交互方式 | 原因 |
|
||||
|---|---|---|---|
|
||||
| 确定性管线 | 抠图白底、图内中文→俄文、放大、裁剪 | **选中图片 → 点按钮 → 出新 Variant** | 参数化即可,无需自然语言 |
|
||||
| 开放式生成 | AI 生图 / 改图("换成户外草地背景") | **加入对话框 → 写要求 → 发送** | 需求无法枚举,必须自然语言 |
|
||||
|
||||
两类操作产出统一落为 Variant,右侧挑图时平级可选。处理中的 Variant 显示占位与进度(来自 Job)。
|
||||
|
||||
### 6.5 对话框
|
||||
|
||||
- 上方 chip 区展示已引用的文本素材与图片素材,可逐个移除。
|
||||
- 右侧下拉可**临时切换本次调用的模型**。
|
||||
- 发送后流式返回,先出 `notes` 说明,再出 patches 落到右侧待接受状态。
|
||||
- 图片素材送入视觉模型前**先降采样**,控制 token 成本。
|
||||
|
||||
---
|
||||
|
||||
## 7. 各模块技术方案
|
||||
|
||||
### 7.1 采集(数据入口)
|
||||
|
||||
沿用 V1 决策:**浏览器插件**,借用户真人登录态与真实环境,最不易被封。MVP 只支持 1688。
|
||||
|
||||
官方 API(1688 开放平台等)对个人几乎不开放;Playwright 无头浏览器最易触发风控。插件方案同时天然契合"多网站补充信息"场景。
|
||||
|
||||
### 7.2 文案生成(俄文标题/描述/标签)
|
||||
|
||||
引用的中文素材 → LLM → 俄文字段补丁,并按 Ozon 习惯做本地化 SEO。
|
||||
|
||||
- 用 **structured output / tool calling** 约束返回为补丁 JSON schema,不用自由文本再解析。
|
||||
- 标题长度、关键词密度等 Ozon 规则写进 system prompt 与校验层双重保障。
|
||||
|
||||
### 7.3 规格属性映射(最容易翻车的一环)
|
||||
|
||||
难点:LLM 生成的颜色/材质/容量等值必须落到 Ozon 字典的合法枚举 id 上。
|
||||
|
||||
三步兜底:
|
||||
|
||||
1. **召回**:属性字典可能有上千个枚举值,先按关键词/向量召回 Top-N 候选塞进 prompt。
|
||||
2. **约束选择**:让 LLM 从候选中选 `dictValueId`,而非自由生成字符串。
|
||||
3. **人工兜底**:匹配不到或置信度低时,右侧该字段标记为"需人工选择",提供搜索下拉。
|
||||
|
||||
发布前做一次**必填项与字典合法性全量校验**,不通过则阻止提交并高亮问题字段。
|
||||
|
||||
### 7.4 图片处理
|
||||
|
||||
| 能力 | 方案 | 难度 |
|
||||
|---|---|---|
|
||||
| 抠图白底 | 抠图模型(如 RMBG / SAM 系)+ 合成白底 | 🟢 低 |
|
||||
| 图内中文→俄文 · 路线A | OCR 定位(PaddleOCR)→ inpaint 抹除 → 按原版式重排俄文 | 🟡 中,版式还原需调试 |
|
||||
| 图内中文→俄文 · 路线B | 多模态图像编辑模型直改 | 🟡 中,自然但版式可能偏移 |
|
||||
| AI 生图 / 改图 | 图生图,对话驱动 | 🟡 中,需控成本 |
|
||||
|
||||
沿用 V1 决策:**A/B 两条路线都接**,各产出一个 Variant,发布页对比挑选。
|
||||
|
||||
### 7.5 Ozon 上传
|
||||
|
||||
走 Seller API 商品导入流程(`/v2/product/import`、图片上传、类目属性)。上传为草稿,用户再去 Ozon 后台正式发布。
|
||||
|
||||
顺序:图片先上传拿到 Ozon 侧 URL → 组装商品 JSON(含校验通过的属性)→ import → 轮询 import 任务状态 → 回写结果到 Draft。
|
||||
|
||||
### 7.6 视频(三期)
|
||||
|
||||
- 硬字幕:逐帧 OCR + 抹除 + 重排,效果不稳定。
|
||||
- 配音:STT → 翻译 → TTS 重配,相对可控。
|
||||
- 一期不做。
|
||||
|
||||
---
|
||||
|
||||
## 8. 多模型配置
|
||||
|
||||
模型不是一类,**至少三个槽位**,各自独立配置 provider / model / key:
|
||||
|
||||
| 槽位 | 用途 | 候选 |
|
||||
|---|---|---|
|
||||
| 文本 LLM | 生成俄文文案、规格映射、类目推荐 | Claude / GPT / DeepSeek / Qwen 等 |
|
||||
| 视觉 LLM | 读图理解、从图里补参数、辅助 OCR 校对 | Claude / GPT-4o / Qwen-VL 等 |
|
||||
| 图像生成/编辑 | 生图、改图、白底、图内改字(路线B) | 按需可插拔 |
|
||||
|
||||
要求:
|
||||
|
||||
- 全局默认 + 发布页对话框内**临时切换**。
|
||||
- 密钥加密存储在后端,插件与前端均不接触。
|
||||
- 抽象一层统一调用接口,避免绑死单一 provider。技术上倾向 **Vercel AI SDK** 之类的多 provider 统一层。
|
||||
|
||||
> **关于 Pi SDK(V1 §6 的待决策)**:V2 下编辑循环的输出被收敛为"结构化字段补丁",本质是 structured output + 工具调用,不是开放式 agent 任务;加上多模型可配置的需求,多 provider 统一层比 agent 框架更贴合。**结论:一期不引入 Pi**,用统一 SDK 自行实现对话循环;未来若确实需要 session 树 fork / 多方案分支,再评估引入。
|
||||
|
||||
---
|
||||
|
||||
## 9. 技术栈
|
||||
|
||||
```
|
||||
Chrome 扩展 WXT(或 Plasmo)+ React + TailwindCSS,Manifest V3 + Side Panel
|
||||
后端 / Web Next.js(前后端一体,App Router)
|
||||
数据库 PostgreSQL + Prisma
|
||||
对象存储 S3 兼容(本地开发用 MinIO)
|
||||
任务队列 MVP 用数据库任务表 + worker 轮询;并发上来再换 BullMQ + Redis
|
||||
图片微服务 Python(FastAPI):OCR / inpaint / 抠图 / 图生图编排
|
||||
模型接入 统一多 provider SDK
|
||||
进度推送 MVP 轮询 /api/job/:id;发布页体验优化时换 SSE
|
||||
环境 Node 22 + Python 3.14(本机已就绪)
|
||||
```
|
||||
|
||||
**队列选型说明**:一期刻意不上 Redis,用数据库任务表 + 单 worker 轮询即可满足单人使用的并发量,少一个部署组件。
|
||||
|
||||
---
|
||||
|
||||
## 10. 分阶段路线
|
||||
|
||||
### 一期 MVP
|
||||
|
||||
**主链路**:1688 多页收集到同一文件夹 → 发布页对话生成俄文文案 → 白底处理 → 挑图填价 → 推 Ozon 草稿。
|
||||
|
||||
落地顺序(每步可独立验证):
|
||||
|
||||
1. **脚手架**:Next.js + Prisma + 数据模型建表 + MinIO 本地对象存储。
|
||||
2. **插件采集**:WXT 脚手架 + 1688 页面识别 + 文件夹选择 + 收集到后端,打通原始数据。*(无需任何密钥即可验证)*
|
||||
3. **文件夹列表页 + 发布页骨架**:左素材 / 右表单 / 底对话框三栏布局,素材可引用。
|
||||
4. **对话生成文案**:对话 → LLM → 字段补丁 → 右侧逐字段接受 + 版本历史。
|
||||
5. **Ozon 类目层**:类目推荐 → 拉属性字典 → 右侧规格表单按字典渲染 → 字典约束的规格映射。
|
||||
6. **Ozon 提交**:图片上传 + 商品 import + 状态回写。**先把纯文案(原图直搬)商品推成功。**
|
||||
7. **图片白底**:抠图管线 + Variant 机制 + 异步任务与进度。
|
||||
|
||||
### 二期
|
||||
|
||||
图内中文翻译双路线(A/B 对比)、AI 生图改图、多平台采集(淘宝/拼多多)、感知哈希去重、SSE 进度、类目属性智能映射优化。
|
||||
|
||||
### 三期
|
||||
|
||||
视频处理。
|
||||
|
||||
---
|
||||
|
||||
## 11. 关键风险与坑
|
||||
|
||||
| 风险 | 级别 | 应对 |
|
||||
|---|---|---|
|
||||
| 1688/淘宝/拼多多反爬 | 🔴 高 | 插件借真人登录态,已是最稳路线;仍需容忍页面改版导致选择器失效,做好降级提示 |
|
||||
| Ozon 类目属性字典匹配 | 🔴 高 | 召回 + 约束选择 + 人工兜底三层;提交前全量校验 |
|
||||
| 右侧手工编辑被 AI 覆盖 | 🟡 中 | 字段补丁 + 逐字段接受 + 版本历史(§6.2) |
|
||||
| 图片 Variant 存储膨胀 | 🟡 中 | 定期清理未被选中的 Variant;原图按需保留 |
|
||||
| 视觉模型调用成本 | 🟡 中 | 图片送模型前降采样;对话上下文限制引用图片数量 |
|
||||
| 页面 DOM 提取不可信输入 | 🟡 中 | 采集内容视为不可信,进 LLM 前做清洗;后端不给模型任何文件/命令执行能力 |
|
||||
| 图内翻译版式还原 | 🟡 中 | A/B 双路线并存,人工挑选 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 已确认决策 / 待办
|
||||
|
||||
### 已确认
|
||||
|
||||
- 采集方式:**浏览器插件**,只做采集,先支持 1688。
|
||||
- 采集单位:**文件夹**,多页面多次收集汇入同一文件夹。
|
||||
- 发布页宿主:**后端托管的发布管理系统**(Web 应用),不打包进插件。
|
||||
- 发布页布局:**左素材(文本上 / 图片下)· 右结构化表单 · 底对话框**。
|
||||
- 右侧形态:**Ozon 字段结构化表单**,AI 返回字段补丁,逐字段接受,带版本历史。
|
||||
- 图片操作:**确定性操作按钮化,AI 生图走对话**;结果统一为 Variant。
|
||||
- 图内文字翻译:**A/B 两条路线都接**,发布页挑选。
|
||||
- 模型:**多模型可配置**,文本 / 视觉 / 图像三类槽位,密钥仅存后端。
|
||||
- 编辑循环:**不引入 Pi SDK**,用多 provider 统一 SDK 自行实现。
|
||||
- Ozon 交互:**插件不直接访问 Ozon**,全部经后端。
|
||||
|
||||
### 待用户提供(不阻塞前 4 步)
|
||||
|
||||
- **Ozon Seller API 的 Client-Id / Api-Key**(沙箱或正式)——用于第 5–6 步。
|
||||
- **模型 API Key**(文本 / 视觉 / 图像各一)——用于第 4 步及图片处理。
|
||||
- **富文本描述模板**(哪怕草稿版)——用于描述字段的结构设计。
|
||||
- **图像编辑模型偏好**(无偏好则做成可插拔,白底先用开源抠图模型跑通)。
|
||||
|
||||
### 待决策
|
||||
|
||||
- 文件夹与 SKU 的关系:一个文件夹固定产出一个商品,还是允许拆成多个变体商品(多颜色/多规格)?
|
||||
- 素材是否需要跨文件夹复用(如通用尾图、品牌图)?
|
||||
- 是否需要发布历史与"再次编辑已发布商品"的能力。
|
||||
@@ -0,0 +1,147 @@
|
||||
# 插件方案修正说明
|
||||
|
||||
> 对上一轮输出(`profiles-ozon.ts` / `download-implementation.ts` / `IMPLEMENTATION_PLAN.md`)的复核
|
||||
> 最后更新:2026-08-11
|
||||
> 上游:[总体架构](../architecture.md) · [契约](../contracts/product-json.md)
|
||||
|
||||
按你的三点反馈复核后,方案主体成立,但有 5 处需要改。**R1 和 R2 是实质性问题**,其余是准确性修正。
|
||||
|
||||
---
|
||||
|
||||
## R1 · 保存目录:downloads API 做不到「选目录」🔴
|
||||
|
||||
上一轮说「完全采用 1688 的 downloads 方案」,这个结论对 1688 插件成立,对我们**不成立**。
|
||||
|
||||
`chrome.downloads.download()` 的 `filename` 只能是**下载目录下的相对路径**,不接受绝对路径,也不接受 `..`。1688 插件够用是因为它只需要「按商品名建子目录」;而你的需求里有一条它没有:
|
||||
|
||||
> 用户可以选择采集数据保存的目录,这样同一商品不同平台采集的数据放在同一文件夹内
|
||||
|
||||
downloads API 下这意味着每次都落在 `~/Downloads/<商品名>/`,用户无法指定别的位置,也无法可靠地"追加到上次那个文件夹"(只能靠商品名字符串撞对)。
|
||||
|
||||
### 改用 File System Access API
|
||||
|
||||
```ts
|
||||
// 首次:用户选一次根目录(如 ~/Ozon商品库)
|
||||
const rootHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||
await idbSet('SH_ROOT_DIR', rootHandle); // IndexedDB 可持久化存 handle
|
||||
|
||||
// 之后:无需再授权,直接建/进商品子目录
|
||||
const root = await idbGet('SH_ROOT_DIR');
|
||||
if (await root.queryPermission({ mode: 'readwrite' }) !== 'granted') {
|
||||
await root.requestPermission({ mode: 'readwrite' }); // 极少数情况需重新确认
|
||||
}
|
||||
const productDir = await root.getDirectoryHandle('儿童保温杯_316', { create: true });
|
||||
const imagesDir = await productDir.getDirectoryHandle('images', { create: true });
|
||||
const mainDir = await imagesDir.getDirectoryHandle('main', { create: true });
|
||||
|
||||
const fh = await mainDir.getFileHandle('main-001.jpg', { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await w.write(blob);
|
||||
await w.close();
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
- **handle 能存进 IndexedDB 并跨会话复用**,不用每次弹框。这正好支撑"Ozon 采完切 1688 追加到同一文件夹"。
|
||||
- 只能在**扩展页面上下文**调用(side panel 可以,content script 不行)。采集在 content script,写盘在 side panel,正好符合现有分工。
|
||||
- 能**读回** `sources.json` 做去重(downloads API 只能写不能读,这是它第二个致命短板)。
|
||||
- 图片字节仍需 background 代理 fetch(绕 CORS / 防盗链),拿到 blob 再交给 side panel 写盘。
|
||||
|
||||
`chrome.downloads` 保留为降级路径:用户拒绝授权目录时,退回 `~/Downloads/<商品名>/`。
|
||||
|
||||
---
|
||||
|
||||
## R2 · Ozon 选择器全部未经验证 🔴
|
||||
|
||||
上一轮 `profiles-ozon.ts` 里的选择器**是我根据 Ozon 的通用 DOM 惯例推测的,没有在真实页面上跑过**。其中:
|
||||
|
||||
| 选择器 | 可信度 | 说明 |
|
||||
|---|---|---|
|
||||
| `[data-widget="webProductHeading"]` | 🟡 中 | Ozon 确实用 `data-widget` 标记区块,但具体名称需实测 |
|
||||
| `[data-widget="webGallery"]` | 🟡 中 | 同上 |
|
||||
| `.tsHeadline500Medium` | 🟡 中 | Ozon 设计系统的 typography class,相对稳定 |
|
||||
| `.k1p_27` `.e5k_27` `.c2h9_27` `.h9o_27` `.RA-a1` | 🔴 低 | **哈希类名,每次发版就变,等于无效** |
|
||||
|
||||
哈希类名写进配置是负资产——它给人"有兜底"的错觉,实际上一周后就失效。**M2 第一步必须是在真实 Ozon 页面上实测,把哈希类名全部替换掉。**
|
||||
|
||||
替代思路,按优先级:
|
||||
|
||||
1. **`data-widget` 属性**:Ozon 的区块标记,改版时相对稳定
|
||||
2. **JSON-LD / `__NUXT__` 之类的内嵌数据**:见 R3
|
||||
3. **结构关系**:`h1` 在页面第一个 `data-widget` 里、图片在 `<picture>` 中等
|
||||
4. **哈希类名**:只在实测确认当前有效时临时用,并标注"随时会失效"
|
||||
|
||||
---
|
||||
|
||||
## R3 · 内嵌 JSON 可能比 DOM 选择器更靠得住 🟡
|
||||
|
||||
> **2026-08-11 更新:这条对淘宝/天猫已证伪。** 两站实测 `script[type="application/ld+json"]`
|
||||
> 都是空数组,只能走 DOM(详见 [`selectors-taobao.md`](./selectors-taobao.md) §4.2)。
|
||||
> 下面的推理对 Ozon 仍待验证——Ozon 是 SSR 电商站,带 JSON-LD 的概率仍然不低。
|
||||
>
|
||||
> 另外淘宝 `window` 上有 `__general_skupanel_cache_data` 等键可能含结构化数据,
|
||||
> 但 MV3 content script 默认在 isolated world,读不到页面 `window`,需 `world: 'MAIN'`。二期评估。
|
||||
|
||||
上一轮把接口抓取评估为"MV3 下 webRequest 读不到响应体,建议以 DOM 为主"——这个结论对**网络层拦截**是对的,但漏了第三条路。
|
||||
|
||||
Ozon 是 SSR + 水合,页面 HTML 里通常带完整的商品数据(`application/ld+json`、或挂在 `window` 上的 state)。这是**同步可读、无需拦截网络**的:
|
||||
|
||||
```ts
|
||||
// 路径 A:JSON-LD(标准化,最稳)
|
||||
document.querySelectorAll('script[type="application/ld+json"]')
|
||||
// → { "@type": "Product", name, sku, brand, offers: { price, priceCurrency }, image[] }
|
||||
|
||||
// 路径 B:内嵌 state(字段全,但结构随版本变)
|
||||
// 实测时在 Console 里翻 window 上的候选键
|
||||
```
|
||||
|
||||
若实测发现 Ozon 的 JSON-LD 里就有标题、价格、品牌、图片列表,那**主路径应该是解析 JSON-LD,DOM 选择器降级为兜底**——JSON-LD 有 schema.org 标准约束,比哈希类名稳定一个数量级。
|
||||
|
||||
M2 的实测任务因此扩为两条:DOM 选择器 + 内嵌 JSON,看哪条覆盖率高。
|
||||
|
||||
---
|
||||
|
||||
## R4 · product.json 生成逻辑要移出插件 🟡
|
||||
|
||||
上一轮 `download-implementation.ts` 里的 `buildOzonProductJson()` 试图填 `attributes[].id`,还标了 `// 需要查询Ozon类目属性字典`。这块**插件做不了也不该做**:属性 id 依赖类目,类目在工作台才定。
|
||||
|
||||
按契约([product-json.md §4](../contracts/product-json.md)):
|
||||
|
||||
```
|
||||
插件 → 写 _raw.params(原始 kv),attributes 留空数组
|
||||
工作台 → 定类目 → 拉字典 → 映射 attributes
|
||||
```
|
||||
|
||||
另外两处要改:
|
||||
|
||||
- `offer_id` 上一轮注释成"需要用户填写",应明确**采集阶段恒为空字符串**。跟卖场景下沿用竞品货号是错的。
|
||||
- `images` 上一轮直接填了采集到的源站 URL。应填**本地相对路径**到 `_images`,`images` 字段留空——Ozon 要的是我们自己图床的公网 URL,源站 URL 提交上去等于盗链且随时失效。
|
||||
|
||||
---
|
||||
|
||||
## R5 · 就绪检测保留人工控制,但补一条提示 🟢
|
||||
|
||||
你的判断对,人工触发能绕开绝大部分动态渲染问题,一期不做 MutationObserver。
|
||||
|
||||
只补一点:Ozon 详情图是**滚动懒加载**的,用户不滚到底部时详情图根本不在 DOM 里。所以侧边栏在检测到 `detail` 组为 0 张时,要提示:
|
||||
|
||||
```
|
||||
主图 6 · SKU 4 · 详情 0
|
||||
⚠️ 详情图为 0,请滚动到页面底部让图片加载后重新采集
|
||||
```
|
||||
|
||||
比静默采到 0 张要好。这不算"智能等待",只是把结果如实告诉用户。
|
||||
|
||||
---
|
||||
|
||||
## 修正后的 M1–M4
|
||||
|
||||
| 里程碑 | 内容 | 关键改动 |
|
||||
|---|---|---|
|
||||
| M1 | product.json 契约 + TS 类型 | 新增,先定契约 |
|
||||
| **M2** | **Ozon 实测:选择器 + 内嵌 JSON 双路径调研** | R2/R3,**这一步必须在真实页面上做,是整个插件的地基** |
|
||||
| M3 | 采集引擎 + 侧边栏表单 + 图片分组勾选 | |
|
||||
| M4 | File System Access 写商品文件夹 | R1,替换 downloads 方案 |
|
||||
| M5 | 1688 profile + 读 sources.json 去重追加 | |
|
||||
|
||||
M2 需要你提供 3–5 个不同类目的 Ozon 商品页链接(最好含一个有 SKU 变体的、一个详情图很多的)。没有真实页面,选择器配置只能停在推测。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
# 淘宝/天猫选择器实测记录
|
||||
|
||||
> 实测日期:2026-08-11
|
||||
> 页面:`detail.tmall.com/item.htm?id=960057430812`、`item.taobao.com/item.htm?id=1060253247160`
|
||||
> 方法:反向扫描(dump 页面实际类名前缀,而非猜名字去查)
|
||||
> 对应实现:`extension/src/profiles/taobao.ts`
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论
|
||||
|
||||
**两站 DOM 完全一致**,同一套前端(`PageFramework--` / `tbpc-layout` / `keyInfo--` 骨架相同),一份 profile 覆盖淘宝与天猫。
|
||||
|
||||
类名形如 `mainTitle--HASH`,是 CSS Modules 产物:**语义前缀稳定,哈希后缀每次构建变**。因此选择器一律写 `[class*="前缀--"]`。
|
||||
|
||||
结尾的 `--` 不能省——它把父容器和子元素区分开:`generalParamsInfoItem--` 不会误命中 `generalParamsInfoItemTitle--`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 确证的选择器
|
||||
|
||||
| 目标 | 选择器 | 实测证据 |
|
||||
|---|---|---|
|
||||
| 标题 | `[class*="mainTitle--"]` | n=2 imgs=0,纯文本节点。天猫「迷你特工队玩具X弗特…」淘宝「对插双刀流发光双刃剑…」 |
|
||||
| 标题兜底 | `[class*="MainTitle--"]` `[class*="ItemTitle--"]` | 外层容器,天猫版带图标(imgs=2) |
|
||||
| 价格 | `[class*="highlightPrice--"]` | 淘宝 `¥5.2`,天猫 `秒杀价¥27.72` |
|
||||
| 价格兜底 | `[class*="priceWrap--"]` | 会带上「优惠前¥36.8」,故仅兜底 |
|
||||
| 主图 | `[class*="picGallery--"] img` / `#picGalleryEle` | 天猫 imgs=6,淘宝 imgs=7 |
|
||||
| 主图缩略 | `[class*="thumbnailPic--"]` | n=5/6 |
|
||||
| SKU 容器 | `[class*="valueItem--"]` | **n=22 imgs=22**(天猫),每项恰含一张 img |
|
||||
| SKU 规格名 | `[class*="valueItemText--"]` | 「特工x武器小【弗特】2种形态- 可变形」 |
|
||||
| 参数项 | `[class*="generalParamsInfoItem--"]` | Title=「品牌」SubTitle=「劣狐狐(模玩)」 |
|
||||
| 详情区 | `[class*="tabDetailWrap--"]` `[class*="detailInfo--"]` | 淘宝 imgs=7 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 两个反直觉的点
|
||||
|
||||
### 3.1 淘宝 SKU 是真实 `<img>`,不是 CSS 背景图
|
||||
|
||||
**与 1688 相反。** 探测数据:
|
||||
|
||||
```
|
||||
valueItem n=22 imgs=22 ← 容器,每项含 1 张 img
|
||||
valueItemImgWrap n=22 imgs=22 ← 图片包裹层
|
||||
valueItemImg n=22 imgs=0 ← img 元素本身(querySelectorAll('img') 查自己得 0)
|
||||
valueItemText n=22 imgs=0 ← 规格名文本
|
||||
```
|
||||
|
||||
`imgs=0` 恰恰证明 `valueItemImg--` 就是 `<img>`。所以淘宝 profile **不能**用 `srcProps: ['backgroundImage']`(1688 必须用)。
|
||||
|
||||
引擎为此补了一段兜底:选择器命中容器且未取到 URL 时,往下找一层 `querySelector('img')`(见 `collector/image.ts`)。
|
||||
|
||||
### 3.2 主图组不设 minWidth
|
||||
|
||||
`picGallery--` 里同时有大图和缩略图,缩略图 `naturalWidth` 只有 60 左右。按 `minWidth: 200` 过滤会把主图**全部误杀**。
|
||||
|
||||
不过滤是安全的,因为 `toOriginalUrl()` 会把两种尺寸都还原成同一个原图 URL,`dedupeKey` 相同即自动去重。
|
||||
|
||||
---
|
||||
|
||||
## 4. 两个失效的既有假设
|
||||
|
||||
### 4.1 页面上没有 `<h1>`
|
||||
|
||||
`document.querySelector('h1')` 返回 null。旧 profile 里的 `.tb-detail-hd h1`、`h1[data-spm]`、裸 `h1` 全部无效,`readySelectors` 也不能用 `h1` 探活。
|
||||
|
||||
### 4.2 没有 JSON-LD
|
||||
|
||||
两站 `script[type="application/ld+json"]` 都是**空数组**。
|
||||
|
||||
`plan-revision.md` R3 里"JSON-LD 比 DOM 选择器稳定一个数量级"的推测**对淘宝不成立**(对 Ozon 仍待验证)。淘宝只能走 DOM。
|
||||
|
||||
---
|
||||
|
||||
## 5. 待办
|
||||
|
||||
### 5.1 desc 暂不采集
|
||||
|
||||
`detailInfo--` 容器里混着用户评价、参数信息、图文详情三块,`extract: 'join'` 出来是无法使用的一坨。考虑到 1688/淘宝的中文文案对 Ozon 价值本就低(需重写),一期跳过。
|
||||
|
||||
若以后要采,需先定位「图文详情」那个 `tabDetailItem--` 的稳定标识。
|
||||
|
||||
### 5.2 详情图需要用户操作
|
||||
|
||||
图文详情是懒加载 + tab 切换。用户不点开「图文详情」tab 就采不到。`scan.ts` 已有 `stats.detail === 0` 的警告。
|
||||
|
||||
### 5.3 内嵌数据待评估
|
||||
|
||||
`window` 上有一批可能有用的键,但**当前架构读不到**——MV3 content script 默认跑在 isolated world,看不见页面 `window`。要读需 `world: 'MAIN'` 或注入 script 标签。
|
||||
|
||||
值得关注的:
|
||||
|
||||
```
|
||||
__general_skupanel_cache_data ← 可能含完整 SKU 结构
|
||||
__ICE_DATA_LOADER__ ← ICE 框架的数据层
|
||||
__itempage_openapi
|
||||
g_config
|
||||
```
|
||||
|
||||
如果 `__general_skupanel_cache_data` 真含 SKU 数据,比 DOM 抓 22 个 `valueItem--` 可靠得多。二期评估。
|
||||
|
||||
---
|
||||
|
||||
## 6. 复测脚本
|
||||
|
||||
改版后重跑,对照本文档的证据列:
|
||||
|
||||
```js
|
||||
(() => {
|
||||
const KEY = /(title|name|main|pic|img|gallery|thumb|sku|value|desc|detail|param|price)/i;
|
||||
const pfx = new Map();
|
||||
document.querySelectorAll('*').forEach(el => {
|
||||
if (typeof el.className !== 'string') return;
|
||||
el.className.split(/\s+/).forEach(c => {
|
||||
const m = c.match(/^([A-Za-z][A-Za-z0-9]*)--/);
|
||||
if (!m || !KEY.test(m[1])) return;
|
||||
const r = pfx.get(m[1]) ?? { prefix: m[1], n: 0, imgs: 0, sample: '' };
|
||||
r.n++; r.imgs += el.querySelectorAll('img').length;
|
||||
if (!r.sample) r.sample = (el.textContent || '').trim().slice(0, 40);
|
||||
pfx.set(m[1], r);
|
||||
});
|
||||
});
|
||||
console.table([...pfx.values()].sort((a, b) => b.n - a.n).slice(0, 40));
|
||||
})();
|
||||
```
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
原则:
|
||||
|
||||
- **密钥只放 `.env`**(不进 git);**模型清单放 `config/models.yaml`**(可入库)。
|
||||
- **密钥只放 `.env`**(不进 git);**模型清单放 `server/config/models.yaml`**(可入库)。
|
||||
- **前端只请求本机 API**,不直连大模型、不接触密钥。
|
||||
- **一体扁平结构**:不拆 `frontend/` / `backend/`;Python 入口在仓库根,静态资源独占 `web/`。
|
||||
- **现有静态能力尽量保留**;服务端先做薄代理 + Prompt 编排。
|
||||
@@ -135,7 +135,7 @@ ozon-seller-kit/
|
||||
|------|--------|------|
|
||||
| 页面结构 | `web/ozonSeller.html` | 文案区 DOM、模型下拉 |
|
||||
| 文案交互 | `web/js/ai-copy.js` | 拉模型列表、带 model 调生成 |
|
||||
| 模型目录 | `config/models.yaml` | id/label/base_url/api_key_env;可入库 |
|
||||
| 模型目录 | `server/config/models.yaml` | id/label/base_url/api_key_env;可入库 |
|
||||
| 密钥 | 根目录 `.env` | 仅密钥与 HOST/PORT;不入库 |
|
||||
| API 路由 | `api/` | 按业务拆文件 |
|
||||
| LLM 调用 | `services/deepseek.py` | 按 ModelSpec 调 OpenAI 兼容接口 |
|
||||
@@ -177,7 +177,7 @@ const API_BASE = window.location.origin; // 同域,无 CORS 烦恼
|
||||
| Web 框架 | FastAPI | 轻量、类型清晰、异步友好 |
|
||||
| HTTP 客户端 | `httpx` | 调 OpenAI 兼容接口 |
|
||||
| 密钥/运行参数 | `pydantic-settings` + `.env` | 只放秘密与端口 |
|
||||
| 模型目录 | `config/models.yaml` | 可扩展多模型/多厂商 |
|
||||
| 模型目录 | `server/config/models.yaml` | 可扩展多模型/多厂商 |
|
||||
| 运行 | `uvicorn` | 标准 ASGI |
|
||||
|
||||
### 5.2 核心依赖
|
||||
@@ -195,11 +195,11 @@ PyYAML
|
||||
|
||||
**原则:**
|
||||
|
||||
- `config/models.yaml`:模型清单(id、显示名、api_model、base_url、api_key_env),**可入库,不含密钥**。
|
||||
- `server/config/models.yaml`:模型清单(id、显示名、api_model、base_url、api_key_env),**可入库,不含密钥**。
|
||||
- `.env`:只放密钥与 HOST/PORT 等运行参数,**不入库**。
|
||||
- 前端通过 `GET /api/ai/models` 获取可选项,**永不接触密钥**。
|
||||
|
||||
`config/models.yaml` 示例:
|
||||
`server/config/models.yaml` 示例:
|
||||
|
||||
```yaml
|
||||
default: deepseek-v4-flash
|
||||
@@ -382,7 +382,7 @@ services/deepseek.py
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -r server/requirements.txt
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入 DEEPSEEK_API_KEY
|
||||
```
|
||||
@@ -391,7 +391,7 @@ cp .env.example .env
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
uvicorn main:app --reload --host 127.0.0.1 --port 8000
|
||||
uvicorn main:app --app-dir server --reload --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
浏览器打开:
|
||||
@@ -422,7 +422,7 @@ web/ozonSeller.html.bak
|
||||
- [x] `web/js/ai-copy.js` 对接 API
|
||||
- [x] FastAPI 挂载 `web/`,同域静态托管可本地跑通
|
||||
- [x] 根目录 `README.md` / `start.command`
|
||||
- [x] `config/models.yaml` 模型目录 + `GET /api/ai/models` + 前端下拉切换
|
||||
- [x] `server/config/models.yaml` 模型目录 + `GET /api/ai/models` + 前端下拉切换
|
||||
|
||||
### Phase 2(图片)
|
||||
|
||||
@@ -451,7 +451,7 @@ web/ozonSeller.html.bak
|
||||
## 10. 结论
|
||||
|
||||
- **目录**:一体扁平;Python 在仓库根,静态页在 `web/`。
|
||||
- **模型配置**:清单在 `config/models.yaml`,密钥在 `.env`;前端只消费 `/api/ai/models`。
|
||||
- **模型配置**:清单在 `server/config/models.yaml`,密钥在 `.env`;前端只消费 `/api/ai/models`。
|
||||
- **服务**:FastAPI(`main.py`)挂载 `web/` 并提供 `/api/*`。
|
||||
- **前端**:俄文文案区支持模型下拉;逻辑在 `web/js/ai-copy.js`。
|
||||
- **扩展**:`api/image.py`、`api/ozon.py` 预留;加模型只需改 yaml + 对应密钥环境变量。
|
||||
@@ -25,17 +25,20 @@ cd /path/to/ozon-seller-kit
|
||||
|
||||
```
|
||||
ozon-seller-kit/
|
||||
├── main.py # FastAPI 入口
|
||||
├── start.command # macOS 一键启动
|
||||
├── requirements.txt
|
||||
├── .env.example # 环境变量模板
|
||||
├── .env # 本地密钥(勿提交)
|
||||
├── config/
|
||||
│ ├── settings.py
|
||||
│ └── models.yaml # 可选模型清单
|
||||
└── web/ # 前端静态页
|
||||
├── .env # 本地密钥(勿提交,位于仓库根)
|
||||
├── server/ # ④ 后端
|
||||
│ ├── main.py # FastAPI 入口
|
||||
│ ├── requirements.txt
|
||||
│ └── config/
|
||||
│ ├── settings.py
|
||||
│ └── models.yaml # 可选模型清单
|
||||
└── web/ # ① 工具台 v1 静态页
|
||||
```
|
||||
|
||||
> 后端在 `server/` 下,但 `.env` 在**仓库根**,由各部分共用。启动时工作目录保持仓库根,靠 `--app-dir server` 定位应用。
|
||||
|
||||
---
|
||||
|
||||
## 3. 配置环境变量
|
||||
@@ -69,12 +72,12 @@ CORS_ORIGINS=
|
||||
|
||||
说明:
|
||||
|
||||
- `HOST` / `PORT` 由 `config/settings.py` 读取;当前 `start.command` 写死为 `127.0.0.1:8000`。若要改端口,需同步改启动命令或脚本。
|
||||
- 以后若在 `config/models.yaml` 中接入其他厂商,按其中的 `api_key_env` 在 `.env` 增加对应变量(例如 `OPENAI_API_KEY`)。
|
||||
- `HOST` / `PORT` 由 `server/config/settings.py` 读取;当前 `start.command` 写死为 `127.0.0.1:8000`。若要改端口,需同步改启动命令或脚本。
|
||||
- 以后若在 `server/config/models.yaml` 中接入其他厂商,按其中的 `api_key_env` 在 `.env` 增加对应变量(例如 `OPENAI_API_KEY`)。
|
||||
|
||||
### 3.4 模型清单(可选)
|
||||
|
||||
`config/models.yaml` 控制页面模型下拉与默认模型,可直接改 `default` 或增删 `models` 条目。密钥只通过 `api_key_env` 引用环境变量名,不要把 Key 写进 yaml。
|
||||
`server/config/models.yaml` 控制页面模型下拉与默认模型,可直接改 `default` 或增删 `models` 条目。密钥只通过 `api_key_env` 引用环境变量名,不要把 Key 写进 yaml。
|
||||
|
||||
开发模式下改 yaml 会热重载(见下方启动参数 `--reload-include '*.yaml'`)。
|
||||
|
||||
@@ -93,7 +96,7 @@ chmod +x start.command # 仅首次需要
|
||||
|
||||
脚本会:
|
||||
|
||||
1. 若不存在 `.venv` → 创建虚拟环境并 `pip install -r requirements.txt`
|
||||
1. 若不存在 `.venv` → 创建虚拟环境并 `pip install -r server/requirements.txt`
|
||||
2. 若不存在 `.env` → 从 `.env.example` 复制后退出,请填 Key 后再次启动
|
||||
3. 启动 Uvicorn:`http://127.0.0.1:8000`
|
||||
|
||||
@@ -104,16 +107,16 @@ chmod +x start.command # 仅首次需要
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -r server/requirements.txt
|
||||
|
||||
# 确保已配置 .env
|
||||
uvicorn main:app --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8000
|
||||
# 确保已配置 .env(在仓库根)。始终在仓库根执行,靠 --app-dir 定位应用
|
||||
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
生产或长时间挂机可不加 `--reload`:
|
||||
|
||||
```bash
|
||||
uvicorn main:app --host 127.0.0.1 --port 8000
|
||||
uvicorn main:app --app-dir server --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
@@ -157,7 +160,7 @@ uvicorn main:app --host 127.0.0.1 --port 8000
|
||||
|
||||
```bash
|
||||
lsof -i :8000
|
||||
uvicorn main:app --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8001
|
||||
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8001
|
||||
```
|
||||
|
||||
换端口后页面地址改为对应端口。
|
||||
@@ -186,7 +189,7 @@ rm -rf .venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -U pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r server/requirements.txt
|
||||
```
|
||||
|
||||
### 只想看静态页、不用 AI
|
||||
@@ -206,7 +209,7 @@ pip install -r requirements.txt
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
uvicorn main:app --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8000
|
||||
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
- 改 Python / yaml:热重载后自动生效(`.env` 除外,需重启)。
|
||||
Reference in New Issue
Block a user