# 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 缩略图 | | 参数表结构 | `
` 嵌套 `
` `
`,解析逻辑相同 | 可以抽一个 `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 { rootDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' }); // 持久化到 IndexedDB(WXT 有 storage.defineItem 封装) await storage.setItem('local:SH_ROOT_DIR', rootDirHandle); } export async function ensureRootDir(): Promise { 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 { 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> { 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. 分步实现,每个里程碑验收后再进下一个** 你倾向哪个?还是有其他想法?