12 KiB
12 KiB
1688/淘宝采集插件实施计划
优先级调整:Ozon 后置,先做 1688/淘宝 理由:1688 选择器有生产验证基础,可先跑通引擎;淘宝同属阿里系,复用度高 上游:总体架构 · 插件原方案 · 方案修正
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 匹配:
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,水合较快):
readySelectors: [
'[class*="ItemHeader"]', // 标题区
'[class*="MainPic"]', // 主图画廊
'[class*="SkuSelector"]' // SKU 选择器
]
文本规则:
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'
}
]
图片规则:
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 核心代码
// 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)
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 个文件)
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. 分步实现,每个里程碑验收后再进下一个
你倾向哪个?还是有其他想法?