feat: 开发采集插件
This commit is contained in:
@@ -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 插件逆向分析)
|
||||
@@ -0,0 +1,23 @@
|
||||
// Background Service Worker - 唯一出网口(绕 CORS 取图)
|
||||
export default defineBackground(() => {
|
||||
console.log('Seller Helper background started');
|
||||
|
||||
// 点击扩展图标 → 打开 Side Panel
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||
|
||||
// 代理图片 fetch(CORS 绕过)
|
||||
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();
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+3553
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Product JSON - 商品文件夹契约(TS 侧)
|
||||
* 对应 server/schemas/product.py(Pydantic 为真源)
|
||||
* 详见 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 去重指纹
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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']
|
||||
});
|
||||
Reference in New Issue
Block a user