commit 1591d5e35a18b4e7f7c534495d5ff1a229022a76 Author: Joey Date: Sat Aug 15 22:19:27 2026 +0800 feat: 初始化项目,并且接近完成 ozon 部分 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cac0c8c --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# ===== 服务 ===== +# 注意:不要用 5000/7000(macOS 隔空播放接收器占用) +HOST=127.0.0.1 +PORT=3300 +# 拼给插件/前端的媒体访问地址(默认本机) +APP_BASE_URL=http://127.0.0.1:3300 + +# ===== 存储 ===== +# 图片/数据库落盘目录(默认 <项目根>/data) +# DATA_DIR=/Users/joey/sites/seller-store/image-suite-studio/data + +# ===== 图像生成 ===== +# 默认 provider:doubao(火山方舟 Seedream)| tongyi(阿里 DashScope) +IMAGE_PROVIDER=doubao +# 单张生图请求超时(秒) +REQUEST_TIMEOUT=300 +# 异步任务轮询上限(秒) +POLL_MAX_WAIT=600 + +# --- 豆包 / 火山方舟 --- +ARK_API_KEY= +ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3/images/generations +ARK_IMAGE_MODEL=doubao-seedream-4-5-251128 + +# --- 通义 / DashScope --- +DASHSCOPE_API_KEY= +DASHSCOPE_BASE_URL= +DASHSCOPE_MODEL=wan2.7-image-pro + +# --- DeepSeek(出图方案规划器)--- +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +DEEPSEEK_MODEL=deepseek-v4-flash diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1020a09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.venv/ +.env +**/__pycache__/ +*.pyc +.DS_Store +web/ozonSeller.html.bak + +# V2 运行时数据(SQLite + 本地媒体) +data/ + +# 反编译参考资料(约 40 个 bundle),设计结论已写入 docs/extension/plan.md §2 +reference/ + +# Node(extension / studio / packages) +node_modules/ +dist/ +.output/ +.wxt/ +.pnpm-store/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..afe0350 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# 电商套图工作台(image-suite-studio) + +Chrome 插件 + Python 后端:采集 Ozon / 1688 / 淘宝 / 天猫 商品页的图片与信息(标题、描述、规格、尺寸), +基于采集素材**一键生成电商套图**(可设 5 种风格、7 种图类型、中英文案),生成后导出 ZIP。 + +参考并复用了 [ozon-seller-kit](../ozon-seller-kit) 的采集引擎(extension-v1/v2)与 +[ecommerce-image-suite](../ecommerce-image-suite) 的生图 Prompt 架构。 + +## 架构 + +``` +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 │ + └──────────────────────────────┘ +``` + +### 采集引擎(extension/src) + +- 声明式 `SiteProfile`(选择器 + srcProps + 去重/排除规则),加站点只需加一个 profile: + - `profiles/ozon.ts` — data-widget 选择器(DOM 兜底) + - `profiles/1688.ts` — 多套画廊选择器变体 + CSS 背景图 SKU + - `profiles/taobao.ts` — CSS Modules 前缀匹配,淘宝/天猫一份 profile +- 多路径采集(`collector/scan.ts` 统一编排): + - **Ozon**:SSR data-state(白名单)→ JSON-LD → 页面 JSON API → DOM,多源合并 + - **淘宝/天猫**:`window.__ICE_APP_CONTEXT__` SSR 主路径 + DOM 补充详情图 + - **1688**:纯 DOM(懒加载属性降级 + 占位图过滤 + 组内去重) +- 图片 URL 自动还原原图(阿里 `_400x400` 后缀 / Ozon `/wc\d+/` 路径段) + +### 套图生成(server/services) + +- `prompt.py`:7 种图类型 × 5 套风格模板,公共组件 QUALITY / PRODUCT_REF_LOCK(商品一致性锁)/ TEXT_RENDER + - 图类型:白底主图 / 核心卖点图 / 卖点图 / 材质图 / 场景展示图 / 多场景拼图 / 电商详情图 + - 风格:经典商拍 / 生活杂志 / 极简高冷 / 活力爆款 / 暗调质感 + - 卖点从采集的参数表/卖点文本自动提炼 +- `generator.py`:图像 provider(图生图,参考图 = 采集主图) + - `doubao`:火山方舟 Seedream(默认,`ARK_API_KEY`) + - `tongyi`:通义万相/千问(`DASHSCOPE_API_KEY`,wan* 异步轮询 / qwen* 同步) + +## 快速开始 + +### 1. 后端 + +```bash +cd server +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt + +# 配置 API Key(二选一,豆包为默认) +cp ../.env.example ../.env +# 编辑 ../.env 填入 ARK_API_KEY 或 DASHSCOPE_API_KEY + +python main.py # http://127.0.0.1:3300 +``` + +### 2. 插件 + +```bash +cd extension +pnpm install +pnpm build # 产物在 .output/chrome-mv3 +# Chrome → chrome://extensions → 开发者模式 → 加载已解压的扩展程序 → 选 .output/chrome-mv3 +``` + +### 3. 使用 + +1. 打开 Ozon / 1688 / 淘宝 / 天猫 的**商品详情页**,滚动到底部(详情图懒加载)后点击插件图标 +2. Side Panel:扫描 → 检查/勾选素材(默认全选主图+SKU)→ 保存到服务端 +3. 选择风格 / 图类型 / 文案语言 → 一键生成 → 完成后「导出 ZIP」 + +## API 一览 + +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/api/materials` | 采集上传(文本 + 图片 URL),异步转存 | +| GET | `/api/products` / `/api/products/{id}` | 商品列表/详情 | +| POST | `/api/products/{id}/suites` | 创建套图任务 `{style_set, types, lang, provider?}` | +| GET | `/api/suites/{id}` | 任务状态 + 已生成图 URL | +| GET | `/api/suites/{id}/zip` | 导出 ZIP | +| GET | `/api/health` | 健康检查 + provider 配置状态 | + +## 说明与限制 + +- 单用户本地部署,未做鉴权(如需暴露公网请自行加 token 校验) +- 生图依赖付费 API(豆包 Seedream / 通义万相),任务逐张串行生成,一张约 10-60 秒 +- 淘宝/天猫页面改版频繁,选择器失效时优先检查 `profiles/taobao.ts` 的 `[class*="xx--"]` 前缀 +- 模特图(两阶段生成)与视频生成暂未实现,可参考 ecommerce-image-suite 后续扩展 diff --git a/extension/entrypoints/background.ts b/extension/entrypoints/background.ts new file mode 100644 index 0000000..1dbbf32 --- /dev/null +++ b/extension/entrypoints/background.ts @@ -0,0 +1,34 @@ +import { generateSuite, getSuite, planSuite } from '../src/api/client'; + +// Background Service Worker —— 唯一出网口(生成 / 规划 / 轮询任务,绕 CORS) +export default defineBackground(() => { + console.log('[电商套图工作台] background started'); + + // 点击扩展图标 → 打开 Side Panel + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.action === 'generateSuite') { + generateSuite(msg.baseUrl, msg.token, msg.payload) + .then((data) => sendResponse({ ok: true, data })) + .catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) })); + return true; + } + + if (msg?.action === 'planSuite') { + planSuite(msg.baseUrl, msg.token, msg.payload) + .then((data) => sendResponse({ ok: true, data })) + .catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) })); + return true; + } + + if (msg?.action === 'getSuite') { + getSuite(msg.baseUrl, msg.token, msg.suiteId) + .then((data) => sendResponse({ ok: true, data })) + .catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) })); + return true; + } + + return false; + }); +}); diff --git a/extension/entrypoints/content/index.ts b/extension/entrypoints/content/index.ts new file mode 100644 index 0000000..325bc7e --- /dev/null +++ b/extension/entrypoints/content/index.ts @@ -0,0 +1,26 @@ +// Content Script —— 注入四个平台的商品页,暴露采集入口 +import { scanCurrentPage } from '../../src/collector/scan'; + +export default defineContentScript({ + matches: [ + // Ozon + 'https://*.ozon.ru/*', + 'https://*.ozon.kz/*', + 'https://*.ozon.by/*', + // 1688 + 'https://detail.1688.com/*', + // 淘宝 / 天猫 + 'https://item.taobao.com/*', + 'https://detail.tmall.com/*', + ], + main() { + console.log('[电商套图工作台] Content script loaded'); + + // 暴露采集入口到全局(供 side panel 调用 / console 调试) + (window as any).__SuiteCollector = { + scan: scanCurrentPage, + }; + + console.log('[电商套图工作台] 就绪。Console 可测: await window.__SuiteCollector.scan()'); + }, +}); diff --git a/extension/entrypoints/sidepanel/App.tsx b/extension/entrypoints/sidepanel/App.tsx new file mode 100644 index 0000000..d8b953d --- /dev/null +++ b/extension/entrypoints/sidepanel/App.tsx @@ -0,0 +1,598 @@ +/** + * 电商套图工作台 - Side Panel + * + * 布局:顶栏 + 目标平台 + * ├─ 左 01 商品信息 | 右 02 采集图片(两列等高,采集阶段) + * ├─ 03 出图方案(整行:方案列表 + AI 智能规划 + 风格 + 一键生成) + * └─ 04 生成结果(整行) + * + * 流程:采集页面 → 编辑/勾选 → AI 规划或用默认方案 → 一键生成 → 导出 ZIP + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App as AntApp, ConfigProvider, Popover, Progress } from 'antd'; +import { SettingOutlined, DownloadOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import type { ScanResult, ImageMaterial } from '../../src/collector/scan'; +import { + buildGeneratePayload, suiteZipUrl, + DEFAULT_PLAN, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS, + type PlanItem, type SuiteInfo, +} from '../../src/api/client'; +import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings'; + +/** 数据来源 → 展示含义 */ +const SOURCE_LABELS: Record = { + state: '页面数据', + ssr: '页面数据', + jsonld: '结构化数据', + api: '站内接口', + dom: 'DOM解析', + mixed: '混合来源', +}; + +/** 平台 → 展示名 */ +const PLATFORM_LABELS: Record = { + ozon: 'Ozon', + '1688': '1688', + taobao: '淘宝/天猫', +}; + +/** 在当前活动 tab 执行采集(content script 已把入口挂到 window) */ +async function scanActiveTab(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) throw new Error('未找到活动标签页'); + const [res] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => (window as any).__SuiteCollector?.scan?.() ?? null, + }); + const r = (res?.result ?? null) as ScanResult | null; + if (!r) throw new Error('采集失败:页面不支持或内容脚本未就绪,请刷新页面后重试'); + return r; +} + +function send(action: string, payload: Record): Promise { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ action, ...payload }, (res: { ok: boolean; data?: T; error?: string }) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + if (!res?.ok) return reject(new Error(res?.error || '后台请求失败')); + resolve(res.data as T); + }); + }); +} + +// ── 小组件 ───────────────────────────────────────────────────────────────── + +const Section: React.FC<{ no: string; title: string; extra?: React.ReactNode; children: React.ReactNode }> = + ({ no, title, extra, children }) => ( +
+
+ {no} + {title} + {extra && {extra}} +
+ {children} +
+ ); + +const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+ + {children} +
+); + +/** 数量加减器 */ +const Stepper: React.FC<{ value: number; onChange: (v: number) => void }> = ({ value, onChange }) => ( + + + {value} + + +); + +const App: React.FC = () => { + const { modal } = AntApp.useApp(); + + // 采集 + const [scanning, setScanning] = useState(false); + const [result, setResult] = useState(null); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [titleEdit, setTitleEdit] = useState(''); + const [descEdit, setDescEdit] = useState(''); + const [paramsOpen, setParamsOpen] = useState(false); + + // 服务端 + const [settings, setSettings] = useState({ baseUrl: 'http://127.0.0.1:3300', token: '' }); + + // 出图方案 + const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('cn'); + const [styleSet, setStyleSet] = useState(1); + const [plan, setPlan] = useState(DEFAULT_PLAN.map(p => ({ ...p }))); + const [planSource, setPlanSource] = useState<'default' | 'ai'>('default'); + const [planSummary, setPlanSummary] = useState(''); + const [planning, setPlanning] = useState(false); + + // 生成 + const [suite, setSuite] = useState(null); + const [generating, setGenerating] = useState(false); + const pollRef = useRef | null>(null); + + // 图片放大预览 + const [previewUrl, setPreviewUrl] = useState(null); + + useEffect(() => { + loadSettings().then(setSettings); + return () => { if (pollRef.current) clearInterval(pollRef.current); }; + }, []); + + const price = result?.texts.find(t => t.kind === 'price')?.content ?? ''; + const brand = result?.texts.find(t => t.kind === 'brand')?.content ?? ''; + const paramPairs = result?.texts.find(t => t.kind === 'params')?.pairs ?? []; + + const handleScan = async () => { + setScanning(true); + try { + const r = await scanActiveTab(); + setResult(r); + setSuite(null); + setParamsOpen(false); + // 默认全选主图 + SKU 图(SKU 图带规格名,AI 规划的 variant 绑定要用) + const keys = new Set(r.images.filter(i => i.groupKey === 'main' || i.groupKey === 'sku').map(i => i.key)); + setSelectedKeys(keys); + setTitleEdit(r.texts.find(t => t.kind === 'title')?.content ?? ''); + setDescEdit(r.texts.find(t => t.kind === 'desc')?.content ?? ''); + } catch (e) { + modal.error({ title: '采集失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); + } finally { + setScanning(false); + } + }; + + const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; + + const pollSuite = useCallback((suiteId: string) => { + stopPolling(); + pollRef.current = setInterval(async () => { + try { + const s = await send('getSuite', { baseUrl: settings.baseUrl, token: settings.token, suiteId }); + setSuite(s); + if (['done', 'partial', 'failed'].includes(s.status)) { + stopPolling(); + setGenerating(false); + if (s.status === 'partial') modal.warning({ title: '部分生成失败', content: '可重试或更换风格重新生成', okText: '知道了' }); + if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' }); + } + } catch (e) { + stopPolling(); + setGenerating(false); + } + }, 3000); + }, [settings.baseUrl, settings.token, modal]); + + /** 当前编辑后的文本素材(规划与生成共用) */ + const editedTexts = () => { + if (!result) return []; + const orig = (kind: string) => result.texts.find(t => t.kind === kind); + const texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }> = []; + const title = titleEdit || orig('title')?.content || ''; + const desc = descEdit || orig('desc')?.content || ''; + if (title) texts.push({ kind: 'title', content: title }); + if (orig('price')?.content) texts.push({ kind: 'price', content: orig('price')!.content }); + if (orig('brand')?.content) texts.push({ kind: 'brand', content: orig('brand')!.content }); + if ((orig('params')?.pairs ?? []).length) texts.push({ kind: 'params', content: '', pairs: orig('params')!.pairs }); + if (orig('selling_point')?.content) texts.push({ kind: 'selling_point', content: orig('selling_point')!.content }); + if (desc) texts.push({ kind: 'desc', content: desc }); + return texts; + }; + + /** AI 智能规划出图方案 */ + const handlePlan = async () => { + if (!result) return modal.warning({ title: '请先采集商品页' }); + setPlanning(true); + try { + const skuVariants = Array.from(new Set( + result.images.filter(i => i.groupKey === 'sku' && i.variantName).map(i => i.variantName!) + )); + const data = await send<{ summary: string; items: PlanItem[] }>('planSuite', { + baseUrl: settings.baseUrl, token: settings.token, + payload: { + texts: editedTexts(), + sku_variants: skuVariants, + image_stats: result.stats, + platform, + }, + }); + setPlan(data.items); + setPlanSource('ai'); + setPlanSummary(data.summary); + const total = data.items.reduce((s, i) => s + i.count, 0); + modal.info({ + title: 'AI 方案已生成', + content: `${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`, + okText: '好的', + }); + } catch (e) { + modal.error({ title: '规划失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); + } finally { + setPlanning(false); + } + }; + + const totalPlanned = plan.reduce((s, i) => s + i.count, 0); + const doneCount = suite?.images.filter(i => i.status === 'ok').length ?? 0; + const suiteTotal = suite?.images.length ?? totalPlanned; + + const handleGenerate = () => { + if (!result) return; + if (selectedKeys.size === 0) return modal.warning({ title: '请先在采集图片区勾选参考图' }); + if (totalPlanned === 0) return modal.warning({ title: '出图方案的张数都是 0' }); + const spec = PLATFORM_SPECS[platform]; + modal.confirm({ + title: '生成电商套图', + content: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),风格「${STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 ${totalPlanned} 张、参考图 ${selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。`, + okText: '开始生成', cancelText: '取消', + onOk: async () => { + setGenerating(true); + setSuite(null); + try { + const payload = buildGeneratePayload( + result, selectedKeys, + { title: titleEdit, desc: descEdit }, + { style_set: styleSet, plan: plan.filter(p => p.count > 0), platform }, + ); + const { suite_id } = await send<{ suite_id: string }>('generateSuite', { + baseUrl: settings.baseUrl, token: settings.token, payload, + }); + pollSuite(suite_id); + } catch (e) { + setGenerating(false); + modal.error({ title: '提交失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); + } + }, + }); + }; + + const handleExport = () => { + if (!suite) return; + chrome.tabs.create({ url: suiteZipUrl(settings.baseUrl, suite.id) }); + }; + + const toggleKey = (key: string) => { + const next = new Set(selectedKeys); + if (next.has(key)) next.delete(key); else next.add(key); + setSelectedKeys(next); + }; + + const groupImages = (groupKey: string): ImageMaterial[] => + result?.images.filter(i => i.groupKey === groupKey) ?? []; + + const toggleGroup = (groupKey: string, on: boolean) => { + const next = new Set(selectedKeys); + groupImages(groupKey).forEach(i => on ? next.add(i.key) : next.delete(i.key)); + setSelectedKeys(next); + }; + + const setPlanCount = (idx: number, count: number) => { + setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count } : p)); + }; + + /** 源站图防盗链时的兜底:走服务端图片代理 */ + const proxied = (u: string) => + `${settings.baseUrl.replace(/\/$/, '')}/api/proxy-image?url=${encodeURIComponent(u)}`; + + /** 缩略图三级降级:thumbUrl → 原图 → 服务端代理 */ + const onThumbError = (e: React.SyntheticEvent, url: string) => { + const el = e.currentTarget; + if (el.dataset.step === '1') { el.dataset.step = '2'; el.src = url; } + else if (el.dataset.step === '2') { el.dataset.step = '3'; el.src = proxied(url); } + }; + + const settingsPopup = ( +
+ + setSettings({ ...settings, baseUrl: e.target.value })} + onBlur={() => saveSettings(settings)} + /> + + + setSettings({ ...settings, token: e.target.value })} + onBlur={() => saveSettings(settings)} + /> + +
+ ); + + return ( +
+ {/* ── 顶栏 ── */} +
+
+
+

电商套图工作台

+
商品采集 · 套图生成 · 一键导出
+
+
+ + + + +
+ + {/* ── 目标平台切换(决定文案语言 + 图片比例)── */} +
+ 目标平台 +
+ {PLATFORM_OPTIONS.map(p => ( + setPlatform(p.value)} + >{p.label} + ))} +
+ + {PLATFORM_SPECS[platform].lang}文案 · {PLATFORM_SPECS[platform].ratio} 图片 + +
+ + {/* ── 采集区:左信息 / 右图片,两列等高 ── */} +
+
+ {!result ? ( +
点击右上角「快速采集」抓取当前商品页
+ ) : ( + <> + + setTitleEdit(e.target.value)} /> + +
+
+ + + +
+
+ + + +
+
+ {paramPairs.length > 0 && ( +
+ + {paramsOpen && ( + + + {paramPairs.slice(0, 30).map((p, i) => ( + + ))} + +
{p.key}{p.value}
+ )} +
+ )} + +