feat: 初始化项目,并且接近完成 ozon 部分

This commit is contained in:
Joey
2026-08-15 22:19:27 +08:00
commit 1591d5e35a
46 changed files with 9827 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import { generateSuite, getSuite, planSuite } from '../src/api/client';
// Background Service Worker —— 唯一出网口(生成 / 规划 / 轮询任务,绕 CORS)
export default defineBackground(() => {
console.log('[电商套图工作台] background started');
// 点击扩展图标 → 打开 Side Panel
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.action === 'generateSuite') {
generateSuite(msg.baseUrl, msg.token, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
if (msg?.action === 'planSuite') {
planSuite(msg.baseUrl, msg.token, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
if (msg?.action === 'getSuite') {
getSuite(msg.baseUrl, msg.token, msg.suiteId)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
return false;
});
});
+26
View File
@@ -0,0 +1,26 @@
// Content Script —— 注入四个平台的商品页,暴露采集入口
import { scanCurrentPage } from '../../src/collector/scan';
export default defineContentScript({
matches: [
// Ozon
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
// 1688
'https://detail.1688.com/*',
// 淘宝 / 天猫
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
],
main() {
console.log('[电商套图工作台] Content script loaded');
// 暴露采集入口到全局(供 side panel 调用 / console 调试)
(window as any).__SuiteCollector = {
scan: scanCurrentPage,
};
console.log('[电商套图工作台] 就绪。Console 可测: await window.__SuiteCollector.scan()');
},
});
+598
View File
@@ -0,0 +1,598 @@
/**
* 电商套图工作台 - Side Panel
*
* 布局:顶栏 + 目标平台
* ├─ 左 01 商品信息 | 右 02 采集图片(两列等高,采集阶段)
* ├─ 03 出图方案(整行:方案列表 + AI 智能规划 + 风格 + 一键生成)
* └─ 04 生成结果(整行)
*
* 流程:采集页面 → 编辑/勾选 → AI 规划或用默认方案 → 一键生成 → 导出 ZIP
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { App as AntApp, ConfigProvider, Popover, Progress } from 'antd';
import { SettingOutlined, DownloadOutlined, ThunderboltOutlined } from '@ant-design/icons';
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
import {
buildGeneratePayload, suiteZipUrl,
DEFAULT_PLAN, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS,
type PlanItem, type SuiteInfo,
} from '../../src/api/client';
import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings';
/** 数据来源 → 展示含义 */
const SOURCE_LABELS: Record<string, string> = {
state: '页面数据',
ssr: '页面数据',
jsonld: '结构化数据',
api: '站内接口',
dom: 'DOM解析',
mixed: '混合来源',
};
/** 平台 → 展示名 */
const PLATFORM_LABELS: Record<string, string> = {
ozon: 'Ozon',
'1688': '1688',
taobao: '淘宝/天猫',
};
/** 在当前活动 tab 执行采集(content script 已把入口挂到 window */
async function scanActiveTab(): Promise<ScanResult> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) throw new Error('未找到活动标签页');
const [res] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => (window as any).__SuiteCollector?.scan?.() ?? null,
});
const r = (res?.result ?? null) as ScanResult | null;
if (!r) throw new Error('采集失败:页面不支持或内容脚本未就绪,请刷新页面后重试');
return r;
}
function send<T>(action: string, payload: Record<string, unknown>): Promise<T> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, ...payload }, (res: { ok: boolean; data?: T; error?: string }) => {
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
if (!res?.ok) return reject(new Error(res?.error || '后台请求失败'));
resolve(res.data as T);
});
});
}
// ── 小组件 ─────────────────────────────────────────────────────────────────
const Section: React.FC<{ no: string; title: string; extra?: React.ReactNode; children: React.ReactNode }> =
({ no, title, extra, children }) => (
<div className="section">
<div className="section-head">
<span className="section-no">{no}</span>
<span className="section-title">{title}</span>
{extra && <span className="section-extra">{extra}</span>}
</div>
{children}
</div>
);
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div className="field">
<label>{label}</label>
{children}
</div>
);
/** 数量加减器 */
const Stepper: React.FC<{ value: number; onChange: (v: number) => void }> = ({ value, onChange }) => (
<span className="stepper">
<button className="step-btn" disabled={value <= 0} onClick={() => onChange(Math.max(0, value - 1))}></button>
<span className="step-num">{value}</span>
<button className="step-btn" disabled={value >= 5} onClick={() => onChange(Math.min(5, value + 1))}></button>
</span>
);
const App: React.FC = () => {
const { modal } = AntApp.useApp();
// 采集
const [scanning, setScanning] = useState(false);
const [result, setResult] = useState<ScanResult | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [titleEdit, setTitleEdit] = useState('');
const [descEdit, setDescEdit] = useState('');
const [paramsOpen, setParamsOpen] = useState(false);
// 服务端
const [settings, setSettings] = useState<BackendSettings>({ baseUrl: 'http://127.0.0.1:3300', token: '' });
// 出图方案
const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('cn');
const [styleSet, setStyleSet] = useState(1);
const [plan, setPlan] = useState<PlanItem[]>(DEFAULT_PLAN.map(p => ({ ...p })));
const [planSource, setPlanSource] = useState<'default' | 'ai'>('default');
const [planSummary, setPlanSummary] = useState('');
const [planning, setPlanning] = useState(false);
// 生成
const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 图片放大预览
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
loadSettings().then(setSettings);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, []);
const price = result?.texts.find(t => t.kind === 'price')?.content ?? '';
const brand = result?.texts.find(t => t.kind === 'brand')?.content ?? '';
const paramPairs = result?.texts.find(t => t.kind === 'params')?.pairs ?? [];
const handleScan = async () => {
setScanning(true);
try {
const r = await scanActiveTab();
setResult(r);
setSuite(null);
setParamsOpen(false);
// 默认全选主图 + SKU 图(SKU 图带规格名,AI 规划的 variant 绑定要用)
const keys = new Set(r.images.filter(i => i.groupKey === 'main' || i.groupKey === 'sku').map(i => i.key));
setSelectedKeys(keys);
setTitleEdit(r.texts.find(t => t.kind === 'title')?.content ?? '');
setDescEdit(r.texts.find(t => t.kind === 'desc')?.content ?? '');
} catch (e) {
modal.error({ title: '采集失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
} finally {
setScanning(false);
}
};
const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
const pollSuite = useCallback((suiteId: string) => {
stopPolling();
pollRef.current = setInterval(async () => {
try {
const s = await send<SuiteInfo>('getSuite', { baseUrl: settings.baseUrl, token: settings.token, suiteId });
setSuite(s);
if (['done', 'partial', 'failed'].includes(s.status)) {
stopPolling();
setGenerating(false);
if (s.status === 'partial') modal.warning({ title: '部分生成失败', content: '可重试或更换风格重新生成', okText: '知道了' });
if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' });
}
} catch (e) {
stopPolling();
setGenerating(false);
}
}, 3000);
}, [settings.baseUrl, settings.token, modal]);
/** 当前编辑后的文本素材(规划与生成共用) */
const editedTexts = () => {
if (!result) return [];
const orig = (kind: string) => result.texts.find(t => t.kind === kind);
const texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }> = [];
const title = titleEdit || orig('title')?.content || '';
const desc = descEdit || orig('desc')?.content || '';
if (title) texts.push({ kind: 'title', content: title });
if (orig('price')?.content) texts.push({ kind: 'price', content: orig('price')!.content });
if (orig('brand')?.content) texts.push({ kind: 'brand', content: orig('brand')!.content });
if ((orig('params')?.pairs ?? []).length) texts.push({ kind: 'params', content: '', pairs: orig('params')!.pairs });
if (orig('selling_point')?.content) texts.push({ kind: 'selling_point', content: orig('selling_point')!.content });
if (desc) texts.push({ kind: 'desc', content: desc });
return texts;
};
/** AI 智能规划出图方案 */
const handlePlan = async () => {
if (!result) return modal.warning({ title: '请先采集商品页' });
setPlanning(true);
try {
const skuVariants = Array.from(new Set(
result.images.filter(i => i.groupKey === 'sku' && i.variantName).map(i => i.variantName!)
));
const data = await send<{ summary: string; items: PlanItem[] }>('planSuite', {
baseUrl: settings.baseUrl, token: settings.token,
payload: {
texts: editedTexts(),
sku_variants: skuVariants,
image_stats: result.stats,
platform,
},
});
setPlan(data.items);
setPlanSource('ai');
setPlanSummary(data.summary);
const total = data.items.reduce((s, i) => s + i.count, 0);
modal.info({
title: 'AI 方案已生成',
content: `${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`,
okText: '好的',
});
} catch (e) {
modal.error({ title: '规划失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
} finally {
setPlanning(false);
}
};
const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
const doneCount = suite?.images.filter(i => i.status === 'ok').length ?? 0;
const suiteTotal = suite?.images.length ?? totalPlanned;
const handleGenerate = () => {
if (!result) return;
if (selectedKeys.size === 0) return modal.warning({ title: '请先在采集图片区勾选参考图' });
if (totalPlanned === 0) return modal.warning({ title: '出图方案的张数都是 0' });
const spec = PLATFORM_SPECS[platform];
modal.confirm({
title: '生成电商套图',
content: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),风格「${STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 ${totalPlanned} 张、参考图 ${selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。`,
okText: '开始生成', cancelText: '取消',
onOk: async () => {
setGenerating(true);
setSuite(null);
try {
const payload = buildGeneratePayload(
result, selectedKeys,
{ title: titleEdit, desc: descEdit },
{ style_set: styleSet, plan: plan.filter(p => p.count > 0), platform },
);
const { suite_id } = await send<{ suite_id: string }>('generateSuite', {
baseUrl: settings.baseUrl, token: settings.token, payload,
});
pollSuite(suite_id);
} catch (e) {
setGenerating(false);
modal.error({ title: '提交失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
}
},
});
};
const handleExport = () => {
if (!suite) return;
chrome.tabs.create({ url: suiteZipUrl(settings.baseUrl, suite.id) });
};
const toggleKey = (key: string) => {
const next = new Set(selectedKeys);
if (next.has(key)) next.delete(key); else next.add(key);
setSelectedKeys(next);
};
const groupImages = (groupKey: string): ImageMaterial[] =>
result?.images.filter(i => i.groupKey === groupKey) ?? [];
const toggleGroup = (groupKey: string, on: boolean) => {
const next = new Set(selectedKeys);
groupImages(groupKey).forEach(i => on ? next.add(i.key) : next.delete(i.key));
setSelectedKeys(next);
};
const setPlanCount = (idx: number, count: number) => {
setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count } : p));
};
/** 源站图防盗链时的兜底:走服务端图片代理 */
const proxied = (u: string) =>
`${settings.baseUrl.replace(/\/$/, '')}/api/proxy-image?url=${encodeURIComponent(u)}`;
/** 缩略图三级降级:thumbUrl → 原图 → 服务端代理 */
const onThumbError = (e: React.SyntheticEvent<HTMLImageElement>, url: string) => {
const el = e.currentTarget;
if (el.dataset.step === '1') { el.dataset.step = '2'; el.src = url; }
else if (el.dataset.step === '2') { el.dataset.step = '3'; el.src = proxied(url); }
};
const settingsPopup = (
<div style={{ width: 260 }}>
<Field label="后端地址">
<input
value={settings.baseUrl}
onChange={(e) => setSettings({ ...settings, baseUrl: e.target.value })}
onBlur={() => saveSettings(settings)}
/>
</Field>
<Field label="Token(可选)">
<input
value={settings.token}
onChange={(e) => setSettings({ ...settings, token: e.target.value })}
onBlur={() => saveSettings(settings)}
/>
</Field>
</div>
);
return (
<div className="page">
{/* ── 顶栏 ── */}
<div className="topbar">
<div className="logo"></div>
<div>
<h1></h1>
<div className="sub"> · · </div>
</div>
<div className="spacer" />
<Popover content={settingsPopup} title="服务端设置" trigger="click" placement="bottomRight">
<button className="icon-btn" title="服务端设置"><SettingOutlined /></button>
</Popover>
<button className="btn btn-primary" disabled={scanning} onClick={handleScan}>
{scanning ? '采集中…' : '快速采集'}
</button>
</div>
{/* ── 目标平台切换(决定文案语言 + 图片比例)── */}
<div className="platform-bar">
<span className="platform-label"></span>
<div className="pills">
{PLATFORM_OPTIONS.map(p => (
<span
key={p.value}
className={`pill ${platform === p.value ? 'on' : ''}`}
onClick={() => setPlatform(p.value)}
>{p.label}</span>
))}
</div>
<span className="platform-spec">
{PLATFORM_SPECS[platform].lang} · {PLATFORM_SPECS[platform].ratio}
</span>
</div>
{/* ── 采集区:左信息 / 右图片,两列等高 ── */}
<div className="two-col">
<Section
no="01"
title="商品信息"
extra={result
? `${PLATFORM_LABELS[result.platform] ?? result.platform} · ${SOURCE_LABELS[result.source] ?? result.source}`
: undefined}
>
{!result ? (
<div className="empty"></div>
) : (
<>
<Field label="标题">
<input value={titleEdit} onChange={(e) => setTitleEdit(e.target.value)} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<Field label="价格">
<input value={price} readOnly style={{ color: 'var(--text-2)' }} />
</Field>
</div>
<div style={{ flex: 1 }}>
<Field label="品牌">
<input value={brand} readOnly style={{ color: 'var(--text-2)' }} />
</Field>
</div>
</div>
{paramPairs.length > 0 && (
<div className="field">
<label style={{ cursor: 'pointer' }} onClick={() => setParamsOpen(v => !v)}>
/ {paramPairs.length} {paramsOpen ? ' ▴' : ' ▾'}
</label>
{paramsOpen && (
<table className="kv-table">
<tbody>
{paramPairs.slice(0, 30).map((p, i) => (
<tr key={i}><td className="k">{p.key}</td><td>{p.value}</td></tr>
))}
</tbody>
</table>
)}
</div>
)}
<Field label="商品描述(用于生成图内文案)">
<textarea rows={6} value={descEdit} onChange={(e) => setDescEdit(e.target.value)} />
</Field>
{result.warnings.length > 0 && (
<div className="warn-box">{result.warnings.map((w, i) => <div key={i}>{w}</div>)}</div>
)}
</>
)}
</Section>
<Section
no="02"
title="采集图片"
extra={result ? `已选 ${selectedKeys.size} / ${result.images.length}` : undefined}
>
{!result ? (
<div className="empty"></div>
) : (
<div className="img-groups">
{['main', 'sku', 'detail'].map(g => groupImages(g).length > 0 && (
<div key={g} style={{ marginBottom: 10 }}>
<div className="group-head">
<span className="name">
{g === 'main' ? '主图' : g === 'sku' ? 'SKU图片' : '详情图'}
</span>
<span className="count">{groupImages(g).length}</span>
<span
className="mini-check"
onClick={() => {
const all = groupImages(g).every(i => selectedKeys.has(i.key));
toggleGroup(g, !all);
}}
>
{groupImages(g).every(i => selectedKeys.has(i.key)) ? '取消全选' : '全选'}
</span>
</div>
<div className="img-grid">
{groupImages(g).map(img => {
const on = selectedKeys.has(img.key);
return (
<div key={img.key} className={`img-cell ${on ? 'on' : ''}`}
title="点击放大预览,勾选圆点选择图片"
onClick={() => setPreviewUrl(img.url)}>
<img
src={img.thumbUrl || img.url}
referrerPolicy="no-referrer"
data-step="1"
onError={(e) => onThumbError(e, img.url)}
/>
<span
className="tick"
onClick={(e) => { e.stopPropagation(); toggleKey(img.key); }}
></span>
{img.variantName && <span className="variant">{img.variantName}</span>}
</div>
);
})}
</div>
</div>
))}
</div>
)}
</Section>
</div>
{/* ── 出图方案(整行)── */}
<Section
no="03"
title="出图方案"
extra={
<span>
{planSource === 'ai' ? <span className="ai-tag">AI </span> : '默认方案'}
{' '} <b style={{ color: 'var(--primary)' }}>{totalPlanned}</b>
</span>
}
>
<div className="plan-list">
{plan.map((p, idx) => (
<div key={idx} className={`plan-row ${p.count === 0 ? 'off' : ''}`}>
<div className="plan-main">
<span className="plan-title">{p.title}</span>
{p.variant_name && <span className="variant-chip">{p.variant_name}</span>}
{p.detail && <span className="plan-detail" title={p.detail}>{p.detail}</span>}
</div>
<Stepper value={p.count} onChange={(v) => setPlanCount(idx, v)} />
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '10px 0' }}>
<button className="btn" disabled={!result || planning} onClick={handlePlan}>
<ThunderboltOutlined /> {planning ? '规划中…' : 'AI 智能规划'}
</button>
{planSource === 'ai' && (
<button
className="btn btn-sm"
onClick={() => { setPlan(DEFAULT_PLAN.map(p => ({ ...p }))); setPlanSource('default'); setPlanSummary(''); }}
></button>
)}
<span className="hint" style={{ flex: 1 }}>
{planSummary || '方案与张数由规划器根据商品信息自动决定,可手动微调,0 即不生成'}
</span>
</div>
<div className="divider" />
<div className="field">
<label></label>
<div className="pills">
{STYLE_SET_OPTIONS.map(s => (
<span
key={s.value}
className={`pill ${styleSet === s.value ? 'on' : ''}`}
onClick={() => setStyleSet(s.value)}
>{s.label}</span>
))}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
<button
className="btn btn-primary" disabled={!result || generating || selectedKeys.size === 0 || totalPlanned === 0}
onClick={handleGenerate}
>
{generating ? '生成中…' : `一键生成(${totalPlanned} 张)`}
</button>
{generating && (
<div style={{ flex: 1 }}>
<Progress
percent={suiteTotal ? Math.round(doneCount / suiteTotal * 100) : 0}
size="small" status="active" format={() => `${doneCount}/${suiteTotal}`}
/>
</div>
)}
</div>
</Section>
{/* ── 生成结果(整行)── */}
<Section
no="04"
title="生成结果"
extra={
suite && ['done', 'partial'].includes(suite.status) && (
<button className="btn btn-sm" onClick={handleExport}>
<DownloadOutlined /> ZIP
</button>
)
}
>
{!suite ? (
<div className="empty"></div>
) : (
<>
<div style={{ marginBottom: 8 }} className="hint">
<b style={{ color: suite.status === 'done' ? 'var(--green)' : suite.status === 'failed' ? 'var(--red)' : 'var(--primary)' }}>
{suite.status === 'running' ? '生成中' : suite.status === 'done' ? '完成' : suite.status === 'partial' ? '部分失败' : suite.status === 'pending' ? '排队中' : '失败'}
</b>
{' '}· {PLATFORM_SPECS[suite.platform]?.label ?? suite.platform}
{' '}· {PLATFORM_SPECS[suite.platform]?.lang ?? suite.lang} · {suite.ratio}
{' '}· {STYLE_SET_OPTIONS.find(s => s.value === suite.style_set)?.label}
</div>
<div className="result-grid">
{suite.images.map(img => (
<div
key={img.type_id + img.name}
className={`result-cell ${img.status !== 'ok' ? 'fail' : ''}`}
title={img.error || img.name}
style={{ cursor: img.status === 'ok' ? 'zoom-in' : 'default' }}
onClick={() => img.status === 'ok' && setPreviewUrl(img.url)}
>
{img.status === 'ok' ? (
<img src={img.url} referrerPolicy="no-referrer" />
) : (
<div style={{ aspectRatio: suite.ratio === '3:4' ? '3/4' : '1', background: 'var(--card-soft)' }} />
)}
{img.status !== 'ok' && <span className="fail-tag">{img.status === 'failed' ? '✗' : '…'}</span>}
<div className="cap">{img.name}</div>
</div>
))}
</div>
{suite.error && <div className="warn-box">{suite.error}</div>}
</>
)}
</Section>
{/* ── 图片放大预览 ── */}
{previewUrl && (
<div className="lightbox" onClick={() => setPreviewUrl(null)}>
<img src={previewUrl} referrerPolicy="no-referrer" onClick={(e) => e.stopPropagation()} />
<span className="lightbox-tip"></span>
</div>
)}
</div>
);
};
const Root: React.FC = () => (
<ConfigProvider
theme={{
token: {
colorPrimary: '#8b5cf6',
colorLink: '#8b5cf6',
borderRadius: 8,
fontFamily: "Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif",
},
}}
>
<AntApp>
<App />
</AntApp>
</ConfigProvider>
);
createRoot(document.getElementById('root')!).render(<Root />);
+255
View File
@@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>电商套图工作台</title>
<style>
:root {
--bg: #f5f5f5; /* 页面背景(中性灰) */
--card: #ffffff;
--card-soft: #fafafa;
--border: #f0f0f0;
--border-strong: #e0e0e0;
--primary: #8b5cf6; /* 紫(ozon-seller-kit v2 主题色) */
--primary-hover: #7c3aed;
--primary-ring: rgba(139, 92, 246, 0.12);
--green: #52c41a;
--red: #ff4d4f;
--warn-bg: #fffbe6;
--warn-border: #ffe58f;
--warn-text: #8c6d1f;
--text: #262626;
--text-2: #8c8c8c;
}
html, body, #root {
min-width: 860px;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 14px;
}
* { box-sizing: border-box; }
/* ── 页面骨架 ── */
.page { padding: 16px 18px 22px; }
/* 采集区两列等高:左右卡片拉伸到同一高度 */
.two-col { display: flex; gap: 14px; align-items: stretch; margin-bottom: 14px; }
.two-col .section { flex: 1; min-width: 0; margin-bottom: 0; display: flex; flex-direction: column; }
.two-col .section .section-head { flex-shrink: 0; }
.img-groups { flex: 1; overflow-y: auto; max-height: 560px; }
.divider { border-top: 1px solid var(--border); margin: 12px 0; }
/* ── 顶部 ── */
.topbar {
display: flex; align-items: center; gap: 11px;
padding-bottom: 14px; margin-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.logo {
width: 38px; height: 38px; border-radius: 9px;
background: var(--primary); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 19px; font-weight: 700;
}
.topbar h1 { font-size: 17px; margin: 0; font-weight: 700; }
.topbar .sub { font-size: 12px; color: var(--text-2); margin-top: 1px; }
.topbar .spacer { flex: 1; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border-strong);
font-size: 14px; cursor: pointer; user-select: none;
background: #fff; color: var(--text);
transition: all .15s;
}
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn-primary {
background: var(--primary); border-color: var(--primary); color: #fff;
font-weight: 600;
}
.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
.btn[disabled] { opacity: .5; cursor: not-allowed; }
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
.icon-btn {
width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border-strong);
background: #fff; cursor: pointer; color: var(--text-2);
display: inline-flex; align-items: center; justify-content: center;
}
.icon-btn:hover { color: var(--primary); border-color: var(--primary); }
/* ── 编号步骤卡片 ── */
.section {
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 14px 16px; margin-bottom: 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.section-head { display: flex; align-items: baseline; gap: 9px; margin-bottom: 12px; }
.section-no {
font-size: 20px; font-weight: 800; color: var(--primary);
font-variant-numeric: tabular-nums; line-height: 1;
}
.section-title { font-size: 15px; font-weight: 700; }
.section-extra { margin-left: auto; font-size: 12px; color: var(--text-2); }
/* ── 字段 ── */
.field { margin-bottom: 10px; }
.field label { display: block; font-size: 12.5px; color: var(--text-2); margin-bottom: 4px; }
.field input, .field textarea {
width: 100%; padding: 8px 11px; border: 1px solid var(--border-strong);
border-radius: 6px; font-size: 14px; font-family: inherit; line-height: 1.5;
background: var(--card-soft); color: var(--text); outline: none;
transition: border-color .15s, box-shadow .15s;
}
.field input:focus, .field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-ring);
background: #fff;
}
.kv-table {
width: 100%; border-collapse: collapse; font-size: 13px;
background: var(--card-soft); border-radius: 6px; overflow: hidden;
}
.kv-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
.kv-table tr:last-child td { border-bottom: none; }
.kv-table td.k { color: var(--text-2); white-space: nowrap; width: 1%; padding-right: 16px; }
/* ── 药丸选择 ── */
.pills { display: flex; flex-wrap: wrap; gap: 7px; }
.pill {
padding: 5px 13px; border-radius: 999px; border: 1px solid var(--border-strong);
background: #fff; font-size: 13px; cursor: pointer; color: var(--text-2);
user-select: none; transition: all .15s; line-height: 1.6;
}
.pill:hover { border-color: var(--primary); color: var(--primary); }
.pill.on {
background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600;
}
/* ── 图片网格 ── */
.group-head { display: flex; align-items: center; gap: 8px; margin: 4px 0 9px; }
.group-head .name { font-size: 13px; font-weight: 600; color: var(--text-2); }
.group-head .count {
font-size: 12px; color: var(--text-2); background: var(--card-soft);
border: 1px solid var(--border); border-radius: 999px; padding: 0 8px;
}
.group-head .mini-check { margin-left: auto; font-size: 12px; color: var(--primary); cursor: pointer; user-select: none; }
.group-head .mini-check:hover { text-decoration: underline; }
.img-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; }
.img-cell {
position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden;
border: 2px solid transparent; cursor: zoom-in; background: var(--card-soft);
}
.img-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.img-cell.on { border-color: var(--primary); }
.img-cell .tick {
position: absolute; top: 5px; left: 5px; width: 18px; height: 18px;
border-radius: 50%; border: 1.5px solid #fff;
background: rgba(255, 255, 255, 0.55); display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 11px; transition: all .15s; cursor: pointer;
}
.img-cell.on .tick { background: var(--primary); border-color: var(--primary); }
.img-cell .variant {
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 11px;
padding: 1px 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── 生成结果(整行,6 列)── */
.result-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.result-cell { position: relative; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
.result-cell img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.result-cell .cap {
font-size: 11.5px; text-align: center; padding: 3px 0;
background: var(--card-soft); color: var(--text-2);
border-top: 1px solid var(--border); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.result-cell.fail { opacity: .55; }
.result-cell .fail-tag {
position: absolute; top: 5px; right: 5px; font-size: 11px;
background: var(--red); color: #fff; border-radius: 4px; padding: 0 5px;
}
/* ── 目标平台切换条 ── */
.platform-bar {
display: flex; align-items: center; gap: 10px;
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 16px; margin-bottom: 14px;
}
.platform-label { font-size: 13px; font-weight: 700; }
.platform-spec { margin-left: auto; font-size: 12.5px; color: var(--text-2); }
/* 三个平台药丸等宽:选中态加粗会让文字变宽,用固定 min-width 消除抖动 */
.platform-bar .pill { min-width: 108px; text-align: center; }
/* ── 图片放大预览 ── */
.lightbox {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.82);
display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 12px; cursor: zoom-out;
}
.lightbox img {
max-width: 92%; max-height: 86%;
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.lightbox-tip { color: rgba(255, 255, 255, 0.75); font-size: 12.5px; }
/* ── 出图方案 ── */
.plan-list { display: flex; flex-direction: column; gap: 4px; }
.plan-row {
display: flex; align-items: center; gap: 10px;
padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px;
background: var(--card-soft);
}
.plan-row.off { opacity: .45; }
.plan-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
.plan-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.variant-chip {
flex-shrink: 0; font-size: 11.5px; padding: 0 8px; line-height: 1.8;
border-radius: 999px; background: #f3efff; border: 1px solid #ddd3fa; color: #6d28d9;
}
.plan-detail {
font-size: 12px; color: var(--text-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stepper { display: inline-flex; align-items: center; gap: 0; flex-shrink: 0; }
.step-btn {
width: 24px; height: 24px; border: 1px solid var(--border-strong); background: #fff;
border-radius: 5px; cursor: pointer; font-size: 14px; line-height: 1; color: var(--text);
display: inline-flex; align-items: center; justify-content: center;
}
.step-btn:hover:not([disabled]) { border-color: var(--primary); color: var(--primary); }
.step-btn[disabled] { opacity: .35; cursor: not-allowed; }
.step-num {
min-width: 28px; text-align: center; font-size: 13.5px; font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ai-tag {
background: var(--primary); color: #fff; font-size: 11px;
border-radius: 4px; padding: 1px 6px; margin-right: 4px;
}
.hint { font-size: 12.5px; color: var(--text-2); line-height: 1.6; }
.ok-chip {
display: inline-flex; align-items: center; gap: 5px;
background: #f6ffed; border: 1px solid #b7eb8f; color: #389e0d;
border-radius: 6px; padding: 4px 9px; font-size: 12.5px;
}
.warn-box {
background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text);
border-radius: 6px; padding: 7px 10px; font-size: 12.5px; margin-top: 6px; line-height: 1.6;
}
.empty {
text-align: center; color: var(--text-2); font-size: 13px;
padding: 22px 0; background: var(--card-soft); border-radius: 6px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "suite-collector-extension",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "wxt",
"build": "wxt build",
"zip": "wxt zip"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"antd": "^6.6.0",
"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"
}
}
+4411
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
allowBuilds:
esbuild: set this to true or false
spawn-sync: set this to true or false
onlyBuiltDependencies:
- esbuild
- spawn-sync
+262
View File
@@ -0,0 +1,262 @@
/**
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
* 契约对齐 server 端 /api/materials 与 /api/suites。
*/
import type { ScanResult } from '../collector/scan';
export interface MaterialsPayload {
product_id: string | null;
source: {
platform: string;
itemId: string | null;
url: string;
collectedAt: number;
};
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
images: Array<{
groupKey: string;
groupName: string;
variantName?: string | null;
url: string;
index: number;
type: string;
dedupeKey?: string | null;
}>;
refererOrigin?: string;
}
/** 服务端支持的套图类型(与 server/services/prompt.py 保持一致) */
export const SUITE_TYPE_OPTIONS = [
{ value: 'white_bg', label: '白底主图' },
{ value: 'key_features', label: '核心卖点图' },
{ value: 'selling_pt', label: '卖点图' },
{ value: 'material', label: '材质图' },
{ value: 'lifestyle', label: '场景展示图' },
{ value: 'multi_scene', label: '多场景拼图' },
{ value: 'ecommerce_detail', label: '电商详情图' },
{ value: 'size_chart', label: '尺寸标注图' },
{ value: 'sku_collection', label: 'SKU合集图' },
{ value: 'custom', label: '创意图' },
] as const;
/** 出图方案项:一类图 × 数量,可绑定 SKU 规格 */
export interface PlanItem {
kind: string;
title: string;
detail: string;
prompt_hint: string;
count: number;
variant_name?: string | null;
}
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map(t => ({
kind: t.value, title: t.label, detail: '', prompt_hint: '', count: 1, variant_name: null,
}));
export const STYLE_SET_OPTIONS = [
{ value: 1, label: '经典商拍' },
{ value: 2, label: '生活杂志' },
{ value: 3, label: '极简高冷' },
{ value: 4, label: '活力爆款' },
{ value: 5, label: '暗调质感' },
] as const;
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
export const PLATFORM_OPTIONS = [
{ value: 'ozon', label: 'Ozon' },
{ value: 'wb', label: 'Wildberries' },
{ value: 'cn', label: '中文' },
] as const;
export type PlatformId = (typeof PLATFORM_OPTIONS)[number]['value'];
export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = {
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
wb: { lang: '俄文', ratio: '3:4', label: 'Wildberries' },
cn: { lang: '中文', ratio: '1:1', label: '中文' },
};
export interface SuiteImageInfo {
type_id: string;
name: string;
url: string;
status: string;
error?: string | null;
}
export interface SuiteInfo {
id: string;
product_id: string;
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
style_set: number;
platform: string;
lang: string;
ratio: string;
types: string[];
provider: string;
images: SuiteImageInfo[];
error?: string | null;
}
/** 用(可能已二次修改的)文本 + 已勾选图片,组装 /api/materials 请求体 */
export function buildMaterialsPayload(
result: ScanResult,
selectedKeys: Set<string>,
edits?: { title?: string; desc?: string },
): MaterialsPayload {
const orig = (kind: string) => result.texts.find((t) => t.kind === kind);
const texts: MaterialsPayload['texts'] = [];
const title = edits?.title ?? orig('title')?.content ?? '';
const price = orig('price')?.content ?? '';
const brand = orig('brand')?.content ?? '';
const params = orig('params')?.pairs ?? [];
const sellingPoints = orig('selling_point')?.content ?? '';
const desc = edits?.desc ?? orig('desc')?.content ?? '';
if (title) texts.push({ kind: 'title', content: title });
if (price) texts.push({ kind: 'price', content: price });
if (brand) texts.push({ kind: 'brand', content: brand });
if (params.length) texts.push({ kind: 'params', content: '', pairs: params });
if (sellingPoints) texts.push({ kind: 'selling_point', content: sellingPoints });
if (desc) texts.push({ kind: 'desc', content: desc });
const images = result.images
.filter((img) => selectedKeys.has(img.key))
.map((img) => ({
groupKey: img.groupKey,
groupName: img.groupName,
variantName: img.variantName ?? null,
url: img.url,
index: img.index,
type: img.type,
dedupeKey: img.url,
}));
return {
product_id: null,
source: {
platform: result.platform,
itemId: result.itemId,
url: result.url,
collectedAt: result.scannedAt,
},
texts,
images,
refererOrigin: undefined,
};
}
function authHeaders(token: string): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function uploadMaterials(
baseUrl: string,
token: string,
payload: MaterialsPayload,
): Promise<{ product_id: string; assets_queued: number; assets_skipped: number }> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
return data;
}
/** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */
export interface GeneratePayload {
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
images: Array<{ url: string; group_key: string; variant_name?: string | null }>;
style_set: number;
plan: PlanItem[];
platform: string;
}
/** 组装无状态生成请求:已编辑的文本 + 已勾选图片 + 出图方案 */
export function buildGeneratePayload(
result: ScanResult,
selectedKeys: Set<string>,
edits: { title?: string; desc?: string },
config: { style_set: number; plan: PlanItem[]; platform: string },
): GeneratePayload {
const orig = (kind: string) => result.texts.find((t) => t.kind === kind);
const texts: GeneratePayload['texts'] = [];
const title = edits.title ?? orig('title')?.content ?? '';
const price = orig('price')?.content ?? '';
const brand = orig('brand')?.content ?? '';
const params = orig('params')?.pairs ?? [];
const sellingPoints = orig('selling_point')?.content ?? '';
const desc = edits.desc ?? orig('desc')?.content ?? '';
if (title) texts.push({ kind: 'title', content: title });
if (price) texts.push({ kind: 'price', content: price });
if (brand) texts.push({ kind: 'brand', content: brand });
if (params.length) texts.push({ kind: 'params', content: '', pairs: params });
if (sellingPoints) texts.push({ kind: 'selling_point', content: sellingPoints });
if (desc) texts.push({ kind: 'desc', content: desc });
const images = result.images
.filter((img) => selectedKeys.has(img.key))
.map((img) => ({ url: img.url, group_key: img.groupKey, variant_name: img.variantName ?? null }));
return { texts, images, ...config };
}
/** 出图方案规划请求体 */
export interface PlanPayload {
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
sku_variants: string[];
image_stats: Record<string, number>;
platform: string;
}
/** AI 智能规划:DeepSeek 根据商品信息生成出图方案 */
export async function planSuite(
baseUrl: string,
token: string,
payload: PlanPayload,
): Promise<{ summary: string; items: PlanItem[] }> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/plan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `规划失败 HTTP ${res.status}`);
return data;
}
/** 无状态一键生成:后端直接用请求数据生图,不落商品库 */
export async function generateSuite(
baseUrl: string,
token: string,
payload: GeneratePayload,
): Promise<{ suite_id: string }> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `提交失败 HTTP ${res.status}`);
return data;
}
/** 查询套图任务状态(轮询用) */
export async function getSuite(baseUrl: string, token: string, suiteId: string): Promise<SuiteInfo> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}`, {
headers: authHeaders(token),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `查询失败 HTTP ${res.status}`);
return data;
}
export function suiteZipUrl(baseUrl: string, suiteId: string): string {
return `${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}/zip`;
}
+54
View File
@@ -0,0 +1,54 @@
/**
* DOM 工具 - 等待元素、Shadow DOM 穿透
* 从 extension-v1 移植
*/
/** 等待任一选择器出现(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);
}, 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 查询元素(Ozon 部分组件用了 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, source')));
} else {
out.push(el);
}
});
}
return out;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 图片提取 - 主图、SKU、详情图、视频
* 从 extension-v1 移植,新增:
* - srcset 处理(Ozon 画廊是 <img srcset> / <picture><source>
* - toOriginalUrl 传平台规则(Ozon /wc\d+/
*/
import {
toAbsoluteUrl,
toOriginalUrl,
urlInBrackets,
looksLikeImageUrl,
dedupeKey,
pickBestFromSrcset,
} from './url';
import { queryAllDeep } from './dom';
import type { ImageGroupKey, SiteProfile, SrcProp } from '../profiles/types';
export interface ImageMaterial {
key: string; // 'main-001'
groupKey: ImageGroupKey; // '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 = '';
let imgEl: HTMLImageElement | null = el instanceof HTMLImageElement ? el : null;
for (const prop of srcProps) {
if (url) break;
if (prop === 'backgroundImage') {
if (el.tagName === 'IMG') {
const img = el as HTMLImageElement;
url = img.currentSrc || img.src || '';
name = img.alt || '';
} else {
const bg = getComputedStyle(el).backgroundImage || '';
const cand = (urlInBrackets(bg) || bg).replace(/['"]/g, '');
if (looksLikeImageUrl(cand)) url = cand;
}
continue;
}
if (prop === 'srcset') {
// <img srcset> 或 <source srcset>
const raw = el.getAttribute('srcset') || (el as any).srcset || '';
if (raw) url = pickBestFromSrcset(raw);
continue;
}
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;
}
}
// 选择器命中的是容器、图在子节点上
if (!url && el.tagName !== 'IMG') {
const inner = el.querySelector('img, source');
if (inner) {
const srcset = inner.getAttribute('srcset');
url = srcset
? pickBestFromSrcset(srcset)
: inner.getAttribute('data-src') || (inner as HTMLImageElement).currentSrc || (inner as HTMLImageElement).src || '';
if (inner instanceof HTMLImageElement) imgEl = inner;
if (!name && inner instanceof HTMLImageElement) 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 };
}
export function collectImages(profile: SiteProfile): ImageMaterial[] {
const result: ImageMaterial[] = [];
for (const group of profile.imageGroups) {
const srcProps = group.srcProps ?? profile.defaultSrcProps;
// 去重按组独立:一张图同时是主图和 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 === 'video' && !/\.(mp4|avi|mov|wmv|m3u8|webm)(\?|$)/i.test(rawUrl) && !/^blob:/i.test(rawUrl)) {
continue;
}
const url = group.type === 'img' ? toOriginalUrl(rawUrl, profile.originalUrlRules) : rawUrl;
// 尺寸过滤
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;
if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue;
}
const k = group.key === 'sku' ? `${dedupeKey(url, profile.originalUrlRules)}::${name}` : dedupeKey(url, profile.originalUrlRules);
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;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* JSON-LD 提取器(schema.org/Product
*
* Ozon 是 SSR 站点,商品页 HTML 里带 application/ld+json
* 是 DOM 之外最稳定的结构化来源(比哈希类名稳定一个数量级)。
*
* 参考实现(毛子ERP)也解析 application/ld+json 取 description / offers.url。
*/
export interface JsonLdProduct {
title?: string;
description?: string;
brand?: string;
sku?: string;
price?: string;
currency?: string;
images: string[];
rating?: string;
reviewCount?: string;
}
function asString(v: unknown): string | undefined {
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
return undefined;
}
function findProduct(node: unknown): any | null {
if (Array.isArray(node)) {
for (const item of node) {
const r = findProduct(item);
if (r) return r;
}
return null;
}
if (!node || typeof node !== 'object') return null;
const obj = node as Record<string, unknown>;
const type = obj['@type'];
const types = Array.isArray(type) ? type : [type];
if (types.some((t) => t === 'Product')) return obj;
// @graph 包裹
if (Array.isArray(obj['@graph'])) {
for (const g of obj['@graph']) {
const r = findProduct(g);
if (r) return r;
}
}
return null;
}
function collectImages(node: unknown, out: string[]): void {
if (!node) return;
if (typeof node === 'string') {
if (/^(https?:)?\/\/.+/i.test(node) && !out.includes(node)) out.push(node);
return;
}
if (Array.isArray(node)) {
node.forEach((n) => collectImages(n, out));
return;
}
if (typeof node === 'object') {
for (const v of Object.values(node as Record<string, unknown>)) {
collectImages(v, out);
}
}
}
export function extractJsonLd(): JsonLdProduct | null {
try {
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
for (const script of Array.from(scripts)) {
const text = script.textContent?.trim();
if (!text) continue;
let data: unknown;
try {
data = JSON.parse(text);
} catch {
continue;
}
const product = findProduct(data);
if (!product) continue;
const offers = Array.isArray(product.offers) ? product.offers[0] : product.offers;
const brandName = product.brand?.name ?? (typeof product.brand === 'string' ? product.brand : undefined);
const images: string[] = [];
if (product.image) collectImages(product.image, images);
return {
title: asString(product.name),
description: asString(product.description),
brand: asString(brandName),
sku: asString(product.sku),
price: asString(offers?.price),
currency: asString(offers?.priceCurrency),
images,
rating: asString(product.aggregateRating?.ratingValue),
reviewCount: asString(product.aggregateRating?.reviewCount),
};
}
} catch (err) {
console.warn('[JSON-LD] 提取失败:', err);
}
return null;
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Ozon 内部页 JSON API 提取器(补充路径)
*
* 参考实现(毛子ERP)的采集核心是直接请求 Ozon 自己的页数据接口:
*
* GET {origin}/api/entrypoint-api.bx/page/json/v2?url=/product/{id}/
* → { widgetStates: { "webCharacteristics-…": "...", "webGallery-…": "...", ... } }
*
* ★ 关键点(毛子ERP 的做法,也是本文件修复点):
* - 默认页 `/product/{id}/` 里带 **webCharacteristics(全量「特征」)**
* SSR 里的 webShortCharacteristics 只给前 5 项(limit:5)。
* - 描述页 `/product/{id}/?layout_container=pdpPage2column&layout_page_index=2`
* 里带 webDescription(富文本描述)。
* 所以要两个 URL 都请求、合并,才能拿到完整参数表 + 描述。
*
* ★ 图片只从画廊类 widget 收(白名单),绝不递归全部 widgetStates
* 避免「为您推荐 / 一起购买」等 carousel 图混入。
*/
export interface OzonPageData {
title?: string;
price?: string;
oldPrice?: string;
description?: string;
/** 主图画廊(仅来自画廊 widget) */
images: string[];
videos: string[];
/** 参数表(kv */
characteristics: Array<{ key: string; value: string }>;
}
const IMG_EXT = /\.(jpg|jpeg|png|webp|gif|avif)(\?|$)/i;
const VID_EXT = /\.(mp4|m3u8|webm|mov)(\?|$)/i;
function parseWidgetState(v: unknown): unknown {
if (typeof v !== 'string') return v;
try {
return JSON.parse(v);
} catch {
return v;
}
}
function parseWidgetStates(widgetStates: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (!widgetStates || typeof widgetStates !== 'object') return out;
for (const [k, v] of Object.entries(widgetStates as Record<string, unknown>)) {
out[k] = parseWidgetState(v);
}
return out;
}
function pushUnique(arr: string[], v: string): void {
if (v && !arr.includes(v)) arr.push(v);
}
/** 递归收集画廊 widget 内的图片/视频 URL(只在这个 widget 内走) */
function collectMedia(node: unknown, images: string[], videos: string[]): void {
if (!node) return;
if (typeof node === 'string') {
if (IMG_EXT.test(node)) pushUnique(images, node);
else if (VID_EXT.test(node)) pushUnique(videos, node);
return;
}
if (Array.isArray(node)) {
node.forEach((n) => collectMedia(n, images, videos));
return;
}
if (typeof node !== 'object') return;
for (const v of Object.values(node as Record<string, unknown>)) {
collectMedia(v, images, videos);
}
}
/** 从 characteristic 类 widget 里收参数表 */
function collectCharacteristics(node: unknown, out: Array<{ key: string; value: string }>): void {
if (!node || typeof node !== 'object') return;
const walk = (n: unknown): void => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
const obj = n as Record<string, unknown>;
for (const [k, v] of Object.entries(obj)) {
if (/characteristic|aspect/i.test(k) && Array.isArray(v)) {
for (const row of v) {
if (!row || typeof row !== 'object') continue;
const r = row as Record<string, unknown>;
// { title: {textRs:[{content}]}, values:[{text}] }Ozon 实测结构)
const key = readText(r.title);
if (key && Array.isArray(r.values)) {
const vals = r.values
.map((x) => (x && typeof x === 'object' ? readText((x as Record<string, unknown>).text) : ''))
.filter(Boolean);
if (vals.length) out.push({ key, value: vals.join(', ') });
continue;
}
// { key/value } / { name/value } / { title/text }
const k2 = (r.key ?? r.name ?? r.title) as string | undefined;
const v2 = (r.value ?? r.text) as string | undefined;
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
out.push({ key: k2, value: v2 });
}
}
} else if (/characteristic|aspect/i.test(k) && typeof v === 'object') {
walk(v);
}
}
};
walk(node);
}
function readText(node: unknown): string {
if (!node) return '';
if (typeof node === 'string') return node.trim();
if (typeof node !== 'object') return '';
// { textRs: [{ type, content }] } / { content } / { text }
const obj = node as Record<string, unknown>;
if (Array.isArray(obj.textRs)) {
return obj.textRs
.map((t) => (t && typeof t === 'object' ? (t as Record<string, unknown>).content ?? '' : ''))
.join('')
.trim();
}
if (typeof obj.content === 'string') return obj.content.trim();
if (typeof obj.text === 'string') return obj.text.trim();
return '';
}
/** 从描述类 widget 里收富文本描述 */
function collectDescription(node: unknown, out: { description?: string }): void {
if (!node || typeof node !== 'object') return;
const obj = node as Record<string, unknown>;
if (typeof obj.richAnnotationJson === 'string') {
try {
const rich = JSON.parse(obj.richAnnotationJson);
out.description = richToString(rich);
} catch {
out.description = obj.richAnnotationJson;
}
return;
}
if (typeof obj.description === 'string') {
out.description = obj.description;
return;
}
}
/** richAnnotationJson(富文本块数组)→ 纯文本 */
function richToString(rich: unknown): string {
if (!rich) return '';
if (typeof rich === 'string') return rich;
const texts: string[] = [];
const walk = (n: unknown): void => {
if (!n) return;
if (typeof n === 'string') {
texts.push(n);
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
if (k === 'text' && typeof v === 'string') texts.push(v);
else if (k !== 'type') walk(v);
}
}
};
walk(rich);
return texts.join('\n').trim();
}
/** 解析单个 widgetStates → 部分 OzonPageData */
function parsePage(widgets: Record<string, unknown>): OzonPageData {
const images: string[] = [];
const videos: string[] = [];
const characteristics: Array<{ key: string; value: string }> = [];
const desc: { description?: string } = {};
let title: string | undefined;
let price: string | undefined;
let oldPrice: string | undefined;
for (const [wkey, wval] of Object.entries(widgets)) {
const key = wkey.toLowerCase();
// 图片/视频:只收主画廊 widgetwebGallery),
// 不能按 "gallery" 子串匹配 —— webReviewGallery 是「买家照片和视频」,会混入
if (key.startsWith('webgallery')) {
collectMedia(wval, images, videos);
}
// 参数表(含全量 webCharacteristics
if (/(characteristic|aspect)/.test(key)) {
collectCharacteristics(wval, characteristics);
}
// 描述
if (/(description|richcontent)/.test(key)) {
collectDescription(wval, desc);
}
// 标题 / 价格(各自的 widget)
if (/heading|title/.test(key) && !title) {
const v = (wval as Record<string, unknown>)?.title ?? (wval as Record<string, unknown>)?.name;
if (typeof v === 'string' && v && !/^https?:/i.test(v)) title = v;
}
if (/webprice/.test(key) && !price) {
const p = (wval as Record<string, unknown>)?.price;
if (typeof p === 'string') price = p;
const op = (wval as Record<string, unknown>)?.originalPrice;
if (typeof op === 'string') oldPrice = op;
}
}
return {
title,
price,
oldPrice,
description: desc.description,
images,
videos,
characteristics: dedupePairs(characteristics),
};
}
async function fetchPage(url: string): Promise<Record<string, unknown> | null> {
try {
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const json = (await res.json()) as { widgetStates?: unknown };
return parseWidgetStates(json.widgetStates);
} catch (err) {
console.warn('[Ozon API] 请求失败:', url, err);
return null;
}
}
export async function fetchOzonPageData(itemId: string): Promise<OzonPageData | null> {
// 默认页(标题/价格/画廊 + 全量特征 webCharacteristics+ 描述页(富文本描述)
const urls = [
`/product/${itemId}/`,
`/product/${itemId}/?layout_container=pdpPage2column&layout_page_index=2`,
];
const merged: OzonPageData = { images: [], videos: [], characteristics: [] };
let gotAny = false;
for (const target of urls) {
const widgets = await fetchPage(
`${location.origin}/api/entrypoint-api.bx/page/json/v2?url=${encodeURIComponent(target)}`,
);
if (!widgets) continue;
const p = parsePage(widgets);
gotAny = true;
merged.title = merged.title || p.title;
merged.price = merged.price || p.price;
merged.oldPrice = merged.oldPrice || p.oldPrice;
merged.description = merged.description || p.description;
for (const img of p.images) if (!merged.images.includes(img)) merged.images.push(img);
for (const v of p.videos) if (!merged.videos.includes(v)) merged.videos.push(v);
for (const c of p.characteristics) merged.characteristics.push(c);
}
merged.characteristics = dedupePairs(merged.characteristics);
return gotAny &&
(merged.images.length || merged.title || merged.price || merged.characteristics.length || merged.description)
? merged
: null;
}
function dedupePairs(pairs: Array<{ key: string; value: string }>): Array<{ key: string; value: string }> {
const seen = new Set<string>();
const out: Array<{ key: string; value: string }> = [];
for (const p of pairs) {
const k = `${p.key}::${p.value}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(p);
}
return out;
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Ozon SSR widget state 提取器(主路径)
*
* Ozon 页面把每个 widget 的 JSON state 内嵌在 DOM 里:
* <div id="state-webGallery-3311626-default-1" data-state='{...}'>
* content script 直接读 data-state 即可,无需访问页面 JSmain world)。
*
* 结构已在真实页面实测(reference/ozon1.html、ozon2.html):
* - webGallery: coverImage / images[{src,alt}](原图)/ videos[{url,coverUrl}]
* - webPrice: price / originalPrice / cardPrice(如 "108,26 ¥"
* - webProductHeading: title
* - webShortCharacteristics / webDetailedCharacteristics: characteristics[]
* - webAspects: aspects[].variants[].data.{searchableText, coverImage}SKU 变体)
* - webReviewProductScore: totalScore / reviewsCount
*
* ★ 白名单机制:只读上面这几个 widget 的 state。
* 绝不遍历全页 —— "为您推荐 / 一起购买" 等其它商品 carousel 的 state
* webRecommendedProducts / webCarousel / 类似 widget)根本不会被读到。
*/
import { toAbsoluteUrl } from './url';
export interface OzonVariant {
name: string;
image?: string; // 可能为 undefined(纯文字规格,如尺码)
}
export interface BreadcrumbItem {
name: string; // 类目名称(如"扑满"、"儿童房"
href: string; // 原始链接(/category/kopilki-15056/ 或 ?category=7041
searchCategoryId?: number; // Ozon 搜索类目 ID(从 ?category=xxx 解析)
slug?: string; // URL slug(从 /category/xxx-123/ 解析,含数字 ID
}
export interface OzonStateData {
title?: string;
price?: string;
originalPrice?: string;
rating?: string;
reviewCount?: string;
galleryImages: string[]; // 原图(无尺寸标记)
videos: string[];
videoCovers: string[];
skuVariants: OzonVariant[];
characteristics: Array<{ key: string; value: string }>;
breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径
}
/** 允许读取的 widget 前缀白名单 */
const ALLOWED_WIDGETS = [
'webGallery-',
'webPrice-',
'webProductHeading-',
'webShortCharacteristics-',
'webDetailedCharacteristics-',
'webCharacteristics-',
'webAspects-',
'webReviewProductScore-',
'breadCrumbs-', // 面包屑类目路径
];
function pushUnique(arr: string[], v: string): void {
const abs = toAbsoluteUrl(v);
if (abs && !arr.includes(abs)) arr.push(abs);
}
function readTextRs(node: unknown): string {
// 提取 textRs / descriptionRs 里的展示文本。
// 规则:content/text 字段的值收进文本;递归进入数组/对象找嵌套的 content/text
// 跳过 type/font/color/id/href 等样式与元数据字段(type=newLine 除外)。
if (node == null) return '';
if (typeof node === 'string') return node.trim();
if (typeof node !== 'object') return '';
const texts: string[] = [];
const walk = (n: unknown): void => {
if (!n) return;
if (typeof n === 'string') {
texts.push(n);
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
if (k === 'type' && (v === 'newLine' || v === 'lineBreak')) {
texts.push('\n');
} else if (k === 'content' || k === 'text') {
walk(v);
} else if (v && typeof v === 'object') {
walk(v);
}
// 其它原始值(font/color/id/type='text' 等)直接跳过
}
}
};
walk(node);
return texts.join('').trim();
}
function parseCharacteristics(chars: unknown): Array<{ key: string; value: string }> {
if (!Array.isArray(chars)) return [];
const out: Array<{ key: string; value: string }> = [];
for (const c of chars) {
if (!c || typeof c !== 'object') continue;
const row = c as Record<string, unknown>;
// 结构 A{ title: { textRs: [...] }, values: [{ text: ... }] }(实测)
const key = readTextRs(row.title);
if (Array.isArray(row.values)) {
const vals = row.values
.map((v) => (v && typeof v === 'object' ? readTextRs((v as Record<string, unknown>).text) : ''))
.map((t) => t.replace(/,\s*$/, '')) // 源数据值自带尾逗号(如 "音乐, "
.filter(Boolean);
if (key && vals.length) out.push({ key, value: vals.join(', ') });
continue;
}
// 结构 B{ key, value } / { name, value } / { title, text }
const k2 = (row.key ?? row.name ?? row.title) as string | undefined;
const v2 = (row.value ?? row.text) as string | undefined;
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
out.push({ key: k2, value: v2 });
}
}
return out;
}
export function extractOzonState(): OzonStateData {
const data: OzonStateData = {
galleryImages: [],
videos: [],
videoCovers: [],
skuVariants: [],
characteristics: [],
breadcrumbs: [],
};
const seenChars = new Set<string>();
const els = document.querySelectorAll('div[id^="state-"]');
for (const el of Array.from(els)) {
const id = el.id.slice('state-'.length);
if (!ALLOWED_WIDGETS.some((p) => id.startsWith(p))) continue;
const raw = el.getAttribute('data-state');
if (!raw) continue;
let state: unknown;
try {
state = JSON.parse(raw);
} catch {
continue;
}
if (!state || typeof state !== 'object') continue;
const s = state as Record<string, unknown>;
if (id.startsWith('webGallery-')) {
if (typeof s.coverImage === 'string') pushUnique(data.galleryImages, s.coverImage);
if (Array.isArray(s.images)) {
for (const img of s.images) {
const src = img && typeof (img as Record<string, unknown>).src === 'string'
? (img as Record<string, unknown>).src as string
: undefined;
if (src) pushUnique(data.galleryImages, src);
}
}
if (Array.isArray(s.videos)) {
for (const v of s.videos) {
const rec = v as Record<string, unknown>;
if (typeof rec.url === 'string') pushUnique(data.videos, rec.url);
if (typeof rec.coverUrl === 'string') pushUnique(data.videoCovers, rec.coverUrl);
}
}
} else if (id.startsWith('webPrice-')) {
if (typeof s.price === 'string') data.price = s.price;
if (typeof s.originalPrice === 'string') data.originalPrice = s.originalPrice;
if (!data.price && typeof s.cardPrice === 'string') data.price = s.cardPrice;
} else if (id.startsWith('webProductHeading-')) {
if (typeof s.title === 'string') data.title = s.title;
} else if (
id.startsWith('webShortCharacteristics-') ||
id.startsWith('webDetailedCharacteristics-') ||
id.startsWith('webCharacteristics-')
) {
for (const c of parseCharacteristics(s.characteristics)) {
const k = `${c.key}::${c.value}`;
if (!seenChars.has(k)) {
seenChars.add(k);
data.characteristics.push(c);
}
}
} else if (id.startsWith('webAspects-')) {
if (Array.isArray(s.aspects)) {
for (const aspect of s.aspects) {
const a = aspect as Record<string, unknown>;
if (!Array.isArray(a.variants)) continue;
for (const v of a.variants) {
const rec = v as Record<string, unknown>;
const d = rec.data as Record<string, unknown> | undefined;
const name = typeof d?.searchableText === 'string' ? d.searchableText
: typeof d?.title === 'string' ? d.title : '';
const image = typeof d?.coverImage === 'string' ? d.coverImage : undefined;
if (name) data.skuVariants.push({ name, image });
}
}
}
} else if (id.startsWith('webReviewProductScore-')) {
if (typeof s.totalScore === 'number') data.rating = String(s.totalScore);
if (typeof s.reviewsCount === 'number') data.reviewCount = String(s.reviewsCount);
} else if (id.startsWith('breadCrumbs-')) {
// breadCrumbs widget state: { breadcrumbs: [{text, link, crumbType}] }
if (Array.isArray(s.breadcrumbs) && data.breadcrumbs.length === 0) {
for (const crumb of s.breadcrumbs) {
const c = crumb as Record<string, unknown>;
const name = typeof c.text === 'string' ? c.text.trim() : '';
const href = typeof c.link === 'string' ? c.link : '';
if (!name || !href) continue;
// 解析 ?category=7041highlight 样式链接)
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
// 解析 /category/kopilki-15056/(末尾带数字 ID 的 slug
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
}
// 如果 widget state 没有面包屑(旧版页面),尝试读 DOM 渲染的 ol
if (data.breadcrumbs.length === 0) {
const ol = document.querySelector('[class*="breadCrumbs"] ol, nav ol, ol[class*="breadcrumb"]');
if (ol) {
for (const a of Array.from(ol.querySelectorAll('a[href]'))) {
const href = a.getAttribute('href') ?? '';
const name = a.textContent?.trim() ?? '';
if (!name) continue;
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
return data;
}
+342
View File
@@ -0,0 +1,342 @@
/**
* 统一采集引擎入口 - 扫描当前页
*
* 按平台选择采集策略:
* - ozon:四路径(SSR data-state ★主路径 → JSON-LD → 页 JSON API → DOM 兜底),多源合并
* - taobao/tmallSSRwindow.__ICE_APP_CONTEXT__)★主路径 + DOM 补充(详情图在 DOM 里)
* - 1688:纯 DOM(多套选择器变体)
*
* 各路径产出的素材最终走同一个合并器:文本按 kind 合并(params 按键并集),
* 图片按组去重后重排 key。
*/
import { matchProfile } from '../profiles';
import { waitForAny } from './dom';
import { collectImages, type ImageMaterial } from './image';
import { collectTexts, mergeTexts, type TextMaterial } from './text';
import { extractJsonLd } from './jsonld';
import { fetchOzonPageData, type OzonPageData } from './ozon-api';
import { extractOzonState, type OzonStateData, type BreadcrumbItem } from './ozon-state';
import { extractSSRData, type SSRData } from './ssr';
import { buildFromSSR } from './ssr-builder';
import { dedupeKey, toOriginalUrl, toThumbUrl } from './url';
import type { SiteProfile } from '../profiles/types';
export type { ImageMaterial, TextMaterial };
export interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
breadcrumbs: BreadcrumbItem[];
scannedAt: number;
stats: Record<string, number>; // 分组统计
warnings: string[]; // 警告(如详情图为 0
source: 'state' | 'ssr' | 'jsonld' | 'api' | 'dom' | 'mixed'; // 主路径
}
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
{ key: 'main', name: '主图' },
{ key: 'sku', name: 'SKU图片' },
{ key: 'detail', name: '详情图' },
{ key: 'video', name: '视频' },
];
// ── Ozon:结构化合并(state + jsonld + api)───────────────────────────────
interface StructuredBundle {
title?: string;
price?: string;
brand?: string;
description?: string;
characteristics: Array<{ key: string; value: string }>;
galleryImages: string[];
videos: string[];
videoCovers: string[];
skuVariants: Array<{ name: string; image?: string }>;
}
function mergeStructured(
state: OzonStateData,
jsonld: ReturnType<typeof extractJsonLd>,
api: OzonPageData | null
): StructuredBundle {
const bundle: StructuredBundle = {
title: state.title || jsonld?.title || api?.title,
price: state.price || jsonld?.price || api?.price,
brand: jsonld?.brand,
description: api?.description || jsonld?.description,
characteristics: [...state.characteristics],
galleryImages: [...state.galleryImages],
videos: [...state.videos],
videoCovers: [...state.videoCovers],
skuVariants: [...state.skuVariants],
};
for (const u of api?.images ?? []) {
if (!bundle.galleryImages.includes(u)) bundle.galleryImages.push(u);
}
for (const u of api?.videos ?? []) {
if (!bundle.videos.includes(u)) bundle.videos.push(u);
}
const seenChars = new Set(bundle.characteristics.map((c) => `${c.key}::${c.value}`));
for (const c of api?.characteristics ?? []) {
const k = `${c.key}::${c.value}`;
if (!seenChars.has(k)) {
seenChars.add(k);
bundle.characteristics.push(c);
}
}
return bundle;
}
function buildFromBundle(profile: SiteProfile, bundle: StructuredBundle): {
texts: TextMaterial[];
images: ImageMaterial[];
} {
const texts: TextMaterial[] = [];
const images: ImageMaterial[] = [];
if (bundle.title) texts.push({ kind: 'title', content: bundle.title });
if (bundle.price) texts.push({ kind: 'price', content: bundle.price });
if (bundle.brand) texts.push({ kind: 'brand', content: bundle.brand });
if (bundle.characteristics.length) {
texts.push({
kind: 'params',
content: bundle.characteristics.map((p) => `${p.key}: ${p.value}`).join('\n'),
pairs: bundle.characteristics,
});
}
if (bundle.description) texts.push({ kind: 'desc', content: bundle.description });
let idx = 0;
bundle.galleryImages.forEach((u, i) => {
const orig = toOriginalUrl(u, profile.originalUrlRules);
images.push({
key: `main-${String(i + 1).padStart(3, '0')}`,
groupKey: 'main',
groupName: '主图',
url: orig,
thumbUrl: toThumbUrl(orig),
index: idx++,
type: 'img',
});
});
bundle.skuVariants.forEach((s, i) => {
if (!s.image) return;
const orig = toOriginalUrl(s.image, profile.originalUrlRules);
images.push({
key: `sku-${String(i + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: s.name || undefined,
url: orig,
thumbUrl: toThumbUrl(orig),
index: idx++,
type: 'img',
});
});
bundle.videos.forEach((u, i) => {
images.push({
key: `video-${String(i + 1).padStart(3, '0')}`,
groupKey: 'video',
groupName: '视频',
url: u,
thumbUrl: bundle.videoCovers[i] ?? '',
index: idx++,
type: 'video',
});
});
return { texts, images };
}
// ── 统一合并器 ────────────────────────────────────────────────────────────
/** 按组分组合并:靠前来源优先,靠后来源填缺,按 dedupeKey 去重后重排 index */
function mergeImages(
primary: ImageMaterial[],
fallback: ImageMaterial[],
profile: SiteProfile
): ImageMaterial[] {
const byGroup = new Map<string, ImageMaterial[]>();
const seen = new Set<string>();
let counter = 0;
const push = (m: ImageMaterial) => {
const k = m.groupKey === 'sku'
? `${dedupeKey(m.url, profile.originalUrlRules)}::${m.variantName ?? ''}`
: dedupeKey(m.url, profile.originalUrlRules);
if (seen.has(k)) return;
seen.add(k);
const arr = byGroup.get(m.groupKey) ?? [];
arr.push({ ...m, index: counter++ });
byGroup.set(m.groupKey, arr);
};
for (const m of primary) push(m);
for (const m of fallback) push(m);
const out: ImageMaterial[] = [];
for (const g of GROUP_ORDER) {
const arr = byGroup.get(g.key);
if (!arr) continue;
arr.forEach((m, i) => {
m.key = `${g.key}-${String(i + 1).padStart(3, '0')}`;
m.groupName = g.name;
});
out.push(...arr);
}
return out;
}
function finalize(
profile: SiteProfile,
itemId: string | null,
texts: TextMaterial[],
images: ImageMaterial[],
breadcrumbs: BreadcrumbItem[],
source: ScanResult['source']
): ScanResult {
const stats: Record<string, number> = {};
for (const img of images) stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
const warnings: string[] = [];
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题');
if (images.length === 0) warnings.push('未扫描到任何图片/视频');
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
return {
platform: profile.id,
itemId,
url: location.href,
texts,
images,
breadcrumbs,
scannedAt: Date.now(),
stats,
warnings,
source,
};
}
// ── 各平台策略 ────────────────────────────────────────────────────────────
/** Ozon:四路径合并(来自 extension-v2 生产逻辑) */
async function scanOzon(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
const state = extractOzonState();
let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom';
const jsonld = extractJsonLd();
let api: OzonPageData | null = null;
if (itemId) {
try {
api = await fetchOzonPageData(itemId);
} catch (err) {
console.warn('[SuiteCollector] API 提取异常:', err);
}
}
const bundle = mergeStructured(state, jsonld, api);
const structured = buildFromBundle(profile, bundle);
if ((structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0) && source === 'dom') {
source = 'mixed';
}
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const domTexts = collectTexts(profile).materials;
const domImages = collectImages(profile);
const texts = mergeTexts(structured.texts, domTexts);
const images = mergeImages(structured.images, domImages, profile);
return finalize(profile, itemId, texts, images, state.breadcrumbs, source);
}
/** 淘宝/天猫:SSR 主路径 + DOM 补充(详情图、SKU 兜底都在 DOM 里) */
async function scanTaobao(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
const ssrData: SSRData | null = extractSSRData();
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
let breadcrumbs: BreadcrumbItem[] = [];
if (ssrData) {
const built = buildFromSSR(ssrData, profile);
// ssr-builder 的本地类型 groupKey 是 string,这里对齐到 ImageGroupKey
primaryTexts = built.texts;
primaryImages = built.images as ImageMaterial[];
source = 'ssr';
}
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
const domImages = collectImages(profile);
if (primaryImages.length > 0 && domImages.length > 0) source = 'mixed';
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, itemId ?? ssrData?.item.itemId ?? null, texts, images, breadcrumbs, source);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
/** 1688:纯 DOM(多套画廊选择器变体覆盖线上版本) */
async function scan1688(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: texts, missingRequired } = collectTexts(profile);
const images = collectImages(profile);
const result = finalize(profile, itemId, texts, images, [], 'dom');
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
// ── 入口 ──────────────────────────────────────────────────────────────────
export async function scanCurrentPage(): Promise<ScanResult | null> {
const profile = matchProfile(location.href);
if (!profile) {
console.warn('[SuiteCollector] 当前页面不支持采集:', location.href);
return null;
}
const itemId = profile.extractItemId(location.href);
console.log('[SuiteCollector] 开始采集:', profile.name, itemId, location.href);
let result: ScanResult | null = null;
try {
if (profile.id === 'ozon') result = await scanOzon(profile, itemId);
else if (profile.id === 'taobao') result = await scanTaobao(profile, itemId);
else result = await scan1688(profile, itemId);
} catch (err) {
console.error('[SuiteCollector] 采集异常:', err);
return null;
}
console.log('[SuiteCollector] 采集完成:', {
platform: result.platform,
texts: result.texts.map((t) => t.kind),
images: result.images.length,
stats: result.stats,
warnings: result.warnings,
source: result.source,
});
return result;
}
// 暴露到全局供 side panel / console 调用
if (typeof window !== 'undefined') {
(window as any).__SuiteCollector = {
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;
}
}
+110
View File
@@ -0,0 +1,110 @@
/**
* 文本提取 - 标题、价格、参数表、卖点、描述、品牌
* 从 extension-v1 移植(DOM 兜底路径)
*/
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 };
}
/** 合并去重:以 kind 为键,结构化来源优先,DOM 来源兜底。
* 参数表(params)特殊处理:两边的 pairs 做并集合并(按 key 去重),
* 因为「关于商品」只给前几项,完整「特征」在 DOM 里,需要合并才能拿全。
*/
export function mergeTexts(
primary: TextMaterial[],
fallback: TextMaterial[]
): TextMaterial[] {
const map = new Map<string, TextMaterial>();
for (const m of [...primary, ...fallback]) {
if (m.kind === 'params') {
const existing = map.get('params');
if (!existing) {
map.set('params', { ...m, pairs: [...(m.pairs ?? [])] });
} else {
const merged = [...(existing.pairs ?? [])];
const seen = new Set(merged.map((p) => p.key));
for (const p of m.pairs ?? []) {
if (!seen.has(p.key)) {
merged.push(p);
seen.add(p.key);
}
}
existing.pairs = merged;
existing.content = merged.map((p) => `${p.key}: ${p.value}`).join('\n');
}
continue;
}
if (!map.has(m.kind)) map.set(m.kind, m);
}
return Array.from(map.values());
}
+132
View File
@@ -0,0 +1,132 @@
/**
* URL 工具链
* 从 extension-v1 移植,新增:
* - toOriginalUrl 支持平台自定义规则(Ozon 的 /wc\d+/ 路径段尺寸标记)
* - pickBestFromSrcset:从 srcset 里挑最大尺寸候选
*/
const IMG_EXT = /\.(jpg|jpeg|png|gif|bmp|heic|webp|avif)$/i;
export interface UrlRule {
match: RegExp;
replace: string;
}
/**
* 缩略图 URL → 原图 URL
* 先走平台规则(Ozon 的 /wc\d+/ → /wc1200/),
* 再走阿里系通用规则:xxx.jpg_400x400.jpg → xxx.jpg
*/
export function toOriginalUrl(url: string, rules?: UrlRule[]): string {
let out = url;
for (const r of rules ?? []) {
// 带 g 标志的正则(query 清洗)要反复 replace,不带 g 的只替换一次
if (r.match.global) {
out = out.replace(r.match, r.replace);
} else if (r.match.test(out)) {
out = out.replace(r.match, r.replace);
}
}
const m = out.match(/^(.+?\.(jpg|jpeg|png|gif|bmp|heic|webp|avif))_/i);
return m ? m[1] : out;
}
/** 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/hash */
export function dedupeKey(url: string, rules?: UrlRule[]): string {
const base = toOriginalUrl(url, rules);
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);
}
}
/**
* 从 srcset 里挑最大尺寸候选。
* 支持两种语法:
* "a.jpg 100w, b.jpg 200w, c.jpg 300w" → c.jpg
* "a.jpg 1x, b.jpg 2x" → 最后一个
* "a.jpg 400w, b.jpg 800w, c.jpg 1200w, d.jpg" → 最后一个(无描述符 = 兜底最大)
*/
export function pickBestFromSrcset(srcset: string): string {
if (!srcset) return '';
const parts = srcset.split(',').map((p) => p.trim()).filter(Boolean);
if (!parts.length) return '';
let best = '';
let bestSize = -1;
for (const part of parts) {
const seg = part.split(/\s+/);
const url = seg[0];
const desc = seg[1] ?? '';
let size = -1;
const w = desc.match(/^(\d+)w$/);
const x = desc.match(/^(\d+(?:\.\d+)?)x$/);
if (w) size = Number(w[1]);
else if (x) size = Math.round(Number(x[1]) * 1000);
else size = 0; // 无描述符,通常是最小的兜底,但也可能是唯一候选
if (size >= bestSize) {
bestSize = size;
best = url;
}
}
return best;
}
/**
* Ozon CDN 原图 → wc200 缩略图(侧边栏预览用,省流量)
* 实测结构(reference/ozon1.html):
* https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg
* → https://ir.ozone.ru/s3/multimedia-1-5/wc200/9290076089.jpg
* 已带尺寸标记(/wc\d+/、/c\d+/)或非 multimedia 路径的 URL 原样返回。
*/
export function toThumbUrl(url: string): string {
const m = url.match(/^(https?:\/\/[^/]+\/s3\/[^/]+\/)([^/]+)$/);
if (m && !/\/wc\d+\//.test(url) && !/\/c\d+\//.test(url)) {
return `${m[1]}wc200/${m[2]}`;
}
return url;
}
/** 清洗文件名非法字符(Windows 兼容) */
export function cleanFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/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']
}
]
};
+21
View File
@@ -0,0 +1,21 @@
/**
* Profile 路由 - 根据 URL 匹配平台(ozon / 1688 / 淘宝 / 天猫)
*/
import type { SiteProfile } from './types';
import { profileOzon } from './ozon';
import { profile1688 } from './1688';
import { profileTaobao } from './taobao';
const PROFILES: SiteProfile[] = [profileOzon, 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 { profileOzon, profile1688, profileTaobao };
export type { SiteProfile };
+162
View File
@@ -0,0 +1,162 @@
/**
* Ozon 商品页采集配置
*
* 选择器已在真实页面实测(reference/ozon1.html、ozon2.html2026-08-15):
* - webProductHeading → <h1> 标题
* - webGallery → 主图(<img srcset>wc50/wc100 缩略图)
* - webAspects → SKU 变体(颜色/尺码选择器)
* - webShortCharacteristics / webDetailedCharacteristics → 参数表("关于商品"区)
* - webPrice → 价格(DOM 结构复杂,价格主路径走 data-state
*
* ★ 主采集路径是 structuredozon-state.ts 读 SSR data-state + JSON-LD + API),
* 本文件的 DOM 选择器只是兜底 + 详情图补充。
*/
import type { SiteProfile } from './types';
export const profileOzon: SiteProfile = {
id: 'ozon',
name: 'Ozon',
urlPatterns: [
// 新版: https://www.ozon.ru/product/slug-123456789/
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/product\/[^/]+-\d+\/?/,
// 旧版: https://www.ozon.ru/context/detail/id/123456789/
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/context\/detail\/id\/\d+/,
],
extractItemId: (url) => {
const m = url.match(/\/product\/[^/]+-(\d+)\/?/);
if (m?.[1]) return m[1];
const m2 = url.match(/\/context\/detail\/id\/(\d+)/);
return m2?.[1] ?? null;
},
readySelectors: [
'[data-widget="webProductHeading"]',
'[data-widget="webGallery"]',
'h1',
],
readyTimeoutMs: 8_000,
// Ozon 画廊图片是 <img srcset>,懒加载真实地址在 srcset / currentSrc / src
defaultSrcProps: ['srcset', 'currentSrc', 'src', 'data-src'],
refererOrigin: 'https://www.ozon.ru',
textRules: [
{
kind: 'title',
selectors: [
'[data-widget="webProductHeading"] h1',
'h1[itemprop="name"]',
'h1',
],
extract: 'first',
required: true,
},
{
kind: 'price',
selectors: [
'[data-widget="webPrice"] span',
'span[itemprop="price"]',
'[data-widget="webPrice"]',
],
extract: 'first',
},
{
kind: 'params',
selectors: [
'[data-widget="webDetailedCharacteristics"] dl',
'[data-widget="webCharacteristics"] dl',
'[data-widget="webShortCharacteristics"] dl',
'[data-widget="webAspects"] dl',
'#section-characteristics dl',
],
extract: 'table',
tableKeySelector: 'dt, [class*="key"], [class*="Key"], [class*="label"]',
tableValueSelector: 'dd, [class*="value"], [class*="Value"]',
},
{
kind: 'selling_point',
selectors: [
'[data-widget="webShortCharacteristics"]',
'[data-widget="webFeatures"]',
'[data-widget="webAO"]',
],
extract: 'join',
},
{
kind: 'desc',
selectors: [
'[data-widget="webDescription"]',
'[data-widget="webRichContent"]',
'#section-description',
],
extract: 'join',
},
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
selectors: [
'[data-widget="webGallery"] img',
'[data-widget="webGallery"] source',
'[data-widget="webPhotoGallery"] img',
],
// 不设 minWidth:画廊缩略图 naturalWidth 可能很小,原图靠 toOriginalUrl 还原
},
{
key: 'sku',
name: 'SKU图片',
type: 'img',
selectors: [
// 实测:变体选择器在 webAspectswebDetailSKU 其实是"复制 SKU"按钮,没有图)
'[data-widget="webAspects"] img',
'[data-widget="webVariants"] img',
],
nameSelectors: [
'span[class*="Value"]',
'span[class*="Text"]',
'span',
],
minWidth: 16,
minHeight: 16,
},
{
key: 'detail',
name: '详情图',
type: 'img',
selectors: [
'[data-widget="webDescription"] img',
'[data-widget="webRichContent"] img',
'[data-widget="webFeatures"] img',
'#section-description img',
],
minWidth: 300,
minHeight: 100,
},
{
key: 'video',
name: '视频',
type: 'video',
selectors: [
'[data-widget="webGallery"] video',
'[data-widget="webVideo"] video',
],
},
],
// 实测 CDNir.ozone.ru):尺寸标记是路径段 /wc\d+/wc50…wc1000)和 /c\d+/c50/c600
// 去掉标记即为原图(页面本身就有无标记的原始 URL)。
originalUrlRules: [
{ match: /\/wc\d+\//, replace: '/' },
{ match: /\/c\d+\//, replace: '/' },
// 去掉尺寸段后路径里会有双斜杠(不动 https:// 的 //
{ match: /(?<!:)\/{2,}/g, replace: '/' },
// 兼容 query 参数形式的尺寸(?width=200&h=300 逐个剥掉)
{ match: /[?&](width|height|size|quality|w|h)=[^&]+/g, replace: '' },
],
};
+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'],
},
],
};
+69
View File
@@ -0,0 +1,69 @@
/**
* Site Profile - 平台采集配置(声明式)
*
* 与 extension-v1 同一套抽象,新增 Ozon 需要的文本类型:
* selling_point(卖点 / About this item)、brand(品牌)。
*
* 采集引擎(collector/)完全通用,加一个新平台只需新增一个 profile。
*/
export type TextKind =
| 'title'
| 'price'
| 'params'
| 'selling_point'
| 'desc'
| 'brand';
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video';
export type SrcProp =
| 'data-lazyload-src'
| 'data-src'
| 'srcset'
| 'currentSrc'
| 'src'
| 'backgroundImage';
export interface TextRule {
kind: TextKind;
/** 多套选择器,逐个尝试直到命中 */
selectors: string[];
extract: 'join' | 'first' | 'table';
/** table 模式的 key/value 子选择器 */
tableKeySelector?: string;
tableValueSelector?: string;
required?: boolean;
}
export interface ImageGroupRule {
key: ImageGroupKey;
name: string;
type: 'img' | 'video';
selectors: string[];
/** 覆盖 defaultSrcProps */
srcProps?: SrcProp[];
/** SKU 规格名来源 */
nameSelectors?: string[];
/** 画廊"当前高亮"元素(排除) */
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[];
/** 图片 URL 还原原图规则(缺省用通用 CDN 后缀规则) */
originalUrlRules?: Array<{ match: RegExp; replace: string }>;
refererOrigin?: string;
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 服务端设置(上传/生成用):后端地址 + Bearer Token,持久化到 chrome.storage.local。
*/
export interface BackendSettings {
baseUrl: string;
token: string;
}
const KEY = 'suite_backend_settings';
export const DEFAULT_BASE_URL = 'http://127.0.0.1:3300';
const DEFAULT: BackendSettings = {
baseUrl: DEFAULT_BASE_URL,
token: '',
};
/** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置) */
const MIGRATE: Record<string, string> = {
'http://127.0.0.1:8810': DEFAULT_BASE_URL,
'http://127.0.0.1:8800': DEFAULT_BASE_URL,
'http://127.0.0.1:7000': DEFAULT_BASE_URL,
'http://localhost:7000': DEFAULT_BASE_URL,
'http://127.0.0.1:7200': DEFAULT_BASE_URL,
'http://localhost:7200': DEFAULT_BASE_URL,
'http://localhost:3300': DEFAULT_BASE_URL,
};
export async function loadSettings(): Promise<BackendSettings> {
const r = await chrome.storage.local.get(KEY);
const saved = r[KEY] ?? {};
const baseUrl = MIGRATE[saved.baseUrl] ?? saved.baseUrl ?? DEFAULT.baseUrl;
const s: BackendSettings = { token: '', ...saved, baseUrl };
if (baseUrl !== saved.baseUrl) await chrome.storage.local.set({ [KEY]: s }); // 迁移结果写回
return s;
}
export async function saveSettings(s: BackendSettings): Promise<void> {
// localhost 会被 Chrome 解析为 IPv6 ::1,若该端口被系统服务(如 macOS AirPlay)占用会 403
// 统一改写为 IPv4 的 127.0.0.1
s = { ...s, baseUrl: s.baseUrl.replace('//localhost:', '//127.0.0.1:') };
await chrome.storage.local.set({ [KEY]: s });
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"exclude": ["node_modules", ".output"]
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
name: '电商套图工作台',
description: '采集 Ozon / 1688 / 淘宝 / 天猫 商品信息与图片,一键生成电商套图并导出',
permissions: [
'storage',
'sidePanel',
'activeTab',
'scripting' // 执行 content script 函数需要
],
host_permissions: [
// Ozon 商品页 + 图片 CDN
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
'https://*.ozonusercontent.com/*',
// 1688 / 淘宝 / 天猫 + 阿里 CDN
'https://detail.1688.com/*',
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
'https://*.alicdn.com/*',
// 本机后端(上传 / 生成套图用);生产换成你的公网域名
'http://127.0.0.1:3300/*',
'http://localhost:3300/*'
],
action: {
default_title: '电商套图工作台'
}
},
modules: ['react']
});