# Seller Helper 插件开发方案 > 状态:设计阶段,未开始编码 > 最后更新:2026-08-06 > 上游文档:[`方案设计-V2.md`](./方案设计-V2.md) > 参考实现:`1688-extension/`(1688 采购助手 v1.1.8,Plasmo 构建产物,已反编译分析) --- ## 1. 文档定位 本文档只讲**插件端**。插件的职责边界在 V2 方案里已经定死: > 插件是**纯采集器**——识别页面、提取素材、归入文件夹、去重、状态回显。 > 不做文案生成、不做图片处理、不碰 Ozon API、不持有任何密钥。 发布页、LLM 调用、图片处理、Ozon 提交全部在后端和 Web 端,本文档不涉及。 第 2 节是对 1688 采购助手的逆向分析结论,第 3 节起是我们自己的设计。 --- ## 2. 从 1688 采购助手学到什么 我反编译了 `1688-extension/` 的 23 个 bundle(中文字符串是 unicode 转义的,解码后才可读),下面每条都附了原始代码证据。 ### 2.1 图片分类是「声明式选择器分组」,不是 AI 识别 这是最值得抄的一点。它的图片分类完全由一份配置驱动: ```js { defaultSrcProps: ["data-lazyload-src", "currentSrc", "src"], groups: [ { key: "main", name: "主图", type: "img", selectors: ["#dt-tab img, .detail-gallery-turn img.detail-gallery-img, .img-list-wrapper img.od-gallery-img, .od-scroller-item span, .v-image-cover"] }, { key: "video", name: "视频", type: "video", selectors: [".lib-video video"] }, { 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"], srcProps: ["backgroundImage"] }, { key: "detail", name: "详情", type: "img", selectors: [".de-description-detail img, #detailContentContainer img, .html-description"] } ] } ``` 四条结论: 1. **分类 = 图片在页面哪个区域**。主图来自画廊区,SKU 图来自规格选择区,详情图来自详情富文本区。位置本身就是最可靠的分类信号,不需要模型。 2. **`defaultSrcProps` 顺序有讲究**:`data-lazyload-src` 排第一。懒加载图片的 `src` 常是占位图或低清图,真实地址在 `data-*` 属性上。 3. **SKU 组单独用 `srcProps: ["backgroundImage"]`**,因为 1688 的 SKU 缩略图是 CSS 背景图而非 ``,取 `src` 拿不到。 4. **每组只有一个字符串,里面塞了多个逗号分隔的选择器**——等价于多套页面版本的并集。1688 详情页有多个 A/B 版本,全写上去哪个命中算哪个。 ### 2.2 SKU 图片会带上规格名 扫描时 SKU 组的 `groupName` 不是固定的"SKU图片",而是这张图对应的规格名: ```js groupName: "sku" === group.key ? cleanString(getImageUrl(el, srcProps).imgName, { replacement: "×", maxLength: 100 }) : group.name ``` `getImageUrl` 返回 `{url, imgName}`,`imgName` 从 SKU 项的文字标签里取。它适配了**五种** SKU DOM 结构: | 图片来源 | 名称来源 | |---|---| | `.prop-img` 的 backgroundImage | `.prop-name` | | `.sku-item-image` 的 backgroundImage | `.sku-item-name` | | `.ant-image-img` / `.item-image-icon` / `.label-image-icon` 的 src | `.item-label` / `.label-name` | | `.single-sku-img-pop` 的 backgroundImage | `.normal-text` | 对我们的价值很大:**采集时就知道"这张图是红色款"**,后面在 Ozon 建多变体商品时能直接对应上。 ### 2.3 URL 工具链 四个小函数解决了一堆脏活: ```js // 缩略图 URL → 原图 URL:阿里 CDN 的尺寸后缀都在 .jpg_ 之后 function getOriginalImageUrl(url) { const m = url.match(/^(.+?\.(jpg|jpeg|png|gif|bmp|heic|webp))_/i); return m ? m[1] : url; } // url("https://...") → https://... function getUrlInBrackets(s) { const m = s?.trim() && s.match(/\((.*?)\)/); return m ? m[1] : ""; } // 归一化:协议相对 // 、根相对 / 、相对路径 function getValidUrl(u) { if (isDataUrl(u)) 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; } function isDataUrl(u) { return /(?:data:(\S+);(\S+),)(.*)/.test(u); } ``` `getOriginalImageUrl` 尤其重要——页面上挂的都是 `xxx.jpg_400x400.jpg` 这种压缩图,去掉后缀才是高清原图。这个正则对阿里系 CDN 通用。 ### 2.4 取图有完整的降级链 `getImageUrl(el, srcProps)` 按 `srcProps` 顺序尝试,每个都失败后还有兜底: ```js const v = el[prop] || el.getAttribute(prop); if (v) { url = prop === "src" ? (el.currentSrc || el.src || "") : v; } else { // 兜底:读计算样式的 backgroundImage,并用扩展名校验是不是图片 const bg = getUrlInBrackets(getComputedStyle(el)?.backgroundImage || "").replace(/['"]/g, ""); if (/^https?:\/\/.*\.(jpg|jpeg|png|gif|bmp|heic|webp)$/i.test(bg)) url = bg; } ``` 注意 `prop === "src"` 时优先用 `el.currentSrc`——`srcset` 响应式图片下 `currentSrc` 才是浏览器实际加载的那张。 ### 2.5 穿透 Shadow DOM ```js group.selectors.forEach(sel => { document.querySelectorAll(sel).forEach(el => { el.shadowRoot ? found.push(...el.shadowRoot.querySelectorAll("img")) : found.push(el); }); }); ``` 1688 部分组件用了 Web Components,不穿透就漏图。 ### 2.6 画廊有四套选择器变体,还区分"当前展示图" ```js const galleryVariants = [ { selectors: ["#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img"], activeSelectors: ["#recyclerview .detail-gallery-turn-wrapper.prepic-active .detail-gallery-img"] }, { selectors: ["#screen .od-gallery-turn-item-wrapper .od-gallery-img"], activeSelectors: ["#screen .od-gallery-turn-item-wrapper.prepic-active .od-gallery-img"] }, { selectors: ["#content .od-scroller-item .v-image-cover"], activeSelectors: ["#content .od-scroller-item .v-image-cover.image-item-active"] }, { selectors: ["#content .od-picture-gallery-list .v-image-cover"], activeSelectors: ["#content .od-picture-gallery-list .v-image-cover.image-item-active"] } ]; ``` 四套并存,说明 1688 详情页至少有四个线上版本同时跑。`activeSelectors` 用来识别当前高亮的那张图,配合 `getOdMainImages({ excludeActive, maxCount })` 可以排除它。 **教训:不要假设只有一套 DOM。选择器必须是数组,逐个试,全部合并。** ### 2.7 标题提取是多级降级 ```js let title = ""; const c = document.querySelector(".title-content"); if (c) { if (c.querySelector(".title-text")) { // 标题被拆成多个 span,要拼接 c.querySelectorAll(".title-text").forEach(e => { title += e.textContent; }); } if (c.querySelector("h1")) title = c.querySelector("h1").textContent || ""; } else { sendLogFromPage({ type: "error", target: "download-image-modal", extra: { step: "init-get-title", message: "OD页面未找到标题元素(.title-content)", offerId: getDetailOfferId() } }); } ``` `.title-text` 会被拆成多个 span(为了给关键词加高亮),必须遍历拼接,只取第一个会丢字。 ### 2.8 抓不到东西时上报埋点——这是抗改版的关键 ```js if (0 === total) { sendLogFromPage({ type: "error", target: "download-image-modal", extra: { step: "init-scan-elements", message: "OD页面未扫描到任何图片/视频元素", selectors: config.groups.map(g => g.selectors).flat(), // 把用过的选择器一起上报 offerId: getDetailOfferId() } }); } ``` **扫到 0 个就上报,并且把当时用的选择器和商品 ID 带上。** 这样对方一改版,后台立刻能看到失败率飙升和具体是哪些选择器失效了。埋点结构统一为 `{ type: error|click|view, target, extra: { step, message, ...ctx } }`。 MutationObserver 等不到目标节点也会上报 `mutation-observer-timeout`。 ### 2.9 选择器可以远程下发热修 ```js case "get-website-config": return { code: 0, data: await getFindSameGoodsWebsiteConfig(payload.configId) }; case "get-dynamic-config": return { code: 0, data: await getDynamicConfig(payload.configId) }; ``` 配合本地缓存键 `_1688_EXTENSION_CONTENT_SELECTOR_CONFIG`。 配上 2.8 的埋点就形成闭环:**页面改版 → 埋点报警 → 后台改配置 → 插件下次拉到新选择器**,全程不用重新发版、不用等 Chrome 商店审核。这对我们同样关键,1688 改版频率不低。 ### 2.10 用 declarativeNetRequest 改 Referer 绕防盗链 ```js chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [110000, 120000], addRules: [{ id: 110000, priority: 1, action: { type: "modifyHeaders", requestHeaders: [ { header: "Origin", operation: "set", value: "https://www.1688.com" }, { header: "Referer", operation: "set", value: "https://www.1688.com/" } ]}, condition: { urlFilter: `*?*${USE_DYNAMIC_RULES}=true`, resourceTypes: ["image", "media"] } }] }); ``` 手法很巧:**给图片 URL 加一个标记 query 参数,DNR 规则匹配这个参数就改写请求头**。因为 `fetch()` 里 `Referer`/`Origin` 是浏览器保护的 forbidden header,JS 改不了,只能靠 DNR。规则 ID 用 `100000/110000/120000` 这种预留段避免冲突。 > **对我们的意义要说清楚**:这个技巧只在**浏览器内**取图时才需要。我们的后端用 Node/Python 发请求,直接在 header 里写 `Referer: https://www.1688.com/` 就行,没有 forbidden header 限制。所以 DNR 只用于插件侧要渲染缩略图或走字节兜底上传的场景。 ### 2.11 background 是唯一出网口 单个 `chrome.runtime.onMessage` 监听器 + 一个巨型 switch,约 80 个 handler,统一响应壳 `{ code, data, errMsg }`,未知请求返回 `{ code: 404, errMsg: "未知请求" }`。 代理抓图的实现: ```js async function fetchImage({ imgSrc }) { if (/^data:image/.test(imgSrc)) return { code: 0, data: imgSrc }; try { const blob = await fetch(imgSrc).then(r => r.ok && r.status === 200 ? r.blob() : Promise.reject(Error("fetch image error"))); const reader = new FileReader(); return await new Promise(res => { reader.onload = () => res({ code: 0, data: reader.result }); reader.onerror = () => res({ code: -1 }); reader.readAsDataURL(blob); }); } catch { return { code: -1 }; } } ``` 这正是 V2 里说的"Service Worker 是唯一出网口"——background 有 `host_permissions` 就不受页面 CORS 约束,content script 则受限。 ### 2.12 下载按「商品标题 / 分组」建目录 ```js const { url, filename, title, group, key } = payload; const path = key === "1" ? [title, filename].filter(Boolean).join("/") // 不分组 : [title, group, filename].filter(Boolean).join("/"); // 按分组建子目录 chrome.downloads.download({ url, filename: path, conflictAction: "uniquify", saveAs: false }); ``` `key` 来自设置项 `_1688_EXTENSION_DOWNLOAD_IMG_TYPE`。`downloads` 权限放在 `optional_permissions` 里按需申请,降低安装时的权限恐慌。 ### 2.13 页面级事件总线解决脚本加载顺序 ```js window.__1688_EXTENSION?.events?.emit("open-ai-image-enhancer", payload); window.__1688_EXTENSION?.events?.emit("open-ai-chatbot", payload, { cacheOnNoListener: { enable: true, overwrite: true } }); ``` 17 个 content script 注入同一页面,`run_at` 各不相同(`document_start` / `document_end` / `document_idle`),谁先谁后不确定。`cacheOnNoListener` 让事件在还没有监听者时先缓存住,等对应脚本加载完再消费。 ### 2.14 重 UI 用 iframe 加载服务端页面 批量下载弹窗(1200px 宽)本体是个 iframe,里面是服务端页面,扩展通过 postMessage 把登录态注进去: ```js const win = iframeRef.current?.contentWindow; const uuid = await getUUID(); const { LOGIN_ID, USER_ID, IS_LOGIN, CRYPTO, DOWNLOAD_IMG_TYPE, DOWNLOAD_IMG_WAY } = await getExtensionLocalStorage([...]); postMessage2Iframe(win, { type: "...", ... }, origin); ``` 消息带 `id` 和 `sign` 字段做请求响应关联与签名校验,还有 `appInited` 握手和 `getExtensionInfo` 能力注入。 **这直接印证了 V2 的判断**:复杂 UI 放服务端,扩展只做壳。它连一个下载弹窗都这么干,我们的发布页更没有理由打包进插件。 ### 2.15 采集状态回显在页面上 ```js const r = await sendMessageToBackground({ name: "selection-pool-exist", payload: { offerId } }); if (r?.data?.result) { this.isJoin = true; this.joinElement.innerText = "已加入选品池"; // 直接改页面按钮文字 + 加绿色对勾 } ``` 加入时按分组: ```js await sendMessageToBackground({ name: "selection-pool-add", payload: { offerId: Number(offerId), platform: "1688", groupInfo: groupName, groupId } }); ``` 配 `selection-group-query` 拉分组列表,hover 出二级菜单选分组,默认 `{ groupInfo: "default", groupId: 0 }`。 **「选品池 + 分组」就是我们要的「文件夹」**,而且它验证了两个 UX 细节:进入页面就查一次是否已收集并在页面上回显;加入时能直接选分组,不用先切到别处。 差别在于它的 `selection-pool-add` **只传 offerId**,商品数据由后端自己去查——它有 1688 内部数据源。我们没有,必须把采集到的内容整体传过去。 ### 2.16 其他细节 - **所有注入 UI 都套 Shadow DOM**:`attachShadow({ mode: "open" })` 出现 13 次,隔离宿主页 CSS。 - **SPA 路由监听三管齐下**:劫持 `pushState` + 监听 `popstate`/`hashchange`(各 12 处)+ `webNavigation.onCompleted`。 - **storage key 统一前缀** `_1688_EXTENSION_*`,约 80 个键,避免和页面 localStorage 撞。 - **图片操作是悬浮工具条**,hover 到任意图片上弹出:AI作图 / AI白底图 / AI换背景 / AI去水印 / AI改尺寸(i18n key `AiPaint` / `AiWhiteBackground` / `AiChangeBackground` / `AiRemoveWaterMarker` / `AiResize`)。这就是你觉得好用的那个图片编辑入口。 - **发请求前先查登录**:`check-login` 不通过就引导登录,不让请求白跑。 - **元素等待用 MutationObserver + 超时**,不用轮询,超时上报埋点。 ### 2.17 借鉴清单汇总 | 借鉴点 | 采纳 | 说明 | |---|---|---| | 声明式选择器分组做图片分类 | ✅ 完全采纳 | 核心机制,见 §6 | | 分组维度 main/sku/detail/video | ✅ 扩展 | 我们再加 `whitebg` 猜测组与 `param`(参数图)| | SKU 图带规格名 | ✅ 完全采纳 | 对接 Ozon 多变体商品的关键 | | `getOriginalImageUrl` 去 CDN 后缀 | ✅ 完全采纳 | 直接抄正则 | | `defaultSrcProps` 懒加载优先 | ✅ 完全采纳 | 顺序不能改 | | backgroundImage 兜底取图 | ✅ 完全采纳 | SKU 图必需 | | Shadow DOM 穿透 | ✅ 完全采纳 | | | 多套选择器变体并存 | ✅ 完全采纳 | 见 §6.2 | | 标题多级降级 + 多 span 拼接 | ✅ 完全采纳 | | | 抓不到时上报选择器 | ✅ 完全采纳 | 抗改版闭环,见 §12 | | 远程下发选择器配置 | ✅ 一期就做 | 见 §11 | | DNR 改 Referer | 🟡 部分 | 仅浏览器内取图用,后端不需要,见 §8 | | background 唯一出网口 + 统一响应壳 | ✅ 完全采纳 | 见 §9 | | 下载按标题/分组建目录 | 🟡 二期 | 我们主路径是传后端,本地下载是附加功能 | | 页面事件总线 + cacheOnNoListener | 🟡 简化 | 我们 content script 少,先不做 | | 重 UI 走 iframe/服务端页面 | ✅ 完全采纳 | 发布页新标签页打开,见 §10.4 | | 采集状态页面回显 | ✅ 完全采纳 | 见 §10.3 | | 分组(选品池)概念 | ✅ 完全采纳 | 即「文件夹」,见 §10 | | 注入 UI 套 Shadow DOM | ✅ 完全采纳 | | | SPA 路由三管齐下 | ✅ 完全采纳 | 见 §6.5 | | storage key 统一前缀 | ✅ 完全采纳 | `SH_` 前缀 | | 图片 hover 工具条 | 🟡 二期 | 一期图片操作在发布页做,插件只采集 | | 请求前查登录 | ✅ 完全采纳 | | **不借鉴的**:它那套 80 个 handler 的巨型 switch、AB 实验框架、通知/疲劳度控制、多浏览器兼容(QQ/搜狗/夸克)、代码混淆——都是大团队多年迭代的产物,我们单人自用不需要。 --- ## 3. 我们的插件职责边界 ``` 插件做的: ✅ 判断当前页是不是支持的商品页 ✅ 提取文本素材(标题、参数表、卖点、详情文案、价格) ✅ 提取图片素材并分组(主图 / SKU图 / 详情图 / 视频) ✅ 缩略图 URL 还原成原图 URL ✅ URL 级去重 ✅ 让用户选「收集到哪个文件夹」,可现场新建 ✅ 回显「本页已收集」状态 ✅ 兜底:图片取不到时在页面上下文抓字节上传 ✅ 打开发布页(新标签页指向后端) ✅ 埋点上报(尤其是提取失败) 插件不做的: ❌ 调 LLM ❌ 图片处理(白底/翻译/生图) ❌ 访问 Ozon API ❌ 存任何密钥 ❌ 渲染发布页 ``` --- ## 4. 技术栈与目录结构 ### 4.1 技术栈 | 项 | 选择 | 理由 | |---|---|---| | 框架 | **WXT** | 比 Plasmo 更活跃,TS 优先,HMR 好,`defineContentScript` 心智负担小。参考实现用的是 Plasmo,但它 2024 后维护转冷 | | UI | React 19 + TailwindCSS | 和后端 Web 端同栈,组件可复用 | | 语言 | TypeScript strict | 采集逻辑靠类型兜住 | | 状态 | Zustand | Side Panel 状态简单,不上 Redux | | 存储封装 | WXT `storage` API | 自带 key 前缀与类型 | | 校验 | Zod | 采集结果 schema 校验,也复用为后端接口契约 | | Manifest | V3 | | | 最低 Chrome | 114 | Side Panel API 起点 | ### 4.2 目录结构 ``` extension/ ├─ wxt.config.ts ├─ entrypoints/ │ ├─ background.ts # Service Worker:消息路由 + 唯一出网口 │ ├─ sidepanel/ │ │ ├─ index.html │ │ └─ App.tsx # 主 UI │ ├─ options/ │ │ ├─ index.html │ │ └─ App.tsx # 后端地址、token、调试开关 │ └─ content/ │ ├─ index.ts # 采集器注入(匹配支持的站点) │ └─ badge.ts # 页面内「已收集」状态回显 ├─ src/ │ ├─ profiles/ # ★ 站点采集配置(声明式) │ │ ├─ types.ts │ │ ├─ 1688.ts │ │ ├─ taobao.ts # 二期 │ │ ├─ pdd.ts # 二期 │ │ └─ index.ts # 按 URL 匹配 profile │ ├─ collector/ # ★ 采集引擎 │ │ ├─ scan.ts # 按 profile 扫描页面 │ │ ├─ text.ts # 文本素材提取 │ │ ├─ image.ts # 图片素材提取 + 分组 │ │ ├─ url.ts # URL 工具链(借鉴 §2.3) │ │ ├─ dom.ts # waitForElement / shadow 穿透 │ │ └─ dedupe.ts # 去重 │ ├─ messaging/ │ │ ├─ types.ts # 消息名与 payload 类型 │ │ └─ client.ts # sendToBackground 封装 │ ├─ api/ │ │ ├─ client.ts # 后端 HTTP 客户端(仅 background 用) │ │ └─ schema.ts # Zod 契约 │ ├─ storage/ │ │ ├─ keys.ts # SH_ 前缀常量 │ │ └─ settings.ts │ ├─ dnr/ │ │ └─ referer.ts # DNR 改 Referer(借鉴 §2.10) │ ├─ telemetry/ │ │ └─ log.ts # 埋点(借鉴 §2.8) │ └─ ui/ # 共享组件(Shadow DOM 挂载工具等) └─ package.json ``` **核心设计:`profiles/` 与 `collector/` 严格分离。** 采集引擎完全通用,加一个新平台只需新增一个 profile 文件,不改引擎代码。这是从 §2.1 学到的最重要的架构决策。 --- ## 5. Manifest 设计 ```jsonc { "manifest_version": 3, "name": "Seller Helper", "version": "0.1.0", "minimum_chrome_version": "114", "permissions": [ "storage", // 设置、文件夹缓存 "sidePanel", // 主 UI "declarativeNetRequest", // 改 Referer 取图(§8) "activeTab" // 手动触发采集 ], "optional_permissions": [ "downloads" // 二期本地下载才申请(借鉴 §2.12) ], "host_permissions": [ "https://detail.1688.com/*", "https://*.alicdn.com/*", // 图片 CDN "https://api.seller-helper.local/*" // 我们的后端,正式域名待定 ], "background": { "service_worker": "background.js", "type": "module" }, "side_panel": { "default_path": "sidepanel.html" }, "options_ui": { "page": "options.html", "open_in_tab": true }, "action": { "default_title": "Seller Helper" }, "content_scripts": [{ "matches": ["https://detail.1688.com/*"], "js": ["content.js"], "run_at": "document_idle" }], "commands": { "collect_current_page": { "suggested_key": { "default": "Ctrl+Shift+S", "mac": "Command+Shift+S" }, "description": "收集当前页面到当前文件夹" } }, "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self';" } } ``` 几个刻意的选择: - **不用 ``**。参考实现用了 `` + `https://*/*`,因为它要在所有电商站点做比价。我们一期只需要 1688 详情页,权限越小安装时越不吓人,后续加平台再加具体域名。 - **`activeTab` 而不是全站注入**,配合快捷键手动触发。 - **`downloads` 放 optional**,借鉴 §2.12。 - **`action` 不配 `default_popup`**,点图标直接开 Side Panel(在 background 里 `chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })`)。 --- ## 6. 采集引擎设计 ### 6.1 Site Profile 类型定义 这是整个插件的核心抽象,直接脱胎于 §2.1 的配置结构,但做了扩展: ```ts // src/profiles/types.ts /** 文本素材的语义类型,与后端 Material.textKind 对齐 */ export type TextKind = 'title' | 'params' | 'selling_point' | 'desc' | 'price' | 'sku_names'; /** 图片分组,与后端 Material 的分组对齐 */ export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'param' | 'video'; /** 取值来源属性,顺序即优先级(借鉴 §2.1 结论 2) */ export type SrcProp = 'data-lazyload-src' | 'data-src' | 'currentSrc' | 'src' | 'backgroundImage'; export interface TextRule { kind: TextKind; /** 多套选择器,逐个尝试直到命中(借鉴 §2.6) */ selectors: string[]; /** join: 命中的所有节点文本拼接(标题被拆 span 的情况,借鉴 §2.7) * first: 只取第一个 * table: 按 kv 解析成参数表 */ extract: 'join' | 'first' | 'table'; /** table 模式下 key/value 的子选择器 */ tableKeySelector?: string; tableValueSelector?: string; required?: boolean; } export interface ImageGroupRule { key: ImageGroupKey; name: string; type: 'img' | 'video'; selectors: string[]; /** 覆盖 defaultSrcProps;SKU 组要用 ['backgroundImage'](借鉴 §2.1 结论 3) */ srcProps?: SrcProp[]; /** 该组图片的名称来源(SKU 规格名,借鉴 §2.2) */ nameSelectors?: string[]; /** 识别"当前高亮"元素,用于排除(借鉴 §2.6) */ activeSelectors?: string[]; /** 尺寸下限,过滤图标和占位图 */ minWidth?: number; minHeight?: number; } export interface SiteProfile { id: string; // '1688' name: string; // '1688' /** URL 匹配,判断是否为支持的商品详情页 */ urlPatterns: RegExp[]; /** 从 URL 或 DOM 提取平台侧商品 ID */ extractItemId: (url: string) => string | null; /** 等待页面就绪的锚点元素,配合 MutationObserver(借鉴 §2.16) */ readySelectors: string[]; readyTimeoutMs?: number; defaultSrcProps: SrcProp[]; textRules: TextRule[]; imageGroups: ImageGroupRule[]; /** 图片 URL 还原原图的额外规则,缺省用通用 CDN 后缀规则 */ originalUrlRules?: Array<{ match: RegExp; replace: string }>; /** 该站图片是否需要 DNR 改 Referer(§8) */ refererOrigin?: string; } ``` ### 6.2 1688 Profile 选择器直接沿用参考实现验证过的那些,但拆成数组(§2.6 的教训): ```ts // src/profiles/1688.ts 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-* 上(§2.1 结论 2) defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'], refererOrigin: 'https://www.1688.com', textRules: [ { kind: 'title', // 标题被拆成多个 .title-text span,必须 join(§2.7) selectors: ['.title-content .title-text', '.title-content h1', '.od-pc-offer-title', 'h1'], extract: 'join', required: true, }, { 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: 'price', selectors: ['.price-original, .od-pc-offer-price-priceRange', '.price .value'], extract: 'first', }, { kind: 'desc', selectors: ['.de-description-detail', '#detailContentContainer', '.html-description'], extract: 'join', }, ], imageGroups: [ { key: 'main', name: '主图', type: 'img', // 四套画廊变体全列上(§2.6) 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 背景图(§2.1 结论 3) srcProps: ['backgroundImage'], // 规格名(§2.2 的五种结构) 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'], }, ], }; ``` > **注意 SKU 组的 `minWidth: 20`**:SKU 缩略图本身很小(通常 40×40),不能用主图的门槛过滤,否则全被滤掉。这是 §2.1 结论 3 的延伸——不同分组的尺寸门槛必须独立配置。 ### 6.3 URL 工具链 直接移植 §2.3,补上类型和我们需要的扩展: ```ts // src/collector/url.ts const IMG_EXT = /\.(jpg|jpeg|png|gif|bmp|heic|webp|avif)$/i; /** 缩略图 URL → 原图 URL。阿里系 CDN 的尺寸后缀都在扩展名后的 _ 之后 */ export function toOriginalUrl(url: string, rules?: Array<{ match: RegExp; replace: string }>): string { for (const r of rules ?? []) { if (r.match.test(url)) return url.replace(r.match, r.replace); } // 通用规则:xxx.jpg_400x400.jpg → xxx.jpg 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] ?? ''; } export function isDataUrl(u: string): boolean { return /^(?:data:(\S+);(\S+),)/.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); } } ``` ### 6.4 图片提取 核心是 `readImageSource`,把 §2.4 的降级链和 §2.2 的规格名提取合到一起: ```ts // src/collector/image.ts import { toAbsoluteUrl, toOriginalUrl, urlInBrackets, looksLikeImageUrl, dedupeKey } from './url'; import type { ImageGroupRule, SiteProfile, SrcProp } from '../profiles/types'; export interface ImageMaterial { key: string; // 'main-1' groupKey: string; // 'main' groupName: string; // '主图' /** SKU 规格名,仅 sku 组有 */ variantName?: string; url: string; // 已还原为原图 thumbUrl: string; // 页面上的原始(小图)地址,用于侧边栏预览 index: number; type: 'img' | 'video'; width?: number; height?: number; } /** 从元素上读出图片地址与名称,按 srcProps 顺序降级(借鉴 §2.4) */ function readImageSource(el: Element, srcProps: SrcProp[], nameSelectors?: string[]): { url: string; name: string } { let url = ''; let name = ''; for (const prop of srcProps) { if (url) break; if (prop === 'backgroundImage') { // 分两种:元素本身是 IMG,或者要往里找子节点的背景图 if (el.tagName === 'IMG') { const img = el as HTMLImageElement; url = img.currentSrc || img.src || ''; name = img.alt || ''; } else { // 依次尝试各种 SKU DOM 结构(§2.2 的五种) 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 才是实际加载的那张(§2.4) url = prop === 'src' ? ((el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || '') : raw; } } } // 名称统一在这里取,与 url 的来源解耦 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 }; } /** 展开选择器命中的元素,穿透 Shadow DOM(借鉴 §2.5) */ function queryAllDeep(selectors: string[]): Element[] { const out: Element[] = []; for (const sel of selectors) { let nodes: NodeListOf; 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; } export function collectImages(profile: SiteProfile): ImageMaterial[] { const seen = new Set(); const result: ImageMaterial[] = []; for (const group of profile.imageGroups) { const srcProps = group.srcProps ?? profile.defaultSrcProps; // 需要排除的"当前高亮"元素 const activeSet = new Set(group.activeSelectors ? queryAllDeep(group.activeSelectors) : []); for (const el of queryAllDeep(group.selectors)) { if (activeSet.has(el)) continue; const { url: rawUrl, name } = readImageSource(el, srcProps, group.nameSelectors); if (!rawUrl) 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, profile.originalUrlRules) : rawUrl; // 尺寸过滤:优先用 naturalWidth(真实尺寸),退到布局尺寸 if (group.type === 'img' && (group.minWidth || group.minHeight)) { const img = el as HTMLImageElement; const w = img.naturalWidth || (el as HTMLElement).offsetWidth || 0; const h = img.naturalHeight || (el as HTMLElement).offsetHeight || 0; // 尺寸为 0 说明还没加载完,放过它,别误杀 if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue; } const k = dedupeKey(url); if (seen.has(k)) continue; seen.add(k); result.push({ key: `${group.key}-${result.length + 1}`, groupKey: group.key, groupName: group.name, variantName: group.key === 'sku' ? name || undefined : undefined, url, thumbUrl: rawUrl, index: result.length, type: group.type, }); } } return result; } ``` 三个容易踩的坑,都在代码里处理了: - **选择器写错不能中断整个扫描**,`querySelectorAll` 包 try/catch。远程下发配置后这点尤其重要,一个错配置不该让插件彻底失灵。 - **尺寸为 0 不等于小图**,是还没加载完。直接按 `< minWidth` 过滤会把懒加载图全误杀,必须先判断 `w > 0 && h > 0`。 - **`activeSelectors` 命中的元素要按对象引用排除**,不能按 URL 排除,因为高亮图和列表里的图 URL 可能相同。 ### 6.5 页面就绪与 SPA 适配 借鉴 §2.16 的三管齐下,加上 §2.8 的超时上报: ```ts // src/collector/dom.ts export function waitForAny(selectors: string[], timeoutMs = 10_000): Promise { 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 }); }); } /** SPA 路由变化:劫持 pushState/replaceState + 监听 popstate/hashchange */ export function onUrlChange(cb: (url: string) => void): () => void { let last = location.href; const fire = () => { if (location.href !== last) { last = location.href; cb(location.href); } }; const origPush = history.pushState; const origReplace = history.replaceState; history.pushState = function (...args) { origPush.apply(this, args); fire(); }; history.replaceState = function (...args) { origReplace.apply(this, args); fire(); }; window.addEventListener('popstate', fire); window.addEventListener('hashchange', fire); return () => { history.pushState = origPush; history.replaceState = origReplace; window.removeEventListener('popstate', fire); window.removeEventListener('hashchange', fire); }; } ``` 1688 详情页在同一商品的不同 SKU 之间切换不会变 URL,但换商品会。所以 `onUrlChange` 触发时要重新判断 profile 匹配、重置采集状态、重新查"是否已收集"。 ### 6.6 文本提取 ```ts // src/collector/text.ts import type { SiteProfile, TextRule } from '../profiles/types'; export interface TextMaterial { kind: TextRule['kind']; content: string; /** table 模式下的结构化结果 */ pairs?: Array<{ key: string; value: string }>; } 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; try { nodes = document.querySelectorAll(sel); } catch { continue; } if (!nodes.length) continue; 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; } if (rule.extract === 'join') { // 标题被拆成多个 span 的情况(§2.7) let text = ''; nodes.forEach((n) => { text += n.textContent ?? ''; }); text = clean(text); if (text) return { kind: rule.kind, content: text }; continue; } 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 }; } ``` `missingRequired` 会一路带到埋点里(§12),标题抓不到就是选择器失效的强信号。 ### 6.7 扫描入口 ```ts // src/collector/scan.ts import { matchProfile } from '../profiles'; import { waitForAny } from './dom'; import { collectImages } from './image'; import { collectTexts } from './text'; import { reportError } from '../telemetry/log'; export interface ScanResult { platform: string; itemId: string | null; url: string; texts: TextMaterial[]; images: ImageMaterial[]; scannedAt: number; /** 分组统计,直接喂给侧边栏显示 */ stats: Record; } export async function scanCurrentPage(): Promise { const profile = matchProfile(location.href); if (!profile) return null; const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000); if (!anchor) { reportError('scan', 'wait-ready-timeout', { platform: profile.id, url: location.href, selectors: profile.readySelectors, }); // 不 return,页面可能部分可用,继续尝试扫描 } const { materials: texts, missingRequired } = collectTexts(profile); const images = collectImages(profile); const stats: Record = {}; for (const img of images) stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1; // 抓不到东西就上报,把选择器带上(§2.8 —— 这是抗改版闭环的起点) if (images.length === 0 || missingRequired.length > 0) { reportError('scan', 'empty-or-missing', { platform: profile.id, url: location.href, itemId: profile.extractItemId(location.href), imageCount: images.length, missingRequired, selectors: profile.imageGroups.flatMap((g) => g.selectors), }); } return { platform: profile.id, itemId: profile.extractItemId(location.href), url: location.href, texts, images, scannedAt: Date.now(), stats, }; } ``` --- ## 7. 去重 两级,插件只做第一级: | 级别 | 位置 | 手段 | |---|---|---| | L1 URL 归一化 | 插件 | `dedupeKey()`:还原原图 + 剥 query/hash,`Set` 去重。**单页内**去重在 §6.4 已做;**跨页**去重靠后端返回该文件夹已有的 URL 指纹列表,插件本地比对后标灰 | | L2 感知哈希 | 后端 | 图片下载后算 pHash,相似度超阈值标 `dupOfId`,发布页折叠。**插件不参与** | 跨页去重的实现:侧边栏加载时调 `folder-fingerprints` 拿当前文件夹已收集的 `dedupeKey` 列表(纯字符串数组,几百条也很轻),扫描结果里命中的图片标记为「已收集」并默认不勾选。这样连续浏览同一商品的多个页面时不会重复提交。 --- ## 8. 图片获取与防盗链 三条路径,按成本从低到高: ``` ① 只传 URL(默认) 插件 → 后端:{ url: "https://cbu01.alicdn.com/xxx.jpg" } 后端自己 GET,并在请求头带 Referer: https://www.1688.com/ ✅ 插件负担最小,绝大多数图片走这条 ② 插件侧渲染缩略图(仅侧边栏预览) 如果 CDN 校验 Referer 而扩展页 Referer 不对 → 走 DNR 改写(下面详述) ③ 字节兜底上传(少数需要登录态的图) content script 在页面上下文 fetch → blob → 上传 background → 转发后端 ``` **关键认知**:DNR 改 Referer 这个技巧(§2.10)只在浏览器里需要。`Referer`/`Origin` 是 fetch 的 forbidden header,JS 改不了,只能靠 DNR 在网络层改。而我们的后端用 Node/Python 发请求,直接写 header 就行,没这个限制。所以: - **路径 ①(主路径)不需要 DNR**,后端自己设 Referer。 - **路径 ②③ 需要 DNR**。 DNR 实现: ```ts // src/dnr/referer.ts /** 加在 URL 上的标记参数,DNR 规则靠它匹配(借鉴 §2.10) */ export const DNR_MARKER = 'sh_dnr'; const RULE_ID_REFERER = 100_001; export async function enableRefererRewrite(origin: string) { await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [RULE_ID_REFERER], addRules: [{ id: RULE_ID_REFERER, priority: 1, action: { type: 'modifyHeaders', requestHeaders: [ { header: 'Origin', operation: 'set', value: origin }, { header: 'Referer', operation: 'set', value: `${origin}/` }, ], }, condition: { urlFilter: `*?*${DNR_MARKER}=1`, resourceTypes: ['image', 'media', 'xmlhttprequest'], }, }], }); } /** 给需要改写 Referer 的图片 URL 打标记 */ export function markForRewrite(url: string): string { const u = new URL(url); u.searchParams.set(DNR_MARKER, '1'); return u.toString(); } ``` 注意 **规则 ID 用 `100_001` 这种预留段**(§2.10 的做法),避免和未来其他 DNR 规则冲突。另外 `markForRewrite` 加的 query 参数不会影响阿里 CDN 返回图片,但**去重时必须用 `dedupeKey()` 剥掉它**,否则同一张图会被当成两张。 --- ## 9. 消息层与 background ### 9.1 统一响应壳 借鉴 §2.11,但用 TS 判别联合替代 `{code, data, errMsg}` 的裸结构: ```ts // src/messaging/types.ts export type Resp = | { ok: true; data: T } | { ok: false; error: string; code?: number }; /** 消息名 → { payload, result } 的映射表,一处定义全局有类型 */ export interface MessageMap { // 采集 'scan-page': { payload: void; result: ScanResult | null }; 'collect': { payload: CollectPayload; result: { materialIds: string[] } }; 'upload-image-bytes': { payload: { folderId: string; dataUrl: string; meta: ImageMeta }; result: { materialId: string } }; // 文件夹 'folder-list': { payload: void; result: Folder[] }; 'folder-create': { payload: { name: string }; result: Folder }; 'folder-active-get': { payload: void; result: Folder | null }; 'folder-active-set': { payload: { folderId: string }; result: void }; 'folder-fingerprints': { payload: { folderId: string }; result: string[] }; // 状态 'item-collected': { payload: { platform: string; itemId: string; folderId: string }; result: { collected: boolean; count: number } }; // 杂项 'open-publish-page': { payload: { folderId: string }; result: void }; 'fetch-image': { payload: { url: string; refererOrigin?: string }; result: { dataUrl: string } }; 'log': { payload: LogEvent; result: void }; 'health': { payload: void; result: { backendOk: boolean; authed: boolean } }; } export type MessageName = keyof MessageMap; ``` 调用侧封装: ```ts // src/messaging/client.ts export async function send( name: K, payload: MessageMap[K]['payload'], ): Promise { const res: Resp = await chrome.runtime.sendMessage({ name, payload }); if (!res?.ok) throw new Error(res?.error ?? 'unknown extension error'); return res.data; } ``` ### 9.2 background 路由 参考实现是一个 80 分支的巨型 switch(§2.11),可读性差。我们用 handler 表: ```ts // entrypoints/background.ts type Handler = ( payload: MessageMap[K]['payload'], sender: chrome.runtime.MessageSender, ) => Promise; const handlers: { [K in MessageName]: Handler } = { 'collect': async (p) => api.postMaterials(p), 'folder-list': async () => api.listFolders(), 'folder-create': async (p) => api.createFolder(p.name), 'fetch-image': async (p) => ({ dataUrl: await fetchImageAsDataUrl(p) }), // ... }; chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const h = (handlers as any)[msg?.name]; if (!h) { sendResponse({ ok: false, error: `unknown message: ${msg?.name}`, code: 404 }); return false; } h(msg.payload, sender) .then((data: unknown) => sendResponse({ ok: true, data })) .catch((e: unknown) => { const error = e instanceof Error ? e.message : String(e); log.error('message-handler', msg.name, { error }); sendResponse({ ok: false, error }); }); return true; // 保持通道开启以支持异步 }); chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); ``` `return true` 那行是 MV3 的经典坑:不返回 `true` 异步 `sendResponse` 会失效。 ### 9.3 后端调用只在 background 沿用 §2.11 的原则:**只有 background 直接访问后端**。它有 `host_permissions`,不受页面 CORS 约束;content script 有约束。Side Panel 页面理论上也能直连,但统一走 background 好处是鉴权 token 和重试逻辑只有一份。 ```ts // src/api/client.ts —— 仅 background 内使用 async function request(path: string, init?: RequestInit): Promise { const { baseUrl, token } = await getSettings(); if (!baseUrl) throw new Error('未配置后端地址,请在扩展设置中填写'); const res = await fetch(`${baseUrl}${path}`, { ...init, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, ...init?.headers, }, }); if (res.status === 401) throw new Error('鉴权失败,请检查设置中的 Token'); if (!res.ok) throw new Error(`后端返回 ${res.status}: ${await res.text().catch(() => '')}`); return res.json() as Promise; } ``` **鉴权**:自用阶段就是后端签发的长期 Bearer token,在 options 页填一次存 `chrome.storage.local`。不做 OAuth,不存密钥以外的任何凭证。 --- ## 10. UI 设计 ### 10.1 Side Panel 主界面 用 Side Panel 而不是 popup——popup 一点页面就消失,浏览时没法常驻(§5 已在 manifest 里配好)。 ``` ┌ Seller Helper ─────────────────────┐ │ 文件夹 儿童保温杯 ▾ [+ 新建] │ ← 当前文件夹,载荷概念 │ 已收集 3 个来源 · 12 图 │ ├────────────────────────────────────┤ │ 本页识别到 │ │ │ │ ▸ 文本 │ │ ☑ 标题 儿童316不锈钢保温杯… │ │ ☑ 参数表 12 项 [展开] │ │ ☑ 价格 ¥18.50 │ │ ☐ 详情文案 1,240 字 [展开] │ │ │ │ ▸ 图片 │ │ 主图 (6) [全选] │ │ ☑□ ☑□ ☑□ ☑□ ☑□ ☑□ │ │ SKU图 (4) [全选] │ │ ☑□蓝色 ☑□粉色 ☑□绿色 ⊘□已收集 │ │ 详情图 (9) [全选] │ │ ☑□ ☑□ ☑□ ☐□ ☐□ … │ │ 视频 (1) [全选] │ │ ☑▶ │ ├────────────────────────────────────┤ │ 已选 18 项 │ │ [ 收集到「儿童保温杯」] │ │ [ 打开发布页 ↗ ] │ └────────────────────────────────────┘ ``` 设计要点: - **分组展示 + 每组独立全选**,直接对应 §2.1 的分组结果。这是参考实现体验好的核心原因:用户一眼就知道哪些是主图哪些是详情图。 - **SKU 图下方标规格名**(§2.2),蓝色/粉色一目了然。 - **已在本文件夹收集过的图标灰 + `⊘` 图标**(§7 跨页去重),默认不勾选。 - **详情图默认只勾前几张**。详情图动辄几十张且多是长图水印图,全勾会让发布页很乱。默认勾选策略:主图全勾、SKU 图全勾、详情图勾前 3 张、视频全勾。 - **文件夹切换器放最顶部**,因为它决定这次收集去哪,必须在点「收集」之前就看得见。 ### 10.2 新建文件夹 在侧边栏内联完成,不跳转: ``` ┌ 新建文件夹 ────────────────┐ │ 名称 [儿童保温杯________] │ │ 提示:留空则用商品标题 │ │ [取消] [创建并切换] │ └────────────────────────────┘ ``` 「留空用商品标题」是个省事的默认值——多数情况下文件夹就是围绕一个商品建的。 ### 10.3 页面内状态回显 借鉴 §2.15。content script 在商品标题旁注入一个小徽标: ``` 儿童316不锈钢保温杯儿童水杯 ✅ 已收集 6 图 · 儿童保温杯 ``` 未收集时显示可点击的: ``` 儿童316不锈钢保温杯儿童水杯 ⊕ 收集到 Seller Helper ``` 实现要点: - **整块 UI 套 Shadow DOM**(§2.16),避免被 1688 的 CSS 污染,也避免污染它。 - 挂载点用 `waitForAny(profile.readySelectors)` 等出来,不轮询。 - URL 变化时(§6.5)重新查一次 `item-collected` 并更新徽标。 - 点击直接打开 Side Panel(`chrome.sidePanel.open()`,需要用户手势,在 content script 的 click handler 里发消息给 background 触发是合法的)。 ### 10.4 发布页不在插件里 「打开发布页」= `chrome.tabs.create({ url: \`${baseUrl}/folders/${folderId}\` })`。 理由在 V2 §4.1 讲过,这里补一条来自参考实现的证据:它连一个批量下载弹窗都是 iframe 加载服务端页面(§2.14),我们的发布页复杂度高一个量级,更没有理由打包进插件。好处是发布页改版只需正常部署,不用重新发布扩展、不用等审核。 ### 10.5 Options 页 只有四项,不要做复杂: ``` 后端地址 [https://api.xxx.com ] [测试连接] 访问 Token [•••••••••••••••• ] 默认勾选 ☑主图 ☑SKU图 ☐详情图(前3张) ☑视频 调试日志 ☐ 开启(在控制台打印采集详情) ``` 「测试连接」调 `health` 消息,明确告诉用户后端通不通、token 对不对——比让用户在收集时才发现失败要好。 --- ## 11. 远程配置与抗改版 这是 §2.9 + §2.8 的闭环,**一期就要做**,因为 1688 改版频率不低,而选择器失效是这个项目最高频的故障。 ``` ┌─────────────────────────────────┐ │ 后端 /api/ext/profiles │ │ { version, profiles: {...} } │ └─────────────────────────────────┘ │ 每次 SW 启动 + 每 6h 拉一次 ▼ chrome.storage.local SH_PROFILE_CONFIG = { version, profiles, fetchedAt } │ ▼ matchProfile(url) ── 远程配置存在且 version 更高 → 用远程 └─ 否则 → 用内置兜底(打包在插件里的 profiles/1688.ts) ``` ```ts // src/profiles/index.ts import { profile1688 } from './1688'; const BUILTIN: SiteProfile[] = [profile1688]; export async function loadProfiles(): Promise { const remote = await storage.get(SH_PROFILE_CONFIG); if (!remote?.profiles) return BUILTIN; // 远程配置里选择器是字符串,正则要在这里 revive try { return hydrateProfiles(remote.profiles); } catch (e) { reportError('profile', 'hydrate-failed', { error: String(e), version: remote.version }); return BUILTIN; // 远程配置坏了必须能降级 } } ``` 两个必须遵守的约束: 1. **内置兜底不能省**。远程拉取失败、JSON 格式坏了、后端挂了,插件都必须还能用打包进去的那份配置正常工作。 2. **远程配置里不能有可执行代码**。`urlPatterns` 和 `originalUrlRules` 是正则,必须以字符串下发再 `new RegExp()`,绝不用 `eval`。`extractItemId` 是函数,不能远程下发,只能是内置的——所以远程配置只覆盖 `textRules`、`imageGroups`、`readySelectors`、`defaultSrcProps` 这些纯数据字段。 配合 §12 的埋点,闭环就成了:**改版 → 埋点报警 → 我改后端配置 → 插件 6 小时内(或重启 SW 立刻)自动生效**。 --- ## 12. 埋点与可观测性 结构沿用 §2.8: ```ts // src/telemetry/log.ts export interface LogEvent { type: 'error' | 'click' | 'view'; target: string; // 'scan' | 'collect' | 'sidepanel' ... step?: string; // 'empty-or-missing' | 'wait-ready-timeout' ... message?: string; extra?: Record; // 上下文,出错时务必带上 selectors url?: string; version: string; // 插件版本 profileVersion?: string; // 采集配置版本 ts: number; } ``` 必须上报的事件(每一条都对应一种真实故障模式): | 事件 | 触发条件 | 排查什么 | |---|---|---| | `scan / empty-or-missing` | 一张图没抓到,或必填文本缺失 | **选择器失效**,最重要的一条 | | `scan / wait-ready-timeout` | 锚点元素等不出来 | 页面结构大改,或加载太慢 | | `scan / partial` | 某个分组数量为 0 但其他组正常 | 单个分组的选择器失效 | | `collect / upload-failed` | 提交后端失败 | 后端故障 / 鉴权过期 / 网络 | | `fetch-image / failed` | 字节兜底也拿不到图 | 防盗链策略变化 | | `profile / hydrate-failed` | 远程配置解析失败 | 我自己配错了 | **上报要异步且不能影响主流程**:`log` 消息发出去就不管结果,失败就丢。埋点本身绝不能让采集失败。 后端侧建一个最小看板:按 `profileVersion` 和 `step` 分组统计最近 24h 的 error 数。`empty-or-missing` 一涨就说明 1688 改版了。 --- ## 13. 存储设计 插件本地只存**设置和缓存**,素材一律写后端(V2 §4.6 的预热策略要求尽早提交)。 ```ts // src/storage/keys.ts —— 统一 SH_ 前缀(借鉴 §2.16) export const SH_BACKEND_URL = 'SH_BACKEND_URL'; export const SH_TOKEN = 'SH_TOKEN'; export const SH_ACTIVE_FOLDER = 'SH_ACTIVE_FOLDER'; // { id, name } export const SH_FOLDER_CACHE = 'SH_FOLDER_CACHE'; // 文件夹列表,5min TTL export const SH_PROFILE_CONFIG = 'SH_PROFILE_CONFIG'; // 远程采集配置 export const SH_DEFAULT_SELECT = 'SH_DEFAULT_SELECT'; // 默认勾选策略 export const SH_DEBUG_LOG = 'SH_DEBUG_LOG'; export const SH_PENDING_QUEUE = 'SH_PENDING_QUEUE'; // 提交失败的重试队列 ``` `SH_PENDING_QUEUE` 值得单独说:收集时后端可能临时不可用,直接报错会让用户白采一遍。所以失败的提交进本地队列,SW 启动时和网络恢复时重试,侧边栏顶部显示「3 条待同步」。队列里只存文本和图片 URL(很小),不存图片字节,避免撑爆 storage。 不需要 `unlimitedStorage`——我们不在本地存图。 --- ## 14. 与后端的接口契约 只列插件用到的那几个。Zod schema 在 `src/api/schema.ts` 定义,和后端共享一份。 ### `GET /api/ext/profiles` 拉远程采集配置(§11)。响应 `{ version: string, profiles: Record }`。 ### `GET /api/folders` 文件夹列表。响应 `Folder[]`,`Folder = { id, name, status, materialCount, imageCount, createdAt }`。 ### `POST /api/folders` `{ name }` → `Folder`。 ### `GET /api/folders/:id/fingerprints` 跨页去重用(§7)。响应 `string[]`(dedupeKey 列表)。 ### `GET /api/folders/:id/collected?platform=1688&itemId=xxx` 状态回显用(§10.3)。响应 `{ collected: boolean, count: number }`。 ### `POST /api/materials` ★ 主接口 ```ts { folderId: string; source: { platform: '1688'; itemId: string | null; url: string; collectedAt: number; }; texts: Array<{ kind: 'title' | 'params' | 'selling_point' | 'desc' | 'price'; content: string; pairs?: Array<{ key: string; value: string }>; }>; images: Array<{ groupKey: 'main' | 'sku' | 'detail' | 'param' | 'video'; groupName: string; variantName?: string; // SKU 规格名(§2.2) url: string; // 已还原为原图 index: number; type: 'img' | 'video'; dedupeKey: string; // 插件算好,后端直接用 }>; /** 该站图片需要的 Referer,后端下载时照着设(§8) */ refererOrigin?: string; } → { materialIds: string[], queuedJobs: number } ``` 后端收到后立即排队下载图片(V2 §4.6 预热),响应不等下载完成。 ### `POST /api/materials/bytes` 字节兜底(§8 路径③)。`multipart/form-data`,字段 `folderId` / `meta`(JSON) / `file`。 ### `POST /api/ext/logs` 埋点批量上报。`{ events: LogEvent[] }`。 --- ## 15. 开发里程碑 每一步都能独立验证,前三步不需要任何模型密钥。 ### M1 · 骨架跑通(约 0.5 天) WXT 项目 + manifest + Side Panel 空壳 + Options 页 + background 消息路由。 **验收**:装进 Chrome,点图标能开侧边栏,Options 能存后端地址并「测试连接」通。 ### M2 · 采集引擎(约 2 天)★ 核心 `profiles/types.ts` + `profiles/1688.ts` + `collector/` 全套(url / dom / text / image / dedupe / scan)。 **验收**:在 5 个不同的 1688 商品页(**故意挑不同页面版本**)执行 `scanCurrentPage()`,控制台打印结果,人工核对: - 主图数量与页面画廊一致,且是原图 URL(能直接在新标签打开看到高清图) - SKU 图带对了规格名 - 详情图无遗漏、无重复 - 标题完整(重点验证被拆 span 的情况) - 参数表 kv 解析正确 这一步是整个插件的地基,值得多花时间在真实页面上验。 ### M3 · 侧边栏 UI + 文件夹(约 2 天) 分组展示 + 勾选 + 文件夹切换/新建 + 收集提交 + 待同步队列。 **验收**:多个商品页收集到同一文件夹,后端能看到素材落库,跨页重复图被标灰。 ### M4 · 页面内状态回显(约 0.5 天) content script 注入 Shadow DOM 徽标 + SPA 路由适配。 **验收**:已收集的商品页刷新后徽标仍显示「已收集」;换商品后徽标正确重置。 ### M5 · 埋点 + 远程配置(约 1 天) `telemetry/log.ts` + `/api/ext/profiles` 拉取与降级。 **验收**:手动把内置选择器改错,能在后端看到 `empty-or-missing` 上报且 `extra.selectors` 完整;把正确选择器放到远程配置里,插件重启后恢复正常。 ### M6 · 图片兜底与 DNR(约 1 天) `fetch-image` + DNR 改 Referer + 字节上传。 **验收**:找一张后端直接下载会 403 的图,走兜底路径能成功入库。 ### 二期 淘宝/拼多多 profile、图片 hover 工具条、本地批量下载(按分组建目录)、页面事件总线。 --- ## 16. 风险与对策 | 风险 | 级别 | 对策 | |---|---|---| | 1688 改版导致选择器失效 | 🔴 高 | 多套选择器并存(§6.2)+ 抓空上报(§12)+ 远程热修(§11)。这三件必须一期做完,它们是一套 | | 详情图数量爆炸(几十张长图) | 🟡 中 | 尺寸门槛过滤 + 默认只勾前 3 张(§10.1)+ 后端 pHash 去重 | | SKU 规格名取不到 | 🟡 中 | 五套 nameSelectors 兜底(§6.2);取不到就留空,发布页允许手填 | | 收集时后端不可用 | 🟡 中 | 本地重试队列 `SH_PENDING_QUEUE`(§13) | | 图片防盗链策略变化 | 🟡 中 | 三级路径降级(§8)+ `fetch-image/failed` 埋点 | | Chrome 商店审核(若上架) | 🟢 低 | 权限最小化(§5),不用 ``;自用可直接加载解压目录 | | MV3 SW 被回收导致状态丢失 | 🟢 低 | 不在 SW 内存里存状态,全部落 `chrome.storage` | | 采集内容是不可信输入 | 🟡 中 | 插件只做提取不做渲染 HTML;后端在喂给 LLM 前清洗(V2 §11 已列) | --- ## 17. 待决策 - **详情图的默认勾选策略**:前 3 张是拍脑袋定的。要不要改成「按尺寸排序取最大的 N 张」或者「只取宽度 > 750 的」?需要看几个真实商品页再定。 - **参数图是否单独成组**:1688 有些商品把尺码表、规格参数做成图片放在详情里。单独成 `param` 组需要额外的识别规则(比如 OCR 判断含表格),一期可能先归到 `detail`。 - **视频要不要一期就采**:V2 里视频处理是三期的事,但视频 URL 采集成本很低。倾向一期就采集入库,只是不处理。 - **`extractItemId` 能否远程下发**:目前设计它是内置函数不能远程改(§11 约束 2)。如果 1688 改了 URL 结构就得发版。可以考虑改成「远程下发正则 + 内置提取逻辑」的组合。