384 lines
12 KiB
Markdown
384 lines
12 KiB
Markdown
# 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. 分步实现,每个里程碑验收后再进下一个**
|
||
|
||
你倾向哪个?还是有其他想法?
|