Files
ozon-seller-kit/seller-helper/docs/插件开发方案.md
T
2026-08-07 17:37:16 +08:00

1635 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Seller Helper 插件开发方案
> 状态:设计阶段,未开始编码
> 最后更新:2026-08-06
> 上游文档:[`方案设计-V2.md`](./方案设计-V2.md)
> 参考实现:`1688-extension/`1688 采购助手 v1.1.8Plasmo 构建产物,已反编译分析)
---
## 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 背景图而非 `<img>`,取 `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 headerJS 改不了,只能靠 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';"
}
}
```
几个刻意的选择:
- **不用 `<all_urls>`**。参考实现用了 `<all_urls>` + `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[];
/** 覆盖 defaultSrcPropsSKU 组要用 ['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<Element>;
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<string>();
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<Element | null> {
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<Element>;
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<string, number>;
}
export async function scanCurrentPage(): Promise<ScanResult | null> {
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<string, number> = {};
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/
✅ 插件负担最小,绝大多数图片走这条
② 插件侧渲染缩略图(仅侧边栏预览)
<img src="页面上的原始 thumbUrl">
如果 CDN 校验 Referer 而扩展页 Referer 不对 → 走 DNR 改写(下面详述)
③ 字节兜底上传(少数需要登录态的图)
content script 在页面上下文 fetch → blob → 上传 background → 转发后端
```
**关键认知**DNR 改 Referer 这个技巧(§2.10)只在浏览器里需要。`Referer`/`Origin` 是 fetch 的 forbidden headerJS 改不了,只能靠 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<T> =
| { 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<K extends MessageName>(
name: K,
payload: MessageMap[K]['payload'],
): Promise<MessageMap[K]['result']> {
const res: Resp<MessageMap[K]['result']> = 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<K extends MessageName> = (
payload: MessageMap[K]['payload'],
sender: chrome.runtime.MessageSender,
) => Promise<MessageMap[K]['result']>;
const handlers: { [K in MessageName]: Handler<K> } = {
'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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>;
}
```
**鉴权**:自用阶段就是后端签发的长期 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<SiteProfile[]> {
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<string, unknown>; // 上下文,出错时务必带上 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<string, SerializedProfile> }`。
### `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),不用 `<all_urls>`;自用可直接加载解压目录 |
| MV3 SW 被回收导致状态丢失 | 🟢 低 | 不在 SW 内存里存状态,全部落 `chrome.storage` |
| 采集内容是不可信输入 | 🟡 中 | 插件只做提取不做渲染 HTML;后端在喂给 LLM 前清洗(V2 §11 已列) |
---
## 17. 待决策
- **详情图的默认勾选策略**:前 3 张是拍脑袋定的。要不要改成「按尺寸排序取最大的 N 张」或者「只取宽度 > 750 的」?需要看几个真实商品页再定。
- **参数图是否单独成组**:1688 有些商品把尺码表、规格参数做成图片放在详情里。单独成 `param` 组需要额外的识别规则(比如 OCR 判断含表格),一期可能先归到 `detail`。
- **视频要不要一期就采**:V2 里视频处理是三期的事,但视频 URL 采集成本很低。倾向一期就采集入库,只是不处理。
- **`extractItemId` 能否远程下发**:目前设计它是内置函数不能远程改(§11 约束 2)。如果 1688 改了 URL 结构就得发版。可以考虑改成「远程下发正则 + 内置提取逻辑」的组合。