feat: 插件开发 ozon 端主体完成

This commit is contained in:
Joey
2026-08-16 17:32:43 +08:00
parent 1591d5e35a
commit b57933e983
32 changed files with 1960 additions and 512 deletions
+10 -10
View File
@@ -11,12 +11,12 @@ Chrome 插件 + Python 后端:采集 Ozon / 1688 / 淘宝 / 天猫 商品页
```
Chrome 插件(WXT + React + antd Python 后端(FastAPI + SQLite
┌────────────────────────────┐ ┌──────────────────────────────┐
│ Side Panel │ │ POST /api/materials
│ ① 扫描商品页(四站点) │ ──上传──▶ │ → 落库 + 后台转存图片
│ ② 勾选/编辑素材 │ │ POST /api/products/{id}/suites
│ ③ 选风格提交生成 │ ──提交──▶ │ → 套图任务(后台逐张生图)
│ ④ 轮询进度 → 导出 ZIP │ ◀─轮询── │ GET /api/suites/{id}
└────────────────────────────┘ │ GET /api/suites/{id}/zip
│ Side Panel │ │ POST /api/plan
│ ① 扫描商品页(四站点) │ ─规划──▶ │ → DeepSeek 出图方案
│ ② 勾选/编辑素材 │ ◀─方案── │ POST /api/generate(无状态)
│ ③ 出图方案(默认/AI规划) │ ──提交──▶ │ → 方案展开 → 逐张生图
│ ④ 轮询进度 → 导出 ZIP │ ◀─轮询── │ GET /api/suites/{id}[/zip]
└────────────────────────────┘ │ GET /api/proxy-image
└──────────────────────────────┘
```
@@ -77,11 +77,11 @@ pnpm build # 产物在 .output/chrome-mv3
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/materials` | 采集上传(文本 + 图片 URL),异步转存 |
| GET | `/api/products` / `/api/products/{id}` | 商品列表/详情 |
| POST | `/api/products/{id}/suites` | 创建套图任务 `{style_set, types, lang, provider?}` |
| POST | `/api/plan` | DeepSeek 出图方案规划(`{texts, sku_variants, image_stats, platform}` |
| POST | `/api/generate` | 无状态一键生成(`{texts, images, plan, style_set, platform}`,不落商品库) |
| GET | `/api/suites/{id}` | 任务状态 + 已生成图 URL |
| GET | `/api/suites/{id}/zip` | 导出 ZIP |
| GET | `/api/suites/{id}/zip` | 导出 ZIP(按方案标题命名) |
| GET | `/api/proxy-image?url=` | 图片代理(绕源站防盗链) |
| GET | `/api/health` | 健康检查 + provider 配置状态 |
## 说明与限制
+16
View File
@@ -29,6 +29,22 @@ export default defineBackground(() => {
return true;
}
// 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API
if (msg?.action === 'fetchText') {
const headers: Record<string, string> = {};
if (msg.referer) headers['Referer'] = msg.referer;
fetch(msg.url, {
headers,
credentials: msg.credentials ? 'include' : 'omit',
})
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
sendResponse({ ok: true, text: await res.text() });
})
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
return false;
});
});
+36
View File
@@ -0,0 +1,36 @@
// MAIN world 桥 —— 跑在页面主世界,读取页面 JS 变量(isolated world 读不到)。
// 协议(对齐竞品 inject.js 模式):
// isolated → MAIN: {type:'sc-bridge-req', requestId, keys: [...]} keys 含 '*' 时返回诊断键列表
// MAIN → isolated: {type:'sc-bridge-res', requestId, payload: {key: value}}
// MAIN 侧零业务逻辑:只读白名单键、JSON 序列化过滤后回传,不注入任何页面行为。
export default defineContentScript({
matches: [
'https://*.ozon.ru/*', 'https://*.ozon.kz/*', 'https://*.ozon.by/*',
'https://detail.1688.com/*',
'https://item.taobao.com/*', 'https://detail.tmall.com/*',
],
world: 'MAIN',
main() {
window.addEventListener('message', (ev: MessageEvent) => {
if (ev.source !== window) return;
const d = ev.data as { type?: string; requestId?: string; keys?: string[] } | null;
if (!d || d.type !== 'sc-bridge-req' || !d.requestId || !Array.isArray(d.keys)) return;
const payload: Record<string, unknown> = {};
if (d.keys.includes('*')) {
// 诊断模式:列出页面上可能有数据的全局键
payload['__sc_window_keys__'] = Object.keys(window).filter(k =>
/^(__|_)?[A-Za-z]/.test(k) && /(context|rawData|ICE|sku|item|g_config|DATA|state)/i.test(k)
);
}
for (const k of d.keys) {
if (k === '*') continue;
try {
const v = (window as unknown as Record<string, unknown>)[k];
if (v !== undefined) payload[k] = JSON.parse(JSON.stringify(v)); // 过滤函数/循环引用
} catch { /* 不可序列化的跳过 */ }
}
window.postMessage({ type: 'sc-bridge-res', requestId: d.requestId, payload }, '*');
});
},
});
+315 -79
View File
@@ -10,12 +10,12 @@
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { App as AntApp, ConfigProvider, Popover, Progress } from 'antd';
import { App as AntApp, ConfigProvider, Popover, Progress, Select } 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,
DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS,
type PlanItem, type SuiteInfo,
} from '../../src/api/client';
import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings';
@@ -37,17 +37,31 @@ const PLATFORM_LABELS: Record<string, string> = {
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 SUPPORTED_URL_RE = /^https?:\/\/([a-z0-9-]+\.ozon\.(ru|kz|by)|detail\.1688\.com|item\.taobao\.com|detail\.tmall\.com)\//i;
/** 在指定 tab 执行采集,返回结果或 null(未就绪/不支持) */
async function tryScan(tabId: number): Promise<ScanResult | null> {
const [res] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
target: { tabId },
func: () => (window as any).__SuiteCollector?.scan?.() ?? null,
});
const r = (res?.result ?? null) as ScanResult | null;
if (!r) throw new Error('采集失败:页面不支持或内容脚本未就绪,请刷新页面后重试');
return r;
return (res?.result ?? null) as ScanResult | null;
}
/** 等待 tab 加载完成(需在 reload 前注册监听),超时放行 */
function waitForTabComplete(tabId: number, timeoutMs = 30_000): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, timeoutMs);
function listener(id: number, info: chrome.tabs.TabChangeInfo) {
if (id === tabId && info.status === 'complete') {
clearTimeout(timer);
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
}
chrome.tabs.onUpdated.addListener(listener);
});
}
function send<T>(action: string, payload: Record<string, unknown>): Promise<T> {
@@ -62,9 +76,11 @@ function send<T>(action: string, payload: Record<string, unknown>): Promise<T> {
// ── 小组件 ─────────────────────────────────────────────────────────────────
const Section: React.FC<{ no: string; title: string; extra?: React.ReactNode; children: React.ReactNode }> =
({ no, title, extra, children }) => (
<div className="section">
const Section: React.FC<{
no: string; title: string; extra?: React.ReactNode; children: React.ReactNode; className?: string;
}> =
({ no, title, extra, children, className }) => (
<div className={`section ${className ?? ''}`}>
<div className="section-head">
<span className="section-no">{no}</span>
<span className="section-title">{title}</span>
@@ -95,9 +111,13 @@ const App: React.FC = () => {
// 采集
const [scanning, setScanning] = useState(false);
const [retrying, setRetrying] = useState(false);
const [result, setResult] = useState<ScanResult | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [titleEdit, setTitleEdit] = useState('');
const [priceEdit, setPriceEdit] = useState('');
const [dimsEdit, setDimsEdit] = useState<{ l: string; w: string; h: string }>({ l: '', w: '', h: '' });
const [weightEdit, setWeightEdit] = useState('');
const [descEdit, setDescEdit] = useState('');
const [paramsOpen, setParamsOpen] = useState(false);
@@ -105,34 +125,107 @@ const App: React.FC = () => {
const [settings, setSettings] = useState<BackendSettings>({ baseUrl: 'http://127.0.0.1:3300', token: '' });
// 出图方案
const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('cn');
const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon');
const [styleSet, setStyleSet] = useState(1);
/** 生图模型(通义 DashScope,默认 wan2.7-image-pro,与后端 .env 默认一致) */
const [model, setModel] = useState<string>('wan2.7-image-pro');
/** 用户改写的风格提示词(按风格 id 存,切风格不丢) */
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
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 [planThenGenerate, setPlanThenGenerate] = useState(false);
// 生成
const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 规划请求序号:重新采集时递增,用于丢弃重置后才返回的过期规划响应 */
const planSeqRef = useRef(0);
// 图片放大预览
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// 图片放大预览(画廊:可左右切换)
const [preview, setPreview] = useState<{ list: string[]; index: number } | null>(null);
const openPreview = (list: string[], index: number) => {
if (list.length === 0) return;
setPreview({ list, index: Math.max(0, Math.min(index, list.length - 1)) });
};
const previewPrev = () => setPreview(p => p && { ...p, index: (p.index - 1 + p.list.length) % p.list.length });
const previewNext = () => setPreview(p => p && { ...p, index: (p.index + 1) % p.list.length });
useEffect(() => {
loadSettings().then(setSettings);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, []);
const price = result?.texts.find(t => t.kind === 'price')?.content ?? '';
// 预览态的键盘导航:← → 切换,Esc 关闭
useEffect(() => {
if (!preview) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') previewPrev();
else if (e.key === 'ArrowRight') previewNext();
else if (e.key === 'Escape') setPreview(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [preview]);
const brand = result?.texts.find(t => t.kind === 'brand')?.content ?? '';
const sales = result?.texts.find(t => t.kind === 'sales')?.content ?? '';
const shop = result?.texts.find(t => t.kind === 'shop')?.content ?? '';
const paramPairs = result?.texts.find(t => t.kind === 'params')?.pairs ?? [];
const handleScan = async () => {
const handleScan = () => {
// 生成中:二次确认(后台任务会继续完成,但本面板停止跟踪)
if (generating) {
modal.confirm({
title: '有正在生成的任务',
content: '重新采集将清空当前采集结果与出图方案,并停止在本面板跟踪正在生成的任务(已提交的后台生成会继续完成,但此处将看不到进度和导出入口)。确定要重新采集吗?',
okText: '继续采集', cancelText: '取消',
onOk: () => doScan(),
});
return;
}
doScan();
};
const doScan = async () => {
// 规划中:直接重置规划并继续采集(递增序号,迟到的规划响应会被丢弃)
if (planning) {
planSeqRef.current++;
setPlanning(false);
}
// 确认路径下停止对旧生成任务的本地跟踪(后台任务继续,不受影响)
if (generating) {
stopPolling();
setGenerating(false);
}
setScanning(true);
setRetrying(false);
try {
const r = await scanActiveTab();
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) throw new Error('未找到活动标签页');
// 首次尝试
let r = await tryScan(tab.id);
// 失败:支持站点上大概率是内容脚本未就绪 → 自动刷新重试一次(仅一次,防死循环)
if (!r) {
if (!SUPPORTED_URL_RE.test(tab.url ?? '')) {
throw new Error('当前页面不是支持的商品详情页(支持 Ozon / 1688 / 淘宝 / 天猫)');
}
setRetrying(true);
const waitLoaded = waitForTabComplete(tab.id); // 先注册监听再刷新,避免错过事件
await chrome.tabs.reload(tab.id);
await waitLoaded;
await new Promise(res => setTimeout(res, 1500)); // 给内容脚本注入留时间
r = await tryScan(tab.id);
if (!r) {
throw new Error('采集失败:已自动刷新重试仍无法采集,请确认当前页面是商品详情页后手动重试');
}
}
setResult(r);
setSuite(null);
setParamsOpen(false);
@@ -141,10 +234,19 @@ const App: React.FC = () => {
setSelectedKeys(keys);
setTitleEdit(r.texts.find(t => t.kind === 'title')?.content ?? '');
setDescEdit(r.texts.find(t => t.kind === 'desc')?.content ?? '');
setPriceEdit(r.texts.find(t => t.kind === 'price')?.content ?? '');
// 从参数表初始化尺寸/重量(编辑后随生成/规划请求覆盖回参数)
const pairs = r.texts.find(t => t.kind === 'params')?.pairs ?? [];
const dimPair = pairs.find(p => /尺寸|长宽高/i.test(p.key) && (String(p.value).match(/\d+(\.\d+)?/g) ?? []).length >= 3);
const nums = dimPair ? (String(dimPair.value).match(/\d+(\.\d+)?/g) ?? []) : [];
setDimsEdit(nums.length >= 3 ? { l: nums[0], w: nums[1], h: nums[2] } : { l: '', w: '', h: '' });
const weightPair = pairs.find(p => /重量/i.test(p.key));
setWeightEdit(weightPair ? String(weightPair.value) : '');
} catch (e) {
modal.error({ title: '采集失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
} finally {
setScanning(false);
setRetrying(false);
}
};
@@ -169,25 +271,66 @@ const App: React.FC = () => {
}, 3000);
}, [settings.baseUrl, settings.token, modal]);
/** 当前编辑后的文本素材(规划与生成共用) */
/** 当前编辑后的文本素材(规划与生成共用;尺寸/重量编辑值覆盖回参数表 */
const editedTexts = () => {
if (!result) return [];
const orig = (kind: string) => result.texts.find(t => t.kind === kind);
let pairs = [...(orig('params')?.pairs ?? [])];
// 尺寸/重量覆盖:先移除旧的同义项,再写入编辑值(有值才写)
const dropRe = [/尺寸|长宽高/i, /重量/i];
pairs = pairs.filter(p => !dropRe.some(re => re.test(p.key)));
const { l, w, h } = dimsEdit;
if (l || w || h) pairs.push({ key: '产品尺寸', value: `${l || '?'}×${w || '?'}×${h || '?'}` });
if (weightEdit) pairs.push({ key: '重量', value: weightEdit });
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 (priceEdit || orig('price')?.content) texts.push({ kind: 'price', content: priceEdit || 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('sales')?.content) texts.push({ kind: 'sales', content: orig('sales')!.content });
if (orig('shop')?.content) texts.push({ kind: 'shop', content: orig('shop')!.content });
if (pairs.length) texts.push({ kind: 'params', content: '', 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 智能规划出图方案 */
/** 提交生成任务(planOverride:刚规划出来的方案,避免闭包读到旧 state) */
const startGenerate = async (planOverride?: PlanItem[]): Promise<void> => {
if (!result) return;
if (selectedKeys.size === 0) {
modal.warning({ title: '请先在采集图片区勾选参考图' });
return;
}
const activePlan = (planOverride ?? plan).filter(p => p.count > 0);
if (activePlan.length === 0) {
modal.warning({ title: '出图方案的张数都是 0' });
return;
}
setGenerating(true);
setSuite(null);
try {
const payload = buildGeneratePayload(
result, selectedKeys, editedTexts(),
{ style_set: styleSet, style_prompt: currentStylePrompt, plan: activePlan, platform, model },
);
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: '知道了' });
}
};
/** AI 智能规划出图方案;勾选「规划并生成」时规划完成自动开始生成 */
const handlePlan = async () => {
if (!result) return modal.warning({ title: '请先采集商品页' });
const seq = ++planSeqRef.current;
setPlanning(true);
try {
const skuVariants = Array.from(new Set(
@@ -202,25 +345,34 @@ const App: React.FC = () => {
platform,
},
});
if (seq !== planSeqRef.current) return; // 规划已被重新采集重置,丢弃过期响应
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: '好的',
});
if (planThenGenerate) {
await startGenerate(data.items); // 规划完成 → 直接生成(用刚返回的方案,不弹确认)
} else {
const total = data.items.reduce((s, i) => s + i.count, 0);
modal.info({
title: 'AI 方案已生成',
content: `${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`,
okText: '好的',
});
}
} catch (e) {
if (seq !== planSeqRef.current) return; // 已重置,过期错误不弹窗
modal.error({ title: '规划失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
} finally {
setPlanning(false);
if (seq === planSeqRef.current) 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 currentStylePrompt = stylePrompts[styleSet]
?? STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.prompt ?? '';
const handleGenerate = () => {
if (!result) return;
@@ -229,26 +381,9 @@ const App: React.FC = () => {
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} 张。生成需要几分钟,可在下方查看进度。`,
content: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),模型「${model}」,风格「${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: '知道了' });
}
},
onOk: () => startGenerate(),
});
};
@@ -266,6 +401,13 @@ const App: React.FC = () => {
const groupImages = (groupKey: string): ImageMaterial[] =>
result?.images.filter(i => i.groupKey === groupKey) ?? [];
/** 预览用的全量图序列(主图→SKU→详情,与展示顺序一致) */
const collectedPreviewList: string[] = result
? (['main', 'sku', 'detail'] as const).flatMap(g => groupImages(g).map(i => i.url))
: [];
/** 生成结果的预览序列(仅成功的图) */
const resultPreviewList: string[] = suite ? suite.images.filter(i => i.status === 'ok').map(i => i.url) : [];
const toggleGroup = (groupKey: string, on: boolean) => {
const next = new Set(selectedKeys);
groupImages(groupKey).forEach(i => on ? next.add(i.key) : next.delete(i.key));
@@ -319,21 +461,21 @@ const App: React.FC = () => {
<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 className="btn btn-primary btn-main" disabled={scanning} onClick={handleScan}>
{retrying ? '刷新重试中…' : scanning ? '采集中…' : '快速采集'}
</button>
</div>
{/* ── 目标平台切换(决定文案语言 + 图片比例)── */}
<div className="platform-bar">
<span className="platform-label"></span>
<div className="pills">
<div className="seg-group">
{PLATFORM_OPTIONS.map(p => (
<span
<button
key={p.value}
className={`pill ${platform === p.value ? 'on' : ''}`}
className={`seg-btn ${platform === p.value ? 'on' : ''}`}
onClick={() => setPlatform(p.value)}
>{p.label}</span>
>{p.label}</button>
))}
</div>
<span className="platform-spec">
@@ -360,7 +502,7 @@ const App: React.FC = () => {
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<Field label="价格">
<input value={price} readOnly style={{ color: 'var(--text-2)' }} />
<input value={priceEdit} onChange={(e) => setPriceEdit(e.target.value)} />
</Field>
</div>
<div style={{ flex: 1 }}>
@@ -369,6 +511,32 @@ const App: React.FC = () => {
</Field>
</div>
</div>
<Field label="尺寸(长 ×× 高)">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input placeholder="长" value={dimsEdit.l} onChange={(e) => setDimsEdit(d => ({ ...d, l: e.target.value }))} />
<span style={{ color: 'var(--text-2)' }}>×</span>
<input placeholder="宽" value={dimsEdit.w} onChange={(e) => setDimsEdit(d => ({ ...d, w: e.target.value }))} />
<span style={{ color: 'var(--text-2)' }}>×</span>
<input placeholder="高" value={dimsEdit.h} onChange={(e) => setDimsEdit(d => ({ ...d, h: e.target.value }))} />
</div>
</Field>
<Field label="重量">
<input placeholder="如 428g / 0.43kg" value={weightEdit} onChange={(e) => setWeightEdit(e.target.value)} />
</Field>
{(sales || shop) && (
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<Field label="销量">
<input value={sales} readOnly style={{ color: 'var(--text-2)' }} />
</Field>
</div>
<div style={{ flex: 1 }}>
<Field label="店铺">
<input value={shop} readOnly style={{ color: 'var(--text-2)' }} />
</Field>
</div>
</div>
)}
{paramPairs.length > 0 && (
<div className="field">
<label style={{ cursor: 'pointer' }} onClick={() => setParamsOpen(v => !v)}>
@@ -386,7 +554,7 @@ const App: React.FC = () => {
</div>
)}
<Field label="商品描述(用于生成图内文案)">
<textarea rows={6} value={descEdit} onChange={(e) => setDescEdit(e.target.value)} />
<textarea rows={10} 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>
@@ -398,7 +566,8 @@ const App: React.FC = () => {
<Section
no="02"
title="采集图片"
extra={result ? `已选 ${selectedKeys.size} / ${result.images.length}` : undefined}
className="section-images"
extra={result ? `已选 ${selectedKeys.size} / ${result.images.filter(i => i.type !== 'video').length}` : undefined}
>
{!result ? (
<div className="empty"></div>
@@ -427,7 +596,7 @@ const App: React.FC = () => {
return (
<div key={img.key} className={`img-cell ${on ? 'on' : ''}`}
title="点击放大预览,勾选圆点选择图片"
onClick={() => setPreviewUrl(img.url)}>
onClick={() => openPreview(collectedPreviewList, collectedPreviewList.indexOf(img.url))}>
<img
src={img.thumbUrl || img.url}
referrerPolicy="no-referrer"
@@ -453,11 +622,21 @@ const App: React.FC = () => {
{/* ── 出图方案(整行)── */}
<Section
no="03"
title="出图方案"
title={`出图方案(共 ${totalPlanned} 张)`}
extra={
<span>
{planSource === 'ai' ? <span className="ai-tag">AI </span> : '默认方案'}
{' '} <b style={{ color: 'var(--primary)' }}>{totalPlanned}</b>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{planSource === 'ai' && <span className="ai-tag">AI </span>}
<label className="auto-chk" title="勾选后,AI 规划完成将自动开始生成">
<input
type="checkbox"
checked={planThenGenerate}
onChange={(e) => setPlanThenGenerate(e.target.checked)}
/>
</label>
<button className="btn btn-ai btn-main" disabled={!result || planning} onClick={handlePlan}>
<ThunderboltOutlined /> {planning ? '规划中…' : 'AI 智能规划'}
</button>
</span>
}
>
@@ -473,10 +652,7 @@ const App: React.FC = () => {
</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>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '10px 0 4px' }}>
{planSource === 'ai' && (
<button
className="btn btn-sm"
@@ -501,13 +677,28 @@ const App: React.FC = () => {
))}
</div>
</div>
<div className="field">
<label>
{stylePrompts[styleSet] !== undefined && (
<span
className="mini-check"
style={{ marginLeft: 8, fontWeight: 400 }}
onClick={() => setStylePrompts(p => {
const n = { ...p };
delete n[styleSet];
return n;
})}
></span>
)}
</label>
<textarea
rows={4}
value={currentStylePrompt}
onChange={(e) => setStylePrompts(p => ({ ...p, [styleSet]: e.target.value }))}
/>
</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
@@ -516,6 +707,30 @@ const App: React.FC = () => {
/>
</div>
)}
<span style={{ flex: 1 }} />
<Select
value={model}
onChange={setModel}
style={{ width: 260 }}
popupMatchSelectWidth={false}
disabled={generating}
>
{IMAGE_MODEL_OPTIONS.map(m => (
<Select.Option key={m.value} value={m.value} label={m.label}>
<div className="model-opt">
<div className="model-opt-name">{m.label}</div>
<div className="model-opt-desc">{m.desc}</div>
</div>
</Select.Option>
))}
</Select>
<button
className="btn btn-primary btn-main"
disabled={!result || generating || selectedKeys.size === 0 || totalPlanned === 0}
onClick={handleGenerate}
>
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
</button>
</div>
</Section>
@@ -550,7 +765,7 @@ const App: React.FC = () => {
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)}
onClick={() => img.status === 'ok' && openPreview(resultPreviewList, resultPreviewList.indexOf(img.url))}
>
{img.status === 'ok' ? (
<img src={img.url} referrerPolicy="no-referrer" />
@@ -567,11 +782,32 @@ const App: React.FC = () => {
)}
</Section>
{/* ── 图片放大预览 ── */}
{previewUrl && (
<div className="lightbox" onClick={() => setPreviewUrl(null)}>
<img src={previewUrl} referrerPolicy="no-referrer" onClick={(e) => e.stopPropagation()} />
<span className="lightbox-tip"></span>
{/* ── 图片放大预览(画廊:← → 切换)── */}
{preview && (
<div className="lightbox" onClick={() => setPreview(null)}>
<img
src={preview.list[preview.index]}
referrerPolicy="no-referrer"
onClick={(e) => e.stopPropagation()}
/>
{preview.list.length > 1 && (
<>
<button
className="lightbox-nav prev"
onClick={(e) => { e.stopPropagation(); previewPrev(); }}
title="上一张(←)"
></button>
<button
className="lightbox-nav next"
onClick={(e) => { e.stopPropagation(); previewNext(); }}
title="下一张(→)"
></button>
<div className="lightbox-counter" onClick={(e) => e.stopPropagation()}>
{preview.index + 1} / {preview.list.length}
</div>
</>
)}
<span className="lightbox-tip"> · </span>
</div>
)}
</div>
+70 -6
View File
@@ -41,7 +41,14 @@
.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; }
/* 图片列表占满 section 除标题外的剩余高度;min-height:0 是 flex 子项内滚动的关键 */
.img-groups { flex: 1 1 auto; min-height: 0; overflow-y: auto; max-height: 78vh; }
/* 采集图片区:section 自身去掉左右 padding,标题行自持 padding
图片区左侧对齐标题,右侧只留窄缝给滚动条(滚动条贴卡片内缘,图片与滚动条之间有小间距) */
.section-images { padding: 14px 0 !important; }
.section-images .section-head { padding: 0 16px; }
.section-images .img-groups { padding: 2px 8px 0 16px; }
.section-images .empty { margin: 0 16px; }
.divider { border-top: 1px solid var(--border); margin: 12px 0; }
/* ── 顶部 ── */
@@ -60,7 +67,7 @@
.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;
display: inline-flex; align-items: center; justify-content: 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);
@@ -72,6 +79,25 @@
font-weight: 600;
}
.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
/* AI 智能规划:深靛紫渐变(智慧/深度感) */
.btn-ai {
background: linear-gradient(135deg, #4338ca 0%, #6d28d9 100%);
border: none; color: #fff; font-weight: 600;
box-shadow: 0 2px 10px rgba(88, 60, 210, 0.35);
}
.btn-ai:hover:not([disabled]) {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #fff; box-shadow: 0 3px 14px rgba(88, 60, 210, 0.45);
}
/* 三个主操作按钮统一宽度 */
.btn-main { width: 160px; }
/* 「规划并生成」复选框 */
.auto-chk {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12.5px; color: var(--text-2); cursor: pointer; user-select: none;
}
.auto-chk input { accent-color: var(--primary); width: 14px; height: 14px; cursor: pointer; }
.auto-chk:hover { color: var(--text); }
.btn[disabled] { opacity: .5; cursor: not-allowed; }
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
.icon-btn {
@@ -120,9 +146,10 @@
/* ── 药丸选择 ── */
.pills { display: flex; flex-wrap: wrap; gap: 7px; }
.pill {
display: inline-flex; align-items: center; justify-content: center;
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;
user-select: none; transition: all .15s; line-height: 1.4;
}
.pill:hover { border-color: var(--primary); color: var(--primary); }
.pill.on {
@@ -138,7 +165,7 @@
}
.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-grid { display: grid; grid-template-columns: repeat(3, 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);
@@ -181,10 +208,26 @@
}
.platform-label { font-size: 13px; font-weight: 700; }
.platform-spec { margin-left: auto; font-size: 12.5px; color: var(--text-2); }
/* 平台切换:Button.Group 形式,选中态用低饱和灰绿(不抢主题色) */
.seg-group { display: inline-flex; }
.seg-btn {
padding: 7px 20px; font-size: 13.5px; font-family: inherit;
min-width: 120px; text-align: center; /* 选中加粗会让文字变宽,固定宽度消除跳动 */
background: #fff; border: 1px solid var(--border-strong); border-left-width: 0;
color: var(--text-2); cursor: pointer; user-select: none; transition: all .15s;
}
.seg-group .seg-btn:first-child { border-left-width: 1px; border-radius: 8px 0 0 8px; }
.seg-group .seg-btn:last-child { border-radius: 0 8px 8px 0; }
.seg-btn:hover { color: var(--text); background: var(--card-soft); }
.seg-btn.on {
background: #eef0eb; border-color: #c9cec6; color: #3f453c; font-weight: 700;
}
.seg-group .seg-btn.on + .seg-btn { border-left-color: #c9cec6; }
/* 三个平台药丸等宽:选中态加粗会让文字变宽,用固定 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);
@@ -192,9 +235,26 @@
flex-direction: column; gap: 12px; cursor: zoom-out;
}
.lightbox img {
max-width: 92%; max-height: 86%;
max-width: 88%; max-height: 82%;
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.lightbox-nav {
position: absolute; top: 50%; transform: translateY(-50%);
width: 40px; height: 64px; border: none; border-radius: 8px;
background: rgba(255, 255, 255, 0.12); color: #fff;
font-size: 30px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s; user-select: none;
}
.lightbox-nav:hover { background: rgba(255, 255, 255, 0.28); }
.lightbox-nav.prev { left: 14px; }
.lightbox-nav.next { right: 14px; }
.lightbox-counter {
position: absolute; top: 14px; right: 16px;
background: rgba(0, 0, 0, 0.5); color: #fff;
font-size: 13px; padding: 3px 10px; border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.lightbox-tip { color: rgba(255, 255, 255, 0.75); font-size: 12.5px; }
/* ── 出图方案 ── */
@@ -233,6 +293,10 @@
}
.hint { font-size: 12.5px; color: var(--text-2); line-height: 1.6; }
/* ── 模型下拉选项 ── */
.model-opt-name { font-size: 13.5px; font-weight: 600; color: var(--text); }
.model-opt-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; }
.ok-chip {
display: inline-flex; align-items: center; gap: 5px;
background: #f6ffed; border: 1px solid #b7eb8f; color: #389e0d;
+2 -5
View File
@@ -1,6 +1,3 @@
allowBuilds:
esbuild: set this to true or false
spawn-sync: set this to true or false
onlyBuiltDependencies:
- esbuild
- spawn-sync
esbuild: true
spawn-sync: true
+71
View File
@@ -0,0 +1,71 @@
/**
* 1688 提取器离线验证 —— 用 reference/1688.html 快照跑真实提取逻辑。
*
* 用法:node scripts/verify-1688.mjs [快照路径]
* 依赖 esbuild 打包 TS 提取器(node_modules 里有)。
*/
import { readFileSync, readdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const htmlPath = process.argv[2] ?? '/Users/joey/sites/seller-store/ozon-seller-kit/reference/1688.html';
// 1. 打包提取器(纯函数无 DOM 依赖);pnpm 布局下 esbuild bin 可能不在 .bin,动态查找
function findEsbuild() {
const candidates = [join(root, 'node_modules/.bin/esbuild')];
try {
const pnpmDir = join(root, 'node_modules/.pnpm');
for (const d of readdirSync(pnpmDir)) {
if (d.startsWith('esbuild@')) {
candidates.push(join(pnpmDir, d, 'node_modules/esbuild/bin/esbuild'));
}
}
} catch { /* ignore */ }
candidates.push('esbuild'); // 全局兜底
for (const c of candidates) {
try { execSync(`${c} --version`, { stdio: 'pipe' }); return c; } catch { /* try next */ }
}
throw new Error('找不到可用的 esbuild');
}
const outFile = '/tmp/1688-state.bundle.mjs';
execSync(`${JSON.stringify(findEsbuild())} src/collector/1688-state.ts --bundle --format=esm --outfile=${outFile}`, { cwd: root });
const { extract1688State } = await import(`file://${outFile}`);
// 2. 从快照提取 script#3 并在 window 垫片里求值(还原 window.context
const html = readFileSync(htmlPath, 'utf8');
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(m => m[1]);
const ctxScript = scripts.find(s => s.includes('window.context')) ?? scripts[3];
const windowShim = {};
new Function('window', 'document', 'location', ctxScript)(
windowShim, { querySelector: () => null }, { hostname: 'detail.1688.com' },
);
const context = windowShim.context;
if (!context) {
console.error('✗ window.context 求值失败');
process.exit(1);
}
console.log(`✓ window.context 就绪(keys: ${Object.keys(context).join(', ')}`);
// 3. 跑提取器并断言
const st = extract1688State(context);
const assert = (cond, msg) => { if (!cond) { console.error(`${msg}`); process.exit(1); } console.log(`${msg}`); };
assert(st !== null, '提取器返回非空');
assert(!!st.title && st.title.length >= 5, `标题: ${st.title}`);
assert(st.galleryImages.length >= 5, `主图 ${st.galleryImages.length}`);
assert(st.videos.length >= 1 && /\.mp4/.test(st.videos[0].url), `视频: ${st.videos[0]?.url?.slice(0, 60) ?? '无'}`);
assert(st.skus.length >= 3, `SKU ${st.skus.length} 个(含规格名/图)`);
assert(st.skus.every(s => /:/.test(s.name)), `SKU : ${st.skus.slice(0, 3).map(s => s.name).join(' | ')}`);
assert(!!st.price && /\d/.test(st.price), `价格区间: ${st.price}`);
assert(!!st.sales && /\d/.test(st.sales), `销量: ${st.sales}`);
assert(!!st.shop && st.shop.length >= 2, `店铺: ${st.shop}`);
const dimPair = st.params.find(p => p.key === '产品尺寸');
assert(!!dimPair && /\d+×\d+×\d+/.test(dimPair.value), `产品尺寸: ${dimPair?.value}`);
assert(st.params.some(p => p.key === '重量'), `重量: ${st.params.find(p => p.key === '重量')?.value}`);
assert(!!st.detailUrl?.startsWith('https://'), `detailUrl: ${st.detailUrl?.slice(0, 70)}`);
const priced = st.skus.filter(s => s.price);
assert(priced.length >= 1, `SKU 价格明细 ${priced.length} 条(如 ${priced[0]?.name} ${priced[0]?.price}`);
console.log('\n全部断言通过 ✅');
+55 -26
View File
@@ -54,12 +54,43 @@ 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: '暗调质感' },
{
value: 1,
label: '高级质感大片',
prompt: '高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见,色彩层次高级克制,商业画册级品质,构图干净、留白充足',
},
{
value: 2,
label: '清新生活场景',
prompt: '明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化,真实自然的氛围感,绿植与暖色织物点缀,温馨有人气',
},
{
value: 3,
label: '极简白底规范',
prompt: '极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图,安静的高级感,画面只保留轻微的自然接触投影',
},
{
value: 4,
label: '炫彩促销风',
prompt: '高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围,构图抢眼、视觉冲击力强',
},
{
value: 5,
label: '暗调轻奢',
prompt: '暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感,如美术馆展陈般的呈现',
},
{
value: 6,
label: '俄式风情',
prompt: '俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,带一丝巴洛克式的华丽细节,适合俄语区市场',
},
{
value: 7,
label: '北欧极简',
prompt: '北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,舒适宁静的氛围',
},
] as const;
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
@@ -71,6 +102,20 @@ export const PLATFORM_OPTIONS = [
export type PlatformId = (typeof PLATFORM_OPTIONS)[number]['value'];
/** 生图模型(通义 DashScope):下拉可选 + 中文特点说明 */
export const IMAGE_MODEL_OPTIONS = [
{
value: 'qwen-image-3.0-pro',
label: 'qwen-image-3.0-pro',
desc: '同步生成,响应快、图文理解强,适合快速批量出图',
},
{
value: 'wan2.7-image-pro',
label: 'wan2.7-image-pro',
desc: '异步精修,质感与细节更强,适合高质量电商大片',
},
] as const;
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' },
@@ -172,38 +217,22 @@ 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;
style_prompt?: string;
plan: PlanItem[];
platform: string;
model?: string | null;
}
/** 组装无状态生成请求:编辑的文本 + 已勾选图片 + 出图方案 */
/** 组装无状态生成请求:编辑的文本 + 已勾选图片 + 出图方案 */
export function buildGeneratePayload(
result: ScanResult,
selectedKeys: Set<string>,
edits: { title?: string; desc?: string },
config: { style_set: number; plan: PlanItem[]; platform: string },
texts: GeneratePayload['texts'],
config: { style_set: number; style_prompt?: string; plan: PlanItem[]; platform: string; model?: string | null },
): 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 };
}
+43
View File
@@ -0,0 +1,43 @@
/**
* isolated world 侧的桥客户端:请求 MAIN world 读取页面全局变量。
* 自带重试(桥脚本可能比内容脚本晚注入),超时返回空对象——调用方降级 DOM。
*/
let seq = 0;
export function readWindowKeys(keys: string[], timeoutMs = 1200): Promise<Record<string, any>> {
const requestId = `sc-${Date.now()}-${seq++}`;
return new Promise((resolve) => {
let done = false;
const started = Date.now();
const cleanup = () => {
window.removeEventListener('message', onMsg);
clearTimeout(retryTimer);
clearTimeout(giveUpTimer);
};
const onMsg = (ev: MessageEvent) => {
if (ev.source !== window) return;
const d = ev.data as { type?: string; requestId?: string; payload?: Record<string, any> } | null;
if (d?.type === 'sc-bridge-res' && d.requestId === requestId) {
done = true;
cleanup();
resolve(d.payload ?? {});
}
};
window.addEventListener('message', onMsg);
const send = () => window.postMessage({ type: 'sc-bridge-req', requestId, keys }, '*');
send();
const retryTimer = setInterval(() => {
if (done) return;
if (Date.now() - started > timeoutMs) return;
send();
}, 250);
const giveUpTimer = setTimeout(() => {
if (done) return;
cleanup();
resolve({});
}, timeoutMs);
});
}
+171
View File
@@ -0,0 +1,171 @@
/**
* 1688 SSR 状态提取器(主路径)——读取 MAIN world 桥回传的 window.context。
*
* 页面第 4 个内联 script 把完整商品数据挂在 window.context
* window.context.result.data.<模块名>.fields
* 结构与 Ozon 的 data-state 同构(34 个模块)。本文件只做纯数据提取,
* 不碰 DOM / URL,方便用 reference/1688.html 快照离线验证(scripts/verify-1688.mjs)。
*/
export interface Sku1688 {
name: string; // "规格型号:黑盒【27件套】"
image?: string;
price?: string; // 该 SKU 价格
canBookCount?: number; // 该 SKU 库存
length?: number;
width?: number;
height?: number;
weight?: number;
}
export interface State1688 {
title?: string;
price?: string; // "19.90-24.60"
sales?: string; // 销量
shop?: string; // 公司/店铺名
unit?: string; // 单位(套/件)
offerId?: string;
categoryIds?: string[];
galleryImages: string[]; // 原图
videos: Array<{ url: string; cover?: string }>;
skus: Sku1688[];
params: Array<{ key: string; value: string }>;
detailUrl?: string; // 详情数据 CDN 端点(图文详情的图片列表来源)
}
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
export function deepFind(root: unknown, key: string, maxNodes = 300_000): any {
if (root == null || typeof root !== 'object') return undefined;
const queue: unknown[] = [root];
const seen = new Set<object>();
let visited = 0;
while (queue.length) {
const cur = queue.shift();
if (cur == null || typeof cur !== 'object') continue;
if (++visited > maxNodes) return undefined;
if (seen.has(cur as object)) continue;
seen.add(cur as object);
for (const [k, v] of Object.entries(cur as Record<string, unknown>)) {
if (k === key) return v;
if (v && typeof v === 'object') queue.push(v);
}
}
return undefined;
}
const num = (v: unknown): number | undefined => {
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
export function extract1688State(context: unknown): State1688 | null {
if (!context || typeof context !== 'object') return null;
// ── gallery:主图 + 视频 ──
const gallery = deepFind(context, 'gallery');
const galleryFields = gallery?.fields ?? gallery ?? {};
const mainImages: string[] = [];
const pushImg = (u: unknown) => {
if (typeof u === 'string' && /^https?:\/\//.test(u) && !mainImages.includes(u)) mainImages.push(u);
};
(galleryFields.mainImage ?? []).forEach(pushImg);
(galleryFields.offerImgList ?? []).forEach((it: any) => typeof it === 'string' ? pushImg(it) : pushImg(it?.imgUrl ?? it?.url ?? it?.image));
const videos: Array<{ url: string; cover?: string }> = [];
const videoObj = galleryFields.video;
if (videoObj?.videoUrl) videos.push({ url: videoObj.videoUrl, cover: videoObj.coverUrl });
(galleryFields.videos ?? []).forEach((v: any) => v?.videoUrl && videos.push({ url: v.videoUrl, cover: v.coverUrl }));
// ── tempModel(在 Root 模块里):标题/销量/公司/类目 ──
const temp = deepFind(context, 'tempModel') ?? {};
const title = typeof temp.offerTitle === 'string' ? temp.offerTitle : undefined;
// ── SKUskuModel.skuProps 全维度展开 ──
const skus: Sku1688[] = [];
const skuModel = deepFind(context, 'skuModel');
for (const prop of skuModel?.skuProps ?? []) {
for (const v of prop?.value ?? []) {
if (!v?.name) continue;
skus.push({
name: `${prop.prop ?? '规格'}:${v.name}`,
image: typeof v.imageUrl === 'string' ? v.imageUrl : undefined,
});
}
}
// ── 价格:区间 + 每 SKU 明细 ──
const tradeModel = deepFind(context, 'tradeModel') ?? {};
let price: string | undefined;
if (typeof tradeModel.minPrice === 'string' && typeof tradeModel.maxPrice === 'string') {
price = tradeModel.minPrice === tradeModel.maxPrice
? `¥${tradeModel.minPrice}`
: `¥${tradeModel.minPrice}-${tradeModel.maxPrice}`;
}
const skuMap = deepFind(context, 'skuMapOriginal') ?? [];
const byName = new Map(skus.map(s => [s.name.split(':').pop() ?? s.name, s]));
for (const row of skuMap) {
const s = byName.get(row?.specAttrs);
if (s) {
if (typeof row.price === 'string') s.price = `¥${row.price}`;
s.canBookCount = num(row.canBookCount);
}
}
// ── 件重尺:每个 SKU 的长宽高/体积/重量 ──
const packRows: any[] = deepFind(context, 'pieceWeightScaleInfo') ?? [];
const params: Array<{ key: string; value: string }> = [];
if (packRows.length) {
for (const r of packRows) {
const s = byName.get(r?.sku1);
if (s) {
s.length = num(r.length); s.width = num(r.width);
s.height = num(r.height); s.weight = num(r.weight);
}
}
const first = packRows[0];
if (num(first.length) && num(first.width) && num(first.height)) {
params.push({ key: '产品尺寸', value: `${first.length}×${first.width}×${first.height}cm` });
}
if (num(first.weight)) {
params.push({ key: '重量', value: `${first.weight}g` });
}
}
// ── 参数表:productAttributes(模块可能服务端报错为空,DOM 兜底)──
const attrs = deepFind(context, 'productAttributes');
const attrFields = attrs?.fields ?? {};
for (const row of attrFields.attributes ?? attrFields.props ?? []) {
const k = typeof row?.name === 'string' ? row.name : row?.propertyName;
const v = typeof row?.value === 'string' ? row.value : row?.valueName;
if (k && v) params.push({ key: String(k), value: String(v) });
}
// ── SKU 价格明细(少量时并入参数,供规划/尺寸图参考)──
const priced = skus.filter(s => s.price);
if (priced.length > 0 && priced.length <= 6) {
params.push({ key: 'SKU价格', value: priced.map(s => `${s.name.split(':').pop()} ${s.price}`).join('') });
}
const detailUrl = deepFind(context, 'detailUrl');
const categoryIds = [
temp.postCategoryId ? String(temp.postCategoryId) : '',
temp.topCategoryId ? String(temp.topCategoryId) : '',
].filter(Boolean);
if (!title && mainImages.length === 0 && skus.length === 0) return null;
return {
title,
price,
sales: temp.saledCount != null ? `${temp.saledCount}` : undefined,
shop: typeof temp.companyName === 'string' ? temp.companyName : undefined,
unit: typeof temp.offerUnit === 'string' ? temp.offerUnit : undefined,
offerId: temp.offerId != null ? String(temp.offerId) : undefined,
categoryIds,
galleryImages: mainImages,
videos,
skus,
params,
detailUrl: typeof detailUrl === 'string' && /^https?:\/\//.test(detailUrl) ? detailUrl : undefined,
};
}
+31
View File
@@ -52,3 +52,34 @@ export function queryAllDeep(selectors: string[]): Element[] {
}
return out;
}
/**
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
* 有界滚动:步进 + 等待页面高度增长,页面不再变高或达到步数上限即停——
* 防止底部「为你推荐」无限加载把采集卡死。滚完恢复原位。
*/
export async function autoScrollToBottom(
opts: { stepPx?: number; stepMs?: number; maxSteps?: number } = {}
): Promise<void> {
const { stepPx = 900, stepMs = 260, maxSteps = 40 } = opts;
const startY = window.scrollY;
let lastHeight = document.body.scrollHeight;
let stagnant = 0; // 连续不增长的步数
for (let i = 0; i < maxSteps; i++) {
window.scrollBy({ top: stepPx, behavior: 'auto' });
await new Promise(r => setTimeout(r, stepMs));
const atBottom = window.scrollY + window.innerHeight >= document.body.scrollHeight - 4;
const h = document.body.scrollHeight;
if (h > lastHeight + 50) {
lastHeight = h;
stagnant = 0; // 页面还在长(懒加载进来新内容),继续
} else if (atBottom) {
stagnant++;
if (stagnant >= 2) break; // 到底且连续两步没有新内容,收工
}
}
// 多数详情图是进入视口才加载,到底后再等一拍让 <img> 完成 src 替换
await new Promise(r => setTimeout(r, stepMs));
window.scrollTo({ top: startY, behavior: 'auto' });
}
+119
View File
@@ -0,0 +1,119 @@
/**
* 通用合并器:各平台采集路径共用的结果装配逻辑。
* 从 scan.ts 抽出(平台拆分),平台文件只负责各路径的数据获取。
*/
import type { ImageMaterial } from './image';
import type { TextMaterial } from './text';
import type { BreadcrumbItem } from './ozon-state';
import type { SiteProfile } from '../profiles/types';
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'; // 主路径
}
export type { ImageMaterial, TextMaterial };
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
{ key: 'main', name: '主图' },
{ key: 'sku', name: 'SKU图片' },
{ key: 'detail', name: '详情图' },
{ key: 'video', name: '视频' },
];
/** 按组分组合并:靠前来源优先,靠后来源填缺,按 dedupeKey 去重后重排 index */
export 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;
}
import { dedupeKey } from './url';
/** 汇总统计与警告,产出最终 ScanResult */
export 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,
};
}
/** 经 background 拉取文本(跨域资源:1688 详情 CDN / mtop API */
export function bgFetchText(url: string, referer?: string): Promise<string | null> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(
{ action: 'fetchText', url, referer },
(res: { ok?: boolean; text?: string }) => {
if (chrome.runtime.lastError || !res?.ok) return resolve(null);
resolve(res.text ?? null);
},
);
});
}
/** 从任意文本里容错提取图片 URL 列表 */
export function extractImageUrls(text: string): string[] {
const urls = text.match(/https?:\/\/[^"'\\\s]+\.(?:jpg|jpeg|png|webp)/gi) ?? [];
return Array.from(new Set(urls));
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 1688 采集编排(平台文件):
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
* ② description.detailUrl → 详情数据 CDN 端点(绕开懒加载,best-effort
* ③ DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl } from '../url';
import { readWindowKeys } from '../../bridge/read-window';
import { extract1688State, type State1688 } from '../1688-state';
import { bgFetchText, extractImageUrls, finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
export async function scan1688(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
let st: State1688 | null = null;
// ① 桥读 window.context
const keys = await readWindowKeys(['context']);
st = extract1688State(keys['context']);
if (st) {
if (st.title) primaryTexts.push({ kind: 'title', content: st.title });
if (st.price) primaryTexts.push({ kind: 'price', content: st.price });
if (st.sales) primaryTexts.push({ kind: 'sales', content: st.sales });
if (st.shop) primaryTexts.push({ kind: 'shop', content: st.shop });
if (st.params.length) {
primaryTexts.push({
kind: 'params',
content: st.params.map(p => `${p.key}: ${p.value}`).join('\n'),
pairs: st.params,
});
}
let idx = 0;
st.galleryImages.forEach(u => {
primaryImages.push({
key: `main-${String(idx + 1).padStart(3, '0')}`,
groupKey: 'main',
groupName: '主图',
url: toOriginalUrl(u),
thumbUrl: u,
index: idx++,
type: 'img',
});
});
st.skus.forEach(s => {
if (!s.image) return;
primaryImages.push({
key: `sku-${String(primaryImages.filter(m => m.groupKey === 'sku').length + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: s.name || undefined,
url: toOriginalUrl(s.image),
thumbUrl: s.image,
index: idx++,
type: 'img',
});
});
st.videos.forEach(v => {
primaryImages.push({
key: `video-${String(primaryImages.filter(m => m.groupKey === 'video').length + 1).padStart(3, '0')}`,
groupKey: 'video',
groupName: '视频',
url: v.url,
thumbUrl: v.cover ?? '',
index: idx++,
type: 'video',
});
});
source = 'state';
}
// ② detailUrl → 详情图列表(绕开懒加载)
if (st?.detailUrl) {
try {
const text = await bgFetchText(st.detailUrl, 'https://detail.1688.com/');
if (text) {
let di = 0;
for (const u of extractImageUrls(text).slice(0, 80)) {
primaryImages.push({
key: `detail-${String(++di).padStart(3, '0')}`,
groupKey: 'detail',
groupName: '详情图',
url: toOriginalUrl(u),
thumbUrl: u,
index: primaryImages.length,
type: 'img',
});
}
if (di > 0) source = 'mixed';
}
} catch (err) {
console.warn('[SuiteCollector] 1688 detailUrl 拉取异常:', err);
}
}
// ③ DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果)
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
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 === 'state') source = 'mixed';
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, itemId, texts, images, [], source);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Ozon 采集编排(平台文件):四路径合并(来自 extension-v2 生产逻辑)。
* ① SSR widget stateDOM data-state 属性,白名单)★主路径
* ② JSON-LDschema.org/Product
* ③ 站内页 JSON APIentrypoint-api.bx
* ④ DOM data-widget 选择器兜底 + 详情图补充
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl, toThumbUrl } from '../url';
import { extractJsonLd } from '../jsonld';
import { fetchOzonPageData, type OzonPageData } from '../ozon-api';
import { extractOzonState, type OzonStateData } from '../ozon-state';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
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 };
}
export 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';
}
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
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);
}
@@ -0,0 +1,86 @@
/**
* 淘宝/天猫 采集编排(平台文件):
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
* isolated world 读不到 window 变量,v1 直读是无效的)
* ② mtop 签名 API 兜底:pcdetail.data.get(主数据)+ detail.getdesc(图文详情,绕开懒加载)
* ③ DOM 兜底 + 补充
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl } from '../url';
import { readWindowKeys } from '../../bridge/read-window';
import { buildFromSSR } from '../ssr-builder';
import { taobaoStateFromBridge, taobaoStateFromMtop } from '../taobao-state';
import { fetchDescImages, fetchPcDetailData } from '../taobao-mtop';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
export async function scanTaobao(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
// ① 桥读页面全局
const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']);
const ssrData = taobaoStateFromBridge(keys);
if (ssrData && (ssrData.item.title || (ssrData.item.images ?? []).length > 0)) {
const built = buildFromSSR(ssrData, profile);
// ssr-builder 的本地类型 groupKey 是 string,这里对齐到 ImageGroupKey
primaryTexts = built.texts;
primaryImages = built.images as ImageMaterial[];
source = 'ssr';
}
// ② mtop:桥拿不到时兜底主数据;图文详情任何时候都尝试(绕开懒加载)
if (itemId && !ssrData) {
try {
const mtopData = await fetchPcDetailData(itemId);
const fromMtop = taobaoStateFromMtop(mtopData);
if (fromMtop && ((fromMtop.item.images ?? []).length > 0 || fromMtop.item.title)) {
const built = buildFromSSR(fromMtop, profile);
primaryTexts = built.texts;
primaryImages = built.images as ImageMaterial[];
source = 'api';
}
} catch (err) {
console.warn('[SuiteCollector] mtop pcdetail 异常:', err);
}
}
if (itemId) {
try {
const descUrls = await fetchDescImages(itemId);
let di = 0;
for (const u of descUrls.slice(0, 60)) {
primaryImages.push({
key: `detail-${String(++di).padStart(3, '0')}`,
groupKey: 'detail',
groupName: '详情图',
url: toOriginalUrl(u),
thumbUrl: u,
index: primaryImages.length,
type: 'img',
});
}
if (di > 0 && source !== 'dom') source = 'mixed';
} catch (err) {
console.warn('[SuiteCollector] mtop getdesc 异常:', err);
}
}
// ③ DOM 兜底 + 补充
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
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 = source === 'dom' ? source : 'mixed';
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, itemId, texts, images, [], source);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
+20 -299
View File
@@ -1,307 +1,21 @@
/**
* 统一采集引擎入口 - 扫描当前页
* 统一采集引擎入口 - 只做路由:按平台分发到 platforms/ 下的平台文件。
*
* 平台选择采集策略
* - ozon四路径(SSR data-state ★主路径 → JSON-LD → 页 JSON API DOM 兜底),多源合并
* - taobao/tmallSSRwindow.__ICE_APP_CONTEXT__)★主路径 + DOM 补充(详情图在 DOM
* - 1688:纯 DOM(多套选择器变体
*
* 各路径产出的素材最终走同一个合并器:文本按 kind 合并(params 按键并集),
* 图片按组去重后重排 key。
* 平台编排逻辑(各路径与合并策略)见
* platforms/ozon.ts Ozon 四路径(SSR data-state / JSON-LD / 站内 API / DOM
* platforms/taobao.ts 淘宝/天猫(桥读全局 / mtop 签名 API / DOM
* platforms/1688.ts 1688(桥读 window.context / detailUrl 详情 / DOM
* 共用合并器见 merge.ts。
*/
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';
import { readWindowKeys } from '../bridge/read-window';
import { scanOzon } from './platforms/ozon';
import { scanTaobao } from './platforms/taobao';
import { scan1688 } from './platforms/1688';
import type { ScanResult } from './merge';
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 type { ScanResult };
export type { ImageMaterial, TextMaterial } from './merge';
export async function scanCurrentPage(): Promise<ScanResult | null> {
const profile = matchProfile(location.href);
@@ -334,9 +48,16 @@ export async function scanCurrentPage(): Promise<ScanResult | null> {
return result;
}
// 诊断工具:列出页面全局数据键(在商品页 console 跑 __SuiteCollector.probe()
export async function probeWindowKeys(): Promise<string[]> {
const res = await readWindowKeys(['*'], 1500);
return (res['__sc_window_keys__'] as string[]) ?? [];
}
// 暴露到全局供 side panel / console 调用
if (typeof window !== 'undefined') {
(window as any).__SuiteCollector = {
scan: scanCurrentPage,
probe: probeWindowKeys,
};
}
+11 -9
View File
@@ -7,7 +7,7 @@ import type { SSRData } from './ssr';
// 直接定义类型避免循环依赖
interface TextMaterial {
kind: 'title' | 'price' | 'params' | 'desc';
kind: 'title' | 'price' | 'params' | 'desc' | 'selling_point' | 'brand' | 'sales' | 'shop';
content: string;
pairs?: Array<{ key: string; value: string }>;
}
@@ -89,18 +89,20 @@ export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
});
});
// 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
// 5. SKU 图(skuBase.props 全维度展开:颜色分类、尺码等
// 多维规格时名称带维度前缀("尺码:M"),单维保持原名("粉色")
const skuPropsList = data.skuBase?.props ?? [];
const multiDim = skuPropsList.length > 1;
for (const skuProp of skuPropsList) {
(skuProp.values ?? []).forEach(v => {
if (!v.image) return; // 有些 SKU 没配图(如纯文字规格)
const origUrl = toOriginalUrl(v.image);
const name = multiDim ? `${skuProp.name}:${v.name}` : v.name;
images.push({
key: `sku-${String(i + 1).padStart(3, '0')}`,
key: `sku-${String(images.filter(m => m.groupKey === 'sku').length + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: v.name || undefined,
variantName: name || undefined,
url: origUrl,
thumbUrl: v.image,
index: idx++,
+191
View File
@@ -0,0 +1,191 @@
/**
* 淘宝 mtop 签名 API(路径②,兜底/补数据)。
*
* 接口(竞品毛子ERP 验证可用):
* mtop.taobao.pcdetail.data.get/1.0 —— 主数据(item/skuBase/skuCore
* mtop.taobao.detail.getdesc/7.0 —— 图文详情(绕过"用户必须点开详情tab"
* 签名算法(公开):sign = md5(`${token}&${t}&${appKey}&${data}`)
* token 取 cookie _m_h5_tk 的第一段(游客 cookie 也有)。
* 请求经 background 代理(host_permissions 覆盖 h5api 域名,带 cookie)。
*/
// ── 紧凑 MD5 实现(SparkMD5 核心逻辑)───────────────────────────────────
function md5cycle(x: number[], k: number[]): void {
let [a, b, c, d] = x;
const ff = (a: number, b: number, c: number, d: number, x: number, s: number, t: number) =>
cmn((b & c) | (~b & d), a, b, x, s, t);
const gg = (a: number, b: number, c: number, d: number, x: number, s: number, t: number) =>
cmn((b & d) | (c & ~d), a, b, x, s, t);
const hh = (a: number, b: number, c: number, d: number, x: number, s: number, t: number) =>
cmn(b ^ c ^ d, a, b, x, s, t);
const ii = (a: number, b: number, c: number, d: number, x: number, s: number, t: number) =>
cmn(c ^ (b | ~d), a, b, x, s, t);
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
a = (((a + q) | 0) + ((x + t) | 0)) | 0;
return (((a << s) | (a >>> (32 - s))) + b) | 0;
}
a = ff(a, b, c, d, k[0], 7, -680876936);
d = ff(d, a, b, c, k[1], 12, -389564586);
c = ff(c, d, a, b, k[2], 17, 606105819);
b = ff(b, c, d, a, k[3], 22, -1044525330);
a = ff(a, b, c, d, k[4], 7, -176418897);
d = ff(d, a, b, c, k[5], 12, 1200080426);
c = ff(c, d, a, b, k[6], 17, -1473231341);
b = ff(b, c, d, a, k[7], 22, -45705983);
a = ff(a, b, c, d, k[8], 7, 1770035416);
d = ff(d, a, b, c, k[9], 12, -1958414417);
c = ff(c, d, a, b, k[10], 17, -42063);
b = ff(b, c, d, a, k[11], 22, -1990404162);
a = ff(a, b, c, d, k[12], 7, 1804603682);
d = ff(d, a, b, c, k[13], 12, -40341101);
c = ff(c, d, a, b, k[14], 17, -1502002290);
b = ff(b, c, d, a, k[15], 22, 1236535329);
a = gg(a, b, c, d, k[1], 5, -165796510);
d = gg(d, a, b, c, k[6], 9, -1069501632);
c = gg(c, d, a, b, k[11], 14, 643717713);
b = gg(b, c, d, a, k[0], 20, -373897302);
a = gg(a, b, c, d, k[5], 5, -701558691);
d = gg(d, a, b, c, k[10], 9, 38016083);
c = gg(c, d, a, b, k[15], 14, -660478335);
b = gg(b, c, d, a, k[4], 20, -405537848);
a = gg(a, b, c, d, k[9], 5, 568446438);
d = gg(d, a, b, c, k[14], 9, -1019803690);
c = gg(c, d, a, b, k[3], 14, -187363961);
b = gg(b, c, d, a, k[8], 20, 1163531501);
a = gg(a, b, c, d, k[13], 5, -1444681467);
d = gg(d, a, b, c, k[2], 9, -51403784);
c = gg(c, d, a, b, k[7], 14, 1735328473);
b = gg(b, c, d, a, k[12], 20, -1926607734);
a = hh(a, b, c, d, k[5], 4, -378558);
d = hh(d, a, b, c, k[8], 11, -2022574463);
c = hh(c, d, a, b, k[11], 16, 1839030562);
b = hh(b, c, d, a, k[14], 23, -35309556);
a = hh(a, b, c, d, k[1], 4, -1530992060);
d = hh(d, a, b, c, k[4], 11, 1272893353);
c = hh(c, d, a, b, k[7], 16, -155497632);
b = hh(b, c, d, a, k[10], 23, -1094730640);
a = hh(a, b, c, d, k[13], 4, 681279174);
d = hh(d, a, b, c, k[0], 11, -358537222);
c = hh(c, d, a, b, k[3], 16, -722521979);
b = hh(b, c, d, a, k[6], 23, 76029189);
a = hh(a, b, c, d, k[9], 4, -640364487);
d = hh(d, a, b, c, k[12], 11, -421815835);
c = hh(c, d, a, b, k[15], 16, 530742520);
b = hh(b, c, d, a, k[2], 23, -995338651);
a = ii(a, b, c, d, k[0], 6, -198630844);
d = ii(d, a, b, c, k[7], 10, 1126891415);
c = ii(c, d, a, b, k[14], 15, -1416354905);
b = ii(b, c, d, a, k[5], 21, -57434055);
a = ii(a, b, c, d, k[12], 6, 1700485571);
d = ii(d, a, b, c, k[3], 10, -1894986606);
c = ii(c, d, a, b, k[10], 15, -1051523);
b = ii(b, c, d, a, k[1], 21, -2054922799);
a = ii(a, b, c, d, k[8], 6, 1873313359);
d = ii(d, a, b, c, k[15], 10, -30611744);
c = ii(c, d, a, b, k[6], 15, -1560198380);
b = ii(b, c, d, a, k[13], 21, 1309151649);
a = ii(a, b, c, d, k[4], 6, -145523070);
d = ii(d, a, b, c, k[11], 10, -1120210379);
c = ii(c, d, a, b, k[2], 15, 718787259);
b = ii(b, c, d, a, k[9], 21, -343485551);
x[0] = (x[0] + a) | 0; x[1] = (x[1] + b) | 0; x[2] = (x[2] + c) | 0; x[3] = (x[3] + d) | 0;
}
function md5blk(s: string): number[] {
const md5blks: number[] = [];
for (let i = 0; i < 16; i++) {
md5blks[i] = (s.charCodeAt(i * 4)) | (s.charCodeAt(i * 4 + 1) << 8) |
(s.charCodeAt(i * 4 + 2) << 16) | (s.charCodeAt(i * 4 + 3) << 24);
}
return md5blks;
}
function rhex(n: number): string {
const hexChr = '0123456789abcdef';
let s = '';
for (let j = 0; j < 4; j++) {
s += hexChr.charAt((n >> (j * 8 + 4)) & 0x0f) + hexChr.charAt((n >> (j * 8)) & 0x0f);
}
return s;
}
export function md5(s: string): string {
const n = s.length;
const state = [1732584193, -271733879, -1732584194, 271733878];
let i: number;
for (i = 64; i <= n; i += 64) md5cycle(state, md5blk(s.substring(i - 64, i)));
const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const rest = s.substring(i - 64);
for (i = 0; i < rest.length; i++) {
tail[i >> 2] |= rest.charCodeAt(i) << ((i % 4) << 3);
}
tail[i >> 2] |= 0x80 << ((i % 4) << 3);
if (i > 55) {
md5cycle(state, tail);
for (i = 0; i < 16; i++) tail[i] = 0;
}
tail[14] = n * 8;
md5cycle(state, tail);
return state.map(rhex).join('');
}
// ── mtop 调用 ────────────────────────────────────────────────────────────
const APP_KEY = '12574478';
function mtopToken(): string {
const m = document.cookie.match(/(?:^|;\s*)_m_h5_tk=([^;]+)/);
return m ? decodeURIComponent(m[1]).split('.')[0] : '';
}
function buildMtopUrl(api: string, v: string, data: string): string {
const host = /tmall\.com$/.test(location.hostname) ? 'h5api.m.tmall.com' : 'h5api.m.taobao.com';
const t = Date.now();
const token = mtopToken();
const sign = md5(`${token}&${t}&${APP_KEY}&${data}`);
return `https://${host}/h5/${api}/${v}/?jsv=2.6.1&appKey=${APP_KEY}&t=${t}` +
`&sign=${sign}&api=${api}&v=${v}&type=json&dataType=json&timeout=20000` +
`&AntiFlood=true&ecode=0&isSec=0&data=${encodeURIComponent(data)}`;
}
/** 经 background 代理取文本(带 cookie),失败返回 null */
async function bgFetchText(url: string): Promise<string | null> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(
{ action: 'fetchText', url, credentials: true },
(res: { ok?: boolean; text?: string }) => {
if (chrome.runtime.lastError || !res?.ok) return resolve(null);
resolve(res.text ?? null);
},
);
});
}
/** 主数据:item / skuBase / skuCore,返回响应里的 data 字段 */
export async function fetchPcDetailData(itemId: string): Promise<unknown | null> {
const data = JSON.stringify({ itemNumId: String(itemId) });
const url = buildMtopUrl('mtop.taobao.pcdetail.data.get', '1.0', data);
const text = await bgFetchText(url);
if (!text) return null;
try {
const json = JSON.parse(text);
if (json?.ret?.[0]?.includes('SUCCESS')) return json.data ?? null;
} catch { /* ignore */ }
return null;
}
/** 图文详情:容错提取响应文本里的图片 URL 列表 */
export async function fetchDescImages(itemId: string): Promise<string[]> {
const data = JSON.stringify({ itemNumId: String(itemId), type: '1' });
const url = buildMtopUrl('mtop.taobao.detail.getdesc', '7.0', data);
const text = await bgFetchText(url);
if (!text) return [];
const urls = text.match(/https?:\/\/[^"'\\\s]+\.(?:jpg|jpeg|png|webp)/gi) ?? [];
return Array.from(new Set(urls));
}
+137
View File
@@ -0,0 +1,137 @@
/**
* 淘宝/天猫 SSR 状态提取 —— 通过 MAIN world 桥读取页面全局变量。
*
* 候选键(selectors-taobao.md §5.3 列出的待评估项):
* __ICE_APP_CONTEXT__ ICE 框架上下文(loaderData.home.data.resv1 已知结构)★主
* __general_skupanel_cache_data 疑似完整 SKU 面板缓存(结构未知,容错深搜)
* __ICE_DATA_LOADER__ ICE 框架数据层(容错深搜)
*
* 输出对齐 ssr.ts 的 SSRData,供 buildFromSSR 消费。
*/
import type { SSRData } from './ssr';
/** 从 __ICE_APP_CONTEXT__ 结构映射(原 v1 extractSSRData 的对象版) */
function fromIceContext(ctx: unknown): SSRData | null {
const res = (ctx as any)?.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,
};
}
/** 容错:未知结构里深搜「SKU props 数组」(元素含 name + values/props 嵰 name/imageUrl */
function skuPropsFromUnknown(root: unknown): SSRData['skuBase'] | null {
const candidates: any[] = [];
const collect = (node: unknown, depth = 0) => {
if (!node || typeof node !== 'object' || depth > 6 || candidates.length) return;
if (Array.isArray(node)) {
if (
node.length >= 1 && node.every((x: any) => x && typeof x === 'object' &&
typeof (x.prop ?? x.name) === 'string' && Array.isArray(x.values ?? x.props))
) { candidates.push(node.map((x: any) => ({
pid: String(x.pid ?? ''),
name: x.prop ?? x.name,
values: (x.values ?? x.props).map((v: any) => ({
vid: String(v.vid ?? ''),
name: v.name ?? v.valueName ?? '',
image: v.image ?? v.imageUrl,
})),
}))); return;
}
node.forEach(n => collect(n, depth + 1));
return;
}
for (const v of Object.values(node as Record<string, unknown>)) collect(v, depth + 1);
};
collect(root);
return candidates.length ? { props: candidates[0] } : null;
}
/** 容错:深搜参数数组(元素含 propertyName/valueName */
function paramsFromUnknown(root: unknown): Array<{ propertyName: string; valueName: string }> {
const out: Array<{ propertyName: string; valueName: string }> = [];
const seen = new Set<string>();
const walk = (node: unknown, depth = 0) => {
if (!node || typeof node !== 'object' || depth > 6 || out.length > 60) return;
if (Array.isArray(node)) {
if (node.length >= 2 && node.every((x: any) => x && typeof x === 'object' &&
typeof x.propertyName === 'string' && typeof x.valueName === 'string')) {
for (const p of node) {
const k = `${p.propertyName}=${p.valueName}`;
if (!seen.has(k)) { seen.add(k); out.push({ propertyName: p.propertyName, valueName: p.valueName }); }
}
}
node.forEach(n => walk(n, depth + 1));
return;
}
for (const v of Object.values(node as Record<string, unknown>)) walk(v, depth + 1);
};
walk(root);
return out;
}
export function taobaoStateFromBridge(keys: Record<string, any>): SSRData | null {
// 主路径:ICE 上下文
const ice = fromIceContext(keys['__ICE_APP_CONTEXT__']);
if (ice) {
// 主路径缺 SKU 时用面板缓存补
if (!ice.skuBase?.props?.length) {
const fromCache = skuPropsFromUnknown(keys['__general_skupanel_cache_data'] ?? keys['__ICE_DATA_LOADER__']);
if (fromCache?.props?.length) ice.skuBase = fromCache;
}
return ice;
}
// 降级:只有面板缓存/数据层 —— 尽力拼一个最小 SSRData(标题给空,DOM 会补)
const cacheRoot = keys['__general_skupanel_cache_data'] ?? keys['__ICE_DATA_LOADER__'];
if (cacheRoot) {
const skuBase = skuPropsFromUnknown(cacheRoot);
const params = paramsFromUnknown(cacheRoot);
if (skuBase?.props?.length || params.length) {
return {
item: { title: '', itemId: '', images: [], videos: [] },
skuBase: skuBase ?? undefined,
params: { basicParamList: params, enhanceParamList: [] },
price: undefined,
};
}
}
return null;
}
/** mtop pcdetail.data.get 响应 → SSRData(竞品验证的接口,结构同 res) */
export function taobaoStateFromMtop(data: unknown): SSRData | null {
if (!data || typeof data !== 'object') return null;
const d = data as any;
const item = d.item ?? d.itemDO;
if (!item) return null;
const images: string[] = (item.images ?? []).filter((u: unknown) => typeof u === 'string');
const videos = (item.videos ?? []).map((v: any) => ({ url: v?.url ?? v?.videoUrl, videoThumbnailURL: v?.videoThumbnailURL ?? v?.coverUrl })).filter((v: any) => v.url);
const params = paramsFromUnknown(d.params ?? d.propsList ?? d);
return {
item: {
title: item.title ?? '',
itemId: String(item.itemId ?? item.itemNumId ?? ''),
images,
videos,
},
skuBase: d.skuBase,
params: { basicParamList: params, enhanceParamList: [] },
price: d.skuCore?.price ?? undefined,
};
}
+21
View File
@@ -24,6 +24,27 @@ function extractOne(rule: TextRule): TextMaterial | null {
}
if (!nodes.length) continue;
// cells 模式:兄弟键值对(antd Descriptions 的 th/td、dl 的 dt/dd
// selectors 命中的每个节点就是「键」,值取它的下一个兄弟元素
if (rule.extract === 'cells') {
const pairs: Array<{ key: string; value: string }> = [];
nodes.forEach((k) => {
const v = k.nextElementSibling;
if (!v) return;
const kc = clean(k.textContent ?? '');
const vc = clean(v.textContent ?? '');
if (kc && vc) pairs.push({ key: kc.replace(/[:]$/, ''), value: vc });
});
if (pairs.length) {
return {
kind: rule.kind,
content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'),
pairs,
};
}
continue;
}
// table 模式:参数表
if (rule.extract === 'table') {
const pairs: Array<{ key: string; value: string }> = [];
+32 -8
View File
@@ -1,6 +1,10 @@
/**
* 1688 采集配置
* 从 docs/extension/plan.md §6.2 移植(选择器来自 v1.1.8 生产 bundle
* 1688 采集配置DOM 兜底路径)
*
* 新版(2026-08 实测,快照:宝宝平衡车)DOM 大改,锚点从业务类名换成稳定的
* id / data-module 属性;旧选择器保留做兼容(旧版页面仍在线上轮转)。
* 主路径(window.context)见 collector/platforms/1688.ts——DOM 只负责兜底
* 和补充参数表(#productAttributes)与详情图(#detail)。
*/
import type { SiteProfile } from './types';
@@ -12,7 +16,7 @@ export const profile1688: SiteProfile = {
extractItemId: (url) => url.match(/\/offer\/(\d+)\.html/)?.[1] ?? null,
readySelectors: ['.title-content', '#dt-tab', '#screen', '#content'],
readySelectors: ['#productTitle', '.title-content', '#detail', '#dt-tab', '#screen', '#content'],
readyTimeoutMs: 10_000,
// 懒加载真实地址在 data-* 上(顺序不能动)
@@ -23,8 +27,13 @@ export const profile1688: SiteProfile = {
textRules: [
{
kind: 'title',
// 标题被拆成多个 .title-text span,必须 join
selectors: ['.title-content .title-text', '.title-content h1', '.od-pc-offer-title', 'h1'],
selectors: [
'#productTitle .title-content', // 新版:data-module="od_title"
'.title-content .title-text', // 旧版:标题拆多个 span,必须 join
'.title-content h1',
'.od-pc-offer-title',
'h1',
],
extract: 'join',
required: true
},
@@ -33,6 +42,17 @@ export const profile1688: SiteProfile = {
selectors: ['.price-original', '.od-pc-offer-price-priceRange', '.price .value'],
extract: 'first'
},
// 参数表(新版):#productAttributes 是 antd Descriptions 表格,
// th(键)/td(值) 成对平铺在 tr 里——用 cells 模式取兄弟节点
{
kind: 'params',
selectors: [
'#productAttributes th.ant-descriptions-item-label',
'#productAttributes th',
],
extract: 'cells'
},
// 参数表(旧版):行式键值表
{
kind: 'params',
selectors: [
@@ -56,8 +76,11 @@ export const profile1688: SiteProfile = {
key: 'main',
name: '主图',
type: 'img',
// 四套画廊变体(说明 1688 至少有四个线上版本)
selectors: [
// 新版:模块锚点(data-module / module- 类名)
'[data-module="od_picture_gallery"] img',
'.module-od-picture-gallery img',
// 旧版四套画廊变体
'#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img',
'#screen .od-gallery-turn-item-wrapper .od-gallery-img',
'#content .od-scroller-item .v-image-cover',
@@ -79,6 +102,8 @@ export const profile1688: SiteProfile = {
name: 'SKU图片',
type: 'img',
selectors: [
'[data-module="od_sku_selection"] img', // 新版
'.module-od-sku-selection img',
'.pc-sku-wrapper .prop-item-inner-wrapper',
'.sku-item-wrapper',
'.specification-cell',
@@ -86,9 +111,7 @@ export const profile1688: SiteProfile = {
'.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
@@ -98,6 +121,7 @@ export const profile1688: SiteProfile = {
name: '详情图',
type: 'img',
selectors: [
'#detail img', // 新版:详情容器(实测 69 张,含少量图标需过滤)
'.de-description-detail img',
'#detailContentContainer img',
'.html-description img'
+12 -2
View File
@@ -13,7 +13,9 @@ export type TextKind =
| 'params'
| 'selling_point'
| 'desc'
| 'brand';
| 'brand'
| 'sales'
| 'shop';
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video';
@@ -29,7 +31,15 @@ export interface TextRule {
kind: TextKind;
/** 多套选择器,逐个尝试直到命中 */
selectors: string[];
extract: 'join' | 'first' | 'table';
/**
* extract 模式:
* join - 所有命中节点的文本拼接(标题被拆多个 span 时用)
* first - 只取第一个命中节点
* table - 行式键值表:selectors 命中行,tableKey/ValueSelector 在行内取键值
* cells - 兄弟键值对:selectors 直接命中「键」节点,值取它的下一个兄弟元素
* (适配 antd Descriptions 的 th/td 结构、dl 的 dt/dd 结构)
*/
extract: 'join' | 'first' | 'table' | 'cells';
/** table 模式的 key/value 子选择器 */
tableKeySelector?: string;
tableValueSelector?: string;
+5
View File
@@ -21,6 +21,11 @@ export default defineConfig({
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
'https://*.alicdn.com/*',
// mtop 开放接口(淘宝主数据/图文详情兜底)
'https://h5api.m.taobao.com/*',
'https://h5api.m.tmall.com/*',
// 1688 详情数据 CDNdescription.detailUrl
'https://itemcdn.tmall.com/*',
// 本机后端(上传 / 生成套图用);生产换成你的公网域名
'http://127.0.0.1:3300/*',
'http://localhost:3300/*'
+12 -2
View File
@@ -7,7 +7,7 @@ from config import get_settings
from db import get_db
from models import Suite
from schemas import (
GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, SuiteCreateResponse, TextMaterial,
GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, SuiteCreateResponse, TextMaterial,
PlanRequest, PlanResponse, PlanItemOut,
)
from services.generator import run_suite
@@ -36,6 +36,10 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict:
raw["sellingPoints"] = t.content
elif t.kind == "desc" and t.content:
raw["desc"] = t.content
elif t.kind == "sales" and t.content:
raw["sales"] = t.content
elif t.kind == "shop" and t.content:
raw["shop"] = t.content
return raw
@@ -80,15 +84,21 @@ async def generate_suite(
spec = PLATFORM_SPECS[req.platform]
settings = get_settings()
provider_name = req.provider or settings.image_provider
model = req.model
if provider_name == "tongyi" and model and model not in TONGYI_MODELS:
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}tongyi 支持: {TONGYI_MODELS}")
suite = Suite(
product_id=None,
style_set=req.style_set,
style_prompt=req.style_prompt,
platform=req.platform,
lang=spec["lang"],
ratio=spec["ratio"],
types=types,
plan=jobs,
provider=req.provider or settings.image_provider,
provider=provider_name,
model=model,
context=texts_to_raw(req.texts),
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
ref_images=[
+7 -2
View File
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import get_settings
from db import get_db
from models import Product, ProductAsset, Suite, SuiteImage, STATUS_OK
from schemas import PLATFORM_SPECS, SUPPORTED_TYPES, SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut
from schemas import PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut
from services import storage
from services.generator import run_suite
@@ -35,6 +35,7 @@ async def _suite_out(db: AsyncSession, suite: Suite) -> SuiteOut:
ratio=suite.ratio,
types=list(suite.types or []),
provider=suite.provider,
model=suite.model,
images=[
SuiteImageOut(
type_id=i.type_id, name=i.name, url=i.stored_url or "",
@@ -75,6 +76,9 @@ async def create_suite(
spec = PLATFORM_SPECS[req.platform]
settings = get_settings()
provider_name = req.provider or settings.image_provider
if provider_name == "tongyi" and req.model and req.model not in TONGYI_MODELS:
raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}tongyi 支持: {TONGYI_MODELS}")
suite = Suite(
product_id=product.id,
style_set=req.style_set,
@@ -82,7 +86,8 @@ async def create_suite(
lang=spec["lang"],
ratio=spec["ratio"],
types=req.types,
provider=req.provider or settings.image_provider,
provider=provider_name,
model=req.model,
)
db.add(suite)
await db.commit()
+11 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import AsyncGenerator
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
@@ -38,10 +39,19 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
yield session
async def _migrate(conn) -> None:
"""给旧库补充新增列(create_all 不会修改已存在的表)。"""
rows = await conn.execute(text("PRAGMA table_info(suites)"))
cols = {row[1] for row in rows}
if "model" not in cols:
await conn.execute(text("ALTER TABLE suites ADD COLUMN model VARCHAR(64)"))
async def init_db() -> None:
"""启动时建表(MVP 不引 Alembic,模型变更删库重建即可)。"""
"""启动时建表 + 轻量列迁移MVP 不引 Alembic)。"""
import models # noqa: F401 确保模型注册
engine = get_engine()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await _migrate(conn)
+2
View File
@@ -85,12 +85,14 @@ class Suite(Base):
)
status: Mapped[str] = mapped_column(String(16), default=SUITE_PENDING, index=True)
style_set: Mapped[int] = mapped_column(Integer, default=1) # 风格模板 1-5
style_prompt: Mapped[str | None] = mapped_column(Text, nullable=True) # 用户改写的风格提示词(覆盖模板)
platform: Mapped[str] = mapped_column(String(8), default="cn") # 目标平台 ozon | wb | cn
lang: Mapped[str] = mapped_column(String(4), default="zh") # ru / zh(由平台推导)
ratio: Mapped[str] = mapped_column(String(8), default="1:1") # 图片比例(由平台推导)
types: Mapped[list | None] = mapped_column(JSON, nullable=True) # 图类型 id 列表(旧)
plan: Mapped[list | None] = mapped_column(JSON, nullable=True) # 出图方案(展开后的逐张任务)
provider: Mapped[str] = mapped_column(String(16), default="doubao")
model: Mapped[str | None] = mapped_column(String(64), nullable=True) # 生图模型名(覆盖 provider 默认)
# 工具化流程:请求自带的数据(生图上下文 + 参考图 URL 列表)
context: Mapped[dict | None] = mapped_column(JSON, nullable=True)
ref_images: Mapped[list | None] = mapped_column(JSON, nullable=True)
+9 -2
View File
@@ -9,6 +9,9 @@ SUPPORTED_TYPES = [
"size_chart", "sku_collection", "custom",
]
# 通义(DashScope)生图模型白名单:插件下拉可选的模型
TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro"]
# ── 采集上传 ──
@@ -60,10 +63,11 @@ PLATFORM_SPECS: dict[str, dict] = {
class SuiteCreateRequest(BaseModel):
style_set: int = Field(default=1, ge=1, le=5, description="风格模板 1-5")
style_set: int = Field(default=1, ge=1, le=7, description="风格模板 1-7")
types: list[str] = Field(default_factory=lambda: ["white_bg", "key_features", "lifestyle", "multi_scene"])
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
provider: str | None = Field(default=None, description="覆盖默认 providerdoubao | tongyi")
model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro")
# ── 无状态套图生成(工具流程:请求自带采集数据)──
@@ -87,11 +91,13 @@ class PlanItem(BaseModel):
class GenerateRequest(BaseModel):
texts: list[TextMaterial] = Field(default_factory=list, description="采集的文本素材")
images: list[GenerateImageItem] = Field(default_factory=list, description="勾选的参考图")
style_set: int = Field(default=1, ge=1, le=5)
style_set: int = Field(default=1, ge=1, le=7)
style_prompt: str | None = Field(default=None, description="用户改写的风格提示词(覆盖 style_set 模板)")
types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成")
plan: list[PlanItem] | None = Field(default=None, description="出图方案(优先于 types")
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
provider: str | None = Field(default=None, description="覆盖默认 providerdoubao | tongyi")
model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro")
# ── 出图方案规划(DeepSeek)──
@@ -139,6 +145,7 @@ class SuiteOut(BaseModel):
ratio: str
types: list[str]
provider: str
model: str | None = None
images: list[SuiteImageOut]
error: str | None = None
+30 -10
View File
@@ -23,6 +23,18 @@ from services.prompt import build_prompt, build_context, type_name
log = logging.getLogger("suite.generator")
def _raise_api_error(resp, provider: str):
"""HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。"""
if resp.is_success:
return
try:
body = resp.json()
detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ')
except Exception: # noqa: BLE001
detail = resp.text[:200]
raise RuntimeError(f"{provider} API HTTP {resp.status_code}{detail or '无错误详情'}")
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
TYPE_REF_INDEX = {
"material": 1,
@@ -81,12 +93,12 @@ async def _resolve_ref(url: str) -> str:
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048") -> bytes:
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
s = get_settings()
if not s.ark_api_key:
raise RuntimeError("未配置 ARK_API_KEY.env")
body = {
"model": s.ark_image_model,
"model": model or s.ark_image_model,
"prompt": prompt.rstrip(". ") + ". " + _DOUBAO_ANTI_AI,
"size": size,
"response_format": "url",
@@ -101,7 +113,7 @@ async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x
headers={"Authorization": f"Bearer {s.ark_api_key}", "Content-Type": "application/json"},
json=body,
)
resp.raise_for_status()
_raise_api_error(resp, "豆包")
img_url = resp.json()["data"][0]["url"]
dl = await client.get(img_url, timeout=s.request_timeout)
dl.raise_for_status()
@@ -140,11 +152,12 @@ async def _tongyi_poll_task(client: httpx.AsyncClient, key: str, task_id: str, m
raise TimeoutError(f"通义异步任务超时 ({max_wait}s): task_id={task_id}")
async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*2048") -> bytes:
async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*2048", model: str | None = None) -> bytes:
s = get_settings()
if not s.dashscope_api_key:
raise RuntimeError("未配置 DASHSCOPE_API_KEY.env")
is_wan = _is_wan_model(s.dashscope_model)
model = model or s.dashscope_model
is_wan = _is_wan_model(model)
url = s.dashscope_base_url or (
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation"
if is_wan
@@ -163,11 +176,11 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
if is_wan:
headers["X-DashScope-Async"] = "enable"
body = {"model": s.dashscope_model, "input": {"messages": [{"role": "user", "content": content}]}, "parameters": params}
body = {"model": model, "input": {"messages": [{"role": "user", "content": content}]}, "parameters": params}
async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
_raise_api_error(resp, "通义")
data = resp.json()
if is_wan:
task_id = data.get("output", {}).get("task_id", "")
@@ -267,7 +280,11 @@ async def run_suite(suite_id: str) -> None:
raw = suite.context if not product else (product.raw or {})
ctx = build_context(raw or {}, fallback_name=product.name if product else "")
size = _image_size(provider_name, suite.ratio, is_wan=_is_wan_model(settings.dashscope_model))
model = suite.model or (
settings.dashscope_model if provider_name == "tongyi" else settings.ark_image_model
)
is_wan = provider_name == "tongyi" and _is_wan_model(model)
size = _image_size(provider_name, suite.ratio, is_wan=is_wan)
# 任务列表:方案(逐张)优先,旧路径按 types
if suite.plan:
@@ -290,12 +307,15 @@ async def run_suite(suite_id: str) -> None:
db.add(image_row)
await db.flush()
try:
prompt = build_prompt(type_id, ctx, suite.style_set, suite.lang, extra=job)
prompt = build_prompt(
type_id, ctx, suite.style_set, suite.lang,
extra=job, style_prompt=suite.style_prompt,
)
if product:
refs = await _select_ref_images(db, product.id, type_id)
else:
refs = _refs_for_job(list(suite.ref_images or []), job)
data = await generator(prompt, refs, size=size)
data = await generator(prompt, refs, size=size, model=model)
key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=".jpg")
image_row.stored_url = storage.public_url(key)
image_row.status = STATUS_OK
+86 -26
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import json
import logging
import re
import httpx
@@ -21,29 +22,26 @@ ALLOWED_KINDS = [
"size_chart", "sku_collection", "custom",
]
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商详情页/主图套图的出图方案。
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商套图的出图方案。
## 输出硬性约束(违反即失败)
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数 8-15。
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则
1. SKU 主图:商品有多个带图 SKU(颜色/款式)时,每个 SKU 出 1 张独立主图(kind=white_bg),
并在 variant_name 里填对应的 SKU 规格名(必须来自「SKU规格」列表,原样照抄);
单 SKU 商品出 1 张主图即可(variant_name 留空)
2. 场景图(kind=lifestyle):按商品的核心使用场景出 2-4 张,每张聚焦一个场景,场景从描述/参数里提取
3. 细节图(kind=material 或 custom):按商品的关键细节/材质/结构出 2-3 张,每张聚焦一个卖点细节
4. 尺寸标注图(kind=size_chart):参数里有长宽高/尺寸数据时出 1 张
5. SKU 合集图(kind=sku_collection):SKU 数量 >1 时出 1 张,同款多色整齐排列
6. 可用 kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene /
ecommerce_detail / size_chart / sku_collection / custom。其他创意图用 custom。
7. 总张数控制在 8-15 张;每项 count 为 1-3。
8. title 用中文短语(≤8字,如「主图·粉色」「浴室壁挂场景」);detail 用中文说明这张图要展示什么(≤40字);
prompt_hint 用英文描述构图(角度/布局/光线要点,≤60 words),供生图模型使用。
1. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
2. 场景图(kind=lifestyle):按核心使用场景出 2-4 张,每张聚焦一个场景。
3. 细节图(kind=material 或 custom):按关键细节/材质/结构出 2-3 张,每张聚焦一个卖点
4. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张
5. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张
6. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom
7. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点
## 输出格式(严格 JSON,不要多余文字
{
"summary": "整体思路一句话",
"items": [
{"kind": "white_bg", "title": "主图·粉色", "detail": "粉色SKU白底主视觉", "prompt_hint": "front view on pure white background", "count": 1, "variant_name": "粉色"}
]
}"""
## 输出示例(紧凑单行
{"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}"""
def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
@@ -76,6 +74,54 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
return items
def _repair_truncated(s: str) -> dict | None:
"""截断修复:从最后一个完整的 '}' 处截断,剥尾逗号后按括号配平补全闭合。
适用于「items 数组中途被 max_tokens 截断」的场景——截断点在完整对象边界,
此前的字符串必然已闭合,简单计数配平即可。
"""
for cut in (m.end() for m in reversed(list(re.finditer(r'\}', s)))):
cand = s[:cut].rstrip().rstrip(',')
opens: list[str] = []
for ch in cand:
if ch in '{[':
opens.append(ch)
elif ch == '}' and opens and opens[-1] == '{':
opens.pop()
elif ch == ']' and opens and opens[-1] == '[':
opens.pop()
suffix = ''.join('}' if o == '{' else ']' for o in reversed(opens))
try:
data = json.loads(cand + suffix)
if isinstance(data, dict):
return data
except json.JSONDecodeError:
continue
return None
def _extract_json(text: str) -> dict:
"""从模型输出提取 JSON:剥离思考块/markdown 围栏,截断时尝试修复。"""
s = (text or '').strip()
# 剥离思考块(思考型模型会把推理过程放进 <think>
s = re.sub(r'<think>.*?</think>', '', s, flags=re.S).strip()
# 剥离 markdown 代码围栏
m = re.search(r'```(?:json)?\s*(.*?)```', s, flags=re.S)
if m:
s = m.group(1).strip()
try:
return json.loads(s)
except json.JSONDecodeError:
pass
start = s.find('{')
if start >= 0:
repaired = _repair_truncated(s[start:])
if repaired is not None:
log.warning("规划器输出疑似被截断,已自动截断修复(可能丢失末尾部分方案项)")
return repaired
raise ValueError("模型输出无法解析为 JSON")
async def generate_plan(
product_info: dict,
sku_variants: list[str],
@@ -94,7 +140,7 @@ async def generate_plan(
"目标平台": platform, # ozon/wb/cn(决定图内文案语言)
}, ensure_ascii=False)
async with httpx.AsyncClient(timeout=60, verify=False) as client:
async with httpx.AsyncClient(timeout=90, verify=False) as client:
resp = await client.post(
f"{s.deepseek_base_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {s.deepseek_api_key}", "Content-Type": "application/json"},
@@ -106,16 +152,30 @@ async def generate_plan(
],
"response_format": {"type": "json_object"},
"temperature": 0.3,
"max_tokens": 2000,
"max_tokens": 8000,
},
)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
body = resp.json()
message = body["choices"][0]["message"]
finish_reason = body["choices"][0].get("finish_reason", "")
usage = body.get("usage") or {}
log.info(
"规划器 token 用量: prompt=%s completion=%s finish=%s",
usage.get("prompt_tokens", "?"), usage.get("completion_tokens", "?"), finish_reason,
)
content = message.get("content") or ""
# 思考型输出:content 为空时从 reasoning_content 里捞
if not content.strip() and message.get("reasoning_content"):
content = message["reasoning_content"]
try:
data = json.loads(content)
except json.JSONDecodeError as exc:
log.error("规划器输出不是合法 JSON: %s", content[:200])
data = _extract_json(content)
except ValueError as exc:
log.error(
"规划器输出解析失败 finish_reason=%s content[:200]=%s",
finish_reason, content[:200],
)
raise RuntimeError("规划器输出解析失败") from exc
items = _normalize_items(data.get("items") or [], sku_variants)
+47 -25
View File
@@ -12,38 +12,53 @@ from __future__ import annotations
import re
# ── 风格模板(与插件端 STYLE_SET_OPTIONS 对应)─────────────────────────────
# ── 风格模板(与插件端 STYLE_SET_OPTIONS 对应;提示词可被用户在插件里改写覆盖)───
# 提示词用中文:生图 provider(通义万相/豆包)均为国产模型,中文理解一流,且便于用户自行改写。
STYLE_SETS: dict[int, dict] = {
1: {
"name": "经典商拍",
"tone": "premium commercial e-commerce photography, clean soft studio lighting, "
"gentle gradient background, catalog-grade presentation, refined and trustworthy",
"bg": "light neutral studio backdrop with soft vignette",
"name": "高级质感大片",
"tone": "高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见,"
"色彩层次高级克制,商业画册级品质,构图干净、留白充足",
"bg": "",
},
2: {
"name": "生活杂志",
"tone": "editorial lifestyle magazine aesthetic, natural window light, "
"cozy lived-in atmosphere, muted film tones, candid storytelling",
"bg": "warm lifestyle home setting with plants and textured fabrics",
"name": "清新生活场景",
"tone": "明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化,"
"真实自然的氛围感,绿植与暖色织物点缀,温馨有人气",
"bg": "",
},
3: {
"name": "极简高冷",
"tone": "minimalist high-end aesthetic, vast negative space, single directional light, "
"cool grey palette, architectural calm, quiet luxury",
"bg": "seamless light grey studio background with subtle shadow",
"name": "极简白底规范",
"tone": "极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图,"
"安静的高级感,画面只保留轻微的自然接触投影",
"bg": "",
},
4: {
"name": "活力爆款",
"tone": "vibrant high-conversion e-commerce style, punchy saturated accents, "
"energetic composition, bold contrast, promotional poster energy",
"bg": "bright colorful gradient backdrop with dynamic geometric shapes",
"name": "炫彩促销风",
"tone": "高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围,"
"构图抢眼、视觉冲击力强",
"bg": "",
},
5: {
"name": "暗调质感",
"tone": "dark moody premium product photography, dramatic rim lighting, "
"deep charcoal background, rich texture detail, luxurious atmosphere",
"bg": "matte black background with soft spotlight and subtle smoke haze",
"name": "暗调轻奢",
"tone": "暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感,"
"如美术馆展陈般的呈现",
"bg": "",
},
6: {
"name": "俄式风情",
"tone": "俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,"
"深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,"
"带一丝巴洛克式的华丽细节,适合俄语区市场",
"bg": "",
},
7: {
"name": "北欧极简",
"tone": "北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,"
"浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,"
"舒适宁静的氛围",
"bg": "",
},
}
@@ -192,9 +207,10 @@ def _prompt_material(ctx: dict, style: dict, lang: str) -> str:
)
def _prompt_lifestyle(ctx: dict, style: dict, lang: str) -> str:
bg = f" ({style['bg']})" if style.get("bg") else ""
return (
f"Lifestyle in-context scene for product \"{ctx['title']}\": the product is naturally "
f"used / placed in a real environment ({style['bg']}), realistic human-scale surroundings, "
f"used / placed in a real environment{bg}, realistic human-scale surroundings, "
f"soft daylight, authentic candid mood, product remains the clear visual focus. "
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
@@ -242,6 +258,7 @@ def _prompt_custom(ctx: dict, style: dict, lang: str, extra: dict) -> str:
hint = (extra.get("prompt_hint") or "").strip()
purpose = extra.get("title") or ""
detail = extra.get("detail") or ""
bg = f" {style['bg']} as environment." if style.get("bg") else ""
composed = (
f"E-commerce marketing image for product \"{ctx['title']}\""
+ (f"{purpose}" if purpose else "")
@@ -250,7 +267,7 @@ def _prompt_custom(ctx: dict, style: dict, lang: str, extra: dict) -> str:
)
if hint:
composed += f" Composition: {hint}."
return f"{composed} {style['tone']}. {style['bg']} as environment. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
return f"{composed}{bg} {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
_PROMPT_BUILDERS = {
"white_bg": _prompt_white_bg,
@@ -265,13 +282,18 @@ _PROMPT_BUILDERS = {
}
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None) -> str:
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None) -> str:
"""构造指定图类型的完整生图 prompt。
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
预设类型也会把 prompt_hint 作为构图补充注入。
style_prompt: 用户改写的风格提示词,覆盖 style_set 内置模板(tone/bg 整体替换)。
"""
style = STYLE_SETS.get(style_set, STYLE_SETS[1])
if style_prompt and style_prompt.strip():
style = {"name": "custom", "tone": style_prompt.strip(), "bg": ""}
else:
style = STYLE_SETS.get(style_set, STYLE_SETS[1])
extra = extra or {}
if type_id == "custom":
prompt = _prompt_custom(ctx, style, lang, extra)
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/zsh
# 双击本文件,或在终端执行:./start.command
# 启动电商套图工作台后端(http://127.0.0.1:3300
cd "$(dirname "$0")"
if [[ ! -d server/.venv ]]; then
echo "未找到 server/.venv,正在创建并安装依赖…"
python3 -m venv server/.venv || exit 1
server/.venv/bin/pip install -q -r server/requirements.txt || exit 1
fi
if [[ ! -f .env ]]; then
echo "未找到 .env,已从 .env.example 复制,请填入 API Key 后再启动。"
cp .env.example .env
echo "按回车关闭…"
read -r
exit 1
fi
echo "启动中:http://127.0.0.1:3300 (插件保持默认后端地址即可)"
echo "按 Ctrl+C 可停止服务"
echo
exec server/.venv/bin/python server/main.py