feat: 开发采集插件
This commit is contained in:
@@ -4,3 +4,12 @@
|
||||
*.pyc
|
||||
.DS_Store
|
||||
web/ozonSeller.html.bak
|
||||
|
||||
# 反编译参考资料(约 40 个 bundle),设计结论已写入 docs/extension/plan.md §2
|
||||
reference/
|
||||
|
||||
# Node(extension / studio / packages)
|
||||
node_modules/
|
||||
dist/
|
||||
.output/
|
||||
.wxt/
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
# Ozon Seller Kit
|
||||
|
||||
Ozon 上品辅助工具:计价、登记、图片水印、俄文文案生成。
|
||||
前后端同仓一体:FastAPI 托管 `web/` 静态页并提供 `/api/*`。
|
||||
Ozon 跨境上品工具链:**采集 → 编辑 → 发布**。
|
||||
|
||||
## 四个组成部分
|
||||
|
||||
| 目录 | 部分 | 状态 |
|
||||
|---|---|---|
|
||||
| `web/` | ① 工具台 v1:计价、登记、水印、俄文文案 | ✅ 在用(冻结) |
|
||||
| `extension/` | ② Chrome 采集插件:Ozon / 1688 商品页采集 | 🔨 待开发 |
|
||||
| `studio/` | ③ 发布工作台:导入文件夹 → 编辑 → 发布 | 📋 待设计 |
|
||||
| `server/` | ④ FastAPI:AI 文案、图片处理、Ozon API | 🔨 部分就绪 |
|
||||
|
||||
四者通过磁盘上的「[商品文件夹](docs/contracts/product-json.md)」契约衔接,不直接耦合代码。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
ozon-seller-kit/
|
||||
├── main.py
|
||||
├── config/ # settings + models.yaml
|
||||
├── api/
|
||||
├── services/
|
||||
├── schemas/
|
||||
├── web/
|
||||
├── docs/
|
||||
├── server/ # ④ FastAPI(main.py / api / services / schemas / config)
|
||||
├── web/ # ① 工具台 v1,冻结
|
||||
├── extension/ # ② 采集插件(待建)
|
||||
├── studio/ # ③ 发布工作台(待建)
|
||||
├── packages/schema/ # 跨端共享契约(待建)
|
||||
├── docs/ # 全部文档
|
||||
├── start.command
|
||||
├── requirements.txt
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
**推荐:双击 `start.command`**,或:
|
||||
**双击 `start.command`**,或:
|
||||
|
||||
```bash
|
||||
./start.command
|
||||
```
|
||||
|
||||
打开:http://127.0.0.1:8000/ozonSeller.html
|
||||
打开 http://127.0.0.1:8000/ozonSeller.html
|
||||
|
||||
- 密钥写在根目录 `.env`(参考 `.env.example`)
|
||||
- 可选模型写在 `config/models.yaml`(页面下拉会自动读取)
|
||||
- 可选模型写在 `server/config/models.yaml`
|
||||
|
||||
部署与启动详见 [`docs/deployment.md`](docs/deployment.md)。
|
||||
文案方案设计见 [`docs/ai-copy-backend-plan.md`](docs/ai-copy-backend-plan.md)。
|
||||
## 文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [`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) | 俄文文案后端方案 |
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
# Ozon Seller Kit 总体架构
|
||||
|
||||
> 状态:架构设计(待确认)
|
||||
> 最后更新:2026-08-11
|
||||
> 相关:[插件方案](./extension/plan.md) · [部署](./deployment.md) · [文案后端](./ai-copy-backend-plan.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
围绕 Ozon 跨境上品的一套自用工具链,覆盖**采集 → 编辑 → 发布**全链路,货源来自 Ozon 竞品页(跟卖)与 1688/淘宝/拼多多(补素材)。
|
||||
|
||||
四个组成部分,各自独立可用、通过明确契约衔接:
|
||||
|
||||
| # | 部分 | 形态 | 状态 | 职责 |
|
||||
|---|---|---|---|---|
|
||||
| ① | **工具台 v1** | 静态页(原生 JS + Tailwind CDN) | ✅ 在用 | 计价、上品登记、水印、俄文文案。**冻结维护,不重构** |
|
||||
| ② | **采集插件** | Chrome MV3 扩展 | 🔨 待开发 | Ozon/1688 商品页采集 → 落本地文件夹 |
|
||||
| ③ | **发布工作台** | Web 应用 | 📋 待设计 | 导入本地文件夹 → 编辑/图片处理 → 提交发布 |
|
||||
| ④ | **服务端** | FastAPI | 🔨 部分就绪 | AI 文案、图片处理、Ozon API 代理、凭证保管 |
|
||||
|
||||
**关键边界**:① 与 ③ 并存不互相替代。① 是已验证好用的轻量工具,③ 是面向"整商品发布"的新流程;③ 成熟后 ① 的计价器可能被吸收,但那是以后的事。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```
|
||||
ozon-seller-kit/
|
||||
├── server/ # ④ 服务端(FastAPI)
|
||||
│ ├── main.py
|
||||
│ ├── api/ # ai / image / ozon / collection
|
||||
│ ├── services/
|
||||
│ ├── schemas/ # Pydantic = 数据契约真源
|
||||
│ ├── config/
|
||||
│ └── requirements.txt
|
||||
│
|
||||
├── web/ # ① 工具台 v1 —— 原地冻结,不改目录名
|
||||
│ ├── ozonSeller.html
|
||||
│ ├── js/ css/ imgs/
|
||||
│ └── README.md # 标注「v1,仅修 bug」
|
||||
│
|
||||
├── extension/ # ② 采集插件(WXT + TS)
|
||||
│ ├── entrypoints/
|
||||
│ ├── src/{profiles,collector,export,storage}/
|
||||
│ └── package.json
|
||||
│
|
||||
├── studio/ # ③ 发布工作台
|
||||
│ └── (技术栈待定,见 §8)
|
||||
│
|
||||
├── packages/
|
||||
│ └── schema/ # 跨端共享契约(TS 类型 + JSON Schema)
|
||||
│
|
||||
├── reference/
|
||||
│ └── 1688-extension/ # 反编译参考资料,非本项目代码
|
||||
│
|
||||
├── docs/ # 全部文档集中于此
|
||||
│ ├── architecture.md # ← 本文
|
||||
│ ├── deployment.md
|
||||
│ ├── ai-copy-backend-plan.md
|
||||
│ ├── contracts/ # 数据契约说明
|
||||
│ ├── extension/ # 插件方案
|
||||
│ └── studio/ # 发布工作台方案
|
||||
│
|
||||
├── start.command
|
||||
└── .env / .env.example
|
||||
```
|
||||
|
||||
两处与现状不同,需要迁移(§9):后端从仓库根收进 `server/`;`seller-helper/` 拆解为 `reference/` + `docs/extension/`。
|
||||
|
||||
### 2.1 为什么后端要收进 `server/`
|
||||
|
||||
现状后端散在仓库根(`main.py`、`api/`、`services/`、`schemas/`、`config/`)。四个部分并列后,根目录会同时出现 Python 包目录和三个前端项目目录,`api/` 这种名字看不出属于谁。收进 `server/` 后每个顶层目录一一对应一个部分。
|
||||
|
||||
代价:`start.command` 与 `docs/deployment.md` 里的启动路径要改,`.env` 加载路径 `parents[1]` 要跟着调。Python 内部 import 全是 `from api import ...` 这类顶层相对形式,只要工作目录切到 `server/` 就不受影响。
|
||||
|
||||
**这是唯一有破坏性的改动,建议在动 `studio/` 之前一次做完,不要拖到中途。** 如果你想零风险,也可以让后端留在根目录——架构其余部分不依赖这个决定。
|
||||
|
||||
### 2.2 为什么 `web/` 不改名
|
||||
|
||||
改名会动 `main.py` 的挂载路径、`start.command`、部署文档,收益只是"名字更清楚"。目录名保持 `web/`,在里面放一个 README 标注定位即可。
|
||||
|
||||
路由上两代共存:`studio/` 上线后占 `/studio`,`web/` 继续在 `/ozonSeller.html`。谁占根路径是路由配置问题,跟目录名无关。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心数据契约:商品文件夹
|
||||
|
||||
**这是整个架构最重要的一个决定。** 四个部分之间不直接调用彼此的代码,只认一个约定:磁盘上的「商品文件夹」。
|
||||
|
||||
```
|
||||
儿童保温杯_316不锈钢/
|
||||
├── product.json # 商品数据(唯一真源)
|
||||
├── sources.json # 采集溯源:哪个平台、哪个 URL、什么时候
|
||||
└── images/
|
||||
├── main/ # main-001.jpg …
|
||||
├── sku/ # sku-001-blue.jpg(文件名带规格名)
|
||||
├── detail/ # detail-001.jpg …
|
||||
└── video/
|
||||
```
|
||||
|
||||
数据流因此变成:
|
||||
|
||||
```
|
||||
插件 ──写──> 商品文件夹 ──读──> 发布工作台 ──调用──> 服务端 ──> Ozon API
|
||||
↑
|
||||
1688 插件二次采集追加
|
||||
```
|
||||
|
||||
三个好处:
|
||||
|
||||
1. **插件和工作台完全解耦**。插件不需要知道工作台存在,反之亦然。任一端重写不影响另一端。
|
||||
2. **中间产物可见可改**。采集结果就是普通文件夹,能用 Finder 看、能手动补图、能备份、能在两台机器间拷。
|
||||
3. **跨平台合并天然成立**。Ozon 采完,切到 1688 采同类商品,选同一个文件夹继续写入——就是往同一目录追加文件,不需要任何服务端参与。
|
||||
|
||||
### 3.1 product.json
|
||||
|
||||
结构对齐 Ozon `ProductAPI_ImportProductsV3`,但**不等于**它的请求体。区别在于采集阶段拿不到的字段留空,由工作台补齐:
|
||||
|
||||
| 字段 | 采集阶段 | 工作台补齐 |
|
||||
|---|---|---|
|
||||
| `name` / `description` | ✅ 原文(可能是俄文) | 改写/翻译 |
|
||||
| `images` | 本地相对路径 | 上传图床后换成公网 URL |
|
||||
| `offer_id` | ❌ 空 | **必须填自己的货号**(跟卖场景下不能沿用竞品的) |
|
||||
| `description_category_id` | ❌ 空 | 类目选择/推荐 |
|
||||
| `attributes[].id` | ❌ 空 | 查类目属性字典映射 |
|
||||
| 尺寸重量 | 参数表里有就带上 | 校验补全 |
|
||||
| `price` / `old_price` | 采到竞品价,仅供参考 | 计价器算出 |
|
||||
|
||||
所以 product.json 有个 `_meta.stage` 字段标明它处在哪个阶段:`collected` → `edited` → `published`。
|
||||
|
||||
契约细节见 [`docs/contracts/product-json.md`](./contracts/product-json.md)。
|
||||
|
||||
### 3.2 契约真源与双语言实现
|
||||
|
||||
Pydantic(`server/schemas/`)是真源,因为服务端最终要用它做校验。由此派生:
|
||||
|
||||
```
|
||||
server/schemas/product.py (Pydantic)
|
||||
│
|
||||
├── 导出 JSON Schema ──> packages/schema/product.schema.json
|
||||
│ │
|
||||
│ └── 生成 TS 类型 ──> 插件 / 工作台
|
||||
└── 服务端运行时校验
|
||||
```
|
||||
|
||||
不上 codegen 流水线(单人项目不值得):插件端手写一份对应的 TS interface,配一个 fixture 文件双向跑一遍校验做契约测试。schema 变更时测试会红。
|
||||
|
||||
---
|
||||
|
||||
## 4. 各部分职责与边界
|
||||
|
||||
### ① 工具台 v1(`web/`)
|
||||
|
||||
**冻结。** 只修 bug,不加功能、不重构、不迁技术栈。它当前的价值是「已经好用」,任何改动都是风险。
|
||||
|
||||
新需求一律进 `studio/`。计价逻辑(`web/js/app.js`)后续会被 studio 复用——那时候是**读它、抄它的公式**,不是改它。
|
||||
|
||||
### ② 采集插件(`extension/`)
|
||||
|
||||
只做四件事:识别页面、提取素材、分组、写文件夹。
|
||||
|
||||
不做:LLM 调用、图片处理、Ozon API、持有任何密钥。
|
||||
|
||||
一期只支持 Ozon(跟卖是第一优先级),1688 二期。详见 [`docs/extension/plan.md`](./extension/plan.md)。
|
||||
|
||||
### ③ 发布工作台(`studio/`)
|
||||
|
||||
导入本地文件夹 → 编辑 → 发布。三块能力:
|
||||
|
||||
- **表单编辑**:product.json 各字段,类目选择,属性映射,计价(复用 v1 公式)
|
||||
- **图片处理**:水印、白底、图内翻译、图生图 —— 重活走服务端
|
||||
- **发布**:提交 Ozon 草稿,回填 `product_id`
|
||||
|
||||
### ④ 服务端(`server/`)
|
||||
|
||||
唯一持有密钥的地方。当前有 `/api/ai/*`(文案);待补 `/api/image/*`、`/api/ozon/*`。
|
||||
|
||||
**Ozon 发布有个硬约束值得提前知道**:`ProductAPI_ImportProductsV3` 的 `images` 只接受**公网可访问的 URL**,Ozon 服务器会主动来拉。本地文件夹里的图必须先上传到图床/对象存储才能发布。这决定了服务端必须有图床能力,也决定了「本地文件夹」方案无法绕过服务端直接发布。
|
||||
|
||||
---
|
||||
|
||||
## 5. 端到端数据流
|
||||
|
||||
```
|
||||
① 浏览 Ozon 竞品页
|
||||
│ 点插件按钮 → 侧边栏打开 → 人工确认页面加载完 → 点采集
|
||||
▼
|
||||
② 侧边栏表单化展示采集结果(可二次编辑),图片分组勾选
|
||||
│ 选保存目录 → 导出
|
||||
▼
|
||||
③ 本地商品文件夹(product.json + images/)
|
||||
│ (可选)切到 1688 采同类商品,选同一文件夹追加
|
||||
▼
|
||||
④ 发布工作台:选择文件夹导入
|
||||
│ 编辑字段 / 加水印 / 图生图 / 定类目 / 计价
|
||||
▼
|
||||
⑤ 服务端:图片上传图床 → 属性字典校验 → 提交 Ozon 草稿
|
||||
▼
|
||||
⑥ 回填 product_id 到 product.json,stage 置 published
|
||||
```
|
||||
|
||||
每一步的产物都落盘,中断了可以从任意一步接着来。
|
||||
|
||||
---
|
||||
|
||||
## 6. 技术栈
|
||||
|
||||
| 部分 | 技术栈 | 说明 |
|
||||
|---|---|---|
|
||||
| ① web | 原生 JS + Tailwind CDN | 不动 |
|
||||
| ② extension | WXT + React + TS strict | WXT 比 Plasmo 活跃 |
|
||||
| ③ studio | 待定(见 §8) | |
|
||||
| ④ server | FastAPI + Pydantic | 已有 |
|
||||
| 共享 | pnpm workspace | 只为 `packages/schema` 共享,不上 turborepo |
|
||||
|
||||
pnpm workspace 的唯一目的是让插件和 studio 共用契约类型。`pnpm-workspace.yaml` 三行搞定,不引入构建编排复杂度。
|
||||
|
||||
---
|
||||
|
||||
## 7. 服务端演进
|
||||
|
||||
当前是无状态的:只有 AI 文案代理,没有数据库。按需要逐步加,不要一次上齐:
|
||||
|
||||
| 阶段 | 触发条件 | 要加什么 |
|
||||
|---|---|---|
|
||||
| 现在 | — | 无状态,`/api/ai/*` |
|
||||
| S1 | studio 要处理图片 | `/api/image/*`(水印/白底/翻译),仍无状态:收图返图 |
|
||||
| S2 | 要发布到 Ozon | `/api/ozon/*` + 图床 + 类目字典缓存(SQLite 够用) |
|
||||
| S3 | 图片处理变慢(图生图、视频) | 任务队列 + `/api/job/:id` 轮询 |
|
||||
| S4 | 想要跨设备同步 | 商品库落库,本地文件夹降级为导入导出格式 |
|
||||
|
||||
**S1、S2 都不需要数据库**,类目字典用文件缓存即可。S4 是个大改动,只在真的有多设备需求时才做——本地文件夹方案的一个优点就是单机场景下完全不需要它。
|
||||
|
||||
---
|
||||
|
||||
## 8. 已定决策
|
||||
|
||||
### D1 · 后端迁入 `server/` ✅
|
||||
|
||||
代价是改 `start.command`、`settings.py` 的 `.env` 路径、`deployment.md`。Python 内部 import 不受影响(工作目录切到 `server/` 即可)。**这是唯一有破坏性的改动,在开工 studio 前一次做完。**
|
||||
|
||||
### D2 · studio 用 React + Vite + TS ✅
|
||||
|
||||
studio 的核心是"几十个字段的结构化表单 + 图片批处理",正是原生 JS 最吃力的场景。与插件同栈,图片处理组件和契约类型可两边复用。v1 的计价公式(`web/js/app.js`)抄过来即可,不改原文件。
|
||||
|
||||
### D3 · 保存用 File System Access API ✅
|
||||
|
||||
`chrome.downloads` 的 `filename` 只能是下载目录下的相对路径,不接受绝对路径或 `..`,因此无法满足"用户选择保存目录",也无法读回 `sources.json` 做去重。
|
||||
|
||||
File System Access 在 side panel(扩展页面上下文)可用,`showDirectoryPicker()` 拿到的 handle 存进 IndexedDB 后**跨会话免重复授权**,正好支撑"Ozon 采完切 1688 追加到同一文件夹"。`chrome.downloads` 保留为降级路径(用户拒绝授权时)。
|
||||
|
||||
细节见 [`docs/extension/plan-revision.md`](./extension/plan-revision.md) R1。
|
||||
|
||||
---
|
||||
|
||||
## 9. 迁移计划
|
||||
|
||||
一次性做完,中途不要停在半路:
|
||||
|
||||
```
|
||||
① 后端收拢
|
||||
main.py api/ services/ schemas/ config/ requirements.txt → server/
|
||||
改 server/config/settings.py: parents[1] → parents[1](指向 server/,.env 仍在仓库根则用 parents[2])
|
||||
改 start.command: cd server 后再 uvicorn
|
||||
改 docs/deployment.md 中所有路径
|
||||
|
||||
② 参考资料归位
|
||||
seller-helper/1688-extension/ → reference/1688-extension/
|
||||
|
||||
③ 文档集中
|
||||
seller-helper/docs/方案设计.md → docs/extension/legacy-v1.md
|
||||
seller-helper/docs/方案设计-V2.md → docs/extension/legacy-v2.md
|
||||
seller-helper/docs/插件开发方案.md → docs/extension/plan.md
|
||||
seller-helper/extension-plan/IMPLEMENTATION_PLAN.md → 合并进 docs/extension/plan.md
|
||||
|
||||
④ 上一轮错放的代码归位
|
||||
seller-helper/extension-plan/profiles-ozon.ts → extension/src/profiles/ozon.ts
|
||||
seller-helper/extension-plan/download-implementation.ts → extension/src/export/download.ts
|
||||
(建插件项目时再放,现在先留在 docs/extension/ 作为草案附件)
|
||||
|
||||
⑤ 删空目录
|
||||
seller-helper/
|
||||
|
||||
⑥ web/ 加 README 标注 v1 冻结
|
||||
```
|
||||
|
||||
`reference/1688-extension/` 要不要进 git:它是反编译产物,约 40 个 bundle。建议**加进 `.gitignore`**,保留在本地即可——设计结论已经写进 `docs/extension/plan.md` §2,原始 bundle 只在需要再次查证时才用。
|
||||
|
||||
---
|
||||
|
||||
## 10. 里程碑(已调整优先级)
|
||||
|
||||
**插件先行:1688/淘宝 → Ozon**。理由见 [`docs/extension/1688-taobao-implementation.md`](./extension/1688-taobao-implementation.md)。
|
||||
|
||||
| # | 内容 | 依赖 | 产出 | 工作量 |
|
||||
|---|---|---|---|---|
|
||||
| **M0** | 目录迁移 + 文档集中 | — | ✅ 已完成,服务正常起 | — |
|
||||
| **M1** | 插件:1688 采集引擎 | M0 | Console 里能跑 `scanCurrentPage()` | 4h |
|
||||
| **M2** | 插件:淘宝 profile | M1 | 淘宝页面同样可用 | 1h |
|
||||
| **M3** | 插件:Side Panel + File System Access | M2 | 生成完整商品文件夹到本地 | 4h |
|
||||
| **M4** | 插件:sources.json 去重 | M3 | 二次采集追加不重复 | 1h |
|
||||
| **M5** | 契约真源:product.json Pydantic | M0 | `server/schemas/product.py` | 2h |
|
||||
| **M6** | 插件:Ozon profile 实测 | M4 | Ozon 选择器验证(需真实页面链接) | 2h |
|
||||
| **M7** | studio:导入文件夹 + 表单编辑 | M5 | 能改能存 | 8h |
|
||||
| **M8** | studio + server:图片处理 | M7 | 水印/白底可用 | 6h |
|
||||
| **M9** | server:Ozon 发布 | M8 | 草稿进 Ozon 后台 | 4h |
|
||||
|
||||
**M4 结束时插件功能完整**,可以采集 1688/淘宝商品到本地文件夹,跨平台追加不重复。M9 结束时全链路跑通。
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 契约:商品文件夹与 product.json
|
||||
|
||||
> 状态:设计(待确认)
|
||||
> 上游:[总体架构 §3](../architecture.md)
|
||||
> Ozon API:[ProductAPI_ImportProductsV3](https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3)
|
||||
|
||||
插件、发布工作台、服务端三方唯一的耦合点。改这份文档等于改三端接口。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文件夹结构
|
||||
|
||||
```
|
||||
<商品名>/
|
||||
├── product.json
|
||||
├── sources.json
|
||||
└── images/
|
||||
├── main/ main-001.jpg …
|
||||
├── sku/ sku-001-синий.jpg …
|
||||
├── detail/ detail-001.jpg …
|
||||
└── video/ video-001.mp4
|
||||
```
|
||||
|
||||
命名规则:
|
||||
|
||||
| 项 | 规则 | 理由 |
|
||||
|---|---|---|
|
||||
| 文件夹名 | 商品名清洗后取前 80 字符 | 保留可读性,避开文件系统长度限制 |
|
||||
| 图片文件名 | `<组>-<3位序号>[-<规格名>].<ext>` | 序号补零保证字典序 = 展示序 |
|
||||
| 规格名 | 保留原文(含俄文/中文),清洗非法字符 | 跟卖时规格名要对应回 Ozon 变体 |
|
||||
| 非法字符 | `< > : " / \ | ? *` → `_` | Windows 兼容 |
|
||||
|
||||
序号从 1 开始,**按页面上的出现顺序**,不重排。main-001 即主图第一张,通常就是 Ozon 的封面图。
|
||||
|
||||
---
|
||||
|
||||
## 2. product.json
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_meta": {
|
||||
"schemaVersion": 1,
|
||||
"stage": "collected", // collected | edited | published
|
||||
"createdAt": "2026-08-11T06:12:00Z",
|
||||
"updatedAt": "2026-08-11T06:12:00Z"
|
||||
},
|
||||
|
||||
// ── Ozon 字段(对齐 ImportProductsV3)──
|
||||
"offer_id": "", // 自己的货号,采集阶段必空
|
||||
"name": "Термокружка детская 316",
|
||||
"description": "…",
|
||||
"description_category_id": null,
|
||||
"type_id": null,
|
||||
|
||||
"price": "1290", // 采到的竞品价,仅参考
|
||||
"old_price": "",
|
||||
"currency_code": "RUB",
|
||||
"vat": "0",
|
||||
|
||||
"depth": null, "width": null, "height": null,
|
||||
"dimension_unit": "mm",
|
||||
"weight": null,
|
||||
"weight_unit": "g",
|
||||
|
||||
"images": [], // 发布时才填公网 URL
|
||||
"primary_image": "",
|
||||
"images360": [],
|
||||
"color_image": "",
|
||||
|
||||
"attributes": [], // 需类目字典映射,见 §4
|
||||
"complex_attributes": [],
|
||||
|
||||
// ── 本地扩展字段(下划线前缀,提交 Ozon 前剥离)──
|
||||
"_images": {
|
||||
"main": [{ "file": "images/main/main-001.jpg", "sourceUrl": "https://…", "w": 1200, "h": 1200 }],
|
||||
"sku": [{ "file": "images/sku/sku-001-синий.jpg", "variantName": "синий", "sourceUrl": "https://…" }],
|
||||
"detail": [],
|
||||
"video": []
|
||||
},
|
||||
|
||||
"_raw": {
|
||||
"title": "Термокружка детская 316",
|
||||
"price": "1 290 ₽",
|
||||
"params": [{ "key": "Материал", "value": "Нержавеющая сталь" }],
|
||||
"desc": "…",
|
||||
"sellingPoints": "…"
|
||||
},
|
||||
|
||||
"_pricing": null // studio 计价结果,结构见 §5
|
||||
}
|
||||
```
|
||||
|
||||
### 2.1 为什么分 Ozon 字段 / `_` 扩展字段
|
||||
|
||||
提交 Ozon 时把所有 `_` 开头的键剥掉,剩下的**就是**请求体的 `items[0]`。这样避免了维护两套结构和一层映射代码。
|
||||
|
||||
`_raw` 保留采集原文:`name` 会被工作台改写(翻译/优化),改坏了要能回溯原始值。
|
||||
|
||||
---
|
||||
|
||||
## 3. stage 状态机
|
||||
|
||||
```
|
||||
collected ──(工作台编辑)──> edited ──(发布成功)──> published
|
||||
```
|
||||
|
||||
| stage | 谁写 | 必须满足 |
|
||||
|---|---|---|
|
||||
| `collected` | 插件 | `name` 非空,`_images` 至少一张 main |
|
||||
| `edited` | 工作台 | `offer_id`、`description_category_id`、尺寸重量、`_pricing` 均已填 |
|
||||
| `published` | 服务端 | 追加 `_ozon.product_id`、`_ozon.publishedAt` |
|
||||
|
||||
工作台导入时按 stage 决定界面:`collected` 走完整编辑流程,`edited` 直接进复核,`published` 只读 + 提示"已发布"。
|
||||
|
||||
---
|
||||
|
||||
## 4. attributes 的处理边界
|
||||
|
||||
**插件不碰 `attributes`。** Ozon 的属性需要 `{ id, complex_id, values[{ dictionary_value_id | value }] }`,其中 `id` 和 `dictionary_value_id` 都得查类目属性字典(`/v1/description-category/attribute` 与 `/v1/description-category/attribute/values`),而字典依赖类目——采集阶段还不知道类目。
|
||||
|
||||
所以:
|
||||
|
||||
```
|
||||
插件 → _raw.params 存原始 kv 文本
|
||||
工作台 → 定类目 → 拉字典 → 映射成 attributes
|
||||
服务端 → 提交前按字典校验必填项
|
||||
```
|
||||
|
||||
映射交互(自动匹配 + 人工确认未匹配项)属于 studio 设计范围,见 `docs/studio/`。
|
||||
|
||||
---
|
||||
|
||||
## 5. _pricing
|
||||
|
||||
复用 v1 计价器(`web/js/app.js`)的公式,结构对齐它现有的输出:
|
||||
|
||||
```jsonc
|
||||
"_pricing": {
|
||||
"purchasePrice": 18.5, // 进货价 ¥
|
||||
"profitRate": 30, // 净利率 %
|
||||
"logisticsLevel": "high", // low | high | high2
|
||||
"weightG": 320,
|
||||
"dims": { "l": 12, "w": 8, "h": 20 },
|
||||
"logisticsFee": 0,
|
||||
"fullCommission": 0,
|
||||
"totalCost": 0,
|
||||
"sellingPriceCny": 0,
|
||||
"sellingPriceRub": 0,
|
||||
"discountReserve": 50,
|
||||
"fxRate": 11.8,
|
||||
"calculatedAt": "2026-08-11T06:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
字段名沿用 v1 页面里的 id 命名,方便对照。
|
||||
|
||||
---
|
||||
|
||||
## 6. sources.json
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"platform": "ozon",
|
||||
"itemId": "123456789",
|
||||
"url": "https://www.ozon.ru/product/…",
|
||||
"collectedAt": "2026-08-11T06:12:00Z",
|
||||
"counts": { "main": 6, "sku": 4, "detail": 9, "video": 0 }
|
||||
},
|
||||
{
|
||||
"platform": "1688",
|
||||
"itemId": "987654321",
|
||||
"url": "https://detail.1688.com/offer/987654321.html",
|
||||
"collectedAt": "2026-08-11T07:40:00Z",
|
||||
"counts": { "main": 5, "detail": 12 }
|
||||
}
|
||||
],
|
||||
"dedupeKeys": ["https://cdn1.ozon.ru/…", "…"]
|
||||
}
|
||||
```
|
||||
|
||||
`dedupeKeys` 是已采集图片的归一化 URL 指纹。二次采集时插件读这个文件,命中的图标记「已收集」并默认不勾选——这是跨平台追加采集不重复的机制,纯本地实现,不需要服务端。
|
||||
|
||||
---
|
||||
|
||||
## 7. 双语言实现
|
||||
|
||||
| 端 | 位置 | 角色 |
|
||||
|---|---|---|
|
||||
| Python | `server/schemas/product.py` | **真源**,Pydantic 模型 + 运行时校验 |
|
||||
| TS | `packages/schema/src/product.ts` | 手写 interface,与真源对齐 |
|
||||
| fixture | `packages/schema/fixtures/*.json` | 两端都跑一遍,防漂移 |
|
||||
|
||||
`schemaVersion` 变更时两端同步改,fixture 加一份新版本样例。当前 v1。
|
||||
@@ -0,0 +1,383 @@
|
||||
# 1688/淘宝采集插件实施计划
|
||||
|
||||
> 优先级调整:Ozon 后置,先做 1688/淘宝
|
||||
> 理由:1688 选择器有生产验证基础,可先跑通引擎;淘宝同属阿里系,复用度高
|
||||
> 上游:[总体架构](../architecture.md) · [插件原方案](./plan.md) · [方案修正](./plan-revision.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么先做 1688/淘宝
|
||||
|
||||
| 维度 | 1688/淘宝 | Ozon |
|
||||
|---|---|---|
|
||||
| 选择器来源 | v1.1.8 生产 bundle 反编译,已验证 | 需实测,哈希类名随时失效 |
|
||||
| 技术难度 | 中(同源复用多) | 高(React SSR + 选择器未知) |
|
||||
| 调试价值 | 可作引擎基准——跑通后 Ozon 采不到就一定是选择器问题 | 选择器和引擎同时调,歧义大 |
|
||||
| 业务价值 | 补素材(1688 图多) | 跟卖(第一优先级但技术难) |
|
||||
|
||||
**策略**:先用 1688 跑通引擎和 File System Access 写盘,再用它诊断 Ozon 的选择器问题。
|
||||
|
||||
---
|
||||
|
||||
## 2. 淘宝 Profile 设计
|
||||
|
||||
### 2.1 与 1688 的共同点
|
||||
|
||||
| 项 | 共享原因 |
|
||||
|---|---|
|
||||
| CDN 规则 | 都是 `xxx.jpg_400x400.jpg` 后缀,`getOriginalImageUrl` 通用 |
|
||||
| 懒加载 | `data-lazyload-src` / `data-src` 优先级相同 |
|
||||
| SKU 背景图 | 都用 `backgroundImage` 取 SKU 缩略图 |
|
||||
| 参数表结构 | `<dl>` 嵌套 `<dt>` `<dd>`,解析逻辑相同 |
|
||||
|
||||
可以抽一个 `profiles/alibaba-common.ts` 存共享工具。
|
||||
|
||||
### 2.2 淘宝特有选择器
|
||||
|
||||
**URL 匹配**:
|
||||
```ts
|
||||
urlPatterns: [
|
||||
/^https:\/\/item\.taobao\.com\/item\.htm\?id=\d+/,
|
||||
/^https:\/\/detail\.tmall\.com\/item\.htm\?id=\d+/ // 天猫
|
||||
]
|
||||
|
||||
extractItemId: (url) => {
|
||||
const m = url.match(/[?&]id=(\d+)/);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
**就绪选择器**(淘宝用 React 16,水合较快):
|
||||
```ts
|
||||
readySelectors: [
|
||||
'[class*="ItemHeader"]', // 标题区
|
||||
'[class*="MainPic"]', // 主图画廊
|
||||
'[class*="SkuSelector"]' // SKU 选择器
|
||||
]
|
||||
```
|
||||
|
||||
**文本规则**:
|
||||
```ts
|
||||
textRules: [
|
||||
{
|
||||
kind: 'title',
|
||||
selectors: [
|
||||
'[class*="ItemHeader--title"]',
|
||||
'.tb-detail-hd h1',
|
||||
'h1[data-spm="1000983"]' // 旧版
|
||||
],
|
||||
extract: 'first',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
kind: 'price',
|
||||
selectors: [
|
||||
'[class*="Price--priceText"]',
|
||||
'.tb-rmb-num',
|
||||
'[class*="priceInt"]'
|
||||
],
|
||||
extract: 'first'
|
||||
},
|
||||
{
|
||||
kind: 'params',
|
||||
selectors: [
|
||||
'[class*="Attributes"] dl',
|
||||
'#attributes .tm-clear',
|
||||
'.attributes-list dl'
|
||||
],
|
||||
extract: 'table',
|
||||
tableKeySelector: 'dt',
|
||||
tableValueSelector: 'dd'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**图片规则**:
|
||||
```ts
|
||||
imageGroups: [
|
||||
{
|
||||
key: 'main',
|
||||
name: '主图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[class*="MainPic"] img', // React 版
|
||||
'#J_ImgBooth img', // 旧版画廊
|
||||
'.tb-booth img'
|
||||
],
|
||||
minWidth: 200,
|
||||
minHeight: 200
|
||||
},
|
||||
{
|
||||
key: 'sku',
|
||||
name: 'SKU图片',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[class*="SkuSelector"] li', // React 版
|
||||
'.tb-img li', // 旧版
|
||||
'[class*="skuItem"]'
|
||||
],
|
||||
srcProps: ['backgroundImage'], // 与 1688 同
|
||||
nameSelectors: ['span', '.value'], // 规格名
|
||||
minWidth: 20,
|
||||
minHeight: 20
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
name: '详情图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'#description img',
|
||||
'[class*="Description"] img',
|
||||
'.detail-content img'
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2.3 淘宝特殊处理
|
||||
|
||||
**动态详情图**:淘宝详情常用懒加载模块,需滚动触发。侧边栏提示同 Ozon:
|
||||
```
|
||||
⚠️ 详情图为 0,请滚动到页面底部后重新采集
|
||||
```
|
||||
|
||||
**天猫 vs 淘宝 C 店**:URL 模式不同但 DOM 结构相似,用同一份 profile 即可。主要差异在类名前缀(`tm-` vs `tb-`),多写几套选择器兜底。
|
||||
|
||||
---
|
||||
|
||||
## 3. 更新后的里程碑
|
||||
|
||||
| # | 内容 | 工作量 | 产出 |
|
||||
|---|---|---|---|
|
||||
| **M1** | WXT 项目初始化 | 0.5h | extension/ 目录就位,能 dev |
|
||||
| **M2** | 核心类型与工具 | 1h | profiles/types + collector/url + product.json TS 类型 |
|
||||
| **M3** | 1688 profile + 采集引擎 | 3h | 能在 1688 页面 console 里跑 `scanCurrentPage()` |
|
||||
| **M4** | 淘宝 profile | 1h | 同上,淘宝页面可用 |
|
||||
| **M5** | Side Panel UI(基础) | 2h | 分组展示采集结果,勾选图片 |
|
||||
| **M6** | File System Access 写盘 | 2h | 生成完整商品文件夹到本地 |
|
||||
| **M7** | sources.json 去重 | 1h | 二次采集追加不重复 |
|
||||
| **M8** | 淘宝实测与修正 | 1h | 在真实页面上跑,修选择器 |
|
||||
|
||||
**总计 11.5 小时**。M3 结束时引擎已可用,M6 结束时完整流程跑通。
|
||||
|
||||
---
|
||||
|
||||
## 4. 目录结构(实际代码)
|
||||
|
||||
```
|
||||
extension/
|
||||
├── wxt.config.ts
|
||||
├── package.json
|
||||
├── entrypoints/
|
||||
│ ├── background.ts # 图片代理 fetch(绕 CORS)
|
||||
│ ├── sidepanel/
|
||||
│ │ ├── index.html
|
||||
│ │ └── App.tsx # 采集控制 UI
|
||||
│ └── content/
|
||||
│ └── index.ts # 注入页面,触发采集
|
||||
│
|
||||
├── src/
|
||||
│ ├── profiles/
|
||||
│ │ ├── types.ts # SiteProfile / TextRule / ImageGroupRule
|
||||
│ │ ├── alibaba-common.ts # 阿里系共享工具(URL / CDN)
|
||||
│ │ ├── 1688.ts # ← 从 plan.md §6.2 移植
|
||||
│ │ ├── taobao.ts # ← 上面 §2.2 设计
|
||||
│ │ └── index.ts # matchProfile(url) 路由
|
||||
│ │
|
||||
│ ├── collector/
|
||||
│ │ ├── scan.ts # scanCurrentPage() 入口
|
||||
│ │ ├── text.ts # 文本提取
|
||||
│ │ ├── image.ts # 图片提取 + 分组
|
||||
│ │ ├── url.ts # ← 从 plan.md §6.3 移植
|
||||
│ │ ├── dom.ts # waitForAny / onUrlChange
|
||||
│ │ └── dedupe.ts # dedupeKey()
|
||||
│ │
|
||||
│ ├── export/
|
||||
│ │ ├── filesystem.ts # File System Access API 封装
|
||||
│ │ ├── builder.ts # 构建 product.json / sources.json
|
||||
│ │ └── images.ts # 图片写盘(调 background 代理)
|
||||
│ │
|
||||
│ ├── storage/
|
||||
│ │ ├── keys.ts # SH_ROOT_DIR / SH_CURRENT_FOLDER
|
||||
│ │ └── settings.ts # 配置读写
|
||||
│ │
|
||||
│ └── schema/
|
||||
│ └── product.ts # ProductJson / SourcesJson TS 类型
|
||||
│
|
||||
└── components/ # Side Panel UI 组件
|
||||
├── ScanResult.tsx # 采集结果展示
|
||||
├── ImagePicker.tsx # 分组图片勾选
|
||||
└── FolderSelector.tsx # 文件夹选择/新建
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键技术点
|
||||
|
||||
### 5.1 File System Access API 核心代码
|
||||
|
||||
```ts
|
||||
// src/export/filesystem.ts
|
||||
|
||||
let rootDirHandle: FileSystemDirectoryHandle | null = null;
|
||||
|
||||
export async function selectRootDir(): Promise<void> {
|
||||
rootDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||
// 持久化到 IndexedDB(WXT 有 storage.defineItem 封装)
|
||||
await storage.setItem('local:SH_ROOT_DIR', rootDirHandle);
|
||||
}
|
||||
|
||||
export async function ensureRootDir(): Promise<FileSystemDirectoryHandle> {
|
||||
if (!rootDirHandle) {
|
||||
rootDirHandle = await storage.getItem('local:SH_ROOT_DIR');
|
||||
}
|
||||
if (!rootDirHandle) {
|
||||
throw new Error('请先选择保存目录');
|
||||
}
|
||||
// 验证权限
|
||||
if (await rootDirHandle.queryPermission({ mode: 'readwrite' }) !== 'granted') {
|
||||
await rootDirHandle.requestPermission({ mode: 'readwrite' });
|
||||
}
|
||||
return rootDirHandle;
|
||||
}
|
||||
|
||||
export async function writeProductFolder(
|
||||
folderName: string,
|
||||
data: {
|
||||
product: ProductJson;
|
||||
sources: SourcesJson;
|
||||
images: Array<{ file: string; blob: Blob }>;
|
||||
}
|
||||
): Promise<void> {
|
||||
const root = await ensureRootDir();
|
||||
const productDir = await root.getDirectoryHandle(folderName, { create: true });
|
||||
|
||||
// 写 product.json
|
||||
const productFile = await productDir.getFileHandle('product.json', { create: true });
|
||||
const w1 = await productFile.createWritable();
|
||||
await w1.write(JSON.stringify(data.product, null, 2));
|
||||
await w1.close();
|
||||
|
||||
// 写 sources.json
|
||||
const sourcesFile = await productDir.getFileHandle('sources.json', { create: true });
|
||||
const w2 = await sourcesFile.createWritable();
|
||||
await w2.write(JSON.stringify(data.sources, null, 2));
|
||||
await w2.close();
|
||||
|
||||
// 写图片(分组到子目录)
|
||||
const imagesDir = await productDir.getDirectoryHandle('images', { create: true });
|
||||
for (const img of data.images) {
|
||||
const [group] = img.file.split('/'); // "main/main-001.jpg" → "main"
|
||||
const groupDir = await imagesDir.getDirectoryHandle(group, { create: true });
|
||||
const filename = img.file.split('/')[1];
|
||||
const fh = await groupDir.getFileHandle(filename, { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await w.write(img.blob);
|
||||
await w.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 跨页去重(读 sources.json)
|
||||
|
||||
```ts
|
||||
export async function readExistingSources(
|
||||
folderName: string
|
||||
): Promise<Set<string>> {
|
||||
try {
|
||||
const root = await ensureRootDir();
|
||||
const productDir = await root.getDirectoryHandle(folderName);
|
||||
const sourcesFile = await productDir.getFileHandle('sources.json');
|
||||
const file = await sourcesFile.getFile();
|
||||
const text = await file.text();
|
||||
const sources: SourcesJson = JSON.parse(text);
|
||||
return new Set(sources.dedupeKeys || []);
|
||||
} catch {
|
||||
return new Set(); // 文件夹不存在或首次采集
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Side Panel UI 交互(简化版)
|
||||
|
||||
```
|
||||
┌─ 1688/淘宝 采集助手 ────────────┐
|
||||
│ │
|
||||
│ 保存到: ~/Ozon商品库/ │
|
||||
│ [选择目录] │
|
||||
│ │
|
||||
│ 当前文件夹: 儿童保温杯_316 │
|
||||
│ [新建] │
|
||||
│ │
|
||||
├─────────────────────────────── │
|
||||
│ 本页识别到: │
|
||||
│ │
|
||||
│ 标题: 儿童316不锈钢保温杯… │
|
||||
│ │
|
||||
│ ☑ 主图 (6) [全选] │
|
||||
│ [缩略图缩略图...] │
|
||||
│ │
|
||||
│ ☑ SKU图 (4) [全选] │
|
||||
│ 蓝色 粉色 绿色 白色 │
|
||||
│ │
|
||||
│ ☐ 详情图 (9) [全选] │
|
||||
│ ⚠️ 0张,请滚到底部后重新采集 │
|
||||
│ │
|
||||
│ ☑ 视频 (1) │
|
||||
│ │
|
||||
├─────────────────────────────── │
|
||||
│ 已选 11 项 │
|
||||
│ │
|
||||
│ [开始采集] [追加到文件夹] │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
**交互要点**:
|
||||
- 首次使用提示选择根目录(只需一次)
|
||||
- 文件夹名默认取商品标题(可改)
|
||||
- 详情图为 0 时明确提示原因
|
||||
- "追加到文件夹"按钮读 sources.json,标灰重复项
|
||||
|
||||
---
|
||||
|
||||
## 7. 开工前检查清单
|
||||
|
||||
### 环境
|
||||
- [ ] Node.js 18+ / pnpm 已安装
|
||||
- [ ] Chrome 114+(File System Access 与 Side Panel 最低版本)
|
||||
|
||||
### 技术决策确认
|
||||
- [ ] Side Panel UI 用 React(已定)还是原生 JS? → **React**
|
||||
- [ ] 要不要一期就做淘宝,还是先只做 1688? → **都做,复用度高**
|
||||
- [ ] product.json 的 TS 类型现在就手写,还是等 Pydantic 先写? → **手写,用契约文档**
|
||||
|
||||
### 文件准备
|
||||
- [ ] `docs/extension/drafts/*.ts` 要不要直接搬到 `extension/src/`? → **等项目初始化后再搬**
|
||||
- [ ] `reference/1688-extension/` 的 bundle 要不要进 git? → **已在 .gitignore,不进**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
我可以:
|
||||
|
||||
**A. 立刻初始化项目**(会生成约 20 个文件)
|
||||
```bash
|
||||
cd /Users/joey-xd/sites/seller-store/ozon-seller-kit
|
||||
mkdir extension && cd extension
|
||||
pnpm create wxt@latest .
|
||||
# 选 React + TypeScript
|
||||
```
|
||||
|
||||
**B. 先写 M2 的核心类型和工具**,验证设计
|
||||
- `src/schema/product.ts`(按契约文档)
|
||||
- `src/collector/url.ts`(从 plan.md 移植)
|
||||
- `src/profiles/types.ts`(从 plan.md 移植)
|
||||
|
||||
**C. 分步实现,每个里程碑验收后再进下一个**
|
||||
|
||||
你倾向哪个?还是有其他想法?
|
||||
@@ -0,0 +1,295 @@
|
||||
// ==========================================
|
||||
// 本地导出实现方案 (基于1688插件方式)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 导出配置
|
||||
*/
|
||||
interface ExportConfig {
|
||||
downloadType: '1' | '2'; // 1=平铺, 2=分组到子文件夹
|
||||
includeJson: boolean; // 是否导出product.json
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出素材到本地
|
||||
* 在 background.ts 中实现
|
||||
*/
|
||||
async function exportToLocal(
|
||||
folderName: string,
|
||||
materials: {
|
||||
texts: TextMaterial[];
|
||||
images: ImageMaterial[];
|
||||
},
|
||||
config: ExportConfig
|
||||
) {
|
||||
const downloadTasks: Promise<void>[] = [];
|
||||
|
||||
// 1. 导出图片
|
||||
for (const img of materials.images) {
|
||||
const groupFolder = config.downloadType === '2' ? img.groupName : '';
|
||||
|
||||
// 文件名: 分组key-索引-规格名(可选).扩展名
|
||||
const ext = img.url.split('.').pop()?.split('?')[0] || 'jpg';
|
||||
let filename = `${img.groupKey}-${String(img.index).padStart(3, '0')}`;
|
||||
if (img.variantName) {
|
||||
filename += `-${img.variantName}`;
|
||||
}
|
||||
filename += `.${ext}`;
|
||||
|
||||
// 构建完整路径: 商品名/分组/文件名
|
||||
const path = [folderName, groupFolder, filename]
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
|
||||
downloadTasks.push(
|
||||
chrome.downloads.download({
|
||||
url: img.url,
|
||||
filename: path,
|
||||
conflictAction: 'uniquify',
|
||||
saveAs: false
|
||||
}).then(() => {
|
||||
console.log(`Downloaded: ${path}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 导出product.json (Ozon API格式)
|
||||
if (config.includeJson) {
|
||||
const productData = buildOzonProductJson(materials);
|
||||
const jsonBlob = new Blob(
|
||||
[JSON.stringify(productData, null, 2)],
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
const jsonUrl = URL.createObjectURL(jsonBlob);
|
||||
|
||||
downloadTasks.push(
|
||||
chrome.downloads.download({
|
||||
url: jsonUrl,
|
||||
filename: `${folderName}/product.json`,
|
||||
conflictAction: 'overwrite',
|
||||
saveAs: false
|
||||
}).then(() => {
|
||||
URL.revokeObjectURL(jsonUrl);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 等待所有下载完成
|
||||
await Promise.allSettled(downloadTasks);
|
||||
|
||||
return {
|
||||
total: downloadTasks.length,
|
||||
folder: folderName
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Ozon API格式的JSON
|
||||
* 参考: https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3
|
||||
*/
|
||||
function buildOzonProductJson(materials: {
|
||||
texts: TextMaterial[];
|
||||
images: ImageMaterial[];
|
||||
}): OzonProductImport {
|
||||
const title = materials.texts.find(t => t.kind === 'title')?.content || '';
|
||||
const desc = materials.texts.find(t => t.kind === 'desc')?.content || '';
|
||||
const params = materials.texts.find(t => t.kind === 'params');
|
||||
|
||||
// 图片URL按分组整理
|
||||
const mainImages = materials.images
|
||||
.filter(img => img.groupKey === 'main')
|
||||
.map(img => img.url);
|
||||
|
||||
const skuImages = materials.images
|
||||
.filter(img => img.groupKey === 'sku')
|
||||
.reduce((acc, img) => {
|
||||
if (img.variantName) {
|
||||
acc[img.variantName] = img.url;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return {
|
||||
items: [{
|
||||
// 基础信息
|
||||
name: title,
|
||||
description: desc,
|
||||
offer_id: '', // 需要用户填写
|
||||
|
||||
// 图片
|
||||
images: mainImages,
|
||||
color_image: skuImages[Object.keys(skuImages)[0]] || '',
|
||||
|
||||
// 参数 (简化版,实际需要映射到Ozon类目属性)
|
||||
attributes: params?.pairs?.map(p => ({
|
||||
complex_id: 0,
|
||||
id: 0, // 需要查询Ozon类目属性字典
|
||||
values: [{
|
||||
value: p.value
|
||||
}]
|
||||
})) || [],
|
||||
|
||||
// 尺寸重量 (需要从参数中提取或用户填写)
|
||||
height: 0,
|
||||
width: 0,
|
||||
depth: 0,
|
||||
dimension_unit: 'cm',
|
||||
weight: 0,
|
||||
weight_unit: 'g'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息处理: 导出命令
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.name === 'export-to-local') {
|
||||
exportToLocal(
|
||||
msg.payload.folderName,
|
||||
msg.payload.materials,
|
||||
msg.payload.config
|
||||
)
|
||||
.then(result => sendResponse({ ok: true, data: result }))
|
||||
.catch(error => sendResponse({ ok: false, error: error.message }));
|
||||
|
||||
return true; // 保持异步通道
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Manifest配置
|
||||
// ==========================================
|
||||
/*
|
||||
{
|
||||
"optional_permissions": [
|
||||
"downloads" // 放在optional中,首次导出时才申请
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"https://www.ozon.ru/*",
|
||||
"https://cdn*.ozon.ru/*" // 图片CDN
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
// ==========================================
|
||||
// Side Panel UI - 导出操作
|
||||
// ==========================================
|
||||
/*
|
||||
<div class="export-section">
|
||||
<h3>导出选项</h3>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="exportType" value="2" checked>
|
||||
分组到子文件夹 (推荐)
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="exportType" value="1">
|
||||
全部平铺
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="includeJson" checked>
|
||||
同时导出product.json (Ozon格式)
|
||||
</label>
|
||||
|
||||
<button onclick="handleExport()">
|
||||
导出到本地 (Downloads文件夹)
|
||||
</button>
|
||||
|
||||
<p class="hint">
|
||||
文件将保存到: ~/Downloads/[商品名]/
|
||||
</p>
|
||||
</div>
|
||||
*/
|
||||
|
||||
async function handleExport() {
|
||||
// 1. 首次使用时请求downloads权限
|
||||
const hasPermission = await chrome.permissions.contains({
|
||||
permissions: ['downloads']
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
const granted = await chrome.permissions.request({
|
||||
permissions: ['downloads']
|
||||
});
|
||||
if (!granted) {
|
||||
alert('需要下载权限才能导出文件');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取当前文件夹数据
|
||||
const materials = await getCurrentFolderMaterials();
|
||||
const folderName = cleanFilename(materials.title || '未命名商品');
|
||||
|
||||
// 3. 发送导出消息到background
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
name: 'export-to-local',
|
||||
payload: {
|
||||
folderName,
|
||||
materials,
|
||||
config: {
|
||||
downloadType: document.querySelector('input[name="exportType"]:checked').value,
|
||||
includeJson: document.querySelector('input[name="includeJson"]').checked
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
alert(`成功导出 ${result.data.total} 个文件到:\n~/Downloads/${result.data.folder}/`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件名中的非法字符
|
||||
*/
|
||||
function cleanFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[<>:"/\\|?*]/g, '_') // Windows非法字符
|
||||
.replace(/\s+/g, '_') // 空格替换为下划线
|
||||
.substring(0, 100); // 限制长度
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 类型定义
|
||||
// ==========================================
|
||||
|
||||
interface TextMaterial {
|
||||
kind: 'title' | 'params' | 'desc' | 'price';
|
||||
content: string;
|
||||
pairs?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
interface ImageMaterial {
|
||||
groupKey: 'main' | 'sku' | 'detail' | 'video';
|
||||
groupName: string;
|
||||
variantName?: string; // SKU规格名
|
||||
url: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface OzonProductImport {
|
||||
items: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
offer_id: string;
|
||||
images: string[];
|
||||
color_image: string;
|
||||
attributes: Array<{
|
||||
complex_id: number;
|
||||
id: number;
|
||||
values: Array<{
|
||||
dictionary_value_id?: number;
|
||||
value?: string;
|
||||
}>;
|
||||
}>;
|
||||
height: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
dimension_unit: string;
|
||||
weight: number;
|
||||
weight_unit: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Ozon商品页采集配置
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 人工控制触发,不做复杂等待
|
||||
* 2. 多套选择器并存,应对Ozon的A/B测试
|
||||
* 3. 优先采集跟卖必需的字段
|
||||
*/
|
||||
|
||||
import type { SiteProfile } from './types';
|
||||
|
||||
export const profileOzon: SiteProfile = {
|
||||
id: 'ozon',
|
||||
name: 'Ozon',
|
||||
|
||||
// URL匹配
|
||||
urlPatterns: [
|
||||
/^https:\/\/www\.ozon\.ru\/product\//,
|
||||
/^https:\/\/www\.ozon\.ru\/context\/detail\/id\//
|
||||
],
|
||||
|
||||
// 提取商品ID
|
||||
extractItemId: (url) => {
|
||||
// Ozon URL格式: https://www.ozon.ru/product/name-123456789/
|
||||
const match = url.match(/\/product\/[^\/]+-(\d+)/);
|
||||
return match?.[1] ?? null;
|
||||
},
|
||||
|
||||
// 简单的就绪检测 - 只要关键元素存在即可
|
||||
readySelectors: [
|
||||
'[data-widget="webProductHeading"]', // 标题区
|
||||
'[data-widget="webGallery"]' // 图片画廊
|
||||
],
|
||||
readyTimeoutMs: 5_000, // 快速失败,不等太久
|
||||
|
||||
// 图片来源属性优先级
|
||||
defaultSrcProps: ['data-src', 'currentSrc', 'src'],
|
||||
|
||||
refererOrigin: 'https://www.ozon.ru',
|
||||
|
||||
// ==========================================
|
||||
// 文本素材规则
|
||||
// ==========================================
|
||||
textRules: [
|
||||
// 1. 标题 (必需)
|
||||
{
|
||||
kind: 'title',
|
||||
selectors: [
|
||||
'[data-widget="webProductHeading"] h1',
|
||||
'.tsHeadline500Medium',
|
||||
'h1[itemprop="name"]'
|
||||
],
|
||||
extract: 'first',
|
||||
required: true
|
||||
},
|
||||
|
||||
// 2. 价格
|
||||
{
|
||||
kind: 'price',
|
||||
selectors: [
|
||||
'[data-widget="webPrice"] span[class*="tsBodyControl500"]',
|
||||
'[data-widget="webPrice"] span',
|
||||
'.c2h9_27 span', // 可能的备用类名
|
||||
'span[itemprop="price"]'
|
||||
],
|
||||
extract: 'first'
|
||||
},
|
||||
|
||||
// 3. 参数表 (特性)
|
||||
{
|
||||
kind: 'params',
|
||||
selectors: [
|
||||
'[data-widget="webCharacteristics"] dl',
|
||||
'[data-widget="webDetailedCharacteristics"] dl',
|
||||
'.k1p_27 dl'
|
||||
],
|
||||
extract: 'table',
|
||||
tableKeySelector: 'dt',
|
||||
tableValueSelector: 'dd'
|
||||
},
|
||||
|
||||
// 4. 简介/卖点
|
||||
{
|
||||
kind: 'selling_point',
|
||||
selectors: [
|
||||
'[data-widget="webFeatures"]',
|
||||
'[data-widget="webAO"]',
|
||||
'.h9o_27' // About this item
|
||||
],
|
||||
extract: 'join'
|
||||
},
|
||||
|
||||
// 5. 详细描述
|
||||
{
|
||||
kind: 'desc',
|
||||
selectors: [
|
||||
'[data-widget="webDescription"]',
|
||||
'[data-widget="webRichContent"]',
|
||||
'.RA-a1'
|
||||
],
|
||||
extract: 'join'
|
||||
}
|
||||
],
|
||||
|
||||
// ==========================================
|
||||
// 图片素材规则
|
||||
// ==========================================
|
||||
imageGroups: [
|
||||
// 主图画廊
|
||||
{
|
||||
key: 'main',
|
||||
name: '主图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-widget="webGallery"] img[class*="Image"]',
|
||||
'[data-widget="webGallery"] source', // picture元素
|
||||
'[data-widget="webPhotoGallery"] img',
|
||||
'.b013-a img' // 旧版选择器
|
||||
],
|
||||
minWidth: 200,
|
||||
minHeight: 200
|
||||
},
|
||||
|
||||
// SKU变体图 (颜色/尺寸)
|
||||
{
|
||||
key: 'sku',
|
||||
name: 'SKU图片',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-widget="webDetailSKU"] button img',
|
||||
'[data-widget="webVariants"] img',
|
||||
'[data-widget="webSku"] img',
|
||||
'.k3r_27 img' // SKU容器
|
||||
],
|
||||
// SKU规格名提取
|
||||
nameSelectors: [
|
||||
'span[class*="Value"]',
|
||||
'span[class*="Text"]',
|
||||
'.tsBodyControl400Small'
|
||||
],
|
||||
minWidth: 20,
|
||||
minHeight: 20
|
||||
},
|
||||
|
||||
// 详情图 (描述中的图片)
|
||||
{
|
||||
key: 'detail',
|
||||
name: '详情图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-widget="webDescription"] img',
|
||||
'[data-widget="webRichContent"] img',
|
||||
'[data-widget="webFeatures"] img',
|
||||
'.RA-a1 img'
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100
|
||||
},
|
||||
|
||||
// 视频 (如果有)
|
||||
{
|
||||
key: 'video',
|
||||
name: '视频',
|
||||
type: 'video',
|
||||
selectors: [
|
||||
'[data-widget="webGallery"] video',
|
||||
'[data-widget="webVideo"] video',
|
||||
'video[class*="Video"]'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// ==========================================
|
||||
// 图片URL处理规则
|
||||
// ==========================================
|
||||
originalUrlRules: [
|
||||
{
|
||||
// Ozon CDN缩略图处理
|
||||
// 例: /wc200/xxx.jpg → /wc1200/xxx.jpg (获取更高分辨率)
|
||||
match: /\/wc\d+\//,
|
||||
replace: '/wc1200/'
|
||||
},
|
||||
{
|
||||
// 或者移除尺寸参数
|
||||
// 例: image.jpg?width=200 → image.jpg
|
||||
match: /\?(width|height|size|quality)=[^&]+&?/g,
|
||||
replace: ''
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Ozon特殊处理函数
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Ozon页面额外的数据提取
|
||||
* (可选) 从页面的JSON-LD结构化数据中提取
|
||||
*/
|
||||
export function extractOzonStructuredData(): {
|
||||
brand?: string;
|
||||
sku?: string;
|
||||
availability?: string;
|
||||
} | null {
|
||||
try {
|
||||
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||||
for (const script of scripts) {
|
||||
const data = JSON.parse(script.textContent || '{}');
|
||||
if (data['@type'] === 'Product') {
|
||||
return {
|
||||
brand: data.brand?.name,
|
||||
sku: data.sku,
|
||||
availability: data.offers?.availability
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract structured data:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测Ozon页面是否已就绪
|
||||
* (简化版 - 只检查关键元素存在)
|
||||
*/
|
||||
export function isOzonPageReady(): {
|
||||
ready: boolean;
|
||||
missing: string[];
|
||||
} {
|
||||
const requiredElements = [
|
||||
{ selector: '[data-widget="webProductHeading"]', name: '标题' },
|
||||
{ selector: '[data-widget="webGallery"]', name: '图片画廊' }
|
||||
];
|
||||
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const elem of requiredElements) {
|
||||
if (!document.querySelector(elem.selector)) {
|
||||
missing.push(elem.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: missing.length === 0,
|
||||
missing
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 使用示例 (在content script中)
|
||||
// ==========================================
|
||||
/*
|
||||
import { profileOzon, isOzonPageReady } from './profiles/ozon';
|
||||
|
||||
// 用户点击"采集"按钮时
|
||||
async function handleCollect() {
|
||||
// 1. 快速检查
|
||||
const { ready, missing } = isOzonPageReady();
|
||||
if (!ready) {
|
||||
alert(`页面未完全加载,缺少: ${missing.join(', ')}\n请稍候再试`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 执行采集
|
||||
const result = await scanCurrentPage(); // 使用通用采集引擎
|
||||
|
||||
// 3. 显示结果
|
||||
console.log('采集完成:', result);
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,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**,不直连大模型、不接触密钥。
|
||||
- **一体扁平结构**:不拆 `frontend/` / `backend/`;Python 入口在仓库根,静态资源独占 `web/`。
|
||||
- **现有静态能力尽量保留**;服务端先做薄代理 + Prompt 编排。
|
||||
@@ -135,7 +135,7 @@ ozon-seller-kit/
|
||||
|------|--------|------|
|
||||
| 页面结构 | `web/ozonSeller.html` | 文案区 DOM、模型下拉 |
|
||||
| 文案交互 | `web/js/ai-copy.js` | 拉模型列表、带 model 调生成 |
|
||||
| 模型目录 | `config/models.yaml` | id/label/base_url/api_key_env;可入库 |
|
||||
| 模型目录 | `server/config/models.yaml` | id/label/base_url/api_key_env;可入库 |
|
||||
| 密钥 | 根目录 `.env` | 仅密钥与 HOST/PORT;不入库 |
|
||||
| API 路由 | `api/` | 按业务拆文件 |
|
||||
| LLM 调用 | `services/deepseek.py` | 按 ModelSpec 调 OpenAI 兼容接口 |
|
||||
@@ -177,7 +177,7 @@ const API_BASE = window.location.origin; // 同域,无 CORS 烦恼
|
||||
| Web 框架 | FastAPI | 轻量、类型清晰、异步友好 |
|
||||
| HTTP 客户端 | `httpx` | 调 OpenAI 兼容接口 |
|
||||
| 密钥/运行参数 | `pydantic-settings` + `.env` | 只放秘密与端口 |
|
||||
| 模型目录 | `config/models.yaml` | 可扩展多模型/多厂商 |
|
||||
| 模型目录 | `server/config/models.yaml` | 可扩展多模型/多厂商 |
|
||||
| 运行 | `uvicorn` | 标准 ASGI |
|
||||
|
||||
### 5.2 核心依赖
|
||||
@@ -195,11 +195,11 @@ PyYAML
|
||||
|
||||
**原则:**
|
||||
|
||||
- `config/models.yaml`:模型清单(id、显示名、api_model、base_url、api_key_env),**可入库,不含密钥**。
|
||||
- `server/config/models.yaml`:模型清单(id、显示名、api_model、base_url、api_key_env),**可入库,不含密钥**。
|
||||
- `.env`:只放密钥与 HOST/PORT 等运行参数,**不入库**。
|
||||
- 前端通过 `GET /api/ai/models` 获取可选项,**永不接触密钥**。
|
||||
|
||||
`config/models.yaml` 示例:
|
||||
`server/config/models.yaml` 示例:
|
||||
|
||||
```yaml
|
||||
default: deepseek-v4-flash
|
||||
@@ -382,7 +382,7 @@ services/deepseek.py
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -r server/requirements.txt
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入 DEEPSEEK_API_KEY
|
||||
```
|
||||
@@ -391,7 +391,7 @@ cp .env.example .env
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
uvicorn main:app --reload --host 127.0.0.1 --port 8000
|
||||
uvicorn main:app --app-dir server --reload --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
浏览器打开:
|
||||
@@ -422,7 +422,7 @@ web/ozonSeller.html.bak
|
||||
- [x] `web/js/ai-copy.js` 对接 API
|
||||
- [x] FastAPI 挂载 `web/`,同域静态托管可本地跑通
|
||||
- [x] 根目录 `README.md` / `start.command`
|
||||
- [x] `config/models.yaml` 模型目录 + `GET /api/ai/models` + 前端下拉切换
|
||||
- [x] `server/config/models.yaml` 模型目录 + `GET /api/ai/models` + 前端下拉切换
|
||||
|
||||
### Phase 2(图片)
|
||||
|
||||
@@ -451,7 +451,7 @@ web/ozonSeller.html.bak
|
||||
## 10. 结论
|
||||
|
||||
- **目录**:一体扁平;Python 在仓库根,静态页在 `web/`。
|
||||
- **模型配置**:清单在 `config/models.yaml`,密钥在 `.env`;前端只消费 `/api/ai/models`。
|
||||
- **模型配置**:清单在 `server/config/models.yaml`,密钥在 `.env`;前端只消费 `/api/ai/models`。
|
||||
- **服务**:FastAPI(`main.py`)挂载 `web/` 并提供 `/api/*`。
|
||||
- **前端**:俄文文案区支持模型下拉;逻辑在 `web/js/ai-copy.js`。
|
||||
- **扩展**:`api/image.py`、`api/ozon.py` 预留;加模型只需改 yaml + 对应密钥环境变量。
|
||||
@@ -25,17 +25,20 @@ cd /path/to/ozon-seller-kit
|
||||
|
||||
```
|
||||
ozon-seller-kit/
|
||||
├── main.py # FastAPI 入口
|
||||
├── start.command # macOS 一键启动
|
||||
├── requirements.txt
|
||||
├── .env.example # 环境变量模板
|
||||
├── .env # 本地密钥(勿提交)
|
||||
├── config/
|
||||
│ ├── settings.py
|
||||
│ └── models.yaml # 可选模型清单
|
||||
└── web/ # 前端静态页
|
||||
├── .env # 本地密钥(勿提交,位于仓库根)
|
||||
├── server/ # ④ 后端
|
||||
│ ├── main.py # FastAPI 入口
|
||||
│ ├── requirements.txt
|
||||
│ └── config/
|
||||
│ ├── settings.py
|
||||
│ └── models.yaml # 可选模型清单
|
||||
└── web/ # ① 工具台 v1 静态页
|
||||
```
|
||||
|
||||
> 后端在 `server/` 下,但 `.env` 在**仓库根**,由各部分共用。启动时工作目录保持仓库根,靠 `--app-dir server` 定位应用。
|
||||
|
||||
---
|
||||
|
||||
## 3. 配置环境变量
|
||||
@@ -69,12 +72,12 @@ CORS_ORIGINS=
|
||||
|
||||
说明:
|
||||
|
||||
- `HOST` / `PORT` 由 `config/settings.py` 读取;当前 `start.command` 写死为 `127.0.0.1:8000`。若要改端口,需同步改启动命令或脚本。
|
||||
- 以后若在 `config/models.yaml` 中接入其他厂商,按其中的 `api_key_env` 在 `.env` 增加对应变量(例如 `OPENAI_API_KEY`)。
|
||||
- `HOST` / `PORT` 由 `server/config/settings.py` 读取;当前 `start.command` 写死为 `127.0.0.1:8000`。若要改端口,需同步改启动命令或脚本。
|
||||
- 以后若在 `server/config/models.yaml` 中接入其他厂商,按其中的 `api_key_env` 在 `.env` 增加对应变量(例如 `OPENAI_API_KEY`)。
|
||||
|
||||
### 3.4 模型清单(可选)
|
||||
|
||||
`config/models.yaml` 控制页面模型下拉与默认模型,可直接改 `default` 或增删 `models` 条目。密钥只通过 `api_key_env` 引用环境变量名,不要把 Key 写进 yaml。
|
||||
`server/config/models.yaml` 控制页面模型下拉与默认模型,可直接改 `default` 或增删 `models` 条目。密钥只通过 `api_key_env` 引用环境变量名,不要把 Key 写进 yaml。
|
||||
|
||||
开发模式下改 yaml 会热重载(见下方启动参数 `--reload-include '*.yaml'`)。
|
||||
|
||||
@@ -93,7 +96,7 @@ chmod +x start.command # 仅首次需要
|
||||
|
||||
脚本会:
|
||||
|
||||
1. 若不存在 `.venv` → 创建虚拟环境并 `pip install -r requirements.txt`
|
||||
1. 若不存在 `.venv` → 创建虚拟环境并 `pip install -r server/requirements.txt`
|
||||
2. 若不存在 `.env` → 从 `.env.example` 复制后退出,请填 Key 后再次启动
|
||||
3. 启动 Uvicorn:`http://127.0.0.1:8000`
|
||||
|
||||
@@ -104,16 +107,16 @@ chmod +x start.command # 仅首次需要
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -r server/requirements.txt
|
||||
|
||||
# 确保已配置 .env
|
||||
uvicorn main:app --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8000
|
||||
# 确保已配置 .env(在仓库根)。始终在仓库根执行,靠 --app-dir 定位应用
|
||||
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
生产或长时间挂机可不加 `--reload`:
|
||||
|
||||
```bash
|
||||
uvicorn main:app --host 127.0.0.1 --port 8000
|
||||
uvicorn main:app --app-dir server --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
@@ -157,7 +160,7 @@ uvicorn main:app --host 127.0.0.1 --port 8000
|
||||
|
||||
```bash
|
||||
lsof -i :8000
|
||||
uvicorn main:app --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8001
|
||||
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8001
|
||||
```
|
||||
|
||||
换端口后页面地址改为对应端口。
|
||||
@@ -186,7 +189,7 @@ rm -rf .venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -U pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r server/requirements.txt
|
||||
```
|
||||
|
||||
### 只想看静态页、不用 AI
|
||||
@@ -206,7 +209,7 @@ pip install -r requirements.txt
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
uvicorn main:app --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8000
|
||||
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
- 改 Python / yaml:热重载后自动生效(`.env` 除外,需重启)。
|
||||
@@ -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']
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Πρόσθετο Βοηθού Αγορών 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Πρόσθετο 1688 για αναζήτηση προϊόντων με στιγμιότυπο, ανάλυση πωλήσεων και τιμών, λήψη υλικών και ειδοποιήσεις旺旺. Βελτιώνει την αποτελεσματικότητα αγορών."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Purchasing Assistant Plugin",
|
||||
"description": "Plugin name"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Official 1688 Plugin for screenshots, product searches, sales trends, downloads, and Wangwang notifications.",
|
||||
"description": "Plugin description"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Purchase Assistant Plugin"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 plugin for screenshot product search, sales analysis, material downloads and旺旺 notifications. Improves purchasing efficiency."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Purchase Assistant Plugin"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 plugin for screenshot product search, sales analysis, material downloads and旺旺 notifications. Improves purchasing efficiency."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Purchase Assistant Plugin"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 plugin for screenshot product search, sales analysis, material downloads and旺旺 notifications. Improves purchasing efficiency."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Versión del Plugin Asistente de Compras 1688",
|
||||
"description": "Nombre del Plugin"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin oficial de 1688 para capturas de pantalla, búsqueda de productos, análisis de ventas, descargas y notificaciones Wangwang.",
|
||||
"description": "Descripción del Plugin"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Complemento del Asistente de Compras de 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Complemento 1688 para búsqueda por captura de pantalla, análisis de ventas y precios, descarga de materiales y notificaciones旺旺. Mejora la eficiencia de compras."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 ostutööriista plugin"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 plugin toodete otsimiseks ekraanipildiga, müügi ja hinna analüüsiks, materjalide allalaadimiseks ja旺旺 teavitusteks. Parandab ostlemise tõhusust."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "افزونهٔ کمک خرید 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "افزونه 1688 برای جستجوی محصول با عکس صفحه، تحلیل فروش و قیمت، دانلود مواد و اطلاعیه旺旺. کارایی خرید را بهبود میبخشد."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Ostopalvelun liitännäinen"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 liitännäinen tuotehakuun ruudunkuvalla, myynti- ja hinta-analyysiin, materiaalien lataamiseen ja旺旺-ilmoituksiin. Parantaa ostojen tehokkuutta."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Plugin ng 1688 Purchase Assistant"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin ng 1688 para sa screenshot search, sales trends, material downloads, at旺旺 notifications. Tumutulong mapabuti ang efficiency sa pagbili."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Version du Plugin Assistant d'Achat 1688",
|
||||
"description": "Nom du Plugin"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin officiel 1688 pour captures d'écran, recherche de produits, tendances de vente, téléchargements et notifications Wangwang.",
|
||||
"description": "Description du Plugin"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 ખરીદી મદદનું પ્લગઇન"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 પ્લગઇન - સ્ક્રીનશૉટ દ્વારા ઉત્પાદન શોધ, વેચાણ વિશ્લેષણ, સામગ્રી ડાઉનલોડ અને旺旺 સૂચનાઓ. ખરીદી કાર્યક્ષમતા સુધારે છે."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "תוסף עוזר קנייה של 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "תוסף 1688 לחיפוש מוצרים בצילום מסך, ניתוח מכירות ומחירים, הורדת חומרים והתראות旺旺. משפר יעילות רכישה."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 खरीदारी सहायक प्लगइन",
|
||||
"description": "प्लगइन का नाम"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 आधिकारिक प्लगइन जो स्क्रीनशॉट, समान उत्पाद खोज, बिक्री प्रवृत्तियों और वांगवांग सूचनाओं का समर्थन करता है।",
|
||||
"description": "प्लगइन विवरण"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Dodatak za pomoć pri kupnji s 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Dodatak 1688 za pretraživanje proizvoda snimkom zaslona, analizu prodaje i cijena, preuzimanje materijala i旺旺 obavijesti. Poboljšava učinkovitost kupnje."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Vásárlási Asszisztens bővítmény"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 bővítmény termékkereséshez képernyőképpel, értékesítési és ármozgás elemzéshez, anyagletöltéshez és旺旺 értesítésekhez. Javítja a vásárlási hatékonyságot."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Plugin Asisten Pembelian 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin 1688 untuk pencarian produk dengan tangkapan layar, analisis penjualan dan harga, unduhan materi dan notifikasi旺旺. Meningkatkan efisiensi pembelian."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Plugin Assistente Acquisti 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin 1688 per ricerca prodotti con screenshot, analisi vendite e prezzi, download materiali e notifiche旺旺. Migliora l'efficienza degli acquisti."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688購入アシスタントプラグイン",
|
||||
"description": "プラグイン名"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688公式プラグインで、ワンクリックスクリーンショット、類似製品検索、販売トレンド、素材ダウンロード、ワンワン通知をサポートします。",
|
||||
"description": "プラグイン説明"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 ಖರೀದಿ ಸಹಾಯಕ ಪ್ಲಗ್ಇನ್"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 ಪ್ಲಗ್ಇನ್ - ಸ್ಕ್ರೀನ್ಶಾಟ್ ಬಳಸಿ ಉತ್ಪನ್ನ ಹುಡುಕಿ, ಮಾರಾಟ ವಿಶ್ಲೇಷಣೆ, ಸಾಮಗ್ರಿ ಡೌನ್ಲೋಡ್, 旺旺 ಅಧಿಸೂಚನೆಗಳು. ಖರೀದಿ ದಕ್ಷತೆ ಸುಧಾರಿಸುತ್ತದೆ."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 구매 도우미 플러그인",
|
||||
"description": "플러그인 이름"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 공식 플러그인으로, 원클릭 스크린샷, 유사 상품 검색, 판매 추세, 다운로드 및 왕왕 알림 기능을 지원합니다.",
|
||||
"description": "플러그인 설명"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Pirkimo pagalbos įskiepis"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 įskiepis prekių paieškai su ekrano nuotrauka, pardavimų analizei, medžiagų atsisiuntimui ir旺旺 pranešimams. Pagerina pirkimo efektyvumą."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Iepirkšanās palīga spraudnis"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 spraudnis produktu meklēšanai ar ekrānuzņēmumu, pārdošanas un cenu analīzei, materiālu lejupielādei un旺旺 paziņojumiem. Uzlabo iepirkšanās efektivitāti."
|
||||
}
|
||||
}
|
||||
@@ -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": "Plugin Pembantu Pembelian 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin 1688 untuk carian produk dengan tangkapan skrin, analisis jualan dan harga, muat turun bahan dan pemberitahuan旺旺. Meningkatkan kecekapan pembelian."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Inkoopassistent-plugin"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 plugin voor productzoeken met schermfoto, verkoop- en prijsanalyse, materiaaldownloads en旺旺-notificaties. Verbetert inkoopefficiëntie."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Kjøpshjelper-tillegg"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 plugin for produktsøk med skjermbilde, salgs- og prisanalyse, materialnedlasting og旺旺-varsler. Forbedrer kjøpseffektivitet."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Wtyczka Asystenta Zakupów 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin 1688 do wyszukiwania produktów zrzutami ekranu, analiz sprzedaży i cen, pobierania materiałów i powiadomień旺旺. Poprawia efektywność zakupów."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Plugin do Assistente de Compras 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin 1688 para busca por captura de tela, análise de vendas e preços, downloads de materiais e notificações旺旺. Melhora a eficiência em compras."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Plugin do Assistente de Compras 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin 1688 para busca por captura de tela, análise de vendas e preços, downloads de materiais e notificações旺旺. Melhora a eficiência em compras."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Plugin Asistent de Cumpărături 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin 1688 pentru căutare produse prin captură de ecran, analize vânzări și prețuri, descărcări materiale și notificări旺旺. Îmbunătățește eficiența cumpărăturilor."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Расширение помощника для закупок 1688",
|
||||
"description": "Название расширения"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Расширение 1688, поддерживает поиск по фотографии, проверку продаж, загрузку материалов и уведомления. Надеемся стать вашим партнером.",
|
||||
"description": "Описание расширения"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Plugin Pomocníka pre nákup na 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin 1688 pre vyhľadávanie produktov so snímkou obrazovky, analýzu predaja a cien, sťahovanie materiálov a旺旺 oznámenia. Zlepšuje efektívnosť nákupu."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Vstavek za pomoč pri nakupovanju na 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin 1688 za iskanje izdelkov s posnetkom zaslona, analizo prodaje in cen, prenos materialov in旺旺 obvestila. Izboljša učinkovitost nakupovanja."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Прикључак помоћника за куповину са 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Прикључак 1688 за претрагу производа помоћу снимка екрана, анализу продаје и цена, преузимање материјала и旺旺 обавештења. Побољшава ефикасност куповине."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Inköpsassistent-tillägg"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 plugin för produktsökning med skärmdump, försäljningsanalys och pristrender, materialnedladdning och旺旺-meddelanden. Förbättrar inköpseffektivitet."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Kiungo cha Msaidizi wa Kununua kwa 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin ya 1688 kwa kutafuta bidhaa kwa picha ya skrini, uchambuzi wa mauzo na bei, upakuaji wa vifaa na arifa za旺旺. Inaboresha ufanisi wa ununuzi."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 வாங்குவதற்கான உதவியாளர் செருகுநிரல்"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 செருகுநிரல் - திரை பிடிப்பு மூலம் தேடல், விற்பனை போக்குகள், தரவு பதிவிறக்கம், 旺旺 அறிவிப்புகள். வாங்கும் திறனை மேம்படுத்துகிறது."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 కొనుగోలు సహాయకుడు ప్లగిన్"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 ప్లగిన్ - స్క్రీన్షాట్తో ఉత్పత్తి శోధన, అమ్మకాల విశ్లేషణ, మెటీరియల్ డౌన్లోడ్ మరియు旺旺 నోటిఫికేషన్లు. కొనుగోలు సామర్థ్యాన్ని మెరుగుపరుస్తుంది."
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "เวอร์ชันปลั๊กอินผู้ช่วยซื้อ 1688",
|
||||
"description": "ชื่อปลั๊กอิน"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "ปลั๊กอิน 1688 รองรับการจับภาพหน้าจอ, ค้นหาผลิตภัณฑ์, แนวโน้มการขาย, ดาวน์โหลดวัสดุ และแจ้งเตือน Wangwang.",
|
||||
"description": "คำอธิบายปลั๊กอิน"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688 Satın Alma Asistanı Eklentisi"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688 eklentisi - ekran görüntüsüyle ürün arama, satış ve fiyat analizi, materyal indirme ve旺旺 bildirimleri. Satın alma verimliliğinizi artırır."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Плагін помічника з закупівлі 1688"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Плагін 1688 для пошуку товарів знімком екрана, аналізу продажів і цін, завантаження матеріалів і旺旺 сповіщень. Покращує ефективність закупівель."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Phiên bản Plugin Trợ lý Mua hàng 1688",
|
||||
"description": "Tên Plugin"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Plugin chính thức của 1688, hỗ trợ chụp màn hình, tìm sản phẩm tương tự, xu hướng bán hàng, tải tài liệu và thông báo Wangwang.",
|
||||
"description": "Mô tả Plugin"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688官方采购助手",
|
||||
"description": "插件名称"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688采购助手插件是阿里巴巴旗下官方电商采购工具,支持一键截图找同款,上下游图搜比价;支持商品素材下载,提供商品销量和价格走势、旺旺消息/订单消息/供应商新品等通知信息、各类商机榜单和趋势热点,还有AI辅助找挑、图片素材优化,帮助您的采购流程更加便捷高效透明。",
|
||||
"description": "插件说明"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "1688採購助手插件版",
|
||||
"description": "插件名稱"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "1688採購助手插件,支持一鍵截圖找同款,銷量和價格走勢查詢,商品素材下載和旺旺通知等功能,希望成為您的貼心生意夥伴,幫助您提升採購效率。",
|
||||
"description": "插件說明"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user