# 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 结构就得发版。可以考虑改成「远程下发正则 + 内置提取逻辑」的组合。