Compare commits
11 Commits
76be4a82dd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6027d8f8d7 | |||
| 38a7aa3b59 | |||
| 8bcff0d821 | |||
| 36357843d0 | |||
| c61d1a3154 | |||
| c18d70017e | |||
| 0e91bde09d | |||
| b27e42dc75 | |||
| 6c4356de24 | |||
| eeb90b69fd | |||
| 134a89a8de |
+29
-1
@@ -2,7 +2,35 @@ DEEPSEEK_API_KEY=sk-xxxx
|
|||||||
# 以后若接入其他厂商,按 models.yaml 中的 api_key_env 增加对应变量,例如:
|
# 以后若接入其他厂商,按 models.yaml 中的 api_key_env 增加对应变量,例如:
|
||||||
# OPENAI_API_KEY=sk-xxxx
|
# OPENAI_API_KEY=sk-xxxx
|
||||||
|
|
||||||
|
# 阿里云百炼(万相图生图 wanx2.1-imageedit),华北2(北京)地域的 API Key
|
||||||
|
DASHSCOPE_API_KEY=sk-xxxx
|
||||||
|
# 仅业务空间调用时填写:https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1
|
||||||
|
# DASHSCOPE_BASE_HTTP_API_URL=
|
||||||
|
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
PORT=8000
|
PORT=8800
|
||||||
# Comma-separated origins when frontend runs on another port. Same-origin mount can leave empty.
|
# Comma-separated origins when frontend runs on another port. Same-origin mount can leave empty.
|
||||||
CORS_ORIGINS=
|
CORS_ORIGINS=
|
||||||
|
|
||||||
|
# ── V2:数据层 ──
|
||||||
|
# 本地过渡用 SQLite(默认);上线腾讯云切 PostgreSQL:
|
||||||
|
# DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/ozon_seller
|
||||||
|
# DATABASE_URL=sqlite+aiosqlite:///./data/app.db
|
||||||
|
|
||||||
|
# ── V2:鉴权 ──
|
||||||
|
# MVP 单用户登录 token(studio 登录页 / 插件 options 页填同一个值),可随意生成一长串随机字符串
|
||||||
|
APP_TOKEN=change-me-to-a-long-random-token
|
||||||
|
# 店铺 Client-Id/Api-Key 的 AES-GCM 加密密钥 + JWT 签名密钥
|
||||||
|
SECRET_KEY=change-me-to-a-long-random-secret
|
||||||
|
|
||||||
|
# ── V2:七牛(图片存储)──
|
||||||
|
# 留空则用本地文件系统兜底(开发期);填了并设 STORAGE_BACKEND=qiniu 则走七牛
|
||||||
|
QINIU_ACCESS_KEY=
|
||||||
|
QINIU_SECRET_KEY=
|
||||||
|
QINIU_BUCKET=
|
||||||
|
# 必须 https!Ozon 拉取商品图片只接受 https 直链,http 会被拒绝
|
||||||
|
QINIU_DOMAIN=https://your-cdn-domain.example.com
|
||||||
|
STORAGE_BACKEND=local
|
||||||
|
|
||||||
|
# ── V2:对外地址(插件/前端回写、生成图回调)──
|
||||||
|
APP_BASE_URL=http://127.0.0.1:8800
|
||||||
|
|||||||
+13
@@ -4,3 +4,16 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
web/ozonSeller.html.bak
|
web/ozonSeller.html.bak
|
||||||
|
|
||||||
|
# V2 运行时数据(SQLite + 本地媒体)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# 反编译参考资料(约 40 个 bundle),设计结论已写入 docs/extension/plan.md §2
|
||||||
|
reference/
|
||||||
|
|
||||||
|
# Node(extension / studio / packages)
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.output/
|
||||||
|
.wxt/
|
||||||
|
.pnpm-store/
|
||||||
|
|||||||
@@ -1,36 +1,70 @@
|
|||||||
# Ozon Seller Kit
|
# Ozon Seller Kit
|
||||||
|
|
||||||
Ozon 上品辅助工具:计价、登记、图片水印、俄文文案生成。
|
Ozon 跨境上品工具链:**采集 → 编辑 → 发布**。
|
||||||
前后端同仓一体:FastAPI 托管 `web/` 静态页并提供 `/api/*`。
|
|
||||||
|
## 四个组成部分
|
||||||
|
|
||||||
|
| 目录 | 部分 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| `web/` | ① 工具台 v1:计价、登记、水印、俄文文案 | ✅ 在用(冻结) |
|
||||||
|
| `extension/` | ② Chrome 采集插件:Ozon / 1688 商品页采集 | 🔨 待开发 |
|
||||||
|
| `studio/` | ③ 发布工作台:AI 图生图(上传/水印/万相图生图) | 🔨 开发中 |
|
||||||
|
| `server/` | ④ FastAPI:AI 文案、图生图(wanx2.1-imageedit)、Ozon API | 🔨 部分就绪 |
|
||||||
|
|
||||||
|
四者通过磁盘上的「[商品文件夹](docs/contracts/product-json.md)」契约衔接,不直接耦合代码。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
```
|
```
|
||||||
ozon-seller-kit/
|
ozon-seller-kit/
|
||||||
├── main.py
|
├── server/ # ④ FastAPI(main.py / api / services / schemas / config)
|
||||||
├── config/ # settings + models.yaml
|
├── web/ # ① 工具台 v1,冻结
|
||||||
├── api/
|
├── extension/ # ② 采集插件(待建)
|
||||||
├── services/
|
├── studio/ # ③ 发布工作台(React + Vite + antd,AI 图生图已上线)
|
||||||
├── schemas/
|
├── packages/schema/ # 跨端共享契约(待建)
|
||||||
├── web/
|
├── docs/ # 全部文档
|
||||||
├── docs/
|
|
||||||
├── start.command
|
├── start.command
|
||||||
├── requirements.txt
|
|
||||||
└── .env.example
|
└── .env.example
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
**推荐:双击 `start.command`**,或:
|
**双击 `start.command`**,或:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./start.command
|
./start.command
|
||||||
```
|
```
|
||||||
|
|
||||||
打开:http://127.0.0.1:8000/ozonSeller.html
|
打开 http://127.0.0.1:8800/ozonSeller.html
|
||||||
|
|
||||||
- 密钥写在根目录 `.env`(参考 `.env.example`)
|
- 密钥写在根目录 `.env`(参考 `.env.example`)
|
||||||
- 可选模型写在 `config/models.yaml`(页面下拉会自动读取)
|
- 可选模型写在 `server/config/models.yaml`
|
||||||
|
|
||||||
部署与启动详见 [`docs/deployment.md`](docs/deployment.md)。
|
### 发布工作台(studio/,AI 图生图)
|
||||||
文案方案设计见 [`docs/ai-copy-backend-plan.md`](docs/ai-copy-backend-plan.md)。
|
|
||||||
|
先启动后端(同上 `./start.command`,或确保 `uvicorn` 跑在 8800),再启动前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd studio
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # http://localhost:8900(启动后自动打开浏览器)
|
||||||
|
```
|
||||||
|
|
||||||
|
前端通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8800`。图生图依赖阿里云百炼的
|
||||||
|
**wanx2.1-imageedit**(华北2/北京),需在 `.env` 配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DASHSCOPE_API_KEY=sk-xxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/architecture.md`](docs/architecture.md) | **总体架构**,先读这个 |
|
||||||
|
| [`docs/contracts/product-json.md`](docs/contracts/product-json.md) | 商品文件夹契约(四部分的衔接点)|
|
||||||
|
| [`docs/extension/plan.md`](docs/extension/plan.md) | 插件方案(含 1688 插件逆向分析)|
|
||||||
|
| [`docs/extension/plan-revision.md`](docs/extension/plan-revision.md) | 插件方案修正(针对 Ozon 优先)|
|
||||||
|
| [`docs/deployment.md`](docs/deployment.md) | 部署与启动 |
|
||||||
|
| [`docs/ai-copy-backend-plan.md`](docs/ai-copy-backend-plan.md) | 俄文文案后端方案 |
|
||||||
|
| [`docs/studio/image-edit.md`](docs/studio/image-edit.md) | AI 图生图(studio 页面 + /api/image/edit) |
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = server/migrations
|
||||||
|
prepend_sys_path = server
|
||||||
|
# URL 由 env.py 从 server/config/settings.py 读取(DATABASE_URL),此处留空
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/image", tags=["image"])
|
|
||||||
|
|
||||||
# Phase 2: watermark / white background / img2img proxy
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
_ROOT_DIR = Path(__file__).resolve().parents[1]
|
|
||||||
load_dotenv(_ROOT_DIR / ".env")
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""运行时与密钥。模型清单见 config/models.yaml。"""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=str(_ROOT_DIR / ".env"),
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
deepseek_api_key: str = ""
|
|
||||||
openai_api_key: str = ""
|
|
||||||
host: str = "127.0.0.1"
|
|
||||||
port: int = 8000
|
|
||||||
cors_origins: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cors_origin_list(self) -> list[str]:
|
|
||||||
if not self.cors_origins.strip():
|
|
||||||
return []
|
|
||||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
return Settings()
|
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
# 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,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 变体的、一个详情图很多的)。没有真实页面,选择器配置只能停在推测。
|
||||||
@@ -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**,不直连大模型、不接触密钥。
|
- **前端只请求本机 API**,不直连大模型、不接触密钥。
|
||||||
- **一体扁平结构**:不拆 `frontend/` / `backend/`;Python 入口在仓库根,静态资源独占 `web/`。
|
- **一体扁平结构**:不拆 `frontend/` / `backend/`;Python 入口在仓库根,静态资源独占 `web/`。
|
||||||
- **现有静态能力尽量保留**;服务端先做薄代理 + Prompt 编排。
|
- **现有静态能力尽量保留**;服务端先做薄代理 + Prompt 编排。
|
||||||
@@ -135,7 +135,7 @@ ozon-seller-kit/
|
|||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 页面结构 | `web/ozonSeller.html` | 文案区 DOM、模型下拉 |
|
| 页面结构 | `web/ozonSeller.html` | 文案区 DOM、模型下拉 |
|
||||||
| 文案交互 | `web/js/ai-copy.js` | 拉模型列表、带 model 调生成 |
|
| 文案交互 | `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;不入库 |
|
| 密钥 | 根目录 `.env` | 仅密钥与 HOST/PORT;不入库 |
|
||||||
| API 路由 | `api/` | 按业务拆文件 |
|
| API 路由 | `api/` | 按业务拆文件 |
|
||||||
| LLM 调用 | `services/deepseek.py` | 按 ModelSpec 调 OpenAI 兼容接口 |
|
| LLM 调用 | `services/deepseek.py` | 按 ModelSpec 调 OpenAI 兼容接口 |
|
||||||
@@ -177,7 +177,7 @@ const API_BASE = window.location.origin; // 同域,无 CORS 烦恼
|
|||||||
| Web 框架 | FastAPI | 轻量、类型清晰、异步友好 |
|
| Web 框架 | FastAPI | 轻量、类型清晰、异步友好 |
|
||||||
| HTTP 客户端 | `httpx` | 调 OpenAI 兼容接口 |
|
| HTTP 客户端 | `httpx` | 调 OpenAI 兼容接口 |
|
||||||
| 密钥/运行参数 | `pydantic-settings` + `.env` | 只放秘密与端口 |
|
| 密钥/运行参数 | `pydantic-settings` + `.env` | 只放秘密与端口 |
|
||||||
| 模型目录 | `config/models.yaml` | 可扩展多模型/多厂商 |
|
| 模型目录 | `server/config/models.yaml` | 可扩展多模型/多厂商 |
|
||||||
| 运行 | `uvicorn` | 标准 ASGI |
|
| 运行 | `uvicorn` | 标准 ASGI |
|
||||||
|
|
||||||
### 5.2 核心依赖
|
### 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 等运行参数,**不入库**。
|
- `.env`:只放密钥与 HOST/PORT 等运行参数,**不入库**。
|
||||||
- 前端通过 `GET /api/ai/models` 获取可选项,**永不接触密钥**。
|
- 前端通过 `GET /api/ai/models` 获取可选项,**永不接触密钥**。
|
||||||
|
|
||||||
`config/models.yaml` 示例:
|
`server/config/models.yaml` 示例:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
default: deepseek-v4-flash
|
default: deepseek-v4-flash
|
||||||
@@ -382,7 +382,7 @@ services/deepseek.py
|
|||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r server/requirements.txt
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env,填入 DEEPSEEK_API_KEY
|
# 编辑 .env,填入 DEEPSEEK_API_KEY
|
||||||
```
|
```
|
||||||
@@ -391,7 +391,7 @@ cp .env.example .env
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
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] `web/js/ai-copy.js` 对接 API
|
||||||
- [x] FastAPI 挂载 `web/`,同域静态托管可本地跑通
|
- [x] FastAPI 挂载 `web/`,同域静态托管可本地跑通
|
||||||
- [x] 根目录 `README.md` / `start.command`
|
- [x] 根目录 `README.md` / `start.command`
|
||||||
- [x] `config/models.yaml` 模型目录 + `GET /api/ai/models` + 前端下拉切换
|
- [x] `server/config/models.yaml` 模型目录 + `GET /api/ai/models` + 前端下拉切换
|
||||||
|
|
||||||
### Phase 2(图片)
|
### Phase 2(图片)
|
||||||
|
|
||||||
@@ -451,7 +451,7 @@ web/ozonSeller.html.bak
|
|||||||
## 10. 结论
|
## 10. 结论
|
||||||
|
|
||||||
- **目录**:一体扁平;Python 在仓库根,静态页在 `web/`。
|
- **目录**:一体扁平;Python 在仓库根,静态页在 `web/`。
|
||||||
- **模型配置**:清单在 `config/models.yaml`,密钥在 `.env`;前端只消费 `/api/ai/models`。
|
- **模型配置**:清单在 `server/config/models.yaml`,密钥在 `.env`;前端只消费 `/api/ai/models`。
|
||||||
- **服务**:FastAPI(`main.py`)挂载 `web/` 并提供 `/api/*`。
|
- **服务**:FastAPI(`main.py`)挂载 `web/` 并提供 `/api/*`。
|
||||||
- **前端**:俄文文案区支持模型下拉;逻辑在 `web/js/ai-copy.js`。
|
- **前端**:俄文文案区支持模型下拉;逻辑在 `web/js/ai-copy.js`。
|
||||||
- **扩展**:`api/image.py`、`api/ozon.py` 预留;加模型只需改 yaml + 对应密钥环境变量。
|
- **扩展**:`api/image.py`、`api/ozon.py` 预留;加模型只需改 yaml + 对应密钥环境变量。
|
||||||
@@ -25,17 +25,20 @@ cd /path/to/ozon-seller-kit
|
|||||||
|
|
||||||
```
|
```
|
||||||
ozon-seller-kit/
|
ozon-seller-kit/
|
||||||
├── main.py # FastAPI 入口
|
|
||||||
├── start.command # macOS 一键启动
|
├── start.command # macOS 一键启动
|
||||||
├── requirements.txt
|
|
||||||
├── .env.example # 环境变量模板
|
├── .env.example # 环境变量模板
|
||||||
├── .env # 本地密钥(勿提交)
|
├── .env # 本地密钥(勿提交,位于仓库根)
|
||||||
├── config/
|
├── server/ # ④ 后端
|
||||||
|
│ ├── main.py # FastAPI 入口
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── config/
|
||||||
│ ├── settings.py
|
│ ├── settings.py
|
||||||
│ └── models.yaml # 可选模型清单
|
│ └── models.yaml # 可选模型清单
|
||||||
└── web/ # 前端静态页
|
└── web/ # ① 工具台 v1 静态页
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 后端在 `server/` 下,但 `.env` 在**仓库根**,由各部分共用。启动时工作目录保持仓库根,靠 `--app-dir server` 定位应用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 配置环境变量
|
## 3. 配置环境变量
|
||||||
@@ -69,12 +72,12 @@ CORS_ORIGINS=
|
|||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `HOST` / `PORT` 由 `config/settings.py` 读取;当前 `start.command` 写死为 `127.0.0.1:8000`。若要改端口,需同步改启动命令或脚本。
|
- `HOST` / `PORT` 由 `server/config/settings.py` 读取;当前 `start.command` 写死为 `127.0.0.1:8000`。若要改端口,需同步改启动命令或脚本。
|
||||||
- 以后若在 `config/models.yaml` 中接入其他厂商,按其中的 `api_key_env` 在 `.env` 增加对应变量(例如 `OPENAI_API_KEY`)。
|
- 以后若在 `server/config/models.yaml` 中接入其他厂商,按其中的 `api_key_env` 在 `.env` 增加对应变量(例如 `OPENAI_API_KEY`)。
|
||||||
|
|
||||||
### 3.4 模型清单(可选)
|
### 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'`)。
|
开发模式下改 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 后再次启动
|
2. 若不存在 `.env` → 从 `.env.example` 复制后退出,请填 Key 后再次启动
|
||||||
3. 启动 Uvicorn:`http://127.0.0.1:8000`
|
3. 启动 Uvicorn:`http://127.0.0.1:8000`
|
||||||
|
|
||||||
@@ -104,16 +107,16 @@ chmod +x start.command # 仅首次需要
|
|||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r server/requirements.txt
|
||||||
|
|
||||||
# 确保已配置 .env
|
# 确保已配置 .env(在仓库根)。始终在仓库根执行,靠 --app-dir 定位应用
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
生产或长时间挂机可不加 `--reload`:
|
生产或长时间挂机可不加 `--reload`:
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
lsof -i :8000
|
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
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -U pip
|
pip install -U pip
|
||||||
pip install -r requirements.txt
|
pip install -r server/requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 只想看静态页、不用 AI
|
### 只想看静态页、不用 AI
|
||||||
@@ -206,7 +209,7 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
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` 除外,需重启)。
|
- 改 Python / yaml:热重载后自动生效(`.env` 除外,需重启)。
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Ozon Seller API 鉴权与基础
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#tag/Introduction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 鉴权方式
|
||||||
|
|
||||||
|
Ozon Seller API 使用 **API Key 鉴权**(非 OAuth),每个请求需在请求头携带:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Client-Id: <你的 Client ID>
|
||||||
|
Api-Key: <你的 API Key>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取凭证
|
||||||
|
|
||||||
|
1. 登录 Ozon 卖家后台
|
||||||
|
2. 进入「设置」→「Seller API」
|
||||||
|
3. 点击「生成 API Key」
|
||||||
|
4. 选择权限级别:
|
||||||
|
- **只读**(Read):仅查询
|
||||||
|
- **读写**(Read & Write):查询 + 创建/更新商品
|
||||||
|
- **管理员**(Admin):所有权限
|
||||||
|
5. 保存 `Client-Id` 和 `Api-Key`(**Api-Key 仅显示一次**)
|
||||||
|
|
||||||
|
### 安全约束
|
||||||
|
|
||||||
|
- ⚠️ **Api-Key 等同密码**:泄露后任何人可操作你的店铺
|
||||||
|
- 🔒 **服务端存储**:加密落库(AES-GCM),前端永不传输/回显明文
|
||||||
|
- 🔄 **定期轮换**:建议每 90 天更换一次
|
||||||
|
- 🚫 **前端禁用**:插件/studio 不得持有店铺凭证,只能持有用户 token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api-seller.ozon.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
所有接口路径都基于此 URL,例如:
|
||||||
|
```
|
||||||
|
POST https://api-seller.ozon.ru/v3/product/import
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 请求示例
|
||||||
|
|
||||||
|
### cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://api-seller.ozon.ru/v1/description-category/tree" \
|
||||||
|
-H "Client-Id: 123456" \
|
||||||
|
-H "Api-Key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"language":"RU"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python (httpx)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Client-Id": "123456",
|
||||||
|
"Api-Key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api-seller.ozon.ru/v1/description-category/tree",
|
||||||
|
headers=headers,
|
||||||
|
json={"language": "RU"}
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 通用响应结构
|
||||||
|
|
||||||
|
### 成功响应(200/201)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": { /* 业务数据 */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
部分接口直接返回数组或对象,不包裹 `result`。
|
||||||
|
|
||||||
|
### 错误响应(4xx/5xx)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "INVALID_ARGUMENT",
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"typeUrl": "type.googleapis.com/ozon.ValidationError",
|
||||||
|
"value": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或简化版:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_PARAMETER",
|
||||||
|
"message": "offer_id is required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 错误码
|
||||||
|
|
||||||
|
| HTTP 状态码 | 含义 | 常见原因 | 处理建议 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **400** | 参数错误 | 必填字段缺失 / 格式错误 / 枚举值非法 | 检查请求体字段,读 `details` 定位 |
|
||||||
|
| **401** | 未鉴权 | 请求头缺 `Client-Id` 或 `Api-Key` | 检查请求头 |
|
||||||
|
| **403** | 权限不足 | Api-Key 权限级别不够(如只读 key 调创建接口) | 重新生成读写权限 key |
|
||||||
|
| **404** | 资源不存在 | `product_id` / `category_id` 不存在 | 检查 ID 是否正确 |
|
||||||
|
| **409** | 资源冲突 | `offer_id` 重复 / 商品已存在 | 改用唯一 offer_id 或走更新接口 |
|
||||||
|
| **429** | 限流 | 请求频率超限 | 指数退避重试(1s → 2s → 4s) |
|
||||||
|
| **500** | 服务端错误 | Ozon 内部错误 | 重试 1-2 次,仍失败则联系支持 |
|
||||||
|
| **503** | 服务不可用 | 维护中 | 稍后重试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 限流规则
|
||||||
|
|
||||||
|
官方未公开明确的限流阈值,根据社区经验:
|
||||||
|
|
||||||
|
- **常规接口**:~10 req/s
|
||||||
|
- **批量接口**(如 `/v3/product/list`):~5 req/s
|
||||||
|
- **同一 task_id 轮询**:建议间隔 ≥5s
|
||||||
|
|
||||||
|
触发 429 后:
|
||||||
|
1. 解析响应头 `Retry-After`(秒数)
|
||||||
|
2. 若无此头,使用指数退避:1s → 2s → 4s → 8s
|
||||||
|
3. 最多重试 3 次
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 超时建议
|
||||||
|
|
||||||
|
| 接口类型 | 超时时间 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| 查询类(类目/属性/商品列表) | 30s | 轻量请求 |
|
||||||
|
| 导入类(`/v3/product/import`) | 60-90s | 后端需校验 + 入库 |
|
||||||
|
| 轮询状态(`/v1/product/import/info`) | 30s | 单次轮询快,但需多次 |
|
||||||
|
| 图片上传 | 90s | 网络传输耗时 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 测试凭证有效性
|
||||||
|
|
||||||
|
### `/v1/roles` —— 获取当前 Key 的角色与权限
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://api-seller.ozon.ru/v1/roles
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**:空 `{}`
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"role_name": "Seller",
|
||||||
|
"permissions": [
|
||||||
|
"read:products",
|
||||||
|
"write:products",
|
||||||
|
"read:categories",
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- ✅ 验证凭证有效性(200 = 有效,401/403 = 无效)
|
||||||
|
- ✅ 查看权限范围(判断是否有 `write:products`)
|
||||||
|
- ✅ 零业务副作用(不消耗额度,不修改数据)
|
||||||
|
|
||||||
|
**V2 集成点**:`POST /api/shops/:id/test` 调此接口作连通性校验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 请求 ID 追踪
|
||||||
|
|
||||||
|
部分接口响应包含 `request_id`(如图生图、导入任务),用于:
|
||||||
|
- 问题排查:联系 Ozon 支持时提供此 ID
|
||||||
|
- 幂等重试:某些接口可根据 `request_id` 避免重复创建
|
||||||
|
|
||||||
|
建议:每次请求在日志里记录 `request_id`(若有)与请求体摘要,便于回溯。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 环境
|
||||||
|
|
||||||
|
Ozon Seller API **仅生产环境**,无测试沙箱。调试时需注意:
|
||||||
|
- ⚠️ 所有操作都在真实店铺
|
||||||
|
- 💡 建议用「测试商品」标识(如 offer_id 前缀 `TEST-`)
|
||||||
|
- 🗑️ 测试后及时删除/归档测试商品
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. SDK 与工具
|
||||||
|
|
||||||
|
官方未提供 Python SDK,社区方案:
|
||||||
|
- 自封装 `httpx` 客户端(V2 采用,见 `server/services/ozon_client.py`)
|
||||||
|
- 第三方库:`ozon-api`(PyPI,非官方,更新滞后)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 相关链接
|
||||||
|
|
||||||
|
- [官方文档(中文)](https://docs.ozon.ru/api/seller/zh/)
|
||||||
|
- [官方文档(俄文)](https://docs.ozon.ru/api/seller/)
|
||||||
|
- [卖家后台](https://seller.ozon.ru/)
|
||||||
|
- [API 状态页](https://status.ozon.ru/)(维护公告)
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# 类目树查询 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/description-category/tree` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 获取 Ozon 商品类目树(选择类目后才能发布商品) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 请求
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"language": "RU"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| language | string | 可选 | 语言代码,可选值:`DEFAULT`(英文)、`RU`(俄文)、`EN`(英文)、`ZH_HANS`(简体中文)。默认 `DEFAULT` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"category_name": "Термокружки",
|
||||||
|
"type_id": 97114,
|
||||||
|
"type_name": "Термокружка",
|
||||||
|
"disabled": false,
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description_category_id": 17028922,
|
||||||
|
"category_name": "Посуда",
|
||||||
|
"type_id": 0,
|
||||||
|
"type_name": "",
|
||||||
|
"disabled": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"category_name": "Термокружки",
|
||||||
|
"type_id": 97114,
|
||||||
|
"type_name": "Термокружка",
|
||||||
|
"disabled": false,
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| description_category_id | integer | **类目 ID**(发布商品时必填) |
|
||||||
|
| category_name | string | 类目名称 |
|
||||||
|
| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) |
|
||||||
|
| type_name | string | 商品类型名称 |
|
||||||
|
| disabled | boolean | **是否禁用**。`true` = 不可建品(父类目),`false` = 可建品(末级类目) |
|
||||||
|
| children | array | 子类目(递归结构) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
1. **只有末级类目可建品**:`disabled=false` 的类目才能用于发布商品
|
||||||
|
2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id`
|
||||||
|
3. **层级结构**:类目可能有多层嵌套(最多 5-6 层),需递归遍历找到末级
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 场景 1:前端类目选择器
|
||||||
|
|
||||||
|
```
|
||||||
|
① 请求类目树(language=RU,给俄文用户看)
|
||||||
|
② 递归展开树形结构
|
||||||
|
③ 用户选择类目后,校验 disabled=false(若 true 则禁止选择或自动展开子级)
|
||||||
|
④ 选中后保存 description_category_id + type_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2:服务端缓存
|
||||||
|
|
||||||
|
```
|
||||||
|
① 启动时拉取类目树(language=DEFAULT,英文字段名便于代码处理)
|
||||||
|
② 存入 category_tree 表(见 docs/v2/database.md §2.7)
|
||||||
|
③ TTL 24h,过期重拉
|
||||||
|
④ 用户选类目时直接查库,不频繁调 API
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例代码
|
||||||
|
|
||||||
|
### Python(服务端缓存)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
async def fetch_category_tree(
|
||||||
|
client_id: str,
|
||||||
|
api_key: str,
|
||||||
|
language: str = "DEFAULT"
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""拉取类目树并返回扁平化列表"""
|
||||||
|
headers = {
|
||||||
|
"Client-Id": client_id,
|
||||||
|
"Api-Key": api_key,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api-seller.ozon.ru/v1/description-category/tree",
|
||||||
|
headers=headers,
|
||||||
|
json={"language": language}
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 递归扁平化
|
||||||
|
def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0):
|
||||||
|
flat = []
|
||||||
|
for node in nodes:
|
||||||
|
flat.append({
|
||||||
|
"description_category_id": node["description_category_id"],
|
||||||
|
"parent_id": parent_id,
|
||||||
|
"category_name": node["category_name"],
|
||||||
|
"type_id": node["type_id"],
|
||||||
|
"type_name": node["type_name"],
|
||||||
|
"disabled": node["disabled"],
|
||||||
|
"level": level,
|
||||||
|
"lang": language
|
||||||
|
})
|
||||||
|
if node.get("children"):
|
||||||
|
flat.extend(flatten(
|
||||||
|
node["children"],
|
||||||
|
level + 1,
|
||||||
|
node["description_category_id"]
|
||||||
|
))
|
||||||
|
return flat
|
||||||
|
|
||||||
|
return flatten(data.get("result", []))
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript(前端选择器)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CategoryNode {
|
||||||
|
description_category_id: number;
|
||||||
|
category_name: string;
|
||||||
|
type_id: number;
|
||||||
|
type_name: string;
|
||||||
|
disabled: boolean;
|
||||||
|
children: CategoryNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCategoryTree(language = 'RU'): Promise<CategoryNode[]> {
|
||||||
|
const resp = await fetch('/api/categories/tree?lang=' + language);
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转为 antd Tree 数据结构
|
||||||
|
function toTreeData(nodes: CategoryNode[]): any[] {
|
||||||
|
return nodes.map(node => ({
|
||||||
|
key: `${node.description_category_id}-${node.type_id}`,
|
||||||
|
title: node.category_name,
|
||||||
|
disabled: node.disabled, // 父类目禁止选择
|
||||||
|
children: node.children.length > 0 ? toTreeData(node.children) : undefined,
|
||||||
|
// 保存原始数据,选中时取用
|
||||||
|
data: {
|
||||||
|
description_category_id: node.description_category_id,
|
||||||
|
type_id: node.type_id
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 缓存策略
|
||||||
|
|
||||||
|
### 全局缓存(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 类目树与店铺无关,所有店铺共用一份
|
||||||
|
# 启动时拉取,存内存 + 数据库
|
||||||
|
# TTL 24h(类目变化不频繁)
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
_category_tree_cache = None
|
||||||
|
_cache_time = None
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
async def get_category_tree_cached(language: str = "DEFAULT"):
|
||||||
|
global _category_tree_cache, _cache_time
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if _category_tree_cache and _cache_time and (now - _cache_time) < timedelta(hours=24):
|
||||||
|
return _category_tree_cache
|
||||||
|
|
||||||
|
# 从任意店铺拉(类目树全局一致)
|
||||||
|
tree = await fetch_category_tree(any_client_id, any_api_key, language)
|
||||||
|
_category_tree_cache = tree
|
||||||
|
_cache_time = now
|
||||||
|
|
||||||
|
# 同时写数据库
|
||||||
|
await save_to_db(tree)
|
||||||
|
|
||||||
|
return tree
|
||||||
|
```
|
||||||
|
|
||||||
|
### 按需更新
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 用户反馈「找不到某类目」时手动刷新
|
||||||
|
async def refresh_category_tree():
|
||||||
|
global _category_tree_cache, _cache_time
|
||||||
|
_category_tree_cache = None
|
||||||
|
_cache_time = None
|
||||||
|
get_category_tree_cached.cache_clear()
|
||||||
|
return await get_category_tree_cached()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 类目树很大吗?
|
||||||
|
|
||||||
|
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。首次拉取需几秒,后续从缓存读取。
|
||||||
|
|
||||||
|
### Q2: 多久更新一次?
|
||||||
|
|
||||||
|
**A**: Ozon 不定期新增类目(月级别),建议 **24h TTL + 手动刷新入口**。
|
||||||
|
|
||||||
|
### Q3: 不同语言的类目树结构一样吗?
|
||||||
|
|
||||||
|
**A**: 结构一致(`description_category_id` / `type_id` 相同),仅 `category_name` / `type_name` 翻译不同。建议:
|
||||||
|
- 服务端缓存 `DEFAULT`(英文,便于代码处理)
|
||||||
|
- 前端按用户语言拉取 `RU`(俄文,展示用)
|
||||||
|
|
||||||
|
### Q4: `type_id=0` 是什么意思?
|
||||||
|
|
||||||
|
**A**: 父类目(`disabled=true`)的 `type_id` 为 0,表示该节点不是商品类型,只是分类层级。只有末级类目的 `type_id > 0`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V2 项目集成
|
||||||
|
|
||||||
|
### API 层(已实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/categories.py
|
||||||
|
|
||||||
|
@router.get("/categories/tree")
|
||||||
|
async def get_tree(
|
||||||
|
lang: str = Query("RU"),
|
||||||
|
shop_id: str = Query(...), # 需要店铺凭证
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
tree = await ozon_client.get_category_tree(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
lang
|
||||||
|
)
|
||||||
|
return {"result": tree}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/CategoryPicker.tsx
|
||||||
|
|
||||||
|
import { Tree } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function CategoryPicker({ shopId, onChange }) {
|
||||||
|
const [treeData, setTreeData] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/categories/tree?shop_id=${shopId}&lang=RU`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => setTreeData(toTreeData(data.result)));
|
||||||
|
}, [shopId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tree
|
||||||
|
treeData={treeData}
|
||||||
|
onSelect={(keys, { node }) => {
|
||||||
|
if (!node.disabled) {
|
||||||
|
onChange(node.data); // { description_category_id, type_id }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [03-category-attributes.md](./03-category-attributes.md) —— 获取类目属性
|
||||||
|
- [docs/v2/database.md](../v2/database.md) §2.7 —— `category_tree` 表结构
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 类目字典缓存策略
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
# 类目属性与字典值 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 获取类目属性
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/description-category/attribute` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 获取指定类目的所有属性(发布商品时需填写) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"language": "RU"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| description_category_id | integer | ✅ 是 | 类目 ID(从类目树获取) |
|
||||||
|
| type_id | integer | ✅ 是 | 商品类型 ID(从类目树获取,与 category_id 配对) |
|
||||||
|
| language | string | 可选 | 语言代码:`DEFAULT` / `RU` / `EN` / `ZH_HANS`。默认 `DEFAULT` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": 85,
|
||||||
|
"name": "Бренд",
|
||||||
|
"description": "Укажите бренд товара",
|
||||||
|
"type": "String",
|
||||||
|
"is_collection": false,
|
||||||
|
"is_required": true,
|
||||||
|
"is_aspect": false,
|
||||||
|
"max_value_count": 1,
|
||||||
|
"dictionary_id": 28732,
|
||||||
|
"category_dependent": false,
|
||||||
|
"group_id": 0,
|
||||||
|
"group_name": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8229,
|
||||||
|
"name": "Цвет товара",
|
||||||
|
"description": "",
|
||||||
|
"type": "String",
|
||||||
|
"is_collection": false,
|
||||||
|
"is_required": false,
|
||||||
|
"is_aspect": true,
|
||||||
|
"max_value_count": 1,
|
||||||
|
"dictionary_id": 61405,
|
||||||
|
"category_dependent": false,
|
||||||
|
"group_id": 1,
|
||||||
|
"group_name": "Варианты"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9048,
|
||||||
|
"name": "Объем",
|
||||||
|
"description": "Укажите объем в миллилитрах",
|
||||||
|
"type": "Integer",
|
||||||
|
"is_collection": false,
|
||||||
|
"is_required": true,
|
||||||
|
"is_aspect": false,
|
||||||
|
"max_value_count": 1,
|
||||||
|
"dictionary_id": 0,
|
||||||
|
"category_dependent": false,
|
||||||
|
"group_id": 2,
|
||||||
|
"group_name": "Основные"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **id** | integer | **属性 ID**(发布时填 `attributes[].id`) |
|
||||||
|
| **name** | string | 属性名称(如"品牌"、"颜色") |
|
||||||
|
| description | string | 属性说明(填写提示) |
|
||||||
|
| **type** | string | 值类型:`String` / `Integer` / `Decimal` / `Boolean` / `URL` |
|
||||||
|
| **is_required** | boolean | **是否必填**。`true` = 必须填写,否则发布失败 |
|
||||||
|
| **is_aspect** | boolean | **是否变体属性**(如颜色/尺码)。`true` = 该属性用于区分 SKU 变体 |
|
||||||
|
| **is_collection** | boolean | 是否多值。`true` = 可填多个值(如"适用场景:家用,办公") |
|
||||||
|
| max_value_count | integer | 最多值数量(`is_collection=true` 时有效) |
|
||||||
|
| **dictionary_id** | integer | **字典 ID**。`> 0` = 有预设值字典(需调字典值接口),`0` = 自由输入 |
|
||||||
|
| category_dependent | boolean | 字典值是否依赖类目(`true` = 不同类目的字典值不同) |
|
||||||
|
| group_id | integer | 属性分组 ID |
|
||||||
|
| group_name | string | 属性分组名(如"基本信息"、"变体") |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 关键字段组合
|
||||||
|
|
||||||
|
| 组合 | 含义 | 示例 | 填写方式 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `is_required=true` | **必填** | 品牌、尺寸、重量 | 必须有值,否则发布失败 |
|
||||||
|
| `dictionary_id > 0` | **有字典** | 品牌、颜色、材质 | 值必须从字典选(dictionary_value_id) |
|
||||||
|
| `dictionary_id = 0` | **自由输入** | 商品名、描述、数值 | 直接填文本/数字 |
|
||||||
|
| `is_aspect=true` | **变体属性** | 颜色、尺码 | 用于区分 SKU(不同颜色 = 不同 SKU) |
|
||||||
|
| `is_collection=true` | **多值** | 适用场景、材质组成 | 可传数组 `["值1", "值2"]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 获取属性值字典
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/description-category/attribute/values` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 获取属性的预设值字典(`dictionary_id > 0` 的属性) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attribute_id": 85,
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"language": "RU",
|
||||||
|
"limit": 1000,
|
||||||
|
"last_value_id": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| attribute_id | integer | ✅ 是 | 属性 ID |
|
||||||
|
| description_category_id | integer | ✅ 是 | 类目 ID |
|
||||||
|
| type_id | integer | ✅ 是 | 商品类型 ID |
|
||||||
|
| language | string | 可选 | 语言代码 |
|
||||||
|
| limit | integer | 可选 | 每页数量,最大 **5000**,默认 1000 |
|
||||||
|
| last_value_id | integer | 可选 | 分页游标(上一页最后一个值的 `id`),首页传 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": 971082156,
|
||||||
|
"value": "Thermos",
|
||||||
|
"info": "",
|
||||||
|
"picture": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 971317107,
|
||||||
|
"value": "Stanley",
|
||||||
|
"info": "",
|
||||||
|
"picture": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"has_next": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **id** | integer | **字典值 ID**(发布时填 `attributes[].values[].dictionary_value_id`) |
|
||||||
|
| **value** | string | 字典值文本(如品牌名"Thermos") |
|
||||||
|
| info | string | 补充说明 |
|
||||||
|
| picture | string | 值配图 URL(部分属性有,如颜色) |
|
||||||
|
| **has_next** | boolean | 是否有下一页(`true` = 用最后一个 `id` 继续分页) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 分页示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def fetch_all_values(attribute_id, category_id, type_id):
|
||||||
|
all_values = []
|
||||||
|
last_id = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api-seller.ozon.ru/v1/description-category/attribute/values",
|
||||||
|
json={
|
||||||
|
"attribute_id": attribute_id,
|
||||||
|
"description_category_id": category_id,
|
||||||
|
"type_id": type_id,
|
||||||
|
"language": "RU",
|
||||||
|
"limit": 5000,
|
||||||
|
"last_value_id": last_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
values = data.get("result", [])
|
||||||
|
all_values.extend(values)
|
||||||
|
|
||||||
|
if not data.get("has_next") or not values:
|
||||||
|
break
|
||||||
|
|
||||||
|
last_id = values[-1]["id"]
|
||||||
|
|
||||||
|
return all_values
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 按关键词搜索属性值
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/description-category/attribute/values/search` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 模糊搜索字典值(避免拉全量字典) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attribute_id": 85,
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"language": "RU",
|
||||||
|
"value": "Ther",
|
||||||
|
"limit": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| attribute_id | integer | ✅ 是 | 属性 ID |
|
||||||
|
| description_category_id | integer | ✅ 是 | 类目 ID |
|
||||||
|
| type_id | integer | ✅ 是 | 商品类型 ID |
|
||||||
|
| language | string | 可选 | 语言代码 |
|
||||||
|
| **value** | string | ✅ 是 | 搜索关键词(≥2 个字符) |
|
||||||
|
| limit | integer | 可选 | 返回数量,最大 **100**,默认 50 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": 971082156,
|
||||||
|
"value": "Thermos",
|
||||||
|
"info": "",
|
||||||
|
"picture": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 971982345,
|
||||||
|
"value": "Thermocafe",
|
||||||
|
"info": "",
|
||||||
|
"picture": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 属性映射工作流
|
||||||
|
|
||||||
|
### 场景:采集来的参数 → Ozon 属性
|
||||||
|
|
||||||
|
```
|
||||||
|
采集原文(raw.params):
|
||||||
|
[
|
||||||
|
{ "key": "Материал", "value": "Нержавеющая сталь" },
|
||||||
|
{ "key": "Объем", "value": "500 мл" },
|
||||||
|
{ "key": "Бренд", "value": "Thermos" }
|
||||||
|
]
|
||||||
|
|
||||||
|
↓ ① 自动匹配属性名
|
||||||
|
|
||||||
|
attributes = [
|
||||||
|
{ id: 8505, name: "Материал" }, // 材质
|
||||||
|
{ id: 9048, name: "Объем" }, // 容量
|
||||||
|
{ id: 85, name: "Бренд" } // 品牌
|
||||||
|
]
|
||||||
|
|
||||||
|
↓ ② 对有字典的属性(dictionary_id > 0),搜索字典值
|
||||||
|
|
||||||
|
POST /attribute/values/search {
|
||||||
|
attribute_id: 85, // 品牌
|
||||||
|
value: "Thermos"
|
||||||
|
}
|
||||||
|
→ { id: 971082156, value: "Thermos" }
|
||||||
|
|
||||||
|
↓ ③ 组装最终 attributes
|
||||||
|
|
||||||
|
products.attributes = [
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 8505,
|
||||||
|
"values": [{ "value": "Нержавеющая сталь" }] // 材质无字典,直接填
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 9048,
|
||||||
|
"values": [{ "value": "500" }] // 容量是数值,提取数字
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 85,
|
||||||
|
"values": [{ "dictionary_value_id": 971082156, "value": "Thermos" }] // 品牌有字典
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 自动匹配策略
|
||||||
|
|
||||||
|
### 策略 A:归一化 + 模糊匹配
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
"""归一化:小写 + 去标点 + 词干"""
|
||||||
|
text = text.lower().strip()
|
||||||
|
text = re.sub(r'[^\w\s]', '', text)
|
||||||
|
# 俄文词干化(需 pymorphy2 库)
|
||||||
|
# text = morph.parse(text)[0].normal_form
|
||||||
|
return text
|
||||||
|
|
||||||
|
def fuzzy_match(采集key: str, 属性列表: list, threshold=0.8):
|
||||||
|
"""模糊匹配:相似度 > 0.8 即认为匹配"""
|
||||||
|
norm_key = normalize(采集key)
|
||||||
|
best = None
|
||||||
|
best_score = 0
|
||||||
|
|
||||||
|
for attr in 属性列表:
|
||||||
|
norm_name = normalize(attr["name"])
|
||||||
|
score = SequenceMatcher(None, norm_key, norm_name).ratio()
|
||||||
|
if score > best_score:
|
||||||
|
best = attr
|
||||||
|
best_score = score
|
||||||
|
|
||||||
|
return best if best_score >= threshold else None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 策略 B:关键词映射表
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 预定义常见映射(中文采集 key → Ozon 属性 name)
|
||||||
|
KEYWORD_MAP = {
|
||||||
|
"品牌": ["Бренд", "Brand"],
|
||||||
|
"材质": ["Материал", "Material"],
|
||||||
|
"重量": ["Вес", "Weight"],
|
||||||
|
"尺寸": ["Размер", "Size"],
|
||||||
|
"颜色": ["Цвет", "Color"],
|
||||||
|
# ... 补充更多
|
||||||
|
}
|
||||||
|
|
||||||
|
def keyword_match(采集key: str, 属性列表: list):
|
||||||
|
for cn_key, ru_keys in KEYWORD_MAP.items():
|
||||||
|
if cn_key in 采集key:
|
||||||
|
for attr in 属性列表:
|
||||||
|
if any(rk in attr["name"] for rk in ru_keys):
|
||||||
|
return attr
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 必填项校验
|
||||||
|
|
||||||
|
### 发布前校验
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def validate_required_attributes(
|
||||||
|
category_id: int,
|
||||||
|
type_id: int,
|
||||||
|
attributes: list
|
||||||
|
) -> list[str]:
|
||||||
|
"""返回缺失的必填属性名列表"""
|
||||||
|
# 获取该类目的所有属性
|
||||||
|
attrs = await fetch_category_attributes(category_id, type_id)
|
||||||
|
|
||||||
|
# 提取必填属性
|
||||||
|
required = [a for a in attrs if a["is_required"]]
|
||||||
|
|
||||||
|
# 已填写的属性 ID
|
||||||
|
filled_ids = {a["id"] for a in attributes}
|
||||||
|
|
||||||
|
# 找出缺失的
|
||||||
|
missing = [a["name"] for a in required if a["id"] not in filled_ids]
|
||||||
|
|
||||||
|
return missing
|
||||||
|
|
||||||
|
# 使用
|
||||||
|
missing = await validate_required_attributes(17033876, 97114, product.attributes)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"缺少必填属性:{', '.join(missing)}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 缓存策略
|
||||||
|
|
||||||
|
### 属性列表缓存
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 按 (category_id, type_id) 缓存
|
||||||
|
# TTL 7 天(属性变化极少)
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
@lru_cache(maxsize=500)
|
||||||
|
async def get_attributes_cached(category_id: int, type_id: int):
|
||||||
|
# 先查数据库
|
||||||
|
cached = await db.query(CategoryAttribute).filter_by(
|
||||||
|
description_category_id=category_id,
|
||||||
|
type_id=type_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# 未缓存,调 API
|
||||||
|
attrs = await fetch_category_attributes(category_id, type_id)
|
||||||
|
|
||||||
|
# 写入数据库
|
||||||
|
await save_attributes_to_db(attrs)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字典值缓存(按需)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 字典值可能很大(数万条),不全量缓存
|
||||||
|
# 策略:用户映射到某属性时,才拉该属性的字典(且优先用 /search)
|
||||||
|
|
||||||
|
async def get_attribute_values(attr_id, category_id, type_id, keyword=None):
|
||||||
|
if keyword:
|
||||||
|
# 有关键词 → 搜索接口(limit 100)
|
||||||
|
return await search_values(attr_id, category_id, type_id, keyword)
|
||||||
|
else:
|
||||||
|
# 无关键词 → 拉全量(分页,存数据库)
|
||||||
|
return await fetch_all_values(attr_id, category_id, type_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 前端交互设计
|
||||||
|
|
||||||
|
### 属性映射 UI(推荐)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 类目属性映射 │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ 采集属性 → Ozon 属性 │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ ✅ Материал (材质) → [自动] Материал (8505) │
|
||||||
|
│ 值:Нержавеющая сталь │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ ✅ Бренд (品牌) → [自动] Бренд (85) ⚠️必填 │
|
||||||
|
│ 值:Thermos → 字典值:[选择 ▼] │
|
||||||
|
│ ├ Thermos ✅ │
|
||||||
|
│ ├ Stanley │
|
||||||
|
│ └ ... │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ ⚠️ 未匹配:包装重量 │
|
||||||
|
│ → [手动选择属性 ▼] │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ ❌ 缺少必填属性: │
|
||||||
|
│ - Объем (容量) [+添加] │
|
||||||
|
│ - Цвет (颜色) [+添加] │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 常见问题
|
||||||
|
|
||||||
|
### Q1: 属性太多怎么办?
|
||||||
|
|
||||||
|
**A**: 一个类目可能有 **50-100+ 属性**,但常用的只有 10-20 个。策略:
|
||||||
|
- 必填属性置顶 + 高亮
|
||||||
|
- 已匹配属性展开,未匹配折叠
|
||||||
|
- 提供搜索/筛选
|
||||||
|
|
||||||
|
### Q2: 字典值有多大?
|
||||||
|
|
||||||
|
**A**:
|
||||||
|
- 小字典(颜色/材质):几十到几百条
|
||||||
|
- 大字典(品牌):**数万条**(如品牌字典 > 50,000)
|
||||||
|
- 策略:**优先用 `/values/search`**,避免拉全量
|
||||||
|
|
||||||
|
### Q3: 自由输入的属性怎么填?
|
||||||
|
|
||||||
|
**A**: `dictionary_id=0` 的属性直接填 `value`,无需 `dictionary_value_id`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 9048,
|
||||||
|
"values": [{ "value": "500" }] // 容量,数值类型
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q4: 如何处理多值属性?
|
||||||
|
|
||||||
|
**A**: `is_collection=true` 时传数组:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 10096,
|
||||||
|
"values": [
|
||||||
|
{ "value": "家用" },
|
||||||
|
{ "value": "办公" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(已实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/categories.py
|
||||||
|
|
||||||
|
@router.get("/categories/{category_id}/attributes")
|
||||||
|
async def get_attributes(
|
||||||
|
category_id: int,
|
||||||
|
type_id: int = Query(...),
|
||||||
|
shop_id: str = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
attrs = await ozon_client.get_category_attributes(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
category_id,
|
||||||
|
type_id
|
||||||
|
)
|
||||||
|
return {"result": attrs}
|
||||||
|
|
||||||
|
@router.get("/categories/attribute/{attribute_id}/values")
|
||||||
|
async def get_attribute_values(
|
||||||
|
attribute_id: int,
|
||||||
|
category_id: int = Query(...),
|
||||||
|
type_id: int = Query(...),
|
||||||
|
q: str = Query(None), # 搜索关键词
|
||||||
|
shop_id: str = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
|
||||||
|
if q and len(q) >= 2:
|
||||||
|
# 搜索接口
|
||||||
|
values = await ozon_client.search_attribute_values(
|
||||||
|
shop.client_id_dec, shop.api_key_dec,
|
||||||
|
attribute_id, category_id, type_id, q
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 全量拉取(分页)
|
||||||
|
values = await ozon_client.get_attribute_values(
|
||||||
|
shop.client_id_dec, shop.api_key_dec,
|
||||||
|
attribute_id, category_id, type_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": values}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/AttributeMapper.tsx
|
||||||
|
|
||||||
|
import { Form, Select, Input, Tag } from 'antd';
|
||||||
|
|
||||||
|
export function AttributeMapper({ categoryId, typeId, rawParams, onChange }) {
|
||||||
|
const [attributes, setAttributes] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/categories/${categoryId}/attributes?type_id=${typeId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => setAttributes(data.result));
|
||||||
|
}, [categoryId, typeId]);
|
||||||
|
|
||||||
|
// 自动匹配
|
||||||
|
const autoMatch = () => {
|
||||||
|
const matched = rawParams.map(p => {
|
||||||
|
const attr = attributes.find(a =>
|
||||||
|
normalize(a.name) === normalize(p.key)
|
||||||
|
);
|
||||||
|
return attr ? { ...p, attrId: attr.id, attr } : p;
|
||||||
|
});
|
||||||
|
return matched;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{autoMatch().map((item, i) => (
|
||||||
|
<Form.Item
|
||||||
|
key={i}
|
||||||
|
label={item.key}
|
||||||
|
required={item.attr?.is_required}
|
||||||
|
>
|
||||||
|
{item.attr?.dictionary_id > 0 ? (
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="选择字典值"
|
||||||
|
onSearch={(q) => fetchValues(item.attr.id, q)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input defaultValue={item.value} />
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [02-category-tree.md](./02-category-tree.md) —— 获取类目树
|
||||||
|
- [04-product-import.md](./04-product-import.md) —— 发布商品(使用属性)
|
||||||
|
- [docs/v2/database.md](../v2/database.md) §2.7 —— 属性缓存表结构
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 属性映射策略
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
# 商品导入(创建/更新)API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v3/product/import` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | **创建或更新商品**(Ozon 核心接口) |
|
||||||
|
| 异步 | ✅ 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 请求体结构
|
||||||
|
|
||||||
|
### 完整示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"name": "Термокружка Thermos из нержавеющей стали 500 мл",
|
||||||
|
"description": "Термокружка из высококачественной нержавеющей стали. Сохраняет температуру до 6 часов.",
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"price": "2990",
|
||||||
|
"old_price": "3490",
|
||||||
|
"currency_code": "RUB",
|
||||||
|
"vat": "0",
|
||||||
|
"depth": 80,
|
||||||
|
"width": 80,
|
||||||
|
"height": 200,
|
||||||
|
"dimension_unit": "mm",
|
||||||
|
"weight": 320,
|
||||||
|
"weight_unit": "g",
|
||||||
|
"images": [
|
||||||
|
"https://cdn.example.com/thermos-main-1.jpg",
|
||||||
|
"https://cdn.example.com/thermos-main-2.jpg"
|
||||||
|
],
|
||||||
|
"primary_image": "",
|
||||||
|
"images360": [],
|
||||||
|
"color_image": "",
|
||||||
|
"barcode": "",
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 85,
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"dictionary_value_id": 971082156,
|
||||||
|
"value": "Thermos"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 8505,
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "Нержавеющая сталь"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"complex_attributes": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 字段说明
|
||||||
|
|
||||||
|
### 基本信息
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **offer_id** | string | ✅ 是 | **自己的货号**(唯一标识,用于更新)。最长 255 字符 |
|
||||||
|
| **name** | string | ✅ 是 | 商品名称。最长 500 字符 |
|
||||||
|
| **description** | string | ✅ 是 | 商品描述。最长 5000 字符,支持 HTML 标签 |
|
||||||
|
| **description_category_id** | integer | ✅ 是 | 类目 ID(从类目树获取) |
|
||||||
|
| **type_id** | integer | ✅ 是 | 商品类型 ID(从类目树获取) |
|
||||||
|
|
||||||
|
### 价格
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **price** | string | ✅ 是 | 销售价(字符串格式,如 `"2990"` = 2990 卢布) |
|
||||||
|
| old_price | string | 可选 | 划线价(原价),用于展示折扣 |
|
||||||
|
| **currency_code** | string | ✅ 是 | 币种,通常 `"RUB"`(卢布)。也可 `"CNY"` 等 |
|
||||||
|
| **vat** | string | ✅ 是 | 增值税率:`"0"` / `"0.1"` / `"0.2"`。俄罗斯默认 `"0"` |
|
||||||
|
|
||||||
|
### 尺寸与重量
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **depth** | number | ✅ 是 | 长度(包装尺寸)。**不能为 0** |
|
||||||
|
| **width** | number | ✅ 是 | 宽度(包装尺寸)。**不能为 0** |
|
||||||
|
| **height** | number | ✅ 是 | 高度(包装尺寸)。**不能为 0** |
|
||||||
|
| **dimension_unit** | string | ✅ 是 | 尺寸单位:`"mm"` / `"cm"` |
|
||||||
|
| **weight** | number | ✅ 是 | 重量(包装重量)。**不能为 0** |
|
||||||
|
| **weight_unit** | string | ✅ 是 | 重量单位:`"g"` / `"kg"` |
|
||||||
|
|
||||||
|
⚠️ **硬约束**:尺寸和重量必须 **> 0**,否则 API 返回 400 错误。
|
||||||
|
|
||||||
|
### 图片
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **images** | array | ✅ 是 | **主图数组**(最多 15 张)。顺序即展示顺序,第一张为主图 |
|
||||||
|
| primary_image | string | 可选 | 主图(单独指定)。若使用则 `images` 最多 14 张 |
|
||||||
|
| images360 | array | 可选 | 360° 图片数组 |
|
||||||
|
| color_image | string | 可选 | 营销色图(部分类目支持) |
|
||||||
|
|
||||||
|
⚠️ **硬约束**:
|
||||||
|
- 图片 URL 必须是 **https 公网直链**(http 会被拒绝)
|
||||||
|
- 图片需可访问(Ozon 服务器会主动拉取)
|
||||||
|
- 建议尺寸:≥ 700×700 px,白底,主体占画面 80%+
|
||||||
|
|
||||||
|
### 属性
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **attributes** | array | ✅ 是 | 商品属性数组(从类目属性获取) |
|
||||||
|
| attributes[].complex_id | integer | ✅ 是 | 复杂属性 ID,通常填 `0` |
|
||||||
|
| attributes[].id | integer | ✅ 是 | 属性 ID |
|
||||||
|
| attributes[].values | array | ✅ 是 | 属性值数组 |
|
||||||
|
| values[].dictionary_value_id | integer | 条件 | 字典值 ID(属性有字典时必填) |
|
||||||
|
| values[].value | string | ✅ 是 | 属性值文本 |
|
||||||
|
|
||||||
|
### 复杂属性
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| complex_attributes | array | 可选 | 复杂属性(视频、尺码表等) |
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| barcode | string | 可选 | 条形码 |
|
||||||
|
| pdf_list | array | 可选 | PDF 文件 URL 列表 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"task_id": 123456789
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| task_id | integer | **任务 ID**(用于轮询状态,见下节) |
|
||||||
|
|
||||||
|
⚠️ **此时商品尚未创建**,需轮询 `/v1/product/import/info` 获取最终结果。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 轮询任务状态
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/product/import/info` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": 123456789
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 987654321,
|
||||||
|
"status": "imported",
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 状态值
|
||||||
|
|
||||||
|
| status | 含义 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| **imported** | ✅ 导入成功 | 保存 `product_id`,标记商品为 `published` |
|
||||||
|
| **pending** | ⏳ 排队中 | 继续轮询(间隔 5s) |
|
||||||
|
| **processing** | ⏳ 处理中 | 继续轮询(间隔 5s) |
|
||||||
|
| **moderation** | ⏳ 审核中 | 继续轮询(间隔 30s,审核可能需几小时) |
|
||||||
|
| **failed** | ❌ 失败 | 读取 `errors` 数组,展示错误给用户 |
|
||||||
|
|
||||||
|
### 错误结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 0,
|
||||||
|
"status": "failed",
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "INVALID_ATTRIBUTE",
|
||||||
|
"message": "Attribute 'Бренд' is required",
|
||||||
|
"field": "attributes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 轮询策略
|
||||||
|
|
||||||
|
### 推荐策略
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def wait_for_import(task_id: int, timeout=300):
|
||||||
|
"""轮询导入状态,最多等待 5 分钟"""
|
||||||
|
start = time.time()
|
||||||
|
interval = 5 # 初始间隔 5s
|
||||||
|
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
resp = await ozon_client.get_import_info(task_id)
|
||||||
|
item = resp["result"]["items"][0]
|
||||||
|
status = item["status"]
|
||||||
|
|
||||||
|
if status == "imported":
|
||||||
|
return {"success": True, "product_id": item["product_id"]}
|
||||||
|
|
||||||
|
if status == "failed":
|
||||||
|
return {"success": False, "errors": item["errors"]}
|
||||||
|
|
||||||
|
if status == "moderation":
|
||||||
|
interval = 30 # 审核阶段降低频率
|
||||||
|
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
# 超时:不算失败,标记为"审核中"继续后台轮询
|
||||||
|
return {"success": None, "status": "timeout"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后台轮询(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 用户提交发布后立即返回,后台协程轮询
|
||||||
|
# 状态变化时通知前端(WebSocket / 长轮询 / 前端定时刷新)
|
||||||
|
|
||||||
|
async def background_poll_task(task_id: int, product_id: str):
|
||||||
|
"""后台协程,轮询直到完成或失败"""
|
||||||
|
result = await wait_for_import(task_id, timeout=3600) # 最多 1 小时
|
||||||
|
|
||||||
|
# 更新数据库
|
||||||
|
await db.execute(
|
||||||
|
update(Product)
|
||||||
|
.where(Product.id == product_id)
|
||||||
|
.values(
|
||||||
|
stage="published" if result["success"] else "failed",
|
||||||
|
ozon_product_id=result.get("product_id"),
|
||||||
|
published_at=datetime.utcnow() if result["success"] else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录任务结果
|
||||||
|
await db.execute(
|
||||||
|
update(PublishTask)
|
||||||
|
.where(PublishTask.ozon_task_id == task_id)
|
||||||
|
.values(
|
||||||
|
status=result.get("status"),
|
||||||
|
errors=result.get("errors"),
|
||||||
|
completed_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 创建 vs 更新
|
||||||
|
|
||||||
|
### 创建新商品
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": "NEW-PRODUCT-001", // 全新 offer_id
|
||||||
|
// ... 其他字段
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果 `offer_id` 不存在 → 创建新商品
|
||||||
|
- 如果 `offer_id` 已存在 → 返回 409 冲突
|
||||||
|
|
||||||
|
### 更新已有商品
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": "EXISTING-001", // 已存在的 offer_id
|
||||||
|
// ... 要更新的字段(可部分更新)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用 `product_id`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product_id": 987654321, // Ozon 商品 ID
|
||||||
|
// ... 要更新的字段
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **注意**:
|
||||||
|
- 更新时,未传的字段**保持原值**(非清空)
|
||||||
|
- 图片数组传空 `[]` 会清空图片(需小心)
|
||||||
|
- 建议更新前先读取当前值(`/v3/product/info/list`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 批量导入
|
||||||
|
|
||||||
|
单次请求最多 **100 个 item**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "offer_id": "PROD-001", /* ... */ },
|
||||||
|
{ "offer_id": "PROD-002", /* ... */ },
|
||||||
|
// ... 最多 100 个
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应包含每个 item 的状态:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{ "offer_id": "PROD-001", "status": "imported", "product_id": 111 },
|
||||||
|
{ "offer_id": "PROD-002", "status": "failed", "errors": [...] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 常见错误
|
||||||
|
|
||||||
|
### 错误码速查
|
||||||
|
|
||||||
|
| code | message | 原因 | 解决 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `INVALID_PARAMETER` | 参数错误 | 必填字段缺失 / 格式错误 | 检查字段完整性 |
|
||||||
|
| `INVALID_ATTRIBUTE` | 属性错误 | 缺少必填属性 / 字典值不匹配 | 补全必填属性,校验字典值 |
|
||||||
|
| `INVALID_CATEGORY` | 类目错误 | `description_category_id` 不存在或已禁用 | 重新选择类目 |
|
||||||
|
| `INVALID_IMAGE` | 图片错误 | URL 不可访问 / 非 https / 格式不支持 | 检查图片 URL 有效性 |
|
||||||
|
| `OFFER_ID_DUPLICATE` | offer_id 重复 | 该 offer_id 已存在 | 换一个唯一 offer_id 或走更新 |
|
||||||
|
| `DIMENSION_REQUIRED` | 尺寸必填 | 尺寸/重量为 0 或缺失 | 填写正确尺寸重量 |
|
||||||
|
| `PRICE_INVALID` | 价格错误 | 价格 ≤ 0 或格式错误 | 检查价格字段 |
|
||||||
|
|
||||||
|
### 典型错误示例
|
||||||
|
|
||||||
|
#### 错误 1:尺寸为 0
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "DIMENSION_REQUIRED",
|
||||||
|
"message": "Dimensions must be greater than 0",
|
||||||
|
"field": "weight"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决**:确保 `depth/width/height/weight` 都 > 0。
|
||||||
|
|
||||||
|
#### 错误 2:缺少必填属性
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "INVALID_ATTRIBUTE",
|
||||||
|
"message": "Required attribute 'Бренд' (id=85) is missing",
|
||||||
|
"field": "attributes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决**:补充缺失的必填属性。
|
||||||
|
|
||||||
|
#### 错误 3:图片 URL 不可访问
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "INVALID_IMAGE",
|
||||||
|
"message": "Image URL is not accessible: https://...",
|
||||||
|
"field": "images[0]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
1. 检查 URL 是 https(非 http)
|
||||||
|
2. 检查 URL 公网可访问(Ozon 服务器需能拉取)
|
||||||
|
3. 检查图片格式(支持 jpg/png/webp)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 发布后操作
|
||||||
|
|
||||||
|
### 设置库存(必须)
|
||||||
|
|
||||||
|
商品导入成功后**不会自动上架**,需设置库存才能开售:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v2/products/stocks
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"product_id": 987654321,
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"stock": 100,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ 不设置库存 → 商品在后台但不可购买。
|
||||||
|
|
||||||
|
### 查询商品详情
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v3/product/info/list
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": ["MY-THERMOS-001"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回商品完整信息(含审核状态、图片、属性)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(已实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/publish.py
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/publish")
|
||||||
|
async def publish_product(
|
||||||
|
product_id: str,
|
||||||
|
shop_id: str = Body(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
# 1. 取商品数据
|
||||||
|
product = await get_product(db, product_id)
|
||||||
|
|
||||||
|
# 2. 校验必填项
|
||||||
|
validate_required_fields(product)
|
||||||
|
|
||||||
|
# 3. 组装 ImportProductsV3 请求体
|
||||||
|
item = build_import_item(product)
|
||||||
|
|
||||||
|
# 4. 调用 Ozon API
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
resp = await ozon_client.import_products(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[item]
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id = resp["result"]["task_id"]
|
||||||
|
|
||||||
|
# 5. 记录发布任务
|
||||||
|
task = PublishTask(
|
||||||
|
product_id=product_id,
|
||||||
|
shop_id=shop_id,
|
||||||
|
ozon_task_id=task_id,
|
||||||
|
status="pending",
|
||||||
|
request_payload=item
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 6. 启动后台轮询
|
||||||
|
asyncio.create_task(background_poll_task(task_id, product_id))
|
||||||
|
|
||||||
|
return {"task_id": task_id}
|
||||||
|
|
||||||
|
def build_import_item(product: Product) -> dict:
|
||||||
|
"""组装 ImportProductsV3 items[0]"""
|
||||||
|
return {
|
||||||
|
"offer_id": product.offer_id,
|
||||||
|
"name": product.name,
|
||||||
|
"description": product.description,
|
||||||
|
"description_category_id": product.description_category_id,
|
||||||
|
"type_id": product.type_id,
|
||||||
|
"price": str(product.price),
|
||||||
|
"old_price": str(product.old_price) if product.old_price else "",
|
||||||
|
"currency_code": product.currency_code,
|
||||||
|
"vat": product.vat,
|
||||||
|
"depth": product.depth,
|
||||||
|
"width": product.width,
|
||||||
|
"height": product.height,
|
||||||
|
"dimension_unit": product.dimension_unit,
|
||||||
|
"weight": product.weight,
|
||||||
|
"weight_unit": product.weight_unit,
|
||||||
|
"images": product.images, # 七牛 URL 数组
|
||||||
|
"primary_image": product.primary_image or "",
|
||||||
|
"images360": product.images360 or [],
|
||||||
|
"color_image": product.color_image or "",
|
||||||
|
"barcode": product.barcode or "",
|
||||||
|
"attributes": product.attributes or [],
|
||||||
|
"complex_attributes": product.complex_attributes or []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/PublishPanel.tsx
|
||||||
|
|
||||||
|
import { Button, Select, message } from 'antd';
|
||||||
|
|
||||||
|
export function PublishPanel({ productId }) {
|
||||||
|
const [shops, setShops] = useState([]);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
|
||||||
|
const handlePublish = async (shopId) => {
|
||||||
|
setPublishing(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/products/${productId}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ shop_id: shopId })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
message.success('发布任务已提交,轮询中...');
|
||||||
|
|
||||||
|
// 轮询状态(或 WebSocket 推送)
|
||||||
|
pollPublishStatus(data.task_id);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`发布失败: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
placeholder="选择目标店铺"
|
||||||
|
options={shops.map(s => ({ label: s.name, value: s.id }))}
|
||||||
|
onChange={handlePublish}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={publishing}
|
||||||
|
onClick={() => /* trigger select */}
|
||||||
|
>
|
||||||
|
发布到 Ozon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [02-category-tree.md](./02-category-tree.md) —— 获取类目
|
||||||
|
- [03-category-attributes.md](./03-category-attributes.md) —— 获取属性
|
||||||
|
- [05-product-info.md](./05-product-info.md) —— 查询商品详情
|
||||||
|
- [09-stocks.md](./09-stocks.md) —— 设置库存(必须)
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) —— 发布集成方案
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
# 商品信息查询 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetProductInfoListV3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v3/product/info/list` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 查询商品详细信息(含审核状态、图片、属性、错误) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 请求
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": ["MY-THERMOS-001", "MY-THERMOS-002"],
|
||||||
|
"product_id": [987654321],
|
||||||
|
"sku": [123456789]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| offer_id | array | 可选 | 自己的货号数组(最多 100 个) |
|
||||||
|
| product_id | array | 可选 | Ozon 商品 ID 数组(最多 100 个) |
|
||||||
|
| sku | array | 可选 | Ozon SKU 数组(最多 100 个) |
|
||||||
|
|
||||||
|
⚠️ **至少提供一个筛选条件**(offer_id / product_id / sku)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 987654321,
|
||||||
|
"name": "Термокружка Thermos из нержавеющей стали 500 мл",
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"barcode": "",
|
||||||
|
"buybox_price": "2990.00",
|
||||||
|
"category_id": 17033876,
|
||||||
|
"created_at": "2024-08-10T10:30:00Z",
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"file_name": "thermos-main-1.jpg",
|
||||||
|
"default": true,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"marketing_price": "2990.00",
|
||||||
|
"min_price": "2690.00",
|
||||||
|
"old_price": "3490.00",
|
||||||
|
"premium_price": "2790.00",
|
||||||
|
"price": "2990.00",
|
||||||
|
"recommended_price": "2990.00",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"is_enabled": true,
|
||||||
|
"sku": 123456789,
|
||||||
|
"source": "fbs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "processed",
|
||||||
|
"stocks": {
|
||||||
|
"coming": 0,
|
||||||
|
"present": 100,
|
||||||
|
"reserved": 5
|
||||||
|
},
|
||||||
|
"errors": [],
|
||||||
|
"vat": "0.00",
|
||||||
|
"visible": true,
|
||||||
|
"visibility_details": {
|
||||||
|
"has_price": true,
|
||||||
|
"has_stock": true,
|
||||||
|
"active_product": true
|
||||||
|
},
|
||||||
|
"price_index": "5.0",
|
||||||
|
"images360": [],
|
||||||
|
"color_image": "",
|
||||||
|
"primary_image": "",
|
||||||
|
"status": {
|
||||||
|
"state": "processed",
|
||||||
|
"state_failed": "",
|
||||||
|
"moderate_status": "approved",
|
||||||
|
"decline_reasons": [],
|
||||||
|
"validation_state": "success",
|
||||||
|
"state_name": "Processed",
|
||||||
|
"state_description": "Product is processed",
|
||||||
|
"is_failed": false,
|
||||||
|
"is_created": true,
|
||||||
|
"state_tooltip": ""
|
||||||
|
},
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"width": 80,
|
||||||
|
"height": 200,
|
||||||
|
"depth": 80,
|
||||||
|
"dimension_unit": "mm",
|
||||||
|
"weight": 320,
|
||||||
|
"weight_unit": "g",
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"attribute_id": 85,
|
||||||
|
"complex_id": 0,
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"dictionary_value_id": 971082156,
|
||||||
|
"value": "Thermos"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心字段说明
|
||||||
|
|
||||||
|
### 基本信息
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | integer | Ozon 商品 ID(`product_id`) |
|
||||||
|
| name | string | 商品名称 |
|
||||||
|
| offer_id | string | 自己的货号 |
|
||||||
|
| barcode | string | 条形码 |
|
||||||
|
| created_at | string | 创建时间(ISO 8601) |
|
||||||
|
|
||||||
|
### 价格
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| price | string | 当前售价 |
|
||||||
|
| old_price | string | 划线价(原价) |
|
||||||
|
| marketing_price | string | 营销价 |
|
||||||
|
| buybox_price | string | BuyBox 价格(赢得购物车的价格) |
|
||||||
|
| recommended_price | string | 平台推荐价 |
|
||||||
|
| min_price | string | 允许的最低价(低于此价需申请) |
|
||||||
|
| premium_price | string | Premium 会员价 |
|
||||||
|
|
||||||
|
### 状态
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **state** | string | **商品状态**(见下表) |
|
||||||
|
| **status** | object | **状态详情**(含审核状态、错误原因) |
|
||||||
|
| visible | boolean | 是否可见(上架) |
|
||||||
|
| visibility_details | object | 可见性详情(是否有价格/库存/激活) |
|
||||||
|
|
||||||
|
#### state 状态值
|
||||||
|
|
||||||
|
| state | 含义 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **processed** | ✅ 已处理 | 商品创建成功,可正常展示 |
|
||||||
|
| **processing** | ⏳ 处理中 | 正在处理(刚导入) |
|
||||||
|
| **moderating** | ⏳ 审核中 | 平台审核中 |
|
||||||
|
| **failed** | ❌ 失败 | 创建/审核失败,查看 `errors` |
|
||||||
|
| **archived** | 📦 已归档 | 商品已下架归档 |
|
||||||
|
|
||||||
|
#### status.moderate_status 审核状态
|
||||||
|
|
||||||
|
| moderate_status | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| **approved** | ✅ 审核通过 |
|
||||||
|
| **pending** | ⏳ 待审核 |
|
||||||
|
| **declined** | ❌ 审核拒绝 |
|
||||||
|
|
||||||
|
### 库存
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| stocks.present | integer | 当前库存 |
|
||||||
|
| stocks.reserved | integer | 已预订数量 |
|
||||||
|
| stocks.coming | integer | 即将到货数量 |
|
||||||
|
|
||||||
|
### 图片
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| images | array | 图片数组 |
|
||||||
|
| images[].file_name | string | 图片文件名 |
|
||||||
|
| images[].default | boolean | 是否主图 |
|
||||||
|
| images[].index | integer | 顺序 |
|
||||||
|
| primary_image | string | 主图 URL |
|
||||||
|
| images360 | array | 360° 图 |
|
||||||
|
| color_image | string | 营销色图 |
|
||||||
|
|
||||||
|
### 尺寸与属性
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| description_category_id | integer | 类目 ID |
|
||||||
|
| type_id | integer | 商品类型 ID |
|
||||||
|
| width / height / depth | number | 尺寸 |
|
||||||
|
| dimension_unit | string | 尺寸单位 |
|
||||||
|
| weight | number | 重量 |
|
||||||
|
| weight_unit | string | 重量单位 |
|
||||||
|
| attributes | array | 属性数组(结构同导入) |
|
||||||
|
|
||||||
|
### 错误信息
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| errors | array | 错误数组(审核失败原因、字段错误等) |
|
||||||
|
| status.decline_reasons | array | 审核拒绝原因 |
|
||||||
|
| status.validation_state | string | 校验状态:`success` / `failed` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 使用场景
|
||||||
|
|
||||||
|
### 场景 1:发布后回查 product_id
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 导入后用 offer_id 查询,获取 product_id
|
||||||
|
async def get_product_id_by_offer(offer_id: str):
|
||||||
|
resp = await ozon_client.get_product_info(
|
||||||
|
offer_id=[offer_id]
|
||||||
|
)
|
||||||
|
items = resp["result"]["items"]
|
||||||
|
if items:
|
||||||
|
return items[0]["id"]
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2:检查审核状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def check_moderation_status(product_id: int):
|
||||||
|
resp = await ozon_client.get_product_info(
|
||||||
|
product_id=[product_id]
|
||||||
|
)
|
||||||
|
item = resp["result"]["items"][0]
|
||||||
|
status = item["status"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"state": status["state"],
|
||||||
|
"moderate_status": status["moderate_status"],
|
||||||
|
"is_approved": status["moderate_status"] == "approved",
|
||||||
|
"decline_reasons": status["decline_reasons"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3:读取审核错误
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_product_errors(offer_id: str):
|
||||||
|
resp = await ozon_client.get_product_info(offer_id=[offer_id])
|
||||||
|
item = resp["result"]["items"][0]
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# 字段错误
|
||||||
|
if item.get("errors"):
|
||||||
|
errors.extend(item["errors"])
|
||||||
|
|
||||||
|
# 审核拒绝原因
|
||||||
|
if item["status"].get("decline_reasons"):
|
||||||
|
errors.extend(item["status"]["decline_reasons"])
|
||||||
|
|
||||||
|
return errors
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 错误处理
|
||||||
|
|
||||||
|
### 商品不存在
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回空数组,非 404 错误。
|
||||||
|
|
||||||
|
### 部分成功
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 987654321,
|
||||||
|
"offer_id": "EXISTING-001",
|
||||||
|
/* ... 完整信息 */
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
请求 3 个 offer_id,只有 1 个存在 → 只返回 1 个 item。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与其他接口的关系
|
||||||
|
|
||||||
|
### 与 `/v3/product/import` 的配合
|
||||||
|
|
||||||
|
```
|
||||||
|
① POST /v3/product/import → task_id
|
||||||
|
② POST /v1/product/import/info → status=imported, product_id=X
|
||||||
|
③ POST /v3/product/info/list (product_id=X) → 读取完整信息(含图片/审核状态)
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途**:导入后可能需要:
|
||||||
|
- 确认图片上传成功
|
||||||
|
- 检查审核状态
|
||||||
|
- 读取 Ozon 生成的 SKU
|
||||||
|
- 查看价格索引(`price_index`,影响排名)
|
||||||
|
|
||||||
|
### 与 `/v3/product/list` 的区别
|
||||||
|
|
||||||
|
| 接口 | 用途 | 返回字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/v3/product/info/list` | **详情查询** | 完整字段(图片/属性/状态/错误) |
|
||||||
|
| `/v3/product/list` | **列表分页** | 基本字段(id/name/price/state),支持筛选/排序 |
|
||||||
|
|
||||||
|
**选择建议**:
|
||||||
|
- 已知 offer_id/product_id,要完整信息 → 用 `info/list`
|
||||||
|
- 分页浏览所有商品、筛选状态 → 用 `list`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 示例代码
|
||||||
|
|
||||||
|
### Python(服务端)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def fetch_product_detail(
|
||||||
|
client_id: str,
|
||||||
|
api_key: str,
|
||||||
|
offer_id: str = None,
|
||||||
|
product_id: int = None
|
||||||
|
):
|
||||||
|
"""查询商品详情"""
|
||||||
|
headers = {
|
||||||
|
"Client-Id": client_id,
|
||||||
|
"Api-Key": api_key,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
if offer_id:
|
||||||
|
payload["offer_id"] = [offer_id]
|
||||||
|
if product_id:
|
||||||
|
payload["product_id"] = [product_id]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api-seller.ozon.ru/v3/product/info/list",
|
||||||
|
headers=headers,
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
items = data.get("result", {}).get("items", [])
|
||||||
|
return items[0] if items else None
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript(前端)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function getProductDetail(
|
||||||
|
productId: string,
|
||||||
|
by: 'offer_id' | 'product_id' = 'offer_id'
|
||||||
|
) {
|
||||||
|
const resp = await fetch('/api/products/detail', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
[by]: [productId]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.result.items[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
const detail = await getProductDetail('MY-THERMOS-001', 'offer_id');
|
||||||
|
console.log('审核状态:', detail.status.moderate_status);
|
||||||
|
console.log('库存:', detail.stocks.present);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 审核拒绝原因解读
|
||||||
|
|
||||||
|
### 常见拒绝原因
|
||||||
|
|
||||||
|
| decline_reason | 含义 | 解决 |
|
||||||
|
|---|---|---|
|
||||||
|
| 图片不符合要求 | 图片非白底/有水印/模糊 | 重新上传符合规范的图片 |
|
||||||
|
| 标题含禁用词 | 标题有夸大宣传/品牌侵权词 | 修改标题,去除违规词 |
|
||||||
|
| 描述不完整 | 描述过短或缺少关键信息 | 补充完整商品描述 |
|
||||||
|
| 类目错误 | 商品与类目不匹配 | 重新选择正确类目 |
|
||||||
|
| 属性缺失 | 缺少必填属性 | 补充必填属性 |
|
||||||
|
| 品牌未授权 | 品牌需授权认证 | 提供品牌授权书或改用无品牌 |
|
||||||
|
|
||||||
|
### 处理流程
|
||||||
|
|
||||||
|
```
|
||||||
|
① 读取 status.decline_reasons
|
||||||
|
② 根据原因修改商品(改图/改文案/改属性)
|
||||||
|
③ 重新调用 /v3/product/import(同 offer_id = 更新)
|
||||||
|
④ 再次审核
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(待实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/products.py
|
||||||
|
|
||||||
|
@router.get("/products/{product_id}/ozon-detail")
|
||||||
|
async def get_ozon_detail(
|
||||||
|
product_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""查询商品在 Ozon 的详情(审核状态/库存/图片)"""
|
||||||
|
product = await get_product(db, product_id)
|
||||||
|
|
||||||
|
if not product.ozon_product_id and not product.offer_id:
|
||||||
|
raise HTTPException(404, "商品尚未发布到 Ozon")
|
||||||
|
|
||||||
|
# 获取店铺凭证(从发布记录找)
|
||||||
|
task = await db.execute(
|
||||||
|
select(PublishTask)
|
||||||
|
.where(PublishTask.product_id == product_id)
|
||||||
|
.order_by(PublishTask.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
task = task.scalar_one_or_none()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(404, "未找到发布记录")
|
||||||
|
|
||||||
|
shop = await get_shop(db, task.shop_id)
|
||||||
|
|
||||||
|
# 调用 Ozon API
|
||||||
|
detail = await ozon_client.get_product_info(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
offer_id=[product.offer_id] if product.offer_id else None,
|
||||||
|
product_id=[product.ozon_product_id] if product.ozon_product_id else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": detail}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(商品详情页展示审核状态)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/OzonStatusBadge.tsx
|
||||||
|
|
||||||
|
import { Badge, Tooltip } from 'antd';
|
||||||
|
|
||||||
|
export function OzonStatusBadge({ productId }) {
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/products/${productId}/ozon-detail`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const item = data.result.items[0];
|
||||||
|
setStatus(item.status);
|
||||||
|
});
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
if (!status) return null;
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
approved: { color: 'success', text: '审核通过' },
|
||||||
|
pending: { color: 'processing', text: '审核中' },
|
||||||
|
declined: { color: 'error', text: '审核拒绝' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusMap[status.moderate_status] || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={status.decline_reasons?.join(', ')}>
|
||||||
|
<Badge status={config.color} text={config.text} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 性能优化
|
||||||
|
|
||||||
|
### 批量查询
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 一次查询多个商品(最多 100 个)
|
||||||
|
async def batch_get_products(offer_ids: list[str]):
|
||||||
|
resp = await ozon_client.get_product_info(offer_id=offer_ids)
|
||||||
|
return {
|
||||||
|
item["offer_id"]: item
|
||||||
|
for item in resp["result"]["items"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用
|
||||||
|
details = await batch_get_products([
|
||||||
|
"PROD-001", "PROD-002", "PROD-003"
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 缓存策略
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 商品详情变化不频繁,可短期缓存
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1000)
|
||||||
|
async def get_product_info_cached(offer_id: str, ttl=300):
|
||||||
|
# TTL 5 分钟
|
||||||
|
detail = await ozon_client.get_product_info(offer_id=[offer_id])
|
||||||
|
return detail["result"]["items"][0] if detail["result"]["items"] else None
|
||||||
|
|
||||||
|
# 审核状态变化时清缓存
|
||||||
|
get_product_info_cached.cache_clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [04-product-import.md](./04-product-import.md) —— 创建/更新商品
|
||||||
|
- [06-product-list.md](./06-product-list.md) —— 商品列表分页
|
||||||
|
- [09-stocks.md](./09-stocks.md) —— 库存管理
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) —— 发布集成方案
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
# 商品列表查询 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetProductListV3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v3/product/list` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 分页查询商品列表(支持筛选、排序) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 请求
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filter": {
|
||||||
|
"offer_id": ["MY-THERMOS-001"],
|
||||||
|
"product_id": [987654321],
|
||||||
|
"visibility": "ALL"
|
||||||
|
},
|
||||||
|
"last_id": "",
|
||||||
|
"limit": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| filter | object | 可选 | 筛选条件 |
|
||||||
|
| filter.offer_id | array | 可选 | 货号数组 |
|
||||||
|
| filter.product_id | array | 可选 | Ozon 商品 ID 数组 |
|
||||||
|
| filter.visibility | string | 可选 | 可见性:`ALL`(全部)/ `VISIBLE`(可见)/ `INVISIBLE`(不可见)/ `EMPTY_STOCK`(无库存)。默认 `ALL` |
|
||||||
|
| **last_id** | string | 可选 | **分页游标**(上一页最后一个商品的 ID),首页传空字符串 `""` |
|
||||||
|
| **limit** | integer | 可选 | 每页数量,最大 **1000**,默认 100 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_id": 987654321,
|
||||||
|
"offer_id": "MY-THERMOS-001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"product_id": 987654322,
|
||||||
|
"offer_id": "MY-THERMOS-002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 256,
|
||||||
|
"last_id": "bnVtYmVyMjo5ODc2NTQzMjI="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| items | array | 商品列表(**仅基本字段**:product_id + offer_id) |
|
||||||
|
| total | integer | 商品总数 |
|
||||||
|
| **last_id** | string | **下一页游标**(Base64 编码,传给下次请求) |
|
||||||
|
|
||||||
|
⚠️ **注意**:此接口**仅返回 product_id 和 offer_id**,不返回名称/价格/图片等详情。要获取完整信息,需再调 `/v3/product/info/list`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 分页示例
|
||||||
|
|
||||||
|
### 游标分页(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def fetch_all_products(visibility="ALL"):
|
||||||
|
"""拉取所有商品(游标分页)"""
|
||||||
|
all_items = []
|
||||||
|
last_id = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = await ozon_client.get_product_list(
|
||||||
|
filter={"visibility": visibility},
|
||||||
|
last_id=last_id,
|
||||||
|
limit=1000 # 单次最多 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
items = resp["result"]["items"]
|
||||||
|
all_items.extend(items)
|
||||||
|
|
||||||
|
# 无更多数据
|
||||||
|
if not resp["result"].get("last_id"):
|
||||||
|
break
|
||||||
|
|
||||||
|
last_id = resp["result"]["last_id"]
|
||||||
|
|
||||||
|
return all_items
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分批处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def process_products_in_batches(batch_size=100):
|
||||||
|
"""分批处理商品(避免一次拉全部)"""
|
||||||
|
last_id = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = await ozon_client.get_product_list(
|
||||||
|
last_id=last_id,
|
||||||
|
limit=batch_size
|
||||||
|
)
|
||||||
|
|
||||||
|
items = resp["result"]["items"]
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 处理当前批次
|
||||||
|
await process_batch(items)
|
||||||
|
|
||||||
|
last_id = resp["result"].get("last_id")
|
||||||
|
if not last_id:
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 获取完整信息
|
||||||
|
|
||||||
|
### 方法 A:批量查详情(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def fetch_products_with_detail(visibility="ALL"):
|
||||||
|
"""拉取商品列表 + 完整信息"""
|
||||||
|
# 1. 拉列表(仅 ID)
|
||||||
|
list_resp = await ozon_client.get_product_list(
|
||||||
|
filter={"visibility": visibility},
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
items = list_resp["result"]["items"]
|
||||||
|
product_ids = [item["product_id"] for item in items]
|
||||||
|
|
||||||
|
# 2. 批量查详情(每次最多 100 个)
|
||||||
|
details = []
|
||||||
|
for i in range(0, len(product_ids), 100):
|
||||||
|
batch = product_ids[i:i+100]
|
||||||
|
detail_resp = await ozon_client.get_product_info(
|
||||||
|
product_id=batch
|
||||||
|
)
|
||||||
|
details.extend(detail_resp["result"]["items"])
|
||||||
|
|
||||||
|
return details
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法 B:按需查详情
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 先列表,用户点击某个商品时再查详情
|
||||||
|
products = await ozon_client.get_product_list(limit=100)
|
||||||
|
|
||||||
|
# 用户点击 product_id=987654321
|
||||||
|
detail = await ozon_client.get_product_info(product_id=[987654321])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 筛选条件详解
|
||||||
|
|
||||||
|
### visibility 筛选
|
||||||
|
|
||||||
|
| 值 | 含义 | 使用场景 |
|
||||||
|
|---|---|---|
|
||||||
|
| **ALL** | 全部商品 | 管理后台(查看所有) |
|
||||||
|
| **VISIBLE** | 可见商品(上架) | 前台展示的商品 |
|
||||||
|
| **INVISIBLE** | 不可见商品(下架/草稿) | 待上架/审核失败/归档 |
|
||||||
|
| **EMPTY_STOCK** | 无库存商品 | 补货提醒 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 查询所有上架商品
|
||||||
|
visible = await ozon_client.get_product_list(
|
||||||
|
filter={"visibility": "VISIBLE"},
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询无库存商品(需补货)
|
||||||
|
empty_stock = await ozon_client.get_product_list(
|
||||||
|
filter={"visibility": "EMPTY_STOCK"},
|
||||||
|
limit=100
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 性能对比
|
||||||
|
|
||||||
|
### `/v3/product/list` vs `/v3/product/info/list`
|
||||||
|
|
||||||
|
| 维度 | `/v3/product/list` | `/v3/product/info/list` |
|
||||||
|
|---|---|---|
|
||||||
|
| 返回字段 | 仅 product_id + offer_id | 完整字段(图片/属性/状态) |
|
||||||
|
| 单次数量 | 最多 **1000** | 最多 **100** |
|
||||||
|
| 响应速度 | 快(字段少) | 慢(字段多) |
|
||||||
|
| 适用场景 | 列表/分页/ID 收集 | 详情查询/更新前读取 |
|
||||||
|
|
||||||
|
**策略**:
|
||||||
|
1. 先用 `/list` 拉 ID 列表(快)
|
||||||
|
2. 再用 `/info/list` 批量查详情(按需,100 个一批)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 与本地数据库同步
|
||||||
|
|
||||||
|
### 场景:回填 product_id
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def sync_product_ids():
|
||||||
|
"""发布后回填 product_id(用 offer_id 匹配)"""
|
||||||
|
# 1. 从数据库取所有「已发布但无 product_id」的商品
|
||||||
|
local_products = await db.execute(
|
||||||
|
select(Product)
|
||||||
|
.where(
|
||||||
|
Product.stage == "published",
|
||||||
|
Product.ozon_product_id.is_(None),
|
||||||
|
Product.offer_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local_products = local_products.scalars().all()
|
||||||
|
|
||||||
|
if not local_products:
|
||||||
|
return
|
||||||
|
|
||||||
|
offer_ids = [p.offer_id for p in local_products]
|
||||||
|
|
||||||
|
# 2. 从 Ozon 查询这些 offer_id 的 product_id
|
||||||
|
ozon_items = []
|
||||||
|
for i in range(0, len(offer_ids), 100):
|
||||||
|
batch = offer_ids[i:i+100]
|
||||||
|
resp = await ozon_client.get_product_info(offer_id=batch)
|
||||||
|
ozon_items.extend(resp["result"]["items"])
|
||||||
|
|
||||||
|
# 3. 回填到数据库
|
||||||
|
ozon_map = {item["offer_id"]: item["id"] for item in ozon_items}
|
||||||
|
|
||||||
|
for p in local_products:
|
||||||
|
if p.offer_id in ozon_map:
|
||||||
|
p.ozon_product_id = ozon_map[p.offer_id]
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景:定期同步状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def sync_product_states():
|
||||||
|
"""定期同步商品状态(审核状态/库存/可见性)"""
|
||||||
|
# 1. 拉取所有 Ozon 商品 ID
|
||||||
|
ozon_resp = await ozon_client.get_product_list(limit=1000)
|
||||||
|
ozon_ids = [item["product_id"] for item in ozon_resp["result"]["items"]]
|
||||||
|
|
||||||
|
# 2. 批量查详情
|
||||||
|
details = []
|
||||||
|
for i in range(0, len(ozon_ids), 100):
|
||||||
|
batch = ozon_ids[i:i+100]
|
||||||
|
resp = await ozon_client.get_product_info(product_id=batch)
|
||||||
|
details.extend(resp["result"]["items"])
|
||||||
|
|
||||||
|
# 3. 更新本地数据库
|
||||||
|
for item in details:
|
||||||
|
await db.execute(
|
||||||
|
update(Product)
|
||||||
|
.where(Product.ozon_product_id == item["id"])
|
||||||
|
.values(
|
||||||
|
stage="published" if item["visible"] else "failed",
|
||||||
|
# 可同步更多字段:价格/库存/审核状态
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 常见问题
|
||||||
|
|
||||||
|
### Q1: 为什么 `/list` 只返回 ID?
|
||||||
|
|
||||||
|
**A**: 性能考虑。商品列表可能有**数万条**,返回完整字段会很慢。设计思路:
|
||||||
|
1. 先快速拉 ID 列表(轻量)
|
||||||
|
2. 前端展示分页,只查当前页的详情
|
||||||
|
3. 或后台批量拉详情,按需处理
|
||||||
|
|
||||||
|
### Q2: 如何获取商品总数?
|
||||||
|
|
||||||
|
**A**: 响应的 `total` 字段。但注意:
|
||||||
|
- `total` 是当前筛选条件下的总数
|
||||||
|
- 不保证精确(可能略有延迟)
|
||||||
|
|
||||||
|
### Q3: 游标分页与偏移分页的区别?
|
||||||
|
|
||||||
|
**A**:
|
||||||
|
- **游标分页**(last_id):适合全量遍历,性能稳定
|
||||||
|
- **偏移分页**(offset):Ozon 不支持(无 offset 参数)
|
||||||
|
|
||||||
|
### Q4: 多久同步一次?
|
||||||
|
|
||||||
|
**A**: 建议策略:
|
||||||
|
- 发布后立即查询(回填 product_id)
|
||||||
|
- 定期同步(每天一次,更新状态/库存)
|
||||||
|
- 用户主动刷新(按需实时查询)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(待实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/products.py
|
||||||
|
|
||||||
|
@router.get("/products/sync-from-ozon")
|
||||||
|
async def sync_from_ozon(
|
||||||
|
shop_id: str = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""从 Ozon 同步商品列表(回填 product_id + 状态)"""
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
|
||||||
|
# 1. 拉取 Ozon 商品列表
|
||||||
|
ozon_items = []
|
||||||
|
last_id = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = await ozon_client.get_product_list(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
last_id=last_id,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
items = resp["result"]["items"]
|
||||||
|
ozon_items.extend(items)
|
||||||
|
|
||||||
|
last_id = resp["result"].get("last_id")
|
||||||
|
if not last_id:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2. 批量查详情
|
||||||
|
product_ids = [item["product_id"] for item in ozon_items]
|
||||||
|
details = []
|
||||||
|
|
||||||
|
for i in range(0, len(product_ids), 100):
|
||||||
|
batch = product_ids[i:i+100]
|
||||||
|
detail_resp = await ozon_client.get_product_info(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
product_id=batch
|
||||||
|
)
|
||||||
|
details.extend(detail_resp["result"]["items"])
|
||||||
|
|
||||||
|
# 3. 更新本地数据库
|
||||||
|
updated = 0
|
||||||
|
for item in details:
|
||||||
|
result = await db.execute(
|
||||||
|
update(Product)
|
||||||
|
.where(Product.offer_id == item["offer_id"])
|
||||||
|
.values(
|
||||||
|
ozon_product_id=item["id"],
|
||||||
|
stage="published" if item["visible"] else "archived"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
updated += result.rowcount
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"synced": len(details),
|
||||||
|
"updated": updated
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/products/SyncButton.tsx
|
||||||
|
|
||||||
|
import { Button, message } from 'antd';
|
||||||
|
import { SyncOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export function SyncFromOzonButton({ shopId }) {
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/products/sync-from-ozon?shop_id=${shopId}`
|
||||||
|
);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
message.success(
|
||||||
|
`已同步 ${data.synced} 个商品,更新 ${data.updated} 条记录`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`同步失败: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
loading={syncing}
|
||||||
|
onClick={handleSync}
|
||||||
|
>
|
||||||
|
从 Ozon 同步
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 高级用法
|
||||||
|
|
||||||
|
### 增量同步
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def incremental_sync(last_sync_time: datetime):
|
||||||
|
"""增量同步:只拉取最近更新的商品"""
|
||||||
|
# Ozon 的 /list 接口不支持按更新时间筛选
|
||||||
|
# 策略:全量拉 ID,对比本地 updated_at,只查变化的
|
||||||
|
|
||||||
|
ozon_items = await fetch_all_products()
|
||||||
|
ozon_ids = {item["product_id"] for item in ozon_items}
|
||||||
|
|
||||||
|
# 查本地已有的 product_id
|
||||||
|
local = await db.execute(
|
||||||
|
select(Product.ozon_product_id, Product.updated_at)
|
||||||
|
.where(Product.ozon_product_id.isnot(None))
|
||||||
|
)
|
||||||
|
local_map = {
|
||||||
|
row.ozon_product_id: row.updated_at
|
||||||
|
for row in local.fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 找出新增的 ID
|
||||||
|
new_ids = ozon_ids - set(local_map.keys())
|
||||||
|
|
||||||
|
# 批量查详情(只查新增的)
|
||||||
|
if new_ids:
|
||||||
|
details = await batch_get_product_info(list(new_ids))
|
||||||
|
# 插入数据库
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [04-product-import.md](./04-product-import.md) —— 创建/更新商品
|
||||||
|
- [05-product-info.md](./05-product-info.md) —— 查询商品详情
|
||||||
|
- [09-stocks.md](./09-stocks.md) —— 库存管理
|
||||||
|
- [docs/v2/database.md](../v2/database.md) —— products 表结构
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
# 库存管理 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ProductsStocksV2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v2/products/stocks` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | **设置/更新商品库存**(必须操作,否则商品不可购买) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 重要约束
|
||||||
|
|
||||||
|
⚠️ **商品导入成功后不会自动上架**,必须设置库存才能开售:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v3/product/import → status=imported(商品已创建)
|
||||||
|
↓
|
||||||
|
POST /v2/products/stocks → 设置库存(商品可购买)
|
||||||
|
```
|
||||||
|
|
||||||
|
未设置库存的商品:
|
||||||
|
- ✅ 在卖家后台可见
|
||||||
|
- ❌ 前台不展示
|
||||||
|
- ❌ 无法购买
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 请求
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 987654321,
|
||||||
|
"stock": 100,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-002",
|
||||||
|
"stock": 50,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| stocks | array | ✅ 是 | 库存数组(最多 **100** 个) |
|
||||||
|
| stocks[].offer_id | string | 条件 | 自己的货号(与 product_id 二选一) |
|
||||||
|
| stocks[].product_id | integer | 条件 | Ozon 商品 ID(与 offer_id 二选一) |
|
||||||
|
| stocks[].stock | integer | ✅ 是 | 库存数量。`0` = 无库存(下架) |
|
||||||
|
| stocks[].warehouse_id | integer | ✅ 是 | 仓库 ID(见下节) |
|
||||||
|
|
||||||
|
⚠️ **必须提供 offer_id 或 product_id**(建议用 offer_id,更稳定)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 仓库 ID(warehouse_id)
|
||||||
|
|
||||||
|
### 获取仓库 ID
|
||||||
|
|
||||||
|
**接口**:`POST /v1/warehouse/list`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"warehouse_id": 12345678,
|
||||||
|
"name": "FBS 仓库-莫斯科",
|
||||||
|
"can_print_act_in_advance": true,
|
||||||
|
"is_rfbs": false,
|
||||||
|
"has_postings_limit": false,
|
||||||
|
"postings_limit": 0,
|
||||||
|
"status": "working"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| warehouse_id | **仓库 ID**(设置库存时用) |
|
||||||
|
| name | 仓库名称 |
|
||||||
|
| is_rfbs | 是否 rFBS 仓库(Ozon 代发货) |
|
||||||
|
| status | 状态:`working`(运行中)/ `disabled`(禁用) |
|
||||||
|
|
||||||
|
### 仓库类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | warehouse_id |
|
||||||
|
|---|---|---|
|
||||||
|
| **FBS** | 自发货(Fulfillment by Seller) | 从 `/v1/warehouse/list` 获取 |
|
||||||
|
| **FBO** | Ozon 发货(Fulfillment by Ozon) | 从 `/v1/warehouse/list` 获取 |
|
||||||
|
| **rFBS** | Ozon 代发货(类似 FBO,但库存在卖家处) | `is_rfbs=true` |
|
||||||
|
|
||||||
|
**推荐**:新商户优先用 **FBS**(自发货),灵活且门槛低。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"errors": [],
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 987654321,
|
||||||
|
"updated": true,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "PRODUCT_NOT_FOUND",
|
||||||
|
"message": "Product not found"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"offer_id": "MY-THERMOS-999",
|
||||||
|
"product_id": 0,
|
||||||
|
"updated": false,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| updated | boolean | 是否更新成功 |
|
||||||
|
| errors | array | 错误数组(失败时) |
|
||||||
|
| offer_id | string | 货号(回显) |
|
||||||
|
| product_id | integer | Ozon 商品 ID(回显) |
|
||||||
|
| warehouse_id | integer | 仓库 ID(回显) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 常见错误
|
||||||
|
|
||||||
|
| code | message | 原因 | 解决 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PRODUCT_NOT_FOUND` | 商品不存在 | offer_id/product_id 错误或商品已删除 | 检查 ID 是否正确 |
|
||||||
|
| `WAREHOUSE_NOT_FOUND` | 仓库不存在 | warehouse_id 错误 | 调 `/v1/warehouse/list` 获取正确 ID |
|
||||||
|
| `INVALID_STOCK` | 库存值错误 | stock < 0 | 库存必须 ≥ 0 |
|
||||||
|
| `PRODUCT_ARCHIVED` | 商品已归档 | 商品处于归档状态 | 先恢复商品再设库存 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 使用场景
|
||||||
|
|
||||||
|
### 场景 1:发布后设置初始库存
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def publish_and_set_stock(product: Product, shop: Shop):
|
||||||
|
"""发布商品 + 设置库存(完整流程)"""
|
||||||
|
# 1. 导入商品
|
||||||
|
resp = await ozon_client.import_products(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[build_import_item(product)]
|
||||||
|
)
|
||||||
|
task_id = resp["result"]["task_id"]
|
||||||
|
|
||||||
|
# 2. 轮询直到成功
|
||||||
|
result = await wait_for_import(task_id)
|
||||||
|
if not result["success"]:
|
||||||
|
raise Exception(f"发布失败: {result['errors']}")
|
||||||
|
|
||||||
|
product_id = result["product_id"]
|
||||||
|
|
||||||
|
# 3. 获取仓库 ID
|
||||||
|
warehouses = await ozon_client.get_warehouses(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec
|
||||||
|
)
|
||||||
|
warehouse_id = warehouses[0]["warehouse_id"] # 取第一个
|
||||||
|
|
||||||
|
# 4. 设置库存
|
||||||
|
stock_resp = await ozon_client.update_stocks(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[{
|
||||||
|
"product_id": product_id,
|
||||||
|
"stock": 100, # 初始库存
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
|
||||||
|
return stock_resp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2:批量更新库存
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def batch_update_stocks(updates: list[dict]):
|
||||||
|
"""批量更新库存(最多 100 个)"""
|
||||||
|
# updates = [
|
||||||
|
# {"offer_id": "PROD-001", "stock": 50},
|
||||||
|
# {"offer_id": "PROD-002", "stock": 0}, # 0 = 下架
|
||||||
|
# ]
|
||||||
|
|
||||||
|
warehouse_id = await get_default_warehouse_id()
|
||||||
|
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"offer_id": u["offer_id"],
|
||||||
|
"stock": u["stock"],
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}
|
||||||
|
for u in updates
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = await ozon_client.update_stocks(
|
||||||
|
client_id, api_key, stocks
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查失败项
|
||||||
|
failed = [
|
||||||
|
item for item in resp["result"]
|
||||||
|
if not item["updated"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": len(resp["result"]) - len(failed),
|
||||||
|
"failed": failed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3:库存为 0 时下架
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def out_of_stock(offer_id: str):
|
||||||
|
"""库存售罄,设为 0(自动下架)"""
|
||||||
|
await ozon_client.update_stocks(
|
||||||
|
client_id, api_key,
|
||||||
|
[{
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"stock": 0, # 库存为 0 → 前台不展示
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 4:补货后上架
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def restock(offer_id: str, quantity: int):
|
||||||
|
"""补货后重新上架"""
|
||||||
|
await ozon_client.update_stocks(
|
||||||
|
client_id, api_key,
|
||||||
|
[{
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"stock": quantity, # 设置新库存 → 自动上架
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 查询当前库存
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v3/product/info/stocks` |
|
||||||
|
| 用途 | 查询商品当前库存 |
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filter": {
|
||||||
|
"offer_id": ["MY-THERMOS-001"],
|
||||||
|
"product_id": [987654321],
|
||||||
|
"visibility": "ALL"
|
||||||
|
},
|
||||||
|
"last_id": "",
|
||||||
|
"limit": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 987654321,
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"type": "fbs",
|
||||||
|
"present": 100,
|
||||||
|
"reserved": 5,
|
||||||
|
"warehouse_id": 12345678,
|
||||||
|
"warehouse_name": "FBS 仓库-莫斯科"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_id": "",
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| stocks[].present | 可用库存 |
|
||||||
|
| stocks[].reserved | 已预订数量(订单未完成) |
|
||||||
|
| stocks[].type | 仓库类型:`fbs` / `fbo` / `rfbs` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 库存同步策略
|
||||||
|
|
||||||
|
### 策略 A:实时同步(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 本地库存变化时立即更新 Ozon
|
||||||
|
async def on_local_stock_change(product_id: str, new_stock: int):
|
||||||
|
product = await get_product(db, product_id)
|
||||||
|
|
||||||
|
if not product.ozon_product_id:
|
||||||
|
return # 未发布到 Ozon
|
||||||
|
|
||||||
|
shop = await get_default_shop(db)
|
||||||
|
warehouse_id = await get_default_warehouse_id()
|
||||||
|
|
||||||
|
await ozon_client.update_stocks(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[{
|
||||||
|
"offer_id": product.offer_id,
|
||||||
|
"stock": new_stock,
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 策略 B:定时同步
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 每天凌晨同步一次(防止偏差累积)
|
||||||
|
async def daily_sync_stocks():
|
||||||
|
"""定时任务:同步本地库存到 Ozon"""
|
||||||
|
products = await db.execute(
|
||||||
|
select(Product)
|
||||||
|
.where(
|
||||||
|
Product.stage == "published",
|
||||||
|
Product.ozon_product_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
products = products.scalars().all()
|
||||||
|
|
||||||
|
warehouse_id = await get_default_warehouse_id()
|
||||||
|
|
||||||
|
# 批量更新(100 个一批)
|
||||||
|
for i in range(0, len(products), 100):
|
||||||
|
batch = products[i:i+100]
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"offer_id": p.offer_id,
|
||||||
|
"stock": p.local_stock, # 假设有 local_stock 字段
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}
|
||||||
|
for p in batch
|
||||||
|
]
|
||||||
|
|
||||||
|
await ozon_client.update_stocks(
|
||||||
|
client_id, api_key, stocks
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 策略 C:反向同步(从 Ozon 读回)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 定期从 Ozon 读回库存(多渠道销售时需要)
|
||||||
|
async def sync_stocks_from_ozon():
|
||||||
|
"""从 Ozon 同步库存到本地"""
|
||||||
|
resp = await ozon_client.get_product_stocks(
|
||||||
|
filter={"visibility": "VISIBLE"},
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in resp["result"]["items"]:
|
||||||
|
offer_id = item["offer_id"]
|
||||||
|
ozon_stock = item["stocks"][0]["present"]
|
||||||
|
|
||||||
|
# 更新本地库存
|
||||||
|
await db.execute(
|
||||||
|
update(Product)
|
||||||
|
.where(Product.offer_id == offer_id)
|
||||||
|
.values(local_stock=ozon_stock)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(待实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/products.py
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/set-stock")
|
||||||
|
async def set_stock(
|
||||||
|
product_id: str,
|
||||||
|
stock: int = Body(..., ge=0),
|
||||||
|
shop_id: str = Body(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""设置商品库存"""
|
||||||
|
product = await get_product(db, product_id)
|
||||||
|
|
||||||
|
if not product.ozon_product_id and not product.offer_id:
|
||||||
|
raise HTTPException(400, "商品尚未发布到 Ozon")
|
||||||
|
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
|
||||||
|
# 获取仓库 ID(缓存)
|
||||||
|
warehouse_id = await get_or_cache_warehouse_id(shop)
|
||||||
|
|
||||||
|
# 调用 Ozon API
|
||||||
|
resp = await ozon_client.update_stocks(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[{
|
||||||
|
"offer_id": product.offer_id,
|
||||||
|
"stock": stock,
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = resp["result"][0]
|
||||||
|
|
||||||
|
if not result["updated"]:
|
||||||
|
raise HTTPException(500, f"更新失败: {result['errors']}")
|
||||||
|
|
||||||
|
# 更新本地记录
|
||||||
|
product.local_stock = stock
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "stock": stock}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shops/{shop_id}/warehouses")
|
||||||
|
async def get_warehouses(
|
||||||
|
shop_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取店铺的仓库列表"""
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
|
||||||
|
warehouses = await ozon_client.get_warehouses(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": warehouses}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/StockPanel.tsx
|
||||||
|
|
||||||
|
import { InputNumber, Button, message } from 'antd';
|
||||||
|
|
||||||
|
export function StockPanel({ productId, shopId }) {
|
||||||
|
const [stock, setStock] = useState(0);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/products/${productId}/set-stock`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ stock, shop_id: shopId })
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success(`库存已设置为 ${stock}`);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`设置失败: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
value={stock}
|
||||||
|
onChange={setStock}
|
||||||
|
placeholder="库存数量"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={saving}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
设置库存
|
||||||
|
</Button>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}>
|
||||||
|
💡 库存为 0 时商品自动下架
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 最佳实践
|
||||||
|
|
||||||
|
### 1. 发布流程中必须设库存
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 正确:
|
||||||
|
POST /v3/product/import → 轮询成功 → POST /v2/products/stocks
|
||||||
|
|
||||||
|
❌ 错误:
|
||||||
|
POST /v3/product/import → 轮询成功 → 结束(商品不可购买)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 缓存仓库 ID
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 仓库 ID 不常变,启动时拉取并缓存
|
||||||
|
_warehouse_cache = {}
|
||||||
|
|
||||||
|
async def get_warehouse_id(shop_id: str):
|
||||||
|
if shop_id not in _warehouse_cache:
|
||||||
|
warehouses = await ozon_client.get_warehouses(...)
|
||||||
|
_warehouse_cache[shop_id] = warehouses[0]["warehouse_id"]
|
||||||
|
|
||||||
|
return _warehouse_cache[shop_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 库存为 0 的处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 库存为 0 → 自动下架,但商品仍在后台
|
||||||
|
# 补货后再设置库存 → 自动上架
|
||||||
|
# 不需要删除商品,只需更新库存
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 批量操作
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 单次最多 100 个,超过需分批
|
||||||
|
async def update_large_batch(stocks: list):
|
||||||
|
results = []
|
||||||
|
for i in range(0, len(stocks), 100):
|
||||||
|
batch = stocks[i:i+100]
|
||||||
|
resp = await ozon_client.update_stocks(client_id, api_key, batch)
|
||||||
|
results.extend(resp["result"])
|
||||||
|
|
||||||
|
return results
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [04-product-import.md](./04-product-import.md) —— 创建商品(发布前置)
|
||||||
|
- [05-product-info.md](./05-product-info.md) —— 查询商品信息(含库存)
|
||||||
|
- [10-prices.md](./10-prices.md) —— 价格更新
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §5 —— 发布链路(含库存设置)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Ozon Seller API 文档总览
|
||||||
|
|
||||||
|
> 本目录整理 Ozon Seller API 的核心接口文档,供 ozon-seller-kit 项目集成使用。
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| [01-authentication.md](./01-authentication.md) | 鉴权方式、请求头、错误码 |
|
||||||
|
| [02-category-tree.md](./02-category-tree.md) | 类目树查询 |
|
||||||
|
| [03-category-attributes.md](./03-category-attributes.md) | 类目属性与字典值 |
|
||||||
|
| [04-product-import.md](./04-product-import.md) | 商品导入(创建/更新) |
|
||||||
|
| [05-product-info.md](./05-product-info.md) | 商品信息查询 |
|
||||||
|
| [06-product-list.md](./06-product-list.md) | 商品列表 |
|
||||||
|
| [07-import-by-sku.md](./07-import-by-sku.md) | 跟卖(按 SKU 复制) |
|
||||||
|
| [08-pictures.md](./08-pictures.md) | 图片更新 |
|
||||||
|
| [09-stocks.md](./09-stocks.md) | 库存管理 |
|
||||||
|
| [10-prices.md](./10-prices.md) | 价格更新 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速索引
|
||||||
|
|
||||||
|
### 核心流程
|
||||||
|
|
||||||
|
**1. 发布新商品**:
|
||||||
|
```
|
||||||
|
① 获取类目树 → 选择类目 → 得 description_category_id + type_id
|
||||||
|
② 获取该类目属性 → 映射属性值
|
||||||
|
③ 组装 ImportProductsV3 请求体
|
||||||
|
④ POST /v3/product/import → 得 task_id
|
||||||
|
⑤ 轮询 POST /v1/product/import/info → 得 product_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 跟卖已有商品**:
|
||||||
|
```
|
||||||
|
① POST /v1/product/import-by-sku(传 sku + 基本信息)
|
||||||
|
② 轮询状态
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 更新商品**:
|
||||||
|
- 更新商品信息:复用 `/v3/product/import`(传 `product_id` 或 `offer_id`)
|
||||||
|
- 更新图片:`POST /v1/product/pictures/import`
|
||||||
|
- 更新价格:`POST /v1/product/import/prices`
|
||||||
|
- 更新库存:`POST /v2/products/stocks`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 基础信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| Base URL | `https://api-seller.ozon.ru` |
|
||||||
|
| 鉴权方式 | 请求头 `Client-Id` + `Api-Key` |
|
||||||
|
| 内容类型 | `application/json` |
|
||||||
|
| 超时建议 | 30s(常规)/ 90s(import/轮询) |
|
||||||
|
| 限流 | 官方未明确公开限流规则,建议控制在 10 req/s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
1. **类目选择**:只有末级类目(`disabled=false`)可建品
|
||||||
|
2. **必填字段**:`name/description/category/price/尺寸重量/offer_id/images` 必填且不能为 0
|
||||||
|
3. **图片 URL**:必须是 **https 公网直链**(http 会被拒绝)
|
||||||
|
4. **属性映射**:`is_required=true` 的属性必须填写
|
||||||
|
5. **异步任务**:`/v3/product/import` 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态
|
||||||
|
6. **库存必须设置**:`import` 成功后商品在后台,需设置库存才能上架
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码速查
|
||||||
|
|
||||||
|
| HTTP | 含义 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 400 | 参数错误 | 检查请求体字段 |
|
||||||
|
| 403 | 权限不足 | 检查 Api-Key 权限级别 |
|
||||||
|
| 404 | 资源不存在 | 检查 product_id/category_id |
|
||||||
|
| 409 | 冲突(如 offer_id 重复) | 改 offer_id 或走更新 |
|
||||||
|
| 429 | 限流 | 指数退避重试 |
|
||||||
|
| 500 | 服务端错误 | 重试或联系支持 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V2 项目集成清单
|
||||||
|
|
||||||
|
| 接口 | 用途 | 实现状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/v1/description-category/tree` | 类目树 | ✅ API 已建(categories.py) |
|
||||||
|
| `/v1/description-category/attribute` | 类目属性 | ✅ API 已建 |
|
||||||
|
| `/v1/description-category/attribute/values` | 属性值字典 | ✅ API 已建 |
|
||||||
|
| `/v3/product/import` | 商品导入 | ✅ API 已建(publish.py) |
|
||||||
|
| `/v1/product/import/info` | 导入状态 | ✅ API 已建 |
|
||||||
|
| `/v3/product/list` | 商品列表 | 🟡 待建 |
|
||||||
|
| `/v3/product/info/list` | 商品详情 | 🟡 待建 |
|
||||||
|
| `/v1/product/import-by-sku` | 跟卖 | 🟡 待建(二期) |
|
||||||
|
| `/v1/product/pictures/import` | 图片更新 | 🟡 待建(二期) |
|
||||||
|
| `/v2/products/stocks` | 库存 | 🟡 待建(二期) |
|
||||||
|
| `/v1/product/import/prices` | 价格 | 🟡 待建(二期) |
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Ozon Seller API 文档整理完成
|
||||||
|
|
||||||
|
已完成 Ozon Seller API 的核心接口文档整理,涵盖商品发布、管理的完整流程。
|
||||||
|
|
||||||
|
## 已完成的文档
|
||||||
|
|
||||||
|
### 核心文档(9 个)
|
||||||
|
|
||||||
|
1. **README.md** - 总览与快速索引
|
||||||
|
2. **01-authentication.md** - 鉴权方式、Base URL、错误码、限流
|
||||||
|
3. **02-category-tree.md** - 类目树查询(选择类目)
|
||||||
|
4. **03-category-attributes.md** - 类目属性与字典值(属性映射)
|
||||||
|
5. **04-product-import.md** - 商品导入/创建/更新(核心接口)
|
||||||
|
6. **05-product-info.md** - 商品详情查询(审核状态、图片、属性)
|
||||||
|
7. **06-product-list.md** - 商品列表分页(ID 收集、批量查询)
|
||||||
|
8. **09-stocks.md** - 库存管理(必须设置才能上架)
|
||||||
|
|
||||||
|
### 待补充(二期)
|
||||||
|
|
||||||
|
- **07-import-by-sku.md** - 跟卖(按 SKU 复制 PDP)
|
||||||
|
- **08-pictures.md** - 图片更新
|
||||||
|
- **10-prices.md** - 价格批量更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档特色
|
||||||
|
|
||||||
|
### 1. 完整的代码示例
|
||||||
|
- Python(服务端)示例
|
||||||
|
- TypeScript(前端)示例
|
||||||
|
- 实际可运行的代码片段
|
||||||
|
|
||||||
|
### 2. V2 项目集成指引
|
||||||
|
- 每个文档都包含"V2 项目集成"章节
|
||||||
|
- API 层实现示例(对齐 server/ 结构)
|
||||||
|
- 前端组件示例(对齐 studio/ 结构)
|
||||||
|
|
||||||
|
### 3. 最佳实践与常见问题
|
||||||
|
- 缓存策略
|
||||||
|
- 错误处理
|
||||||
|
- 性能优化
|
||||||
|
- 分页/批量操作
|
||||||
|
|
||||||
|
### 4. 实用场景
|
||||||
|
- 发布流程(端到端)
|
||||||
|
- 属性映射工作流
|
||||||
|
- 库存同步策略
|
||||||
|
- 审核状态检查
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心流程速查
|
||||||
|
|
||||||
|
### 完整发布流程
|
||||||
|
|
||||||
|
```
|
||||||
|
① 获取类目树 → 选择类目
|
||||||
|
POST /v1/description-category/tree
|
||||||
|
|
||||||
|
② 获取类目属性 → 映射属性
|
||||||
|
POST /v1/description-category/attribute
|
||||||
|
POST /v1/description-category/attribute/values/search
|
||||||
|
|
||||||
|
③ 组装请求体 → 发布商品
|
||||||
|
POST /v3/product/import → task_id
|
||||||
|
|
||||||
|
④ 轮询状态 → 获取 product_id
|
||||||
|
POST /v1/product/import/info → status=imported
|
||||||
|
|
||||||
|
⑤ 设置库存(必须)
|
||||||
|
POST /v2/products/stocks → 商品上架
|
||||||
|
|
||||||
|
⑥ 查询详情(可选)
|
||||||
|
POST /v3/product/info/list → 审核状态/图片/库存
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键约束总结
|
||||||
|
|
||||||
|
1. **类目**:只有末级类目(`disabled=false`)可建品
|
||||||
|
2. **必填字段**:name/description/category/price/尺寸重量/offer_id/images
|
||||||
|
3. **图片 URL**:必须 https 公网直链
|
||||||
|
4. **属性**:`is_required=true` 的必须填写
|
||||||
|
5. **异步任务**:`/import` 返回 task_id,需轮询状态
|
||||||
|
6. **库存必须设置**:不设置库存 = 商品不可购买
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 V2 项目的对应关系
|
||||||
|
|
||||||
|
| Ozon API | V2 后端 API | V2 前端页面 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/description-category/tree` | `/api/categories/tree` | CategoryPicker | ✅ 已建 |
|
||||||
|
| `/description-category/attribute` | `/api/categories/{id}/attributes` | AttributeMapper | ✅ 已建 |
|
||||||
|
| `/attribute/values` | `/api/categories/attribute/{id}/values` | - | ✅ 已建 |
|
||||||
|
| `/v3/product/import` | `/api/products/{id}/publish` | PublishPanel | ✅ 已建 |
|
||||||
|
| `/v1/product/import/info` | (background poll) | - | ✅ 已建 |
|
||||||
|
| `/v3/product/info/list` | `/api/products/{id}/ozon-detail` | OzonStatusBadge | 🟡 待建 |
|
||||||
|
| `/v3/product/list` | `/api/products/sync-from-ozon` | SyncButton | 🟡 待建 |
|
||||||
|
| `/v2/products/stocks` | `/api/products/{id}/set-stock` | StockPanel | 🟡 待建 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用建议
|
||||||
|
|
||||||
|
### 阅读顺序(新接入)
|
||||||
|
|
||||||
|
1. **01-authentication.md** - 了解鉴权与基础
|
||||||
|
2. **04-product-import.md** - 核心接口,先看这个
|
||||||
|
3. **02-category-tree.md** - 类目选择
|
||||||
|
4. **03-category-attributes.md** - 属性映射(难点)
|
||||||
|
5. **09-stocks.md** - 库存设置(必须)
|
||||||
|
6. 其他按需查阅
|
||||||
|
|
||||||
|
### 开发时查阅
|
||||||
|
|
||||||
|
- 看接口契约 → 查对应章节的"请求/响应"
|
||||||
|
- 看错误处理 → 查"常见错误"章节
|
||||||
|
- 看集成方式 → 查"V2 项目集成"章节
|
||||||
|
- 看最佳实践 → 查"使用场景"或"最佳实践"章节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
### 立即可用
|
||||||
|
现有 9 个文档已覆盖 V2 项目一期的所有核心接口,可立即用于:
|
||||||
|
- 服务端 `ozon_client.py` 开发
|
||||||
|
- API 端点实现参考
|
||||||
|
- 前端组件开发参考
|
||||||
|
|
||||||
|
### 二期补充
|
||||||
|
需要时再补充:
|
||||||
|
- 跟卖(import-by-sku)
|
||||||
|
- 图片单独更新
|
||||||
|
- 价格批量更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) - V2 发布集成方案(与本文档配套)
|
||||||
|
- [docs/v2/api.md](../v2/api.md) - V2 后端 API 设计
|
||||||
|
- [docs/v2/database.md](../v2/database.md) - V2 数据库设计
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# AI 图生图(wanx2.1-imageedit)
|
||||||
|
|
||||||
|
「发布工作台」(`studio/`)当前唯一的页面:上传多张图片 → 加水印(文字/图片)→ 对单张图片调用
|
||||||
|
万相 2.1 通用图像编辑模型做图生图。
|
||||||
|
|
||||||
|
## 目录与职责
|
||||||
|
|
||||||
|
| 位置 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `studio/src/pages/ai-image/` | 页面:上传、水印设置、预览网格、AI 弹窗 |
|
||||||
|
| `studio/src/pages/ai-image/components/WatermarkCanvas.tsx` | 单图可拖拽水印画布 |
|
||||||
|
| `studio/src/pages/ai-image/components/ImageEditModal.tsx` | 图生图弹窗(上:原图/生成对比;下:对话框) |
|
||||||
|
| `studio/src/utils/watermark.ts` | 水印绘制(移植自 `web/js/app.js`,坐标相对原图) |
|
||||||
|
| `studio/src/services/image.ts` | 前端调用后端 `/api/image/edit` |
|
||||||
|
| `server/api/image.py` | 路由 `POST /api/image/edit` |
|
||||||
|
| `server/services/image_edit.py` | 万相模型调用(同步 SDK 在线程池执行) |
|
||||||
|
| `server/schemas/image_edit.py` | 请求/响应 Pydantic 模型 |
|
||||||
|
|
||||||
|
## 接口契约
|
||||||
|
|
||||||
|
`POST /api/image/edit`
|
||||||
|
|
||||||
|
请求(JSON):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base_image": "data:image/png;base64,... | https://...",
|
||||||
|
"prompt": "把背景换成纯白色摄影棚",
|
||||||
|
"function": "description_edit",
|
||||||
|
"n": 1,
|
||||||
|
"size": "",
|
||||||
|
"seed": null,
|
||||||
|
"style": "",
|
||||||
|
"prompt_extend": true,
|
||||||
|
"strength": 0.8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `base_image`:前端把「已加水印」的当前图片以 data URL 传入(data URL 与公网 URL 二选一)。
|
||||||
|
- `function` 白名单:`description_edit`(文本指令编辑)、`description_edit_with_mask`(局部重绘,需 `mask_image`)、
|
||||||
|
`stylization_local`、`stylization_all`。默认 `description_edit`。
|
||||||
|
- `prompt_extend`:是否自动扩写提示词,官方示例默认开启(默认 `true`)。
|
||||||
|
- `strength`:修改幅度 0.0~1.0,官方默认 0.5(值越小越接近原图)。
|
||||||
|
**新增文字、大幅修改等场景建议调高到 0.8 左右**,否则模型可能因"贴近原图"而忽略新内容。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "6e319d88-...",
|
||||||
|
"results": [{ "url": "https://...oss-cn-wulanchabu...png?...", "image_base64": "data:image/png;base64,..." }],
|
||||||
|
"image_count": 1,
|
||||||
|
"request_id": "dc41682c-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `url` 有效期 24 小时。`image_base64` 为服务端代下载的 data URL,前端可据此直接合成/导出,
|
||||||
|
> 规避阿里云结果 URL 未开放 CORS 导致的画布污染(无法 toDataURL)。
|
||||||
|
|
||||||
|
## 密钥与地域
|
||||||
|
|
||||||
|
- 模型:`wanx2.1-imageedit`,仅支持**华北 2(北京)**地域的 API Key。
|
||||||
|
- 密钥:`.env` 中的 `DASHSCOPE_API_KEY`。
|
||||||
|
- 业务空间调用时,另配 `DASHSCOPE_BASE_HTTP_API_URL=https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1`;
|
||||||
|
普通 API Key 调用留空。
|
||||||
|
|
||||||
|
## 关于图片存储
|
||||||
|
|
||||||
|
当前服务端**不做图片存储**:前端直接把 base64 图片传给模型,结果 URL 原样返回给前端展示。
|
||||||
|
后续接入七牛云时,可在 `services/image_edit.py` 拿到 `results[].url` 后增加「下载 → 转存七牛 → 返回新 URL」一步,
|
||||||
|
api 层与前端契约无需改动。
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Ozon Seller Kit V2 方案总览
|
||||||
|
|
||||||
|
> 状态:方案设计(待确认)
|
||||||
|
> 最后更新:2026-08-14
|
||||||
|
> 定位:本文是 V2 全部设计文档的入口与决策总表。先读本文,再按需读分册。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 一句话定位
|
||||||
|
|
||||||
|
V2 把 Ozon Seller Kit 从「**本地文件夹 + 单机工具**」升级为「**云端数据库 + 多店铺工作台**」:
|
||||||
|
|
||||||
|
```
|
||||||
|
V1(现状) V2(目标)
|
||||||
|
插件 ──写本地文件夹──> studio 读 插件 ──上传──> 服务端落库(采集箱)
|
||||||
|
用户 ──> studio 工作台:看采集箱 → 编辑 → 发布
|
||||||
|
服务端 ──> Ozon Seller API(多店铺)
|
||||||
|
发布结果落库 → 支持 CSV 导出
|
||||||
|
```
|
||||||
|
|
||||||
|
核心变化只有一条:**契约真源从「磁盘上的商品文件夹」换成「数据库 + 七牛对象存储」**。本地文件夹不再承载主流程,降级为可选的导入/导出格式。
|
||||||
|
|
||||||
|
这是原架构文档(`docs/architecture.md` §7)里早已规划的 **S4 阶段**:商品库落库,本地文件夹降级。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 现状盘点(V1 资产)
|
||||||
|
|
||||||
|
| 部分 | 现状 | V2 处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| `web/` 工具台 v1 | ✅ 在用(计价/登记/水印/俄文文案),冻结 | **只读**。计价公式、水印算法、文案交互被抄进 studio,不改原文件 |
|
||||||
|
| `extension-v1` | 1688/淘宝采集(SSR/DOM) | 保留为素材补充来源(二期) |
|
||||||
|
| `extension-v2` | Ozon 采集,**写本地文件夹**(File System Access) | 改造成**上传服务端落库**,删除本地写盘主路径 |
|
||||||
|
| `studio/` | React + antd,**仅「AI 图生图」一页**(wanx2.1-imageedit) | 扩为**多页工作台**:采集箱 / 商品编辑 / 发布 / 店铺 / 导出 |
|
||||||
|
| `server/` | FastAPI,无 DB,无 Ozon 对接;有 `/api/ai/*`、`/api/image/edit` | 加 DB + 七牛 + Ozon 对接 + 鉴权 + 异步任务 |
|
||||||
|
|
||||||
|
现状能力与可复用清单详见 [`capability-inventory.md`](./capability-inventory.md)(V2 设计输入稿)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. V2 决策总表(D 系列)
|
||||||
|
|
||||||
|
| 编号 | 决策 | 内容 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D1** | 契约云端化 | 商品数据落 **PostgreSQL**,图片落 **七牛**;本地文件夹降级为导入/导出格式 | 多设备、多店铺、可发布、可导出,单机文件夹做不到 |
|
||||||
|
| **D2** | 插件上传 | 插件采集结果走 `POST /api/materials` 上传落库,不再写本地 | 复用 `docs/extension/plan.md` §14 已定好的契约 |
|
||||||
|
| **D3** | 数据库选型 | PostgreSQL 16(腾讯云 CDB)+ SQLAlchemy 2.0 + Alembic | 单库覆盖结构化字段 + JSONB(attributes/raw),运维成熟 |
|
||||||
|
| **D4** | 图片存储 | 七牛云:源图由服务端代下转存,生成图也转存;Ozon 发布用七牛公网 URL | Ozon `images` 只收公网 URL(见 `architecture.md` §4) |
|
||||||
|
| **D5** | 图片方案 | **方案 B(高低搭配)✅ 已拍板**:集成 ecommerce-image-suite「电商套图」+ 保留 wanx2.1-imageedit(改名「智能修图」) | 二者是不同能力、共用 DASHSCOPE Key,互补不互斥;详见 [`image-strategy.md`](./image-strategy.md) |
|
||||||
|
| **D6** | 工作台化 | studio 从单页扩为:采集箱 → 商品编辑(计价+文案+图片+类目/属性)→ 发布 → 店铺 → 导出 | 对齐「采集 → 编辑 → 发布」主链路 |
|
||||||
|
| **D7** | 鉴权 | MVP 用长期 Bearer Token(单用户自用);预留 `users` 表升级多用户 | 自用阶段不做 OAuth,与插件 options 页一致 |
|
||||||
|
| **D8** | 部署 | 腾讯云:FastAPI + nginx + PostgreSQL + 七牛;studio 静态托管;插件/前端指向公网后端 | 用户明确要部署腾讯云 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据流(端到端)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 浏览 Ozon 竞品页(或 1688/淘宝补素材)
|
||||||
|
│ 点插件 → 侧边栏 → 采集 → 勾选
|
||||||
|
▼
|
||||||
|
② 插件 POST /api/materials 上传(texts + images 的 URL + source)
|
||||||
|
│ 服务端立即落库为「采集箱商品」,异步排队下载源图 → 转存七牛
|
||||||
|
▼
|
||||||
|
③ studio「采集箱」列表:查看/筛选/删除商品
|
||||||
|
│ 进入商品编辑页
|
||||||
|
▼
|
||||||
|
④ 编辑:offer_id / 计价 / 俄文文案 / 图片(水印·智能修图·套图)/ 类目选择 / 属性映射
|
||||||
|
│ 每一步落库(Draft),可随时回来继续
|
||||||
|
▼
|
||||||
|
⑤ 发布:绑定店铺 → 服务端组装 ImportProductsV3 items[0] → POST /v3/product/import
|
||||||
|
│ 轮询 /v1/product/import/info 直到 imported / moderation / failed
|
||||||
|
▼
|
||||||
|
⑥ 落库:ozon_product_id / product_id / 状态 / 发布任务记录
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
⑦ CSV 导出:采集箱 + 已发布商品的字段导出(含 product_id 回填)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 分册索引
|
||||||
|
|
||||||
|
| 文档 | 内容 | 什么时候读 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`architecture.md`](./architecture.md) | V2 总体架构:组件、技术栈、目录、鉴权、部署拓扑 | 先读这个,建立全局 |
|
||||||
|
| [`database.md`](./database.md) | PostgreSQL 表结构(按 Ozon 字段 + 采集/编辑/发布/店铺维度) | 做数据层时读 |
|
||||||
|
| [`api.md`](./api.md) | 后端 REST 接口契约(采集入库 / 商品 / 类目 / 店铺 / 发布 / 导出 / 图片 / 汇率) | 前后端联调时读 |
|
||||||
|
| [`image-strategy.md`](./image-strategy.md) | 图片处理:方案 A/B 对比、推荐、七牛存储、套图集成方式 | 图片这块没想明白时读 |
|
||||||
|
| [`ozon-publish.md`](./ozon-publish.md) | Ozon Seller API 集成:鉴权、店铺绑定、类目/属性、发布、任务状态、CSV 导出字段 | 做发布链路时读 |
|
||||||
|
| [`migration.md`](./migration.md) | 分阶段落地计划、改动点清单、风险 | 开工前读 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与 V1 文档的关系
|
||||||
|
|
||||||
|
- `docs/architecture.md`(V1 总架构)仍有效,V2 是其 **S4 阶段**的具体化;S1–S3 的结论(后端收进 `server/`、契约对齐 ImportProductsV3、`_` 前缀剥离、图床公网 URL 硬约束)全部沿用。
|
||||||
|
- `docs/contracts/product-json.md` 的**字段结构**在 V2 成为 `products` 表 + `product_assets` 表的设计蓝本;「文件夹」语义换成「商品记录」。
|
||||||
|
- `docs/extension/plan.md` §9/§13/§14 的**消息层、`/api/materials` 契约、鉴权、重试队列**在 V2 原样采纳,只把「写文件夹」换成「上传落库」。
|
||||||
|
- 若存在分歧,以 `docs/v2/` 为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 关键风险(先立 flag,细节见 migration.md)
|
||||||
|
|
||||||
|
| 风险 | 级别 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| Ozon 类目/属性字典大且需实时性 | 🟡 中 | 服务端缓存 + 按类目按需拉取,见 [`ozon-publish.md`](./ozon-publish.md) §3 |
|
||||||
|
| 采集属性 → Ozon 属性 id 的映射工作量大 | 🔴 高 | 自动匹配 + 人工确认 UI,见 `ozon-publish.md` §4 |
|
||||||
|
| ecommerce-image-suite 是「脚本+Skill」形态,非服务 | 🟡 中 | 把 generate.py 的 prompt 引擎抽成服务端能力,见 `image-strategy.md` §5 |
|
||||||
|
| 发布是异步(task_id 轮询),用户不能干等 | 🟡 中 | 任务表 + 轮询 + 状态回显,见 `ozon-publish.md` §5 |
|
||||||
|
| 密钥落库(店铺 Client-Id/Api-Key) | 🔴 高 | 服务端 AES 加密存储,前端永不回显明文,见 `database.md` §2.6 |
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
# V2 后端 API 设计
|
||||||
|
|
||||||
|
> 状态:方案设计(待确认)
|
||||||
|
> 上游:[V2 总览](./README.md) · [V2 架构](./architecture.md) · [数据库](./database.md)
|
||||||
|
|
||||||
|
统一约定:
|
||||||
|
- 前缀 `/api`;鉴权 `Authorization: Bearer <JWT>`(除 `/auth/login`、`/health` 外)。
|
||||||
|
- 错误:FastAPI 语义状态码;`detail` 为可读中文;校验错误 `422`。
|
||||||
|
- 列表分页:`?page=&page_size=` 或 `?limit=&last_id=`(Ozon 风格,仅对代理 Ozon 的接口)。
|
||||||
|
- 时间一律 ISO 8601 UTC。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 鉴权 `/api/auth`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/auth/login` | `{token}` → `{access_token, expires_at}`。MVP:token 等于 `.env` 的 `APP_TOKEN`;升级后:用户名密码 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 采集入库 `/api/materials`(插件 → 服务端)
|
||||||
|
|
||||||
|
沿用 `docs/extension/plan.md` §14 的契约,**把「文件夹」换成「商品」**。
|
||||||
|
|
||||||
|
### `POST /api/materials` ★ 主接口
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"product": { // 对应「文件夹」;首次上传可空=新建
|
||||||
|
"id": "uuid-or-null" // 传了=追加到已有商品(跨平台补素材)
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"platform": "ozon", // ozon | 1688 | taobao
|
||||||
|
"itemId": "123456789",
|
||||||
|
"url": "https://www.ozon.ru/product/…",
|
||||||
|
"collectedAt": 1710000000000
|
||||||
|
},
|
||||||
|
"texts": [
|
||||||
|
{ "kind": "title", "content": "…" },
|
||||||
|
{ "kind": "params", "content": "…", "pairs": [{ "key": "…", "value": "…" }] }
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
{ "groupKey": "main", "groupName": "主图", "variantName": null,
|
||||||
|
"url": "https://…原图…", "index": 0, "type": "img", "dedupeKey": "…" }
|
||||||
|
],
|
||||||
|
"refererOrigin": "https://www.ozon.ru" // 该站图片下载需带的 Referer
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "product": { "id": "…", "stage": "collected" }, "assetsQueued": 12 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**语义**:服务端立即落库(product + texts 进 raw + assets 置 `pending`),**响应不等图片下载完成**;后台协程按 `refererOrigin` 下载源图 → 转存七牛 → 更新 asset 状态。插件无需等待。
|
||||||
|
|
||||||
|
### 其余
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/materials/bytes` | 字节兜底:`multipart/form-data`(`product_id` + `meta`(JSON) + `file`),供源图带登录态、插件在页面上下文抓字节上传 |
|
||||||
|
| GET | `/products/:id/fingerprints` | 跨页去重:返回已采集 `dedupeKey` 列表 |
|
||||||
|
| GET | `/collected?platform=&itemId=` | 状态回显:`{collected, count}`(页面徽标用) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 商品 `/api/products`
|
||||||
|
|
||||||
|
### 列表(采集箱 / 发布列表)
|
||||||
|
|
||||||
|
`GET /api/products?stage=&q=&page=&page_size=`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"total": 128,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "…", "stage": "collected",
|
||||||
|
"name": "…", "offer_id": "", "price": null,
|
||||||
|
"source_platform": "ozon", "source_url": "…",
|
||||||
|
"asset_count": { "main": 6, "sku": 4, "detail": 9 },
|
||||||
|
"ozon_product_id": null,
|
||||||
|
"created_at": "…", "updated_at": "…"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 详情 / 编辑 / 删除
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/products/:id` | 完整商品(含 raw / pricing / copy / attributes / assets) |
|
||||||
|
| PATCH | `/products/:id` | 部分更新(编辑页 autosave)。可改字段见 database.md §2.3 |
|
||||||
|
| POST | `/products` | 手动新建商品(不经过插件) |
|
||||||
|
| DELETE | `/products/:id` | 软删(stage→archived)或硬删(采集箱未发布项) |
|
||||||
|
| POST | `/products/:id/stage` | `{stage}` 流转(editing→ready 前校验必填项) |
|
||||||
|
|
||||||
|
> 编辑页的计价、文案、图片、类目/属性都是「编辑 `products` 的某几列」,统一走 `PATCH /products/:id` 或细分子资源(见 §6–§8),不新增独立存储。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 汇率 `/api/fx`
|
||||||
|
|
||||||
|
`GET /api/fx` → `{ "rate": 11.84, "source": "cbr", "updated_at": "…" }`
|
||||||
|
|
||||||
|
服务端抓取(FloatRates → 俄央行 → er-api 三级降级,沿用 v1 数据源但**移到服务端**),缓存 + 脏数据过滤(5~25 区间)。前端计价用它,也可在计价时快照进 `products.fx_rate`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. AI 文案 `/api/ai`(沿用现有,零改动)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/ai/models` | 模型列表(models.yaml) |
|
||||||
|
| POST | `/ai/copy` | 中文采买信息 → 俄文标题/描述/标签 + 中文对照 |
|
||||||
|
|
||||||
|
V2 编辑页的「文案」面板直接消费这两个接口,生成结果写入 `products.copy` + `products.name/description`(用户确认后)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 图片 `/api/image`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/image/edit` | **智能修图**(wanx2.1-imageedit,改名,沿用现有)——单图换背景/去水印/局部重绘/加文字 |
|
||||||
|
| POST | `/image/suite` | **电商套图**(集成 ecommerce-image-suite,新增)——原图 + 卖点 → 套图 |
|
||||||
|
| POST | `/image/upload-token` | 获取七牛直传 token(若走前端直传;一期可省,走服务端中转) |
|
||||||
|
|
||||||
|
`/image/edit` 与 `/image/suite` 的结果图由服务端**下载 → 转存七牛 → 返回七牛 URL**(改造现状 image_edit.py 已预留的扩展点)。
|
||||||
|
|
||||||
|
详见 [`image-strategy.md`](./image-strategy.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 店铺 `/api/shops`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/shops` | 列表(脱敏:不返回 key) |
|
||||||
|
| POST | `/shops` | `{name, client_id, api_key, currency_code}` 绑定(加密落库) |
|
||||||
|
| PATCH | `/shops/:id` | 更新(可只更新 name/currency,或换 key) |
|
||||||
|
| DELETE | `/shops/:id` | 删除(级联校验是否有进行中发布) |
|
||||||
|
| POST | `/shops/:id/test` | **连通性校验**:用该店铺凭证调 Ozon `/v1/roles`,成功→`{ok:true, roles:[…]}`,失败→`status=invalid` 并返回原因 |
|
||||||
|
|
||||||
|
> `test` 用 `/v1/roles`(返回该 key 的角色与方法权限),既验凭证又验权限范围,成本为零。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 类目与属性 `/api/categories`(服务端代理 Ozon)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/categories/tree?lang=RU` | 类目树(代理 `/v1/description-category/tree`,服务端缓存) |
|
||||||
|
| GET | `/categories/:category_id/attributes?type_id=` | 属性列表(代理 `/v1/description-category/attribute`) |
|
||||||
|
| GET | `/categories/attribute/:attribute_id/values?category_id=&type_id=&q=` | 属性值字典(代理 `/values` 或 `/values/search` 按需) |
|
||||||
|
|
||||||
|
> 这些接口需要店铺凭证(Client-Id/Api-Key)。请求体里带 `shop_id`,服务端用对应店铺凭证调 Ozon。类目树可全局缓存(与店铺无关);属性/值按类目缓存。详见 `ozon-publish.md` §3。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 发布 `/api/publish`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/products/:id/publish` | `{shop_id}` 发布:组装 items[0] → `/v3/product/import` → 建 `publish_tasks` → 返回 `{task_id}` |
|
||||||
|
| GET | `/publish/:taskId` | 查询发布任务状态(服务端已轮询回写,直接读库) |
|
||||||
|
| GET | `/products/:id/publish-history` | 该商品历史发布记录 |
|
||||||
|
|
||||||
|
**发布请求体组装**(服务端职责,见 `ozon-publish.md` §5):
|
||||||
|
1. 取 `products` 平铺字段 + `attributes`/`complex_attributes`;
|
||||||
|
2. 剥离 `_` 前缀扩展字段(DB 里已天然分层,无需剥离);
|
||||||
|
3. 校验必填(name/description/category/尺寸重量/offer_id/images);
|
||||||
|
4. `POST /v3/product/import`(头 `Client-Id`/`Api-Key`)→ 得 `task_id`;
|
||||||
|
5. 后台轮询 `POST /v1/product/import/info` → 回写 `products.stage` + `ozon_product_id`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. CSV 导出 `/api/export`
|
||||||
|
|
||||||
|
`GET /api/export/products.csv?stage=&ids=` → 流式返回带 BOM 的 UTF-8 CSV。
|
||||||
|
|
||||||
|
字段(默认全量,`fields=` 可指定子集):
|
||||||
|
|
||||||
|
```
|
||||||
|
offer_id, product_id, name, description_category_id, price, old_price,
|
||||||
|
currency_code, vat, weight, weight_unit, depth, width, height, dimension_unit,
|
||||||
|
barcode, primary_image, images(join "|"), source_platform, source_item_id, source_url,
|
||||||
|
stage, published_at, created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
已发布商品含 `product_id`;未发布留空。支持按 `stage`(collected/published/全部)与 `ids`(勾选导出)筛选。CSV 字段明细与公式见 [`ozon-publish.md`](./ozon-publish.md) §6。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 插件运维接口(沿用 plan.md §14)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/ext/profiles` | 远程采集配置下发(选择器热更) |
|
||||||
|
| POST | `/ext/logs` | 埋点批量上报(抗改版看板) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 接口 → 现有代码映射(改造量)
|
||||||
|
|
||||||
|
| V2 接口 | 现状 | 改造 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/auth/*` | 无 | 新建 |
|
||||||
|
| `/materials` 系列 | 无(plan 里有设计) | 新建 |
|
||||||
|
| `/products` 系列 | 无 | 新建 |
|
||||||
|
| `/fx` | 无(v1 前端直连第三方) | 新建(搬 v1 数据源到服务端) |
|
||||||
|
| `/ai/*` | ✅ 有 | 复用 |
|
||||||
|
| `/image/edit` | ✅ 有 | 复用 + 加七牛转存 |
|
||||||
|
| `/image/suite` | 无 | 新建(集成 ecommerce-image-suite) |
|
||||||
|
| `/shops`、`/categories`、`/publish`、`/export` | 无(`api/ozon.py` 占位) | 新建 |
|
||||||
|
| `/ext/*` | 无 | 新建 |
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# V2 总体架构
|
||||||
|
|
||||||
|
> 状态:方案设计(待确认)
|
||||||
|
> 上游:[V2 总览](./README.md) · V1 [`architecture.md`](../architecture.md)
|
||||||
|
> 前置阅读:建议先读 [`capability-inventory.md`](./capability-inventory.md) 了解现状资产。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 组件与职责
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────┐ Bearer Token ┌──────────────────────────────────────────────┐
|
||||||
|
│ Chrome 插件 │ ────────────────▶ │ 服务端 server/(FastAPI) │
|
||||||
|
│ extension-v2 │ POST /api/materials│ │
|
||||||
|
│ (Ozon/1688 采集)│ │ ├─ api/ 采集/商品/类目/店铺/发布/图片/汇率 │
|
||||||
|
└───────────────┘ │ ├─ services/ Ozon / DeepSeek / 套图 / 七牛 │
|
||||||
|
▲ │ ├─ models/ SQLAlchemy ORM + Alembic │
|
||||||
|
│ 远程配置 / 埋点 │ └─ jobs/ 异步:图下载转存 / 发布轮询 │
|
||||||
|
┌───────┴───────┐ │ │
|
||||||
|
│ 采集配置文件 │ └──────┬───────────────┬───────────────┬────────┘
|
||||||
|
│(可热更) │ │ │ │
|
||||||
|
└───────────────┘ ┌──────────▼──┐ ┌───────▼────────┐ ┌─────▼─────────┐
|
||||||
|
│ PostgreSQL │ │ 七牛对象存储 │ │ Ozon Seller API│
|
||||||
|
│ 商品/店铺/任务│ │ 源图/生成图/水印 │ │ Client-Id+Api-Key│
|
||||||
|
└─────────────┘ └────────────────┘ └───────────────┘
|
||||||
|
▲
|
||||||
|
│ REST /api/*
|
||||||
|
┌─────────────┴──────────────┐
|
||||||
|
│ studio/(React + Vite + antd)│
|
||||||
|
│ 采集箱 / 商品编辑 / 发布 / 店铺 / 导出 │
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
四个部分与 V1 一致(插件 / studio / server / 采集配置),但 **studio 的职责显著扩大**(从单页图生图 → 完整工作台),**server 从无状态代理 → 有状态业务中枢**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心边界(延续 V1,补充 V2)
|
||||||
|
|
||||||
|
1. **密钥只放服务端**:DeepSeek / DASHSCOPE / 七牛 / Ozon 店铺 Client-Id+Api-Key 全部只在 server 侧;插件和 studio 只持有一个长期 Bearer Token。
|
||||||
|
2. **插件仍做纯采集**:不调 LLM、不做图片处理、不碰 Ozon API;只是把「写本地文件夹」换成「上传落库」。
|
||||||
|
3. **服务端是唯一出网口(对 Ozon/云厂商)**:插件 background 与服务端通信,studio 与服务端通信;谁都不直连 Ozon。
|
||||||
|
4. **商品数据单点真源 = `products` 表**:`_` 前缀的本地扩展字段(`_raw`/`_pricing`/`_images`)仍保留在 JSONB 里,提交 Ozon 前按 V1 契约剥离。
|
||||||
|
5. **图片一律七牛公网 URL**:数据库里存七牛 URL,不存本地路径、不存源站 URL(源站 URL 仅存 `product_assets.source_url` 做溯源)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术栈
|
||||||
|
|
||||||
|
| 部分 | 技术栈 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| server | FastAPI + SQLAlchemy 2.0(async)+ Alembic + httpx | 沿用现状 FastAPI;ORM 用 SQLAlchemy 2.0 async |
|
||||||
|
| DB | PostgreSQL 16(腾讯云 CDB) | JSONB 存 attributes/raw/pricing |
|
||||||
|
| 任务 | 轻量:先 DB 轮询 + asyncio 后台任务;量大再上 Redis/Celery | 图下载转存、发布轮询都是 IO 密集 |
|
||||||
|
| 对象存储 | 七牛云 Kodo | 源图转存 + 生成图 + 水印结果 |
|
||||||
|
| 缓存 | 类目/属性字典 → PostgreSQL 表 + 内存 LRU;可选 Redis | 见 `ozon-publish.md` §3 |
|
||||||
|
| studio | React 19 + Vite 7 + antd 6 + react-router 7 | 沿用现状;加 react-query 或 zustand 管状态 |
|
||||||
|
| extension | WXT + React + TS | 沿用;改造 export → upload |
|
||||||
|
| 鉴权 | JWT(短期)+ Bearer Token;MVP 单用户,预留 `users` 表 | 见 §6 |
|
||||||
|
|
||||||
|
**与 V1 的差异**:唯一新增重依赖是 **SQLAlchemy + Alembic** 和 **七牛 SDK(qiniu)**。任务队列一期不引入 Redis/Celery,用「DB 状态机 + 后台协程」即可(单人自用规模)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 目录结构(目标)
|
||||||
|
|
||||||
|
```
|
||||||
|
ozon-seller-kit/
|
||||||
|
├── server/
|
||||||
|
│ ├── main.py # 应用入口,挂载路由 + studio 静态
|
||||||
|
│ ├── api/ # 按域拆:collection / products / categories /
|
||||||
|
│ │ │ # shops / publish / export / image / ai / fx / auth
|
||||||
|
│ ├── services/ # ozon_client / deepseek / image_suite / qiniu / pricing
|
||||||
|
│ ├── models/ # SQLAlchemy 模型(见 database.md)
|
||||||
|
│ ├── schemas/ # Pydantic(接口契约真源)
|
||||||
|
│ ├── jobs/ # 后台协程:下载转存 / 发布轮询
|
||||||
|
│ ├── migrations/ # Alembic
|
||||||
|
│ └── config/ # settings.py + models.yaml(沿用)
|
||||||
|
├── studio/ # 工作台(多页)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── collection/ # 采集箱列表
|
||||||
|
│ │ ├── product/ # 商品编辑(核心)
|
||||||
|
│ │ │ └── components/ # PricingPanel / CopyPanel / ImagePanel /
|
||||||
|
│ │ │ # CategoryPicker / AttributeMapper / PublishPanel
|
||||||
|
│ │ ├── publish/ # 发布任务 / 状态
|
||||||
|
│ │ ├── shops/ # 店铺管理(Client-Id / Api-Key)
|
||||||
|
│ │ ├── export/ # CSV 导出
|
||||||
|
│ │ └── ai-image/ # 保留:智能修图(wanx2.1-imageedit,改名)
|
||||||
|
│ ├── services/ # 与 /api/* 对齐的客户端
|
||||||
|
│ ├── stores/ # zustand:商品编辑态 / 采集箱筛选
|
||||||
|
│ └── pricing/ # 从 v1 抄来的计价纯函数(不改原文件)
|
||||||
|
├── extension-v2/ # 采集插件(改造:上传落库)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── messaging/ # 消息层(plan.md §9)
|
||||||
|
│ ├── api/ # 后端客户端(仅 background)
|
||||||
|
│ └── collector/ profiles/ # 沿用采集引擎
|
||||||
|
├── web/ # v1 工具台,冻结
|
||||||
|
├── docs/
|
||||||
|
│ ├── v2/ # ★ 本文档集
|
||||||
|
│ └── ...(V1 文档)
|
||||||
|
└── .env / .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 状态机:商品生命周期
|
||||||
|
|
||||||
|
V1 是 `collected → edited → published`。V2 因为「落库 + 异步发布」,扩展为:
|
||||||
|
|
||||||
|
```
|
||||||
|
collected ──(进入编辑)──> editing ──(填写完整)──> ready ──(点发布)──> publishing
|
||||||
|
│
|
||||||
|
┌───────────────────────────────────────┤
|
||||||
|
▼ ▼
|
||||||
|
imported(成功) failed(失败,可改后重发)
|
||||||
|
│ ▲
|
||||||
|
└────── published ──(可归档)──> archived ─┘
|
||||||
|
|
||||||
|
collected 插件刚上传,只有素材与原文
|
||||||
|
editing 用户正在编辑(计价/文案/图片/类目)
|
||||||
|
ready 必填项齐全,可发布
|
||||||
|
publishing 已提交 ImportProductsV3,拿到 task_id,等待轮询
|
||||||
|
published 轮询 imported 成功,回填 product_id
|
||||||
|
failed 轮询返回 errors / 校验失败;可回到 editing 修复后重发
|
||||||
|
archived 手动归档(软删)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每步都落库,刷新/换设备不丢。
|
||||||
|
- `publishing` 由发布任务表(`publish_tasks`)驱动,服务端轮询 `/v1/product/import/info` 更新状态。
|
||||||
|
- 状态定义详见 [`database.md`](./database.md) §2.2。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 鉴权与多租户
|
||||||
|
|
||||||
|
**MVP(单人自用)**:`.env` 里配一个 `APP_TOKEN`,插件 options 页和 studio 登录页填同一个值,请求头 `Authorization: Bearer <APP_TOKEN>`。服务端校验后签发短期 JWT,后续请求用 JWT。
|
||||||
|
|
||||||
|
**预留升级路径(不影响 MVP)**:`users` 表 + `shops.user_id` 已留好外键,未来要做多用户 SaaS 只需补注册/登录 + 按 `user_id` 过滤查询,schema 不用改。
|
||||||
|
|
||||||
|
| 层 | MVP | 升级 |
|
||||||
|
|---|---|---|
|
||||||
|
| 身份 | 单个 `APP_TOKEN` | `users` 表 + 密码哈希 |
|
||||||
|
| 会话 | 短期 JWT(`Authorization: Bearer`) | 同左,加刷新令牌 |
|
||||||
|
| 店铺归属 | 全部归当前用户 | 按 `user_id` 隔离 |
|
||||||
|
| 密钥保护 | 店铺 Api-Key 服务端 AES-GCM 加密落库 | 同左 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 部署拓扑(腾讯云)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────── nginx (443) ──────────────┐
|
||||||
|
│ /api/* → uvicorn (127.0.0.1:8800) │
|
||||||
|
浏览器/插件 ─────▶ │ / → studio 静态资源(构建产物)│
|
||||||
|
│ /ozonSeller.html → web/(v1,可选保留) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┼─────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
PostgreSQL(CDB) 七牛 Kodo(对象存储) 外部 API(Ozon/DeepSeek/DashScope)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **单进程部署**:FastAPI 同源托管 studio 构建产物(与 V1 托管 web/ 同思路),`/api` 走 nginx 反代到 uvicorn。
|
||||||
|
- **环境变量**:`.env` 在服务器上维护(不入 git),新增 `APP_TOKEN` / `DATABASE_URL` / `QINIU_*` / `APP_BASE_URL`。
|
||||||
|
- **CORS**:同源托管时 `CORS_ORIGINS` 留空;开发期 studio 跑 8900 时用 Vite 代理 `/api`,无需 CORS。
|
||||||
|
- 详见 [`migration.md`](./migration.md) §5。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 与 V1 的差异小结
|
||||||
|
|
||||||
|
| 维度 | V1 | V2 |
|
||||||
|
|---|---|---|
|
||||||
|
| 契约真源 | 磁盘「商品文件夹」 | `products` 表 + 七牛 |
|
||||||
|
| 插件出口 | File System Access 写盘 | `POST /api/materials` 落库 |
|
||||||
|
| studio | 单页图生图 | 多页工作台 |
|
||||||
|
| server | 无状态代理(ai/image) | 有状态业务中枢(DB/七牛/Ozon/任务) |
|
||||||
|
| 发布 | 预留 `/api/ozon/*` 占位 | 完整发布链路 + 任务轮询 |
|
||||||
|
| 图片 | 图生图(wanx2.1)+ 前端水印 | 智能修图 + 电商套图 + 七牛托管 |
|
||||||
|
| 数据导出 | v1 登记表 CSV(前端) | 服务端统一 CSV 导出 |
|
||||||
|
| 部署 | 本机 127.0.0.1 | 腾讯云公网 |
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# Ozon Seller Kit 现有能力清单(V2 方案设计输入)
|
||||||
|
|
||||||
|
> 依据代码逐文件核对生成(server/ 与 studio/,忽略 node_modules/.venv/__pycache__/.output)。
|
||||||
|
> 数据来源文件:`ozon-seller-kit/server/**` 与 `ozon-seller-kit/studio/src/**`,全部行号以当前工作区为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、后端 API 清单
|
||||||
|
|
||||||
|
FastAPI 应用入口 `server/main.py`:
|
||||||
|
|
||||||
|
- 应用名 `Ozon Seller Kit` v0.1.0(main.py:13)。
|
||||||
|
- CORS 中间件:仅当 `cors_origin_list` 非空时启用,`allow_credentials=True`,方法/请求头全放行(main.py:16-23)。
|
||||||
|
- 挂载三个路由:`api/ai`、`api/image`、`api/ozon`(main.py:25-27)。
|
||||||
|
- `GET /api/health` → `{"status":"ok"}`(main.py:30-32)。
|
||||||
|
- 若仓库根 `web/`(v1 工具台,冻结)存在,则 `app.mount("/", StaticFiles(html=True))` 静态托管(main.py:11, 35-36)——即生产形态下 FastAPI 同源托管前端。
|
||||||
|
|
||||||
|
| 方法 | 路径 | 功能 | 关键入参 | 关键出参 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GET | `/api/health` | 健康检查(main.py:30-32) | 无 | `{status: "ok"}` |
|
||||||
|
| GET | `/api/ai/models` | 列出可用模型(ai.py:10-12 → models_catalog.list_model_options) | 无 | `{default: str, models: [{id, label}]}` |
|
||||||
|
| POST | `/api/ai/copy` | 生成 Ozon 俄文商品文案(ai.py:15-17 → deepseek.generate_copy) | `CopyRequest`:`source_text`(≥10字符)、`product_name`、`model_code`、`model`(可选) | `CopyResponse`:`titles_ru/zh[2]`、`description_ru/zh`、`tags_ru/zh`、`model`、`usage{prompt_tokens,completion_tokens}` |
|
||||||
|
| POST | `/api/image/edit` | AI 图生图/图像编辑(image.py:9-11 → image_edit.edit_image) | `ImageEditRequest`:`base_image`(dataURL/公网URL)、`prompt`、`model`(白名单)、`mask_image?`、`function?`、`n`(1-4)、`size?`、`seed?`、`style?`、`prompt_extend`、`strength?` | `ImageEditResponse`:`task_id`、`results[{url(24h), image_base64(dataURL)}]`、`image_count`、`request_id` |
|
||||||
|
| — | `/api/ozon/*` | **空占位**(ozon.py:3-5):仅定义前缀与注释 `# Phase 3: Ozon Seller API product upload`,无任何端点 | — | — |
|
||||||
|
|
||||||
|
前端调用面:`studio/src/services/image.ts` 只调 `/api/image/edit`;`/api/ai/*` 目前只有 v1 工具台 `web/js/ai-copy.js`(233 行 models、299 行 copy,用原生 fetch)在消费。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、配置与模型目录机制
|
||||||
|
|
||||||
|
### 2.1 环境变量(server/config/settings.py + 仓库根 `.env`)
|
||||||
|
|
||||||
|
- `load_dotenv` 与 pydantic-settings 均指向仓库根 `.env`(settings.py:8-9, 16-17,`parents[2]` 上跳两级)。
|
||||||
|
- 字段(settings.py:21-29):
|
||||||
|
- `deepseek_api_key` / `openai_api_key` / `dashscope_api_key`
|
||||||
|
- `dashscope_base_http_api_url`(华北2北京业务空间专用,普通 API Key 留空)
|
||||||
|
- `host=127.0.0.1`、`port=8800`(uvicorn 启动参数)
|
||||||
|
- `cors_origins`(逗号分隔)→ 属性 `cors_origin_list`(settings.py:31-35)
|
||||||
|
- `get_settings()` 带 `@lru_cache`(settings.py:38-40)。
|
||||||
|
- `.env.example` 给出全部变量名:`DEEPSEEK_API_KEY`、`DASHSCOPE_API_KEY`、`DASHSCOPE_BASE_HTTP_API_URL`、`HOST`、`PORT`、`CORS_ORIGINS`。
|
||||||
|
|
||||||
|
### 2.2 模型目录(server/config/models.yaml + services/models_catalog.py)
|
||||||
|
|
||||||
|
- YAML 结构:`default`(默认模型 id)+ `models[]`,每项含 `id/label/provider/api_model/base_url/api_key_env/max_tokens/params`。
|
||||||
|
- 密钥**不写入 yaml**,只引用环境变量名(`api_key_env: DEEPSEEK_API_KEY`)。
|
||||||
|
- 当前仅两个 deepseek 模型(deepseek-v4-flash 默认、deepseek-v4-pro),均 `base_url=https://api.deepseek.com`、`max_tokens=4000`、`params.thinking.type=disabled`(关闭思维链,防止推理耗尽 token 正文为空)。
|
||||||
|
- `models_catalog.py`:
|
||||||
|
- `ModelSpec` Pydantic 模型(16-25 行),`params` 为任意 dict、直接并入请求体。
|
||||||
|
- `load_models_file()` 带 `@lru_cache`(43-54 行),校验 default 在列表中、列表非空;文件缺失抛 RuntimeError。
|
||||||
|
- `list_model_options()`(57-62 行)→ 前端下拉用 `{id,label}`。
|
||||||
|
- `get_model_spec(model_id)`(65-74 行):空值回落 default,未知 id 抛 400。
|
||||||
|
- `resolve_api_key(spec)`(77-83 行):按 `api_key_env` 读环境变量,缺失抛 500。
|
||||||
|
- 设计要点:**模型即配置**——新增模型只需改 yaml + 加环境变量,代码零改动(deepseek 类);这是 V2 可直接继承的机制。
|
||||||
|
|
||||||
|
### 2.3 依赖(server/requirements.txt)
|
||||||
|
|
||||||
|
`fastapi>=0.115`、`uvicorn[standard]>=0.32`、`httpx>=0.27`、`pydantic-settings>=2.6`、`python-dotenv`、`PyYAML`、`dashscope>=1.23.8`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、AI 文案服务细节(services/deepseek.py + prompts/copy_ru.py)
|
||||||
|
|
||||||
|
### 3.1 调用链
|
||||||
|
|
||||||
|
`POST /api/ai/copy` → `generate_copy(req)`(deepseek.py:144-181):
|
||||||
|
|
||||||
|
1. `get_model_spec(req.model)` 取模型规格,未传用默认。
|
||||||
|
2. messages = system(`SYSTEM_PROMPT`) + user(`build_user_prompt(...)`),system 完整文本见 copy_ru.py:1-65。
|
||||||
|
3. `_chat_once(spec, messages)`(deepseek.py:93-141):
|
||||||
|
- URL = `base_url + /chat/completions`;Bearer 认证。
|
||||||
|
- payload:`model=api_model`、`temperature=0.45`(事实稳定、营销留少量变化)、`max_tokens`、`response_format={type:"json_object"}`、`**spec.params`。
|
||||||
|
- httpx 超时 90s;网络错误/HTTP≥400 一律 502(detail 截断 500 字符)。
|
||||||
|
- 解析 `body["choices"][0]["message"]["content"]`;若 `finish_reason=="length"` 且正文为空 → 502 并提示调 max_tokens 或关思维链(131-138 行,对应 yaml 中 thinking disabled 的注释)。
|
||||||
|
4. **重试机制**(159-177 行):最多 2 次;解析失败时把上一次输出以 `role=assistant` 追加,再追加一条"请仅重新输出合法 JSON"的 user 消息重试一次;仍失败抛 502。
|
||||||
|
|
||||||
|
### 3.2 JSON 解析与字段清洗
|
||||||
|
|
||||||
|
- `_extract_json_object`(17-37 行):剥 ```json 代码块 → `json.loads` → 失败则截取首 `{` 到末 `}` 再解析 → 必须为 dict。
|
||||||
|
- 字段类型容错:
|
||||||
|
- `_as_title_list`(64-73 行):**标题不按逗号切分**(标题本身含逗号)。
|
||||||
|
- `_as_str_list`(48-61 行):标签按 `[,,\n]` 切分,兼容字符串/数组。
|
||||||
|
- `_as_str`(40-45 行):必须字符串。
|
||||||
|
- `_map_copy_payload`(76-90 行)→ `CopyResponse`,usage 取 prompt/completion tokens。
|
||||||
|
- 最终校验:`titles_ru` 与 `description_ru` 非空,否则视为失败触发重试(164-166 行)。
|
||||||
|
|
||||||
|
### 3.3 提示词结构(copy_ru.py)
|
||||||
|
|
||||||
|
- **SYSTEM_PROMPT** 核心约束:
|
||||||
|
- 输出固定 JSON schema(titles_ru/zh 各 2 条一一对应;tags_ru/zh 各 10~15 个、逐项对应;description 完整俄文卡 + 中文逐项对照)。
|
||||||
|
- 描述固定结构:`Описание товара` → `Характеристики`(只列原文事实,俄式尺寸写法)→ `Преимущества`(3~6 条利益点)→ `Комплектация`(仅原文提到配件时)。
|
||||||
|
- 标题规则:60-90 字符、核心品类词开头、前 30 字符含关键属性、删年份/新款/爆款噪声、两标题互补。
|
||||||
|
- **事实边界**(强约束):禁止虚构结构/配件/认证/产地/品牌/受众/使用效果;行业词归一(搪胶→винил 非 каучук);"防摔"不得推导安全认证。
|
||||||
|
- 优先级:事实准确 > 俄语自然 > 信息完整与转化力 > 关键词覆盖。
|
||||||
|
- **build_user_prompt**(68-86 行):模板包裹 `<当前商品名>`、`<型号>`、`<商品资料>`,并要求区分"事实来源"与"生成要求",与文案无关的指令忽略、要求不得写成事实。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、图生图服务细节(services/image_edit.py)
|
||||||
|
|
||||||
|
### 4.1 云与模型
|
||||||
|
|
||||||
|
- 调用**阿里云百炼 DashScope**(`dashscope` Python SDK,同步调用,`asyncio.to_thread` 放入线程池,177-185 行)。
|
||||||
|
- 模型白名单(schemas/image_edit.py:9-17):`wanx2.1-imageedit`、`wan2.6-image`、`qwen-image-edit`、`qwen-image-edit-plus`、`qwen-image-edit-plus-2025-10-30`。
|
||||||
|
- 两种调用形态(按模型路由,170-174 行):
|
||||||
|
- **wanx2.1-imageedit → `ImageSynthesis.call`**(71-121 行):同步、function 式。kwargs:`api_key/model/function/prompt/base_image_url/n` + 可选 `mask_image_url/size/seed/style/prompt_extend/strength`。解析 `rsp.output.results[].url` 与 `usage.image_count`、`request_id`。
|
||||||
|
- **wan2.6-image / qwen-image-edit 系列 → `MultiModalConversation.call`**(124-167 行):messages=`[{role:user, content:[{image: base_image},{text: prompt}]}]` + `n/size/prompt_extend`;解析 `output.choices[0].message.content[].image`。
|
||||||
|
- `function` 白名单(schemas/image_edit.py:24-29):`description_edit`(无掩码图生图,默认)/ `description_edit_with_mask`(局部重绘,需 mask_image)/ `stylization_local` / `stylization_all`。
|
||||||
|
|
||||||
|
### 4.2 请求校验(schemas/image_edit.py)
|
||||||
|
|
||||||
|
- `base_image` / `mask_image`:data URL(`data:image/...`)或公网 http(s) URL;data URL 编码后 ≤ 15MB(`_MAX_BASE64_LENGTH`,32 行);mask 可空。
|
||||||
|
- `prompt` 非空;`model` 白名单校验;`n` 1~4;`strength` 0.0~1.0(默认 0.5,加文字/大改建议 0.8);`prompt_extend` 默认 True。
|
||||||
|
|
||||||
|
### 4.3 关键工程决策
|
||||||
|
|
||||||
|
- **服务端代理下载 → data URL**(`_download_to_data_url`,29-48 行):阿里云结果 URL 未开放 CORS,前端直接绘 canvas 会污染画布无法导出;服务端下载后转 `data:{content-type};base64,...`,上限 20MB(`_MAX_PROXY_BYTES`,23 行),失败回退空串、前端用 `url`。
|
||||||
|
- 无密钥 → 500;SDK 异常/HTTP 非 200 → 502(`_fail`,60-64 行)。
|
||||||
|
- `_apply_base_url`(51-57 行):仅北京业务空间需设 `dashscope.base_http_api_url`。
|
||||||
|
- 文件头注释明确:**当前不落盘、不存储图片**,将来接七牛云可在返回 URL 后加"下载并转存"步骤,api 层与前端契约不动(image_edit.py:1-9)——这是刻意的扩展点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、studio 前端页面/组件/功能清单与交互流程
|
||||||
|
|
||||||
|
### 5.1 技术栈(studio/package.json + vite.config.ts)
|
||||||
|
|
||||||
|
- React 19.2、react-router 7(createBrowserRouter)、antd 6.1(zhCN)、axios、Vite 7(SWC 插件)、TS 5.9;`@` 别名 → `src`。
|
||||||
|
- dev server:端口 8900(strictPort、open),`/api` 代理到 `http://127.0.0.1:8800`(vite.config.ts:17-22)。
|
||||||
|
|
||||||
|
### 5.2 页面与路由
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| src/main.tsx | createRoot 挂载 |
|
||||||
|
| src/App.tsx | ConfigProvider(zhCN、主色 #8b5cf6、圆角 8)+ AntdApp + RouterProvider |
|
||||||
|
| src/router/index.tsx | `/` 与 `/ai-image` → AiImagePage(MainLayout 内);`*` → Navigate `/` |
|
||||||
|
| src/layouts/MainLayout.tsx | 固定 Sider(240, 深色, 可折叠) + Header(页面标题/副标题) + Content(Outlet);<768px 切 Drawer 移动菜单 |
|
||||||
|
| src/layouts/SidebarMenu.tsx | antd Menu,"主要功能"分组,点击 navigate |
|
||||||
|
| src/layouts/menuConfig.tsx | **仅一个菜单项**:`/ai-image`「AI 图生图」(subtitle:上传图片、加水印、用万相模型进行图生图编辑);`getPageInfo` 路径→标题映射 |
|
||||||
|
|
||||||
|
### 5.3 AiImagePage(pages/ai-image/AiImagePage.tsx)——主页面
|
||||||
|
|
||||||
|
状态:图片列表 `ImageItem[]`(id/fileName/image/dataUrl/state)、全局水印设置(type=image|text、text 默认 'Panda Store'、opacity 默认 30、水印图 `/imgs/watermark.jpg` 预加载为 HTMLImageElement)、编辑弹窗(modalOpen/editing)。
|
||||||
|
|
||||||
|
区块与流程:
|
||||||
|
1. **水印设置面板**:类型 Radio(图片/文字)→ 文字输入 或 水印图预览(圆形贴图);透明度 Slider(0-100)。
|
||||||
|
2. **上传**:`Upload.Dragger` 多图、`accept=image/*`、`beforeUpload` 返回 false(阻止自动上传,纯前端读文件)→ `fileToImage`(FileReader → dataURL → Image)→ 追加进列表,按图片尺寸算默认水印位置(右下角)。
|
||||||
|
3. **预览网格**:每张图一个 `WatermarkCanvas`(可拖拽水印,坐标相对原图、`clampPos` 限制不越界);卡片操作:加水印/清除水印(`toggleWatermark`)、**AI 生图**(`openEdit`:`renderFullRes` 全分辨率合成水印图 → dataURL → 打开弹窗)、导出 PNG(`downloadCanvas`,文件名加 `_watermark` 后缀)、删除;顶部"全部加水印"(`applyToAll` 重建所有 state)与"清空所有"。
|
||||||
|
4. **AI 生图弹窗** = ImageEditModal(见下)。
|
||||||
|
|
||||||
|
### 5.4 ImageEditModal(pages/ai-image/components/ImageEditModal.tsx)——编辑工作台 Drawer
|
||||||
|
|
||||||
|
右抽屉(`min(1240px, 96vw)`),每次打开重置状态;三段式布局 + 底部 AI 指令条:
|
||||||
|
|
||||||
|
- **左:底图切换 Thumb 列表**——「原图」+ 每次 AI 生成的结果图(`AiResult{id,image,dataUrl,url}`),点击切换当前底图(标注可叠加在 AI 结果上继续编辑)。
|
||||||
|
- **中:AnnotationCanvas**(预览最大宽 900)——标注画布,Pointer 交互:拖拽移动、旋转手柄旋转、四角手柄缩放(文字改 fontSize、标尺等比改 length/tSize/lineWidth/labelFontSize);命中检测 `hitTest`(旋转手柄 → 四角 → body);`onBeginInteraction` 每次交互开始时压撤销快照。
|
||||||
|
- **右:属性编辑**——Tabs(文字/标尺);添加文字/标尺按钮;选中元素属性面板 ElementPropsPanel;**样式预设**:选中元素可"保存预设"(localStorage key `ozon_annotation_presets_v1`,只存样式不含内容/位置),新建元素/选中元素可套用预设;撤销/清空/导出 PNG。
|
||||||
|
- **底部 AI 条**:TextArea 指令(Enter 快捷生成)+ 模型 Select(5 个模型带中文说明)+「AI 生成」按钮。
|
||||||
|
- **runAi**(195-228 行):`editImage({base_image: currentDataUrl, prompt, model, n:1, strength:0.8, prompt_extend:true})` → 取 `results[0]`,优先 `image_base64` 否则 `url` → 解码为 Image 追加为结果底图并自动切换。出错用 `apiErrorMessage` 提示。
|
||||||
|
- **exportImage**(230-240 行):新 canvas 合成底图 + 全部标注元素 → PNG 下载。
|
||||||
|
|
||||||
|
### 5.5 组件与工具细节
|
||||||
|
|
||||||
|
| 文件 | 能力 |
|
||||||
|
|---|---|
|
||||||
|
| components/WatermarkCanvas.tsx | 单图水印预览(PREVIEW_MAX_WIDTH=360),命中水印区域拖拽,坐标映射回原图,`clampPos` 防越界 |
|
||||||
|
| components/AnnotationCanvas.tsx | 标注画布(预览最大宽 900):绘制底图+元素+选中框;Pointer 交互(move/rotate/resize);交互开始回调存撤销 |
|
||||||
|
| components/ElementPropsPanel.tsx | 文字:内容/字体(9 种)/字号/加粗/文字色/描边色/描边宽;标尺:长度/线色/线宽/T字大小/标签文字/标签色/标签字号;公共:透明度/旋转 |
|
||||||
|
| utils/watermark.ts | `WATERMARK_SCALE=0.15`(图片水印直径比)、`WATERMARK_MARGIN=10`、`WATERMARK_TEXT_SCALE=0.051`;`getWatermarkSize/clampPos/defaultPos`;`drawWatermarked`:图片水印圆形裁剪+全局透明度,文字水印先绘离屏层再合成(避免描边透出);`renderFullRes` 全分辨率合成 |
|
||||||
|
| utils/annotation.ts | `FONT_FAMILIES`、`HANDLE_RADIUS=7`、`ROTATE_GAP=26`;`measureText/elementBox/toLocal/hitTest`;`drawElement/drawText/drawRuler/drawSelectionHandles`;`createTextElement/createRulerElement` 工厂(默认值随图宽缩放) |
|
||||||
|
| utils/image.ts | `fileToImage`、`canvasToDataUrl`、`downloadCanvas` |
|
||||||
|
| types/image.ts | `WatermarkType/WatermarkState/ImageItem/ImageEditRequest/ImageEditResponse`(与后端 schema 字段一致) |
|
||||||
|
| types/annotation.ts | `TextElement/RulerElement`(中心点/旋转/透明度 + 各自样式字段) |
|
||||||
|
| services/api.ts | axios 实例:baseURL=`envConfig.apiBaseUrl`(默认 `/api`)、**timeout 120s**;`api.get/post` 解包 `res.data`;`apiErrorMessage` 提取 FastAPI `detail` |
|
||||||
|
| services/image.ts | `editImage(payload)` → `POST /image/edit` |
|
||||||
|
| config/env.ts | `VITE_API_BASE_URL`(默认 `/api`)、`VITE_APP_NAME`、`debug=import.meta.env.DEV` |
|
||||||
|
|
||||||
|
### 5.6 前后端衔接方式
|
||||||
|
|
||||||
|
- 前端**只调 `/api/image/edit`**(services/image.ts);`/api/ai/*` 暂无 studio 页面(v1 `web/js/ai-copy.js` 在消费,v1 已冻结)。
|
||||||
|
- 图片以 data URL 在请求体内传输(15MB 上限),响应以服务端代理的 `image_base64` 为主、`url` 兜底——专为规避阿里云结果 URL 无 CORS 的画布污染问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、可直接复用到 V2 vs 需要重写的部分
|
||||||
|
|
||||||
|
### 6.1 可直接复用(成熟、结构清晰、低耦合)
|
||||||
|
|
||||||
|
| 资产 | 理由 |
|
||||||
|
|---|---|
|
||||||
|
| 模型目录机制(models.yaml + models_catalog.py) | "模型即配置":换/加 LLM 只改 yaml+env,代码零改动;`resolve_api_key` 按 env 名取密钥,安全;V2 加多厂商直接扩展 |
|
||||||
|
| DeepSeek 调用骨架(deepseek.py 的 `_chat_once` + `_extract_json_object` + 重试纠错循环) | 通用性强:JSON 输出强制、代码块剥离、容错截取、失败追加修正消息重试一次;可抽象为通用 "JSON 任务 LLM 调用器" 供 V2 任意生成任务复用 |
|
||||||
|
| 文案 schema 与提示词(schemas/copy.py + prompts/copy_ru.py) | 领域逻辑已打磨(事实边界、俄语标题/描述结构、中俄对照);V2 若保留文案生成,整套平移 |
|
||||||
|
| 图生图服务(services/image_edit.py + schemas/image_edit.py) | 多模型路由(ImageSynthesis vs MultiModalConversation)、服务端代理下载规避 CORS、线程池隔离同步 SDK、响应契约(task_id/request_id 已预留异步字段);注释已规划"返回后接七牛转存"扩展点,与 V2 存储需求天然衔接 |
|
||||||
|
| 前端 API 层(services/api.ts + services/image.ts + config/env.ts) | axios 封装(120s 超时、detail 提取)与端点封装可原样复用;新增端点照 image.ts 模式加即可 |
|
||||||
|
| 水印/图片/标注工具集(utils/watermark.ts、utils/image.ts、utils/annotation.ts) | 纯 canvas 数学、框架无关、按原图坐标系建模(预览缩放/全分辨率导出分离),可直接搬入 V2 |
|
||||||
|
| 标注数据模型(types/annotation.ts:TextElement/RulerElement) | 字段设计合理(中心点+旋转+透明度+样式),可直接作为 V2 标注/叠加元素的数据契约种子,甚至与后端共享 schema |
|
||||||
|
| 布局壳(MainLayout + SidebarMenu + menuConfig) | 菜单配置驱动、移动端适配完整;V2 加页面只改 menuConfig + router |
|
||||||
|
| 水印批处理主流程(AiImagePage 上传→全局水印→批量套用→导出) | 完整闭环,可整体作为 V2 的一个功能模块迁入 |
|
||||||
|
|
||||||
|
### 6.2 需要重写/新建(当前缺失或形态不适配 V2)
|
||||||
|
|
||||||
|
| 资产 | 现状与重写理由 |
|
||||||
|
|---|---|
|
||||||
|
| Ozon 对接(api/ozon.py) | 纯占位,V2 若做"发布到 Ozon"需从零实现 Seller API(token 管理、类目树、商品上传、图片上传等) |
|
||||||
|
| 前端 AI 文案页 | studio 完全没有文案 UI;后端 `/api/ai/copy` 就绪,V2 需新建页面(可参考 v1 web/js/ai-copy.js 的表单交互) |
|
||||||
|
| 服务端文件/任务持久化 | 现状:图不落盘、无上传端点、无 DB、无任务队列;图生图同步等待(前端 120s 超时)。V2 若引入"商品文件夹"契约(README 所述四部分衔接点)与异步任务,需新增上传/资产/任务/持久化体系 |
|
||||||
|
| ImageEditModal 状态管理 | 编辑+AI 生成+撤销+预设全在组件本地 useState;V2 若需跨步骤工作流/历史/多页共享,需抽出 store(如 zustand/redux)与后端任务状态同步 |
|
||||||
|
| AiImagePage 批量状态 | 纯内存 useState,刷新即失;V2 按商品文件夹组织时需要持久化/恢复会话 |
|
||||||
|
| 鉴权与错误治理 | 无任何鉴权、无统一错误码规范(全部 HTTPException detail 字符串);V2 多用户/上线需补齐 |
|
||||||
|
| 水印可配置性 | 水印图硬编码 `/imgs/watermark.jpg`、比例/字号为常量;V2 应支持自定义水印资源与配置 |
|
||||||
|
| 路由/菜单 | 仅 1 页;V2 多页面需按功能域重组(当前结构太薄,扩展时建议直接重构而非继续堆叠) |
|
||||||
|
| 测试与文档链 | 无自动化测试;契约靠 README/docs 维护。V2 建议引入 schema 共享(README 中 `packages/schema` 待建项)避免前后端字段漂移 |
|
||||||
|
|
||||||
|
### 6.3 一句话结论
|
||||||
|
|
||||||
|
**后端资产(模型目录、LLM 调用骨架、图生图多模型服务、schema)质量高且刻意留了扩展点(异步字段、七牛转存注释),可大幅平移;前端可平移的是纯 canvas 工具层与布局壳,而所有"产品化"能力——Ozon 对接、文案 UI、持久化/资产、异步任务、鉴权、状态管理——目前为空或雏形,是 V2 的主要建设量。**
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# V2 数据库设计
|
||||||
|
|
||||||
|
> 状态:方案设计(待确认)
|
||||||
|
> 上游:[V2 总览](./README.md) · [V2 架构](./architecture.md)
|
||||||
|
> 字段来源:Ozon `ProductAPI_ImportProductsV3` + V1 `docs/contracts/product-json.md` + 采集/发布/店铺维度
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 设计原则
|
||||||
|
|
||||||
|
1. **商品主表对齐 Ozon 字段**:`products` 表按 `ImportProductsV3` 的字段平铺(`name/description/price/offer_id/...`),JSONB 存三类结构:`attributes` / `complex_attributes`(Ozon 动态属性)、`raw`(采集原文)、`pricing`(计价结果)。
|
||||||
|
2. **素材与发布字段分离**:采集来的源图(分组/变体/源站 URL/七牛 URL)放 `product_assets` 表;`products.images` 只存「将提交给 Ozon 的有序公网 URL 数组」。
|
||||||
|
3. **店铺密钥加密落库**:`shops.client_id_enc` / `api_key_enc` 用服务端密钥 AES-GCM 加密,前端永不回显明文。
|
||||||
|
4. **发布异步化**:发布请求与结果存 `publish_tasks`,商品状态由轮询结果回写。
|
||||||
|
5. **类目字典可重建**:`category_*` 三张表是 Ozon 字典的本地缓存,可随时清空重拉,不作为业务真源。
|
||||||
|
|
||||||
|
数据库:**PostgreSQL 16**。ID 统一 `UUID`(`gen_random_uuid()`)或 Ozon 原生 `BIGINT`(类目/属性 id 用 BIGINT 保持与 Ozon 一致)。时间统一 `timestamptz`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 表结构
|
||||||
|
|
||||||
|
### 2.1 `users` —— 用户(预留,MVP 单用户可空置)
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| username | varchar(64) UNIQUE | 登录名 |
|
||||||
|
| password_hash | varchar(255) | Argon2/bcrypt |
|
||||||
|
| created_at | timestamptz | |
|
||||||
|
|
||||||
|
MVP 用 `APP_TOKEN` 时此表可留空;升级多用户时启用。
|
||||||
|
|
||||||
|
### 2.2 `shops` —— Ozon 店铺
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| user_id | UUID FK → users | 归属(MVP 可为空) |
|
||||||
|
| name | varchar(128) | 店铺显示名 |
|
||||||
|
| client_id_enc | text | Client-Id 密文 |
|
||||||
|
| api_key_enc | text | Api-Key 密文 |
|
||||||
|
| currency_code | varchar(3) DEFAULT 'RUB' | 店铺结算币种(RUB/CNY) |
|
||||||
|
| status | enum('active','invalid','disabled') DEFAULT 'active' | invalid=连通性校验失败 |
|
||||||
|
| last_checked_at | timestamptz | 最近一次校验时间 |
|
||||||
|
| created_at / updated_at | timestamptz | |
|
||||||
|
|
||||||
|
> **密钥安全**:`client_id` / `api_key` 用服务端 `SECRET_KEY` 做 AES-GCM 加密后存 `*_enc`。列表接口只返回 `id/name/currency/status/last_checked_at` 与**打码**的 client_id 后四位,永不返回明文 key。
|
||||||
|
|
||||||
|
### 2.3 `products` —— 商品(采集箱 + 编辑 + 发布一体化)
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 | 对应 Ozon 字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | UUID PK | 内部主键 | — |
|
||||||
|
| user_id | UUID FK → users | 归属(MVP 可空) | — |
|
||||||
|
| stage | enum | `collected/editing/ready/publishing/published/failed/archived` | — |
|
||||||
|
| source_platform | varchar(16) | `ozon/1688/taobao` | — |
|
||||||
|
| source_item_id | varchar(64) | 源平台商品 ID(去重) | — |
|
||||||
|
| source_url | text | 采集来源 URL | — |
|
||||||
|
| offer_id | varchar(255) | **自己的货号**(采集恒空,编辑必填) | offer_id |
|
||||||
|
| ozon_product_id | bigint | 发布成功后回填 | — |
|
||||||
|
| ozon_sku | bigint | 跟卖(import-by-sku)用,可空 | — |
|
||||||
|
| name | text | 商品名(俄文,最终) | name |
|
||||||
|
| description | text | 商品描述(俄文,最终) | description |
|
||||||
|
| description_category_id | bigint | 类目 | description_category_id |
|
||||||
|
| type_id | bigint | 商品类型 | type_id |
|
||||||
|
| price | numeric(20,2) | 销售价 | price |
|
||||||
|
| old_price | numeric(20,2) | 划线价 | old_price |
|
||||||
|
| currency_code | varchar(3) DEFAULT 'RUB' | | currency_code |
|
||||||
|
| vat | varchar(8) DEFAULT '0' | 0 / 0.1 / 0.2 | vat |
|
||||||
|
| depth / width / height | numeric(12,3) | 尺寸 | depth/width/height |
|
||||||
|
| dimension_unit | varchar(4) DEFAULT 'mm' | mm / cm | dimension_unit |
|
||||||
|
| weight | numeric(12,3) | 重量 | weight |
|
||||||
|
| weight_unit | varchar(4) DEFAULT 'g' | g / kg | weight_unit |
|
||||||
|
| barcode | varchar(64) | 条码 | barcode |
|
||||||
|
| images | jsonb | 有序公网 URL(七牛)数组,≤15 | images |
|
||||||
|
| primary_image | text | 主图 URL | primary_image |
|
||||||
|
| images360 | jsonb | 360 图 URL 数组 | images360 |
|
||||||
|
| color_image | text | 营销色图 URL | color_image |
|
||||||
|
| pdf_list | jsonb | | pdf_list |
|
||||||
|
| attributes | jsonb | `[{complex_id,id,values:[{dictionary_value_id,value}]}]` | attributes |
|
||||||
|
| complex_attributes | jsonb | 视频/尺码表等 | complex_attributes |
|
||||||
|
| promotions | jsonb | | promotions |
|
||||||
|
| raw | jsonb | 采集原文:`{title,price,params[],desc,sellingPoints,brand,texts[]}` | —(`_raw`) |
|
||||||
|
| pricing | jsonb | 计价结果(见 §3) | —(`_pricing`) |
|
||||||
|
| copy | jsonb | AI 文案结果:`{titles_ru/zh,description_ru/zh,tags_ru/zh,model}` | — |
|
||||||
|
| fx_rate | numeric(12,4) | 计价时快照的汇率 | — |
|
||||||
|
| published_at | timestamptz | 发布成功时间 | — |
|
||||||
|
| created_at / updated_at | timestamptz | | |
|
||||||
|
|
||||||
|
索引:
|
||||||
|
- `(user_id, stage)` —— 采集箱/发布列表主查询
|
||||||
|
- `(source_platform, source_item_id)` UNIQUE(可空)—— 采集去重
|
||||||
|
- `offer_id` —— 货号查重
|
||||||
|
- `ozon_product_id`
|
||||||
|
|
||||||
|
### 2.4 `product_assets` —— 采集素材(图片/视频)
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| product_id | UUID FK → products ON DELETE CASCADE | |
|
||||||
|
| group_key | varchar(16) | `main/sku/detail/video/param` |
|
||||||
|
| variant_name | varchar(128) | SKU 规格名(俄文原样) |
|
||||||
|
| sort_order | int | 组内顺序(1 起,对应命名 `main-001`) |
|
||||||
|
| type | varchar(8) | `img/video` |
|
||||||
|
| source_url | text | 源站原图 URL(溯源) |
|
||||||
|
| qiniu_url | text | 七牛公网 URL(转存成功后) |
|
||||||
|
| status | enum('pending','downloading','uploaded','failed') | 转存状态 |
|
||||||
|
| dedupe_key | varchar(512) | URL 归一化指纹(去重) |
|
||||||
|
| width / height | int | |
|
||||||
|
| error | text | 失败原因 |
|
||||||
|
| created_at | timestamptz | |
|
||||||
|
|
||||||
|
索引:`(product_id, group_key, sort_order)`。
|
||||||
|
|
||||||
|
> **与 `products.images` 的关系**:`product_assets` 是「素材库」(编辑期勾选、分组、去重);用户从素材库选出 ≤15 张主图后,按顺序写 `products.images`(七牛 URL)。这两层解耦,跟卖换主图不改素材库。
|
||||||
|
|
||||||
|
### 2.5 `product_texts` —— 采集文本(可选,也可并进 raw)
|
||||||
|
|
||||||
|
> 一期建议**并进 `products.raw`**(JSONB),不必单开表。若后续要按「卖点/参数」检索,再拆此表:
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| product_id | UUID FK | |
|
||||||
|
| kind | varchar(16) | `title/params/selling_point/desc/price/brand` |
|
||||||
|
| content | text | 文本 |
|
||||||
|
| pairs | jsonb | `table` 模式的 kv |
|
||||||
|
|
||||||
|
### 2.6 `publish_tasks` —— 发布任务
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| product_id | UUID FK → products | |
|
||||||
|
| shop_id | UUID FK → shops | 发布到哪个店铺 |
|
||||||
|
| ozon_task_id | bigint | `/v3/product/import` 返回的 task_id |
|
||||||
|
| status | enum('pending','processing','moderation','imported','failed') | 轮询结果 |
|
||||||
|
| request_payload | jsonb | 实际发给 Ozon 的 items[0](脱敏后) |
|
||||||
|
| response | jsonb | `/v1/product/import/info` 原始结果 |
|
||||||
|
| errors | jsonb | 失败原因数组 |
|
||||||
|
| created_at / completed_at | timestamptz | |
|
||||||
|
|
||||||
|
索引:`(product_id, created_at DESC)`、`ozon_task_id`。
|
||||||
|
|
||||||
|
### 2.7 类目字典缓存(三张,可重建)
|
||||||
|
|
||||||
|
#### `category_tree`
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| description_category_id | bigint PK | 类目 ID |
|
||||||
|
| parent_id | bigint | 父类目 |
|
||||||
|
| category_name | varchar(255) | |
|
||||||
|
| type_id | bigint | 商品类型 ID |
|
||||||
|
| type_name | varchar(255) | |
|
||||||
|
| disabled | boolean | 不可建品 |
|
||||||
|
| level | int | 层级 |
|
||||||
|
| lang | varchar(8) | DEFAULT/RU/EN/ZH_HANS |
|
||||||
|
| updated_at | timestamptz | 缓存时间 |
|
||||||
|
|
||||||
|
#### `category_attributes`
|
||||||
|
|
||||||
|
主键 `(description_category_id, type_id, attribute_id)`。
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| description_category_id / type_id / attribute_id | bigint | 复合主键 |
|
||||||
|
| name | varchar(255) | 属性名 |
|
||||||
|
| description | text | |
|
||||||
|
| type | varchar(32) | 属性值类型 |
|
||||||
|
| dictionary_id | bigint | 0=无字典 |
|
||||||
|
| group_id / group_name | bigint / varchar | 属性分组 |
|
||||||
|
| is_required | boolean | 必填 |
|
||||||
|
| is_aspect | boolean | 变体属性(颜色/尺码) |
|
||||||
|
| is_collection | boolean | 多值 |
|
||||||
|
| max_value_count | int | |
|
||||||
|
| attribute_complex_id | bigint | 复杂属性 |
|
||||||
|
| complex_is_collection | boolean | |
|
||||||
|
| category_dependent | boolean | 字典值是否依赖类目 |
|
||||||
|
| lang | varchar(8) | |
|
||||||
|
| updated_at | timestamptz | |
|
||||||
|
|
||||||
|
#### `attribute_values`
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | bigint | 字典值 ID |
|
||||||
|
| attribute_id | bigint | |
|
||||||
|
| description_category_id / type_id | bigint | |
|
||||||
|
| value | varchar(512) | 字典值文本 |
|
||||||
|
| picture | text | 值配图 |
|
||||||
|
| info | text | |
|
||||||
|
| lang | varchar(8) | |
|
||||||
|
| updated_at | timestamptz | |
|
||||||
|
|
||||||
|
> 字典值可能很大(一个类目数万条),**按需拉取**:用户选了类目+属性后才拉该属性字典,且只缓存用过的属性(见 `ozon-publish.md` §3)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. JSONB 结构约定
|
||||||
|
|
||||||
|
### 3.1 `products.raw`(采集原文,对齐 V1 `_raw` + texts)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"title": "Термокружка детская 316",
|
||||||
|
"price": "1 290 ₽",
|
||||||
|
"params": [{ "key": "Материал", "value": "Нержавеющая сталь" }],
|
||||||
|
"desc": "…",
|
||||||
|
"sellingPoints": "…",
|
||||||
|
"brand": "…",
|
||||||
|
"texts": [ // 插件 texts[] 原样
|
||||||
|
{ "kind": "params", "content": "…", "pairs": [{ "key": "…", "value": "…" }] }
|
||||||
|
],
|
||||||
|
"images": { "main": [...], "sku": [...], "detail": [...], "video": [...] } // 采集快照(可选)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `products.pricing`(对齐 V1 `_pricing`)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"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": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 计价公式与字段沿用 v1(`web/js/app.js`),**只抄不改**,见 `migration.md` §3。`products.price` 最终取 `sellingPriceRub`(预留折扣后售价)。
|
||||||
|
|
||||||
|
### 3.3 `products.attributes`(对齐 Ozon)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
[ { "complex_id": 0, "id": 5076, "values": [ { "dictionary_value_id": 971082156, "value": "Speaker stand" } ] } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 关系图
|
||||||
|
|
||||||
|
```
|
||||||
|
users 1─n shops 1─n publish_tasks n─1 products
|
||||||
|
1─n product_assets
|
||||||
|
1─n product_texts(可选)
|
||||||
|
products n─1 category_tree(弱关联,仅存 id)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 迁移(Alembic)约定
|
||||||
|
|
||||||
|
- 首个迁移建全部表;后续 schema 变更走 Alembic revision。
|
||||||
|
- JSONB 字段的 schema 演进靠应用层版本号(`raw.schemaVersion` / `pricing.schemaVersion`)而非 DB 迁移,避免频繁 ALTER。
|
||||||
|
- `shops.client_id_enc/api_key_enc` 的加密密钥 `SECRET_KEY` 放 `.env`,**换环境(本地/腾讯云)需保证一致或做好密文重写**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 规模预估(单人自用 → 小团队)
|
||||||
|
|
||||||
|
| 表 | 量级 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| products | 万级 | 每商品数十素材,主表轻 |
|
||||||
|
| product_assets | 十万级 | 每商品 10~30 图 |
|
||||||
|
| publish_tasks | 万级 | 每发布一次一条 |
|
||||||
|
| category_* | 类目数万 / 属性数百万 / 值可能上亿(按需缓存) | 只缓存用过的 |
|
||||||
|
|
||||||
|
该量级单机 PostgreSQL 绰绰有余,无需分库分表;`product_assets` 后续可考虑按 product_id 分区或归档。
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# V2 图片处理方案
|
||||||
|
|
||||||
|
> 状态:方案设计(**图片方案 B 已拍板** 2026-08-15;套图落地节奏待定)
|
||||||
|
> 上游:[V2 总览](./README.md) · [V2 架构](./architecture.md)
|
||||||
|
> 相关:V1 [`docs/studio/image-edit.md`](../studio/image-edit.md) · `ecommerce-image-suite/` 源码与 `SKILL.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 先厘清:这是「两种不同的能力」,不是二选一的替代品
|
||||||
|
|
||||||
|
用户提出的两个方案,本质是把两种**不同粒度**的图片能力放在一起比了:
|
||||||
|
|
||||||
|
| | 现有 studio「AI 生图」 | ecommerce-image-suite「电商套图」 |
|
||||||
|
|---|---|---|
|
||||||
|
| 模型 | 万相 `wanx2.1-imageedit`(+ qwen-image-edit 系列) | `wan2.7-image-pro` / 豆包 `doubao-seedream` / GPT-image 等 |
|
||||||
|
| 形态 | **单图编辑**:换背景/去水印/局部重绘/加文字/风格化 | **套图生成**:原图 → 8~9 张营销图 |
|
||||||
|
| 输入 | 一张底图 + 一句指令 | 商品原图(1~3 张)+ 卖点文案 |
|
||||||
|
| 输出 | 1 张改好的图 | 白底主图/核心卖点图/卖点图/材质图/场景图/模特图/多场景拼图/详情图/三角度 |
|
||||||
|
| 适用 | 「修一张图」:白底、去水印、补字 | 「产出一整套」:营销素材、详情页 |
|
||||||
|
| 成本 | 单次编辑(百炼按张计) | 一套 8~9 张 × 单张价 + 1 次视觉分析 |
|
||||||
|
|
||||||
|
**结论先行**:这两个是**互补**的,不是谁替代谁。所以不存在真正的「方案一 vs 方案二」,而是「只保留一个」还是「两个都要」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 对 Ozon 跟卖场景,真正刚需是什么
|
||||||
|
|
||||||
|
跟卖(从竞品页采集 → 自己上品)的图片痛点很具体:
|
||||||
|
|
||||||
|
1. **白底主图**:Ozon 主图要求纯白底、无文字、无水印。竞品图往往带背景/水印/营销字。
|
||||||
|
2. **去水印/去字**:采集来的图常带竞品水印。
|
||||||
|
3. **补充营销图**:主图之外,详情页要卖点图、场景图、模特图(服饰类)。
|
||||||
|
|
||||||
|
| 痛点 | 最合适的工具 | 成本 |
|
||||||
|
|---|---|---|
|
||||||
|
| 白底 / 去水印 / 去字 / 换背景 | **`wanx2.1-imageedit`(单图编辑)** | 单张,便宜,可控 |
|
||||||
|
| 整套营销图 / 模特图 / 场景图 / 详情图 | **ecommerce-image-suite(套图)** | 一套多张,按需 |
|
||||||
|
|
||||||
|
**单图编辑是高频刚需**(几乎每个商品都要做白底),**套图是选配**(服饰/需要营销图的类目才用,且部分类目 Ozon 主图够用)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 方案对比
|
||||||
|
|
||||||
|
### 方案 A:只集成 ecommerce-image-suite,去掉 wanx2.1-imageedit
|
||||||
|
|
||||||
|
- ✅ 简单:图片能力一个入口,前端一套 UI。
|
||||||
|
- ❌ **贵且不划算**:白底/去水印这种单图需求,也要走整套生成(8~9 张),大量浪费。
|
||||||
|
- ❌ **产出不完全对口**:套图里只有「白底主图」一张符合 Ozon 主图规范;卖点图/场景图**带营销文字**,不能当 Ozon 主图(Ozon 主图禁文字水印),只能进详情/补充。
|
||||||
|
- ❌ 丢掉了「改一张图」的精细控制(局部重绘、加字、改背景强度),这些 wanx2.1-imageedit 已经做好且便宜。
|
||||||
|
|
||||||
|
### 方案 B:套图 + 保留 wanx2.1-imageedit(改名「智能修图」),高低搭配 ✅ **已选(2026-08-15)**
|
||||||
|
|
||||||
|
- 高频单图需求(白底/去水印/换背景/加字)→ **智能修图**(wanx2.1-imageedit,已有代码,复用改名)。
|
||||||
|
- 低频整套需求(卖点图/场景图/模特图/详情图)→ **电商套图**(ecommerce-image-suite)。
|
||||||
|
- ✅ 两者共用 `DASHSCOPE_API_KEY`,无额外对接成本。
|
||||||
|
- ✅ 现有 `server/api/image.py` + `services/image_edit.py` + studio `AiImagePage` 整套**原样保留**,只是改个名字和菜单。
|
||||||
|
- ✅ 成本可控:默认用便宜的修图,需要时再整套。
|
||||||
|
|
||||||
|
**成本佐证**(来自 ecommerce-image-suite `references/providers.md`):
|
||||||
|
|
||||||
|
| 供应商/模型 | 单价 | 参考图 | 国内直连 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 千问 `wan2.7-image-pro` | ¥0.14/张 | ✅ | ✅ |
|
||||||
|
| 豆包 `doubao-seedream-4-5` | ¥0.12/张 | ✅ | ✅ |
|
||||||
|
| Gemini 3.1-flash-image | $0.03/张 | ✅ | 需代理 |
|
||||||
|
| GPT-image-1.5 | $0.04~0.2/张 | ✅ | 需代理 |
|
||||||
|
| Stability core | $0.03/张 | ❌ | 需代理 |
|
||||||
|
|
||||||
|
一套 8 张 ≈ ¥0.96~1.12(国内直连),加一次视觉分析(qwen-vl-max)。单图编辑是「按需 1 张」,成本远低于整套。
|
||||||
|
|
||||||
|
> **纠正一个直觉**:套图「贵」不在单张价,而在「一次要生成一整套」。单张价其实比很多平台便宜。所以「方案 B 更贵」不成立——方案 B 反而因为默认走单图编辑而更省。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 命名与入口(方案 B 落定后的 UI)
|
||||||
|
|
||||||
|
studio 编辑页「图片」面板里,每张图/每个插槽提供两类操作:
|
||||||
|
|
||||||
|
| 菜单 | 能力 | 底层 |
|
||||||
|
|---|---|---|
|
||||||
|
| **智能修图** | 白底、去水印、换背景、局部重绘、加文字、风格化 | `wanx2.1-imageedit`(`/api/image/edit`) |
|
||||||
|
| **电商套图** | 从商品原图生成整套营销图(可勾选图型) | ecommerce-image-suite(`/api/image/suite`) |
|
||||||
|
|
||||||
|
现有的「AI 图生图」独立页保留,改名「智能修图」,作为单图精修工作台;「电商套图」作为编辑页图片面板里的一个按钮/抽屉。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 电商套图的集成方式(ecommerce-image-suite 是「脚本+Skill」,不是服务)
|
||||||
|
|
||||||
|
`ecommerce-image-suite` 目前是给 Agent/人用的 **脚本 + Skill** 形态(`analyze.py` + `generate.py`,Apache-2.0),不是现成 API。要集成进 studio,有三档:
|
||||||
|
|
||||||
|
| 档 | 做法 | 代价 | 建议 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| L1 快速 | 服务端 subprocess 调 `analyze.py`/`generate.py` | 依赖 Python 环境、脚本路径、退出码解析;无并发控制 | 验证期可用 |
|
||||||
|
| L2 正式 ✅ | 把 `generate.py` 的 **prompt 引擎 + 供应商调用**抽成 `services/image_suite.py`(纯 Python 模块,直接在 FastAPI 里调 DashScope/豆包) | 移植 prompt 模板与参数(图型 9 种、6 套视觉模板、平台规范),约 1~2 天 | **推荐** |
|
||||||
|
| L3 独立服务 | 套图做成独立微服务,HTTP 调用 | 重,单人项目不值 | 不建议 |
|
||||||
|
|
||||||
|
**L2 的关键取舍**:ecommerce-image-suite 里有大量「Agent 交互」逻辑(模特选择、模板推荐、场景推荐、确认步骤),这些在 studio 里**不该照搬**。V2 只取它的**生成引擎**(图型 Prompt 模板 + 供应商 API 调用),把交互简化成 studio 表单:
|
||||||
|
|
||||||
|
- 输入:选 1~3 张商品原图(素材库已有)+ 卖点文案(可自动从 `products.raw`/`copy` 取,可手改)+ 勾选图型(白底主图/卖点图/场景图/…)+ 目标语言(俄文)。
|
||||||
|
- 输出:生成结果逐张进素材库(`product_assets`,group_key 标 `generated`),用户挑图再进 `products.images`。
|
||||||
|
- 视觉分析(analyze 那步):一期跳过(直接让用户填卖点),二期可调 qwen-vl-max 自动提炼卖点。
|
||||||
|
|
||||||
|
> 注意:ecommerce-image-suite 的图型 Prompt 模板当前是为**国内平台/Amazon**写的(中文/英文文案、平台字体规范)。Ozon 是俄文市场,**俄文文案渲染需要新增一套俄文 Prompt 约束**(或先出英文/无文字图,俄文文案靠前端叠加,见 §7)。这是集成里唯一需要新做的实质工作。
|
||||||
|
|
||||||
|
### 5.1 已知坑(源码级核对,服务端集成必须处理)
|
||||||
|
|
||||||
|
| 坑 | 说明 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| `generate.py` 全局禁用 SSL 校验 | 脚本为方便本地跑图关闭了 TLS 验证 | 服务端集成(L1/L2)**必须移除**,恢复正常 TLS,否则是安全漏洞 |
|
||||||
|
| 退出码恒 0,单张失败不中断 | 成败不反映在退出码上 | 以 `generate_result.json` 为**唯一真源**逐张判成败;失败图重试或标记 |
|
||||||
|
| 文档与代码不一致 | Gemini 端点、豆包/视频模型版本、README 称"无 LICENSE"但实为 Apache-2.0 等 | 以 `generate.py` 实际调用为准,逐供应商核对后再落地 |
|
||||||
|
| 输出固定中文文件名 | `白底主图.jpg` 等中文命名 | 转存七牛时改用英文/序号命名,避免 Ozon 与跨平台文件名问题 |
|
||||||
|
|
||||||
|
### 5.2 用 SKILL.md 当 studio 向导蓝图(可选)
|
||||||
|
|
||||||
|
`SKILL.md` 里那段「上传原图 → 分析卖点 → 选平台/图型/模板/模特 → 生成 → 确认」的对话流,本身是经过打磨的**交互蓝图**。做 studio「电商套图」抽屉时可直接参照它,把多步向导固化成表单步骤(原图选择 → 卖点确认 → 图型勾选 → 模板/模特 → 生成),省去重新设计交互的成本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 七牛存储(贯穿所有图片路径)
|
||||||
|
|
||||||
|
| 来源 | 处理 |
|
||||||
|
|---|---|
|
||||||
|
| 采集源图 | 插件传 URL → 服务端下载(带 Referer)→ 转存七牛 → `product_assets.qiniu_url` |
|
||||||
|
| 智能修图结果 | DashScope 返回 URL(24h)→ 服务端下载 → 转存七牛 → 返回七牛 URL |
|
||||||
|
| 套图结果 | 同上 |
|
||||||
|
| 前端水印合成 | studio canvas 合成 → 上传七牛(服务端中转或直传 token) |
|
||||||
|
|
||||||
|
**为什么必须转存**:Ozon `images` 只收公网可访问 URL(Ozon 服务器主动拉取);阿里云结果 URL 24h 失效且无 CORS;源站 URL 可能防盗链/失效。七牛是稳定公网源。
|
||||||
|
|
||||||
|
一期建议**服务端中转上传**(改动小、无前端直传的 token 复杂度);量大后再切前端直传 + 上传 token。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 推荐落地顺序(务实版)
|
||||||
|
|
||||||
|
1. **一期只做「智能修图」**(已有代码):白底/去水印/换背景。这是最高频、最省、复用度最高的部分。`/api/image/edit` 加七牛转存即可。
|
||||||
|
2. **二期集成「电商套图」**:按 §5 L2 抽 `services/image_suite.py`,先支持 `white_bg / key_features / selling_pt / material / lifestyle / model / multi_scene` 几个高频图型,俄文文案先出英文/无字版本。
|
||||||
|
3. **三期**:俄文文案渲染(新增俄文 Prompt 约束或前端叠字)、视觉分析自动提炼卖点、模特库接入(45 位内置模特)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 决策记录
|
||||||
|
|
||||||
|
- ✅ **图片方案:已选 B(高低搭配)**(2026-08-15):集成 ecommerce-image-suite「电商套图」+ 保留 wanx2.1-imageedit(改名「智能修图」)。
|
||||||
|
- ⏳ 待定:**电商套图一期就做,还是先只交付「智能修图」跑通闭环、套图二期再加**(见 [`migration.md`](./migration.md) §7)。
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# V2 落地计划与改动清单
|
||||||
|
|
||||||
|
> 状态:方案设计(待确认)
|
||||||
|
> 上游:[V2 总览](./README.md) · 其余各分册
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 分阶段里程碑(建议顺序)
|
||||||
|
|
||||||
|
每个里程碑都可独立验收,且不破坏 V1 正在用的部分。
|
||||||
|
|
||||||
|
| # | 里程碑 | 内容 | 产出/验收 | 估时 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **M0** | 数据层与骨架 | 建 DB + SQLAlchemy 模型 + Alembic 首迁移;`/api/health` 接 DB;`.env` 加 `DATABASE_URL`/`APP_TOKEN`/`SECRET_KEY`/`QINIU_*` | 服务能连库、能 `alembic upgrade` | 1d |
|
||||||
|
| **M1** | 插件上传落库 | extension-v2 加消息层 + api client + options;`POST /api/materials` + 素材下载转存七牛(后台协程) | 插件点「上传」,采集箱能看到商品与图 | 2d |
|
||||||
|
| **M2** | 采集箱列表 + 商品编辑骨架 | studio 加「采集箱」页 + 「商品编辑」页(表单 + autosave 落库);计价面板(抄 v1 公式);文案面板(接 `/api/ai/copy`) | 能看采集箱、编辑保存、算价、生成文案 | 3d |
|
||||||
|
| **M3** | 图片:智能修图 + 七牛 | `/api/image/edit` 加七牛转存;编辑页图片面板(水印沿用 canvas + 智能修图入口) | 图片能转存七牛、能白底/去水印 | 1d |
|
||||||
|
| **M4** | 店铺 + 类目 + 属性 | `shops` CRUD + 连通校验;`/api/categories/*` 代理 + 缓存;属性映射 UI | 能绑店铺、选类目、映射属性 | 3d |
|
||||||
|
| **M5** | 发布链路 | `POST /products/:id/publish` + 组装 items[0] + 轮询回填 + 发布结果页 | 商品成功进 Ozon 后台,product_id 回填 | 2d |
|
||||||
|
| **M6** | CSV 导出 + 打磨 | `/api/export/products.csv` + 导出页;错误处理/限流/日志 | 能导出 CSV | 1d |
|
||||||
|
| **M7** | 部署腾讯云 | nginx + systemd + PostgreSQL + 七牛配置;插件/studio 指向公网 | 公网可访问,闭环 | 1d |
|
||||||
|
| **M8** | (二期)电商套图 | 集成 ecommerce-image-suite(`/api/image/suite`);俄文 Prompt | 按方案 B 决策而定 | 2~3d |
|
||||||
|
|
||||||
|
> M2/M4 是最大的两块(编辑页 + 属性映射),也是最值得先用静态样例打磨 UI 的部分。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 各端改动清单
|
||||||
|
|
||||||
|
### 2.1 extension-v2(采集插件)
|
||||||
|
|
||||||
|
1. 加 `src/messaging/`(`MessageMap` + client)与 `src/api/client.ts`(Bearer 鉴权,仅 background)。
|
||||||
|
2. `background.ts` 从「单个 fetchImage 分支」改为 handler 表路由;新增 `collect`/`product-*`/`health` 等消息。
|
||||||
|
3. options 页:后端地址 + Token + 「测试连接」。
|
||||||
|
4. sidepanel「导出到本地」旁边加「上传到服务端」:复用 `buildProduct()` 产物 → `POST /api/materials`。
|
||||||
|
5. 删除/降级 File System Access 写盘主路径(保留为可选本地备份)。
|
||||||
|
6. `SH_PENDING_QUEUE` 重试队列 + `GET /products/:id/fingerprints` 跨页去重接线(v2 已写 builder 但未读回比对)。
|
||||||
|
7. manifest 加后端域名 `host_permissions`。
|
||||||
|
|
||||||
|
> 详细契约沿用 `docs/extension/plan.md` §9/§13/§14,已在 `api.md` §2 落地。
|
||||||
|
|
||||||
|
### 2.2 server(服务端)
|
||||||
|
|
||||||
|
1. 依赖加:`sqlalchemy[asyncio]`、`asyncpg`、`alembic`、`qiniu`、`python-jose`(或 pyjwt)、`cryptography`。
|
||||||
|
2. 新增 `models/`、`migrations/`、`jobs/`(下载转存协程、发布轮询协程)。
|
||||||
|
3. 新增 api 文件:`collection/products/categories/shops/publish/export/fx/auth`。
|
||||||
|
4. 复用不改:`ai.py`/`image.py`/`deepseek.py`/`image_edit.py`/`models_catalog.py`(`image_edit.py` 加七牛转存一步)。
|
||||||
|
5. 新增 `services/ozon_client.py`(Ozon 通用调用)、`services/qiniu.py`(上传/下载转存)、`services/pricing.py`(把 v1 公式实现为服务端校验/计算,供「ready 校验」与 CSV)。
|
||||||
|
6. 鉴权中间件:JWT 校验 + `APP_TOKEN` 换发。
|
||||||
|
|
||||||
|
### 2.3 studio(工作台)
|
||||||
|
|
||||||
|
1. 菜单从单页扩为多页:`采集箱 / 商品编辑 / 发布 / 店铺 / 导出 / 智能修图(原 AI 图生图)`。
|
||||||
|
2. 新建 `pages/product/` 及子组件(PricingPanel/CopyPanel/ImagePanel/CategoryPicker/AttributeMapper/PublishPanel)。
|
||||||
|
3. 计价纯函数从 `web/js/app.js` **抄**进 `src/pricing/`(不改原文件),补单测锁定 v1 数值。
|
||||||
|
4. 文案面板复用 `/api/ai/copy`(参考 `web/js/ai-copy.js` 交互)。
|
||||||
|
5. 状态管理引入 zustand(编辑页跨面板共享);axios 客户端对齐 `/api/*`。
|
||||||
|
6. 复用不动:`utils/watermark.ts`、`annotation.ts`、`image.ts`、布局壳、`AiImagePage`(改名「智能修图」)。
|
||||||
|
|
||||||
|
### 2.4 web(v1 工具台)
|
||||||
|
|
||||||
|
**冻结,零改动**。只被读(抄公式、抄文案交互)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 计价公式迁移(v1 → 服务端 + studio)
|
||||||
|
|
||||||
|
来源:`web/js/app.js`(`calculateLogisticsFee` / `calculateAndDisplay` / `validateDimensions` / `validateLogisticsLevel` / `validatePriceRange` / `updateDerivedPrices`)。
|
||||||
|
|
||||||
|
迁移方式:
|
||||||
|
- **studio 侧**:抽成 TS 纯函数(输入字段 + 汇率 + 预留% → 输出全部结果字段),展示在编辑页计价面板。
|
||||||
|
- **server 侧**:抽成 `services/pricing.py`(同样公式的 Python 版),用于「ready 校验」、`products.price` 最终写入、CSV 导出的一致性。
|
||||||
|
|
||||||
|
> **为什么两端各一份**:计价是高频纯前端交互(实时算),不需要每次走后端;但发布前校验和导出需要服务端有权威结果。约定:**以服务端 `services/pricing.py` 为真源**,前端 TS 版照抄并对齐,用同一组 fixture 测两端一致性(沿用 V1 契约测试思路)。
|
||||||
|
|
||||||
|
关键常量(照抄不改):
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 物流费 | low/high/high2 三档 × 两重量段(公式见 app.js:959) |
|
||||||
|
| 净到手比例 netRate | low=0.845;high/high2=0.785 |
|
||||||
|
| 完全抽成 | 15.5%(low)/ 21.5%(其他),含约 3.5% 其它费 |
|
||||||
|
| 汇率源 | FloatRates → 俄央行 → er-api,5~25 区间过滤,兜底 11.5 |
|
||||||
|
| 预留折扣 | 默认 50%,0~95 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 配置(`.env` 新增项)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 现有
|
||||||
|
DEEPSEEK_API_KEY=…
|
||||||
|
DASHSCOPE_API_KEY=…
|
||||||
|
# V2 新增
|
||||||
|
APP_TOKEN=… # 单用户登录 token(MVP)
|
||||||
|
SECRET_KEY=… # 店铺凭证 AES-GCM 加密密钥
|
||||||
|
DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/ozon_seller
|
||||||
|
# 七牛
|
||||||
|
QINIU_ACCESS_KEY=…
|
||||||
|
QINIU_SECRET_KEY=…
|
||||||
|
QINIU_BUCKET=…
|
||||||
|
QINIU_DOMAIN=https://cdn.example.com # 七牛绑定域名(Ozon 拉取用)
|
||||||
|
APP_BASE_URL=https://api.example.com # 插件/studio 回写、生成图回调用
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.example` 同步补占位并注释。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 部署(腾讯云)
|
||||||
|
|
||||||
|
1. **资源**:轻量应用服务器 / CVM + CDB PostgreSQL + 七牛(域名需备案,Ozon 拉取的是公网 URL,务必用已备案域名)。
|
||||||
|
2. **应用**:`uvicorn main:app --app-dir server --host 127.0.0.1 --port 8800 --workers 2`,systemd 守护;nginx 反代 `/api`,托管 studio 构建产物。
|
||||||
|
3. **数据**:`alembic upgrade head`;`.env` 放服务器(不入 git);`SECRET_KEY` 换环境时注意密文一致性(见 database.md §5)。
|
||||||
|
4. **七牛**:配置 bucket + 绑定 CDN 域名 + 证书;Ozon 服务器需能公网访问该域名。
|
||||||
|
5. **健康检查**:`/api/health`(含 DB ping)给运维探活。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 级别 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| 属性映射工作量大、体验差 | 🔴 高 | 自动匹配 + 人工确认;先做基础版,迭代智能匹配 |
|
||||||
|
| Ozon 改版/限额/风控 | 🟡 中 | 采集端已有四路径 + 埋点热更;发布端错误透传 + 退避 |
|
||||||
|
| ecommerce-image-suite 集成是脚本非服务 | 🟡 中 | 抽 prompt 引擎为服务模块(image-strategy §5 L2) |
|
||||||
|
| 店铺密钥泄露 | 🔴 高 | AES-GCM 加密落库 + 前端打码 + 永不回显明文 + 日志脱敏 |
|
||||||
|
| 任务异步(下载/发布)状态不可见 | 🟡 中 | 素材/发布都有状态表 + 前端轮询回显 |
|
||||||
|
| 本地 → 云上环境不一致 | 🟡 中 | 十二要素:配置全走 `.env`,Alembic 管 schema |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 待确认项(开工前拍板)
|
||||||
|
|
||||||
|
1. ~~图片方案 A / B~~ ✅ **已定 B(高低搭配)**(2026-08-15,见 [`image-strategy.md`](./image-strategy.md) §8)。
|
||||||
|
2. **电商套图是否进一期**:建议一期先只交付「智能修图」跑通闭环,套图二期。
|
||||||
|
3. **单用户还是预留多用户**:schema 已按多用户预留,MVP 用 `APP_TOKEN` 即可。
|
||||||
|
4. **库存是否自动设置**:一期发布到「已创建/审核」,库存去后台补(或二期接 `/v2/products/stocks`)。
|
||||||
|
5. **数据库选型**:已定 PostgreSQL;若想更省事可换 SQLite(本地)→ 但 JSONB/并发/腾讯云部署建议直接用 PostgreSQL。
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# V2 多 SKU(多变体)配置方案调研
|
||||||
|
|
||||||
|
> 状态:调研结论(待实现)
|
||||||
|
> 来源:Ozon Seller API 官方文档 + `reference/maozi-plugin-3.2.3`(毛子ERP 逆向)+ `reference/AI编辑 - 毛子ERP.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 结论一句话
|
||||||
|
|
||||||
|
Ozon 的多变体商品**不是"一个商品带多个 SKU 子结构"**,而是 **N 个独立商品(各自 `offer_id` / `product_id`),通过同一个「型号名称」属性(attribute id = `9048`)自动合并成一张卡**。变体之间的差异只能体现在「aspect 属性」(颜色/尺码等)上。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ozon 官方机制
|
||||||
|
|
||||||
|
### 2.1 合并规则(官方文档原文)
|
||||||
|
|
||||||
|
`/v3/product/import` 文档明确写:
|
||||||
|
|
||||||
|
> To merge two product description pages, pass `9048` in the `attributes` array for each product. **All attributes except size or color must match** in these description pages.
|
||||||
|
|
||||||
|
即:
|
||||||
|
|
||||||
|
- 每个变体 = `items[]` 里的一个独立 item(独立 `offer_id`)。
|
||||||
|
- 每个 item 的 `attributes` 里都带上 `id=9048`(型号名称 / Название модели),且**值相同**。
|
||||||
|
- 除「尺寸/颜色」这类 aspect 属性外,其它属性必须完全一致。
|
||||||
|
- Ozon 会把同型号、仅 aspect 不同的商品**自动合并成一张带变体选择器的卡片**。
|
||||||
|
|
||||||
|
### 2.2 aspect 属性(`is_aspect`)
|
||||||
|
|
||||||
|
`/v1/description-category/attribute` 返回的属性里,`is_aspect=true` 表示该属性是「区分同型号商品的变体维度」(官方定义:颜色、尺码这类)。这就是多变体的"轴":
|
||||||
|
|
||||||
|
- 变体轴 = 类目下 `is_aspect=true` 的属性(通常 `颜色`、`尺码`/`尺寸`)。
|
||||||
|
- 其它属性(品牌、材料、型号名称…)在各变体间必须一致。
|
||||||
|
|
||||||
|
### 2.3 每个变体的图片
|
||||||
|
|
||||||
|
- 主图 `images`:每个变体传自己的主图(通常是该 SKU 的图)。
|
||||||
|
- 采集端已支持"SKU 图带规格名"(`sku-001-синий.jpg`),正好一一对应。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 采集侧(毛子ERP 的 SKU 组合逻辑)
|
||||||
|
|
||||||
|
毛子ERP(1688 采集,`content-scripts/content.js` 里的 `生成SKU组合` 函数)的做法:
|
||||||
|
|
||||||
|
```
|
||||||
|
规格选项(颜色×尺寸…) × SKU详情 → 笛卡尔积 → 变体列表
|
||||||
|
红色, 蓝色 M, L 红色-M, 红色-L, 蓝色-M, 蓝色-L
|
||||||
|
```
|
||||||
|
|
||||||
|
关键点:
|
||||||
|
1. 从 `webAspects` 拿到规格维度(aspect)+ 每个维度的可选值(含 SKU 图)。
|
||||||
|
2. 若有独立 SKU 详情(价格/图),做笛卡尔积;否则用默认价格 + 规格图。
|
||||||
|
3. 每个变体:`{ name: "红色-M", price, primary_image, sku }`。
|
||||||
|
|
||||||
|
对我们的映射:`webAspects` 采集回来的 `skuVariants`(已有 `variantName` + `image`)就是变体轴的数据源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 发布侧(Ozon 多 SKU 实现方案)
|
||||||
|
|
||||||
|
### 4.1 数据模型
|
||||||
|
|
||||||
|
在现有 `products` 表基础上,多 SKU 用**一张卡对应多个 product 记录**来表示:
|
||||||
|
|
||||||
|
- 每个变体是一条 `products` 记录,`offer_id` 唯一。
|
||||||
|
- 变体间共享 `型号名称`(存 `raw.model_name`,映射到 attribute `9048`)。
|
||||||
|
- 变体差异在 `attributes`(aspect 属性填不同字典值)。
|
||||||
|
|
||||||
|
### 4.2 发布流程
|
||||||
|
|
||||||
|
```
|
||||||
|
① 主商品:确定类目 → 拉属性 → 找 is_aspect=true 的属性(如 颜色/尺码)
|
||||||
|
② 变体配置 UI:
|
||||||
|
颜色 ▾ [红/蓝/绿](字典值)
|
||||||
|
尺码 ▾ [M/L/XL](字典值)
|
||||||
|
→ 笛卡尔积生成变体列表(可编辑每个变体的 offer_id / 价格 / 主图)
|
||||||
|
③ 对每个变体,组装 items[i]:
|
||||||
|
offer_id = 变体自己的货号(如 SKU-001-RED-M)
|
||||||
|
attributes = 同型号名称(9048) + 变体自己的颜色/尺码字典值 + 其它共同属性
|
||||||
|
images = 变体自己的主图
|
||||||
|
④ 一次 /v3/product/import 提交所有变体(≤100 个 item)
|
||||||
|
⑤ 轮询回填每个变体的 product_id,Ozon 自动合并成一张卡
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 关键字段映射
|
||||||
|
|
||||||
|
| 概念 | 来源 | 落点 |
|
||||||
|
|---|---|---|
|
||||||
|
| 型号名称 | 编辑页「型号名称」输入 | `attributes[{id:9048, values:[{value}]}]` |
|
||||||
|
| 变体轴 | 类目 `is_aspect=true` 属性 | `attributes`(不同变体填不同 `dictionary_value_id`) |
|
||||||
|
| 变体货号 | 用户/自动 | 每个变体 `offer_id` |
|
||||||
|
| 变体图 | 采集 `skuVariants[].image` | 每个变体 `images[0]` |
|
||||||
|
| 共同属性 | 属性映射结果 | 每个变体相同 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 待实现的 UI(studio 新增「SKU 配置」面板)
|
||||||
|
|
||||||
|
```
|
||||||
|
[型号名称] 儿童保温杯 316
|
||||||
|
[变体维度] 颜色(字典下拉,多选) [红][蓝][绿]
|
||||||
|
尺码(字典下拉,多选) [M][L][XL]
|
||||||
|
[生成变体] ← 笛卡尔积
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ 颜色 尺码 货号 价格 主图 │
|
||||||
|
│ 红 M SKU-001-RED-M [ ] [图] │
|
||||||
|
│ 红 L SKU-001-RED-L [ ] [图] │
|
||||||
|
│ 蓝 M SKU-001-BLU-M [ ] [图] │
|
||||||
|
│ … │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
[保存变体] → 生成 N 条 products 记录(或发布时展开)
|
||||||
|
```
|
||||||
|
|
||||||
|
实现上两种选择:
|
||||||
|
- **A. 落库展开**:保存时直接生成 N 条 `products`(每个变体一条),发布时各自提交。
|
||||||
|
- **B. 发布时展开**:主商品存一份变体配置 JSON,发布时动态展开成 N 个 item 提交。
|
||||||
|
|
||||||
|
建议 **A**(落库展开)——与现有"采集箱 → 单商品编辑 → 发布"链路一致,每个变体可独立查看/编辑/重发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 注意点
|
||||||
|
|
||||||
|
1. **变体必须同型号**:`9048` 值必须完全一致,否则 Ozon 不合并,会生成 N 张独立卡片。
|
||||||
|
2. **非 aspect 属性必须一致**:品牌/材料/描述等若不同,Ozon 拒绝合并。
|
||||||
|
3. **变体图数量**:每个变体各自 ≤15 张主图;`skuVariants` 采集的规格图作为变体主图候选。
|
||||||
|
4. **`complex_attributes` 不是用来做 SKU 的**:它用于视频/尺码表等富内容,别混用。
|
||||||
|
5. **单次 ≤100 item**:变体数超过 100 需分批提交。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 结论
|
||||||
|
|
||||||
|
- 多 SKU = **同型号名称(9048) + aspect 属性区分 + 多 product 记录**,靠 Ozon 自动合并,无需额外的"组合商品" API。
|
||||||
|
- 采集端已具备变体轴数据(`webAspects` 的 `skuVariants`),发布端只需一个「变体配置 + 展开提交」的 UI 即可落地。
|
||||||
|
- 推荐实现路径:studio 增加「SKU 配置」面板 → 落库展开为多条 product → 复用现有发布链路。
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# V2 Ozon 发布集成
|
||||||
|
|
||||||
|
> 状态:方案设计(待确认)
|
||||||
|
> 上游:[V2 总览](./README.md) · [V2 架构](./architecture.md) · [数据库](./database.md) · [API](./api.md)
|
||||||
|
> 官方文档:[Ozon Seller API(中文)](https://docs.ozon.ru/api/seller/zh/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 鉴权与店铺凭证
|
||||||
|
|
||||||
|
Ozon Seller API 用 **两个请求头** 鉴权(不是 OAuth):
|
||||||
|
|
||||||
|
```
|
||||||
|
Client-Id: <你的 Client ID>
|
||||||
|
Api-Key: <你的 API Key>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 获取:Ozon 卖家后台 → 设置 → Seller API → 生成 Key(可选权限级别)。
|
||||||
|
- **凭证归属店铺**:V2 里每店铺一条 `shops` 记录,`client_id`/`api_key` 加密落库,调用时解密拼头。
|
||||||
|
- **连通性校验**:`POST /api/shops/:id/test` 调 [`/v1/roles`](https://docs.ozon.ru/api/seller/zh/#operation/AccessAPI_RolesByToken)(返回该 key 的角色与可用方法),既验证凭证又看权限范围,零成本。
|
||||||
|
|
||||||
|
服务端封装 `services/ozon_client.py`:统一 base URL(`https://api-seller.ozon.ru`)、拼头、超时、错误映射(400/403/409/500 → 语义化 detail)、限流退避。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心接口(本项目用到)
|
||||||
|
|
||||||
|
| 用途 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 类目树 | `POST /v1/description-category/tree` | 返回 `description_category_id / type_id / category_name / type_name / disabled / children`;**只有末级类目可建品** |
|
||||||
|
| 类目属性 | `POST /v1/description-category/attribute` | 入参 `description_category_id + type_id`;返回属性含 `is_required / is_aspect / is_collection / dictionary_id / type / max_value_count` |
|
||||||
|
| 属性值字典 | `POST /v1/description-category/attribute/values` | 入参 `attribute_id + category_id + type_id + limit(≤2000) + last_value_id`(分页) |
|
||||||
|
| 属性值搜索 | `POST /v1/description-category/attribute/values/search` | 按 `value` 模糊匹配参考值(≥2 字符,limit≤100) |
|
||||||
|
| **发布/更新商品** | `POST /v3/product/import` | 一次 ≤100 个 item;返回 `task_id` |
|
||||||
|
| **发布状态** | `POST /v1/product/import/info` | 入参 `task_id`;返回 `items[{offer_id, product_id, status, errors[]}]` |
|
||||||
|
| 商品列表/回填 | `POST /v3/product/list` | 用 `offer_id/product_id` 过滤取 `product_id`,或分页拉全部 |
|
||||||
|
| 跟卖复制 PDP | `POST /v1/product/import-by-sku` | 入参 `sku + name + offer_id + price...`;返回 `task_id + unmatched_sku_list` |
|
||||||
|
| 图片更新 | `POST /v1/product/pictures/import` | 按 `product_id` 覆盖 `images/images360/color_image` |
|
||||||
|
| 商品详情(含图片/审核错误) | `POST /v3/product/info/list` | 回读已发布商品的图片/状态/错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 类目与属性字典(采集属性 → Ozon 属性的关键)
|
||||||
|
|
||||||
|
### 3.1 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
类目树(全局缓存)
|
||||||
|
└─ 用户选类目 → 得 description_category_id + type_id
|
||||||
|
└─ 拉该类目属性(按 category+type 缓存)
|
||||||
|
└─ 对每个「有字典」的属性,按需拉值(/values 或 /values/search)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **类目树全局缓存**:与店铺无关(虽然接口要凭证),服务端拉一次存 `category_tree` 表 + 内存 LRU,TTL 24h。
|
||||||
|
- **属性按类目缓存**:`category_attributes` 表,按 `(category_id, type_id)` 缓存。
|
||||||
|
- **属性值按需拉取**:值目录可能非常大,只在用户映射到某个属性时才拉,且用 `/values/search`(按关键词搜)而非全量拉。
|
||||||
|
|
||||||
|
### 3.2 属性映射(采集的 `raw.params` → Ozon `attributes[]`)
|
||||||
|
|
||||||
|
这是发布链路**最重的工作**。流程:
|
||||||
|
|
||||||
|
```
|
||||||
|
采集 raw.params: [{key:"Материал", value:"Нержавеющая сталь"}, …]
|
||||||
|
│ ① 自动匹配:key 与属性 name 模糊匹配(归一化 + 词干)
|
||||||
|
│ ② 有字典的属性:value 去 /values/search 找 dictionary_value_id
|
||||||
|
▼
|
||||||
|
属性映射 UI:自动匹配结果 + 人工确认未匹配项 + 必填项高亮
|
||||||
|
▼
|
||||||
|
products.attributes = [{complex_id:0, id, values:[{dictionary_value_id, value}]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**必填项校验**:服务端在「ready 校验」和「发布前」两次校验:`category_attributes` 里 `is_required=true` 的属性必须已映射,否则阻断发布并指出缺哪些。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 发布请求体组装(对齐 ImportProductsV3)
|
||||||
|
|
||||||
|
`items[0]` 字段(已核对官方示例):
|
||||||
|
|
||||||
|
| 字段 | 来源 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| offer_id | `products.offer_id` | **自己的货号**,跟卖不能用竞品的 |
|
||||||
|
| name / description | `products.name/description`(俄文) | |
|
||||||
|
| description_category_id / type_id | `products.*` | 从类目树选 |
|
||||||
|
| price / old_price / currency_code / vat | `products.*` | currency 须与店铺设置一致(默认 RUB) |
|
||||||
|
| depth/width/height/dimension_unit/weight/weight_unit | `products.*` | **必填且不能为 0**(官方硬约束) |
|
||||||
|
| barcode | `products.barcode` | 可选 |
|
||||||
|
| images | `products.images`(七牛 URL,≤15) | 顺序即展示顺序;首张为主图。**必须 https 直链**(实测 Ozon 不接受 http,见 §7) |
|
||||||
|
| primary_image | `products.primary_image` | 用 primary_image 则 images ≤14 |
|
||||||
|
| images360 / color_image | `products.*` | 可选 |
|
||||||
|
| attributes | `products.attributes` | 映射结果 |
|
||||||
|
| complex_attributes | `products.complex_attributes` | 视频/尺码表等 |
|
||||||
|
| pdf_list / promotions | 可选 | 一般留空 |
|
||||||
|
|
||||||
|
**跟卖场景可选优化**:若竞品允许复制 PDP,走 `/v1/product/import-by-sku`(只需 sku + 基本信息),更快且继承竞品详情——但受「卖家是否允许复制」限制,且不能更新,故作为**可选快捷路径**,主路径仍是 `import`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 发布状态机与轮询
|
||||||
|
|
||||||
|
`/v3/product/import` 是异步的,返回 `task_id`。流程:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v3/product/import → { task_id }
|
||||||
|
│ 建 publish_tasks(status=pending, ozon_task_id)
|
||||||
|
▼
|
||||||
|
后台协程轮询 POST /v1/product/import/info { task_id }
|
||||||
|
│ items[0].status ∈ imported | moderation | failed(+errors[])
|
||||||
|
▼
|
||||||
|
imported → products.stage=published, ozon_product_id=items[0].product_id
|
||||||
|
moderation → products.stage=publishing(继续轮询,通常 <1 天)
|
||||||
|
failed → products.stage=failed, publish_tasks.errors=items[0].errors
|
||||||
|
```
|
||||||
|
|
||||||
|
- 轮询间隔:先 5s,退避到 30s;`moderation` 状态降低频率到分钟级。
|
||||||
|
- 用 `/v3/product/list`(filter by offer_id)回读 `product_id` 兜底(轮询遗漏时)。
|
||||||
|
- **上架还需设置库存**:`import` 成功后商品进入后台但不自动上架(`architecture.md` 与官方文档均明确「只有设置库存后才开售」)。V2 一期发布到「已创建/审核」即可,库存设置(`/v2/products/stocks`)作为二期可选,或提示用户去后台补库存。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CSV 导出字段
|
||||||
|
|
||||||
|
服务端 `GET /api/export/products.csv`,带 BOM 的 UTF-8,Excel 直接打开不乱码。字段:
|
||||||
|
|
||||||
|
```
|
||||||
|
offer_id, product_id, name, description_category_id, type_id,
|
||||||
|
price, old_price, currency_code, vat,
|
||||||
|
weight, weight_unit, depth, width, height, dimension_unit,
|
||||||
|
barcode, primary_image, images, source_platform, source_item_id, source_url,
|
||||||
|
stage, published_at, created_at, updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
- `images` 用 `|` 拼接七牛 URL。
|
||||||
|
- 未发布商品 `product_id` 为空。
|
||||||
|
- 支持筛选 `stage`(collected/ready/published/failed/全部)与 `ids`(勾选导出)。
|
||||||
|
|
||||||
|
> 对齐 V1:v1 登记表导出的 `sku + 卢布预留价` 组合码,V2 里 `offer_id + price`(卢布)即等价物;若要完全兼容 v1 组合码,可加一列 `combo`(`offer_id + 卢布预留价`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 错误处理与限流
|
||||||
|
|
||||||
|
| Ozon 错误 | 含义 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 400 Invalid parameter | 参数错误 | 把 detail 透传前端,定位字段 |
|
||||||
|
| 403 Access denied | 权限不足 | 提示检查 Api-Key 权限级别 |
|
||||||
|
| 409 Request conflict | 冲突(如 offer_id 重复) | 提示改 offer_id 或走更新 |
|
||||||
|
| 429 / 限流 | 频率超限 | 指数退避重试 |
|
||||||
|
| `item_limit_exceeded` | 超过当日建/更新商品限额 | 提示限额,可查 `/v4/product/info/limit` |
|
||||||
|
|
||||||
|
`publish_tasks.errors` 完整保存 Ozon 返回的 errors 数组,前端发布结果页展示中文解读。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 店铺绑定交互
|
||||||
|
|
||||||
|
1. 店铺管理页「新增店铺」:填 `名称 + Client ID + API Key + 结算币种`。
|
||||||
|
2. 点「测试连接」→ `/api/shops/:id/test` → 调 `/v1/roles` → 显示 `ok` 与角色列表,或失败原因(凭证错/权限不足/网络)。
|
||||||
|
3. 保存后 `client_id` 只显示尾号打码(如 `…1234`),key 永不回显。
|
||||||
|
4. 发布时从店铺下拉选择目标店铺。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 一期范围 vs 二期
|
||||||
|
|
||||||
|
| 能力 | 一期 | 二期 |
|
||||||
|
|---|---|---|
|
||||||
|
| 店铺绑定 + 连通性校验 | ✅ | |
|
||||||
|
| 类目树 + 属性 + 值字典(缓存) | ✅ | |
|
||||||
|
| 属性映射 UI(自动 + 人工) | ✅ 基础版 | 智能匹配优化 |
|
||||||
|
| `/v3/product/import` 发布 + 轮询回填 | ✅ | |
|
||||||
|
| `/v1/product/import-by-sku` 跟卖复制 | 🟡 可选 | |
|
||||||
|
| 库存设置(上架) | ❌(提示去后台) | `/v2/products/stocks` |
|
||||||
|
| 价格/库存批量更新 | ❌ | `/v1/product/import/prices`、`/v2/products/stocks` |
|
||||||
|
| 图片更新(换图) | ❌ | `/v1/product/pictures/import` |
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Seller Helper - 1688/淘宝采集插件
|
||||||
|
|
||||||
|
采集 1688/淘宝商品信息和图片到本地文件夹。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
然后:
|
||||||
|
1. 打开 Chrome 扩展管理页面:`chrome://extensions/`
|
||||||
|
2. 开启"开发者模式"
|
||||||
|
3. 点击"加载已解压的扩展程序"
|
||||||
|
4. 选择 `extension/.output/chrome-mv3`
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 打开任意 1688 或淘宝商品页
|
||||||
|
2. 点击扩展图标,打开侧边栏
|
||||||
|
3. 点击"开始采集"
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
extension/
|
||||||
|
├── entrypoints/
|
||||||
|
│ ├── background.ts # Service Worker(代理图片 fetch)
|
||||||
|
│ ├── sidepanel/ # 采集控制 UI
|
||||||
|
│ └── content/ # 注入到商品页
|
||||||
|
├── src/
|
||||||
|
│ ├── profiles/
|
||||||
|
│ │ ├── types.ts # SiteProfile 类型定义
|
||||||
|
│ │ └── 1688.ts # 1688 采集配置(生产验证)
|
||||||
|
│ ├── collector/
|
||||||
|
│ │ └── url.ts # URL 工具(CDN 后缀处理)
|
||||||
|
│ └── schema/
|
||||||
|
│ └── product.ts # product.json 类型
|
||||||
|
├── wxt.config.ts
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
✅ **M1 完成** - 采集引擎核心
|
||||||
|
- ✅ 1688 profile(选择器来自 v1.1.8 生产 bundle)
|
||||||
|
- ✅ 淘宝 profile(复用阿里系 CDN 规则)
|
||||||
|
- ✅ URL 工具链(CDN 后缀处理、去重 key)
|
||||||
|
- ✅ 图片提取(主图/SKU/详情/视频)
|
||||||
|
- ✅ 文本提取(标题/价格/参数表/描述)
|
||||||
|
- ✅ DOM 等待 + Shadow DOM 穿透
|
||||||
|
- ✅ Side Panel UI(展示采集结果)
|
||||||
|
- ✅ Console 可测试:`window.__SellerHelper.scan()`
|
||||||
|
|
||||||
|
🔨 **待实现(M3)**:
|
||||||
|
- [ ] File System Access 写盘(选目录 + 生成 product.json)
|
||||||
|
- [ ] sources.json 去重(二次采集追加不重复)
|
||||||
|
- [ ] 图片批量勾选与预览
|
||||||
|
- [ ] 文件夹管理(新建/切换)
|
||||||
|
|
||||||
|
## 测试方法
|
||||||
|
|
||||||
|
### 方法 1: Side Panel(推荐)
|
||||||
|
|
||||||
|
1. 打开任意 1688/淘宝商品详情页
|
||||||
|
2. 点击扩展图标 → Side Panel 打开
|
||||||
|
3. 滚动页面到底部(加载详情图)
|
||||||
|
4. 点击"开始采集"
|
||||||
|
5. 查看采集结果(文本数量、图片分组统计、警告)
|
||||||
|
|
||||||
|
### 方法 2: Console 测试
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 在 1688/淘宝商品详情页的 Console 中执行
|
||||||
|
const result = await window.__SellerHelper.scan();
|
||||||
|
console.table(result.texts);
|
||||||
|
console.table(result.images);
|
||||||
|
console.log('stats:', result.stats);
|
||||||
|
console.log('warnings:', result.warnings);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 采集重点
|
||||||
|
|
||||||
|
**必采**(高价值 + 高成功率):
|
||||||
|
- ✅ 标题(100%)
|
||||||
|
- ✅ 主图(100%)
|
||||||
|
- ✅ SKU 图 + 规格名(95%)
|
||||||
|
- ✅ 详情图(90%,需滚动)
|
||||||
|
- ✅ 视频(80%)
|
||||||
|
|
||||||
|
**可选**(保留在 `_raw` 供 studio 参考):
|
||||||
|
- 🟢 价格(90%)
|
||||||
|
- 🟢 参数表(80%)—— 1688 的参数不对应 Ozon 属性 ID
|
||||||
|
- 🟢 详情文案(70%)—— 中文,需翻译
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [总体架构](../../docs/architecture.md)
|
||||||
|
- [1688/淘宝实施计划](../../docs/extension/1688-taobao-implementation.md)
|
||||||
|
- [插件原方案](../../docs/extension/plan.md)(1688 插件逆向分析)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Background Service Worker - 唯一出网口(绕 CORS 取图)
|
||||||
|
export default defineBackground(() => {
|
||||||
|
console.log('Seller Helper background started');
|
||||||
|
|
||||||
|
// 点击扩展图标 → 打开 Side Panel
|
||||||
|
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||||
|
|
||||||
|
// 代理图片 fetch(CORS 绕过)
|
||||||
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
|
if (msg.action === 'fetchImage') {
|
||||||
|
fetchImageAsBlob(msg.url)
|
||||||
|
.then(blob => sendResponse({ ok: true, blob }))
|
||||||
|
.catch(err => sendResponse({ ok: false, error: err.message }));
|
||||||
|
return true; // 保持异步通道
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchImageAsBlob(url: string): Promise<Blob> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Content Script - 注入到 1688/淘宝商品页
|
||||||
|
import { scanCurrentPage } from '../../src/collector/scan';
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: [
|
||||||
|
'https://detail.1688.com/*',
|
||||||
|
'https://item.taobao.com/*',
|
||||||
|
'https://detail.tmall.com/*'
|
||||||
|
],
|
||||||
|
main() {
|
||||||
|
console.log('[Seller Helper] Content script loaded');
|
||||||
|
|
||||||
|
// 暴露采集入口到全局(供 side panel 调用)
|
||||||
|
(window as any).__SellerHelper = {
|
||||||
|
scan: scanCurrentPage
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Seller Helper] Ready to scan. Call window.__SellerHelper.scan() to test.');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ScanResult } from '../../src/collector/scan';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [status, setStatus] = useState<string>('准备就绪');
|
||||||
|
const [result, setResult] = useState<ScanResult | null>(null);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const handleScan = async () => {
|
||||||
|
setStatus('采集中...');
|
||||||
|
setError('');
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前活跃 tab
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (!tab?.id) {
|
||||||
|
setError('无法获取当前标签页');
|
||||||
|
setStatus('准备就绪');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行采集(调用 content script 暴露的全局函数)
|
||||||
|
const scanResult = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
func: () => (window as any).__SellerHelper?.scan()
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = scanResult[0]?.result;
|
||||||
|
if (!data) {
|
||||||
|
setError('当前页面不支持采集(仅支持 1688/淘宝商品详情页)');
|
||||||
|
setStatus('准备就绪');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(data);
|
||||||
|
setStatus('采集完成');
|
||||||
|
} catch (err) {
|
||||||
|
setError(`采集失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
setStatus('准备就绪');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1rem', fontFamily: 'system-ui', width: '360px' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>Seller Helper</h2>
|
||||||
|
<p style={{ margin: '0.25rem 0', fontSize: '0.875rem', color: '#666' }}>
|
||||||
|
1688/淘宝 商品采集
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
background: status === '采集完成' ? '#f0fff0' : '#f0f0f0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
状态: {status}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
background: '#fff0f0',
|
||||||
|
border: '1px solid #ffcccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: '#cc0000'
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
background: '#1890ff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
onClick={handleScan}
|
||||||
|
disabled={status === '采集中...'}
|
||||||
|
>
|
||||||
|
{status === '采集中...' ? '采集中...' : '开始采集'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div style={{ marginTop: '1rem', fontSize: '0.875rem' }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '0.5rem',
|
||||||
|
background: '#fafafa',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<div><strong>{result.platform}</strong> · {result.itemId}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#999', marginTop: '0.25rem' }}>
|
||||||
|
{new Date(result.scannedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<h3 style={{ margin: '0 0 0.5rem 0', fontSize: '0.875rem' }}>文本 ({result.texts.length})</h3>
|
||||||
|
{result.texts.map((t, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: '#f9f9f9',
|
||||||
|
borderLeft: '3px solid #1890ff',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}>
|
||||||
|
<strong>{t.kind}</strong>: {t.content.substring(0, 50)}...
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<h3 style={{ margin: '0 0 0.5rem 0', fontSize: '0.875rem' }}>图片 ({result.images.length})</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{Object.entries(result.stats).map(([group, count]) => (
|
||||||
|
<div key={group} style={{
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: '#e6f7ff',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}>
|
||||||
|
{group}: {count}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.warnings.length > 0 && (
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<h3 style={{ margin: '0 0 0.5rem 0', fontSize: '0.875rem', color: '#ff6600' }}>
|
||||||
|
⚠️ 警告
|
||||||
|
</h3>
|
||||||
|
{result.warnings.map((w, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: '#fff7e6',
|
||||||
|
borderLeft: '3px solid #ff6600',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}>
|
||||||
|
{w}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
background: '#f9f9f9',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#666'
|
||||||
|
}}>
|
||||||
|
<div>💡 使用说明:</div>
|
||||||
|
<ol style={{ margin: '0.25rem 0 0 1.25rem', padding: 0 }}>
|
||||||
|
<li>打开 1688 或淘宝商品详情页</li>
|
||||||
|
<li>滚动到页面底部(加载详情图)</li>
|
||||||
|
<li>点击"开始采集"按钮</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById('root')!);
|
||||||
|
root.render(<App />);
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Seller Helper</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./App.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "seller-helper-extension",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wxt",
|
||||||
|
"build": "wxt build",
|
||||||
|
"zip": "wxt zip"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.268",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"wxt": "^0.19.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
|
||||||
|
}
|
||||||
Generated
+3553
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* DOM 工具 - 等待元素、Shadow DOM 穿透
|
||||||
|
* 从 docs/extension/plan.md §6.5 移植
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待任一选择器出现(MutationObserver + 超时)
|
||||||
|
*/
|
||||||
|
export function waitForAny(
|
||||||
|
selectors: string[],
|
||||||
|
timeoutMs = 10_000
|
||||||
|
): Promise<Element | null> {
|
||||||
|
const hit = () => selectors.map(s => document.querySelector(s)).find(Boolean) ?? null;
|
||||||
|
|
||||||
|
const found = hit();
|
||||||
|
if (found) return Promise.resolve(found);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
resolve(null); // 超时返回 null
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const el = hit();
|
||||||
|
if (el) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
observer.disconnect();
|
||||||
|
resolve(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 穿透 Shadow DOM 查询元素
|
||||||
|
* 1688 部分组件用了 Web Components
|
||||||
|
*/
|
||||||
|
export function queryAllDeep(selectors: string[]): Element[] {
|
||||||
|
const out: Element[] = [];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
let nodes: NodeListOf<Element>;
|
||||||
|
try {
|
||||||
|
nodes = document.querySelectorAll(sel);
|
||||||
|
} catch {
|
||||||
|
continue; // 选择器写错不能拖垮整个扫描
|
||||||
|
}
|
||||||
|
nodes.forEach(el => {
|
||||||
|
if (el.shadowRoot) {
|
||||||
|
out.push(...Array.from(el.shadowRoot.querySelectorAll('img, video')));
|
||||||
|
} else {
|
||||||
|
out.push(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 图片提取 - 主图、SKU、详情图、视频
|
||||||
|
* 从 docs/extension/plan.md §6.4 移植(核心逻辑)
|
||||||
|
*/
|
||||||
|
import { toAbsoluteUrl, toOriginalUrl, urlInBrackets, looksLikeImageUrl, dedupeKey } from './url';
|
||||||
|
import { queryAllDeep } from './dom';
|
||||||
|
import type { ImageGroupRule, SiteProfile, SrcProp } from '../profiles/types';
|
||||||
|
|
||||||
|
export interface ImageMaterial {
|
||||||
|
key: string; // 'main-001'
|
||||||
|
groupKey: string; // 'main'
|
||||||
|
groupName: string; // '主图'
|
||||||
|
variantName?: string; // SKU 规格名(仅 sku 组)
|
||||||
|
url: string; // 已还原为原图
|
||||||
|
thumbUrl: string; // 页面上的原始小图地址
|
||||||
|
index: number;
|
||||||
|
type: 'img' | 'video';
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从元素上读出图片地址与名称,按 srcProps 顺序降级
|
||||||
|
*/
|
||||||
|
function readImageSource(
|
||||||
|
el: Element,
|
||||||
|
srcProps: SrcProp[],
|
||||||
|
nameSelectors?: string[]
|
||||||
|
): { url: string; name: string; imgEl: HTMLImageElement | null } {
|
||||||
|
let url = '';
|
||||||
|
let name = '';
|
||||||
|
// 真正承载图片的元素。选择器命中容器时它是子 <img>,
|
||||||
|
// 尺寸过滤必须量它而不是容器,否则容器的 offsetWidth 会让小图蒙混过关
|
||||||
|
let imgEl: HTMLImageElement | null = el instanceof HTMLImageElement ? el : null;
|
||||||
|
|
||||||
|
for (const prop of srcProps) {
|
||||||
|
if (url) break;
|
||||||
|
|
||||||
|
if (prop === 'backgroundImage') {
|
||||||
|
// SKU 组常用 CSS 背景图
|
||||||
|
if (el.tagName === 'IMG') {
|
||||||
|
const img = el as HTMLImageElement;
|
||||||
|
url = img.currentSrc || img.src || '';
|
||||||
|
name = img.alt || '';
|
||||||
|
} else {
|
||||||
|
// 尝试多种 SKU DOM 结构
|
||||||
|
const bgCandidates = ['.prop-img', '.sku-item-image', '.single-sku-img-pop', '.item-image-icon'];
|
||||||
|
for (const sel of bgCandidates) {
|
||||||
|
const node = el.querySelector(sel);
|
||||||
|
if (!node) continue;
|
||||||
|
if (node instanceof HTMLImageElement && node.src) {
|
||||||
|
url = node.src;
|
||||||
|
} else {
|
||||||
|
const bg = getComputedStyle(node).backgroundImage || '';
|
||||||
|
url = (urlInBrackets(bg) || bg).replace(/['"]/g, '');
|
||||||
|
}
|
||||||
|
if (url) break;
|
||||||
|
}
|
||||||
|
// 兜底:元素自身背景图
|
||||||
|
if (!url) {
|
||||||
|
const bg = getComputedStyle(el).backgroundImage || '';
|
||||||
|
const cand = (urlInBrackets(bg) || bg).replace(/['"]/g, '');
|
||||||
|
if (looksLikeImageUrl(cand)) url = cand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const raw = (el as any)[prop] || el.getAttribute(prop);
|
||||||
|
if (raw) {
|
||||||
|
// srcset 场景下 currentSrc 才是实际加载的那张
|
||||||
|
url = prop === 'src' ? ((el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || '') : raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择器命中的是容器、图在子节点上(淘宝 valueItem-- 就是这种)
|
||||||
|
// 1688 的 SKU 是 CSS 背景图,走不到这里;淘宝的是真实 <img>,靠这段兜住
|
||||||
|
if (!url && el.tagName !== 'IMG') {
|
||||||
|
const inner = el.querySelector('img');
|
||||||
|
if (inner) {
|
||||||
|
url = inner.getAttribute('data-src') || inner.currentSrc || inner.src || '';
|
||||||
|
imgEl = inner;
|
||||||
|
if (!name) name = inner.alt || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称统一取(SKU 规格名)
|
||||||
|
if (!name && nameSelectors?.length) {
|
||||||
|
for (const sel of nameSelectors) {
|
||||||
|
const t = el.querySelector(sel)?.textContent?.trim();
|
||||||
|
if (t) { name = t; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: url ? toAbsoluteUrl(url) : '', name, imgEl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 占位图识别。阿里系用 `-tps-1-1.png` / `-tps-2-2.png` 这类极小透明图
|
||||||
|
* 占位,真实地址要等懒加载。采到它们等于污染数据。
|
||||||
|
*/
|
||||||
|
function isPlaceholder(url: string, imgEl: HTMLImageElement | null): boolean {
|
||||||
|
if (/-tps-\d-\d\.(png|gif)/i.test(url)) return true;
|
||||||
|
if (/^data:image\/gif/i.test(url)) return true;
|
||||||
|
// 已加载完成但尺寸只有几像素 → 占位图
|
||||||
|
if (imgEl?.complete && imgEl.naturalWidth > 0 && imgEl.naturalWidth <= 4) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectImages(profile: SiteProfile): ImageMaterial[] {
|
||||||
|
const result: ImageMaterial[] = [];
|
||||||
|
|
||||||
|
for (const group of profile.imageGroups) {
|
||||||
|
const srcProps = group.srcProps ?? profile.defaultSrcProps;
|
||||||
|
|
||||||
|
// 去重按组独立:一张图同时是主图和 SKU 图是正常的,
|
||||||
|
// 全局去重会让后处理的组丢图(连带丢掉 SKU 规格名)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
// 排除"当前高亮"元素(画廊)
|
||||||
|
const activeSet = new Set(group.activeSelectors ? queryAllDeep(group.activeSelectors) : []);
|
||||||
|
|
||||||
|
for (const el of queryAllDeep(group.selectors)) {
|
||||||
|
if (activeSet.has(el)) continue;
|
||||||
|
|
||||||
|
// 跳过位于排除容器内的元素(如详情区里的用户评价图)
|
||||||
|
if (group.excludeWithin?.some(sel => el.closest(sel))) continue;
|
||||||
|
|
||||||
|
const { url: rawUrl, name, imgEl } = readImageSource(el, srcProps, group.nameSelectors);
|
||||||
|
if (!rawUrl) continue;
|
||||||
|
|
||||||
|
// 占位图不能进结果——它不是商品图
|
||||||
|
if (group.type === 'img' && isPlaceholder(rawUrl, imgEl)) continue;
|
||||||
|
|
||||||
|
// 视频校验
|
||||||
|
if (group.type === 'video' && !/\.(mp4|avi|mov|wmv|m3u8)(\?|$)/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = group.type === 'img' ? toOriginalUrl(rawUrl) : rawUrl;
|
||||||
|
|
||||||
|
// 尺寸过滤。量真正承载图片的 <img>,不是外层容器——
|
||||||
|
// 否则容器的 offsetWidth 会让 2x2 占位图通过 minWidth 检查
|
||||||
|
if (group.type === 'img' && (group.minWidth || group.minHeight)) {
|
||||||
|
const measured = imgEl ?? (el as HTMLElement);
|
||||||
|
const w = (measured as HTMLImageElement).naturalWidth || (measured as HTMLElement).offsetWidth || 0;
|
||||||
|
const h = (measured as HTMLImageElement).naturalHeight || (measured as HTMLElement).offsetHeight || 0;
|
||||||
|
// 尺寸为 0 说明还没加载完,放过它(别误杀懒加载图)
|
||||||
|
if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重。SKU 组把规格名并入 key——不同规格共用同一张图时
|
||||||
|
// 两条都要留下,否则规格与图的对应关系就断了
|
||||||
|
const k = group.key === 'sku' ? `${dedupeKey(url)}::${name}` : dedupeKey(url);
|
||||||
|
if (seen.has(k)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
key: `${group.key}-${String(result.filter(r => r.groupKey === group.key).length + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: group.key,
|
||||||
|
groupName: group.name,
|
||||||
|
variantName: group.key === 'sku' ? name || undefined : undefined,
|
||||||
|
url,
|
||||||
|
thumbUrl: rawUrl,
|
||||||
|
index: result.length,
|
||||||
|
type: group.type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* 采集引擎入口 - 扫描当前页面
|
||||||
|
* 从 docs/extension/plan.md §6.7 移植(组装各模块)
|
||||||
|
*
|
||||||
|
* 淘宝/天猫优先从 SSR JSON 提取(window.__ICE_APP_CONTEXT__),
|
||||||
|
* 提取失败时降级到 DOM 采集。
|
||||||
|
*/
|
||||||
|
import { matchProfile } from '../profiles';
|
||||||
|
import { waitForAny } from './dom';
|
||||||
|
import { collectImages, type ImageMaterial } from './image';
|
||||||
|
import { collectTexts, type TextMaterial } from './text';
|
||||||
|
import { extractSSRData } from './ssr';
|
||||||
|
import { buildFromSSR } from './ssr-builder';
|
||||||
|
|
||||||
|
export type { ImageMaterial, TextMaterial };
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
platform: string;
|
||||||
|
itemId: string | null;
|
||||||
|
url: string;
|
||||||
|
texts: TextMaterial[];
|
||||||
|
images: ImageMaterial[];
|
||||||
|
scannedAt: number;
|
||||||
|
stats: Record<string, number>; // 分组统计
|
||||||
|
warnings: string[]; // 警告(如详情图为 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ImageMaterial, TextMaterial };
|
||||||
|
|
||||||
|
export async function scanCurrentPage(): Promise<ScanResult | null> {
|
||||||
|
const profile = matchProfile(location.href);
|
||||||
|
if (!profile) {
|
||||||
|
console.warn('[Seller Helper] 当前页面不支持采集:', location.href);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Seller Helper] 开始采集:', profile.name, location.href);
|
||||||
|
|
||||||
|
// ★ 淘宝/天猫优先从 SSR JSON 提取
|
||||||
|
const ssrData = extractSSRData();
|
||||||
|
if (ssrData) {
|
||||||
|
console.log('[Seller Helper] 使用 SSR 数据(JSON)');
|
||||||
|
const result = buildFromSSR(ssrData, profile);
|
||||||
|
console.log('[Seller Helper] SSR 采集完成:', {
|
||||||
|
texts: result.texts.length,
|
||||||
|
images: result.images.length,
|
||||||
|
stats: result.stats,
|
||||||
|
warnings: result.warnings
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到 DOM 采集
|
||||||
|
console.log('[Seller Helper] SSR 数据不可用,降级到 DOM 采集');
|
||||||
|
|
||||||
|
// 等待页面就绪
|
||||||
|
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
||||||
|
if (!anchor) {
|
||||||
|
console.warn('[Seller Helper] 等待页面就绪超时');
|
||||||
|
// 不 return,页面可能部分可用,继续尝试
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取文本
|
||||||
|
const { materials: texts, missingRequired } = collectTexts(profile);
|
||||||
|
|
||||||
|
// 提取图片
|
||||||
|
const images = collectImages(profile);
|
||||||
|
|
||||||
|
// 统计各组数量
|
||||||
|
const stats: Record<string, number> = {};
|
||||||
|
for (const img of images) {
|
||||||
|
stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成警告
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (missingRequired.length > 0) {
|
||||||
|
warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (images.length === 0) {
|
||||||
|
warnings.push('未扫描到任何图片/视频');
|
||||||
|
}
|
||||||
|
if (stats.detail === 0) {
|
||||||
|
warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Seller Helper] 采集完成:', {
|
||||||
|
texts: texts.length,
|
||||||
|
images: images.length,
|
||||||
|
stats,
|
||||||
|
warnings
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: profile.id,
|
||||||
|
itemId: profile.extractItemId(location.href),
|
||||||
|
url: location.href,
|
||||||
|
texts,
|
||||||
|
images,
|
||||||
|
scannedAt: Date.now(),
|
||||||
|
stats,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露到全局供 side panel 调用
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as any).__SellerHelper = {
|
||||||
|
scan: scanCurrentPage
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* 从 SSR JSON 构建 ScanResult
|
||||||
|
*/
|
||||||
|
import { toOriginalUrl } from './url';
|
||||||
|
import type { SiteProfile } from '../profiles/types';
|
||||||
|
import type { SSRData } from './ssr';
|
||||||
|
|
||||||
|
// 直接定义类型避免循环依赖
|
||||||
|
interface TextMaterial {
|
||||||
|
kind: 'title' | 'price' | 'params' | 'desc';
|
||||||
|
content: string;
|
||||||
|
pairs?: Array<{ key: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageMaterial {
|
||||||
|
key: string;
|
||||||
|
groupKey: string;
|
||||||
|
groupName: string;
|
||||||
|
variantName?: string;
|
||||||
|
url: string;
|
||||||
|
thumbUrl: string;
|
||||||
|
index: number;
|
||||||
|
type: 'img' | 'video';
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanResult {
|
||||||
|
platform: string;
|
||||||
|
itemId: string | null;
|
||||||
|
url: string;
|
||||||
|
texts: TextMaterial[];
|
||||||
|
images: ImageMaterial[];
|
||||||
|
scannedAt: number;
|
||||||
|
stats: Record<string, number>;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
|
||||||
|
const texts: TextMaterial[] = [];
|
||||||
|
const images: ImageMaterial[] = [];
|
||||||
|
|
||||||
|
// 1. 标题(必需)
|
||||||
|
texts.push({
|
||||||
|
kind: 'title',
|
||||||
|
content: data.item.title
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 价格
|
||||||
|
if (data.price?.priceText) {
|
||||||
|
texts.push({
|
||||||
|
kind: 'price',
|
||||||
|
content: `¥${data.price.priceText}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 参数表
|
||||||
|
const allParams = [
|
||||||
|
...(data.params?.basicParamList || []),
|
||||||
|
...(data.params?.enhanceParamList || [])
|
||||||
|
];
|
||||||
|
if (allParams.length > 0) {
|
||||||
|
const pairs = allParams
|
||||||
|
.filter(p => p.propertyName && p.valueName)
|
||||||
|
.map(p => ({ key: p.propertyName, value: p.valueName }));
|
||||||
|
|
||||||
|
if (pairs.length > 0) {
|
||||||
|
texts.push({
|
||||||
|
kind: 'params',
|
||||||
|
content: pairs.map(p => `${p.key}: ${p.value}`).join('\n'),
|
||||||
|
pairs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 主图(item.images)
|
||||||
|
let idx = 0;
|
||||||
|
(data.item.images || []).forEach((url, i) => {
|
||||||
|
if (!url) return;
|
||||||
|
const origUrl = toOriginalUrl(url);
|
||||||
|
images.push({
|
||||||
|
key: `main-${String(i + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: 'main',
|
||||||
|
groupName: '主图',
|
||||||
|
url: origUrl,
|
||||||
|
thumbUrl: url,
|
||||||
|
index: idx++,
|
||||||
|
type: 'img'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. SKU 图(skuBase.props[0].values)
|
||||||
|
// 淘宝/天猫通常只有一个规格维度(颜色分类),取 props[0]
|
||||||
|
const skuProp = data.skuBase?.props?.[0];
|
||||||
|
if (skuProp?.values) {
|
||||||
|
skuProp.values.forEach((v, i) => {
|
||||||
|
if (!v.image) return; // 有些 SKU 没配图(如天猫那个 vid=43699206432)
|
||||||
|
const origUrl = toOriginalUrl(v.image);
|
||||||
|
images.push({
|
||||||
|
key: `sku-${String(i + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: 'sku',
|
||||||
|
groupName: 'SKU图片',
|
||||||
|
variantName: v.name || undefined,
|
||||||
|
url: origUrl,
|
||||||
|
thumbUrl: v.image,
|
||||||
|
index: idx++,
|
||||||
|
type: 'img'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 视频(item.videos)
|
||||||
|
(data.item.videos || []).forEach((v, i) => {
|
||||||
|
if (!v.url) return;
|
||||||
|
images.push({
|
||||||
|
key: `video-${String(i + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: 'video',
|
||||||
|
groupName: '视频',
|
||||||
|
url: v.url,
|
||||||
|
thumbUrl: v.videoThumbnailURL || v.url,
|
||||||
|
index: idx++,
|
||||||
|
type: 'video'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统计各组数量
|
||||||
|
const stats: Record<string, number> = {};
|
||||||
|
for (const img of images) {
|
||||||
|
stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成警告
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (texts.length === 0) {
|
||||||
|
warnings.push('未提取到任何文本');
|
||||||
|
}
|
||||||
|
if (images.length === 0) {
|
||||||
|
warnings.push('未扫描到任何图片/视频');
|
||||||
|
}
|
||||||
|
// SSR 数据里没有详情图,需要 DOM 补充
|
||||||
|
if (stats.detail === undefined) {
|
||||||
|
warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: profile.id,
|
||||||
|
itemId: data.item.itemId,
|
||||||
|
url: location.href,
|
||||||
|
texts,
|
||||||
|
images,
|
||||||
|
scannedAt: Date.now(),
|
||||||
|
stats,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* SSR 数据提取器 - 淘宝/天猫页面内嵌 JSON
|
||||||
|
*
|
||||||
|
* 页面 HTML 里有完整商品数据挂在 window.__ICE_APP_CONTEXT__,
|
||||||
|
* 包含标题、主图、SKU(图+名)、价格、参数,比 DOM 采集稳定 10 倍:
|
||||||
|
* - 不受懒加载影响
|
||||||
|
* - 不受改版影响(JSON 结构远比 CSS 类名稳定)
|
||||||
|
* - 一次拿全所有 SKU,无需滚动
|
||||||
|
*
|
||||||
|
* 当前只支持淘宝/天猫(__ICE_APP_CONTEXT__),
|
||||||
|
* 其他平台返回 null,触发 DOM 降级。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SSRData {
|
||||||
|
item: {
|
||||||
|
title: string;
|
||||||
|
itemId: string;
|
||||||
|
images: string[];
|
||||||
|
videos?: Array<{ url: string; videoThumbnailURL?: string }>;
|
||||||
|
};
|
||||||
|
skuBase?: {
|
||||||
|
props: Array<{
|
||||||
|
pid: string;
|
||||||
|
name: string; // "颜色分类" / "商品规格"
|
||||||
|
values: Array<{
|
||||||
|
vid: string;
|
||||||
|
name: string; // SKU 规格名
|
||||||
|
image?: string; // SKU 图片
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
params?: {
|
||||||
|
basicParamList?: Array<{ propertyName: string; valueName: string }>;
|
||||||
|
enhanceParamList?: Array<{ propertyName: string; valueName: string }>;
|
||||||
|
};
|
||||||
|
price?: {
|
||||||
|
priceText?: string;
|
||||||
|
priceMoney?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试从页面提取 SSR 数据(淘宝/天猫 __ICE_APP_CONTEXT__)
|
||||||
|
*/
|
||||||
|
export function extractSSRData(): SSRData | null {
|
||||||
|
try {
|
||||||
|
const ctx = (window as any).__ICE_APP_CONTEXT__;
|
||||||
|
if (!ctx?.loaderData?.home?.data?.res) return null;
|
||||||
|
|
||||||
|
const res = ctx.loaderData.home.data.res;
|
||||||
|
|
||||||
|
// 基础结构验证
|
||||||
|
if (!res.item?.title || !res.item?.itemId) return null;
|
||||||
|
|
||||||
|
// 提取参数(两个来源都试)
|
||||||
|
const industryParams = res.plusViewVO?.industryParamVO;
|
||||||
|
const extensionParams = res.componentsVO?.extensionInfoVO?.infos?.find(
|
||||||
|
(i: any) => i.type === 'BASE_PROPS'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
title: res.item.title,
|
||||||
|
itemId: res.item.itemId,
|
||||||
|
images: res.item.images || [],
|
||||||
|
videos: res.item.videos
|
||||||
|
},
|
||||||
|
skuBase: res.skuBase,
|
||||||
|
params: {
|
||||||
|
basicParamList: industryParams?.basicParamList || extensionParams?.items || [],
|
||||||
|
enhanceParamList: industryParams?.enhanceParamList || []
|
||||||
|
},
|
||||||
|
price: res.componentsVO?.priceVO?.price || res.componentsVO?.priceVO?.extraPrice
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SSR] 提取失败:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 文本提取 - 标题、价格、参数表、描述
|
||||||
|
* 从 docs/extension/plan.md §6.6 移植(简化版)
|
||||||
|
*/
|
||||||
|
import type { SiteProfile, TextRule } from '../profiles/types';
|
||||||
|
|
||||||
|
export interface TextMaterial {
|
||||||
|
kind: TextRule['kind'];
|
||||||
|
content: string;
|
||||||
|
pairs?: Array<{ key: string; value: string }>; // table 模式的结构化结果
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(s: string): string {
|
||||||
|
return s.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOne(rule: TextRule): TextMaterial | null {
|
||||||
|
for (const sel of rule.selectors) {
|
||||||
|
let nodes: NodeListOf<Element>;
|
||||||
|
try {
|
||||||
|
nodes = document.querySelectorAll(sel);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!nodes.length) continue;
|
||||||
|
|
||||||
|
// table 模式:参数表
|
||||||
|
if (rule.extract === 'table') {
|
||||||
|
const pairs: Array<{ key: string; value: string }> = [];
|
||||||
|
nodes.forEach(row => {
|
||||||
|
const k = clean(row.querySelector(rule.tableKeySelector ?? '')?.textContent ?? '');
|
||||||
|
const v = clean(row.querySelector(rule.tableValueSelector ?? '')?.textContent ?? '');
|
||||||
|
if (k && v) pairs.push({ key: k.replace(/[::]$/, ''), value: v });
|
||||||
|
});
|
||||||
|
if (pairs.length) {
|
||||||
|
return {
|
||||||
|
kind: rule.kind,
|
||||||
|
content: pairs.map(p => `${p.key}: ${p.value}`).join('\n'),
|
||||||
|
pairs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// join 模式:标题被拆成多个 span
|
||||||
|
if (rule.extract === 'join') {
|
||||||
|
let text = '';
|
||||||
|
nodes.forEach(n => { text += n.textContent ?? ''; });
|
||||||
|
text = clean(text);
|
||||||
|
if (text) return { kind: rule.kind, content: text };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// first 模式:只取第一个
|
||||||
|
const first = clean(nodes[0].textContent ?? '');
|
||||||
|
if (first) return { kind: rule.kind, content: first };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectTexts(profile: SiteProfile): {
|
||||||
|
materials: TextMaterial[];
|
||||||
|
missingRequired: string[];
|
||||||
|
} {
|
||||||
|
const materials: TextMaterial[] = [];
|
||||||
|
const missingRequired: string[] = [];
|
||||||
|
|
||||||
|
for (const rule of profile.textRules) {
|
||||||
|
const m = extractOne(rule);
|
||||||
|
if (m) materials.push(m);
|
||||||
|
else if (rule.required) missingRequired.push(rule.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { materials, missingRequired };
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* URL 工具链 - 处理阿里系 CDN 图片 URL
|
||||||
|
* 从 docs/extension/plan.md §6.3 移植(来自生产代码)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const IMG_EXT = /\.(jpg|jpeg|png|gif|bmp|heic|webp|avif)$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩略图 URL → 原图 URL
|
||||||
|
* 阿里 CDN 尺寸后缀在扩展名后:xxx.jpg_400x400.jpg → xxx.jpg
|
||||||
|
*/
|
||||||
|
export function toOriginalUrl(url: string): string {
|
||||||
|
const m = url.match(/^(.+?\.(jpg|jpeg|png|gif|bmp|heic|webp|avif))_/i);
|
||||||
|
return m ? m[1] : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** url("https://...") → https://... */
|
||||||
|
export function urlInBrackets(s: string): string {
|
||||||
|
if (!s?.trim()) return '';
|
||||||
|
return s.match(/\((.*?)\)/)?.[1]?.replace(/['"]/g, '') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDataUrl(u: string): boolean {
|
||||||
|
return /^data:image/.test(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 协议相对 // / 根相对 / / 相对路径 → 绝对 URL */
|
||||||
|
export function toAbsoluteUrl(u: string): string {
|
||||||
|
if (!u) return u;
|
||||||
|
if (isDataUrl(u) || u.startsWith('blob:')) return u;
|
||||||
|
const proto = u.startsWith('http:') ? 'http' : 'https';
|
||||||
|
if (/^\/\//.test(u)) return `${proto}:${u}`;
|
||||||
|
if (/^\//.test(u)) return `${location.origin}${u}`;
|
||||||
|
if (!/^(.*):/.test(u)) return `${location.origin}/${u}`;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去重用的归一化 key:剥掉尺寸后缀和 query */
|
||||||
|
export function dedupeKey(url: string): string {
|
||||||
|
const base = toOriginalUrl(url);
|
||||||
|
try {
|
||||||
|
const u = new URL(base);
|
||||||
|
u.search = '';
|
||||||
|
u.hash = '';
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeImageUrl(u: string): boolean {
|
||||||
|
if (isDataUrl(u)) return true;
|
||||||
|
try {
|
||||||
|
return IMG_EXT.test(new URL(u).pathname);
|
||||||
|
} catch {
|
||||||
|
return IMG_EXT.test(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清洗文件名非法字符(Windows 兼容) */
|
||||||
|
export function cleanFilename(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[<>:"/\\|?*]/g, '_')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.substring(0, 80);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 1688 采集配置
|
||||||
|
* 从 docs/extension/plan.md §6.2 移植(选择器来自 v1.1.8 生产 bundle)
|
||||||
|
*/
|
||||||
|
import type { SiteProfile } from './types';
|
||||||
|
|
||||||
|
export const profile1688: SiteProfile = {
|
||||||
|
id: '1688',
|
||||||
|
name: '1688',
|
||||||
|
|
||||||
|
urlPatterns: [/^https:\/\/detail\.1688\.com\/offer\/\d+\.html/],
|
||||||
|
|
||||||
|
extractItemId: (url) => url.match(/\/offer\/(\d+)\.html/)?.[1] ?? null,
|
||||||
|
|
||||||
|
readySelectors: ['.title-content', '#dt-tab', '#screen', '#content'],
|
||||||
|
readyTimeoutMs: 10_000,
|
||||||
|
|
||||||
|
// 懒加载真实地址在 data-* 上(顺序不能动)
|
||||||
|
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
|
||||||
|
|
||||||
|
refererOrigin: 'https://www.1688.com',
|
||||||
|
|
||||||
|
textRules: [
|
||||||
|
{
|
||||||
|
kind: 'title',
|
||||||
|
// 标题被拆成多个 .title-text span,必须 join
|
||||||
|
selectors: ['.title-content .title-text', '.title-content h1', '.od-pc-offer-title', 'h1'],
|
||||||
|
extract: 'join',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'price',
|
||||||
|
selectors: ['.price-original', '.od-pc-offer-price-priceRange', '.price .value'],
|
||||||
|
extract: 'first'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'params',
|
||||||
|
selectors: [
|
||||||
|
'.offer-attr-list .offer-attr-item',
|
||||||
|
'.od-pc-attribute-table tr',
|
||||||
|
'.obj-content .table-tr'
|
||||||
|
],
|
||||||
|
extract: 'table',
|
||||||
|
tableKeySelector: '.offer-attr-item-name, td:first-child, .table-th',
|
||||||
|
tableValueSelector: '.offer-attr-item-value, td:last-child, .table-td'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'desc',
|
||||||
|
selectors: ['.de-description-detail', '#detailContentContainer', '.html-description'],
|
||||||
|
extract: 'join'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
imageGroups: [
|
||||||
|
{
|
||||||
|
key: 'main',
|
||||||
|
name: '主图',
|
||||||
|
type: 'img',
|
||||||
|
// 四套画廊变体(说明 1688 至少有四个线上版本)
|
||||||
|
selectors: [
|
||||||
|
'#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img',
|
||||||
|
'#screen .od-gallery-turn-item-wrapper .od-gallery-img',
|
||||||
|
'#content .od-scroller-item .v-image-cover',
|
||||||
|
'#content .od-picture-gallery-list .v-image-cover',
|
||||||
|
'#dt-tab img',
|
||||||
|
'.detail-gallery-turn img.detail-gallery-img',
|
||||||
|
'.img-list-wrapper img.od-gallery-img'
|
||||||
|
],
|
||||||
|
activeSelectors: [
|
||||||
|
'.detail-gallery-turn-wrapper.prepic-active .detail-gallery-img',
|
||||||
|
'.od-gallery-turn-item-wrapper.prepic-active .od-gallery-img',
|
||||||
|
'.v-image-cover.image-item-active'
|
||||||
|
],
|
||||||
|
minWidth: 200,
|
||||||
|
minHeight: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sku',
|
||||||
|
name: 'SKU图片',
|
||||||
|
type: 'img',
|
||||||
|
selectors: [
|
||||||
|
'.pc-sku-wrapper .prop-item-inner-wrapper',
|
||||||
|
'.sku-item-wrapper',
|
||||||
|
'.specification-cell',
|
||||||
|
'.sku-filter-button',
|
||||||
|
'.expand-view-item',
|
||||||
|
'.feature-item img'
|
||||||
|
],
|
||||||
|
// SKU 缩略图是 CSS 背景图
|
||||||
|
srcProps: ['backgroundImage'],
|
||||||
|
// 规格名(五种 DOM 结构)
|
||||||
|
nameSelectors: ['.prop-name', '.sku-item-name', '.item-label', '.label-name', '.normal-text'],
|
||||||
|
minWidth: 20,
|
||||||
|
minHeight: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'detail',
|
||||||
|
name: '详情图',
|
||||||
|
type: 'img',
|
||||||
|
selectors: [
|
||||||
|
'.de-description-detail img',
|
||||||
|
'#detailContentContainer img',
|
||||||
|
'.html-description img'
|
||||||
|
],
|
||||||
|
minWidth: 300,
|
||||||
|
minHeight: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'video',
|
||||||
|
name: '视频',
|
||||||
|
type: 'video',
|
||||||
|
selectors: ['.lib-video video', 'video']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Profile 路由 - 根据 URL 匹配平台
|
||||||
|
*/
|
||||||
|
import type { SiteProfile } from './types';
|
||||||
|
import { profile1688 } from './1688';
|
||||||
|
import { profileTaobao } from './taobao';
|
||||||
|
|
||||||
|
const PROFILES: SiteProfile[] = [
|
||||||
|
profile1688,
|
||||||
|
profileTaobao
|
||||||
|
];
|
||||||
|
|
||||||
|
export function matchProfile(url: string): SiteProfile | null {
|
||||||
|
for (const p of PROFILES) {
|
||||||
|
if (p.urlPatterns.some(re => re.test(url))) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { profile1688, profileTaobao };
|
||||||
|
export type { SiteProfile };
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 淘宝 / 天猫采集配置
|
||||||
|
*
|
||||||
|
* 选择器全部来自真实页面实测(2026-08-11,两个商品页各跑一轮反向探测):
|
||||||
|
* 天猫 detail.tmall.com/item.htm?id=960057430812
|
||||||
|
* 淘宝 item.taobao.com/item.htm?id=1060253247160
|
||||||
|
* 两站 DOM 完全一致(同一套前端),一份 profile 覆盖。
|
||||||
|
*
|
||||||
|
* 类名是 CSS Modules 的 `语义前缀--哈希` 形式,哈希每次构建都变,
|
||||||
|
* 所以一律用 `[class*="前缀--"]` 前缀匹配。
|
||||||
|
*
|
||||||
|
* 结尾那个 `--` 不能省——它把父容器和子元素区分开:
|
||||||
|
* `generalParamsInfoItem--` 不会误命中 `generalParamsInfoItemTitle--`。
|
||||||
|
*
|
||||||
|
* 实测证据见 docs/extension/selectors-taobao.md
|
||||||
|
*/
|
||||||
|
import type { SiteProfile } from './types';
|
||||||
|
|
||||||
|
export const profileTaobao: SiteProfile = {
|
||||||
|
id: 'taobao',
|
||||||
|
name: '淘宝/天猫',
|
||||||
|
|
||||||
|
urlPatterns: [
|
||||||
|
/^https:\/\/item\.taobao\.com\/item\.htm/,
|
||||||
|
/^https:\/\/detail\.tmall\.com\/item\.htm/,
|
||||||
|
],
|
||||||
|
|
||||||
|
extractItemId: (url) => url.match(/[?&]id=(\d+)/)?.[1] ?? null,
|
||||||
|
|
||||||
|
// 页面上没有 <h1>,别再拿它探活
|
||||||
|
readySelectors: [
|
||||||
|
'[class*="mainTitle--"]',
|
||||||
|
'[class*="picGallery--"]',
|
||||||
|
'#picGalleryEle',
|
||||||
|
],
|
||||||
|
readyTimeoutMs: 10_000,
|
||||||
|
|
||||||
|
// 阿里系 CDN 规则与 1688 相同
|
||||||
|
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
|
||||||
|
|
||||||
|
refererOrigin: 'https://www.taobao.com',
|
||||||
|
|
||||||
|
textRules: [
|
||||||
|
{
|
||||||
|
kind: 'title',
|
||||||
|
// mainTitle-- 是纯文本节点(探测里 imgs=0),最干净
|
||||||
|
// ItemTitle-- / MainTitle-- 是外层容器,带图标,作兜底
|
||||||
|
// 注意:属性选择器区分大小写,三个都得写
|
||||||
|
selectors: [
|
||||||
|
'[class*="mainTitle--"]',
|
||||||
|
'[class*="MainTitle--"]',
|
||||||
|
'[class*="ItemTitle--"]',
|
||||||
|
],
|
||||||
|
extract: 'first',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'price',
|
||||||
|
// highlightPrice-- 是当前实际售价,两站一致
|
||||||
|
// priceWrap-- 是外层,会把"优惠前¥36.8"一起带进来,只作兜底
|
||||||
|
selectors: [
|
||||||
|
'[class*="highlightPrice--"]',
|
||||||
|
'[class*="priceWrap--"]',
|
||||||
|
],
|
||||||
|
extract: 'first',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'params',
|
||||||
|
// generalParamsInfoItem-- 每项含 Title(键) + SubTitle(值)
|
||||||
|
selectors: ['[class*="generalParamsInfoItem--"]'],
|
||||||
|
extract: 'table',
|
||||||
|
tableKeySelector: '[class*="ParamsInfoItemTitle--"]',
|
||||||
|
tableValueSelector: '[class*="ParamsInfoItemSubTitle--"]',
|
||||||
|
},
|
||||||
|
// desc 故意不采:详情容器 detailInfo-- 里混着用户评价、参数、图文详情,
|
||||||
|
// join 出来是一坨无法使用的字符串。1688/淘宝的中文文案对 Ozon 价值也低
|
||||||
|
// (见 docs/extension/1688-taobao-implementation.md 采集优先级)。
|
||||||
|
],
|
||||||
|
|
||||||
|
imageGroups: [
|
||||||
|
{
|
||||||
|
key: 'main',
|
||||||
|
name: '主图',
|
||||||
|
type: 'img',
|
||||||
|
// picGallery-- 内含大图 + 缩略图,同一张图的两种尺寸
|
||||||
|
// toOriginalUrl() 剥掉尺寸后缀后 dedupeKey 相同,会自动去重
|
||||||
|
selectors: [
|
||||||
|
'[class*="picGallery--"] img',
|
||||||
|
'#picGalleryEle img',
|
||||||
|
'[class*="thumbnailPic--"]',
|
||||||
|
],
|
||||||
|
// 不设 minWidth:缩略图 naturalWidth 只有 60 左右,
|
||||||
|
// 按 200 过滤会把主图全误杀(原图靠 toOriginalUrl 还原)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sku',
|
||||||
|
name: 'SKU图片',
|
||||||
|
type: 'img',
|
||||||
|
// ★ 与 1688 不同:淘宝 SKU 是真实 <img>,不是 CSS 背景图
|
||||||
|
// 探测证据:valueItem-- n=22 imgs=22(每项恰含一张 img)
|
||||||
|
// 所以这里不能用 srcProps: ['backgroundImage']
|
||||||
|
selectors: [
|
||||||
|
'[class*="valueItem--"]',
|
||||||
|
'[class*="valueItemImgWrap--"]',
|
||||||
|
],
|
||||||
|
nameSelectors: ['[class*="valueItemText--"]'],
|
||||||
|
minWidth: 20,
|
||||||
|
minHeight: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'detail',
|
||||||
|
name: '详情图',
|
||||||
|
type: 'img',
|
||||||
|
// 图文详情是懒加载的,需用户点开「图文详情」tab 或滚到底
|
||||||
|
selectors: [
|
||||||
|
'[class*="tabDetailWrap--"] img',
|
||||||
|
'[class*="detailInfo--"] img',
|
||||||
|
],
|
||||||
|
// detailInfo-- 同时包着「用户评价」区,买家晒单图能有 400-800px,
|
||||||
|
// 光靠 minWidth 滤不掉。这些图带水印、质量差,不能采
|
||||||
|
excludeWithin: [
|
||||||
|
'[class*="Comment--"]',
|
||||||
|
'[class*="comments--"]',
|
||||||
|
'[class*="userInfo--"]',
|
||||||
|
'[class*="rate"]',
|
||||||
|
],
|
||||||
|
minWidth: 300,
|
||||||
|
minHeight: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'video',
|
||||||
|
name: '视频',
|
||||||
|
type: 'video',
|
||||||
|
selectors: ['[class*="picGallery--"] video', 'video'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Site Profile - 平台采集配置(声明式)
|
||||||
|
* 从 docs/extension/plan.md §6.1 移植
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TextKind = 'title' | 'price' | 'params' | 'desc';
|
||||||
|
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video';
|
||||||
|
export type SrcProp = 'data-lazyload-src' | 'data-src' | 'currentSrc' | 'src' | 'backgroundImage';
|
||||||
|
|
||||||
|
export interface TextRule {
|
||||||
|
kind: TextKind;
|
||||||
|
selectors: string[]; // 多套选择器,逐个尝试
|
||||||
|
extract: 'join' | 'first' | 'table';
|
||||||
|
tableKeySelector?: string; // table 模式的 key 选择器
|
||||||
|
tableValueSelector?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGroupRule {
|
||||||
|
key: ImageGroupKey;
|
||||||
|
name: string;
|
||||||
|
type: 'img' | 'video';
|
||||||
|
selectors: string[];
|
||||||
|
srcProps?: SrcProp[]; // 覆盖 defaultSrcProps
|
||||||
|
nameSelectors?: string[]; // SKU 规格名来源
|
||||||
|
activeSelectors?: string[]; // 画廊"当前高亮"元素(排除)
|
||||||
|
/** 位于这些容器内的图片一律跳过(用 el.closest 判断)。
|
||||||
|
* 典型用途:详情区里混着的用户评价晒单图 */
|
||||||
|
excludeWithin?: string[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
urlPatterns: RegExp[];
|
||||||
|
extractItemId: (url: string) => string | null;
|
||||||
|
readySelectors: string[]; // 等待这些元素出现
|
||||||
|
readyTimeoutMs?: number;
|
||||||
|
defaultSrcProps: SrcProp[];
|
||||||
|
textRules: TextRule[];
|
||||||
|
imageGroups: ImageGroupRule[];
|
||||||
|
originalUrlRules?: Array<{ match: RegExp; replace: string }>;
|
||||||
|
refererOrigin?: string; // 图片防盗链需要的 Referer
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Product JSON - 商品文件夹契约(TS 侧)
|
||||||
|
* 对应 server/schemas/product.py(Pydantic 为真源)
|
||||||
|
* 详见 docs/contracts/product-json.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Stage = 'collected' | 'edited' | 'published';
|
||||||
|
|
||||||
|
export interface ProductJson {
|
||||||
|
_meta: {
|
||||||
|
schemaVersion: 1;
|
||||||
|
stage: Stage;
|
||||||
|
createdAt: string; // ISO 8601
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ozon 字段(对齐 ImportProductsV3)
|
||||||
|
offer_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
description_category_id: number | null;
|
||||||
|
price: string;
|
||||||
|
old_price?: string;
|
||||||
|
currency_code: 'RUB' | 'CNY';
|
||||||
|
vat: string;
|
||||||
|
|
||||||
|
depth: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
dimension_unit: 'mm' | 'cm';
|
||||||
|
weight: number | null;
|
||||||
|
weight_unit: 'g' | 'kg';
|
||||||
|
|
||||||
|
images: string[]; // 发布时填公网 URL
|
||||||
|
primary_image?: string;
|
||||||
|
color_image?: string;
|
||||||
|
|
||||||
|
attributes: any[]; // 工作台映射后才填
|
||||||
|
complex_attributes?: any[];
|
||||||
|
|
||||||
|
// 本地扩展字段(下划线前缀)
|
||||||
|
_images: {
|
||||||
|
main: ImageMeta[];
|
||||||
|
sku: ImageMeta[];
|
||||||
|
detail: ImageMeta[];
|
||||||
|
video: ImageMeta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
_raw: {
|
||||||
|
title: string;
|
||||||
|
price: string;
|
||||||
|
params?: Array<{ key: string; value: string }>;
|
||||||
|
desc?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
_pricing?: any; // 工作台计价结果
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageMeta {
|
||||||
|
file: string; // 相对路径:images/main/main-001.jpg
|
||||||
|
sourceUrl: string; // 源站 URL(可能失效)
|
||||||
|
variantName?: string; // SKU 规格名
|
||||||
|
w?: number;
|
||||||
|
h?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourcesJson {
|
||||||
|
sources: Array<{
|
||||||
|
platform: '1688' | 'taobao' | 'ozon';
|
||||||
|
itemId: string | null;
|
||||||
|
url: string;
|
||||||
|
collectedAt: string; // ISO 8601
|
||||||
|
counts: Record<string, number>;
|
||||||
|
}>;
|
||||||
|
dedupeKeys: string[]; // URL 去重指纹
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"types": ["chrome"]
|
||||||
|
},
|
||||||
|
"include": ["entrypoints", "src", "components"],
|
||||||
|
"exclude": ["node_modules", ".output"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
manifest: {
|
||||||
|
name: 'Seller Helper - 1688/淘宝采集',
|
||||||
|
description: '采集 1688/淘宝商品信息和图片到本地文件夹',
|
||||||
|
permissions: [
|
||||||
|
'storage',
|
||||||
|
'sidePanel',
|
||||||
|
'activeTab',
|
||||||
|
'scripting' // 执行 content script 函数需要
|
||||||
|
],
|
||||||
|
host_permissions: [
|
||||||
|
'https://detail.1688.com/*',
|
||||||
|
'https://item.taobao.com/*',
|
||||||
|
'https://detail.tmall.com/*',
|
||||||
|
'https://*.alicdn.com/*' // 阿里 CDN
|
||||||
|
],
|
||||||
|
action: {
|
||||||
|
default_title: 'Seller Helper'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modules: ['react']
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Ozon Seller Kit - Ozon 采集插件(extension-v2)
|
||||||
|
|
||||||
|
采集 Ozon 商品页信息(标题 / 价格 / 参数 / 卖点 / 描述 / 图片 / 视频)到本地商品文件夹。
|
||||||
|
|
||||||
|
参考实现:`reference/maozi-plugin-3.2.3`(毛子ERP,Ozon 跟卖插件)。本项目按其采集思路,
|
||||||
|
落成本仓库 `docs/` 已经定下的「纯采集器 + 本地商品文件夹」架构(见 `docs/contracts/product-json.md`)。
|
||||||
|
|
||||||
|
## 与毛子ERP 的对应关系
|
||||||
|
|
||||||
|
| 毛子ERP 做法 | 本插件实现 |
|
||||||
|
|---|---|
|
||||||
|
| 请求 Ozon 内部页 JSON 接口 `entrypoint-api.bx/page/json/v2` | ✅ `src/collector/ozon-api.ts`(只收画廊 widget 的图)|
|
||||||
|
| 解析 `application/ld+json` 结构化数据 | ✅ `src/collector/jsonld.ts` |
|
||||||
|
| DOM `data-widget` 区块选择器 | ✅ `src/profiles/ozon.ts`(兜底 + 详情图补充)|
|
||||||
|
| —(Ozon SSR 页面自带 widget data-state) | ✅ `src/collector/ozon-state.ts`(★ 主路径,白名单)|
|
||||||
|
| 上传到毛子云后端 | ❌ 改为写本地商品文件夹(架构决策 D3/R1)|
|
||||||
|
| 登录 / AI / 跟卖工作流 | ❌ 不实现,插件只做采集 |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension-v2
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # 或 pnpm build 出 .output/chrome-mv3
|
||||||
|
```
|
||||||
|
|
||||||
|
然后:
|
||||||
|
1. 打开 `chrome://extensions/`
|
||||||
|
2. 开启「开发者模式」
|
||||||
|
3. 「加载已解压的扩展程序」→ 选择 `extension-v2/.output/chrome-mv3`
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 打开任意 Ozon 商品详情页(`ozon.ru` / `ozon.kz` / `ozon.by`)
|
||||||
|
2. 滚动到页面底部,让详情图完成懒加载
|
||||||
|
3. 点扩展图标 → 侧边栏打开
|
||||||
|
4. 「选择保存目录」(仅首次,之后自动记住)
|
||||||
|
5. 「开始采集当前页」→ 核对结果、勾选图片
|
||||||
|
6. 「导出到本地」→ 生成商品文件夹
|
||||||
|
|
||||||
|
## 采集路径(四路径降级)
|
||||||
|
|
||||||
|
```
|
||||||
|
① SSR widget state(DOM data-state 属性)── 主路径,同步、白名单、无需网络
|
||||||
|
webGallery / webPrice / webProductHeading / webShortCharacteristics / webAspects …
|
||||||
|
│ 缺失
|
||||||
|
▼
|
||||||
|
② JSON-LD(schema.org/Product)── 标准化,稳定(标题/品牌/价格/评分)
|
||||||
|
│ 缺失
|
||||||
|
▼
|
||||||
|
③ Ozon 页 JSON API(entrypoint-api.bx)── 补充完整参数表与富文本描述
|
||||||
|
│ 缺失
|
||||||
|
▼
|
||||||
|
④ DOM 选择器(data-widget 区块)── 兜底 + 详情图补充
|
||||||
|
```
|
||||||
|
|
||||||
|
★ **「为您推荐 / 一起购买」等其它商品 carousel 的图片不会被采集**:
|
||||||
|
- ① 只读白名单 widget(webGallery / webAspects 等)的 data-state,绝不遍历全页;
|
||||||
|
- ③ 只从画廊类 widget 收图片,不递归整个 widgetStates(旧版 bug 所在);
|
||||||
|
- ④ DOM 选择器按 data-widget 区块作用域限定。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
extension-v2/
|
||||||
|
├── entrypoints/
|
||||||
|
│ ├── background.ts # 图片代理 fetch(绕 CORS / 防盗链)
|
||||||
|
│ ├── sidepanel/ # 采集控制 UI
|
||||||
|
│ └── content/index.ts # 注入商品页,暴露采集入口
|
||||||
|
├── src/
|
||||||
|
│ ├── profiles/ # ozon.ts(DOM 选择器 + URL/CDN 规则)
|
||||||
|
│ ├── collector/ # 采集引擎
|
||||||
|
│ │ ├── ozon-state.ts # ★ SSR data-state 提取(主路径,白名单)
|
||||||
|
│ │ ├── jsonld.ts # JSON-LD 提取
|
||||||
|
│ │ ├── ozon-api.ts # Ozon 页 JSON API(只收画廊 widget 的图)
|
||||||
|
│ │ ├── scan.ts # scanCurrentPage() 入口(四路径编排)
|
||||||
|
│ │ ├── image.ts / text.ts / dom.ts / url.ts
|
||||||
|
│ ├── export/ # builder / filesystem / idb
|
||||||
|
│ └── schema/product.ts # product.json 契约(TS 侧)
|
||||||
|
├── scripts/verify-pages.ts # 用 reference/ozon*.html 验证采集逻辑
|
||||||
|
├── wxt.config.ts
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Console 调试
|
||||||
|
|
||||||
|
在 Ozon 商品详情页的 Console 里执行:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const r = await window.__SellerHelperOzon.scan();
|
||||||
|
console.table(r.texts);
|
||||||
|
console.table(r.images);
|
||||||
|
console.log('stats:', r.stats, 'warnings:', r.warnings, 'source:', r.source);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
`scripts/verify-pages.ts` 用真实保存的页面(`reference/ozon1.html`、`ozon2.html`)
|
||||||
|
跑采集逻辑,覆盖:标题 / 价格 / 评分 / 画廊原图 / SKU 变体(图+名)/ 参数表 /
|
||||||
|
URL 还原(/wc\d+/、/c\d+/ 尺寸标记 → 原图)/ 缩略图生成。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从 extension-v2 目录
|
||||||
|
node_modules/.pnpm/esbuild@0.25.12/node_modules/esbuild/bin/esbuild \
|
||||||
|
scripts/verify-pages.ts --bundle --platform=node --format=esm --outfile=/tmp/verify.mjs
|
||||||
|
node /tmp/verify.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前状态与已知限制
|
||||||
|
|
||||||
|
- ✅ 四路径采集引擎(SSR state / JSON-LD / API / DOM),选择器已在真实页面核实
|
||||||
|
- ✅ 「为您推荐 / 一起购买」等其它商品图片已排除(白名单 + 画廊 widget 限定)
|
||||||
|
- ✅ Side Panel UI(分组预览、勾选、文件夹名、写盘进度)
|
||||||
|
- ✅ File System Access 写本地商品文件夹(product.json + sources.json + images/)
|
||||||
|
- ✅ 图片 CDN 域名(ir.ozone.ru / io.ozone.ru / v-1.ozone.ru / cdn1.ozonusercontent.com)已加入 host_permissions
|
||||||
|
- ⚠️ 详情图(webDescription 区)在静态快照里没有,需滚动到底部后由 DOM 补充
|
||||||
|
- ⚠️ 完整参数表(>5 项)走 API 补充,若 API 被风控则只有前 5 项(webShortCharacteristics)
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [总体架构](../../docs/architecture.md)
|
||||||
|
- [商品文件夹契约](../../docs/contracts/product-json.md)
|
||||||
|
- [插件方案(含毛子ERP 逆向分析)](../../docs/extension/plan.md)
|
||||||
|
- [插件方案修正(Ozon 优先)](../../docs/extension/plan-revision.md)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { uploadMaterials } from '../src/api/client';
|
||||||
|
|
||||||
|
// Background Service Worker —— 唯一出网口(代理图片 fetch / 上传服务端,绕 CORS / 防盗链)
|
||||||
|
export default defineBackground(() => {
|
||||||
|
console.log('[套娃采集助手] background started');
|
||||||
|
|
||||||
|
// 点击扩展图标 → 打开 Side Panel
|
||||||
|
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||||
|
if (msg?.action === 'fetchImage') {
|
||||||
|
fetchImageAsDataUrl(msg.url)
|
||||||
|
.then((dataUrl) => sendResponse({ ok: true, dataUrl }))
|
||||||
|
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
||||||
|
return true; // 保持异步通道
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg?.action === 'uploadMaterials') {
|
||||||
|
uploadMaterials(msg.baseUrl, msg.token, msg.payload)
|
||||||
|
.then((data) => sendResponse({ ok: true, data }))
|
||||||
|
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 代理取图,返回 base64 data URL(结构化克隆可安全跨消息传递) */
|
||||||
|
async function fetchImageAsDataUrl(url: string): Promise<string> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const type = res.headers.get('content-type') || 'image/jpeg';
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
|
||||||
|
// 分块转二进制串,避免超长参数列表
|
||||||
|
let bin = '';
|
||||||
|
const chunk = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunk) {
|
||||||
|
bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||||
|
}
|
||||||
|
return `data:${type};base64,${btoa(bin)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Content Script —— 注入 Ozon 商品页,暴露采集入口
|
||||||
|
import { scanCurrentPage } from '../../src/collector/scan';
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: [
|
||||||
|
'https://*.ozon.ru/*',
|
||||||
|
'https://*.ozon.kz/*',
|
||||||
|
'https://*.ozon.by/*'
|
||||||
|
],
|
||||||
|
main() {
|
||||||
|
console.log('[Ozon Seller Kit] Content script loaded');
|
||||||
|
|
||||||
|
// 暴露采集入口到全局(供 side panel 调用 / console 调试)
|
||||||
|
(window as any).__SellerHelperOzon = {
|
||||||
|
scan: scanCurrentPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Ozon Seller Kit] 就绪。Console 可测: await window.__SellerHelperOzon.scan()');
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
App as AntdApp,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Collapse,
|
||||||
|
ConfigProvider,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import {
|
||||||
|
CloudUploadOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
ScanOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
|
||||||
|
import { cleanFilename } from '../../src/collector/url';
|
||||||
|
import { buildProduct, type TextEdits } from '../../src/export/builder';
|
||||||
|
import { chooseRootDir, writeProductFolder, type ExportResult } from '../../src/export/filesystem';
|
||||||
|
import { loadRootDir } from '../../src/export/idb';
|
||||||
|
import { buildMaterialsPayload } from '../../src/api/client';
|
||||||
|
import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
|
||||||
|
{ key: 'main', name: '主图' },
|
||||||
|
{ key: 'sku', name: 'SKU图' },
|
||||||
|
{ key: 'detail', name: '详情图' },
|
||||||
|
{ key: 'video', name: '视频' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function defaultSelection(result: ScanResult): Set<string> {
|
||||||
|
const sel = new Set<string>();
|
||||||
|
let detailCount = 0;
|
||||||
|
for (const img of result.images) {
|
||||||
|
if (img.groupKey === 'detail') {
|
||||||
|
if (detailCount < 3) sel.add(img.key);
|
||||||
|
detailCount++;
|
||||||
|
} else {
|
||||||
|
sel.add(img.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm(s: string): string {
|
||||||
|
return s.toLowerCase().trim().replace(/[,,::()()]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从参数表里抽出「包装重量 + 包装尺寸(长宽高)」,其余参数保留 */
|
||||||
|
function extractWeightAndDims(pairs: Array<{ key: string; value: string }>): {
|
||||||
|
weight: string;
|
||||||
|
dims: { l: string; w: string; h: string };
|
||||||
|
dimsUnit: 'mm' | 'cm';
|
||||||
|
remaining: Array<{ key: string; value: string }>;
|
||||||
|
} {
|
||||||
|
// 包装重量:优先「包装重量」,其次「重量 / вес」
|
||||||
|
const weightP =
|
||||||
|
pairs.find((p) => {
|
||||||
|
const k = norm(p.key);
|
||||||
|
return k.includes('包装重量') || k.includes('вес упаковки');
|
||||||
|
}) ??
|
||||||
|
pairs.find((p) => {
|
||||||
|
const k = norm(p.key);
|
||||||
|
return k.includes('重量') || k.includes('вес');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分开的长/宽/高(用「长度/宽度/高度」而非「长/宽/高」,避免误匹配「长X宽x高」这种合并键)
|
||||||
|
const lenP = pairs.find((p) => {
|
||||||
|
const k = norm(p.key);
|
||||||
|
return k.includes('包装长度') || k.includes('长度') || k.includes('длина');
|
||||||
|
});
|
||||||
|
const widP = pairs.find((p) => {
|
||||||
|
const k = norm(p.key);
|
||||||
|
return k.includes('包装宽度') || k.includes('宽度') || k.includes('ширина');
|
||||||
|
});
|
||||||
|
const heiP = pairs.find((p) => {
|
||||||
|
const k = norm(p.key);
|
||||||
|
return k.includes('包装高度') || k.includes('高度') || k.includes('высота');
|
||||||
|
});
|
||||||
|
|
||||||
|
let l = lenP?.value ?? '';
|
||||||
|
let w = widP?.value ?? '';
|
||||||
|
let h = heiP?.value ?? '';
|
||||||
|
let dimsUnit: 'mm' | 'cm' = 'cm';
|
||||||
|
|
||||||
|
// 合并的「包装尺寸(长X宽x高),厘米 = 48*18*25」→ 拆分
|
||||||
|
let dimP: { key: string; value: string } | undefined;
|
||||||
|
if (!l && !w && !h) {
|
||||||
|
dimP = pairs.find((p) => norm(p.key).includes('包装尺寸'));
|
||||||
|
if (!dimP) dimP = pairs.find((p) => norm(p.key).includes('размер') || norm(p.key).includes('габарит'));
|
||||||
|
if (!dimP) dimP = pairs.find((p) => norm(p.key).includes('尺寸'));
|
||||||
|
if (dimP) {
|
||||||
|
const isMm = /(мм|mm|毫米)/.test(`${dimP.key} ${dimP.value}`.toLowerCase());
|
||||||
|
dimsUnit = isMm ? 'mm' : 'cm';
|
||||||
|
const nums = dimP.value.match(/\d+(?:[.,]\d+)?/g) ?? [];
|
||||||
|
if (nums.length >= 3) {
|
||||||
|
l = nums[0] ?? '';
|
||||||
|
w = nums[1] ?? '';
|
||||||
|
h = nums[2] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const combined = `${lenP?.key ?? ''} ${lenP?.value ?? ''} ${widP?.value ?? ''} ${heiP?.value ?? ''}`.toLowerCase();
|
||||||
|
dimsUnit = /(мм|mm|毫米)/.test(combined) ? 'mm' : 'cm';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其余参数保留(去掉已抽走的重量/尺寸项)
|
||||||
|
const used = new Set([weightP, lenP, widP, heiP, dimP].filter(Boolean));
|
||||||
|
const remaining = pairs.filter((p) => !used.has(p));
|
||||||
|
|
||||||
|
return { weight: weightP?.value ?? '', dims: { l, w, h }, dimsUnit, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用页面里的采集入口,返回 ScanResult 或 null(未加载/不支持) */
|
||||||
|
async function scanTab(tabId: number): Promise<ScanResult | null> {
|
||||||
|
try {
|
||||||
|
const [r] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: () => (window as any).__SellerHelperOzon?.scan?.(),
|
||||||
|
});
|
||||||
|
return (r?.result as ScanResult) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断当前 tab 是否 Ozon 商品详情页 */
|
||||||
|
async function isProductPage(tabId: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [r] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: () =>
|
||||||
|
/\/product\/[^/]+-\d+\/?/.test(location.pathname) ||
|
||||||
|
/\/context\/detail\/id\/\d+/.test(location.pathname),
|
||||||
|
});
|
||||||
|
return !!r?.result;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel() {
|
||||||
|
const { message } = AntdApp.useApp();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [result, setResult] = useState<ScanResult | null>(null);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [params, setParams] = useState<Array<{ key: string; value: string }>>([]);
|
||||||
|
const [dimsUnit, setDimsUnit] = useState<'mm' | 'cm'>('cm');
|
||||||
|
const [folderName, setFolderName] = useState('');
|
||||||
|
const [rootLabel, setRootLabel] = useState('');
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
|
||||||
|
const [uploadResult, setUploadResult] = useState<{ product_id: string; stage: string; assets_queued: number } | null>(null);
|
||||||
|
|
||||||
|
// 服务端设置(上传用)
|
||||||
|
const [settings, setSettings] = useState<BackendSettings | null>(null);
|
||||||
|
const [baseUrl, setBaseUrl] = useState('http://127.0.0.1:8800');
|
||||||
|
const [appToken, setAppToken] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRootDir().then((h) => setRootLabel(h ? h.name : ''));
|
||||||
|
loadSettings().then((s) => {
|
||||||
|
setSettings(s);
|
||||||
|
setBaseUrl(s.baseUrl);
|
||||||
|
setAppToken(s.token);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (!result) return [];
|
||||||
|
return GROUP_ORDER.map((g) => ({
|
||||||
|
...g,
|
||||||
|
items: result.images.filter((img) => img.groupKey === g.key),
|
||||||
|
})).filter((g) => g.items.length > 0);
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
const selectedCount = useMemo(() => {
|
||||||
|
if (!result) return 0;
|
||||||
|
return result.images.filter((img) => selected.has(img.key)).length;
|
||||||
|
}, [result, selected]);
|
||||||
|
|
||||||
|
const onSaveSettings = async () => {
|
||||||
|
const s: BackendSettings = { baseUrl: baseUrl.trim(), token: appToken.trim() };
|
||||||
|
await saveSettings(s);
|
||||||
|
setSettings(s);
|
||||||
|
message.success('服务端设置已保存');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScan = async () => {
|
||||||
|
setError('');
|
||||||
|
setResult(null);
|
||||||
|
setExportResult(null);
|
||||||
|
setUploadResult(null);
|
||||||
|
setStatus('采集中');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (!tab?.id) {
|
||||||
|
setError('无法获取当前标签页');
|
||||||
|
setStatus('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = await scanTab(tab.id);
|
||||||
|
|
||||||
|
// 内容脚本没加载(页面在插件安装/重载前就打开了)→ 手动注入后重试
|
||||||
|
if (!data) {
|
||||||
|
try {
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
files: ['content-scripts/content.js'],
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
data = await scanTab(tab.id);
|
||||||
|
} catch {
|
||||||
|
/* 注入失败忽略,走下方错误提示 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
const isProduct = await isProductPage(tab.id);
|
||||||
|
setError(
|
||||||
|
isProduct
|
||||||
|
? '采集失败:内容脚本未生效,请刷新商品页后重试'
|
||||||
|
: '当前页面不是 Ozon 商品详情页,请打开一个商品页后再采集',
|
||||||
|
);
|
||||||
|
setStatus('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(data);
|
||||||
|
setSelected(defaultSelection(data));
|
||||||
|
setFolderName(cleanFilename(data.texts.find((t) => t.kind === 'title')?.content ?? '') || '');
|
||||||
|
const rawParams = data.texts.find((t) => t.kind === 'params')?.pairs ?? [];
|
||||||
|
const { weight, dims, dimsUnit: du, remaining } = extractWeightAndDims(rawParams);
|
||||||
|
setParams(remaining);
|
||||||
|
setDimsUnit(du);
|
||||||
|
// 填表单
|
||||||
|
form.setFieldsValue({
|
||||||
|
title: data.texts.find((t) => t.kind === 'title')?.content ?? '',
|
||||||
|
price: data.texts.find((t) => t.kind === 'price')?.content ?? '',
|
||||||
|
brand: data.texts.find((t) => t.kind === 'brand')?.content || '无品牌',
|
||||||
|
sellingPoints: data.texts.find((t) => t.kind === 'selling_point')?.content ?? '',
|
||||||
|
desc: data.texts.find((t) => t.kind === 'desc')?.content ?? '',
|
||||||
|
packWeight: weight,
|
||||||
|
packLen: dims.l,
|
||||||
|
packWidth: dims.w,
|
||||||
|
packHeight: dims.h,
|
||||||
|
});
|
||||||
|
setStatus('采集完成');
|
||||||
|
} catch (err) {
|
||||||
|
setError(`采集失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
setStatus('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickDir = async () => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
// 始终弹选择器(选择或更换目录)
|
||||||
|
const handle = await chooseRootDir();
|
||||||
|
setRootLabel(handle.name);
|
||||||
|
message.success(`已选择目录「${handle.name}」`);
|
||||||
|
} catch (err) {
|
||||||
|
// 用户取消选择(AbortError)不算错误,静默处理
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(`选择目录失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOne = (key: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGroup = (items: ImageMaterial[]) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
const allOn = items.every((i) => next.has(i.key));
|
||||||
|
for (const i of items) {
|
||||||
|
if (allOn) next.delete(i.key);
|
||||||
|
else next.add(i.key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEdits = (): TextEdits => {
|
||||||
|
const v = form.getFieldsValue();
|
||||||
|
return {
|
||||||
|
title: v.title,
|
||||||
|
price: v.price,
|
||||||
|
brand: v.brand,
|
||||||
|
sellingPoints: v.sellingPoints,
|
||||||
|
desc: v.desc,
|
||||||
|
params,
|
||||||
|
weight: v.packWeight,
|
||||||
|
dims: { l: v.packLen, w: v.packWidth, h: v.packHeight },
|
||||||
|
dimsUnit,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!result) return;
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
setError('请至少勾选一张图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExporting(true);
|
||||||
|
setError('');
|
||||||
|
setExportResult(null);
|
||||||
|
try {
|
||||||
|
const name = folderName.trim() || `ozon-${result.itemId ?? 'product'}`;
|
||||||
|
const built = buildProduct(result, selected, getEdits());
|
||||||
|
const res = await writeProductFolder(name, built.product, built.sources, built.files);
|
||||||
|
setExportResult(res);
|
||||||
|
message.success('已导出到本地');
|
||||||
|
} catch (err) {
|
||||||
|
setError(`导出失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!result) return;
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
setError('请至少勾选一张图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
setError('');
|
||||||
|
setUploadResult(null);
|
||||||
|
try {
|
||||||
|
const payload = buildMaterialsPayload(result, selected, getEdits());
|
||||||
|
const resp = await chrome.runtime.sendMessage({
|
||||||
|
action: 'uploadMaterials',
|
||||||
|
baseUrl: settings?.baseUrl ?? 'http://127.0.0.1:8800',
|
||||||
|
token: settings?.token ?? '',
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
if (!resp?.ok) throw new Error(resp?.error ?? '上传失败');
|
||||||
|
setUploadResult(resp.data);
|
||||||
|
message.success('已上传服务端,进入采集箱');
|
||||||
|
} catch (err) {
|
||||||
|
setError(`上传失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh' }}>
|
||||||
|
{/* 顶部固定:标题 + 采集按钮 */}
|
||||||
|
<div style={{ position: 'sticky', top: 0, zIndex: 20, background: '#f5f5f5', padding: '12px 12px 8px', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
|
🪆 套娃采集助手
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
采集 Ozon 商品页 · 导出到本地或上传服务端
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 采集按钮 */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
icon={<ScanOutlined />}
|
||||||
|
loading={status === '采集中'}
|
||||||
|
onClick={handleScan}
|
||||||
|
>
|
||||||
|
{status === '采集中' ? '采集中…' : '开始采集当前页'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0 12px 12px' }}>
|
||||||
|
|
||||||
|
{/* 服务端设置 */}
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
label: (
|
||||||
|
<Space size={4}>
|
||||||
|
<SettingOutlined />
|
||||||
|
<span style={{ fontSize: 12 }}>服务端设置(上传用)</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12 }}>后端地址</Text>
|
||||||
|
<Input
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
placeholder="http://127.0.0.1:8800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12 }}>访问 Token(可选,留空即可)</Text>
|
||||||
|
<Input.Password
|
||||||
|
value={appToken}
|
||||||
|
onChange={(e) => setAppToken(e.target.value)}
|
||||||
|
placeholder="后续加账户体系时再填"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="small" onClick={onSaveSettings}>
|
||||||
|
保存设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Alert type="error" showIcon message={error} style={{ marginTop: 8 }} />}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
|
<Card size="small" style={{ marginTop: 12 }} title="采集信息(可修改)">
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Space wrap size={4}>
|
||||||
|
<Tag color="purple">{result.platform.toUpperCase()}</Tag>
|
||||||
|
{result.itemId && <Tag>{result.itemId}</Tag>}
|
||||||
|
<Tag color="blue">来源:{result.source}</Tag>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical" size="small">
|
||||||
|
<Form.Item label="标题" name="title">
|
||||||
|
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={8}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="价格" name="price">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="品牌" name="brand">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item label="包装重量" name="packWeight">
|
||||||
|
<Input placeholder="如 3.5 кг" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="包装尺寸(长 × 宽 × 高)">
|
||||||
|
<Space.Compact block>
|
||||||
|
<Form.Item name="packLen" noStyle>
|
||||||
|
<Input placeholder="长" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="packWidth" noStyle>
|
||||||
|
<Input placeholder="宽" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="packHeight" noStyle>
|
||||||
|
<Input placeholder="高" />
|
||||||
|
</Form.Item>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="卖点" name="sellingPoints">
|
||||||
|
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="描述" name="desc">
|
||||||
|
<Input.TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 参数表(左侧参数名只读,右侧参数值可编辑) */}
|
||||||
|
{params.length > 0 && (
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'params',
|
||||||
|
label: <Text style={{ fontSize: 12 }}>参数表({params.length} 项)</Text>,
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
{params.map((p, i) => (
|
||||||
|
<Row key={i} gutter={8} align="middle" style={{ marginBottom: 6 }}>
|
||||||
|
<Col span={10}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all', display: 'block' }}>
|
||||||
|
{p.key || '—'}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={14}>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={p.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...params];
|
||||||
|
next[i] = { ...next[i], value: e.target.value };
|
||||||
|
setParams(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 图片分组 */}
|
||||||
|
<Card size="small" style={{ marginTop: 12 }} title={`图片素材(已选 ${selectedCount} 张)`}>
|
||||||
|
{groups.map((g) => {
|
||||||
|
const allOn = g.items.every((i) => selected.has(i.key));
|
||||||
|
return (
|
||||||
|
<div key={g.key} style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<Text strong style={{ fontSize: 12 }}>
|
||||||
|
{g.name} ({g.items.length})
|
||||||
|
</Text>
|
||||||
|
<a style={{ fontSize: 12 }} onClick={() => toggleGroup(g.items)}>
|
||||||
|
{allOn ? '取消全选' : '全选'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{g.items.map((img) => {
|
||||||
|
const on = selected.has(img.key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={img.key}
|
||||||
|
onClick={() => toggleOne(img.key)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
border: on ? `2px solid ${token.colorPrimary}` : '1px solid #e0e0e0',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{img.type === 'video' ? (
|
||||||
|
<div style={{ position: 'relative', width: '100%', height: '100%', background: '#eee' }}>
|
||||||
|
{img.thumbUrl && !/\.(mp4|webm|m3u8|mov|avi)(\?|$)/i.test(img.thumbUrl) ? (
|
||||||
|
<img src={img.thumbUrl} alt="视频封面" style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
|
||||||
|
) : null}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.25)' }}>
|
||||||
|
<span style={{ color: '#fff', fontSize: 20, lineHeight: 1 }}>▶</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img src={img.thumbUrl} alt={img.variantName ?? g.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
|
||||||
|
)}
|
||||||
|
{on && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: `rgba(139,92,246,0.15)` }}>
|
||||||
|
<span style={{ position: 'absolute', top: 2, right: 5, color: token.colorPrimary, fontSize: 14 }}>✓</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{img.variantName && (
|
||||||
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(0,0,0,0.5)', color: '#fff', fontSize: 9, padding: '1px 2px', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{img.variantName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 警告 */}
|
||||||
|
{result.warnings.length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{result.warnings.map((w, i) => (
|
||||||
|
<Alert key={i} type="warning" showIcon message={w} style={{ marginBottom: 4 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ position: 'sticky', bottom: 0, background: '#f5f5f5', padding: '12px 0', zIndex: 10, marginTop: 12, borderTop: '1px solid #f0f0f0' }}>
|
||||||
|
{/* 本地文件夹名 + 导出/上传(固定在底部) */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>本地文件夹名</Text>
|
||||||
|
<Input
|
||||||
|
value={folderName}
|
||||||
|
onChange={(e) => setFolderName(e.target.value)}
|
||||||
|
placeholder="留空用商品标题"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{rootLabel ? `保存到:${rootLabel}` : '未选择保存目录'}
|
||||||
|
</div>
|
||||||
|
<Button size="small" onClick={handlePickDir}>
|
||||||
|
{rootLabel ? '更换目录' : '选择目录'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={8}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={handleExport}
|
||||||
|
loading={exporting}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
>
|
||||||
|
导出到本地
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
icon={<CloudUploadOutlined />}
|
||||||
|
onClick={handleUpload}
|
||||||
|
loading={uploading}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
>
|
||||||
|
上传服务端
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{exportResult && (
|
||||||
|
<Alert
|
||||||
|
type={exportResult.failed.length ? 'warning' : 'success'}
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
message={`已写入 ${exportResult.written} 张图片到「${exportResult.folderName}」${exportResult.failed.length ? `,${exportResult.failed.length} 张失败` : ''}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{uploadResult && (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
message={`已上传服务端,商品已进入采集箱(素材 ${uploadResult.assets_queued} 张,后台转存中)`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!result && (
|
||||||
|
<div style={{ marginTop: 16, padding: 12, background: '#fafafa', borderRadius: 8, fontSize: 12, color: '#888' }}>
|
||||||
|
<div>💡 使用说明:</div>
|
||||||
|
<ol style={{ margin: '4px 0 0 18px', padding: 0 }}>
|
||||||
|
<li>打开 Ozon 商品详情页(ru/kz/by)</li>
|
||||||
|
<li>滚动到页面底部(加载详情图)</li>
|
||||||
|
<li>点「开始采集」→ 核对/修改信息 → 导出或上传</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Root() {
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#8b5cf6',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AntdApp>
|
||||||
|
<Panel />
|
||||||
|
</AntdApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById('root')!);
|
||||||
|
root.render(<Root />);
|
||||||
|
|
||||||
|
export default Root;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>套娃采集助手</title>
|
||||||
|
<style>
|
||||||
|
/* 放大侧边栏宽度(Chrome side panel 受 min-width 约束) */
|
||||||
|
html, body, #root {
|
||||||
|
min-width: 460px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
/* 缩小表单各项上下间距 */
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.ant-form-item .ant-form-item-label {
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./App.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "taowa-collector",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wxt",
|
||||||
|
"build": "wxt build",
|
||||||
|
"zip": "wxt zip"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.3.2",
|
||||||
|
"antd": "^6.6.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.268",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/wicg-file-system-access": "^2023.10.7",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"wxt": "^0.19.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
|
||||||
|
}
|
||||||
Generated
+4419
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 用真实保存的 Ozon 页面验证采集逻辑(不依赖浏览器)
|
||||||
|
* 用法:先 esbuild 打包再 node 运行(见 README / 命令注释)
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { extractOzonState } from '../src/collector/ozon-state';
|
||||||
|
import { extractJsonLd } from '../src/collector/jsonld';
|
||||||
|
import { toOriginalUrl, toThumbUrl, pickBestFromSrcset } from '../src/collector/url';
|
||||||
|
|
||||||
|
class FakeEl {
|
||||||
|
id: string;
|
||||||
|
attrs: Record<string, string>;
|
||||||
|
constructor(id: string, attrs: Record<string, string>) {
|
||||||
|
this.id = id;
|
||||||
|
this.attrs = attrs;
|
||||||
|
}
|
||||||
|
getAttribute(name: string): string | null {
|
||||||
|
return this.attrs[name] ?? null;
|
||||||
|
}
|
||||||
|
get textContent(): string {
|
||||||
|
return this.attrs['text'] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFakeDoc(html: string) {
|
||||||
|
const stateEls: FakeEl[] = [];
|
||||||
|
const re = /<div\s+id="state-([^"]+)"\s+data-state='(.*?)'\s*>/gs;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(html))) {
|
||||||
|
stateEls.push(new FakeEl('state-' + m[1], { 'data-state': m[2] }));
|
||||||
|
}
|
||||||
|
const ldEls: FakeEl[] = [];
|
||||||
|
const re2 = /<script[^>]*type="application\/ld\+json"[^>]*>(.*?)<\/script>/gs;
|
||||||
|
while ((m = re2.exec(html))) {
|
||||||
|
ldEls.push(new FakeEl('', { text: m[1] }));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
querySelectorAll(sel: string): FakeEl[] {
|
||||||
|
if (sel === 'div[id^="state-"]') return stateEls;
|
||||||
|
if (sel === 'script[type="application/ld+json"]') return ldEls;
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(name: string, actual: unknown, expected: unknown): void {
|
||||||
|
const a = JSON.stringify(actual);
|
||||||
|
const e = JSON.stringify(expected);
|
||||||
|
const ok = a === e;
|
||||||
|
console.log(`${ok ? '✅' : '❌'} ${name}`);
|
||||||
|
if (!ok) {
|
||||||
|
console.log(' expected:', e);
|
||||||
|
console.log(' actual :', a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fn of ['../reference/ozon1.html', '../reference/ozon2.html']) {
|
||||||
|
console.log(`\n========== ${fn} ==========`);
|
||||||
|
const html = readFileSync(join(process.cwd(), fn), 'utf8');
|
||||||
|
(globalThis as any).document = buildFakeDoc(html);
|
||||||
|
|
||||||
|
const state = extractOzonState();
|
||||||
|
console.log('state:', JSON.stringify({
|
||||||
|
title: state.title,
|
||||||
|
price: state.price,
|
||||||
|
originalPrice: state.originalPrice,
|
||||||
|
rating: state.rating,
|
||||||
|
reviewCount: state.reviewCount,
|
||||||
|
gallery: state.galleryImages.length,
|
||||||
|
videos: state.videos,
|
||||||
|
variants: state.skuVariants,
|
||||||
|
characteristics: state.characteristics,
|
||||||
|
}, null, 1));
|
||||||
|
|
||||||
|
const ld = extractJsonLd();
|
||||||
|
console.log('jsonld:', JSON.stringify(ld));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── URL 工具验证 ──
|
||||||
|
console.log('\n========== url tools ==========');
|
||||||
|
const rules = [
|
||||||
|
{ match: /\/wc\d+\//, replace: '/' },
|
||||||
|
{ match: /\/c\d+\//, replace: '/' },
|
||||||
|
{ match: /(?<!:)\/{2,}/g, replace: '/' },
|
||||||
|
{ match: /[?&](width|height|size|quality|w|h)=[^&]+/g, replace: '' },
|
||||||
|
];
|
||||||
|
check('wc1000 → 原图', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-5/wc1000/9290076089.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg');
|
||||||
|
check('wc140 → 原图', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-q/wc140/9290076002.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-q/9290076002.jpg');
|
||||||
|
check('c50 → 原图', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-5/c50/9290076089.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg');
|
||||||
|
check('原图不变', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg');
|
||||||
|
check('带 query 尺寸', toOriginalUrl('https://cdn.x.com/a.jpg?width=200&h=300', rules), 'https://cdn.x.com/a.jpg');
|
||||||
|
check('缩略图', toThumbUrl('https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg'), 'https://ir.ozone.ru/s3/multimedia-1-5/wc200/9290076089.jpg');
|
||||||
|
check('已带标记不再缩略', toThumbUrl('https://ir.ozone.ru/s3/multimedia-1-5/wc50/9290076089.jpg'), 'https://ir.ozone.ru/s3/multimedia-1-5/wc50/9290076089.jpg');
|
||||||
|
check('srcset 取最大', pickBestFromSrcset('https://ir.ozone.ru/s3/a/wc50/1.jpg 1x, https://ir.ozone.ru/s3/a/wc100/1.jpg 2x'), 'https://ir.ozone.ru/s3/a/wc100/1.jpg');
|
||||||
|
|
||||||
|
console.log('\n全部验证结束');
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
|
||||||
|
* 契约对齐 server 端 /api/materials(见 docs/v2/api.md)。
|
||||||
|
*/
|
||||||
|
import type { ScanResult } from '../collector/scan';
|
||||||
|
import type { TextEdits } from '../export/builder';
|
||||||
|
|
||||||
|
export interface MaterialsPayload {
|
||||||
|
product_id: string | null;
|
||||||
|
source: {
|
||||||
|
platform: string;
|
||||||
|
itemId: string | null;
|
||||||
|
url: string;
|
||||||
|
collectedAt: number;
|
||||||
|
};
|
||||||
|
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
|
||||||
|
images: Array<{
|
||||||
|
groupKey: string;
|
||||||
|
groupName: string;
|
||||||
|
variantName?: string | null;
|
||||||
|
url: string;
|
||||||
|
index: number;
|
||||||
|
type: string;
|
||||||
|
dedupeKey?: string | null;
|
||||||
|
}>;
|
||||||
|
refererOrigin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用(可能已二次修改的)文本 + 已勾选图片,组装 /api/materials 请求体 */
|
||||||
|
export function buildMaterialsPayload(
|
||||||
|
result: ScanResult,
|
||||||
|
selectedKeys: Set<string>,
|
||||||
|
edits?: TextEdits,
|
||||||
|
): MaterialsPayload {
|
||||||
|
const text = (kind: string) => result.texts.find((t) => t.kind === kind)?.content ?? '';
|
||||||
|
|
||||||
|
const title = edits?.title ?? text('title');
|
||||||
|
const price = edits?.price ?? text('price');
|
||||||
|
const brand = edits?.brand ?? text('brand');
|
||||||
|
const desc = edits?.desc ?? text('desc');
|
||||||
|
const sellingPoints = edits?.sellingPoints ?? text('selling_point');
|
||||||
|
const params: Array<{ key: string; value: string }> = [
|
||||||
|
...(edits?.params ?? result.texts.find((t) => t.kind === 'params')?.pairs ?? []),
|
||||||
|
];
|
||||||
|
// 包装重量 / 包装尺寸合并进参数(后端存到 raw.params,studio 里再映射为 Ozon 字段)
|
||||||
|
if (edits?.weight) params.push({ key: '包装重量', value: edits.weight });
|
||||||
|
const dimSuffix = edits?.dimsUnit === 'mm' ? ' mm' : ' cm';
|
||||||
|
if (edits?.dims?.l) params.push({ key: '包装长度', value: `${edits.dims.l}${dimSuffix}` });
|
||||||
|
if (edits?.dims?.w) params.push({ key: '包装宽度', value: `${edits.dims.w}${dimSuffix}` });
|
||||||
|
if (edits?.dims?.h) params.push({ key: '包装高度', value: `${edits.dims.h}${dimSuffix}` });
|
||||||
|
|
||||||
|
const texts: MaterialsPayload['texts'] = [];
|
||||||
|
if (title) texts.push({ kind: 'title', content: title });
|
||||||
|
if (price) texts.push({ kind: 'price', content: price });
|
||||||
|
if (brand) texts.push({ kind: 'brand', content: brand });
|
||||||
|
if (params.length) texts.push({ kind: 'params', content: '', pairs: params });
|
||||||
|
if (sellingPoints) texts.push({ kind: 'selling_point', content: sellingPoints });
|
||||||
|
if (desc) texts.push({ kind: 'desc', content: desc });
|
||||||
|
|
||||||
|
const images = result.images
|
||||||
|
.filter((img) => selectedKeys.has(img.key))
|
||||||
|
.map((img) => ({
|
||||||
|
groupKey: img.groupKey,
|
||||||
|
groupName: img.groupName,
|
||||||
|
variantName: img.variantName ?? null,
|
||||||
|
url: img.url,
|
||||||
|
index: img.index,
|
||||||
|
type: img.type,
|
||||||
|
dedupeKey: img.url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
product_id: null,
|
||||||
|
source: {
|
||||||
|
platform: result.platform,
|
||||||
|
itemId: result.itemId,
|
||||||
|
url: result.url,
|
||||||
|
collectedAt: result.scannedAt,
|
||||||
|
},
|
||||||
|
texts,
|
||||||
|
images,
|
||||||
|
refererOrigin: 'https://www.ozon.ru',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadMaterials(
|
||||||
|
baseUrl: string,
|
||||||
|
token: string,
|
||||||
|
payload: MaterialsPayload,
|
||||||
|
): Promise<{ product_id: string; stage: string; assets_queued: number }> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`; // 单用户宽松模式:token 可空
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/materials`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* DOM 工具 - 等待元素、Shadow DOM 穿透
|
||||||
|
* 从 extension-v1 移植
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 等待任一选择器出现(MutationObserver + 超时) */
|
||||||
|
export function waitForAny(
|
||||||
|
selectors: string[],
|
||||||
|
timeoutMs = 10_000
|
||||||
|
): Promise<Element | null> {
|
||||||
|
const hit = () => selectors.map((s) => document.querySelector(s)).find(Boolean) ?? null;
|
||||||
|
|
||||||
|
const found = hit();
|
||||||
|
if (found) return Promise.resolve(found);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
resolve(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const el = hit();
|
||||||
|
if (el) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
observer.disconnect();
|
||||||
|
resolve(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 穿透 Shadow DOM 查询元素(Ozon 部分组件用了 Web Components) */
|
||||||
|
export function queryAllDeep(selectors: string[]): Element[] {
|
||||||
|
const out: Element[] = [];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
let nodes: NodeListOf<Element>;
|
||||||
|
try {
|
||||||
|
nodes = document.querySelectorAll(sel);
|
||||||
|
} catch {
|
||||||
|
continue; // 选择器写错不能拖垮整个扫描
|
||||||
|
}
|
||||||
|
nodes.forEach((el) => {
|
||||||
|
if (el.shadowRoot) {
|
||||||
|
out.push(...Array.from(el.shadowRoot.querySelectorAll('img, video, source')));
|
||||||
|
} else {
|
||||||
|
out.push(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 图片提取 - 主图、SKU、详情图、视频
|
||||||
|
* 从 extension-v1 移植,新增:
|
||||||
|
* - srcset 处理(Ozon 画廊是 <img srcset> / <picture><source>)
|
||||||
|
* - toOriginalUrl 传平台规则(Ozon /wc\d+/)
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
toAbsoluteUrl,
|
||||||
|
toOriginalUrl,
|
||||||
|
urlInBrackets,
|
||||||
|
looksLikeImageUrl,
|
||||||
|
dedupeKey,
|
||||||
|
pickBestFromSrcset,
|
||||||
|
} from './url';
|
||||||
|
import { queryAllDeep } from './dom';
|
||||||
|
import type { ImageGroupKey, SiteProfile, SrcProp } from '../profiles/types';
|
||||||
|
|
||||||
|
export interface ImageMaterial {
|
||||||
|
key: string; // 'main-001'
|
||||||
|
groupKey: ImageGroupKey; // 'main'
|
||||||
|
groupName: string; // '主图'
|
||||||
|
variantName?: string; // SKU 规格名(仅 sku 组)
|
||||||
|
url: string; // 已还原为原图
|
||||||
|
thumbUrl: string; // 页面上的原始小图地址
|
||||||
|
index: number;
|
||||||
|
type: 'img' | 'video';
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从元素上读出图片地址与名称,按 srcProps 顺序降级 */
|
||||||
|
function readImageSource(
|
||||||
|
el: Element,
|
||||||
|
srcProps: SrcProp[],
|
||||||
|
nameSelectors?: string[]
|
||||||
|
): { url: string; name: string; imgEl: HTMLImageElement | null } {
|
||||||
|
let url = '';
|
||||||
|
let name = '';
|
||||||
|
let imgEl: HTMLImageElement | null = el instanceof HTMLImageElement ? el : null;
|
||||||
|
|
||||||
|
for (const prop of srcProps) {
|
||||||
|
if (url) break;
|
||||||
|
|
||||||
|
if (prop === 'backgroundImage') {
|
||||||
|
if (el.tagName === 'IMG') {
|
||||||
|
const img = el as HTMLImageElement;
|
||||||
|
url = img.currentSrc || img.src || '';
|
||||||
|
name = img.alt || '';
|
||||||
|
} else {
|
||||||
|
const bg = getComputedStyle(el).backgroundImage || '';
|
||||||
|
const cand = (urlInBrackets(bg) || bg).replace(/['"]/g, '');
|
||||||
|
if (looksLikeImageUrl(cand)) url = cand;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === 'srcset') {
|
||||||
|
// <img srcset> 或 <source srcset>
|
||||||
|
const raw = el.getAttribute('srcset') || (el as any).srcset || '';
|
||||||
|
if (raw) url = pickBestFromSrcset(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = (el as any)[prop] || el.getAttribute(prop);
|
||||||
|
if (raw) {
|
||||||
|
// srcset 场景下 currentSrc 才是实际加载的那张
|
||||||
|
url = prop === 'src' ? ((el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || '') : raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择器命中的是容器、图在子节点上
|
||||||
|
if (!url && el.tagName !== 'IMG') {
|
||||||
|
const inner = el.querySelector('img, source');
|
||||||
|
if (inner) {
|
||||||
|
const srcset = inner.getAttribute('srcset');
|
||||||
|
url = srcset
|
||||||
|
? pickBestFromSrcset(srcset)
|
||||||
|
: inner.getAttribute('data-src') || (inner as HTMLImageElement).currentSrc || (inner as HTMLImageElement).src || '';
|
||||||
|
if (inner instanceof HTMLImageElement) imgEl = inner;
|
||||||
|
if (!name && inner instanceof HTMLImageElement) name = inner.alt || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称统一取(SKU 规格名)
|
||||||
|
if (!name && nameSelectors?.length) {
|
||||||
|
for (const sel of nameSelectors) {
|
||||||
|
const t = el.querySelector(sel)?.textContent?.trim();
|
||||||
|
if (t) {
|
||||||
|
name = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: url ? toAbsoluteUrl(url) : '', name, imgEl };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectImages(profile: SiteProfile): ImageMaterial[] {
|
||||||
|
const result: ImageMaterial[] = [];
|
||||||
|
|
||||||
|
for (const group of profile.imageGroups) {
|
||||||
|
const srcProps = group.srcProps ?? profile.defaultSrcProps;
|
||||||
|
// 去重按组独立:一张图同时是主图和 SKU 图是正常的
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const activeSet = new Set(group.activeSelectors ? queryAllDeep(group.activeSelectors) : []);
|
||||||
|
|
||||||
|
for (const el of queryAllDeep(group.selectors)) {
|
||||||
|
if (activeSet.has(el)) continue;
|
||||||
|
if (group.excludeWithin?.some((sel) => el.closest(sel))) continue;
|
||||||
|
|
||||||
|
const { url: rawUrl, name, imgEl } = readImageSource(el, srcProps, group.nameSelectors);
|
||||||
|
if (!rawUrl) continue;
|
||||||
|
|
||||||
|
if (group.type === 'video' && !/\.(mp4|avi|mov|wmv|m3u8|webm)(\?|$)/i.test(rawUrl) && !/^blob:/i.test(rawUrl)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = group.type === 'img' ? toOriginalUrl(rawUrl, profile.originalUrlRules) : rawUrl;
|
||||||
|
|
||||||
|
// 尺寸过滤
|
||||||
|
if (group.type === 'img' && (group.minWidth || group.minHeight)) {
|
||||||
|
const measured = imgEl ?? (el as HTMLElement);
|
||||||
|
const w = (measured as HTMLImageElement).naturalWidth || (measured as HTMLElement).offsetWidth || 0;
|
||||||
|
const h = (measured as HTMLImageElement).naturalHeight || (measured as HTMLElement).offsetHeight || 0;
|
||||||
|
if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = group.key === 'sku' ? `${dedupeKey(url, profile.originalUrlRules)}::${name}` : dedupeKey(url, profile.originalUrlRules);
|
||||||
|
if (seen.has(k)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
key: `${group.key}-${String(result.filter((r) => r.groupKey === group.key).length + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: group.key,
|
||||||
|
groupName: group.name,
|
||||||
|
variantName: group.key === 'sku' ? name || undefined : undefined,
|
||||||
|
url,
|
||||||
|
thumbUrl: rawUrl,
|
||||||
|
index: result.length,
|
||||||
|
type: group.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* JSON-LD 提取器(schema.org/Product)
|
||||||
|
*
|
||||||
|
* Ozon 是 SSR 站点,商品页 HTML 里带 application/ld+json,
|
||||||
|
* 是 DOM 之外最稳定的结构化来源(比哈希类名稳定一个数量级)。
|
||||||
|
*
|
||||||
|
* 参考实现(毛子ERP)也解析 application/ld+json 取 description / offers.url。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface JsonLdProduct {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
brand?: string;
|
||||||
|
sku?: string;
|
||||||
|
price?: string;
|
||||||
|
currency?: string;
|
||||||
|
images: string[];
|
||||||
|
rating?: string;
|
||||||
|
reviewCount?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(v: unknown): string | undefined {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
if (typeof v === 'number') return String(v);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findProduct(node: unknown): any | null {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) {
|
||||||
|
const r = findProduct(item);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!node || typeof node !== 'object') return null;
|
||||||
|
|
||||||
|
const obj = node as Record<string, unknown>;
|
||||||
|
const type = obj['@type'];
|
||||||
|
const types = Array.isArray(type) ? type : [type];
|
||||||
|
if (types.some((t) => t === 'Product')) return obj;
|
||||||
|
|
||||||
|
// @graph 包裹
|
||||||
|
if (Array.isArray(obj['@graph'])) {
|
||||||
|
for (const g of obj['@graph']) {
|
||||||
|
const r = findProduct(g);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectImages(node: unknown, out: string[]): void {
|
||||||
|
if (!node) return;
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
if (/^(https?:)?\/\/.+/i.test(node) && !out.includes(node)) out.push(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
node.forEach((n) => collectImages(n, out));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof node === 'object') {
|
||||||
|
for (const v of Object.values(node as Record<string, unknown>)) {
|
||||||
|
collectImages(v, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractJsonLd(): JsonLdProduct | null {
|
||||||
|
try {
|
||||||
|
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||||||
|
for (const script of Array.from(scripts)) {
|
||||||
|
const text = script.textContent?.trim();
|
||||||
|
if (!text) continue;
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const product = findProduct(data);
|
||||||
|
if (!product) continue;
|
||||||
|
|
||||||
|
const offers = Array.isArray(product.offers) ? product.offers[0] : product.offers;
|
||||||
|
const brandName = product.brand?.name ?? (typeof product.brand === 'string' ? product.brand : undefined);
|
||||||
|
|
||||||
|
const images: string[] = [];
|
||||||
|
if (product.image) collectImages(product.image, images);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: asString(product.name),
|
||||||
|
description: asString(product.description),
|
||||||
|
brand: asString(brandName),
|
||||||
|
sku: asString(product.sku),
|
||||||
|
price: asString(offers?.price),
|
||||||
|
currency: asString(offers?.priceCurrency),
|
||||||
|
images,
|
||||||
|
rating: asString(product.aggregateRating?.ratingValue),
|
||||||
|
reviewCount: asString(product.aggregateRating?.reviewCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[JSON-LD] 提取失败:', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* Ozon 内部页 JSON API 提取器(补充路径)
|
||||||
|
*
|
||||||
|
* 参考实现(毛子ERP)的采集核心是直接请求 Ozon 自己的页数据接口:
|
||||||
|
*
|
||||||
|
* GET {origin}/api/entrypoint-api.bx/page/json/v2?url=/product/{id}/
|
||||||
|
* → { widgetStates: { "webCharacteristics-…": "...", "webGallery-…": "...", ... } }
|
||||||
|
*
|
||||||
|
* ★ 关键点(毛子ERP 的做法,也是本文件修复点):
|
||||||
|
* - 默认页 `/product/{id}/` 里带 **webCharacteristics(全量「特征」)**,
|
||||||
|
* SSR 里的 webShortCharacteristics 只给前 5 项(limit:5)。
|
||||||
|
* - 描述页 `/product/{id}/?layout_container=pdpPage2column&layout_page_index=2`
|
||||||
|
* 里带 webDescription(富文本描述)。
|
||||||
|
* 所以要两个 URL 都请求、合并,才能拿到完整参数表 + 描述。
|
||||||
|
*
|
||||||
|
* ★ 图片只从画廊类 widget 收(白名单),绝不递归全部 widgetStates,
|
||||||
|
* 避免「为您推荐 / 一起购买」等 carousel 图混入。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OzonPageData {
|
||||||
|
title?: string;
|
||||||
|
price?: string;
|
||||||
|
oldPrice?: string;
|
||||||
|
description?: string;
|
||||||
|
/** 主图画廊(仅来自画廊 widget) */
|
||||||
|
images: string[];
|
||||||
|
videos: string[];
|
||||||
|
/** 参数表(kv) */
|
||||||
|
characteristics: Array<{ key: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMG_EXT = /\.(jpg|jpeg|png|webp|gif|avif)(\?|$)/i;
|
||||||
|
const VID_EXT = /\.(mp4|m3u8|webm|mov)(\?|$)/i;
|
||||||
|
|
||||||
|
function parseWidgetState(v: unknown): unknown {
|
||||||
|
if (typeof v !== 'string') return v;
|
||||||
|
try {
|
||||||
|
return JSON.parse(v);
|
||||||
|
} catch {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWidgetStates(widgetStates: unknown): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
if (!widgetStates || typeof widgetStates !== 'object') return out;
|
||||||
|
for (const [k, v] of Object.entries(widgetStates as Record<string, unknown>)) {
|
||||||
|
out[k] = parseWidgetState(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUnique(arr: string[], v: string): void {
|
||||||
|
if (v && !arr.includes(v)) arr.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归收集画廊 widget 内的图片/视频 URL(只在这个 widget 内走) */
|
||||||
|
function collectMedia(node: unknown, images: string[], videos: string[]): void {
|
||||||
|
if (!node) return;
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
if (IMG_EXT.test(node)) pushUnique(images, node);
|
||||||
|
else if (VID_EXT.test(node)) pushUnique(videos, node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
node.forEach((n) => collectMedia(n, images, videos));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof node !== 'object') return;
|
||||||
|
for (const v of Object.values(node as Record<string, unknown>)) {
|
||||||
|
collectMedia(v, images, videos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 characteristic 类 widget 里收参数表 */
|
||||||
|
function collectCharacteristics(node: unknown, out: Array<{ key: string; value: string }>): void {
|
||||||
|
if (!node || typeof node !== 'object') return;
|
||||||
|
const walk = (n: unknown): void => {
|
||||||
|
if (!n || typeof n !== 'object') return;
|
||||||
|
if (Array.isArray(n)) {
|
||||||
|
n.forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const obj = n as Record<string, unknown>;
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (/characteristic|aspect/i.test(k) && Array.isArray(v)) {
|
||||||
|
for (const row of v) {
|
||||||
|
if (!row || typeof row !== 'object') continue;
|
||||||
|
const r = row as Record<string, unknown>;
|
||||||
|
// { title: {textRs:[{content}]}, values:[{text}] }(Ozon 实测结构)
|
||||||
|
const key = readText(r.title);
|
||||||
|
if (key && Array.isArray(r.values)) {
|
||||||
|
const vals = r.values
|
||||||
|
.map((x) => (x && typeof x === 'object' ? readText((x as Record<string, unknown>).text) : ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (vals.length) out.push({ key, value: vals.join(', ') });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// { key/value } / { name/value } / { title/text }
|
||||||
|
const k2 = (r.key ?? r.name ?? r.title) as string | undefined;
|
||||||
|
const v2 = (r.value ?? r.text) as string | undefined;
|
||||||
|
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
|
||||||
|
out.push({ key: k2, value: v2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (/characteristic|aspect/i.test(k) && typeof v === 'object') {
|
||||||
|
walk(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readText(node: unknown): string {
|
||||||
|
if (!node) return '';
|
||||||
|
if (typeof node === 'string') return node.trim();
|
||||||
|
if (typeof node !== 'object') return '';
|
||||||
|
// { textRs: [{ type, content }] } / { content } / { text }
|
||||||
|
const obj = node as Record<string, unknown>;
|
||||||
|
if (Array.isArray(obj.textRs)) {
|
||||||
|
return obj.textRs
|
||||||
|
.map((t) => (t && typeof t === 'object' ? (t as Record<string, unknown>).content ?? '' : ''))
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
if (typeof obj.content === 'string') return obj.content.trim();
|
||||||
|
if (typeof obj.text === 'string') return obj.text.trim();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从描述类 widget 里收富文本描述 */
|
||||||
|
function collectDescription(node: unknown, out: { description?: string }): void {
|
||||||
|
if (!node || typeof node !== 'object') return;
|
||||||
|
const obj = node as Record<string, unknown>;
|
||||||
|
if (typeof obj.richAnnotationJson === 'string') {
|
||||||
|
try {
|
||||||
|
const rich = JSON.parse(obj.richAnnotationJson);
|
||||||
|
out.description = richToString(rich);
|
||||||
|
} catch {
|
||||||
|
out.description = obj.richAnnotationJson;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof obj.description === 'string') {
|
||||||
|
out.description = obj.description;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** richAnnotationJson(富文本块数组)→ 纯文本 */
|
||||||
|
function richToString(rich: unknown): string {
|
||||||
|
if (!rich) return '';
|
||||||
|
if (typeof rich === 'string') return rich;
|
||||||
|
const texts: string[] = [];
|
||||||
|
const walk = (n: unknown): void => {
|
||||||
|
if (!n) return;
|
||||||
|
if (typeof n === 'string') {
|
||||||
|
texts.push(n);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(n)) {
|
||||||
|
n.forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof n === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
|
||||||
|
if (k === 'text' && typeof v === 'string') texts.push(v);
|
||||||
|
else if (k !== 'type') walk(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(rich);
|
||||||
|
return texts.join('\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析单个 widgetStates → 部分 OzonPageData */
|
||||||
|
function parsePage(widgets: Record<string, unknown>): OzonPageData {
|
||||||
|
const images: string[] = [];
|
||||||
|
const videos: string[] = [];
|
||||||
|
const characteristics: Array<{ key: string; value: string }> = [];
|
||||||
|
const desc: { description?: string } = {};
|
||||||
|
let title: string | undefined;
|
||||||
|
let price: string | undefined;
|
||||||
|
let oldPrice: string | undefined;
|
||||||
|
|
||||||
|
for (const [wkey, wval] of Object.entries(widgets)) {
|
||||||
|
const key = wkey.toLowerCase();
|
||||||
|
|
||||||
|
// 图片/视频:只收主画廊 widget(webGallery),
|
||||||
|
// 不能按 "gallery" 子串匹配 —— webReviewGallery 是「买家照片和视频」,会混入
|
||||||
|
if (key.startsWith('webgallery')) {
|
||||||
|
collectMedia(wval, images, videos);
|
||||||
|
}
|
||||||
|
// 参数表(含全量 webCharacteristics)
|
||||||
|
if (/(characteristic|aspect)/.test(key)) {
|
||||||
|
collectCharacteristics(wval, characteristics);
|
||||||
|
}
|
||||||
|
// 描述
|
||||||
|
if (/(description|richcontent)/.test(key)) {
|
||||||
|
collectDescription(wval, desc);
|
||||||
|
}
|
||||||
|
// 标题 / 价格(各自的 widget)
|
||||||
|
if (/heading|title/.test(key) && !title) {
|
||||||
|
const v = (wval as Record<string, unknown>)?.title ?? (wval as Record<string, unknown>)?.name;
|
||||||
|
if (typeof v === 'string' && v && !/^https?:/i.test(v)) title = v;
|
||||||
|
}
|
||||||
|
if (/webprice/.test(key) && !price) {
|
||||||
|
const p = (wval as Record<string, unknown>)?.price;
|
||||||
|
if (typeof p === 'string') price = p;
|
||||||
|
const op = (wval as Record<string, unknown>)?.originalPrice;
|
||||||
|
if (typeof op === 'string') oldPrice = op;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
price,
|
||||||
|
oldPrice,
|
||||||
|
description: desc.description,
|
||||||
|
images,
|
||||||
|
videos,
|
||||||
|
characteristics: dedupePairs(characteristics),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPage(url: string): Promise<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = (await res.json()) as { widgetStates?: unknown };
|
||||||
|
return parseWidgetStates(json.widgetStates);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Ozon API] 请求失败:', url, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOzonPageData(itemId: string): Promise<OzonPageData | null> {
|
||||||
|
// 默认页(标题/价格/画廊 + 全量特征 webCharacteristics)+ 描述页(富文本描述)
|
||||||
|
const urls = [
|
||||||
|
`/product/${itemId}/`,
|
||||||
|
`/product/${itemId}/?layout_container=pdpPage2column&layout_page_index=2`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const merged: OzonPageData = { images: [], videos: [], characteristics: [] };
|
||||||
|
let gotAny = false;
|
||||||
|
|
||||||
|
for (const target of urls) {
|
||||||
|
const widgets = await fetchPage(
|
||||||
|
`${location.origin}/api/entrypoint-api.bx/page/json/v2?url=${encodeURIComponent(target)}`,
|
||||||
|
);
|
||||||
|
if (!widgets) continue;
|
||||||
|
const p = parsePage(widgets);
|
||||||
|
gotAny = true;
|
||||||
|
|
||||||
|
merged.title = merged.title || p.title;
|
||||||
|
merged.price = merged.price || p.price;
|
||||||
|
merged.oldPrice = merged.oldPrice || p.oldPrice;
|
||||||
|
merged.description = merged.description || p.description;
|
||||||
|
for (const img of p.images) if (!merged.images.includes(img)) merged.images.push(img);
|
||||||
|
for (const v of p.videos) if (!merged.videos.includes(v)) merged.videos.push(v);
|
||||||
|
for (const c of p.characteristics) merged.characteristics.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.characteristics = dedupePairs(merged.characteristics);
|
||||||
|
|
||||||
|
return gotAny &&
|
||||||
|
(merged.images.length || merged.title || merged.price || merged.characteristics.length || merged.description)
|
||||||
|
? merged
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupePairs(pairs: Array<{ key: string; value: string }>): Array<{ key: string; value: string }> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Array<{ key: string; value: string }> = [];
|
||||||
|
for (const p of pairs) {
|
||||||
|
const k = `${p.key}::${p.value}`;
|
||||||
|
if (seen.has(k)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Ozon SSR widget state 提取器(主路径)
|
||||||
|
*
|
||||||
|
* Ozon 页面把每个 widget 的 JSON state 内嵌在 DOM 里:
|
||||||
|
* <div id="state-webGallery-3311626-default-1" data-state='{...}'>
|
||||||
|
* content script 直接读 data-state 即可,无需访问页面 JS(main world)。
|
||||||
|
*
|
||||||
|
* 结构已在真实页面实测(reference/ozon1.html、ozon2.html):
|
||||||
|
* - webGallery: coverImage / images[{src,alt}](原图)/ videos[{url,coverUrl}]
|
||||||
|
* - webPrice: price / originalPrice / cardPrice(如 "108,26 ¥")
|
||||||
|
* - webProductHeading: title
|
||||||
|
* - webShortCharacteristics / webDetailedCharacteristics: characteristics[]
|
||||||
|
* - webAspects: aspects[].variants[].data.{searchableText, coverImage}(SKU 变体)
|
||||||
|
* - webReviewProductScore: totalScore / reviewsCount
|
||||||
|
*
|
||||||
|
* ★ 白名单机制:只读上面这几个 widget 的 state。
|
||||||
|
* 绝不遍历全页 —— "为您推荐 / 一起购买" 等其它商品 carousel 的 state
|
||||||
|
* (webRecommendedProducts / webCarousel / 类似 widget)根本不会被读到。
|
||||||
|
*/
|
||||||
|
import { toAbsoluteUrl } from './url';
|
||||||
|
|
||||||
|
export interface OzonVariant {
|
||||||
|
name: string;
|
||||||
|
image?: string; // 可能为 undefined(纯文字规格,如尺码)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
name: string; // 类目名称(如"扑满"、"儿童房")
|
||||||
|
href: string; // 原始链接(/category/kopilki-15056/ 或 ?category=7041)
|
||||||
|
searchCategoryId?: number; // Ozon 搜索类目 ID(从 ?category=xxx 解析)
|
||||||
|
slug?: string; // URL slug(从 /category/xxx-123/ 解析,含数字 ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OzonStateData {
|
||||||
|
title?: string;
|
||||||
|
price?: string;
|
||||||
|
originalPrice?: string;
|
||||||
|
rating?: string;
|
||||||
|
reviewCount?: string;
|
||||||
|
galleryImages: string[]; // 原图(无尺寸标记)
|
||||||
|
videos: string[];
|
||||||
|
videoCovers: string[];
|
||||||
|
skuVariants: OzonVariant[];
|
||||||
|
characteristics: Array<{ key: string; value: string }>;
|
||||||
|
breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 允许读取的 widget 前缀白名单 */
|
||||||
|
const ALLOWED_WIDGETS = [
|
||||||
|
'webGallery-',
|
||||||
|
'webPrice-',
|
||||||
|
'webProductHeading-',
|
||||||
|
'webShortCharacteristics-',
|
||||||
|
'webDetailedCharacteristics-',
|
||||||
|
'webCharacteristics-',
|
||||||
|
'webAspects-',
|
||||||
|
'webReviewProductScore-',
|
||||||
|
'breadCrumbs-', // 面包屑类目路径
|
||||||
|
];
|
||||||
|
|
||||||
|
function pushUnique(arr: string[], v: string): void {
|
||||||
|
const abs = toAbsoluteUrl(v);
|
||||||
|
if (abs && !arr.includes(abs)) arr.push(abs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTextRs(node: unknown): string {
|
||||||
|
// 提取 textRs / descriptionRs 里的展示文本。
|
||||||
|
// 规则:content/text 字段的值收进文本;递归进入数组/对象找嵌套的 content/text;
|
||||||
|
// 跳过 type/font/color/id/href 等样式与元数据字段(type=newLine 除外)。
|
||||||
|
if (node == null) return '';
|
||||||
|
if (typeof node === 'string') return node.trim();
|
||||||
|
if (typeof node !== 'object') return '';
|
||||||
|
const texts: string[] = [];
|
||||||
|
const walk = (n: unknown): void => {
|
||||||
|
if (!n) return;
|
||||||
|
if (typeof n === 'string') {
|
||||||
|
texts.push(n);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(n)) {
|
||||||
|
n.forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof n === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
|
||||||
|
if (k === 'type' && (v === 'newLine' || v === 'lineBreak')) {
|
||||||
|
texts.push('\n');
|
||||||
|
} else if (k === 'content' || k === 'text') {
|
||||||
|
walk(v);
|
||||||
|
} else if (v && typeof v === 'object') {
|
||||||
|
walk(v);
|
||||||
|
}
|
||||||
|
// 其它原始值(font/color/id/type='text' 等)直接跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(node);
|
||||||
|
return texts.join('').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCharacteristics(chars: unknown): Array<{ key: string; value: string }> {
|
||||||
|
if (!Array.isArray(chars)) return [];
|
||||||
|
const out: Array<{ key: string; value: string }> = [];
|
||||||
|
for (const c of chars) {
|
||||||
|
if (!c || typeof c !== 'object') continue;
|
||||||
|
const row = c as Record<string, unknown>;
|
||||||
|
// 结构 A:{ title: { textRs: [...] }, values: [{ text: ... }] }(实测)
|
||||||
|
const key = readTextRs(row.title);
|
||||||
|
if (Array.isArray(row.values)) {
|
||||||
|
const vals = row.values
|
||||||
|
.map((v) => (v && typeof v === 'object' ? readTextRs((v as Record<string, unknown>).text) : ''))
|
||||||
|
.map((t) => t.replace(/,\s*$/, '')) // 源数据值自带尾逗号(如 "音乐, ")
|
||||||
|
.filter(Boolean);
|
||||||
|
if (key && vals.length) out.push({ key, value: vals.join(', ') });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 结构 B:{ key, value } / { name, value } / { title, text }
|
||||||
|
const k2 = (row.key ?? row.name ?? row.title) as string | undefined;
|
||||||
|
const v2 = (row.value ?? row.text) as string | undefined;
|
||||||
|
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
|
||||||
|
out.push({ key: k2, value: v2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOzonState(): OzonStateData {
|
||||||
|
const data: OzonStateData = {
|
||||||
|
galleryImages: [],
|
||||||
|
videos: [],
|
||||||
|
videoCovers: [],
|
||||||
|
skuVariants: [],
|
||||||
|
characteristics: [],
|
||||||
|
breadcrumbs: [],
|
||||||
|
};
|
||||||
|
const seenChars = new Set<string>();
|
||||||
|
|
||||||
|
const els = document.querySelectorAll('div[id^="state-"]');
|
||||||
|
for (const el of Array.from(els)) {
|
||||||
|
const id = el.id.slice('state-'.length);
|
||||||
|
if (!ALLOWED_WIDGETS.some((p) => id.startsWith(p))) continue;
|
||||||
|
const raw = el.getAttribute('data-state');
|
||||||
|
if (!raw) continue;
|
||||||
|
let state: unknown;
|
||||||
|
try {
|
||||||
|
state = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!state || typeof state !== 'object') continue;
|
||||||
|
const s = state as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (id.startsWith('webGallery-')) {
|
||||||
|
if (typeof s.coverImage === 'string') pushUnique(data.galleryImages, s.coverImage);
|
||||||
|
if (Array.isArray(s.images)) {
|
||||||
|
for (const img of s.images) {
|
||||||
|
const src = img && typeof (img as Record<string, unknown>).src === 'string'
|
||||||
|
? (img as Record<string, unknown>).src as string
|
||||||
|
: undefined;
|
||||||
|
if (src) pushUnique(data.galleryImages, src);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(s.videos)) {
|
||||||
|
for (const v of s.videos) {
|
||||||
|
const rec = v as Record<string, unknown>;
|
||||||
|
if (typeof rec.url === 'string') pushUnique(data.videos, rec.url);
|
||||||
|
if (typeof rec.coverUrl === 'string') pushUnique(data.videoCovers, rec.coverUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (id.startsWith('webPrice-')) {
|
||||||
|
if (typeof s.price === 'string') data.price = s.price;
|
||||||
|
if (typeof s.originalPrice === 'string') data.originalPrice = s.originalPrice;
|
||||||
|
if (!data.price && typeof s.cardPrice === 'string') data.price = s.cardPrice;
|
||||||
|
} else if (id.startsWith('webProductHeading-')) {
|
||||||
|
if (typeof s.title === 'string') data.title = s.title;
|
||||||
|
} else if (
|
||||||
|
id.startsWith('webShortCharacteristics-') ||
|
||||||
|
id.startsWith('webDetailedCharacteristics-') ||
|
||||||
|
id.startsWith('webCharacteristics-')
|
||||||
|
) {
|
||||||
|
for (const c of parseCharacteristics(s.characteristics)) {
|
||||||
|
const k = `${c.key}::${c.value}`;
|
||||||
|
if (!seenChars.has(k)) {
|
||||||
|
seenChars.add(k);
|
||||||
|
data.characteristics.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (id.startsWith('webAspects-')) {
|
||||||
|
if (Array.isArray(s.aspects)) {
|
||||||
|
for (const aspect of s.aspects) {
|
||||||
|
const a = aspect as Record<string, unknown>;
|
||||||
|
if (!Array.isArray(a.variants)) continue;
|
||||||
|
for (const v of a.variants) {
|
||||||
|
const rec = v as Record<string, unknown>;
|
||||||
|
const d = rec.data as Record<string, unknown> | undefined;
|
||||||
|
const name = typeof d?.searchableText === 'string' ? d.searchableText
|
||||||
|
: typeof d?.title === 'string' ? d.title : '';
|
||||||
|
const image = typeof d?.coverImage === 'string' ? d.coverImage : undefined;
|
||||||
|
if (name) data.skuVariants.push({ name, image });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (id.startsWith('webReviewProductScore-')) {
|
||||||
|
if (typeof s.totalScore === 'number') data.rating = String(s.totalScore);
|
||||||
|
if (typeof s.reviewsCount === 'number') data.reviewCount = String(s.reviewsCount);
|
||||||
|
} else if (id.startsWith('breadCrumbs-')) {
|
||||||
|
// breadCrumbs widget state: { breadcrumbs: [{text, link, crumbType}] }
|
||||||
|
if (Array.isArray(s.breadcrumbs) && data.breadcrumbs.length === 0) {
|
||||||
|
for (const crumb of s.breadcrumbs) {
|
||||||
|
const c = crumb as Record<string, unknown>;
|
||||||
|
const name = typeof c.text === 'string' ? c.text.trim() : '';
|
||||||
|
const href = typeof c.link === 'string' ? c.link : '';
|
||||||
|
if (!name || !href) continue;
|
||||||
|
// 解析 ?category=7041(highlight 样式链接)
|
||||||
|
const catMatch = href.match(/[?&]category=(\d+)/);
|
||||||
|
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
|
||||||
|
// 解析 /category/kopilki-15056/(末尾带数字 ID 的 slug)
|
||||||
|
const slugMatch = href.match(/\/category\/([^/?]+)/);
|
||||||
|
const slug = slugMatch ? slugMatch[1] : undefined;
|
||||||
|
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 widget state 没有面包屑(旧版页面),尝试读 DOM 渲染的 ol
|
||||||
|
if (data.breadcrumbs.length === 0) {
|
||||||
|
const ol = document.querySelector('[class*="breadCrumbs"] ol, nav ol, ol[class*="breadcrumb"]');
|
||||||
|
if (ol) {
|
||||||
|
for (const a of Array.from(ol.querySelectorAll('a[href]'))) {
|
||||||
|
const href = a.getAttribute('href') ?? '';
|
||||||
|
const name = a.textContent?.trim() ?? '';
|
||||||
|
if (!name) continue;
|
||||||
|
const catMatch = href.match(/[?&]category=(\d+)/);
|
||||||
|
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
|
||||||
|
const slugMatch = href.match(/\/category\/([^/?]+)/);
|
||||||
|
const slug = slugMatch ? slugMatch[1] : undefined;
|
||||||
|
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* 采集引擎入口 - 扫描当前页
|
||||||
|
*
|
||||||
|
* Ozon 四路径(优先级从高到低):
|
||||||
|
* ① SSR widget state(DOM data-state 属性,同步、白名单、无需网络)★ 主路径
|
||||||
|
* ② JSON-LD(schema.org/Product)
|
||||||
|
* ③ Ozon 内部页 JSON API(entrypoint-api.bx,只收画廊 widget 的图)
|
||||||
|
* ④ DOM 选择器(data-widget 区块)—— 兜底 + 详情图补充
|
||||||
|
*
|
||||||
|
* ① 白名单保证不会读到「为您推荐 / 一起购买」等其它商品 carousel 的图片。
|
||||||
|
*/
|
||||||
|
import { matchProfile } from '../profiles';
|
||||||
|
import { waitForAny } from './dom';
|
||||||
|
import { collectImages, type ImageMaterial } from './image';
|
||||||
|
import { collectTexts, mergeTexts, type TextMaterial } from './text';
|
||||||
|
import { extractJsonLd } from './jsonld';
|
||||||
|
import { fetchOzonPageData, type OzonPageData } from './ozon-api';
|
||||||
|
import { extractOzonState, type OzonStateData } from './ozon-state';
|
||||||
|
import { dedupeKey, toOriginalUrl, toThumbUrl } from './url';
|
||||||
|
import type { SiteProfile } from '../profiles/types';
|
||||||
|
|
||||||
|
export type { ImageMaterial, TextMaterial };
|
||||||
|
|
||||||
|
import type { BreadcrumbItem } from './ozon-state';
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
platform: string;
|
||||||
|
itemId: string | null;
|
||||||
|
url: string;
|
||||||
|
texts: TextMaterial[];
|
||||||
|
images: ImageMaterial[];
|
||||||
|
breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径(用于 studio 类目推荐)
|
||||||
|
scannedAt: number;
|
||||||
|
stats: Record<string, number>; // 分组统计
|
||||||
|
warnings: string[]; // 警告(如详情图为 0)
|
||||||
|
source: 'state' | 'jsonld' | 'api' | 'dom' | 'mixed'; // 主路径
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
|
||||||
|
{ key: 'main', name: '主图' },
|
||||||
|
{ key: 'sku', name: 'SKU图片' },
|
||||||
|
{ key: 'detail', name: '详情图' },
|
||||||
|
{ key: 'video', name: '视频' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 合并后的结构化素材 */
|
||||||
|
interface StructuredBundle {
|
||||||
|
title?: string;
|
||||||
|
price?: string;
|
||||||
|
brand?: string;
|
||||||
|
description?: string;
|
||||||
|
characteristics: Array<{ key: string; value: string }>;
|
||||||
|
galleryImages: string[];
|
||||||
|
videos: string[];
|
||||||
|
videoCovers: string[];
|
||||||
|
skuVariants: Array<{ name: string; image?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并 state + JSON-LD + API,靠前来源优先,靠后来源填空缺 */
|
||||||
|
function mergeStructured(
|
||||||
|
state: OzonStateData,
|
||||||
|
jsonld: ReturnType<typeof extractJsonLd>,
|
||||||
|
api: OzonPageData | null
|
||||||
|
): StructuredBundle {
|
||||||
|
const bundle: StructuredBundle = {
|
||||||
|
title: state.title || jsonld?.title || api?.title,
|
||||||
|
price: state.price || jsonld?.price || api?.price,
|
||||||
|
brand: jsonld?.brand,
|
||||||
|
description: api?.description || jsonld?.description,
|
||||||
|
characteristics: [...state.characteristics],
|
||||||
|
galleryImages: [...state.galleryImages],
|
||||||
|
videos: [...state.videos],
|
||||||
|
videoCovers: [...state.videoCovers],
|
||||||
|
skuVariants: [...state.skuVariants],
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 补充:画廊图片、视频、参数(state 没有才补)
|
||||||
|
for (const u of api?.images ?? []) {
|
||||||
|
if (!bundle.galleryImages.includes(u)) bundle.galleryImages.push(u);
|
||||||
|
}
|
||||||
|
for (const u of api?.videos ?? []) {
|
||||||
|
if (!bundle.videos.includes(u)) bundle.videos.push(u);
|
||||||
|
}
|
||||||
|
const seenChars = new Set(bundle.characteristics.map((c) => `${c.key}::${c.value}`));
|
||||||
|
for (const c of api?.characteristics ?? []) {
|
||||||
|
const k = `${c.key}::${c.value}`;
|
||||||
|
if (!seenChars.has(k)) {
|
||||||
|
seenChars.add(k);
|
||||||
|
bundle.characteristics.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从合并后的结构化素材构建文本与图片 */
|
||||||
|
function buildFromBundle(profile: SiteProfile, bundle: StructuredBundle): {
|
||||||
|
texts: TextMaterial[];
|
||||||
|
images: ImageMaterial[];
|
||||||
|
} {
|
||||||
|
const texts: TextMaterial[] = [];
|
||||||
|
const images: ImageMaterial[] = [];
|
||||||
|
|
||||||
|
if (bundle.title) texts.push({ kind: 'title', content: bundle.title });
|
||||||
|
if (bundle.price) texts.push({ kind: 'price', content: bundle.price });
|
||||||
|
if (bundle.brand) texts.push({ kind: 'brand', content: bundle.brand });
|
||||||
|
if (bundle.characteristics.length) {
|
||||||
|
texts.push({
|
||||||
|
kind: 'params',
|
||||||
|
content: bundle.characteristics.map((p) => `${p.key}: ${p.value}`).join('\n'),
|
||||||
|
pairs: bundle.characteristics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bundle.description) texts.push({ kind: 'desc', content: bundle.description });
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
bundle.galleryImages.forEach((u, i) => {
|
||||||
|
const orig = toOriginalUrl(u, profile.originalUrlRules);
|
||||||
|
images.push({
|
||||||
|
key: `main-${String(i + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: 'main',
|
||||||
|
groupName: '主图',
|
||||||
|
url: orig,
|
||||||
|
thumbUrl: toThumbUrl(orig),
|
||||||
|
index: idx++,
|
||||||
|
type: 'img',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
bundle.skuVariants.forEach((s, i) => {
|
||||||
|
if (!s.image) return;
|
||||||
|
const orig = toOriginalUrl(s.image, profile.originalUrlRules);
|
||||||
|
images.push({
|
||||||
|
key: `sku-${String(i + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: 'sku',
|
||||||
|
groupName: 'SKU图片',
|
||||||
|
variantName: s.name || undefined,
|
||||||
|
url: orig,
|
||||||
|
thumbUrl: toThumbUrl(orig),
|
||||||
|
index: idx++,
|
||||||
|
type: 'img',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
bundle.videos.forEach((u, i) => {
|
||||||
|
images.push({
|
||||||
|
key: `video-${String(i + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: 'video',
|
||||||
|
groupName: '视频',
|
||||||
|
url: u,
|
||||||
|
// 用视频封面图做缩略图(首帧),拿不到再留空走 ▶ 占位
|
||||||
|
thumbUrl: bundle.videoCovers[i] ?? '',
|
||||||
|
index: idx++,
|
||||||
|
type: 'video',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { texts, images };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按组分组合并:结构化优先,DOM 填缺,按 dedupeKey 去重后重排 index */
|
||||||
|
function mergeImages(
|
||||||
|
structured: ImageMaterial[],
|
||||||
|
dom: ImageMaterial[],
|
||||||
|
profile: SiteProfile
|
||||||
|
): ImageMaterial[] {
|
||||||
|
const byGroup = new Map<string, ImageMaterial[]>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
const push = (m: ImageMaterial) => {
|
||||||
|
const k = m.groupKey === 'sku'
|
||||||
|
? `${dedupeKey(m.url, profile.originalUrlRules)}::${m.variantName ?? ''}`
|
||||||
|
: dedupeKey(m.url, profile.originalUrlRules);
|
||||||
|
if (seen.has(k)) return;
|
||||||
|
seen.add(k);
|
||||||
|
const arr = byGroup.get(m.groupKey) ?? [];
|
||||||
|
arr.push({ ...m, index: counter++ });
|
||||||
|
byGroup.set(m.groupKey, arr);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const m of structured) push(m);
|
||||||
|
for (const m of dom) push(m);
|
||||||
|
|
||||||
|
const out: ImageMaterial[] = [];
|
||||||
|
for (const g of GROUP_ORDER) {
|
||||||
|
const arr = byGroup.get(g.key);
|
||||||
|
if (!arr) continue;
|
||||||
|
// 组内重排 key(main-001 …)
|
||||||
|
arr.forEach((m, i) => {
|
||||||
|
m.key = `${g.key}-${String(i + 1).padStart(3, '0')}`;
|
||||||
|
m.groupName = g.name;
|
||||||
|
});
|
||||||
|
out.push(...arr);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanCurrentPage(): Promise<ScanResult | null> {
|
||||||
|
const profile = matchProfile(location.href);
|
||||||
|
if (!profile) {
|
||||||
|
console.warn('[Ozon Seller Kit] 当前页面不支持采集:', location.href);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = profile.extractItemId(location.href);
|
||||||
|
console.log('[Ozon Seller Kit] 开始采集:', profile.name, itemId, location.href);
|
||||||
|
|
||||||
|
// ── 路径①:SSR widget state(同步、白名单)──
|
||||||
|
const state = extractOzonState();
|
||||||
|
let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom';
|
||||||
|
|
||||||
|
// ── 路径②:JSON-LD ──
|
||||||
|
const jsonld = extractJsonLd();
|
||||||
|
|
||||||
|
// ── 路径③:Ozon 页 JSON API(异步,失败不阻塞)──
|
||||||
|
let api: OzonPageData | null = null;
|
||||||
|
if (profile.id === 'ozon' && itemId) {
|
||||||
|
try {
|
||||||
|
api = await fetchOzonPageData(itemId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Ozon Seller Kit] API 提取异常:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = mergeStructured(state, jsonld, api);
|
||||||
|
const structured = buildFromBundle(profile, bundle);
|
||||||
|
const usedStructured = structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0;
|
||||||
|
if (usedStructured && source === 'dom') source = 'mixed';
|
||||||
|
|
||||||
|
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
|
||||||
|
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
|
||||||
|
if (!anchor) {
|
||||||
|
console.warn('[Ozon Seller Kit] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||||
|
}
|
||||||
|
const domTexts = collectTexts(profile).materials;
|
||||||
|
const domImages = collectImages(profile);
|
||||||
|
|
||||||
|
// ── 合并 ──
|
||||||
|
const texts = mergeTexts(structured.texts, domTexts);
|
||||||
|
const images = mergeImages(structured.images, domImages, profile);
|
||||||
|
|
||||||
|
const stats: Record<string, number> = {};
|
||||||
|
for (const img of images) stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题(所有路径均失败)');
|
||||||
|
if (images.length === 0) warnings.push('未扫描到任何图片/视频');
|
||||||
|
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
|
||||||
|
|
||||||
|
console.log('[Ozon Seller Kit] 采集完成:', {
|
||||||
|
texts: texts.map((t) => t.kind),
|
||||||
|
images: images.length,
|
||||||
|
stats,
|
||||||
|
warnings,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: profile.id,
|
||||||
|
itemId,
|
||||||
|
url: location.href,
|
||||||
|
texts,
|
||||||
|
images,
|
||||||
|
breadcrumbs: state.breadcrumbs,
|
||||||
|
scannedAt: Date.now(),
|
||||||
|
stats,
|
||||||
|
warnings,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露到全局供 side panel / console 调用
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as any).__SellerHelperOzon = {
|
||||||
|
scan: scanCurrentPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 文本提取 - 标题、价格、参数表、卖点、描述、品牌
|
||||||
|
* 从 extension-v1 移植(DOM 兜底路径)
|
||||||
|
*/
|
||||||
|
import type { SiteProfile, TextRule } from '../profiles/types';
|
||||||
|
|
||||||
|
export interface TextMaterial {
|
||||||
|
kind: TextRule['kind'];
|
||||||
|
content: string;
|
||||||
|
pairs?: Array<{ key: string; value: string }>; // table 模式的结构化结果
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(s: string): string {
|
||||||
|
return s.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOne(rule: TextRule): TextMaterial | null {
|
||||||
|
for (const sel of rule.selectors) {
|
||||||
|
let nodes: NodeListOf<Element>;
|
||||||
|
try {
|
||||||
|
nodes = document.querySelectorAll(sel);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!nodes.length) continue;
|
||||||
|
|
||||||
|
// table 模式:参数表
|
||||||
|
if (rule.extract === 'table') {
|
||||||
|
const pairs: Array<{ key: string; value: string }> = [];
|
||||||
|
nodes.forEach((row) => {
|
||||||
|
const k = clean(row.querySelector(rule.tableKeySelector ?? '')?.textContent ?? '');
|
||||||
|
const v = clean(row.querySelector(rule.tableValueSelector ?? '')?.textContent ?? '');
|
||||||
|
if (k && v) pairs.push({ key: k.replace(/[::]$/, ''), value: v });
|
||||||
|
});
|
||||||
|
if (pairs.length) {
|
||||||
|
return {
|
||||||
|
kind: rule.kind,
|
||||||
|
content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'),
|
||||||
|
pairs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// join 模式:标题被拆成多个 span
|
||||||
|
if (rule.extract === 'join') {
|
||||||
|
let text = '';
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
text += n.textContent ?? '';
|
||||||
|
});
|
||||||
|
text = clean(text);
|
||||||
|
if (text) return { kind: rule.kind, content: text };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// first 模式:只取第一个
|
||||||
|
const first = clean(nodes[0].textContent ?? '');
|
||||||
|
if (first) return { kind: rule.kind, content: first };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectTexts(profile: SiteProfile): {
|
||||||
|
materials: TextMaterial[];
|
||||||
|
missingRequired: string[];
|
||||||
|
} {
|
||||||
|
const materials: TextMaterial[] = [];
|
||||||
|
const missingRequired: string[] = [];
|
||||||
|
|
||||||
|
for (const rule of profile.textRules) {
|
||||||
|
const m = extractOne(rule);
|
||||||
|
if (m) materials.push(m);
|
||||||
|
else if (rule.required) missingRequired.push(rule.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { materials, missingRequired };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并去重:以 kind 为键,结构化来源优先,DOM 来源兜底。
|
||||||
|
* 参数表(params)特殊处理:两边的 pairs 做并集合并(按 key 去重),
|
||||||
|
* 因为「关于商品」只给前几项,完整「特征」在 DOM 里,需要合并才能拿全。
|
||||||
|
*/
|
||||||
|
export function mergeTexts(
|
||||||
|
primary: TextMaterial[],
|
||||||
|
fallback: TextMaterial[]
|
||||||
|
): TextMaterial[] {
|
||||||
|
const map = new Map<string, TextMaterial>();
|
||||||
|
for (const m of [...primary, ...fallback]) {
|
||||||
|
if (m.kind === 'params') {
|
||||||
|
const existing = map.get('params');
|
||||||
|
if (!existing) {
|
||||||
|
map.set('params', { ...m, pairs: [...(m.pairs ?? [])] });
|
||||||
|
} else {
|
||||||
|
const merged = [...(existing.pairs ?? [])];
|
||||||
|
const seen = new Set(merged.map((p) => p.key));
|
||||||
|
for (const p of m.pairs ?? []) {
|
||||||
|
if (!seen.has(p.key)) {
|
||||||
|
merged.push(p);
|
||||||
|
seen.add(p.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing.pairs = merged;
|
||||||
|
existing.content = merged.map((p) => `${p.key}: ${p.value}`).join('\n');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!map.has(m.kind)) map.set(m.kind, m);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* URL 工具链
|
||||||
|
* 从 extension-v1 移植,新增:
|
||||||
|
* - toOriginalUrl 支持平台自定义规则(Ozon 的 /wc\d+/ 路径段尺寸标记)
|
||||||
|
* - pickBestFromSrcset:从 srcset 里挑最大尺寸候选
|
||||||
|
*/
|
||||||
|
|
||||||
|
const IMG_EXT = /\.(jpg|jpeg|png|gif|bmp|heic|webp|avif)$/i;
|
||||||
|
|
||||||
|
export interface UrlRule {
|
||||||
|
match: RegExp;
|
||||||
|
replace: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩略图 URL → 原图 URL
|
||||||
|
* 先走平台规则(Ozon 的 /wc\d+/ → /wc1200/),
|
||||||
|
* 再走阿里系通用规则:xxx.jpg_400x400.jpg → xxx.jpg
|
||||||
|
*/
|
||||||
|
export function toOriginalUrl(url: string, rules?: UrlRule[]): string {
|
||||||
|
let out = url;
|
||||||
|
for (const r of rules ?? []) {
|
||||||
|
// 带 g 标志的正则(query 清洗)要反复 replace,不带 g 的只替换一次
|
||||||
|
if (r.match.global) {
|
||||||
|
out = out.replace(r.match, r.replace);
|
||||||
|
} else if (r.match.test(out)) {
|
||||||
|
out = out.replace(r.match, r.replace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const m = out.match(/^(.+?\.(jpg|jpeg|png|gif|bmp|heic|webp|avif))_/i);
|
||||||
|
return m ? m[1] : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** url("https://...") → https://... */
|
||||||
|
export function urlInBrackets(s: string): string {
|
||||||
|
if (!s?.trim()) return '';
|
||||||
|
return s.match(/\((.*?)\)/)?.[1]?.replace(/['"]/g, '') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDataUrl(u: string): boolean {
|
||||||
|
return /^data:image/.test(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 协议相对 // / 根相对 / / 相对路径 → 绝对 URL */
|
||||||
|
export function toAbsoluteUrl(u: string): string {
|
||||||
|
if (!u) return u;
|
||||||
|
if (isDataUrl(u) || u.startsWith('blob:')) return u;
|
||||||
|
const proto = u.startsWith('http:') ? 'http' : 'https';
|
||||||
|
if (/^\/\//.test(u)) return `${proto}:${u}`;
|
||||||
|
if (/^\//.test(u)) return `${location.origin}${u}`;
|
||||||
|
if (!/^(.*):/.test(u)) return `${location.origin}/${u}`;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去重用的归一化 key:还原原图 + 剥 query/hash */
|
||||||
|
export function dedupeKey(url: string, rules?: UrlRule[]): string {
|
||||||
|
const base = toOriginalUrl(url, rules);
|
||||||
|
try {
|
||||||
|
const u = new URL(base);
|
||||||
|
u.search = '';
|
||||||
|
u.hash = '';
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeImageUrl(u: string): boolean {
|
||||||
|
if (isDataUrl(u)) return true;
|
||||||
|
try {
|
||||||
|
return IMG_EXT.test(new URL(u).pathname);
|
||||||
|
} catch {
|
||||||
|
return IMG_EXT.test(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 srcset 里挑最大尺寸候选。
|
||||||
|
* 支持两种语法:
|
||||||
|
* "a.jpg 100w, b.jpg 200w, c.jpg 300w" → c.jpg
|
||||||
|
* "a.jpg 1x, b.jpg 2x" → 最后一个
|
||||||
|
* "a.jpg 400w, b.jpg 800w, c.jpg 1200w, d.jpg" → 最后一个(无描述符 = 兜底最大)
|
||||||
|
*/
|
||||||
|
export function pickBestFromSrcset(srcset: string): string {
|
||||||
|
if (!srcset) return '';
|
||||||
|
const parts = srcset.split(',').map((p) => p.trim()).filter(Boolean);
|
||||||
|
if (!parts.length) return '';
|
||||||
|
|
||||||
|
let best = '';
|
||||||
|
let bestSize = -1;
|
||||||
|
for (const part of parts) {
|
||||||
|
const seg = part.split(/\s+/);
|
||||||
|
const url = seg[0];
|
||||||
|
const desc = seg[1] ?? '';
|
||||||
|
let size = -1;
|
||||||
|
const w = desc.match(/^(\d+)w$/);
|
||||||
|
const x = desc.match(/^(\d+(?:\.\d+)?)x$/);
|
||||||
|
if (w) size = Number(w[1]);
|
||||||
|
else if (x) size = Math.round(Number(x[1]) * 1000);
|
||||||
|
else size = 0; // 无描述符,通常是最小的兜底,但也可能是唯一候选
|
||||||
|
|
||||||
|
if (size >= bestSize) {
|
||||||
|
bestSize = size;
|
||||||
|
best = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ozon CDN 原图 → wc200 缩略图(侧边栏预览用,省流量)
|
||||||
|
* 实测结构(reference/ozon1.html):
|
||||||
|
* https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg
|
||||||
|
* → https://ir.ozone.ru/s3/multimedia-1-5/wc200/9290076089.jpg
|
||||||
|
* 已带尺寸标记(/wc\d+/、/c\d+/)或非 multimedia 路径的 URL 原样返回。
|
||||||
|
*/
|
||||||
|
export function toThumbUrl(url: string): string {
|
||||||
|
const m = url.match(/^(https?:\/\/[^/]+\/s3\/[^/]+\/)([^/]+)$/);
|
||||||
|
if (m && !/\/wc\d+\//.test(url) && !/\/c\d+\//.test(url)) {
|
||||||
|
return `${m[1]}wc200/${m[2]}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清洗文件名非法字符(Windows 兼容) */
|
||||||
|
export function cleanFilename(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[<>:"/\\|?*]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.substring(0, 80);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* 商品文件夹构建器 —— ScanResult → product.json / sources.json / 待写图片清单
|
||||||
|
* 契约见 docs/contracts/product-json.md
|
||||||
|
*/
|
||||||
|
import type { ScanResult, ImageMaterial } from '../collector/scan';
|
||||||
|
import type { ProductJson, SourcesJson } from '../schema/product';
|
||||||
|
import { cleanFilename } from '../collector/url';
|
||||||
|
|
||||||
|
export interface BuiltProduct {
|
||||||
|
folderName: string;
|
||||||
|
product: ProductJson;
|
||||||
|
sources: SourcesJson;
|
||||||
|
/** 待写图片:相对路径 + 源 URL */
|
||||||
|
files: Array<{ relativePath: string; url: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户在表单里二次修改后的文本(覆盖采集原文) */
|
||||||
|
export interface TextEdits {
|
||||||
|
title?: string;
|
||||||
|
price?: string;
|
||||||
|
brand?: string;
|
||||||
|
desc?: string;
|
||||||
|
sellingPoints?: string;
|
||||||
|
params?: Array<{ key: string; value: string }>;
|
||||||
|
/** 包装重量(原样字符串,如 "3.5 кг") */
|
||||||
|
weight?: string;
|
||||||
|
/** 包装尺寸(长/宽/高) */
|
||||||
|
dims?: { l: string; w: string; h: string };
|
||||||
|
/** 包装尺寸单位(上传时保留单位,避免 mm/cm 混淆) */
|
||||||
|
dimsUnit?: 'mm' | 'cm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExt(img: ImageMaterial): string {
|
||||||
|
try {
|
||||||
|
const m = new URL(img.url).pathname.match(/\.(jpg|jpeg|png|webp|gif|avif|mp4|mov|m3u8|webm)$/i);
|
||||||
|
if (m) return m[1].toLowerCase();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return img.type === 'video' ? 'mp4' : 'jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "1 290 ₽" → "1290" */
|
||||||
|
function extractNumericPrice(text: string): string {
|
||||||
|
const m = text.replace(/\s/g, '').replace(',', '.').match(/(\d+(?:\.\d+)?)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "3.5 кг" → 3.5 */
|
||||||
|
function parseNumber(text: string): number | null {
|
||||||
|
const m = (text ?? '').replace(',', '.').match(/(\d+(?:\.\d+)?)/);
|
||||||
|
return m ? parseFloat(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析包装重量,返回 { weight, unit },单位自动识别 kg/g */
|
||||||
|
function parseWeight(text?: string): { weight: number | null; unit: 'g' | 'kg' } {
|
||||||
|
const s = (text ?? '').toLowerCase();
|
||||||
|
const num = parseNumber(s);
|
||||||
|
if (num == null) return { weight: null, unit: 'g' };
|
||||||
|
if (s.includes('кг') || s.includes('kg')) return { weight: num, unit: 'kg' };
|
||||||
|
if (s.includes('г') || s.includes('g')) return { weight: num, unit: 'g' };
|
||||||
|
return { weight: num, unit: 'g' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析包装尺寸,单位自动识别 cm/mm */
|
||||||
|
function parseDimUnit(text?: string): 'cm' | 'mm' {
|
||||||
|
const s = (text ?? '').toLowerCase();
|
||||||
|
if (s.includes('мм') || s.includes('mm')) return 'mm';
|
||||||
|
return 'cm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProduct(
|
||||||
|
result: ScanResult,
|
||||||
|
selectedKeys: Set<string>,
|
||||||
|
edits?: TextEdits
|
||||||
|
): BuiltProduct {
|
||||||
|
const text = (kind: string) => result.texts.find((t) => t.kind === kind)?.content ?? '';
|
||||||
|
|
||||||
|
const title = edits?.title ?? text('title');
|
||||||
|
const priceText = edits?.price ?? text('price');
|
||||||
|
const brand = edits?.brand ?? text('brand');
|
||||||
|
const desc = edits?.desc ?? text('desc');
|
||||||
|
const sellingPoints = edits?.sellingPoints ?? text('selling_point');
|
||||||
|
const params =
|
||||||
|
edits?.params ?? result.texts.find((t) => t.kind === 'params')?.pairs;
|
||||||
|
|
||||||
|
const folderName = cleanFilename(title || `ozon-${result.itemId ?? 'product'}`) || 'ozon-product';
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const selected = result.images.filter((img) => selectedKeys.has(img.key));
|
||||||
|
|
||||||
|
const images: ProductJson['_images'] = { main: [], sku: [], detail: [], video: [] };
|
||||||
|
const files: BuiltProduct['files'] = [];
|
||||||
|
const dedupeKeys: string[] = [];
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const img of selected) {
|
||||||
|
counts[img.groupKey] = (counts[img.groupKey] ?? 0) + 1;
|
||||||
|
const ext = extractExt(img);
|
||||||
|
const base = `${img.groupKey}-${String(counts[img.groupKey]).padStart(3, '0')}`;
|
||||||
|
const variantSuffix = img.groupKey === 'sku' && img.variantName ? `-${cleanFilename(img.variantName)}` : '';
|
||||||
|
const filename = `${base}${variantSuffix}.${ext}`;
|
||||||
|
const relativePath = `images/${img.groupKey}/${filename}`;
|
||||||
|
|
||||||
|
images[img.groupKey].push({
|
||||||
|
file: relativePath,
|
||||||
|
sourceUrl: img.url,
|
||||||
|
variantName: img.variantName,
|
||||||
|
w: img.width,
|
||||||
|
h: img.height,
|
||||||
|
});
|
||||||
|
files.push({ relativePath, url: img.url });
|
||||||
|
dedupeKeys.push(img.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightInfo = parseWeight(edits?.weight);
|
||||||
|
const dimUnit = parseDimUnit(edits?.dims?.l || edits?.dims?.w || edits?.dims?.h);
|
||||||
|
|
||||||
|
const product: ProductJson = {
|
||||||
|
_meta: { schemaVersion: 1, stage: 'collected', createdAt: now, updatedAt: now },
|
||||||
|
offer_id: '',
|
||||||
|
name: title,
|
||||||
|
description: desc,
|
||||||
|
description_category_id: null,
|
||||||
|
type_id: null,
|
||||||
|
price: extractNumericPrice(priceText),
|
||||||
|
old_price: '',
|
||||||
|
currency_code: 'RUB',
|
||||||
|
vat: '0',
|
||||||
|
depth: parseNumber(edits?.dims?.l ?? ''),
|
||||||
|
width: parseNumber(edits?.dims?.w ?? ''),
|
||||||
|
height: parseNumber(edits?.dims?.h ?? ''),
|
||||||
|
dimension_unit: dimUnit,
|
||||||
|
weight: weightInfo.weight,
|
||||||
|
weight_unit: weightInfo.unit,
|
||||||
|
images: [],
|
||||||
|
primary_image: '',
|
||||||
|
images360: [],
|
||||||
|
color_image: '',
|
||||||
|
attributes: [],
|
||||||
|
complex_attributes: [],
|
||||||
|
_images: images,
|
||||||
|
_raw: {
|
||||||
|
title,
|
||||||
|
price: priceText,
|
||||||
|
params,
|
||||||
|
desc,
|
||||||
|
sellingPoints,
|
||||||
|
brand,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sources: SourcesJson = {
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
platform: result.platform as 'ozon',
|
||||||
|
itemId: result.itemId,
|
||||||
|
url: result.url,
|
||||||
|
collectedAt: now,
|
||||||
|
counts: { ...result.stats },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dedupeKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { folderName, product, sources, files };
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* File System Access 写盘 —— 生成完整商品文件夹
|
||||||
|
*
|
||||||
|
* 目录结构(契约见 docs/contracts/product-json.md):
|
||||||
|
* <商品名>/
|
||||||
|
* ├── product.json
|
||||||
|
* ├── sources.json
|
||||||
|
* └── images/{main,sku,detail,video}/main-001.jpg …
|
||||||
|
*
|
||||||
|
* 图片字节统一走 background 代理 fetch(绕 CORS / 防盗链)。
|
||||||
|
*/
|
||||||
|
import { loadRootDir, saveRootDir } from './idb';
|
||||||
|
import type { ProductJson, SourcesJson } from '../schema/product';
|
||||||
|
|
||||||
|
export interface ExportResult {
|
||||||
|
folderName: string;
|
||||||
|
written: number;
|
||||||
|
failed: Array<{ file: string; error: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取(或让用户选)根目录,并确保读写权限 */
|
||||||
|
export async function ensureRootDir(): Promise<FileSystemDirectoryHandle> {
|
||||||
|
let handle = await loadRootDir();
|
||||||
|
|
||||||
|
if (!handle) {
|
||||||
|
handle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||||
|
await saveRootDir(handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
let perm = await handle.queryPermission({ mode: 'readwrite' });
|
||||||
|
if (perm !== 'granted') {
|
||||||
|
perm = await handle.requestPermission({ mode: 'readwrite' });
|
||||||
|
}
|
||||||
|
if (perm !== 'granted') throw new Error('目录读写权限被拒绝');
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新选择根目录(忽略已保存的,强制弹出选择器) */
|
||||||
|
export async function chooseRootDir(): Promise<FileSystemDirectoryHandle> {
|
||||||
|
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||||
|
await saveRootDir(handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过 background 代理取图,返回 Blob */
|
||||||
|
async function fetchImageBlob(url: string): Promise<Blob> {
|
||||||
|
const resp = await chrome.runtime.sendMessage({ action: 'fetchImage', url });
|
||||||
|
if (!resp?.ok) throw new Error(resp?.error ?? '图片下载失败');
|
||||||
|
const res = await fetch(resp.dataUrl);
|
||||||
|
if (!res.ok) throw new Error(`解码失败 HTTP ${res.status}`);
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeProductFolder(
|
||||||
|
folderName: string,
|
||||||
|
product: ProductJson,
|
||||||
|
sources: SourcesJson,
|
||||||
|
files: Array<{ relativePath: string; url: string }>,
|
||||||
|
onProgress?: (done: number, total: number) => void
|
||||||
|
): Promise<ExportResult> {
|
||||||
|
const root = await ensureRootDir();
|
||||||
|
const productDir = await root.getDirectoryHandle(folderName, { create: true });
|
||||||
|
|
||||||
|
// product.json
|
||||||
|
const pj = await productDir.getFileHandle('product.json', { create: true });
|
||||||
|
const w1 = await pj.createWritable();
|
||||||
|
await w1.write(JSON.stringify(product, null, 2));
|
||||||
|
await w1.close();
|
||||||
|
|
||||||
|
// sources.json
|
||||||
|
const sj = await productDir.getFileHandle('sources.json', { create: true });
|
||||||
|
const w2 = await sj.createWritable();
|
||||||
|
await w2.write(JSON.stringify(sources, null, 2));
|
||||||
|
await w2.close();
|
||||||
|
|
||||||
|
// images/
|
||||||
|
const imagesDir = await productDir.getDirectoryHandle('images', { create: true });
|
||||||
|
const total = files.length;
|
||||||
|
let written = 0;
|
||||||
|
const failed: Array<{ file: string; error: string }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const f = files[i];
|
||||||
|
const parts = f.relativePath.split('/'); // "images/main/main-001.jpg"
|
||||||
|
const group = parts[1];
|
||||||
|
const filename = parts[2];
|
||||||
|
try {
|
||||||
|
const blob = await fetchImageBlob(f.url);
|
||||||
|
const groupDir = await imagesDir.getDirectoryHandle(group, { create: true });
|
||||||
|
const fh = await groupDir.getFileHandle(filename, { create: true });
|
||||||
|
const w = await fh.createWritable();
|
||||||
|
await w.write(blob);
|
||||||
|
await w.close();
|
||||||
|
written++;
|
||||||
|
} catch (err) {
|
||||||
|
failed.push({ file: f.relativePath, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
onProgress?.(i + 1, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { folderName, written, failed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* IndexedDB 封装 —— 持久化 FileSystemDirectoryHandle
|
||||||
|
*
|
||||||
|
* chrome.storage 存不了 FileSystemDirectoryHandle(它不是 JSON 可序列化类型),
|
||||||
|
* 必须用 IndexedDB(structured clone 支持)。存一次后跨会话免重复授权。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DB_NAME = 'ozon-seller-kit';
|
||||||
|
const STORE = 'handles';
|
||||||
|
const ROOT_KEY = 'SH_ROOT_DIR';
|
||||||
|
|
||||||
|
function openDb(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
if (!req.result.objectStoreNames.contains(STORE)) {
|
||||||
|
req.result.createObjectStore(STORE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbSet(key: string, value: unknown): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite');
|
||||||
|
tx.objectStore(STORE).put(value, key);
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
tx.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
reject(tx.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbGet<T>(key: string): Promise<T | null> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readonly');
|
||||||
|
const req = tx.objectStore(STORE).get(key);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
db.close();
|
||||||
|
resolve((req.result as T) ?? null);
|
||||||
|
};
|
||||||
|
req.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
reject(req.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRootDir(handle: FileSystemDirectoryHandle): Promise<void> {
|
||||||
|
await idbSet(ROOT_KEY, handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRootDir(): Promise<FileSystemDirectoryHandle | null> {
|
||||||
|
return idbGet<FileSystemDirectoryHandle>(ROOT_KEY);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Profile 路由 - 根据 URL 匹配平台
|
||||||
|
*/
|
||||||
|
import type { SiteProfile } from './types';
|
||||||
|
import { profileOzon } from './ozon';
|
||||||
|
|
||||||
|
const PROFILES: SiteProfile[] = [profileOzon];
|
||||||
|
|
||||||
|
export function matchProfile(url: string): SiteProfile | null {
|
||||||
|
for (const p of PROFILES) {
|
||||||
|
if (p.urlPatterns.some((re) => re.test(url))) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { profileOzon };
|
||||||
|
export type { SiteProfile };
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Ozon 商品页采集配置
|
||||||
|
*
|
||||||
|
* 选择器已在真实页面实测(reference/ozon1.html、ozon2.html,2026-08-15):
|
||||||
|
* - webProductHeading → <h1> 标题
|
||||||
|
* - webGallery → 主图(<img srcset>,wc50/wc100 缩略图)
|
||||||
|
* - webAspects → SKU 变体(颜色/尺码选择器)
|
||||||
|
* - webShortCharacteristics / webDetailedCharacteristics → 参数表("关于商品"区)
|
||||||
|
* - webPrice → 价格(DOM 结构复杂,价格主路径走 data-state)
|
||||||
|
*
|
||||||
|
* ★ 主采集路径是 structured(ozon-state.ts 读 SSR data-state + JSON-LD + API),
|
||||||
|
* 本文件的 DOM 选择器只是兜底 + 详情图补充。
|
||||||
|
*/
|
||||||
|
import type { SiteProfile } from './types';
|
||||||
|
|
||||||
|
export const profileOzon: SiteProfile = {
|
||||||
|
id: 'ozon',
|
||||||
|
name: 'Ozon',
|
||||||
|
|
||||||
|
urlPatterns: [
|
||||||
|
// 新版: https://www.ozon.ru/product/slug-123456789/
|
||||||
|
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/product\/[^/]+-\d+\/?/,
|
||||||
|
// 旧版: https://www.ozon.ru/context/detail/id/123456789/
|
||||||
|
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/context\/detail\/id\/\d+/,
|
||||||
|
],
|
||||||
|
|
||||||
|
extractItemId: (url) => {
|
||||||
|
const m = url.match(/\/product\/[^/]+-(\d+)\/?/);
|
||||||
|
if (m?.[1]) return m[1];
|
||||||
|
const m2 = url.match(/\/context\/detail\/id\/(\d+)/);
|
||||||
|
return m2?.[1] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
readySelectors: [
|
||||||
|
'[data-widget="webProductHeading"]',
|
||||||
|
'[data-widget="webGallery"]',
|
||||||
|
'h1',
|
||||||
|
],
|
||||||
|
readyTimeoutMs: 8_000,
|
||||||
|
|
||||||
|
// Ozon 画廊图片是 <img srcset>,懒加载真实地址在 srcset / currentSrc / src
|
||||||
|
defaultSrcProps: ['srcset', 'currentSrc', 'src', 'data-src'],
|
||||||
|
|
||||||
|
refererOrigin: 'https://www.ozon.ru',
|
||||||
|
|
||||||
|
textRules: [
|
||||||
|
{
|
||||||
|
kind: 'title',
|
||||||
|
selectors: [
|
||||||
|
'[data-widget="webProductHeading"] h1',
|
||||||
|
'h1[itemprop="name"]',
|
||||||
|
'h1',
|
||||||
|
],
|
||||||
|
extract: 'first',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'price',
|
||||||
|
selectors: [
|
||||||
|
'[data-widget="webPrice"] span',
|
||||||
|
'span[itemprop="price"]',
|
||||||
|
'[data-widget="webPrice"]',
|
||||||
|
],
|
||||||
|
extract: 'first',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'params',
|
||||||
|
selectors: [
|
||||||
|
'[data-widget="webDetailedCharacteristics"] dl',
|
||||||
|
'[data-widget="webCharacteristics"] dl',
|
||||||
|
'[data-widget="webShortCharacteristics"] dl',
|
||||||
|
'[data-widget="webAspects"] dl',
|
||||||
|
'#section-characteristics dl',
|
||||||
|
],
|
||||||
|
extract: 'table',
|
||||||
|
tableKeySelector: 'dt, [class*="key"], [class*="Key"], [class*="label"]',
|
||||||
|
tableValueSelector: 'dd, [class*="value"], [class*="Value"]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'selling_point',
|
||||||
|
selectors: [
|
||||||
|
'[data-widget="webShortCharacteristics"]',
|
||||||
|
'[data-widget="webFeatures"]',
|
||||||
|
'[data-widget="webAO"]',
|
||||||
|
],
|
||||||
|
extract: 'join',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'desc',
|
||||||
|
selectors: [
|
||||||
|
'[data-widget="webDescription"]',
|
||||||
|
'[data-widget="webRichContent"]',
|
||||||
|
'#section-description',
|
||||||
|
],
|
||||||
|
extract: 'join',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
imageGroups: [
|
||||||
|
{
|
||||||
|
key: 'main',
|
||||||
|
name: '主图',
|
||||||
|
type: 'img',
|
||||||
|
selectors: [
|
||||||
|
'[data-widget="webGallery"] img',
|
||||||
|
'[data-widget="webGallery"] source',
|
||||||
|
'[data-widget="webPhotoGallery"] img',
|
||||||
|
],
|
||||||
|
// 不设 minWidth:画廊缩略图 naturalWidth 可能很小,原图靠 toOriginalUrl 还原
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sku',
|
||||||
|
name: 'SKU图片',
|
||||||
|
type: 'img',
|
||||||
|
selectors: [
|
||||||
|
// 实测:变体选择器在 webAspects(webDetailSKU 其实是"复制 SKU"按钮,没有图)
|
||||||
|
'[data-widget="webAspects"] img',
|
||||||
|
'[data-widget="webVariants"] img',
|
||||||
|
],
|
||||||
|
nameSelectors: [
|
||||||
|
'span[class*="Value"]',
|
||||||
|
'span[class*="Text"]',
|
||||||
|
'span',
|
||||||
|
],
|
||||||
|
minWidth: 16,
|
||||||
|
minHeight: 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'detail',
|
||||||
|
name: '详情图',
|
||||||
|
type: 'img',
|
||||||
|
selectors: [
|
||||||
|
'[data-widget="webDescription"] img',
|
||||||
|
'[data-widget="webRichContent"] img',
|
||||||
|
'[data-widget="webFeatures"] img',
|
||||||
|
'#section-description img',
|
||||||
|
],
|
||||||
|
minWidth: 300,
|
||||||
|
minHeight: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'video',
|
||||||
|
name: '视频',
|
||||||
|
type: 'video',
|
||||||
|
selectors: [
|
||||||
|
'[data-widget="webGallery"] video',
|
||||||
|
'[data-widget="webVideo"] video',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// 实测 CDN(ir.ozone.ru):尺寸标记是路径段 /wc\d+/(wc50…wc1000)和 /c\d+/(c50/c600)
|
||||||
|
// 去掉标记即为原图(页面本身就有无标记的原始 URL)。
|
||||||
|
originalUrlRules: [
|
||||||
|
{ match: /\/wc\d+\//, replace: '/' },
|
||||||
|
{ match: /\/c\d+\//, replace: '/' },
|
||||||
|
// 去掉尺寸段后路径里会有双斜杠(不动 https:// 的 //)
|
||||||
|
{ match: /(?<!:)\/{2,}/g, replace: '/' },
|
||||||
|
// 兼容 query 参数形式的尺寸(?width=200&h=300 逐个剥掉)
|
||||||
|
{ match: /[?&](width|height|size|quality|w|h)=[^&]+/g, replace: '' },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Site Profile - 平台采集配置(声明式)
|
||||||
|
*
|
||||||
|
* 与 extension-v1 同一套抽象,新增 Ozon 需要的文本类型:
|
||||||
|
* selling_point(卖点 / About this item)、brand(品牌)。
|
||||||
|
*
|
||||||
|
* 采集引擎(collector/)完全通用,加一个新平台只需新增一个 profile。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TextKind =
|
||||||
|
| 'title'
|
||||||
|
| 'price'
|
||||||
|
| 'params'
|
||||||
|
| 'selling_point'
|
||||||
|
| 'desc'
|
||||||
|
| 'brand';
|
||||||
|
|
||||||
|
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video';
|
||||||
|
|
||||||
|
export type SrcProp =
|
||||||
|
| 'data-lazyload-src'
|
||||||
|
| 'data-src'
|
||||||
|
| 'srcset'
|
||||||
|
| 'currentSrc'
|
||||||
|
| 'src'
|
||||||
|
| 'backgroundImage';
|
||||||
|
|
||||||
|
export interface TextRule {
|
||||||
|
kind: TextKind;
|
||||||
|
/** 多套选择器,逐个尝试直到命中 */
|
||||||
|
selectors: string[];
|
||||||
|
extract: 'join' | 'first' | 'table';
|
||||||
|
/** table 模式的 key/value 子选择器 */
|
||||||
|
tableKeySelector?: string;
|
||||||
|
tableValueSelector?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGroupRule {
|
||||||
|
key: ImageGroupKey;
|
||||||
|
name: string;
|
||||||
|
type: 'img' | 'video';
|
||||||
|
selectors: string[];
|
||||||
|
/** 覆盖 defaultSrcProps */
|
||||||
|
srcProps?: SrcProp[];
|
||||||
|
/** SKU 规格名来源 */
|
||||||
|
nameSelectors?: string[];
|
||||||
|
/** 画廊"当前高亮"元素(排除) */
|
||||||
|
activeSelectors?: string[];
|
||||||
|
/** 位于这些容器内的图片一律跳过(el.closest 判断) */
|
||||||
|
excludeWithin?: string[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
urlPatterns: RegExp[];
|
||||||
|
extractItemId: (url: string) => string | null;
|
||||||
|
readySelectors: string[];
|
||||||
|
readyTimeoutMs?: number;
|
||||||
|
defaultSrcProps: SrcProp[];
|
||||||
|
textRules: TextRule[];
|
||||||
|
imageGroups: ImageGroupRule[];
|
||||||
|
/** 图片 URL 还原原图规则(缺省用通用 CDN 后缀规则) */
|
||||||
|
originalUrlRules?: Array<{ match: RegExp; replace: string }>;
|
||||||
|
refererOrigin?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Product JSON - 商品文件夹契约(TS 侧)
|
||||||
|
* 对应 server/schemas/product.py(Pydantic 为真源)
|
||||||
|
* 详见 docs/contracts/product-json.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Stage = 'collected' | 'edited' | 'published';
|
||||||
|
|
||||||
|
export interface ProductJson {
|
||||||
|
_meta: {
|
||||||
|
schemaVersion: 1;
|
||||||
|
stage: Stage;
|
||||||
|
createdAt: string; // ISO 8601
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ozon 字段(对齐 ImportProductsV3)
|
||||||
|
offer_id: string; // 自己的货号,采集阶段恒空
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
description_category_id: number | null;
|
||||||
|
type_id: number | null;
|
||||||
|
price: string; // 采到的竞品价,仅参考
|
||||||
|
old_price: string;
|
||||||
|
currency_code: 'RUB' | 'CNY';
|
||||||
|
vat: string;
|
||||||
|
|
||||||
|
depth: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
dimension_unit: 'mm' | 'cm';
|
||||||
|
weight: number | null;
|
||||||
|
weight_unit: 'g' | 'kg';
|
||||||
|
|
||||||
|
images: string[]; // 发布时才填公网 URL
|
||||||
|
primary_image: string;
|
||||||
|
images360: string[];
|
||||||
|
color_image: string;
|
||||||
|
|
||||||
|
attributes: any[]; // 工作台映射后才填
|
||||||
|
complex_attributes: any[];
|
||||||
|
|
||||||
|
// 本地扩展字段(下划线前缀,提交 Ozon 前剥离)
|
||||||
|
_images: {
|
||||||
|
main: ImageMeta[];
|
||||||
|
sku: ImageMeta[];
|
||||||
|
detail: ImageMeta[];
|
||||||
|
video: ImageMeta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
_raw: {
|
||||||
|
title: string;
|
||||||
|
price: string;
|
||||||
|
params?: Array<{ key: string; value: string }>;
|
||||||
|
desc?: string;
|
||||||
|
sellingPoints?: string;
|
||||||
|
brand?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
_pricing?: any; // 工作台计价结果
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageMeta {
|
||||||
|
file: string; // 相对路径:images/main/main-001.jpg
|
||||||
|
sourceUrl: string; // 源站 URL(可能失效)
|
||||||
|
variantName?: string; // SKU 规格名
|
||||||
|
w?: number;
|
||||||
|
h?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourcesJson {
|
||||||
|
sources: Array<{
|
||||||
|
platform: 'ozon' | '1688' | 'taobao';
|
||||||
|
itemId: string | null;
|
||||||
|
url: string;
|
||||||
|
collectedAt: string; // ISO 8601
|
||||||
|
counts: Record<string, number>;
|
||||||
|
}>;
|
||||||
|
dedupeKeys: string[]; // URL 去重指纹
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 服务端设置(上传用):后端地址 + Bearer Token,持久化到 chrome.storage.local。
|
||||||
|
*/
|
||||||
|
export interface BackendSettings {
|
||||||
|
baseUrl: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'taowa_backend_settings';
|
||||||
|
|
||||||
|
const DEFAULT: BackendSettings = {
|
||||||
|
baseUrl: 'http://127.0.0.1:8800',
|
||||||
|
token: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadSettings(): Promise<BackendSettings> {
|
||||||
|
const r = await chrome.storage.local.get(KEY);
|
||||||
|
return { ...DEFAULT, ...(r[KEY] ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(s: BackendSettings): Promise<void> {
|
||||||
|
await chrome.storage.local.set({ [KEY]: s });
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.wxt/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", ".output"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
manifest: {
|
||||||
|
name: '套娃采集助手',
|
||||||
|
description: '套娃(Matryoshka)· Ozon 商品页采集,支持导出到本地或上传服务端',
|
||||||
|
permissions: [
|
||||||
|
'storage',
|
||||||
|
'sidePanel',
|
||||||
|
'activeTab',
|
||||||
|
'scripting' // 执行 content script 函数需要
|
||||||
|
],
|
||||||
|
host_permissions: [
|
||||||
|
// 商品页 + 图片/视频 CDN(实测:ir.ozone.ru / io.ozone.ru / v-1.ozone.ru / cdn1.ozonusercontent.com)
|
||||||
|
'https://*.ozon.ru/*',
|
||||||
|
'https://*.ozon.kz/*',
|
||||||
|
'https://*.ozon.by/*',
|
||||||
|
'https://*.ozone.ru/*',
|
||||||
|
'https://*.ozonusercontent.com/*',
|
||||||
|
// 本机后端(上传用);生产换成你的公网域名
|
||||||
|
'http://127.0.0.1:8800/*',
|
||||||
|
'http://localhost:8800/*'
|
||||||
|
],
|
||||||
|
action: {
|
||||||
|
default_title: '套娃采集'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modules: ['react']
|
||||||
|
});
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from api import ai, image, ozon
|
|
||||||
from config import get_settings
|
|
||||||
|
|
||||||
WEB_DIR = Path(__file__).resolve().parent / "web"
|
|
||||||
|
|
||||||
app = FastAPI(title="Ozon Seller Kit", version="0.1.0")
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
if settings.cors_origin_list:
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=settings.cors_origin_list,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(ai.router)
|
|
||||||
app.include_router(image.router)
|
|
||||||
app.include_router(ozon.router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
async def health() -> dict[str, str]:
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
if WEB_DIR.is_dir():
|
|
||||||
app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
fastapi>=0.115.0
|
|
||||||
uvicorn[standard]>=0.32.0
|
|
||||||
httpx>=0.27.0
|
|
||||||
pydantic-settings>=2.6.0
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
PyYAML>=6.0.0
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.1.8"
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.i_QiiG_wrapper{z-index:2147483641;cursor:wait;display:none;position:fixed;inset:0;overflow:hidden}.i_QiiG_wrapper.i_QiiG_visible{display:block}.A2hPZa_cursor{width:var(--cursor-size,75px);height:var(--cursor-size,75px);pointer-events:none;z-index:10000;position:absolute}.A2hPZa_cursorBorder{transform-origin:50%;background:linear-gradient(45deg,#39b6ff,#bd45fb);width:100%;height:100%;margin-top:-18px;margin-left:-10px;position:absolute;transform:rotate(-135deg)scale(1.2);mask-image:url(cursor-border.decb5f82.svg);mask-size:100% 100%;mask-repeat:no-repeat}.A2hPZa_cursorFilling{transform-origin:50%;background:url(cursor-fill.6b9e128f.svg) 0 0/100% 100% no-repeat;width:100%;height:100%;margin-top:-18px;margin-left:-10px;position:absolute;transform:rotate(-135deg)scale(1.2)}.A2hPZa_cursorRipple{pointer-events:none;width:100%;height:100%;margin-top:-50%;margin-left:-50%;position:absolute;&:after{content:"";opacity:0;border:4px solid #39b6ff;border-radius:50%;position:absolute;inset:0}}.A2hPZa_cursor.A2hPZa_clicking .A2hPZa_cursorRipple:after{animation:.3s ease-out forwards A2hPZa_cursor-ripple}@keyframes A2hPZa_cursor-ripple{0%{opacity:1;transform:scale(0)}to{opacity:0;transform:scale(2)}}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extensionName": {
|
|
||||||
"message": "ፒንጂን የ1688 ግዢ አስተዳዳሪ"
|
|
||||||
},
|
|
||||||
"extensionDescription": {
|
|
||||||
"message": "የ1688 ፕላግ-ኢን ለምርት ፍለጋ በስክሪን ሾት፣ የሽያጭ ትንተና፣ የቁሳቁስ ማውረድ እና旺旺 ማሳወቂያዎች። የግዢ ውጤታማነትን ያሻሽላል።"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extensionName": {
|
|
||||||
"message": "ملحق مساعد الشراء من 1688"
|
|
||||||
},
|
|
||||||
"extensionDescription": {
|
|
||||||
"message": "ملحق 1688 للبحث عن المنتجات بلقطة الشاشة وتحليل المبيعات والأسعار وتنزيل المواد وإشعارات旺旺. يحسن كفاءة الشراء."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extensionName": {
|
|
||||||
"message": "Приставка за помощник за поръчка от 1688"
|
|
||||||
},
|
|
||||||
"extensionDescription": {
|
|
||||||
"message": "Приставка 1688 за търсене на продукти със скрийншот, анализ на продажби и цени, изтегляне на материали и旺旺 уведомления. Подобрява ефективността на поръчването."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extensionName": {
|
|
||||||
"message": "1688 ক্রয় সহায়ক প্লাগইন"
|
|
||||||
},
|
|
||||||
"extensionDescription": {
|
|
||||||
"message": "1688 প্লাগিন - স্ক্রিনশট দিয়ে পণ্য খোঁজা, বিক্রয় বিশ্লেষণ, উপকরণ ডাউনলোড এবং旺旺 বার্তা। ক্রয় দক্ষতা বাড়ায়।"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extensionName": {
|
|
||||||
"message": "Complement de l'assistant de compres de 1688"
|
|
||||||
},
|
|
||||||
"extensionDescription": {
|
|
||||||
"message": "Complement 1688 per cercar productes amb captura de pantalla, consultar tendències de vendes i preus, descarregar materials i rebre notificacions旺旺. Millora l'eficiència de compra."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extensionName": {
|
|
||||||
"message": "Plugin Asistenta pro nákup z 1688"
|
|
||||||
},
|
|
||||||
"extensionDescription": {
|
|
||||||
"message": "Plugin 1688 pro vyhledávání produktů snímkem obrazovky, analýzu prodeje a cen, stahování materiálů a旺旺 oznámení. Zvyšuje efektivitu nákupu."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extensionName": {
|
|
||||||
"message": "1688 Indkøbsassistent-plugin"
|
|
||||||
},
|
|
||||||
"extensionDescription": {
|
|
||||||
"message": "1688 plugin til produktsøgning med skærmbillede, salgsanalyse og prisudvikling, materialenedlastning og旺旺-notifikationer. Forbedrer indkøbseffektivitet."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extensionName": {
|
|
||||||
"message": "1688 Einkaufsassistent Plugin",
|
|
||||||
"description": "Plugin-Name"
|
|
||||||
},
|
|
||||||
"extensionDescription": {
|
|
||||||
"message": "Offizielles 1688-Plugin für Screenshots, Produktsuche, Verkaufsanalysen und Benachrichtigungen. Ihr Partner beim Einkaufen.",
|
|
||||||
"description": "Plugin-Beschreibung"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user