feat: 开发采集、采集箱和商品编辑功能

This commit is contained in:
Joey
2026-08-15 22:17:26 +08:00
parent c61d1a3154
commit 36357843d0
130 changed files with 18005 additions and 12 deletions
+102
View File
@@ -0,0 +1,102 @@
# Seller Helper - 1688/淘宝采集插件
采集 1688/淘宝商品信息和图片到本地文件夹。
## 快速开始
```bash
cd extension
pnpm install
pnpm dev
```
然后:
1. 打开 Chrome 扩展管理页面:`chrome://extensions/`
2. 开启"开发者模式"
3. 点击"加载已解压的扩展程序"
4. 选择 `extension/.output/chrome-mv3`
## 使用
1. 打开任意 1688 或淘宝商品页
2. 点击扩展图标,打开侧边栏
3. 点击"开始采集"
## 目录结构
```
extension/
├── entrypoints/
│ ├── background.ts # Service Worker(代理图片 fetch
│ ├── sidepanel/ # 采集控制 UI
│ └── content/ # 注入到商品页
├── src/
│ ├── profiles/
│ │ ├── types.ts # SiteProfile 类型定义
│ │ └── 1688.ts # 1688 采集配置(生产验证)
│ ├── collector/
│ │ └── url.ts # URL 工具(CDN 后缀处理)
│ └── schema/
│ └── product.ts # product.json 类型
├── wxt.config.ts
└── package.json
```
## 当前状态
**M1 完成** - 采集引擎核心
- ✅ 1688 profile(选择器来自 v1.1.8 生产 bundle
- ✅ 淘宝 profile(复用阿里系 CDN 规则)
- ✅ URL 工具链(CDN 后缀处理、去重 key)
- ✅ 图片提取(主图/SKU/详情/视频)
- ✅ 文本提取(标题/价格/参数表/描述)
- ✅ DOM 等待 + Shadow DOM 穿透
- ✅ Side Panel UI(展示采集结果)
- ✅ Console 可测试:`window.__SellerHelper.scan()`
🔨 **待实现(M3**
- [ ] File System Access 写盘(选目录 + 生成 product.json
- [ ] sources.json 去重(二次采集追加不重复)
- [ ] 图片批量勾选与预览
- [ ] 文件夹管理(新建/切换)
## 测试方法
### 方法 1: Side Panel(推荐)
1. 打开任意 1688/淘宝商品详情页
2. 点击扩展图标 → Side Panel 打开
3. 滚动页面到底部(加载详情图)
4. 点击"开始采集"
5. 查看采集结果(文本数量、图片分组统计、警告)
### 方法 2: Console 测试
```js
// 在 1688/淘宝商品详情页的 Console 中执行
const result = await window.__SellerHelper.scan();
console.table(result.texts);
console.table(result.images);
console.log('stats:', result.stats);
console.log('warnings:', result.warnings);
```
## 采集重点
**必采**(高价值 + 高成功率):
- ✅ 标题(100%
- ✅ 主图(100%
- ✅ SKU 图 + 规格名(95%
- ✅ 详情图(90%,需滚动)
- ✅ 视频(80%
**可选**(保留在 `_raw` 供 studio 参考):
- 🟢 价格(90%
- 🟢 参数表(80%)—— 1688 的参数不对应 Ozon 属性 ID
- 🟢 详情文案(70%)—— 中文,需翻译
## 相关文档
- [总体架构](../../docs/architecture.md)
- [1688/淘宝实施计划](../../docs/extension/1688-taobao-implementation.md)
- [插件原方案](../../docs/extension/plan.md)1688 插件逆向分析)
+23
View File
@@ -0,0 +1,23 @@
// Background Service Worker - 唯一出网口(绕 CORS 取图)
export default defineBackground(() => {
console.log('Seller Helper background started');
// 点击扩展图标 → 打开 Side Panel
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
// 代理图片 fetchCORS 绕过)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === 'fetchImage') {
fetchImageAsBlob(msg.url)
.then(blob => sendResponse({ ok: true, blob }))
.catch(err => sendResponse({ ok: false, error: err.message }));
return true; // 保持异步通道
}
});
});
async function fetchImageAsBlob(url: string): Promise<Blob> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
}
+20
View File
@@ -0,0 +1,20 @@
// Content Script - 注入到 1688/淘宝商品页
import { scanCurrentPage } from '../../src/collector/scan';
export default defineContentScript({
matches: [
'https://detail.1688.com/*',
'https://item.taobao.com/*',
'https://detail.tmall.com/*'
],
main() {
console.log('[Seller Helper] Content script loaded');
// 暴露采集入口到全局(供 side panel 调用)
(window as any).__SellerHelper = {
scan: scanCurrentPage
};
console.log('[Seller Helper] Ready to scan. Call window.__SellerHelper.scan() to test.');
}
});
+183
View File
@@ -0,0 +1,183 @@
import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import type { ScanResult } from '../../src/collector/scan';
function App() {
const [status, setStatus] = useState<string>('准备就绪');
const [result, setResult] = useState<ScanResult | null>(null);
const [error, setError] = useState<string>('');
const handleScan = async () => {
setStatus('采集中...');
setError('');
setResult(null);
try {
// 获取当前活跃 tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
setError('无法获取当前标签页');
setStatus('准备就绪');
return;
}
// 执行采集(调用 content script 暴露的全局函数)
const scanResult = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => (window as any).__SellerHelper?.scan()
});
const data = scanResult[0]?.result;
if (!data) {
setError('当前页面不支持采集(仅支持 1688/淘宝商品详情页)');
setStatus('准备就绪');
return;
}
setResult(data);
setStatus('采集完成');
} catch (err) {
setError(`采集失败: ${err instanceof Error ? err.message : String(err)}`);
setStatus('准备就绪');
}
};
return (
<div style={{ padding: '1rem', fontFamily: 'system-ui', width: '360px' }}>
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>Seller Helper</h2>
<p style={{ margin: '0.25rem 0', fontSize: '0.875rem', color: '#666' }}>
1688/
</p>
<div style={{
marginTop: '1rem',
padding: '0.5rem',
background: status === '采集完成' ? '#f0fff0' : '#f0f0f0',
borderRadius: '4px',
fontSize: '0.875rem'
}}>
: {status}
</div>
{error && (
<div style={{
marginTop: '0.5rem',
padding: '0.5rem',
background: '#fff0f0',
border: '1px solid #ffcccc',
borderRadius: '4px',
fontSize: '0.875rem',
color: '#cc0000'
}}>
{error}
</div>
)}
<button
style={{
width: '100%',
padding: '0.75rem',
marginTop: '1rem',
background: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
fontWeight: 500
}}
onClick={handleScan}
disabled={status === '采集中...'}
>
{status === '采集中...' ? '采集中...' : '开始采集'}
</button>
{result && (
<div style={{ marginTop: '1rem', fontSize: '0.875rem' }}>
<div style={{
padding: '0.5rem',
background: '#fafafa',
borderRadius: '4px',
marginBottom: '0.5rem'
}}>
<div><strong>{result.platform}</strong> · {result.itemId}</div>
<div style={{ fontSize: '0.75rem', color: '#999', marginTop: '0.25rem' }}>
{new Date(result.scannedAt).toLocaleString()}
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<h3 style={{ margin: '0 0 0.5rem 0', fontSize: '0.875rem' }}> ({result.texts.length})</h3>
{result.texts.map((t, i) => (
<div key={i} style={{
padding: '0.25rem 0.5rem',
background: '#f9f9f9',
borderLeft: '3px solid #1890ff',
marginBottom: '0.25rem',
fontSize: '0.75rem'
}}>
<strong>{t.kind}</strong>: {t.content.substring(0, 50)}...
</div>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<h3 style={{ margin: '0 0 0.5rem 0', fontSize: '0.875rem' }}> ({result.images.length})</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{Object.entries(result.stats).map(([group, count]) => (
<div key={group} style={{
padding: '0.25rem 0.5rem',
background: '#e6f7ff',
borderRadius: '4px',
fontSize: '0.75rem'
}}>
{group}: {count}
</div>
))}
</div>
</div>
{result.warnings.length > 0 && (
<div style={{ marginTop: '0.75rem' }}>
<h3 style={{ margin: '0 0 0.5rem 0', fontSize: '0.875rem', color: '#ff6600' }}>
</h3>
{result.warnings.map((w, i) => (
<div key={i} style={{
padding: '0.25rem 0.5rem',
background: '#fff7e6',
borderLeft: '3px solid #ff6600',
marginBottom: '0.25rem',
fontSize: '0.75rem'
}}>
{w}
</div>
))}
</div>
)}
</div>
)}
<div style={{
marginTop: '1rem',
padding: '0.5rem',
background: '#f9f9f9',
borderRadius: '4px',
fontSize: '0.75rem',
color: '#666'
}}>
<div>💡 使</div>
<ol style={{ margin: '0.25rem 0 0 1.25rem', padding: 0 }}>
<li> 1688 </li>
<li></li>
<li>"开始采集"</li>
</ol>
</div>
</div>
);
}
const root = createRoot(document.getElementById('root')!);
root.render(<App />);
export default App;
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Seller Helper</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "seller-helper-extension",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "wxt",
"build": "wxt build",
"zip": "wxt zip"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/chrome": "^0.0.268",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.3",
"wxt": "^0.19.0"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
}
+3553
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
/**
* DOM 工具 - 等待元素、Shadow DOM 穿透
* 从 docs/extension/plan.md §6.5 移植
*/
/**
* 等待任一选择器出现(MutationObserver + 超时)
*/
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 });
});
}
/**
* 穿透 Shadow DOM 查询元素
* 1688 部分组件用了 Web Components
*/
export 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;
}
+171
View File
@@ -0,0 +1,171 @@
/**
* 图片提取 - 主图、SKU、详情图、视频
* 从 docs/extension/plan.md §6.4 移植(核心逻辑)
*/
import { toAbsoluteUrl, toOriginalUrl, urlInBrackets, looksLikeImageUrl, dedupeKey } from './url';
import { queryAllDeep } from './dom';
import type { ImageGroupRule, SiteProfile, SrcProp } from '../profiles/types';
export interface ImageMaterial {
key: string; // 'main-001'
groupKey: string; // 'main'
groupName: string; // '主图'
variantName?: string; // SKU 规格名(仅 sku 组)
url: string; // 已还原为原图
thumbUrl: string; // 页面上的原始小图地址
index: number;
type: 'img' | 'video';
width?: number;
height?: number;
}
/**
* 从元素上读出图片地址与名称,按 srcProps 顺序降级
*/
function readImageSource(
el: Element,
srcProps: SrcProp[],
nameSelectors?: string[]
): { url: string; name: string; imgEl: HTMLImageElement | null } {
let url = '';
let name = '';
// 真正承载图片的元素。选择器命中容器时它是子 <img>
// 尺寸过滤必须量它而不是容器,否则容器的 offsetWidth 会让小图蒙混过关
let imgEl: HTMLImageElement | null = el instanceof HTMLImageElement ? el : null;
for (const prop of srcProps) {
if (url) break;
if (prop === 'backgroundImage') {
// SKU 组常用 CSS 背景图
if (el.tagName === 'IMG') {
const img = el as HTMLImageElement;
url = img.currentSrc || img.src || '';
name = img.alt || '';
} else {
// 尝试多种 SKU DOM 结构
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 才是实际加载的那张
url = prop === 'src' ? ((el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || '') : raw;
}
}
}
// 选择器命中的是容器、图在子节点上(淘宝 valueItem-- 就是这种)
// 1688 的 SKU 是 CSS 背景图,走不到这里;淘宝的是真实 <img>,靠这段兜住
if (!url && el.tagName !== 'IMG') {
const inner = el.querySelector('img');
if (inner) {
url = inner.getAttribute('data-src') || inner.currentSrc || inner.src || '';
imgEl = inner;
if (!name) name = inner.alt || '';
}
}
// 名称统一取(SKU 规格名)
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, imgEl };
}
/**
* 占位图识别。阿里系用 `-tps-1-1.png` / `-tps-2-2.png` 这类极小透明图
* 占位,真实地址要等懒加载。采到它们等于污染数据。
*/
function isPlaceholder(url: string, imgEl: HTMLImageElement | null): boolean {
if (/-tps-\d-\d\.(png|gif)/i.test(url)) return true;
if (/^data:image\/gif/i.test(url)) return true;
// 已加载完成但尺寸只有几像素 → 占位图
if (imgEl?.complete && imgEl.naturalWidth > 0 && imgEl.naturalWidth <= 4) return true;
return false;
}
export function collectImages(profile: SiteProfile): ImageMaterial[] {
const result: ImageMaterial[] = [];
for (const group of profile.imageGroups) {
const srcProps = group.srcProps ?? profile.defaultSrcProps;
// 去重按组独立:一张图同时是主图和 SKU 图是正常的,
// 全局去重会让后处理的组丢图(连带丢掉 SKU 规格名)
const seen = new Set<string>();
// 排除"当前高亮"元素(画廊)
const activeSet = new Set(group.activeSelectors ? queryAllDeep(group.activeSelectors) : []);
for (const el of queryAllDeep(group.selectors)) {
if (activeSet.has(el)) continue;
// 跳过位于排除容器内的元素(如详情区里的用户评价图)
if (group.excludeWithin?.some(sel => el.closest(sel))) continue;
const { url: rawUrl, name, imgEl } = readImageSource(el, srcProps, group.nameSelectors);
if (!rawUrl) continue;
// 占位图不能进结果——它不是商品图
if (group.type === 'img' && isPlaceholder(rawUrl, imgEl)) 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) : rawUrl;
// 尺寸过滤。量真正承载图片的 <img>,不是外层容器——
// 否则容器的 offsetWidth 会让 2x2 占位图通过 minWidth 检查
if (group.type === 'img' && (group.minWidth || group.minHeight)) {
const measured = imgEl ?? (el as HTMLElement);
const w = (measured as HTMLImageElement).naturalWidth || (measured as HTMLElement).offsetWidth || 0;
const h = (measured as HTMLImageElement).naturalHeight || (measured as HTMLElement).offsetHeight || 0;
// 尺寸为 0 说明还没加载完,放过它(别误杀懒加载图)
if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue;
}
// 去重。SKU 组把规格名并入 key——不同规格共用同一张图时
// 两条都要留下,否则规格与图的对应关系就断了
const k = group.key === 'sku' ? `${dedupeKey(url)}::${name}` : dedupeKey(url);
if (seen.has(k)) continue;
seen.add(k);
result.push({
key: `${group.key}-${String(result.filter(r => r.groupKey === group.key).length + 1).padStart(3, '0')}`,
groupKey: group.key,
groupName: group.name,
variantName: group.key === 'sku' ? name || undefined : undefined,
url,
thumbUrl: rawUrl,
index: result.length,
type: group.type
});
}
}
return result;
}
+111
View File
@@ -0,0 +1,111 @@
/**
* 采集引擎入口 - 扫描当前页面
* 从 docs/extension/plan.md §6.7 移植(组装各模块)
*
* 淘宝/天猫优先从 SSR JSON 提取(window.__ICE_APP_CONTEXT__),
* 提取失败时降级到 DOM 采集。
*/
import { matchProfile } from '../profiles';
import { waitForAny } from './dom';
import { collectImages, type ImageMaterial } from './image';
import { collectTexts, type TextMaterial } from './text';
import { extractSSRData } from './ssr';
import { buildFromSSR } from './ssr-builder';
export type { ImageMaterial, TextMaterial };
export interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
scannedAt: number;
stats: Record<string, number>; // 分组统计
warnings: string[]; // 警告(如详情图为 0
}
export type { ImageMaterial, TextMaterial };
export async function scanCurrentPage(): Promise<ScanResult | null> {
const profile = matchProfile(location.href);
if (!profile) {
console.warn('[Seller Helper] 当前页面不支持采集:', location.href);
return null;
}
console.log('[Seller Helper] 开始采集:', profile.name, location.href);
// ★ 淘宝/天猫优先从 SSR JSON 提取
const ssrData = extractSSRData();
if (ssrData) {
console.log('[Seller Helper] 使用 SSR 数据(JSON');
const result = buildFromSSR(ssrData, profile);
console.log('[Seller Helper] SSR 采集完成:', {
texts: result.texts.length,
images: result.images.length,
stats: result.stats,
warnings: result.warnings
});
return result;
}
// 降级到 DOM 采集
console.log('[Seller Helper] SSR 数据不可用,降级到 DOM 采集');
// 等待页面就绪
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) {
console.warn('[Seller Helper] 等待页面就绪超时');
// 不 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;
}
// 生成警告
const warnings: string[] = [];
if (missingRequired.length > 0) {
warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
}
if (images.length === 0) {
warnings.push('未扫描到任何图片/视频');
}
if (stats.detail === 0) {
warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
}
console.log('[Seller Helper] 采集完成:', {
texts: texts.length,
images: images.length,
stats,
warnings
});
return {
platform: profile.id,
itemId: profile.extractItemId(location.href),
url: location.href,
texts,
images,
scannedAt: Date.now(),
stats,
warnings
};
}
// 暴露到全局供 side panel 调用
if (typeof window !== 'undefined') {
(window as any).__SellerHelper = {
scan: scanCurrentPage
};
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 从 SSR JSON 构建 ScanResult
*/
import { toOriginalUrl } from './url';
import type { SiteProfile } from '../profiles/types';
import type { SSRData } from './ssr';
// 直接定义类型避免循环依赖
interface TextMaterial {
kind: 'title' | 'price' | 'params' | 'desc';
content: string;
pairs?: Array<{ key: string; value: string }>;
}
interface ImageMaterial {
key: string;
groupKey: string;
groupName: string;
variantName?: string;
url: string;
thumbUrl: string;
index: number;
type: 'img' | 'video';
width?: number;
height?: number;
}
interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
scannedAt: number;
stats: Record<string, number>;
warnings: string[];
}
export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
const texts: TextMaterial[] = [];
const images: ImageMaterial[] = [];
// 1. 标题(必需)
texts.push({
kind: 'title',
content: data.item.title
});
// 2. 价格
if (data.price?.priceText) {
texts.push({
kind: 'price',
content: `${data.price.priceText}`
});
}
// 3. 参数表
const allParams = [
...(data.params?.basicParamList || []),
...(data.params?.enhanceParamList || [])
];
if (allParams.length > 0) {
const pairs = allParams
.filter(p => p.propertyName && p.valueName)
.map(p => ({ key: p.propertyName, value: p.valueName }));
if (pairs.length > 0) {
texts.push({
kind: 'params',
content: pairs.map(p => `${p.key}: ${p.value}`).join('\n'),
pairs
});
}
}
// 4. 主图(item.images
let idx = 0;
(data.item.images || []).forEach((url, i) => {
if (!url) return;
const origUrl = toOriginalUrl(url);
images.push({
key: `main-${String(i + 1).padStart(3, '0')}`,
groupKey: 'main',
groupName: '主图',
url: origUrl,
thumbUrl: url,
index: idx++,
type: 'img'
});
});
// 5. SKU 图(skuBase.props[0].values
// 淘宝/天猫通常只有一个规格维度(颜色分类),取 props[0]
const skuProp = data.skuBase?.props?.[0];
if (skuProp?.values) {
skuProp.values.forEach((v, i) => {
if (!v.image) return; // 有些 SKU 没配图(如天猫那个 vid=43699206432
const origUrl = toOriginalUrl(v.image);
images.push({
key: `sku-${String(i + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: v.name || undefined,
url: origUrl,
thumbUrl: v.image,
index: idx++,
type: 'img'
});
});
}
// 6. 视频(item.videos
(data.item.videos || []).forEach((v, i) => {
if (!v.url) return;
images.push({
key: `video-${String(i + 1).padStart(3, '0')}`,
groupKey: 'video',
groupName: '视频',
url: v.url,
thumbUrl: v.videoThumbnailURL || v.url,
index: idx++,
type: 'video'
});
});
// 统计各组数量
const stats: Record<string, number> = {};
for (const img of images) {
stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
}
// 生成警告
const warnings: string[] = [];
if (texts.length === 0) {
warnings.push('未提取到任何文本');
}
if (images.length === 0) {
warnings.push('未扫描到任何图片/视频');
}
// SSR 数据里没有详情图,需要 DOM 补充
if (stats.detail === undefined) {
warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集');
}
return {
platform: profile.id,
itemId: data.item.itemId,
url: location.href,
texts,
images,
scannedAt: Date.now(),
stats,
warnings
};
}
+79
View File
@@ -0,0 +1,79 @@
/**
* SSR 数据提取器 - 淘宝/天猫页面内嵌 JSON
*
* 页面 HTML 里有完整商品数据挂在 window.__ICE_APP_CONTEXT__
* 包含标题、主图、SKU(图+名)、价格、参数,比 DOM 采集稳定 10 倍:
* - 不受懒加载影响
* - 不受改版影响(JSON 结构远比 CSS 类名稳定)
* - 一次拿全所有 SKU,无需滚动
*
* 当前只支持淘宝/天猫(__ICE_APP_CONTEXT__),
* 其他平台返回 null,触发 DOM 降级。
*/
export interface SSRData {
item: {
title: string;
itemId: string;
images: string[];
videos?: Array<{ url: string; videoThumbnailURL?: string }>;
};
skuBase?: {
props: Array<{
pid: string;
name: string; // "颜色分类" / "商品规格"
values: Array<{
vid: string;
name: string; // SKU 规格名
image?: string; // SKU 图片
}>;
}>;
};
params?: {
basicParamList?: Array<{ propertyName: string; valueName: string }>;
enhanceParamList?: Array<{ propertyName: string; valueName: string }>;
};
price?: {
priceText?: string;
priceMoney?: string;
};
}
/**
* 尝试从页面提取 SSR 数据(淘宝/天猫 __ICE_APP_CONTEXT__
*/
export function extractSSRData(): SSRData | null {
try {
const ctx = (window as any).__ICE_APP_CONTEXT__;
if (!ctx?.loaderData?.home?.data?.res) return null;
const res = ctx.loaderData.home.data.res;
// 基础结构验证
if (!res.item?.title || !res.item?.itemId) return null;
// 提取参数(两个来源都试)
const industryParams = res.plusViewVO?.industryParamVO;
const extensionParams = res.componentsVO?.extensionInfoVO?.infos?.find(
(i: any) => i.type === 'BASE_PROPS'
);
return {
item: {
title: res.item.title,
itemId: res.item.itemId,
images: res.item.images || [],
videos: res.item.videos
},
skuBase: res.skuBase,
params: {
basicParamList: industryParams?.basicParamList || extensionParams?.items || [],
enhanceParamList: industryParams?.enhanceParamList || []
},
price: res.componentsVO?.priceVO?.price || res.componentsVO?.priceVO?.extraPrice
};
} catch (err) {
console.warn('[SSR] 提取失败:', err);
return null;
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 文本提取 - 标题、价格、参数表、描述
* 从 docs/extension/plan.md §6.6 移植(简化版)
*/
import type { SiteProfile, TextRule } from '../profiles/types';
export interface TextMaterial {
kind: TextRule['kind'];
content: string;
pairs?: Array<{ key: string; value: string }>; // table 模式的结构化结果
}
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;
// table 模式:参数表
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;
}
// join 模式:标题被拆成多个 span
if (rule.extract === 'join') {
let text = '';
nodes.forEach(n => { text += n.textContent ?? ''; });
text = clean(text);
if (text) return { kind: rule.kind, content: text };
continue;
}
// first 模式:只取第一个
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 };
}
+66
View File
@@ -0,0 +1,66 @@
/**
* URL 工具链 - 处理阿里系 CDN 图片 URL
* 从 docs/extension/plan.md §6.3 移植(来自生产代码)
*/
const IMG_EXT = /\.(jpg|jpeg|png|gif|bmp|heic|webp|avif)$/i;
/**
* 缩略图 URL → 原图 URL
* 阿里 CDN 尺寸后缀在扩展名后:xxx.jpg_400x400.jpg → xxx.jpg
*/
export function toOriginalUrl(url: string): string {
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]?.replace(/['"]/g, '') ?? '';
}
export function isDataUrl(u: string): boolean {
return /^data:image/.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);
}
}
/** 清洗文件名非法字符(Windows 兼容) */
export function cleanFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, '_')
.substring(0, 80);
}
+115
View File
@@ -0,0 +1,115 @@
/**
* 1688 采集配置
* 从 docs/extension/plan.md §6.2 移植(选择器来自 v1.1.8 生产 bundle
*/
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-* 上(顺序不能动)
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
refererOrigin: 'https://www.1688.com',
textRules: [
{
kind: 'title',
// 标题被拆成多个 .title-text span,必须 join
selectors: ['.title-content .title-text', '.title-content h1', '.od-pc-offer-title', 'h1'],
extract: 'join',
required: true
},
{
kind: 'price',
selectors: ['.price-original', '.od-pc-offer-price-priceRange', '.price .value'],
extract: 'first'
},
{
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: 'desc',
selectors: ['.de-description-detail', '#detailContentContainer', '.html-description'],
extract: 'join'
}
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
// 四套画廊变体(说明 1688 至少有四个线上版本)
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 背景图
srcProps: ['backgroundImage'],
// 规格名(五种 DOM 结构)
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']
}
]
};
+23
View File
@@ -0,0 +1,23 @@
/**
* Profile 路由 - 根据 URL 匹配平台
*/
import type { SiteProfile } from './types';
import { profile1688 } from './1688';
import { profileTaobao } from './taobao';
const PROFILES: SiteProfile[] = [
profile1688,
profileTaobao
];
export function matchProfile(url: string): SiteProfile | null {
for (const p of PROFILES) {
if (p.urlPatterns.some(re => re.test(url))) {
return p;
}
}
return null;
}
export { profile1688, profileTaobao };
export type { SiteProfile };
+137
View File
@@ -0,0 +1,137 @@
/**
* 淘宝 / 天猫采集配置
*
* 选择器全部来自真实页面实测(2026-08-11,两个商品页各跑一轮反向探测):
* 天猫 detail.tmall.com/item.htm?id=960057430812
* 淘宝 item.taobao.com/item.htm?id=1060253247160
* 两站 DOM 完全一致(同一套前端),一份 profile 覆盖。
*
* 类名是 CSS Modules 的 `语义前缀--哈希` 形式,哈希每次构建都变,
* 所以一律用 `[class*="前缀--"]` 前缀匹配。
*
* 结尾那个 `--` 不能省——它把父容器和子元素区分开:
* `generalParamsInfoItem--` 不会误命中 `generalParamsInfoItemTitle--`。
*
* 实测证据见 docs/extension/selectors-taobao.md
*/
import type { SiteProfile } from './types';
export const profileTaobao: SiteProfile = {
id: 'taobao',
name: '淘宝/天猫',
urlPatterns: [
/^https:\/\/item\.taobao\.com\/item\.htm/,
/^https:\/\/detail\.tmall\.com\/item\.htm/,
],
extractItemId: (url) => url.match(/[?&]id=(\d+)/)?.[1] ?? null,
// 页面上没有 <h1>,别再拿它探活
readySelectors: [
'[class*="mainTitle--"]',
'[class*="picGallery--"]',
'#picGalleryEle',
],
readyTimeoutMs: 10_000,
// 阿里系 CDN 规则与 1688 相同
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
refererOrigin: 'https://www.taobao.com',
textRules: [
{
kind: 'title',
// mainTitle-- 是纯文本节点(探测里 imgs=0),最干净
// ItemTitle-- / MainTitle-- 是外层容器,带图标,作兜底
// 注意:属性选择器区分大小写,三个都得写
selectors: [
'[class*="mainTitle--"]',
'[class*="MainTitle--"]',
'[class*="ItemTitle--"]',
],
extract: 'first',
required: true,
},
{
kind: 'price',
// highlightPrice-- 是当前实际售价,两站一致
// priceWrap-- 是外层,会把"优惠前¥36.8"一起带进来,只作兜底
selectors: [
'[class*="highlightPrice--"]',
'[class*="priceWrap--"]',
],
extract: 'first',
},
{
kind: 'params',
// generalParamsInfoItem-- 每项含 Title(键) + SubTitle(值)
selectors: ['[class*="generalParamsInfoItem--"]'],
extract: 'table',
tableKeySelector: '[class*="ParamsInfoItemTitle--"]',
tableValueSelector: '[class*="ParamsInfoItemSubTitle--"]',
},
// desc 故意不采:详情容器 detailInfo-- 里混着用户评价、参数、图文详情,
// join 出来是一坨无法使用的字符串。1688/淘宝的中文文案对 Ozon 价值也低
// (见 docs/extension/1688-taobao-implementation.md 采集优先级)。
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
// picGallery-- 内含大图 + 缩略图,同一张图的两种尺寸
// toOriginalUrl() 剥掉尺寸后缀后 dedupeKey 相同,会自动去重
selectors: [
'[class*="picGallery--"] img',
'#picGalleryEle img',
'[class*="thumbnailPic--"]',
],
// 不设 minWidth:缩略图 naturalWidth 只有 60 左右,
// 按 200 过滤会把主图全误杀(原图靠 toOriginalUrl 还原)
},
{
key: 'sku',
name: 'SKU图片',
type: 'img',
// ★ 与 1688 不同:淘宝 SKU 是真实 <img>,不是 CSS 背景图
// 探测证据:valueItem-- n=22 imgs=22(每项恰含一张 img
// 所以这里不能用 srcProps: ['backgroundImage']
selectors: [
'[class*="valueItem--"]',
'[class*="valueItemImgWrap--"]',
],
nameSelectors: ['[class*="valueItemText--"]'],
minWidth: 20,
minHeight: 20,
},
{
key: 'detail',
name: '详情图',
type: 'img',
// 图文详情是懒加载的,需用户点开「图文详情」tab 或滚到底
selectors: [
'[class*="tabDetailWrap--"] img',
'[class*="detailInfo--"] img',
],
// detailInfo-- 同时包着「用户评价」区,买家晒单图能有 400-800px,
// 光靠 minWidth 滤不掉。这些图带水印、质量差,不能采
excludeWithin: [
'[class*="Comment--"]',
'[class*="comments--"]',
'[class*="userInfo--"]',
'[class*="rate"]',
],
minWidth: 300,
minHeight: 100,
},
{
key: 'video',
name: '视频',
type: 'video',
selectors: ['[class*="picGallery--"] video', 'video'],
},
],
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Site Profile - 平台采集配置(声明式)
* 从 docs/extension/plan.md §6.1 移植
*/
export type TextKind = 'title' | 'price' | 'params' | 'desc';
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video';
export type SrcProp = 'data-lazyload-src' | 'data-src' | 'currentSrc' | 'src' | 'backgroundImage';
export interface TextRule {
kind: TextKind;
selectors: string[]; // 多套选择器,逐个尝试
extract: 'join' | 'first' | 'table';
tableKeySelector?: string; // table 模式的 key 选择器
tableValueSelector?: string;
required?: boolean;
}
export interface ImageGroupRule {
key: ImageGroupKey;
name: string;
type: 'img' | 'video';
selectors: string[];
srcProps?: SrcProp[]; // 覆盖 defaultSrcProps
nameSelectors?: string[]; // SKU 规格名来源
activeSelectors?: string[]; // 画廊"当前高亮"元素(排除)
/** 位于这些容器内的图片一律跳过(用 el.closest 判断)。
* 典型用途:详情区里混着的用户评价晒单图 */
excludeWithin?: string[];
minWidth?: number;
minHeight?: number;
}
export interface SiteProfile {
id: string;
name: string;
urlPatterns: RegExp[];
extractItemId: (url: string) => string | null;
readySelectors: string[]; // 等待这些元素出现
readyTimeoutMs?: number;
defaultSrcProps: SrcProp[];
textRules: TextRule[];
imageGroups: ImageGroupRule[];
originalUrlRules?: Array<{ match: RegExp; replace: string }>;
refererOrigin?: string; // 图片防盗链需要的 Referer
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Product JSON - 商品文件夹契约(TS 侧)
* 对应 server/schemas/product.pyPydantic 为真源)
* 详见 docs/contracts/product-json.md
*/
export type Stage = 'collected' | 'edited' | 'published';
export interface ProductJson {
_meta: {
schemaVersion: 1;
stage: Stage;
createdAt: string; // ISO 8601
updatedAt: string;
};
// Ozon 字段(对齐 ImportProductsV3
offer_id: string;
name: string;
description: string;
description_category_id: number | null;
price: string;
old_price?: string;
currency_code: 'RUB' | 'CNY';
vat: string;
depth: number | null;
width: number | null;
height: number | null;
dimension_unit: 'mm' | 'cm';
weight: number | null;
weight_unit: 'g' | 'kg';
images: string[]; // 发布时填公网 URL
primary_image?: string;
color_image?: string;
attributes: any[]; // 工作台映射后才填
complex_attributes?: any[];
// 本地扩展字段(下划线前缀)
_images: {
main: ImageMeta[];
sku: ImageMeta[];
detail: ImageMeta[];
video: ImageMeta[];
};
_raw: {
title: string;
price: string;
params?: Array<{ key: string; value: string }>;
desc?: string;
};
_pricing?: any; // 工作台计价结果
}
export interface ImageMeta {
file: string; // 相对路径:images/main/main-001.jpg
sourceUrl: string; // 源站 URL(可能失效)
variantName?: string; // SKU 规格名
w?: number;
h?: number;
}
export interface SourcesJson {
sources: Array<{
platform: '1688' | 'taobao' | 'ozon';
itemId: string | null;
url: string;
collectedAt: string; // ISO 8601
counts: Record<string, number>;
}>;
dedupeKeys: string[]; // URL 去重指纹
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"jsx": "react-jsx",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"types": ["chrome"]
},
"include": ["entrypoints", "src", "components"],
"exclude": ["node_modules", ".output"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
name: 'Seller Helper - 1688/淘宝采集',
description: '采集 1688/淘宝商品信息和图片到本地文件夹',
permissions: [
'storage',
'sidePanel',
'activeTab',
'scripting' // 执行 content script 函数需要
],
host_permissions: [
'https://detail.1688.com/*',
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
'https://*.alicdn.com/*' // 阿里 CDN
],
action: {
default_title: 'Seller Helper'
}
},
modules: ['react']
});