Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0199c7b12f | |||
| 024777ed8a | |||
| 95cb93a160 | |||
| 90b7c8737d | |||
| 6732cb178a | |||
| 82cb694837 | |||
| 06b220ae8d | |||
| e9d1eef07e | |||
| 9d15d8f784 | |||
| b57933e983 |
@@ -27,6 +27,11 @@ DASHSCOPE_API_KEY=
|
||||
DASHSCOPE_BASE_URL=
|
||||
DASHSCOPE_MODEL=wan2.7-image-pro
|
||||
|
||||
# --- RightAPI(gpt-image / nano-banana,OpenAI 兼容中转)---
|
||||
RIGHTAPI_API_KEY=
|
||||
RIGHTAPI_BASE_URL=https://rightapi.ai/draw
|
||||
RIGHTAPI_IMAGE_MODEL=gpt-image-2
|
||||
|
||||
# --- DeepSeek(出图方案规划器)---
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 水印功能实现计划(服务端后处理合成,非 AI 模型加水印)
|
||||
|
||||
## 架构决策
|
||||
- 水印在 **AI 出图返回后、落盘前** 由服务端合成(`run_suite` 里 `generator()` 返回字节之后、`storage.write_bytes` 之前)。预览(/media URL)与导出 ZIP 自然都是带水印图,所见即所得;不经过提示词/AI 模型。
|
||||
- 开关与配置放插件"服务端设置"Popover,随生成请求下发(服务端不做 .env 配置项);持久化在 chrome.storage.local。
|
||||
- 默认样式复刻 ozonSeller「图表处理」:图片水印 = `imgs/watermark.jpg` 圆形徽章(宽 15%、右下、透明度 30%);文字水印 = 默认文案 `xiongmaoyx`(白字黑描边、字号 6% 宽度、加粗)。位置本期固定右下,不做调节。
|
||||
|
||||
## 服务端(server/)
|
||||
|
||||
1. **依赖与资产**
|
||||
- `requirements.txt` 加 `pillow` 并安装(venv 现无 PIL)
|
||||
- 复制 `ozon-seller-kit/web/imgs/watermark.jpg` → `server/assets/watermark.jpg`(200×200 JPEG)
|
||||
- `config.py` 加 `watermark_image_path`(默认指向上述资产)
|
||||
|
||||
2. **新模块 `services/watermark.py`**
|
||||
- `apply_watermark(data: bytes, opts: dict) -> bytes`:魔数识别 PNG/JPEG → PIL 打开转 RGBA 合成 → 按原格式保存(JPEG quality≈95)
|
||||
- 图片水印:资产中心裁方 → 圆形遮罩 → 缩放到图宽 15% → 按 opacity 合成 → 右下角贴入(边距 ≈1% 图宽)
|
||||
- 文字水印:字号 = 图宽 6%(下限 12px)、白色填充 + 黑色描边(alpha 0.55、描边宽 fontSize/8);字体回退链 PingFang → Hiragino Sans GB → STHeiti → Pillow 默认(模块级缓存首次命中)
|
||||
- 容错:字体/资产缺失时 log warning 并返回原图,绝不阻断生图
|
||||
|
||||
3. **协议与流转**
|
||||
- `schemas.py`:新增 `WatermarkOptions`(`enabled=False, type='image'|'text', text='xiongmaoyx', opacity=30`),`GenerateRequest` 加 `watermark` 字段
|
||||
- `tasks.py`:`Task` 加 `watermark: dict | None`
|
||||
- `api/generate.py`:`create_task` 透传
|
||||
- `generator.py` `run_suite`:落盘前 `if watermark enabled: data = apply_watermark(...)`
|
||||
|
||||
## 插件端(extension/)
|
||||
|
||||
4. **设置与请求**
|
||||
- `src/storage/settings.ts`:`BackendSettings` 加 `watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 }`;loadSettings 对该子对象做深合并(兼容老数据)
|
||||
- `src/api/client.ts`:`GeneratePayload` 加 `watermark?`,`buildGeneratePayload` 透传
|
||||
5. **UI(App.tsx settingsPopup)**
|
||||
- 加水印设置块:开启 checkbox、类型 pills(图片/文字)、文字内容 input(仅文字类型显示)、透明度 number(0–100 步进 5,带 %);Popover 宽度 260→300
|
||||
- `startGenerate` 的 config 在开启时带 `watermark`,关闭不下发
|
||||
|
||||
## 验证
|
||||
- 烟测:对已有生成图字节分别跑图片/文字两种水印(含透明度边界),人工查看合成效果
|
||||
- 前端 `tsc --noEmit`;README 补充说明
|
||||
|
||||
## 影响面
|
||||
仅生成图被处理;参考图/上传图不动。水印合成失败自动跳过不阻断生成。开关关闭时生成的图保持干净,导出即所见。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 移除数据库,纯内存任务表 + 串行生成队列(无恢复功能,无鉴权)
|
||||
|
||||
确认结论:不做"恢复进行中任务"则数据库无不可替代用途 —— 轮询用进程内任务表,历史记录功能不存在,重启时任务本来就会死(数据库只是把提示从"任务中断"换成"生成失败")。多用户并发使用单后端实例不受影响。
|
||||
|
||||
## A. 新增 `server/services/tasks.py` — 内存任务注册表
|
||||
|
||||
- `@dataclass TaskImage`(type_id/name/status/url/error)
|
||||
- `@dataclass Task`:id、status(pending|running|done|partial|failed)、platform/lang/ratio、style_set、style_prompt、requirements、provider、model、total、images、error,以及执行参数 context/plan/ref_images
|
||||
- 模块级 `_TASKS: dict[str, Task]`;asyncio 单事件循环内读写,无并发问题
|
||||
- `total` 语义保持:计划总张数;`images` 逐张追加(前端进度 x/y 依赖)
|
||||
|
||||
## B. 改造 `server/services/generator.py`
|
||||
|
||||
- `run_suite(task: Task)` 接收内存任务,不再查库;每张生成后 `task.images.append(...)`;`storage.write_bytes`(文件系统)不动
|
||||
- **串行生成队列**:模块级 `asyncio.Lock`,拿锁后才置 running;多用户同时提交时后续任务保持 pending(前端已显示"排队中"),避免共享 API key 触发 rightapi 同 key 分钟级冷却
|
||||
- 删除:商品路径分支(product 加载、`_select_ref_images`)、SuiteImage/Suite 读写、`fail_stale_suites`(重启后内存为空,轮询自然 404,前端已有"任务已中断"提示)
|
||||
|
||||
## C. 改造 `server/api/generate.py`
|
||||
|
||||
- `POST /api/generate`:原 Suite 构建逻辑(texts_to_raw、plan 展开、ref_images 排序、模型路由校验)平移到 Task 对象,存入 `_TASKS`,`background.add_task(run_suite, task)`
|
||||
- `POST /api/plan` 不动(本就不碰数据库)
|
||||
|
||||
## D. 改造 `server/api/suites.py`
|
||||
|
||||
- 保留 `GET /api/suites/{id}`(读内存,不存在 404「任务不存在(服务可能已重启)」)、`GET /api/suites/{id}/zip`(从 Task.images 打包成功图)
|
||||
- 删除两个商品挂载端点
|
||||
|
||||
## E. 删除文件与依赖
|
||||
|
||||
- 删除:`server/db.py`、`server/models.py`(4 张表)、`server/api/collection.py`、`server/api/products.py`
|
||||
- `server/main.py`:去掉 lifespan/init_db/fail_stale_suites 与对应路由
|
||||
- `server/schemas.py`:删除商品路径与 materials 类型(SuiteCreateRequest、MaterialsRequest/Response、ProductOut/AssetOut/ProductListOut);保留 TextMaterial、GenerateRequest、SuiteOut 契约(前端零改动)
|
||||
- `server/requirements.txt`:删 `sqlalchemy[asyncio]`、`aiosqlite`
|
||||
- 前端 `client.ts`:删除 materials 死代码(buildMaterialsPayload/uploadMaterials 及类型)
|
||||
|
||||
## F. README
|
||||
|
||||
架构说明更新:进程内任务表、重启即新会话(进行中任务中断,前端有提示)、数据目录只剩 media/;标注接口暂无鉴权,公网暴露前需内网/反代白名单,登录鉴权后续版本补充;将来若需恢复任务/历史记录/多实例,再引入数据库(任务表结构简单,迁移成本低)
|
||||
|
||||
## 不改的部分
|
||||
|
||||
- 前端交互/UI、生图 provider、prompt 逻辑、`data/media/` 图片文件
|
||||
- `data/app.db` 数据文件保留(不再被使用,可自行删除)
|
||||
|
||||
## 验证
|
||||
|
||||
1. `py_compile` 后端改动文件;`tsc --noEmit` + 前端 build
|
||||
2. 零成本链路测试(count=0 的 plan,不实际生图):提交 → 轮询 done/0 张 → 不存在的 id 返回 404
|
||||
3. `start.command` 重启,health 正常
|
||||
@@ -0,0 +1,48 @@
|
||||
# 插件打开方式改造:Side Panel → 页内悬浮面板
|
||||
|
||||
## 架构(与 1688 参考插件一致,面板加载插件内置页面 ✅ 已确认)
|
||||
|
||||
```
|
||||
商品详情页(Ozon / 1688 / 淘宝 / 天猫)
|
||||
└─ [Shadow DOM 隔离区](不被商品页样式污染)
|
||||
├─ 右下角悬浮按钮「套」 ← 点击展开/收起
|
||||
└─ 面板 iframe(src = chrome-extension://<id>/sidepanel.html,插件内置资源)
|
||||
悬浮覆盖在页面上 · 贴右侧 · 滑入动画 · 原页面不被挤压
|
||||
```
|
||||
|
||||
- 悬浮按钮:Shadow DOM 直接渲染(同 1688 插件 Plasmo CSUI 做法),WXT 用 `createShadowRootUi`
|
||||
- 面板:iframe 悬浮覆盖(同 1688 插件),但加载插件内置页面——扩展页面在 iframe 里 chrome.* 权限齐全,现有采集/生成/轮询/导出逻辑**零改动**;唯一额外要求是 manifest 声明 `web_accessible_resources`(已查证:商品站 CSP 拦不住扩展 iframe)
|
||||
|
||||
## 改动清单
|
||||
|
||||
### 1. 新增 `extension/entrypoints/panel.content.ts` —— 悬浮按钮 + 面板宿主
|
||||
- `matches` 与现有采集 content script 相同(Ozon / 1688 / 淘宝 / 天猫)
|
||||
- `createShadowRootUi` 挂独立 Shadow DOM:
|
||||
- **按钮**:右下角 48px 圆钮、品牌色渐变「套」;仅在商品详情页显示(复用 `matchProfile()` 判断),监听 `pushState/popstate` 兼容站内软导航
|
||||
- **面板**:fixed 贴屏幕右侧(top/bottom/right 16px),宽 `min(880px, 100vw-32px)`,圆角阴影,`translateX(110%) → 0` 滑入 0.25s,z-index 拉满
|
||||
- iframe 懒加载:首次点开才设 `src=chrome.runtime.getURL('sidepanel.html')`,关闭只隐藏不销毁(同页面内重开状态保留)
|
||||
- 关闭通道:面板内 postMessage `{type:'sc-panel-close'}`(校验来源);工具栏图标 toggle 消息
|
||||
|
||||
### 2. `App.tsx` 小改(~20 行)
|
||||
- `IN_PAGE = window.self !== window.top` 检测
|
||||
- IN_PAGE 时:顶栏加 ✕ 关闭按钮 + ESC 关闭 → `window.parent.postMessage` 通知宿主页收起
|
||||
- 其余逻辑不动
|
||||
|
||||
### 3. `background.ts`
|
||||
- 删 `setPanelBehavior`(不再自动开 Side Panel)
|
||||
- 加 `chrome.action.onClicked` → 向当前 tab 发 `{action:'toggle-suite-panel'}`(点图标也能开关面板)
|
||||
|
||||
### 4. `wxt.config.ts`
|
||||
- 加 `web_accessible_resources`:`sidepanel.html`,限定 6 个商品站 host
|
||||
- 保留 sidePanel 权限与入口(Chrome 侧边栏仍可手动打开,作兜底)
|
||||
|
||||
### 5. README 使用说明更新
|
||||
|
||||
## 已知限制
|
||||
- 面板随页面销毁:跳转其他商品页后状态重置(生成任务在服务端继续跑,只丢进度视图)。后续可选:suite_id 存 `chrome.storage.session` 做任务恢复
|
||||
|
||||
## 验证
|
||||
1. `pnpm build` → Chrome 重新加载扩展
|
||||
2. 商品页:按钮只在详情页出现;点击滑出面板、原页面不被挤压
|
||||
3. 全流程:采集 → 方案 → 生成 → 导出 ZIP
|
||||
4. 关闭方式:面板 ✕ / ESC / 工具栏图标;列表页不显示按钮
|
||||
@@ -9,17 +9,28 @@ Chrome 插件 + Python 后端:采集 Ozon / 1688 / 淘宝 / 天猫 商品页
|
||||
## 架构
|
||||
|
||||
```
|
||||
Chrome 插件(WXT + React + antd) Python 后端(FastAPI + SQLite)
|
||||
Chrome 插件(WXT + React + antd) Python 后端(FastAPI,无数据库)
|
||||
┌────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ Side Panel │ │ POST /api/materials │
|
||||
│ ① 扫描商品页(四站点) │ ──上传──▶ │ → 落库 + 后台转存图片 │
|
||||
│ ② 勾选/编辑素材 │ │ POST /api/products/{id}/suites│
|
||||
│ ③ 选风格提交生成 │ ──提交──▶ │ → 套图任务(后台逐张生图) │
|
||||
│ ④ 轮询进度 → 导出 ZIP │ ◀─轮询── │ GET /api/suites/{id} │
|
||||
└────────────────────────────┘ │ GET /api/suites/{id}/zip │
|
||||
│ 页内悬浮面板 │ │ POST /api/plan │
|
||||
│ ① 扫描商品页(四站点) │ ─规划──▶ │ → DeepSeek 出图方案 │
|
||||
│ ② 勾选/编辑素材 │ ◀─方案── │ POST /api/generate(无状态) │
|
||||
│ ③ 出图方案(默认/AI规划) │ ──提交──▶ │ → 方案展开 → 逐张生图 │
|
||||
│ ④ 轮询进度 → 导出 ZIP │ ◀─轮询── │ GET /api/suites/{id}[/zip] │
|
||||
└────────────────────────────┘ │ GET /api/proxy-image │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### 任务与存储(无数据库设计)
|
||||
|
||||
- 生成任务存**进程内内存注册表**(`services/tasks.py`):轮询/导出只服务当前会话正在跟踪的任务,
|
||||
重启即新会话(进行中任务中断,前端会提示"任务已中断,请重新生成")—— 前端没有历史记录功能,
|
||||
任务状态无需跨进程持久化
|
||||
- **串行生成队列**:所有用户共享同一批 API key,同一时间只跑一个任务,其余排队(pending),
|
||||
避免触发中转限流;多用户并发提交互不干扰(任务按 id 隔离,单实例部署)
|
||||
- 图片本体全部落文件系统 `data/media/`(`/media` 静态托管),ZIP 导出直接读文件
|
||||
- 接口暂无鉴权:公网暴露前需内网/反代白名单限制,登录鉴权后续版本补充;
|
||||
将来若需任务恢复/历史记录/多实例部署,再引入数据库(任务表结构简单,迁移成本低)
|
||||
|
||||
### 采集引擎(extension/src)
|
||||
|
||||
- 声明式 `SiteProfile`(选择器 + srcProps + 去重/排除规则),加站点只需加一个 profile:
|
||||
@@ -34,13 +45,21 @@ Chrome 插件(WXT + React + antd) Python 后端(FastAPI + SQLite
|
||||
|
||||
### 套图生成(server/services)
|
||||
|
||||
- `prompt.py`:7 种图类型 × 5 套风格模板,公共组件 QUALITY / PRODUCT_REF_LOCK(商品一致性锁)/ TEXT_RENDER
|
||||
- 图类型:白底主图 / 核心卖点图 / 卖点图 / 材质图 / 场景展示图 / 多场景拼图 / 电商详情图
|
||||
- 风格:经典商拍 / 生活杂志 / 极简高冷 / 活力爆款 / 暗调质感
|
||||
- `services/prompts/`:提示词按模型家族独立封装,`__init__.py` 按 (provider, model) 路由分发
|
||||
- `common.py`:商品上下文提炼、5 套风格模板、图内文案规范 TEXT_RENDER(家族共用)
|
||||
- `alibaba.py`:通义 wan*/qwen*(主体参考语义);`doubao.py`:豆包(同语义,复用阿里装配)
|
||||
- `gpt.py`:gpt-image-2/-vip(`/v1/images/edits` 编辑语义,保真优先:商品只由 Image 1 定义,文字锚定仅作识别)
|
||||
- `google.py`:nano-banana 系列(原生主体保持语义)
|
||||
- 图类型:白底主图 / 核心卖点图 / 卖点图 / 材质图 / 场景展示图 / 多场景拼图 / 电商详情图 / 尺寸标注图 / SKU合集 / 创意图
|
||||
- 风格:北欧极简 / 清新明亮 / 高级感深色 / 暖调生活 / 纯净棚拍
|
||||
- 卖点从采集的参数表/卖点文本自动提炼
|
||||
- `generator.py`:图像 provider(图生图,参考图 = 采集主图)
|
||||
- `doubao`:火山方舟 Seedream(默认,`ARK_API_KEY`)
|
||||
- `tongyi`:通义万相/千问(`DASHSCOPE_API_KEY`,wan* 异步轮询 / qwen* 同步)
|
||||
- `rightapi`:gpt-image-2 / gpt-image-2-vip / nano-banana / nano-banana-2 / nano-banana-2-lite / nano-banana-pro(OpenAI 兼容中转 `RIGHTAPI_API_KEY`,统一 `/v1/images/generations` 异步任务流,参考图走 JSON `image` data-URI 数组——见 `docs/rightapi-调用排查与修复方案.md`)
|
||||
- ⚠️ `gpt-image-2-vip` 为官逆通道:不透传保真参数、参考图被弱化,商品还原度不稳定(生成前有警示);正式出图用 `gpt-image-2`
|
||||
- 插件只传模型名,服务端按模型名自动路由到对应 provider
|
||||
- `watermark.py`:生成图水印(Pillow 后处理,AI 出图后、落盘前合成;插件「服务端设置」里开关,默认样式复刻 ozonSeller:图片圆形徽章 / 文字白字黑描边,右下角;预览与导出即所见)
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -69,19 +88,21 @@ pnpm build # 产物在 .output/chrome-mv3
|
||||
|
||||
### 3. 使用
|
||||
|
||||
1. 打开 Ozon / 1688 / 淘宝 / 天猫 的**商品详情页**,滚动到底部(详情图懒加载)后点击插件图标
|
||||
2. Side Panel:扫描 → 检查/勾选素材(默认全选主图+SKU)→ 保存到服务端
|
||||
3. 选择风格 / 图类型 / 文案语言 → 一键生成 → 完成后「导出 ZIP」
|
||||
1. 打开 Ozon / 1688 / 淘宝 / 天猫 的**商品详情页**,滚动到底部(详情图懒加载),页面右下角出现「套」悬浮按钮
|
||||
2. 点击悬浮按钮(或点击工具栏插件图标)→ 右侧滑出悬浮面板,悬浮在商品页上方、不挤压原页面
|
||||
3. 面板内:扫描 → 检查/勾选素材(默认全选主图+SKU)→ 选择风格 / 图类型 / 文案语言 → 一键生成 → 完成后「导出 ZIP」
|
||||
4. 收起面板:面板顶栏 ✕、Esc 或再点工具栏图标;同一页面内重新展开,状态保留
|
||||
|
||||
## API 一览
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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(按方案标题命名) |
|
||||
| POST | `/api/export-images` | 导出采集图片 ZIP(`{title, images}`,内部按分组名建文件夹) |
|
||||
| GET | `/api/proxy-image?url=` | 图片代理(绕源站防盗链) |
|
||||
| GET | `/api/health` | 健康检查 + provider 配置状态 |
|
||||
|
||||
## 说明与限制
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# ===== 服务 =====
|
||||
# 注意:不能用 5000/7000(macOS 隔空播放接收器占用,localhost 走 IPv6 会被它截走返回 403)
|
||||
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)
|
||||
# 当前使用 ozon-seller-kit 的阿里 key,因此默认 tongyi
|
||||
IMAGE_PROVIDER=tongyi
|
||||
REQUEST_TIMEOUT=300
|
||||
POLL_MAX_WAIT=600
|
||||
|
||||
# --- 豆包 / 火山方舟(暂无 key)---
|
||||
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(来自 ozon-seller-kit/.env)---
|
||||
DASHSCOPE_API_KEY=sk-ws-H.ERYXEHP.cxZf.MEUCIF0mtavEb2GGVW0XGUNG9_Hp8MyP4ciDW9U3zxNFw-8aAiEA5GcGCH99DhcyzEujKt1vCRT8PpRypf57M3AMfuFdZWI
|
||||
DASHSCOPE_BASE_URL=
|
||||
DASHSCOPE_MODEL=wan2.7-image-pro
|
||||
|
||||
# ===== 预留:DeepSeek(出图方案规划器)=====
|
||||
DEEPSEEK_API_KEY=sk-5e288c6750944ebe9379e9dadaf2cf16
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
|
||||
# --- RightAPI(gpt-image,OpenAI 兼容中转)---
|
||||
RIGHTAPI_API_KEY=sk-b6b9ffba28b64ca795be31f6dee842c6
|
||||
RIGHTAPI_BASE_URL=https://rightapi.ai/draw
|
||||
RIGHTAPI_IMAGE_MODEL=gpt-image-2
|
||||
# 出图质量:auto | low | medium | high(high 单张约 1-5 分钟,超时自动兜底 600s)
|
||||
RIGHTAPI_IMAGE_QUALITY=high
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# RightAPI(gpt-image / nano-banana)调用方式排查报告
|
||||
|
||||
> 2026-08-20 · 状态:**已实施**(§6 已落地到 `server/services/generator.py` 与配置)
|
||||
> 结论先行:**是调用方式不对**。现行代码把参考图用 multipart 传给未在文档中的
|
||||
> `/v1/images/edits` 端点;该中转已于 2026-07-14 全面切换"统一异步模式",文档中的
|
||||
> 正确用法是 `/v1/images/generations` + JSON `image`(data-URI 数组)+ `async: true`
|
||||
> 提交任务,再轮询 `/v1/tasks/{task_id}` 取图。实测:**文档路径下 gpt-image-2、
|
||||
> gpt-image-2-vip、nano-banana-2-lite 全部逐像素保真**;现行 edits 路径要么 502、
|
||||
> 要么出图但参考图未生效(商品按文字重造)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现行代码怎么调的(generator.py `_rightapi_request`)
|
||||
|
||||
```python
|
||||
# 有参考图(套图流程必然有)→ multipart POST /v1/images/edits
|
||||
files = [("image[]", ("ref-1.png", data, mime)), ...]
|
||||
data = {"model", "prompt", "size": "2048x2048", "quality": "high",
|
||||
"output_format": "jpeg", "n": 1, "input_fidelity": "high"}
|
||||
resp = client.post(f"{base}/v1/images/edits", files=files, data=data)
|
||||
# 期望同步响应 data[0].b64_json / data[0].url,无任务轮询
|
||||
```
|
||||
|
||||
问题:
|
||||
- **`/v1/images/edits` 不在文档接口列表里**(文档只有:图片生成、Gemini 生成、任务查询);
|
||||
- 参考图用 multipart `image[]` 传输——新管道只认 JSON body 里的 `image`(data-URI 数组);
|
||||
- 未带 `"async": true`,也没有任务轮询逻辑;
|
||||
- `quality` / `output_format` / `input_fidelity` 均不在文档参数表中。
|
||||
|
||||
## 2. 文档的正确用法(docs.rightapi.ai,2026-07-14 更新)
|
||||
|
||||
### 2.1 提交:POST `/v1/images/generations`(OpenAI Images 兼容,异步)
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-image-2", // 或 nano-banana 系列等
|
||||
"prompt": "...",
|
||||
"n": 1,
|
||||
"size": "1:1", // 比例 1:1 / 16:9 / 9:16 / 4:3,或像素串 "1024x1024"
|
||||
"async": true, // 固定带
|
||||
"image": ["data:image/png;base64,..."] // 参考图:data-URI 数组(保真关键)
|
||||
}
|
||||
```
|
||||
|
||||
响应(立即返回):
|
||||
|
||||
```json
|
||||
{"task_id": "task_xxx", "status": "processing", "progress": 0, "message": "..."}
|
||||
```
|
||||
|
||||
### 2.2 轮询:GET `/v1/tasks/{task_id}`(站点级,**不带 /draw 前缀**)
|
||||
|
||||
- 进行中:`{"id","task_id","object","model","status":"in_progress","progress":0~2,"created_at"}`
|
||||
- **完成:`{"created": ..., "data": [{"url": "https://...jpeg"}]}`**
|
||||
——实测完成响应**没有 `status: "completed"` 字段**(与文档描述不符),
|
||||
完成判定 = 响应里出现 `data`;结果只有 `url`(未见 b64_json)。
|
||||
- `progress` 基本不动(一直 0~2),只能当装饰,不能当进度条依据。
|
||||
|
||||
### 2.3 其他要点
|
||||
|
||||
- Gemini 原生端点 `/v1beta/models/{model}:generateContent`(contents/parts + inline_data,
|
||||
generationConfig.imageConfig 支持 aspectRatio / imageSize)——nano-banana 系列可走,
|
||||
但非必需(generations 端点同样支持传参考图),本期可不做;
|
||||
- `imageSize`:"1K"/"2K"/"4K",**仅 nano-banana / gpt-image vip 模型可用**;
|
||||
- 文档域名示例为 `www.right.codes/draw`,实测现有配置 `rightapi.ai/draw` 仍通
|
||||
(提交与任务查询都可用,`rightapi.ai/v1/tasks/...` 实测正常)。
|
||||
|
||||
## 3. 实测证据(2026-08-20,受控对照实验)
|
||||
|
||||
测试图:程序生成的特征图形——白底 + 青色杯身 + 红色横条纹 + 三颗黄色五角星 + 右侧把手。
|
||||
提示词:"把背景替换成纯绿色,保持图中那个青色杯子完全不变……"。
|
||||
保真判定 = 逐项核对杯身/条纹/星星/把手是否原样(我人工查看生成图)。
|
||||
|
||||
| # | 路径 | 模型 | 结果 |
|
||||
|---|------|------|------|
|
||||
| A | **文档路径** generations + image[] + async | nano-banana-2-lite | ✅ **保真完美**,仅背景变绿 |
|
||||
| C | **文档路径** generations + image[] + async | gpt-image-2 | ✅ **保真完美**,仅背景变绿 |
|
||||
| D | **文档路径** generations + image[] + async | gpt-image-2-vip(官逆) | ✅ **保真完美**,仅背景变绿 |
|
||||
| B | **现行代码** edits + multipart image[] | nano-banana-2-lite | ❌ **502 Bad Gateway**(间隔 90s 重试仍 502;同期 generations 路径正常) |
|
||||
|
||||
用户今日实测(11:08–11:16,本地任务表,同一鲨鱼玩偶参考图):
|
||||
|
||||
| 套图 | 模型(路径) | 结果 |
|
||||
|------|--------------|------|
|
||||
| 7bd57ffa | gpt-image-2-vip(现行 edits) | ⚠️ 出图,但鲨鱼被**重新设计**(眼睛/鱼鳍/比例全变) |
|
||||
| ee36e92f | nano-banana-2(现行 edits) | ⚠️ 同上,商品被重造 |
|
||||
| 42c37fa2 | wan2.6-image(DashScope,正常链路) | ⚠️ 鲨鱼同样有漂移(**另一层问题**,见 §5) |
|
||||
|
||||
探针产物(供复核):`/tmp/rightapi-probe/`(ref.png / gen-async-lite.png / gen-async-0.png)。
|
||||
|
||||
## 4. 根因分析
|
||||
|
||||
1. **参考图从未真正送达模型**:edits + multipart 是旧同步模式的调用方式;中转 7-14
|
||||
切到统一异步管道后,multipart 参考图不被解析 → 模型只收到 prompt 文字 → 按文字
|
||||
(含标题/风格词)重新合成商品 → **"不是原商品"必现**。gpt 与 google 全中,因为
|
||||
它们共用这一条错误链路。
|
||||
2. **端点本身进入半废弃状态**:今天 edits 已对 lite 模型直接 502(两次、间隔 90s),
|
||||
对 gpt-image-2-vip / nano-banana-2 尚能返回(用户 11 点实测出图)——属于残留兼容,
|
||||
随时可能全断。之前代码里"同 key 分钟级冷却 502"的注释,与该端点的不稳定状态吻合。
|
||||
3. 提示词层面的修复(上一轮 gpt/google 家族重写)方向正确但**没治病根**:参考图没到
|
||||
模型,提示词写得再保真也没用。证据:同一套提示词组件,走文档路径(探测 A/C/D)
|
||||
保真完美。
|
||||
|
||||
## 5. 顺带观察:通义今日也有漂移(不在本次修复范围)
|
||||
|
||||
wan2.6-image 走 DashScope 正常链路(参考图确实送达)仍重造了鲨鱼——这是
|
||||
主体参考模型能力/提示词层面的问题(wan2.6-image 是参考遵循较弱的一档),
|
||||
与本次 RightAPI 调用方式无关,建议后续单独评估(比如套餐默认模型换成
|
||||
wan2.7-image-pro 或 qwen-image-3.0-pro,两者参考遵循更强)。
|
||||
|
||||
## 6. 修复方案(已实施)
|
||||
|
||||
只改 `server/services/generator.py` 的 RightAPI provider,提示词层不动:
|
||||
|
||||
1. **统一走 `/v1/images/generations`**(有无参考图都走它;无参考图就不带 `image` 字段):
|
||||
```python
|
||||
body = {"model": model, "prompt": prompt, "n": 1,
|
||||
"size": size, "async": True}
|
||||
if refs:
|
||||
body["image"] = [data_uri, ...] # data-URI 数组(≤2 张,沿用现选图逻辑)
|
||||
resp = post(f"{base}/v1/images/generations", json=body)
|
||||
task_id = resp.json()["task_id"]
|
||||
```
|
||||
2. **新增任务轮询**:`GET {origin}/v1/tasks/{task_id}`(origin = base 去掉 `/draw`);
|
||||
3s 起步、逐步加到 10s,上限沿用 `poll_max_wait`(600s,gpt 高质量单张 1–5 分钟);
|
||||
完成判定 = `data` 出现(不能依赖 `status == "completed"`);失败态 = `status` 为
|
||||
failed/error/cancelled;然后下载 `data[0].url`。
|
||||
3. **参数清理**:删 `quality` / `output_format` / `input_fidelity`(均非文档参数;
|
||||
`input_fidelity` 的探测-降级机制整体移除)。`size` 改传像素串
|
||||
`"1536x2048"`(3:4)/ `"2048x2048"`(1:1)——比例枚举里没有 3:4,像素串是文档允许的写法。
|
||||
4. **重试保留**:提交/轮询遇到 429/5xx/超时,沿用 60→120→240s 退避(`rightapi_max_retries`)。
|
||||
5. **配置**:`RIGHTAPI_BASE_URL` 保持 `https://rightapi.ai/draw` 不变;`rightapi_image_quality`
|
||||
配置项删除(或停用)。
|
||||
|
||||
预计工作量:`_rightapi_request` 重写约 60 行 + 轮询函数 30 行,其余层(提示词分发、
|
||||
任务执行器、前端)零改动。
|
||||
|
||||
## 7. 上线前待确认项
|
||||
|
||||
1. **3:4 像素串 `1536x2048` 是否被接受**——探测只验证了 `size: "1:1"`(文档说像素串
|
||||
合法,但建议改完后先出 1 张 Ozon 规格图验证);
|
||||
2. nano-banana / nano-banana-2 / nano-banana-pro 三个型号未逐一实测(同族接口一致,
|
||||
lite / gpt 系已验证通路,风险低);
|
||||
3. 是否启用 `imageSize`(2K/4K,仅 nano-banana 与 gpt-image vip 支持)——默认不传,
|
||||
需要高清再说;
|
||||
4. Gemini 原生端点(`:generateContent`)本期不接,留作后续选项。
|
||||
@@ -4,8 +4,15 @@ import { generateSuite, getSuite, planSuite } from '../src/api/client';
|
||||
export default defineBackground(() => {
|
||||
console.log('[电商套图工作台] background started');
|
||||
|
||||
// 点击扩展图标 → 打开 Side Panel
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||
// 点击扩展图标 → 开关当前商品页的悬浮面板(页面无 content script 时忽略)
|
||||
chrome.action.onClicked.addListener(async (tab) => {
|
||||
if (tab.id == null) return;
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tab.id, { action: 'toggle-suite-panel' });
|
||||
} catch {
|
||||
// 非四站点页面,未注入面板
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.action === 'generateSuite') {
|
||||
@@ -29,6 +36,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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }, '*');
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// Panel Content Script —— 商品页右下角悬浮按钮 + 页内悬浮面板
|
||||
//
|
||||
// 面板 = iframe 加载插件内置 sidepanel.html(扩展页面在 iframe 里仍有 chrome.* 权限,
|
||||
// 采集/生成/导出逻辑零改动);按钮与面板容器渲染在独立 Shadow DOM 中,
|
||||
// 不受商品页全局 CSS 影响,面板悬浮覆盖页面、不挤压原页面布局。
|
||||
import { matchProfile } from '../src/profiles/index';
|
||||
|
||||
/** 面板内 App.tsx → 宿主页的收起消息 */
|
||||
const PANEL_CLOSE_MSG = 'sc-panel-close';
|
||||
|
||||
const STYLES = `
|
||||
:host { all: initial; }
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: 700 20px/1 -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
|
||||
box-shadow: 0 4px 16px rgba(109, 40, 217, 0.45);
|
||||
z-index: 3;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.fab:hover { transform: scale(1.08); }
|
||||
.fab.hidden { display: none; }
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: min(880px, 100vw);
|
||||
border-radius: 14px 0 0 14px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.panel.open { transform: translateX(0); pointer-events: auto; }
|
||||
|
||||
.panel iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
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/*',
|
||||
],
|
||||
async main(ctx) {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'suite-studio-panel',
|
||||
position: 'overlay',
|
||||
anchor: 'body',
|
||||
alignment: 'bottom-right',
|
||||
zIndex: 2147483646,
|
||||
css: STYLES,
|
||||
isolateEvents: true,
|
||||
onMount(container) {
|
||||
const fab = document.createElement('button');
|
||||
fab.className = 'fab hidden';
|
||||
fab.title = '电商套图工作台';
|
||||
fab.textContent = '套';
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'panel';
|
||||
// iframe 懒加载:首次展开才设 src,避免每个商品页都加载整个面板应用
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.title = '电商套图工作台';
|
||||
panel.append(iframe);
|
||||
container.append(fab, panel);
|
||||
|
||||
const open = () => {
|
||||
if (!iframe.src) iframe.src = chrome.runtime.getURL('/sidepanel.html');
|
||||
panel.classList.add('open');
|
||||
fab.classList.add('hidden');
|
||||
// 聚焦进面板,键盘操作(Esc 关闭 / 预览翻页)直接可用
|
||||
iframe.focus();
|
||||
};
|
||||
const close = () => {
|
||||
panel.classList.remove('open');
|
||||
if (isProductPage()) fab.classList.remove('hidden');
|
||||
};
|
||||
|
||||
fab.addEventListener('click', open);
|
||||
|
||||
// 面板内 App(✕ / Esc)→ 收起
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.source === iframe.contentWindow && (e.data as any)?.type === PANEL_CLOSE_MSG) close();
|
||||
});
|
||||
|
||||
// 工具栏图标点击 → 开关面板
|
||||
chrome.runtime.onMessage.addListener((msg: any) => {
|
||||
if (msg?.action === 'toggle-suite-panel') {
|
||||
panel.classList.contains('open') ? close() : open();
|
||||
}
|
||||
});
|
||||
|
||||
// 仅商品详情页显示按钮;站内软导航后重判(WXT 内置事件,自动拦截 pushState/replaceState/popState)
|
||||
const refresh = () => {
|
||||
if (isProductPage()) {
|
||||
if (!panel.classList.contains('open')) fab.classList.remove('hidden');
|
||||
} else {
|
||||
fab.classList.add('hidden');
|
||||
close();
|
||||
}
|
||||
};
|
||||
ctx.addEventListener(window, 'wxt:locationchange', refresh);
|
||||
refresh();
|
||||
|
||||
return { open, close };
|
||||
},
|
||||
});
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
|
||||
/** 是否为四站点支持的商品详情页(与采集 profile 一致) */
|
||||
function isProductPage(): boolean {
|
||||
return matchProfile(location.href) !== null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
--primary: #8b5cf6; /* 紫(ozon-seller-kit v2 主题色) */
|
||||
--primary-hover: #7c3aed;
|
||||
--primary-ring: rgba(139, 92, 246, 0.12);
|
||||
--primary-soft: #a78bfa; /* 主题色同色系偏淡(未勾选描边/✓) */
|
||||
--green: #52c41a;
|
||||
--red: #ff4d4f;
|
||||
--warn-bg: #fffbe6;
|
||||
@@ -41,7 +42,20 @@
|
||||
.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; }
|
||||
.img-export-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 10px 16px 2px; padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.img-export-bar .hint { font-size: 12px; color: var(--text-2); }
|
||||
.divider { border-top: 1px solid var(--border); margin: 12px 0; }
|
||||
|
||||
/* ── 顶部 ── */
|
||||
@@ -60,7 +74,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 +86,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 +153,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 +172,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);
|
||||
@@ -147,11 +181,11 @@
|
||||
.img-cell.on { border-color: var(--primary); }
|
||||
.img-cell .tick {
|
||||
position: absolute; top: 5px; left: 5px; width: 18px; height: 18px;
|
||||
border-radius: 50%; border: 1.5px solid #fff;
|
||||
border-radius: 50%; border: 1.5px solid var(--primary-soft);
|
||||
background: rgba(255, 255, 255, 0.55); display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 11px; transition: all .15s; cursor: pointer;
|
||||
color: var(--primary-soft); font-size: 11px; transition: all .15s; cursor: pointer;
|
||||
}
|
||||
.img-cell.on .tick { background: var(--primary); border-color: var(--primary); }
|
||||
.img-cell.on .tick { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.img-cell .variant {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 11px;
|
||||
@@ -181,10 +215,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 +242,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; }
|
||||
|
||||
/* ── 出图方案 ── */
|
||||
@@ -202,9 +269,15 @@
|
||||
.plan-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--card-soft);
|
||||
background: var(--card-soft); cursor: pointer;
|
||||
}
|
||||
.plan-row:hover { border-color: var(--primary); }
|
||||
.plan-row.off { opacity: .45; }
|
||||
.plan-all-toggle {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 8px 2px 2px; padding-top: 6px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.plan-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
|
||||
.plan-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
|
||||
.variant-chip {
|
||||
@@ -215,6 +288,8 @@
|
||||
font-size: 12px; color: var(--text-2);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
/* 构图提示(prompt_hint):生图要求的落地处,用主题蓝区分 */
|
||||
.plan-detail.plan-hint { color: #4f6bed; }
|
||||
.stepper { display: inline-flex; align-items: center; gap: 0; flex-shrink: 0; }
|
||||
.step-btn {
|
||||
width: 24px; height: 24px; border: 1px solid var(--border-strong); background: #fff;
|
||||
@@ -233,6 +308,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;
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.5.3",
|
||||
"wxt": "^0.19.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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全部断言通过 ✅');
|
||||
+157
-117
@@ -1,31 +1,10 @@
|
||||
/**
|
||||
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
|
||||
* 契约对齐 server 端 /api/materials 与 /api/suites。
|
||||
* 契约对齐 server 端 /api/plan、/api/generate 与 /api/suites。
|
||||
*/
|
||||
import type { ScanResult } from '../collector/scan';
|
||||
import type { ImageMaterial } from '../collector/scan';
|
||||
|
||||
export interface MaterialsPayload {
|
||||
product_id: string | null;
|
||||
source: {
|
||||
platform: string;
|
||||
itemId: string | null;
|
||||
url: string;
|
||||
collectedAt: number;
|
||||
};
|
||||
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
|
||||
images: Array<{
|
||||
groupKey: string;
|
||||
groupName: string;
|
||||
variantName?: string | null;
|
||||
url: string;
|
||||
index: number;
|
||||
type: string;
|
||||
dedupeKey?: string | null;
|
||||
}>;
|
||||
refererOrigin?: string;
|
||||
}
|
||||
|
||||
/** 服务端支持的套图类型(与 server/services/prompt.py 保持一致) */
|
||||
/** 服务端支持的套图类型(与 server/services/prompts/common.py 保持一致) */
|
||||
export const SUITE_TYPE_OPTIONS = [
|
||||
{ value: 'white_bg', label: '白底主图' },
|
||||
{ value: 'key_features', label: '核心卖点图' },
|
||||
@@ -54,12 +33,33 @@ 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: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
|
||||
@@ -71,6 +71,60 @@ export const PLATFORM_OPTIONS = [
|
||||
|
||||
export type PlatformId = (typeof PLATFORM_OPTIONS)[number]['value'];
|
||||
|
||||
/** 生图模型(下拉可选 + 中文特点说明;服务端按模型名路由 provider) */
|
||||
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: '异步精修,质感与细节更强,适合高质量电商大片',
|
||||
},
|
||||
{
|
||||
value: 'wan2.6-image',
|
||||
label: 'wan2.6-image',
|
||||
desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好',
|
||||
},
|
||||
{
|
||||
value: 'wan2.6-t2i',
|
||||
label: 'wan2.6-t2i',
|
||||
desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快',
|
||||
},
|
||||
{
|
||||
value: 'gpt-image-2',
|
||||
label: 'gpt-image-2',
|
||||
desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟',
|
||||
},
|
||||
{
|
||||
value: 'gpt-image-2-vip',
|
||||
label: 'gpt-image-2-vip',
|
||||
desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana',
|
||||
label: 'nano-banana',
|
||||
desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana-2',
|
||||
label: 'nano-banana-2',
|
||||
desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana-2-lite',
|
||||
label: 'nano-banana-2-lite',
|
||||
desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana-pro',
|
||||
label: 'nano-banana-pro',
|
||||
desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片',
|
||||
},
|
||||
] 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' },
|
||||
@@ -87,84 +141,27 @@ export interface SuiteImageInfo {
|
||||
|
||||
export interface SuiteInfo {
|
||||
id: string;
|
||||
product_id: string;
|
||||
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
|
||||
style_set: number;
|
||||
platform: string;
|
||||
lang: string;
|
||||
ratio: string;
|
||||
types: string[];
|
||||
provider: string;
|
||||
total?: number; // 计划生成总张数(后端返回;images 逐张追加,过程中 length < total)
|
||||
images: SuiteImageInfo[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** 用(可能已二次修改的)文本 + 已勾选图片,组装 /api/materials 请求体 */
|
||||
export function buildMaterialsPayload(
|
||||
result: ScanResult,
|
||||
selectedKeys: Set<string>,
|
||||
edits?: { title?: string; desc?: string },
|
||||
): MaterialsPayload {
|
||||
const orig = (kind: string) => result.texts.find((t) => t.kind === kind);
|
||||
|
||||
const texts: MaterialsPayload['texts'] = [];
|
||||
const title = edits?.title ?? orig('title')?.content ?? '';
|
||||
const price = orig('price')?.content ?? '';
|
||||
const brand = orig('brand')?.content ?? '';
|
||||
const params = orig('params')?.pairs ?? [];
|
||||
const sellingPoints = orig('selling_point')?.content ?? '';
|
||||
const desc = edits?.desc ?? orig('desc')?.content ?? '';
|
||||
|
||||
if (title) texts.push({ kind: 'title', content: title });
|
||||
if (price) texts.push({ kind: 'price', content: price });
|
||||
if (brand) texts.push({ kind: 'brand', content: brand });
|
||||
if (params.length) texts.push({ kind: 'params', content: '', pairs: params });
|
||||
if (sellingPoints) texts.push({ kind: 'selling_point', content: sellingPoints });
|
||||
if (desc) texts.push({ kind: 'desc', content: desc });
|
||||
|
||||
const images = result.images
|
||||
.filter((img) => selectedKeys.has(img.key))
|
||||
.map((img) => ({
|
||||
groupKey: img.groupKey,
|
||||
groupName: img.groupName,
|
||||
variantName: img.variantName ?? null,
|
||||
url: img.url,
|
||||
index: img.index,
|
||||
type: img.type,
|
||||
dedupeKey: img.url,
|
||||
}));
|
||||
|
||||
return {
|
||||
product_id: null,
|
||||
source: {
|
||||
platform: result.platform,
|
||||
itemId: result.itemId,
|
||||
url: result.url,
|
||||
collectedAt: result.scannedAt,
|
||||
},
|
||||
texts,
|
||||
images,
|
||||
refererOrigin: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export async function uploadMaterials(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
payload: MaterialsPayload,
|
||||
): Promise<{ product_id: string; assets_queued: number; assets_skipped: number }> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/materials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
|
||||
return data;
|
||||
/** 生成图水印选项(服务端在 AI 出图后合成;shape 对齐 server WatermarkOptions) */
|
||||
export interface WatermarkPayload {
|
||||
enabled: boolean;
|
||||
type: 'image' | 'text';
|
||||
text: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */
|
||||
@@ -172,39 +169,25 @@ 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;
|
||||
requirements?: string | null;
|
||||
plan: PlanItem[];
|
||||
platform: string;
|
||||
model?: string | null;
|
||||
watermark?: WatermarkPayload;
|
||||
}
|
||||
|
||||
/** 组装无状态生成请求:已编辑的文本 + 已勾选图片 + 出图方案 */
|
||||
/** 组装无状态生成请求:编辑后的文本 + 已勾选图片(含手动上传)+ 出图方案 */
|
||||
export function buildGeneratePayload(
|
||||
result: ScanResult,
|
||||
images: ImageMaterial[],
|
||||
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; requirements?: string | null; plan: PlanItem[]; platform: string; model?: string | null; watermark?: WatermarkPayload },
|
||||
): 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
|
||||
const selected = 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 };
|
||||
return { texts, images: selected, ...config };
|
||||
}
|
||||
|
||||
/** 出图方案规划请求体 */
|
||||
@@ -213,6 +196,7 @@ export interface PlanPayload {
|
||||
sku_variants: string[];
|
||||
image_stats: Record<string, number>;
|
||||
platform: string;
|
||||
requirements?: string | null;
|
||||
}
|
||||
|
||||
/** AI 智能规划:DeepSeek 根据商品信息生成出图方案 */
|
||||
@@ -260,3 +244,59 @@ export async function getSuite(baseUrl: string, token: string, suiteId: string):
|
||||
export function suiteZipUrl(baseUrl: string, suiteId: string): string {
|
||||
return `${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}/zip`;
|
||||
}
|
||||
|
||||
/** 下载生成结果 ZIP:GET → blob(由调用方经 chrome.downloads 落盘,文件名用标题) */
|
||||
export async function downloadSuiteZip(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
suiteId: string,
|
||||
): Promise<Blob> {
|
||||
const res = await fetch(suiteZipUrl(baseUrl, suiteId), { headers: authHeaders(token) });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.detail || `下载失败 HTTP ${res.status}`);
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
/** 手动上传本地图片到服务端,返回可访问 URL(补充参考图用) */
|
||||
export async function uploadImage(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
file: File,
|
||||
): Promise<{ url: string; key: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token), // 不显式设 Content-Type,交给浏览器生成 boundary
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 导出采集图片请求体:后端打包成 ZIP(内部按分组名建文件夹,文件名沿用采集 key) */
|
||||
export interface ExportImagesPayload {
|
||||
title: string;
|
||||
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
|
||||
}
|
||||
|
||||
/** 导出采集图片:POST /api/export-images → ZIP blob(由调用方经 chrome.downloads 落盘) */
|
||||
export async function exportImages(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
payload: ExportImagesPayload,
|
||||
): Promise<Blob> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/export-images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.detail || `导出失败 HTTP ${res.status}`);
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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 }>;
|
||||
}
|
||||
|
||||
/** 深度查找指定键(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;
|
||||
|
||||
// ── SKU:skuModel.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 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,
|
||||
};
|
||||
}
|
||||
@@ -52,3 +52,37 @@ export function queryAllDeep(selectors: string[]): Element[] {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
|
||||
* 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征),
|
||||
* 等待页面高度增长,页面不再变高或达到步数上限即停——防止底部「为你推荐」无限加载把采集卡死。
|
||||
* 滚完恢复原位。
|
||||
*/
|
||||
export async function autoScrollToBottom(
|
||||
opts: { stepPx?: number; stepMs?: number; maxSteps?: number } = {}
|
||||
): Promise<void> {
|
||||
const { stepPx = 500, stepMs = 400, maxSteps = 80 } = opts;
|
||||
const startY = window.scrollY;
|
||||
let lastHeight = document.body.scrollHeight;
|
||||
let stagnant = 0; // 连续不增长的步数
|
||||
// 每步在 0.7~1.3 倍步长、0.7~1.5 倍间隔内随机抖动,模拟人工节奏
|
||||
const rand = (min: number, max: number) => min + Math.random() * (max - min);
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
window.scrollBy({ top: Math.round(stepPx * rand(0.7, 1.3)), behavior: 'auto' });
|
||||
await new Promise(r => setTimeout(r, Math.round(stepMs * rand(0.7, 1.5))));
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 通用合并器:各平台采集路径共用的结果装配逻辑。
|
||||
* 从 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 1688 采集编排(平台文件):
|
||||
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
|
||||
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
|
||||
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
|
||||
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑),
|
||||
* 详情图改为滚动加载后由 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 { extract1688State, type State1688 } from '../1688-state';
|
||||
import { 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';
|
||||
}
|
||||
|
||||
// ② 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;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Ozon 采集编排(平台文件):四路径合并(来自 extension-v2 生产逻辑)。
|
||||
* ① SSR widget state(DOM data-state 属性,白名单)★主路径
|
||||
* ② JSON-LD(schema.org/Product)
|
||||
* ③ 站内页 JSON API(entrypoint-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,49 @@
|
||||
/**
|
||||
* 淘宝/天猫 采集编排(平台文件):
|
||||
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
|
||||
* (isolated world 读不到 window 变量,v1 直读是无效的)
|
||||
* ② DOM 兜底 + 补充
|
||||
* 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com),
|
||||
* 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。
|
||||
*/
|
||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
||||
import { collectImages, type ImageMaterial } from '../image';
|
||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||
import { readWindowKeys } from '../../bridge/read-window';
|
||||
import { buildFromSSR } from '../ssr-builder';
|
||||
import { taobaoStateFromBridge } from '../taobao-state';
|
||||
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';
|
||||
}
|
||||
|
||||
// ② 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
@@ -1,307 +1,21 @@
|
||||
/**
|
||||
* 统一采集引擎入口 - 扫描当前页
|
||||
* 统一采集引擎入口 - 只做路由:按平台分发到 platforms/ 下的平台文件。
|
||||
*
|
||||
* 按平台选择采集策略:
|
||||
* - ozon:四路径(SSR data-state ★主路径 → JSON-LD → 页 JSON API → DOM 兜底),多源合并
|
||||
* - taobao/tmall:SSR(window.__ICE_APP_CONTEXT__)★主路径 + DOM 补充(详情图在 DOM 里)
|
||||
* - 1688:纯 DOM(多套选择器变体)
|
||||
*
|
||||
* 各路径产出的素材最终走同一个合并器:文本按 kind 合并(params 按键并集),
|
||||
* 图片按组去重后重排 key。
|
||||
* 平台编排逻辑(各路径与合并策略)见:
|
||||
* platforms/ozon.ts Ozon 四路径(SSR data-state / JSON-LD / 站内 API / DOM)
|
||||
* platforms/taobao.ts 淘宝/天猫(桥读全局 SSR / DOM)
|
||||
* platforms/1688.ts 1688(桥读 window.context SSR / 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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++,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 淘宝/天猫 SSR 状态提取 —— 通过 MAIN world 桥读取页面全局变量。
|
||||
*
|
||||
* 候选键(selectors-taobao.md §5.3 列出的待评估项):
|
||||
* __ICE_APP_CONTEXT__ ICE 框架上下文(loaderData.home.data.res,v1 已知结构)★主
|
||||
* __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;
|
||||
}
|
||||
@@ -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 }> = [];
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -123,6 +123,12 @@ export const profileTaobao: SiteProfile = {
|
||||
'[class*="comments--"]',
|
||||
'[class*="userInfo--"]',
|
||||
'[class*="rate"]',
|
||||
// 本店推荐:详情区底部的推荐卡片流(RecommendInfo-- 容器 / data-spm="recommends" /
|
||||
// recommend-- 卡片区 / cardPic-- 卡片图盒),不是本商品的详情图,不能采
|
||||
'[class*="RecommendInfo--"]',
|
||||
'[data-spm="recommends"]',
|
||||
'[class*="recommend--"]',
|
||||
'[class*="cardPic--"]',
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100,
|
||||
|
||||
@@ -13,9 +13,11 @@ export type TextKind =
|
||||
| 'params'
|
||||
| 'selling_point'
|
||||
| 'desc'
|
||||
| 'brand';
|
||||
| 'brand'
|
||||
| 'sales'
|
||||
| 'shop';
|
||||
|
||||
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video';
|
||||
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video' | 'upload';
|
||||
|
||||
export type SrcProp =
|
||||
| 'data-lazyload-src'
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
/**
|
||||
* 服务端设置(上传/生成用):后端地址 + Bearer Token,持久化到 chrome.storage.local。
|
||||
* 服务端设置(上传/生成用):后端地址 + Bearer Token + 水印选项,持久化到 chrome.storage.local。
|
||||
*/
|
||||
|
||||
/** 生成图水印:服务端在 AI 出图后、落盘前合成(与生图模型无关)。默认复刻 ozonSeller。 */
|
||||
export interface WatermarkSettings {
|
||||
enabled: boolean;
|
||||
type: 'image' | 'text';
|
||||
text: string;
|
||||
opacity: number; // 1-100(%)
|
||||
}
|
||||
|
||||
export interface BackendSettings {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
watermark: WatermarkSettings;
|
||||
}
|
||||
|
||||
const KEY = 'suite_backend_settings';
|
||||
@@ -13,6 +23,7 @@ export const DEFAULT_BASE_URL = 'http://127.0.0.1:3300';
|
||||
const DEFAULT: BackendSettings = {
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
token: '',
|
||||
watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 },
|
||||
};
|
||||
|
||||
/** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置) */
|
||||
@@ -30,7 +41,13 @@ export async function loadSettings(): Promise<BackendSettings> {
|
||||
const r = await chrome.storage.local.get(KEY);
|
||||
const saved = r[KEY] ?? {};
|
||||
const baseUrl = MIGRATE[saved.baseUrl] ?? saved.baseUrl ?? DEFAULT.baseUrl;
|
||||
const s: BackendSettings = { token: '', ...saved, baseUrl };
|
||||
// 水印子对象深合并:老版本存储里没有 watermark,避免整对象覆盖丢默认值
|
||||
const s: BackendSettings = {
|
||||
token: '',
|
||||
...saved,
|
||||
baseUrl,
|
||||
watermark: { ...DEFAULT.watermark, ...(saved.watermark ?? {}) },
|
||||
};
|
||||
if (baseUrl !== saved.baseUrl) await chrome.storage.local.set({ [KEY]: s }); // 迁移结果写回
|
||||
return s;
|
||||
}
|
||||
|
||||
+17
-2
@@ -8,7 +8,8 @@ export default defineConfig({
|
||||
'storage',
|
||||
'sidePanel',
|
||||
'activeTab',
|
||||
'scripting' // 执行 content script 函数需要
|
||||
'scripting', // 执行 content script 函数需要
|
||||
'downloads' // 导出采集图片 / 套图 ZIP 到本地
|
||||
],
|
||||
host_permissions: [
|
||||
// Ozon 商品页 + 图片 CDN
|
||||
@@ -21,13 +22,27 @@ export default defineConfig({
|
||||
'https://item.taobao.com/*',
|
||||
'https://detail.tmall.com/*',
|
||||
'https://*.alicdn.com/*',
|
||||
// 1688 详情数据 CDN(description.detailUrl)
|
||||
'https://itemcdn.tmall.com/*',
|
||||
// 本机后端(上传 / 生成套图用);生产换成你的公网域名
|
||||
'http://127.0.0.1:3300/*',
|
||||
'http://localhost:3300/*'
|
||||
],
|
||||
action: {
|
||||
default_title: '电商套图工作台'
|
||||
}
|
||||
},
|
||||
// 页内悬浮面板用 iframe 加载 sidepanel.html,必须声明为 web accessible
|
||||
web_accessible_resources: [{
|
||||
resources: ['sidepanel.html'],
|
||||
matches: [
|
||||
'https://*.ozon.ru/*',
|
||||
'https://*.ozon.kz/*',
|
||||
'https://*.ozon.by/*',
|
||||
'https://detail.1688.com/*',
|
||||
'https://item.taobao.com/*',
|
||||
'https://detail.tmall.com/*',
|
||||
],
|
||||
}]
|
||||
},
|
||||
modules: ['react']
|
||||
});
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""采集入库:插件上传文本 + 图片 URL,落库后异步转存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db import get_db, get_session_factory
|
||||
from models import (
|
||||
Product, ProductAsset,
|
||||
STATUS_PENDING, STATUS_DOWNLOADING, STATUS_OK, STATUS_FAILED, STAGE_COLLECTED,
|
||||
)
|
||||
from schemas import MaterialsRequest, MaterialsResponse, TextMaterial
|
||||
from services import storage
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["collection"])
|
||||
|
||||
|
||||
def _parse_number(text: str | None) -> float | None:
|
||||
"""'1 290 ₽' / '¥36.80' → 1290.0 / 36.8"""
|
||||
if not text:
|
||||
return None
|
||||
m = re.search(r"(\d+(?:[.,]\d+)?)", text.replace(" ", "").replace(",", "."))
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _apply_texts(product: Product, texts: list[TextMaterial]) -> None:
|
||||
raw = dict(product.raw or {})
|
||||
raw_texts: list[dict] = list(raw.get("texts") or [])
|
||||
for t in texts:
|
||||
raw_texts.append({"kind": t.kind, "content": t.content, "pairs": t.pairs})
|
||||
if t.kind == "title" and t.content and not product.name:
|
||||
product.name = t.content
|
||||
raw["title"] = t.content
|
||||
elif t.kind == "price":
|
||||
raw["price"] = t.content
|
||||
num = _parse_number(t.content)
|
||||
if num is not None and (product.price is None or product.price == 0):
|
||||
product.price = num
|
||||
elif t.kind == "params" and t.pairs:
|
||||
# 与已有参数按 key 并集合并(跨页追加时同一参数不重复)
|
||||
merged = {p["key"]: p["value"] for p in (raw.get("params") or [])}
|
||||
for p in t.pairs:
|
||||
merged.setdefault(p["key"], p["value"])
|
||||
raw["params"] = [{"key": k, "value": v} for k, v in merged.items()]
|
||||
elif t.kind == "selling_point":
|
||||
raw["sellingPoints"] = t.content
|
||||
elif t.kind == "desc":
|
||||
raw["desc"] = t.content
|
||||
if not product.description:
|
||||
product.description = t.content
|
||||
elif t.kind == "brand":
|
||||
raw["brand"] = t.content
|
||||
raw["texts"] = raw_texts
|
||||
product.raw = raw
|
||||
|
||||
|
||||
async def _get_or_create_product(db: AsyncSession, req: MaterialsRequest) -> Product:
|
||||
if req.product_id:
|
||||
product = await db.get(Product, UUID(req.product_id))
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
return product
|
||||
product = Product(
|
||||
stage=STAGE_COLLECTED,
|
||||
source_platform=req.source.platform,
|
||||
source_item_id=req.source.itemId,
|
||||
source_url=req.source.url,
|
||||
)
|
||||
db.add(product)
|
||||
await db.flush()
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/materials", response_model=MaterialsResponse)
|
||||
async def create_materials(
|
||||
req: MaterialsRequest,
|
||||
background: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> MaterialsResponse:
|
||||
product = await _get_or_create_product(db, req)
|
||||
_apply_texts(product, req.texts)
|
||||
|
||||
if not product.source_url:
|
||||
product.source_url = req.source.url
|
||||
if not product.source_platform:
|
||||
product.source_platform = req.source.platform
|
||||
|
||||
# 去重 + 建素材
|
||||
existing: set[str] = set()
|
||||
if req.images:
|
||||
rows = (await db.execute(
|
||||
select(ProductAsset.dedupe_key).where(
|
||||
ProductAsset.product_id == product.id,
|
||||
ProductAsset.dedupe_key.isnot(None),
|
||||
)
|
||||
)).scalars().all()
|
||||
existing = {k for k in rows if k}
|
||||
|
||||
queued, skipped = 0, 0
|
||||
for img in req.images:
|
||||
if img.dedupeKey and img.dedupeKey in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
db.add(ProductAsset(
|
||||
product_id=product.id,
|
||||
group_key=img.groupKey,
|
||||
variant_name=img.variantName,
|
||||
sort_order=img.index,
|
||||
type=img.type,
|
||||
source_url=img.url,
|
||||
status=STATUS_PENDING,
|
||||
dedupe_key=img.dedupeKey,
|
||||
))
|
||||
if img.dedupeKey:
|
||||
existing.add(img.dedupeKey)
|
||||
queued += 1
|
||||
|
||||
# 更新分组计数
|
||||
counts: dict = {}
|
||||
for a in await db.scalars(select(ProductAsset).where(ProductAsset.product_id == product.id)):
|
||||
counts[a.group_key] = counts.get(a.group_key, 0) + 1
|
||||
product.asset_counts = counts
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(product)
|
||||
|
||||
if queued:
|
||||
background.add_task(process_product_assets, str(product.id))
|
||||
return MaterialsResponse(product_id=str(product.id), assets_queued=queued, assets_skipped=skipped)
|
||||
|
||||
|
||||
async def process_product_assets(product_id: str) -> None:
|
||||
"""后台:下载 pending 素材 → 转存本地 media。失败逐张标记,不中断。"""
|
||||
async with get_session_factory()() as db:
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(
|
||||
ProductAsset.product_id == UUID(product_id),
|
||||
ProductAsset.status == STATUS_PENDING,
|
||||
ProductAsset.type == "img",
|
||||
)
|
||||
)).all()
|
||||
for a in assets:
|
||||
a.status = STATUS_DOWNLOADING
|
||||
await db.commit()
|
||||
try:
|
||||
a.stored_url = await storage.save_from_url(a.source_url, key_prefix="assets")
|
||||
a.status = STATUS_OK
|
||||
except Exception as exc: # noqa: BLE001
|
||||
a.status = STATUS_FAILED
|
||||
a.error = str(exc)[:500]
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/collected")
|
||||
async def is_collected(platform: str, itemId: str, db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(
|
||||
select(Product.id).where(
|
||||
Product.source_platform == platform,
|
||||
Product.source_item_id == itemId,
|
||||
)
|
||||
)).scalars().all()
|
||||
return {"collected": len(rows) > 0, "count": len(rows)}
|
||||
@@ -0,0 +1,120 @@
|
||||
"""导出采集图片:把采集到的源站图片打包成 ZIP 下载到本地。
|
||||
|
||||
参考图 URL 可能是源站 CDN(需 Referer 绕过防盗链)或本地上传的 media 文件。
|
||||
ZIP 内部结构沿用现有分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传),
|
||||
文件名沿用采集 key(main-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import mimetypes
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.proxy import guess_referer
|
||||
from services import storage
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["export"])
|
||||
|
||||
_EXT_BY_CTYPE = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/bmp": ".bmp",
|
||||
}
|
||||
|
||||
|
||||
class ExportImageItem(BaseModel):
|
||||
url: str
|
||||
groupName: str = "主图"
|
||||
variantName: str | None = None
|
||||
key: str = "" # 采集 key,如 main-001 / sku-002 / upload-001
|
||||
|
||||
|
||||
class ExportImagesRequest(BaseModel):
|
||||
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
|
||||
images: list[ExportImageItem]
|
||||
|
||||
|
||||
def _clean(name: str) -> str:
|
||||
"""清洗文件夹/文件名非法字符(与插件 cleanFilename 同规则,Windows 兼容)。"""
|
||||
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", (name or "").strip())
|
||||
s = re.sub(r"\s+", "_", s)
|
||||
return s.strip(" .")[:80]
|
||||
|
||||
|
||||
def _ext(url: str, ctype: str) -> str:
|
||||
"""由 content-type(优先)或 URL 后缀决定扩展名。"""
|
||||
ctype = ctype.split(";")[0].strip().lower()
|
||||
if ctype in _EXT_BY_CTYPE:
|
||||
return _EXT_BY_CTYPE[ctype]
|
||||
if ctype.startswith("image/"):
|
||||
return "." + ctype.split("/")[-1]
|
||||
path = url.split("?")[0].lower()
|
||||
for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"):
|
||||
if path.endswith(ext):
|
||||
return ".jpg" if ext == ".jpeg" else ext
|
||||
return ".jpg"
|
||||
|
||||
|
||||
def _is_image(url: str, ctype: str) -> bool:
|
||||
ctype = ctype.split(";")[0].strip().lower()
|
||||
if ctype.startswith("image/"):
|
||||
return True
|
||||
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
|
||||
|
||||
|
||||
async def _download(url: str) -> tuple[bytes, str]:
|
||||
"""本地 media 文件直读磁盘;远程 URL 带 Referer 下载。"""
|
||||
path = storage.local_path(url)
|
||||
if path is not None:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
return path.read_bytes(), mime
|
||||
return await storage.download_bytes(url, referer=guess_referer(url))
|
||||
|
||||
|
||||
@router.post("/export-images")
|
||||
async def export_images(req: ExportImagesRequest):
|
||||
if not req.images:
|
||||
raise HTTPException(status_code=400, detail="没有可导出的图片")
|
||||
|
||||
root = _clean(req.title) or "采集图片"
|
||||
buf = io.BytesIO()
|
||||
used: set[str] = set()
|
||||
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for img in req.images:
|
||||
try:
|
||||
data, ctype = await _download(img.url)
|
||||
except Exception: # noqa: BLE001
|
||||
continue # 单张失败不中断整包
|
||||
if not _is_image(img.url, ctype):
|
||||
continue
|
||||
|
||||
ext = _ext(img.url, ctype)
|
||||
base = _clean(img.key) or "image"
|
||||
if img.variantName:
|
||||
base += f"-{_clean(img.variantName)}"
|
||||
filename = f"{base}{ext}"
|
||||
if filename in used: # 同名加序号防覆盖
|
||||
stem = filename[: -len(ext)]
|
||||
n = 2
|
||||
while f"{stem}-{n}{ext}" in used:
|
||||
n += 1
|
||||
filename = f"{stem}-{n}{ext}"
|
||||
used.add(filename)
|
||||
|
||||
group = _clean(img.groupName) or "图片"
|
||||
zf.writestr(f"{root}/{group}/{filename}", data)
|
||||
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="collect.zip"'},
|
||||
)
|
||||
+31
-20
@@ -1,18 +1,18 @@
|
||||
"""无状态套图生成:请求自带采集数据,不落商品库。"""
|
||||
"""无状态套图生成:请求自带采集数据,任务存进程内注册表。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
|
||||
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, RIGHTAPI_MODELS,
|
||||
SuiteCreateResponse, TextMaterial, resolve_provider,
|
||||
PlanRequest, PlanResponse, PlanItemOut,
|
||||
)
|
||||
from services.generator import run_suite
|
||||
from services.planner import generate_plan
|
||||
from services.prompt import type_name
|
||||
from services.prompts import type_name
|
||||
from services.tasks import create_task
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["generate"])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -43,7 +47,6 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict:
|
||||
async def generate_suite(
|
||||
req: GenerateRequest,
|
||||
background: BackgroundTasks,
|
||||
db=Depends(get_db),
|
||||
) -> SuiteCreateResponse:
|
||||
if not req.images:
|
||||
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
|
||||
@@ -67,7 +70,6 @@ async def generate_suite(
|
||||
})
|
||||
if not jobs:
|
||||
raise HTTPException(status_code=400, detail="方案中所有项的数量都是 0")
|
||||
types = list(dict.fromkeys(j["kind"] for j in jobs))
|
||||
else:
|
||||
types = req.types or ["white_bg", "key_features", "lifestyle", "multi_scene"]
|
||||
bad = [t for t in types if t not in SUPPORTED_TYPES]
|
||||
@@ -80,16 +82,28 @@ async def generate_suite(
|
||||
spec = PLATFORM_SPECS[req.platform]
|
||||
|
||||
settings = get_settings()
|
||||
suite = Suite(
|
||||
product_id=None,
|
||||
style_set=req.style_set,
|
||||
# 插件只传模型名:已知模型直接路由到对应 provider(gpt-image-2 → rightapi)
|
||||
provider_name = resolve_provider(req.model, req.provider, 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})")
|
||||
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})")
|
||||
|
||||
task = create_task(
|
||||
status="pending",
|
||||
platform=req.platform,
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
types=types,
|
||||
plan=jobs,
|
||||
provider=req.provider or settings.image_provider,
|
||||
style_set=req.style_set,
|
||||
style_prompt=req.style_prompt,
|
||||
requirements=req.requirements,
|
||||
provider=provider_name,
|
||||
model=model,
|
||||
total=len(jobs),
|
||||
context=texts_to_raw(req.texts),
|
||||
plan=jobs,
|
||||
watermark=req.watermark.model_dump() if req.watermark else None,
|
||||
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
|
||||
ref_images=[
|
||||
{
|
||||
@@ -100,12 +114,9 @@ async def generate_suite(
|
||||
for i in sorted(req.images, key=lambda x: 0 if x.group_key == "main" else 1)
|
||||
],
|
||||
)
|
||||
db.add(suite)
|
||||
await db.commit()
|
||||
await db.refresh(suite)
|
||||
|
||||
background.add_task(run_suite, str(suite.id))
|
||||
return SuiteCreateResponse(suite_id=str(suite.id))
|
||||
background.add_task(run_suite, task)
|
||||
return SuiteCreateResponse(suite_id=task.id)
|
||||
|
||||
|
||||
@router.post("/plan", response_model=PlanResponse)
|
||||
@@ -115,7 +126,7 @@ async def plan_suite(req: PlanRequest) -> PlanResponse:
|
||||
if not product_info.get("title"):
|
||||
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
|
||||
try:
|
||||
result = await generate_plan(product_info, req.sku_variants, req.image_stats, req.platform)
|
||||
result = await generate_plan(product_info, req.sku_variants, req.image_stats, req.platform, requirements=req.requirements)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""商品查询 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db import get_db
|
||||
from models import Product, ProductAsset
|
||||
from schemas import AssetOut, ProductListOut, ProductOut
|
||||
from services import storage
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["products"])
|
||||
|
||||
|
||||
def _asset_out(a: ProductAsset) -> AssetOut:
|
||||
return AssetOut(
|
||||
id=str(a.id),
|
||||
group_key=a.group_key,
|
||||
variant_name=a.variant_name,
|
||||
type=a.type,
|
||||
source_url=a.source_url,
|
||||
url=a.stored_url,
|
||||
status=a.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/products", response_model=ProductListOut)
|
||||
async def list_products(
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
cond = []
|
||||
if q:
|
||||
cond.append(Product.name.contains(q))
|
||||
total = (await db.scalar(select(func.count()).select_from(Product).where(*cond))) or 0
|
||||
rows = (await db.scalars(
|
||||
select(Product).where(*cond).order_by(Product.created_at.desc())
|
||||
.offset((page - 1) * page_size).limit(page_size)
|
||||
)).all()
|
||||
return ProductListOut(total=total, items=[
|
||||
ProductOut(
|
||||
id=str(p.id), stage=p.stage, source_platform=p.source_platform,
|
||||
source_item_id=p.source_item_id, source_url=p.source_url,
|
||||
name=p.name, description=p.description, price=p.price,
|
||||
asset_counts=p.asset_counts,
|
||||
created_at=p.created_at.isoformat() if p.created_at else None,
|
||||
) for p in rows
|
||||
])
|
||||
|
||||
|
||||
@router.get("/products/{product_id}", response_model=ProductOut)
|
||||
async def get_product(product_id: str, db: AsyncSession = Depends(get_db)):
|
||||
p = await db.get(Product, UUID(product_id))
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(ProductAsset.product_id == p.id)
|
||||
.order_by(ProductAsset.sort_order)
|
||||
)).all()
|
||||
return ProductOut(
|
||||
id=str(p.id), stage=p.stage, source_platform=p.source_platform,
|
||||
source_item_id=p.source_item_id, source_url=p.source_url,
|
||||
name=p.name, description=p.description, price=p.price,
|
||||
asset_counts=p.asset_counts, assets=[_asset_out(a) for a in assets],
|
||||
created_at=p.created_at.isoformat() if p.created_at else None,
|
||||
)
|
||||
+29
-100
@@ -1,131 +1,60 @@
|
||||
"""套图生成 API:创建任务 / 查询状态 / 导出 ZIP。"""
|
||||
"""套图任务 API:轮询进度 / 导出 ZIP(进程内内存任务表)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
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 SuiteImageOut, SuiteOut
|
||||
from services import storage
|
||||
from services.generator import run_suite
|
||||
from services.tasks import IMG_OK, Task, get_task
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["suites"])
|
||||
|
||||
|
||||
async def _suite_out(db: AsyncSession, suite: Suite) -> SuiteOut:
|
||||
images = (await db.scalars(
|
||||
select(SuiteImage).where(SuiteImage.suite_id == suite.id)
|
||||
.order_by(SuiteImage.created_at)
|
||||
)).all()
|
||||
def _task_out(task: Task) -> SuiteOut:
|
||||
return SuiteOut(
|
||||
id=str(suite.id),
|
||||
product_id=str(suite.product_id),
|
||||
status=suite.status,
|
||||
style_set=suite.style_set,
|
||||
platform=suite.platform,
|
||||
lang=suite.lang,
|
||||
ratio=suite.ratio,
|
||||
types=list(suite.types or []),
|
||||
provider=suite.provider,
|
||||
id=task.id,
|
||||
status=task.status,
|
||||
style_set=task.style_set,
|
||||
platform=task.platform,
|
||||
lang=task.lang,
|
||||
ratio=task.ratio,
|
||||
provider=task.provider,
|
||||
model=task.model,
|
||||
total=task.total,
|
||||
images=[
|
||||
SuiteImageOut(
|
||||
type_id=i.type_id, name=i.name, url=i.stored_url or "",
|
||||
type_id=i.type_id, name=i.name, url=i.url,
|
||||
status=i.status, error=i.error,
|
||||
) for i in images
|
||||
) for i in task.images
|
||||
],
|
||||
error=suite.error,
|
||||
error=task.error,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/suites", response_model=SuiteCreateResponse)
|
||||
async def create_suite(
|
||||
product_id: str,
|
||||
req: SuiteCreateRequest,
|
||||
background: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
product = await db.get(Product, UUID(product_id))
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
|
||||
# 主图组至少一张图(不要求转存完成:生图可直接用源站 URL 代理解析)
|
||||
ok_assets = (await db.scalars(
|
||||
select(ProductAsset.id).where(
|
||||
ProductAsset.product_id == product.id,
|
||||
ProductAsset.group_key == "main",
|
||||
ProductAsset.type == "img",
|
||||
)
|
||||
)).all()
|
||||
if not ok_assets:
|
||||
raise HTTPException(status_code=400, detail="商品没有主图,无法生成")
|
||||
|
||||
bad = [t for t in req.types if t not in SUPPORTED_TYPES]
|
||||
if bad:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的图类型: {bad}")
|
||||
if req.platform not in PLATFORM_SPECS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的目标平台: {req.platform}(ozon | wb | cn)")
|
||||
spec = PLATFORM_SPECS[req.platform]
|
||||
|
||||
settings = get_settings()
|
||||
suite = Suite(
|
||||
product_id=product.id,
|
||||
style_set=req.style_set,
|
||||
platform=req.platform,
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
types=req.types,
|
||||
provider=req.provider or settings.image_provider,
|
||||
)
|
||||
db.add(suite)
|
||||
await db.commit()
|
||||
await db.refresh(suite)
|
||||
|
||||
background.add_task(run_suite, str(suite.id))
|
||||
return SuiteCreateResponse(suite_id=str(suite.id))
|
||||
|
||||
|
||||
@router.get("/suites/{suite_id}", response_model=SuiteOut)
|
||||
async def get_suite(suite_id: str, db: AsyncSession = Depends(get_db)):
|
||||
suite = await db.get(Suite, UUID(suite_id))
|
||||
if suite is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return await _suite_out(db, suite)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/suites")
|
||||
async def list_suites(product_id: str, db: AsyncSession = Depends(get_db)):
|
||||
suites = (await db.scalars(
|
||||
select(Suite).where(Suite.product_id == UUID(product_id))
|
||||
.order_by(Suite.created_at.desc())
|
||||
)).all()
|
||||
return [await _suite_out(db, s) for s in suites]
|
||||
async def get_suite(suite_id: str):
|
||||
task = get_task(suite_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启),请重新生成")
|
||||
return _task_out(task)
|
||||
|
||||
|
||||
@router.get("/suites/{suite_id}/zip")
|
||||
async def download_suite_zip(suite_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def download_suite_zip(suite_id: str):
|
||||
"""把任务内所有成功图打包成 ZIP(中文文件名)。"""
|
||||
suite = await db.get(Suite, UUID(suite_id))
|
||||
if suite is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
images = (await db.scalars(
|
||||
select(SuiteImage).where(
|
||||
SuiteImage.suite_id == suite.id, SuiteImage.status == STATUS_OK,
|
||||
).order_by(SuiteImage.created_at)
|
||||
)).all()
|
||||
task = get_task(suite_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启)")
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
seen: set[str] = set()
|
||||
for i, img in enumerate(images):
|
||||
path = storage.local_path(img.stored_url or "")
|
||||
for i, img in enumerate([i for i in task.images if i.status == IMG_OK]):
|
||||
path = storage.local_path(img.url or "")
|
||||
if path is None:
|
||||
continue
|
||||
filename = img.name or img.type_id
|
||||
@@ -137,5 +66,5 @@ async def download_suite_zip(suite_id: str, db: AsyncSession = Depends(get_db)):
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="suite-{suite_id}.zip"'},
|
||||
headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.zip\""},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""手动上传图片:插件用户在采集区手动补充参考图,转存本地 media 供预览与生图。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
|
||||
from services import storage
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["upload"])
|
||||
|
||||
# content-type → 落盘扩展名
|
||||
_ALLOWED_TYPES = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
}
|
||||
|
||||
MAX_BYTES = 20 * 1024 * 1024 # 20MB
|
||||
|
||||
|
||||
@router.post("/upload-image")
|
||||
async def upload_image(file: UploadFile = File(...)):
|
||||
data = await file.read()
|
||||
ctype = (file.content_type or "").split(";")[0].strip().lower()
|
||||
if ctype not in _ALLOWED_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的图片类型: {file.content_type}")
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="空文件")
|
||||
if len(data) > MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="图片超过 20MB")
|
||||
key = storage.write_bytes(data, key_prefix="uploads", ext=_ALLOWED_TYPES[ctype])
|
||||
return {"url": storage.public_url(key), "key": key}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -9,6 +9,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# 仓库根(server/ 的上一级)
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# 水印徽章图(复刻 ozonSeller 图表处理的默认水印;请求可选图片/文字水印)
|
||||
WATERMARK_IMAGE_PATH = Path(__file__).resolve().parent / "assets" / "watermark.jpg"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
@@ -26,6 +29,9 @@ class Settings(BaseSettings):
|
||||
# ── 存储 ──
|
||||
data_dir: str = str(ROOT / "data")
|
||||
|
||||
# 水印图片路径(图片水印的徽章源图,可用 .env 覆盖)
|
||||
watermark_image_path: str = str(WATERMARK_IMAGE_PATH)
|
||||
|
||||
# ── 图像生成 provider:doubao(火山方舟 Seedream)| tongyi(阿里 DashScope)──
|
||||
image_provider: str = "doubao"
|
||||
request_timeout: int = 300 # 单张生图请求超时(秒)
|
||||
@@ -41,6 +47,13 @@ class Settings(BaseSettings):
|
||||
dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点
|
||||
dashscope_model: str = "wan2.7-image-pro"
|
||||
|
||||
# RightAPI(OpenAI 兼容中转,gpt-image / nano-banana 系列)
|
||||
rightapi_api_key: str = ""
|
||||
rightapi_base_url: str = "https://rightapi.ai/draw"
|
||||
rightapi_image_model: str = "gpt-image-2"
|
||||
rightapi_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
|
||||
rightapi_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
|
||||
|
||||
# DeepSeek(出图方案规划器)
|
||||
deepseek_api_key: str = ""
|
||||
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""数据库:SQLite(aiosqlite)+ SQLAlchemy async。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
_engine = None
|
||||
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine, _session_factory
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
db_path = f"{settings.data_dir}/app.db"
|
||||
_engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", echo=False)
|
||||
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
get_engine()
|
||||
assert _session_factory is not None
|
||||
return _session_factory
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with get_session_factory()() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""启动时建表(MVP 不引 Alembic,模型变更删库重建即可)。"""
|
||||
import models # noqa: F401 确保模型注册
|
||||
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
+7
-15
@@ -2,27 +2,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api import collection, generate, products, proxy, suites
|
||||
from api import export, generate, proxy, suites, upload
|
||||
from config import get_settings
|
||||
from db import init_db
|
||||
from services.storage import media_root
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="电商套图工作台", version="0.1.0", lifespan=lifespan)
|
||||
app = FastAPI(title="电商套图工作台", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -31,13 +22,13 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(collection.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(suites.router)
|
||||
app.include_router(generate.router)
|
||||
app.include_router(suites.router)
|
||||
app.include_router(proxy.router)
|
||||
app.include_router(upload.router)
|
||||
app.include_router(export.router)
|
||||
|
||||
# 静态托管生成的图片/转存素材
|
||||
# 静态托管生成的图片
|
||||
app.mount("/media", StaticFiles(directory=str(media_root())), name="media")
|
||||
|
||||
|
||||
@@ -49,6 +40,7 @@ async def health():
|
||||
"provider": settings.image_provider,
|
||||
"ark_configured": bool(settings.ark_api_key),
|
||||
"dashscope_configured": bool(settings.dashscope_api_key),
|
||||
"rightapi_configured": bool(settings.rightapi_api_key),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""数据模型:Product(商品)/ ProductAsset(采集素材)/ Suite(套图任务)/ SuiteImage(生成图)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, JSON, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from db import Base
|
||||
|
||||
# 产品阶段
|
||||
STAGE_COLLECTED = "collected"
|
||||
STAGE_GENERATED = "generated"
|
||||
|
||||
# 素材/生成图状态
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_DOWNLOADING = "downloading"
|
||||
STATUS_OK = "ok"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
# 套图任务状态
|
||||
SUITE_PENDING = "pending"
|
||||
SUITE_RUNNING = "running"
|
||||
SUITE_DONE = "done"
|
||||
SUITE_PARTIAL = "partial"
|
||||
SUITE_FAILED = "failed"
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
stage: Mapped[str] = mapped_column(String(16), default=STAGE_COLLECTED, index=True)
|
||||
|
||||
# 采集溯源
|
||||
source_platform: Mapped[str | None] = mapped_column(String(16), nullable=True) # ozon | 1688 | taobao
|
||||
source_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(Text, default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# 采集原文:{title, price, brand, params: [...], sellingPoints, desc, texts: [...]}
|
||||
raw: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
asset_counts: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True
|
||||
)
|
||||
|
||||
|
||||
class ProductAsset(Base):
|
||||
"""采集素材(源站图片,转存到本地 media)。"""
|
||||
|
||||
__tablename__ = "product_assets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
group_key: Mapped[str] = mapped_column(String(16), default="main") # main/sku/detail/video
|
||||
variant_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
type: Mapped[str] = mapped_column(String(8), default="img") # img / video
|
||||
source_url: Mapped[str] = mapped_column(Text, default="")
|
||||
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 本地 media key 或公网 URL
|
||||
status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING, index=True)
|
||||
dedupe_key: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Suite(Base):
|
||||
"""一次套图生成任务(无状态:直接携带采集数据,不依赖商品库)。"""
|
||||
|
||||
__tablename__ = "suites"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
# 兼容旧的商品挂载路径;工具化流程为空
|
||||
product_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default=SUITE_PENDING, index=True)
|
||||
style_set: Mapped[int] = mapped_column(Integer, default=1) # 风格模板 1-5
|
||||
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")
|
||||
# 工具化流程:请求自带的数据(生图上下文 + 参考图 URL 列表)
|
||||
context: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
ref_images: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class SuiteImage(Base):
|
||||
"""任务里单张生成图。"""
|
||||
|
||||
__tablename__ = "suite_images"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
suite_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("suites.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
type_id: Mapped[str] = mapped_column(String(32)) # white_bg / key_features / ...
|
||||
name: Mapped[str] = mapped_column(String(64), default="") # 中文名(文件名)
|
||||
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -1,8 +1,7 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
sqlalchemy[asyncio]>=2.0
|
||||
aiosqlite>=0.20
|
||||
pydantic>=2.6
|
||||
pydantic-settings>=2.2
|
||||
httpx>=0.27
|
||||
python-multipart>=0.0.9
|
||||
pillow>=10.0
|
||||
|
||||
+45
-69
@@ -1,6 +1,8 @@
|
||||
"""Pydantic 契约(插件 ↔ 服务端)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SUPPORTED_TYPES = [
|
||||
@@ -9,46 +11,41 @@ SUPPORTED_TYPES = [
|
||||
"size_chart", "sku_collection", "custom",
|
||||
]
|
||||
|
||||
# 通义(DashScope)生图模型白名单:插件下拉可选的模型
|
||||
TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro", "wan2.6-image", "wan2.6-t2i"]
|
||||
|
||||
# ── 采集上传 ──
|
||||
# RightAPI 生图模型白名单(gpt-image 系列 + Google nano-banana 系列,同一中转)
|
||||
RIGHTAPI_MODELS = [
|
||||
"gpt-image-2",
|
||||
"gpt-image-2-vip",
|
||||
"nano-banana",
|
||||
"nano-banana-2",
|
||||
"nano-banana-2-lite",
|
||||
"nano-banana-pro",
|
||||
]
|
||||
|
||||
class SourceInfo(BaseModel):
|
||||
platform: str = Field(..., description="ozon | 1688 | taobao")
|
||||
itemId: str | None = None
|
||||
url: str = ""
|
||||
collectedAt: int | None = None # epoch 毫秒
|
||||
# 模型 → provider 推断表:插件只传模型名,服务端据此路由(模型名优先于 provider 字段)
|
||||
MODEL_PROVIDERS: dict[str, str] = {
|
||||
**{m: "tongyi" for m in TONGYI_MODELS},
|
||||
**{m: "rightapi" for m in RIGHTAPI_MODELS},
|
||||
}
|
||||
|
||||
|
||||
def resolve_provider(model: str | None, requested: str | None, default: str) -> str:
|
||||
"""已知模型名直接定位 provider;未知模型回退到请求指定的 provider 或默认值。"""
|
||||
if model and model in MODEL_PROVIDERS:
|
||||
return MODEL_PROVIDERS[model]
|
||||
return requested or default
|
||||
|
||||
|
||||
# ── 文本素材(规划 / 生成共用)──
|
||||
|
||||
class TextMaterial(BaseModel):
|
||||
kind: str = Field(..., description="title | params | selling_point | desc | price | brand")
|
||||
content: str = ""
|
||||
pairs: list[dict] | None = None # [{key, value}]
|
||||
|
||||
|
||||
class ImageMaterial(BaseModel):
|
||||
groupKey: str = Field(..., description="main | sku | detail | video")
|
||||
groupName: str = ""
|
||||
variantName: str | None = None
|
||||
url: str = Field(..., description="源站原图 URL")
|
||||
index: int = 0
|
||||
type: str = "img"
|
||||
dedupeKey: str | None = None
|
||||
|
||||
|
||||
class MaterialsRequest(BaseModel):
|
||||
product_id: str | None = Field(default=None, description="传了=追加到已有商品")
|
||||
source: SourceInfo
|
||||
texts: list[TextMaterial] = Field(default_factory=list)
|
||||
images: list[ImageMaterial] = Field(default_factory=list)
|
||||
refererOrigin: str | None = None
|
||||
|
||||
|
||||
class MaterialsResponse(BaseModel):
|
||||
product_id: str
|
||||
assets_queued: int
|
||||
assets_skipped: int = 0
|
||||
|
||||
|
||||
# ── 套图生成 ──
|
||||
|
||||
# 目标平台 → 文案语言 + 图片比例(平台决定规格,不再单独选语言)
|
||||
@@ -59,13 +56,6 @@ PLATFORM_SPECS: dict[str, dict] = {
|
||||
}
|
||||
|
||||
|
||||
class SuiteCreateRequest(BaseModel):
|
||||
style_set: int = Field(default=1, ge=1, le=5, description="风格模板 1-5")
|
||||
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="覆盖默认 provider(doubao | tongyi)")
|
||||
|
||||
|
||||
# ── 无状态套图生成(工具流程:请求自带采集数据)──
|
||||
|
||||
class GenerateImageItem(BaseModel):
|
||||
@@ -74,6 +64,17 @@ class GenerateImageItem(BaseModel):
|
||||
variant_name: str | None = Field(default=None, description="SKU 规格名(方案绑定用)")
|
||||
|
||||
|
||||
class WatermarkOptions(BaseModel):
|
||||
"""生成图水印:AI 出图后由服务端后处理合成(与生图模型无关)。
|
||||
|
||||
默认样式复刻 ozonSeller 图表处理:图片徽章 / 文字描边,右下角。
|
||||
"""
|
||||
enabled: bool = Field(default=False, description="是否开启水印")
|
||||
type: Literal["image", "text"] = Field(default="image", description="图片水印 | 文字水印")
|
||||
text: str = Field(default="xiongmaoyx", description="文字水印内容")
|
||||
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)")
|
||||
|
||||
|
||||
class PlanItem(BaseModel):
|
||||
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
|
||||
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
|
||||
@@ -88,10 +89,14 @@ 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_prompt: str | None = Field(default=None, description="用户改写的风格提示词(覆盖 style_set 模板)")
|
||||
requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束,覆盖其他设定)")
|
||||
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="覆盖默认 provider(doubao | tongyi)")
|
||||
model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro)")
|
||||
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成)")
|
||||
|
||||
|
||||
# ── 出图方案规划(DeepSeek)──
|
||||
@@ -101,6 +106,7 @@ class PlanRequest(BaseModel):
|
||||
sku_variants: list[str] = Field(default_factory=list, description="带图的 SKU 规格名")
|
||||
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计")
|
||||
platform: str = Field(default="cn")
|
||||
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
|
||||
|
||||
|
||||
class PlanItemOut(BaseModel):
|
||||
@@ -131,44 +137,14 @@ class SuiteImageOut(BaseModel):
|
||||
|
||||
class SuiteOut(BaseModel):
|
||||
id: str
|
||||
product_id: str
|
||||
status: str
|
||||
style_set: int
|
||||
platform: str
|
||||
lang: str
|
||||
ratio: str
|
||||
types: list[str]
|
||||
provider: str
|
||||
model: str | None = None
|
||||
total: int = 0 # 计划生成总张数(进度分母;images 是逐张追加,过程中 length < total)
|
||||
images: list[SuiteImageOut]
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ── 商品 ──
|
||||
|
||||
class AssetOut(BaseModel):
|
||||
id: str
|
||||
group_key: str
|
||||
variant_name: str | None = None
|
||||
type: str
|
||||
source_url: str
|
||||
url: str | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class ProductOut(BaseModel):
|
||||
id: str
|
||||
stage: str
|
||||
source_platform: str | None = None
|
||||
source_item_id: str | None = None
|
||||
source_url: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
price: float | None = None
|
||||
asset_counts: dict | None = None
|
||||
assets: list[AssetOut] = Field(default_factory=list)
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class ProductListOut(BaseModel):
|
||||
total: int
|
||||
items: list[ProductOut]
|
||||
|
||||
+251
-102
@@ -10,19 +10,47 @@ import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
from uuid import UUID
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import get_settings
|
||||
from db import get_session_factory
|
||||
from models import Product, ProductAsset, Suite, SuiteImage, SUITE_RUNNING, SUITE_DONE, SUITE_PARTIAL, SUITE_FAILED, STATUS_OK, STATUS_FAILED
|
||||
from services import storage
|
||||
from services.prompt import build_prompt, build_context, type_name
|
||||
from services.prompts import build_prompt, build_context, type_name
|
||||
from services.tasks import Task, TaskImage, TASK_FAILED, TASK_RUNNING, TASK_DONE, TASK_PARTIAL, IMG_FAILED, IMG_OK
|
||||
from services.watermark import apply_watermark
|
||||
|
||||
log = logging.getLogger("suite.generator")
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
"""带 HTTP 状态码的 API 错误(用于区分可重试的网关/限流错误)。"""
|
||||
|
||||
def __init__(self, message: str, status: int = 0):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
_HTML_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _raise_api_error(resp, provider: str):
|
||||
"""HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。"""
|
||||
if resp.is_success:
|
||||
return
|
||||
text = resp.text or ""
|
||||
if "<html" in text[:300].lower() or text.lstrip()[:15].lower().startswith("<!doctype"):
|
||||
# Cloudflare/网关错误页:取 <title> 作摘要,避免整段 HTML 进错误信息
|
||||
m = _HTML_TITLE_RE.search(text)
|
||||
detail = (re.sub(r"\s+", " ", m.group(1)).strip() if m else "") or "网关返回 HTML 错误页(上游/CDN 故障)"
|
||||
raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail}", resp.status_code)
|
||||
try:
|
||||
body = resp.json()
|
||||
detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ')
|
||||
except Exception: # noqa: BLE001
|
||||
detail = text[:200]
|
||||
raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail or '无错误详情'}", resp.status_code)
|
||||
|
||||
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
|
||||
TYPE_REF_INDEX = {
|
||||
"material": 1,
|
||||
@@ -30,11 +58,17 @@ TYPE_REF_INDEX = {
|
||||
DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 + 背面/细节 1 张)
|
||||
|
||||
|
||||
def _image_size(provider: str, ratio: str, is_wan: bool = True) -> str:
|
||||
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
|
||||
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon/WB),1:1 方图(国内)。"""
|
||||
if provider == "doubao":
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
if provider == "rightapi":
|
||||
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
|
||||
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
|
||||
if model.startswith("wan2.6"):
|
||||
return "1152*1536" if ratio == "3:4" else "1440*1440"
|
||||
if ratio == "3:4":
|
||||
return "1536*2048" if is_wan else "768*1024"
|
||||
return "2048*2048" if is_wan else "1024*1024"
|
||||
@@ -58,35 +92,42 @@ def _bytes_to_data_uri(data: bytes, mime: str) -> str:
|
||||
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
|
||||
|
||||
|
||||
async def _resolve_ref(url: str) -> str:
|
||||
"""参考图 URL → data URI。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。
|
||||
async def _resolve_ref_bytes(url: str) -> tuple[bytes, str]:
|
||||
"""参考图 URL → (bytes, mime)。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。
|
||||
|
||||
生图 API 的服务器无法访问 127.0.0.1,代理 URL 也不能直接透传,
|
||||
所以统一在本地解析成 base64 data URI 再进请求体。
|
||||
所以统一在本地解析成原始字节再进请求体(data URI 或 multipart)。
|
||||
"""
|
||||
if url.startswith("data:"):
|
||||
return url
|
||||
head, _, b64 = url.partition(",")
|
||||
mime = head[5:].split(";", 1)[0] or "image/jpeg"
|
||||
return base64.b64decode(b64), mime
|
||||
path = storage.local_path(url)
|
||||
if path is not None:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
return _bytes_to_data_uri(path.read_bytes(), mime)
|
||||
return path.read_bytes(), mime
|
||||
if url.startswith(("http://", "https://")):
|
||||
from api.proxy import guess_referer
|
||||
data, ctype = await storage.download_bytes(url, referer=guess_referer(url))
|
||||
if not ctype.startswith("image/"):
|
||||
ctype = "image/jpeg"
|
||||
return _bytes_to_data_uri(data, ctype)
|
||||
return data, ctype
|
||||
raise FileNotFoundError(f"无法解析参考图: {url}")
|
||||
|
||||
|
||||
async def _resolve_ref(url: str) -> str:
|
||||
data, mime = await _resolve_ref_bytes(url)
|
||||
return _bytes_to_data_uri(data, mime)
|
||||
|
||||
|
||||
# ── 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 +142,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()
|
||||
@@ -114,6 +155,11 @@ def _is_wan_model(model: str) -> bool:
|
||||
return model.lower().startswith("wan")
|
||||
|
||||
|
||||
def _is_t2i_model(model: str) -> bool:
|
||||
"""纯文生图模型(如 wan2.6-t2i):不接受参考图,商品一致性只能靠文案描述。"""
|
||||
return "t2i" in model.lower()
|
||||
|
||||
|
||||
async def _tongyi_poll_task(client: httpx.AsyncClient, key: str, task_id: str, max_wait: int) -> str:
|
||||
poll_url = "https://dashscope.aliyuncs.com/api/v1/tasks/" + task_id
|
||||
elapsed, interval = 0, 3
|
||||
@@ -140,18 +186,22 @@ 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
|
||||
else "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
)
|
||||
|
||||
content: list[dict] = [{"image": await _resolve_ref(u)} for u in ref_images]
|
||||
# t2i 模型不接受参考图:content 只有文本,商品一致性依赖 prompt 里的标题/卖点描述
|
||||
content: list[dict] = []
|
||||
if not _is_t2i_model(model):
|
||||
content = [{"image": await _resolve_ref(u)} for u in ref_images]
|
||||
content.append({"text": prompt})
|
||||
|
||||
params = {"size": size, "n": 1, "watermark": False}
|
||||
@@ -163,11 +213,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", "")
|
||||
@@ -185,7 +235,136 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
|
||||
return dl.content
|
||||
|
||||
|
||||
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi}
|
||||
# ── Provider:RightAPI(gpt-image / nano-banana,OpenAI 兼容中转)──────────
|
||||
|
||||
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429)
|
||||
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
async def _rightapi_poll_task(client: httpx.AsyncClient, headers: dict, origin: str,
|
||||
task_id: str, max_wait: int) -> dict:
|
||||
"""轮询站点级任务查询接口 GET /v1/tasks/{task_id}(不带 /draw 前缀)。
|
||||
|
||||
实测要点(docs/rightapi-调用排查与修复方案.md §2.2):
|
||||
- 完成响应**没有** status:"completed" 字段,完成判定 = 响应里出现 data;
|
||||
- progress 基本不动(0~2),不能当进度条依据;
|
||||
- 失败态 = status 为 failed / error / cancelled。
|
||||
"""
|
||||
poll_url = f"{origin}/v1/tasks/{task_id}"
|
||||
elapsed, interval = 0, 3
|
||||
while elapsed < max_wait:
|
||||
resp = await client.get(poll_url, headers=headers, timeout=30)
|
||||
_raise_api_error(resp, "RightAPI")
|
||||
result = resp.json()
|
||||
status = result.get("status", "")
|
||||
if status in ("failed", "error", "cancelled"):
|
||||
err = result.get("error") or {}
|
||||
raise RuntimeError(f"RightAPI 任务失败: {err.get('message') or result}")
|
||||
if "data" in result:
|
||||
return result
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
interval = min(interval + 2, 10)
|
||||
raise TimeoutError(f"RightAPI 异步任务超时 ({max_wait}s): task_id={task_id}")
|
||||
|
||||
|
||||
def _rightapi_extract_image(result: dict) -> tuple[str | None, str | None]:
|
||||
"""从轮询完成结果里取 (kind, payload):kind ∈ url | b64,未取到返回 (None, None)。
|
||||
|
||||
完成形状为 Images 协议:{"created":..., "data":[{"url": "..."}]}(实测只见 url)。
|
||||
"""
|
||||
data = result.get("data") or []
|
||||
if data:
|
||||
item = data[0] or {}
|
||||
url = item.get("url") or ""
|
||||
if url:
|
||||
return ("url", url)
|
||||
b64 = item.get("b64_json") or ""
|
||||
if b64:
|
||||
return ("b64", b64)
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
|
||||
"""RightAPI 各模型:统一走 /v1/images/generations(异步)。
|
||||
|
||||
官方协议(docs.rightapi.ai/docs/rc_draw/,2026-07 起统一异步):
|
||||
- POST /draw/v1/images/generations,请求体固定带 async:true,参考图放 image 数组(data-URI);
|
||||
- 返回 task_id 后轮询 GET /v1/tasks/{task_id}(站点级,不带 /draw);
|
||||
- 参数只有 model/prompt/n/size/imageSize/image/async;不传 quality/output_format/input_fidelity。
|
||||
参考图沿用现选图逻辑(≤2 张,image 数组)。单张 1-5 分钟,轮询上限 poll_max_wait 兜底。
|
||||
"""
|
||||
base = s.rightapi_base_url.rstrip("/")
|
||||
# 任务查询是站点级接口,不带 /draw:从 base 里拆出 origin(https://rightapi.ai/draw → https://rightapi.ai)
|
||||
origin = base.split("/draw", 1)[0].rstrip("/") or base
|
||||
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"async": True,
|
||||
}
|
||||
if ref_images:
|
||||
body["image"] = [await _resolve_ref(u) for u in ref_images]
|
||||
|
||||
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/generations",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
_raise_api_error(resp, "RightAPI")
|
||||
submitted = resp.json()
|
||||
task_id = submitted.get("task_id") or ""
|
||||
if task_id:
|
||||
result = await _rightapi_poll_task(client, headers, origin, task_id, s.poll_max_wait)
|
||||
else:
|
||||
# 极端兜底:个别中转可能同步返回 data(文档不保证,但防御处理)
|
||||
result = submitted
|
||||
|
||||
kind, payload = _rightapi_extract_image(result)
|
||||
if kind == "b64" and payload:
|
||||
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
|
||||
if kind == "url" and payload:
|
||||
dl = await client.get(payload, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
raise RuntimeError(f"RightAPI 任务完成但没有图片数据: {result}")
|
||||
|
||||
|
||||
async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
|
||||
"""带重试的 RightAPI 入口:429/5xx/超时按递增间隔重试。
|
||||
|
||||
实测该中转对同 key 连续请求有分钟级冷却(成功一张后紧接着的请求会被网关秒拒 502),
|
||||
60s → 120s → 240s 的退避基本能等到窗口放开。
|
||||
"""
|
||||
s = get_settings()
|
||||
if not s.rightapi_api_key:
|
||||
raise RuntimeError("未配置 RIGHTAPI_API_KEY(.env)")
|
||||
model = model or s.rightapi_image_model
|
||||
attempts = max(1, s.rightapi_max_retries)
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return await _rightapi_request(s, prompt, ref_images, size, model)
|
||||
except ApiError as exc:
|
||||
last_exc = exc
|
||||
if exc.status not in RETRYABLE_STATUS:
|
||||
raise # 参数错误等不可重试,立即失败
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
last_exc = exc # 网络抖动/超时可重试
|
||||
if i == attempts - 1:
|
||||
break
|
||||
wait = s.rightapi_retry_wait * (2 ** i)
|
||||
log.warning("RightAPI 第 %d/%d 次请求失败(%s),%ds 后重试", i + 1, attempts, last_exc, wait)
|
||||
await asyncio.sleep(wait)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
|
||||
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi, "rightapi": generate_rightapi}
|
||||
|
||||
|
||||
# ── 任务执行器 ────────────────────────────────────────────────────────────
|
||||
@@ -217,100 +396,70 @@ def _refs_for_job(images: list[dict], job: dict) -> list[str]:
|
||||
return _order_refs(pool, job.get("kind", ""))
|
||||
|
||||
|
||||
async def _select_ref_images(db, product_id: UUID, type_id: str) -> list[str]:
|
||||
"""商品路径:主图组前几张。转存完成的用本地文件,未完成的直接用源站 URL。"""
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(
|
||||
ProductAsset.product_id == product_id,
|
||||
ProductAsset.group_key == "main",
|
||||
ProductAsset.type == "img",
|
||||
).order_by(ProductAsset.sort_order)
|
||||
)).all()
|
||||
refs = [a.stored_url or a.source_url for a in assets if (a.stored_url or a.source_url)]
|
||||
if not refs:
|
||||
raise RuntimeError("商品没有可用参考图(未采集主图)")
|
||||
return _order_refs(refs, type_id)
|
||||
# 串行生成队列:所有用户共享同一批 API key,并发生成会触发中转限流
|
||||
# (rightapi 同 key 分钟级冷却);同一时间只跑一个任务,其余保持 pending 排队。
|
||||
_GEN_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
async def run_suite(suite_id: str) -> None:
|
||||
"""后台执行套图任务:逐张生成 → 落盘 → 记录;单张失败不中断。
|
||||
|
||||
两条路径:
|
||||
- 无状态(product_id 为空):上下文与参考图来自请求自带的 context / ref_images
|
||||
- 商品路径(兼容旧流程):从 product + product_assets 取
|
||||
"""
|
||||
async def run_suite(task: Task) -> None:
|
||||
"""后台执行套图任务:排队 → 逐张生成 → 落盘 → 更新内存状态;单张失败不中断。"""
|
||||
settings = get_settings()
|
||||
async with get_session_factory()() as db:
|
||||
suite = await db.get(Suite, UUID(suite_id))
|
||||
if suite is None:
|
||||
return
|
||||
|
||||
product = None
|
||||
if suite.product_id:
|
||||
product = await db.get(Product, suite.product_id)
|
||||
if product is None:
|
||||
suite.status = SUITE_FAILED
|
||||
suite.error = "商品不存在"
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
suite.status = SUITE_RUNNING
|
||||
await db.commit()
|
||||
|
||||
provider_name = suite.provider or settings.image_provider
|
||||
provider_name = task.provider or settings.image_provider
|
||||
generator = GENERATORS.get(provider_name)
|
||||
if generator is None:
|
||||
suite.status = SUITE_FAILED
|
||||
suite.error = f"未知 provider: {provider_name}"
|
||||
await db.commit()
|
||||
task.status = TASK_FAILED
|
||||
task.error = f"未知 provider: {provider_name}"
|
||||
return
|
||||
|
||||
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))
|
||||
|
||||
# 任务列表:方案(逐张)优先,旧路径按 types
|
||||
if suite.plan:
|
||||
jobs = [dict(j) for j in suite.plan]
|
||||
else:
|
||||
jobs = [
|
||||
{"kind": t, "title": type_name(t), "detail": "", "prompt_hint": "", "variant_name": None}
|
||||
for t in (suite.types or [])
|
||||
]
|
||||
ctx = build_context(task.context or {}, fallback_name="")
|
||||
model = task.model or {
|
||||
"tongyi": settings.dashscope_model,
|
||||
"rightapi": settings.rightapi_image_model,
|
||||
}.get(provider_name, settings.ark_image_model)
|
||||
is_wan = provider_name == "tongyi" and _is_wan_model(model)
|
||||
size = _image_size(provider_name, task.ratio, is_wan=is_wan, model=model)
|
||||
jobs = [dict(j) for j in task.plan]
|
||||
|
||||
async with _GEN_LOCK:
|
||||
task.status = TASK_RUNNING
|
||||
ok, failed = 0, 0
|
||||
failures: list[str] = []
|
||||
for job in jobs:
|
||||
type_id = job["kind"]
|
||||
image_row = SuiteImage(
|
||||
suite_id=suite.id,
|
||||
type_id=type_id,
|
||||
name=job.get("title") or type_name(type_id),
|
||||
status=STATUS_FAILED,
|
||||
)
|
||||
db.add(image_row)
|
||||
await db.flush()
|
||||
image = TaskImage(type_id=type_id, name=job.get("title") or type_name(type_id))
|
||||
task.images.append(image)
|
||||
try:
|
||||
prompt = build_prompt(type_id, ctx, suite.style_set, suite.lang, extra=job)
|
||||
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)
|
||||
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
|
||||
# 提示词按模型家族分发:国产主体参考 / gpt edits 保真 / google 主体保持
|
||||
prompt = build_prompt(
|
||||
provider_name, model, type_id, ctx, task.style_set, task.lang,
|
||||
extra=job, style_prompt=task.style_prompt, requirements=task.requirements,
|
||||
)
|
||||
refs = _refs_for_job(list(task.ref_images or []), job)
|
||||
data = await generator(prompt, refs, size=size, model=model)
|
||||
# 水印:AI 出图返回后、落盘前的后处理(失败不阻断,内部返回原图)
|
||||
wm = task.watermark or {}
|
||||
if wm.get("enabled"):
|
||||
data = apply_watermark(data, wm)
|
||||
# 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名
|
||||
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
|
||||
key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext)
|
||||
image.url = storage.public_url(key)
|
||||
image.status = IMG_OK
|
||||
ok += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("套图 %s 类型 %s 生成失败", suite_id, type_id)
|
||||
image_row.error = str(exc)[:500]
|
||||
log.exception("套图 %s 类型 %s 生成失败", task.id, type_id)
|
||||
image.status = IMG_FAILED # 默认 pending,失败显式置 failed
|
||||
image.error = str(exc)[:500]
|
||||
failures.append(f"{job.get('title') or type_name(type_id)}:{str(exc)[:200]}")
|
||||
failed += 1
|
||||
await db.commit()
|
||||
|
||||
suite.status = SUITE_DONE if failed == 0 else (SUITE_PARTIAL if ok > 0 else SUITE_FAILED)
|
||||
if failed and not ok:
|
||||
suite.error = "全部生成失败,请检查 API Key / 参考图"
|
||||
from datetime import datetime, timezone
|
||||
suite.finished_at = datetime.now(timezone.utc)
|
||||
if product:
|
||||
product.stage = "generated" # 商品路径才有的阶段升级
|
||||
await db.commit()
|
||||
task.status = TASK_DONE if failed == 0 else (TASK_PARTIAL if ok > 0 else TASK_FAILED)
|
||||
if failed:
|
||||
uniq = list(dict.fromkeys(failures)) # 去重保序
|
||||
detail = ";".join(uniq[:6])
|
||||
if len(uniq) > 6:
|
||||
detail += f";…等共 {failed} 张失败"
|
||||
if ok == 0:
|
||||
task.error = f"全部生成失败。{detail}"
|
||||
else:
|
||||
task.error = f"部分生成失败({failed} 张)。{detail}"
|
||||
|
||||
+123
-30
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -21,29 +22,51 @@ 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 _system_prompt_with_requirements(requirements: str | None) -> str:
|
||||
"""把生图要求作为最高优先级约束注入 system prompt(置于规划规则之前)。
|
||||
|
||||
不仅声明优先级,还明确要求把要求落地到每个方案项的 prompt_hint,
|
||||
避免模型只把要求当作背景信息而不影响输出。
|
||||
"""
|
||||
if not (requirements and requirements.strip()):
|
||||
return SYSTEM_PROMPT
|
||||
marker = "\n## 输出硬性约束"
|
||||
idx = SYSTEM_PROMPT.find(marker)
|
||||
if idx < 0:
|
||||
return SYSTEM_PROMPT
|
||||
req = requirements.strip()
|
||||
block = (
|
||||
"\n## 生图要求(最高优先级,硬性约束,覆盖下方所有规划规则与约束)\n"
|
||||
+ req
|
||||
+ "\n\n"
|
||||
+ "规划方案时,必须把上述生图要求落地到每一项:\n"
|
||||
+ "1. 每个方案项的 prompt_hint 必须融入上述要求的关键约束(如要求纯黑背景,则每个 prompt_hint 都要写明 black background);\n"
|
||||
+ "2. title / detail 措辞不得与上述要求矛盾;\n"
|
||||
+ "3. 任何规划规则与上述要求冲突时,一律以本生图要求为准。\n"
|
||||
)
|
||||
return SYSTEM_PROMPT[:idx] + block + SYSTEM_PROMPT[idx:]
|
||||
|
||||
|
||||
def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
||||
@@ -76,46 +99,116 @@ 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],
|
||||
image_stats: dict,
|
||||
platform: str,
|
||||
requirements: str | None = None,
|
||||
) -> dict:
|
||||
"""调用 DeepSeek 生成方案。返回 {summary, items}。"""
|
||||
"""调用 DeepSeek 生成方案。返回 {summary, items}。
|
||||
|
||||
requirements:生图要求,最高优先级注入 system prompt,规划方案必须遵循。
|
||||
"""
|
||||
s = get_settings()
|
||||
if not s.deepseek_api_key:
|
||||
raise RuntimeError("未配置 DEEPSEEK_API_KEY(.env)")
|
||||
|
||||
user_content = json.dumps({
|
||||
user_payload: dict = {
|
||||
"商品信息": product_info, # {title, desc, params:[{key,value}], sellingPoints, price}
|
||||
"SKU规格": sku_variants, # 带图的 SKU 规格名(variant_name 只能从中选)
|
||||
"图片统计": image_stats, # {main: n, sku: n, detail: n}
|
||||
"目标平台": platform, # ozon/wb/cn(决定图内文案语言)
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
# 生图要求同时在 user 侧强调(与 system prompt 双重约束),确保模型真正遵循
|
||||
if requirements and requirements.strip():
|
||||
user_payload["生图要求(最高优先级,必须体现在每个方案项中)"] = requirements.strip()
|
||||
user_content = json.dumps(user_payload, 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"},
|
||||
json={
|
||||
"model": s.deepseek_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "system", "content": _system_prompt_with_requirements(requirements)},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"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)
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"""套图 Prompt 引擎。
|
||||
|
||||
借鉴 ecommerce-image-suite 的动态 Prompt 架构,浓缩为:
|
||||
- 7 种图类型 × 5 套视觉风格模板
|
||||
- 公共组件:QUALITY(画质)/ PRODUCT_REF_LOCK(商品一致性锁)/ TEXT_RENDER(图内文案规范)
|
||||
- 卖点从采集的参数表/卖点文本自动提炼
|
||||
|
||||
核心原则:所有图严格保持商品一致性(same silhouette, same print, same color),
|
||||
只允许改变背景 / 角度 / 光线 / 排版。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# ── 风格模板(与插件端 STYLE_SET_OPTIONS 对应)─────────────────────────────
|
||||
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
# ── 图类型中文名(导出文件名用)───────────────────────────────────────────
|
||||
|
||||
TYPE_NAMES_ZH: dict[str, str] = {
|
||||
"white_bg": "白底主图",
|
||||
"key_features": "核心卖点图",
|
||||
"selling_pt": "卖点图",
|
||||
"material": "材质图",
|
||||
"lifestyle": "场景展示图",
|
||||
"multi_scene": "多场景拼图",
|
||||
"ecommerce_detail": "电商详情图",
|
||||
"size_chart": "尺寸标注图",
|
||||
"sku_collection": "SKU合集图",
|
||||
"custom": "创意图",
|
||||
}
|
||||
|
||||
# ── 公共组件 ──────────────────────────────────────────────────────────────
|
||||
|
||||
QUALITY = (
|
||||
"Shot on Sony A7R V with 85mm lens at f/2.0, ultra-detailed, photorealistic, "
|
||||
"8K commercial image quality, professional retouching."
|
||||
)
|
||||
|
||||
PRODUCT_REF_LOCK = (
|
||||
"CRITICAL: The product must look EXACTLY the same as in the reference image — "
|
||||
"identical silhouette, proportions, colors, print pattern, stitching and every design detail. "
|
||||
"Only the background, camera angle, lighting and styling may change. "
|
||||
"Do not redesign, add or remove any element of the product."
|
||||
)
|
||||
|
||||
TEXT_RENDER = {
|
||||
"zh": (
|
||||
"Render concise Chinese marketing text inside the image: main headline max 8 Chinese characters, "
|
||||
"sub-lines max 12 characters each, font is modern clean sans-serif (Source Han Sans style), "
|
||||
"high legibility, tasteful typography layout, colors harmonized with the composition. "
|
||||
"No spelling errors, no garbled characters."
|
||||
),
|
||||
"en": (
|
||||
"Render concise English marketing text inside the image: headline max 5 words, "
|
||||
"sub-lines max 8 words each, Helvetica Neue style sans-serif, high legibility, "
|
||||
"tasteful typography layout, colors harmonized with the composition. No spelling errors."
|
||||
),
|
||||
"ru": (
|
||||
"Render concise Russian marketing text inside the image: headline max 4 words, "
|
||||
"sub-lines max 6 words each, modern clean sans-serif (Inter / PT Sans style), "
|
||||
"proper Cyrillic typography, high legibility, tasteful layout, colors harmonized with the composition. "
|
||||
"No spelling errors, no mixed latin/cyrillic gibberish."
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_NEGATIVE_INTENT = (
|
||||
"no AI-generated look, no CGI quality, no plastic appearance, no watermark, "
|
||||
"no distorted text, no deformed product, no extra limbs, no blurry areas"
|
||||
)
|
||||
|
||||
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
|
||||
|
||||
def _shorten(text: str, n: int) -> str:
|
||||
text = re.sub(r"\s+", " ", (text or "")).strip()
|
||||
return text[:n]
|
||||
|
||||
def _clean_title(title: str) -> str:
|
||||
"""去掉常见堆砌词,让标题更可读。"""
|
||||
t = _shorten(title, 60)
|
||||
return re.sub(r"[【【】】\\[\\]|/]", " ", t).strip()
|
||||
|
||||
def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -> dict:
|
||||
"""从采集数据提炼生图上下文:标题、描述行、卖点列表、参数行。
|
||||
|
||||
raw: {title, desc, price, params: [{key, value}], sellingPoints}
|
||||
"""
|
||||
title = _clean_title(raw.get("title") or fallback_name or "product")
|
||||
desc = _shorten(raw.get("desc") or fallback_desc or "", 200)
|
||||
|
||||
# 卖点:优先显式卖点文本;否则从参数表里挑短而有信息量的键值对
|
||||
selling_points: list[dict] = []
|
||||
sp_text = raw.get("sellingPoints") or ""
|
||||
if sp_text:
|
||||
for chunk in re.split(r"[;;\n·]+|(?<!\d)\.(?!\d)", sp_text):
|
||||
c = _shorten(chunk, 20)
|
||||
if c and len(selling_points) < 5:
|
||||
selling_points.append({"zh": c, "en": c})
|
||||
if not selling_points:
|
||||
for p in (raw.get("params") or [])[:12]:
|
||||
k, v = _shorten(p.get("key", ""), 10), _shorten(str(p.get("value", "")), 16)
|
||||
if k and v and k.lower() not in {"货号", "sku", "isbn", "上架时间"}:
|
||||
selling_points.append({"zh": f"{k} {v}", "en": f"{k} {v}"})
|
||||
if len(selling_points) >= 5:
|
||||
break
|
||||
|
||||
params_line = "; ".join(
|
||||
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
|
||||
)
|
||||
return {
|
||||
"title": title,
|
||||
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
|
||||
"desc": desc,
|
||||
"selling_points": selling_points[:3],
|
||||
"params_line": params_line,
|
||||
"price": raw.get("price") or "",
|
||||
}
|
||||
|
||||
def _sp_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
|
||||
sps = ctx["selling_points"][:max_n]
|
||||
if not sps:
|
||||
return ""
|
||||
key = "zh" if lang == "zh" else "en"
|
||||
return "; ".join(s[key] for s in sps if s.get(key))
|
||||
|
||||
# ── 各图类型 Prompt ───────────────────────────────────────────────────────
|
||||
|
||||
def _prompt_white_bg(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"E-commerce main product image on pure white background (RGB 255,255,255), "
|
||||
f"product \"{ctx['title']}\" centered and filling about 85% of the frame, "
|
||||
f"front view, even shadowless studio lighting with a faint natural contact shadow, "
|
||||
f"{style['tone']}. No text, no props, no background elements. {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_key_features(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = _sp_lines(ctx, lang) or ctx["title"]
|
||||
return (
|
||||
f"E-commerce key-features infographic for product \"{ctx['title']}\", square layout: "
|
||||
f"product on the left two-thirds ({style['bg']}), right column lists 3 feature callouts "
|
||||
f"with minimal line icons, thin leader lines pointing to product details. "
|
||||
f"Feature callouts: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_selling_pt(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = _sp_lines(ctx, lang, 1) or ctx["title"]
|
||||
return (
|
||||
f"Single-selling-point e-commerce poster for product \"{ctx['title']}\": "
|
||||
f"hero product close-up at dynamic angle ({style['bg']}), one large bold headline "
|
||||
f"about \"{sp}\", generous negative space, one small magnified detail circle "
|
||||
f"highlighting material or craft. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_material(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"Macro material close-up of product \"{ctx['title']}\": extreme detail shot revealing "
|
||||
f"fabric weave / surface texture / stitching / finish, shallow depth of field, "
|
||||
f"raking light across the surface, {style['tone']}. Small caption label in corner. "
|
||||
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_lifestyle(ctx: dict, style: dict, lang: str) -> str:
|
||||
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"soft daylight, authentic candid mood, product remains the clear visual focus. "
|
||||
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_multi_scene(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = _sp_lines(ctx, lang)
|
||||
return (
|
||||
f"Triptych multi-scene e-commerce image for product \"{ctx['title']}\": three vertical panels "
|
||||
f"separated by thin gutters, each panel shows the SAME product in a different usage scene "
|
||||
f"(e.g. home interior / outdoor street / office desk), consistent color grading across panels. "
|
||||
f"Panel captions: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = _sp_lines(ctx, lang) or ctx["title"]
|
||||
params = ctx["params_line"]
|
||||
return (
|
||||
f"E-commerce detail-page hero section for product \"{ctx['title']}\", square layout: "
|
||||
f"top half is a hero banner with the product at a 3/4 angle ({style['bg']}); "
|
||||
f"bottom half is a clean spec card listing 3 feature rows with line icons"
|
||||
+ (f" (specs: {params})" if params else "")
|
||||
+ f" and one highlighted row: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
|
||||
dims = ctx["params_line"]
|
||||
return (
|
||||
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
|
||||
f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, "
|
||||
f"measurement values rendered next to each line"
|
||||
+ (f" (known specs: {dims})" if dims else "")
|
||||
+ f", small caption row, precise technical drawing aesthetic. {style['tone']}. "
|
||||
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_sku_collection(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"Colorway collection image for product \"{ctx['title']}\": the SAME product in all its color/variant "
|
||||
f"options arranged in a neat equal grid (2-4 items per row), each colorway with a small label chip below it, "
|
||||
f"consistent lighting and scale across all items, clean e-commerce presentation. "
|
||||
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
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 ""
|
||||
composed = (
|
||||
f"E-commerce marketing image for product \"{ctx['title']}\""
|
||||
+ (f" — {purpose}" if purpose else "")
|
||||
+ (f": {detail}" if detail else "")
|
||||
+ "."
|
||||
)
|
||||
if hint:
|
||||
composed += f" Composition: {hint}."
|
||||
return f"{composed} {style['tone']}. {style['bg']} as environment. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
|
||||
_PROMPT_BUILDERS = {
|
||||
"white_bg": _prompt_white_bg,
|
||||
"key_features": _prompt_key_features,
|
||||
"selling_pt": _prompt_selling_pt,
|
||||
"material": _prompt_material,
|
||||
"lifestyle": _prompt_lifestyle,
|
||||
"multi_scene": _prompt_multi_scene,
|
||||
"ecommerce_detail": _prompt_ecommerce_detail,
|
||||
"size_chart": _prompt_size_chart,
|
||||
"sku_collection": _prompt_sku_collection,
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None) -> str:
|
||||
"""构造指定图类型的完整生图 prompt。
|
||||
|
||||
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
|
||||
预设类型也会把 prompt_hint 作为构图补充注入。
|
||||
"""
|
||||
style = STYLE_SETS.get(style_set, STYLE_SETS[1])
|
||||
extra = extra or {}
|
||||
if type_id == "custom":
|
||||
prompt = _prompt_custom(ctx, style, lang, extra)
|
||||
else:
|
||||
builder = _PROMPT_BUILDERS.get(type_id)
|
||||
if builder is None:
|
||||
raise ValueError(f"未知图类型: {type_id}")
|
||||
prompt = builder(ctx, style, lang)
|
||||
hint = (extra.get("prompt_hint") or "").strip()
|
||||
if hint:
|
||||
prompt = prompt.rstrip(".") + f". Additional composition guidance: {hint}."
|
||||
return prompt + ". " + DEFAULT_NEGATIVE_INTENT
|
||||
|
||||
|
||||
def type_name(type_id: str) -> str:
|
||||
return TYPE_NAMES_ZH.get(type_id, type_id)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""套图提示词引擎:按模型家族分发,各家族独立封装。
|
||||
|
||||
不同家族的生图语义差异极大,共用一套提示词会导致语义错配
|
||||
(gpt-image-2 按文字重造商品即由此而来),故按家族各自成册:
|
||||
|
||||
alibaba 通义 wan*/qwen*(DashScope)—— 主体参考语义
|
||||
doubao 豆包 Seedream(火山方舟)—— 主体参考语义,与通义共用装配
|
||||
gpt gpt-image-2 / gpt-image-2-vip(RightAPI)—— /v1/images/edits 编辑语义
|
||||
google nano-banana 系列(RightAPI)—— 原生主体保持语义
|
||||
|
||||
路由规则:provider 为主;rightapi 内再按模型名细分 gpt / google。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import alibaba, doubao, google, gpt
|
||||
from .common import build_context, type_name
|
||||
|
||||
_MODULE_BY_FAMILY = {
|
||||
"alibaba": alibaba,
|
||||
"doubao": doubao,
|
||||
"gpt": gpt,
|
||||
"google": google,
|
||||
}
|
||||
|
||||
|
||||
def prompt_family(provider: str, model: str | None) -> str:
|
||||
"""(provider, model) → 提示词家族名。"""
|
||||
if provider == "rightapi":
|
||||
if (model or "").lower().startswith("nano-banana"):
|
||||
return "google"
|
||||
return "gpt" # gpt-image-* 及未知中转模型默认按 edits 语义处理
|
||||
if provider == "tongyi":
|
||||
return "alibaba"
|
||||
return "doubao" # doubao 及默认 provider
|
||||
|
||||
|
||||
def build_prompt(provider: str, model: str | None, type_id: str, ctx: dict, style_set: int,
|
||||
lang: str, extra: dict | None = None, style_prompt: str | None = None,
|
||||
requirements: str | None = None) -> str:
|
||||
"""按模型家族构造指定图类型的完整生图 prompt。参数含义见各家族 build_prompt。"""
|
||||
module = _MODULE_BY_FAMILY[prompt_family(provider, model)]
|
||||
return module.build_prompt(
|
||||
type_id, ctx, style_set, lang,
|
||||
extra=extra, style_prompt=style_prompt, requirements=requirements,
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""阿里通义(wan* 万相 / qwen* 千问)提示词:国产"主体参考"语义。
|
||||
|
||||
生图 API 把参考图当商品锚(subject reference)、prompt 当场景描述,
|
||||
风格词/文字商品描述不会反噬商品本体,负面清单也可以安全写入 prompt。
|
||||
豆包(doubao.py)与此语义一致,直接复用本模块装配。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import (
|
||||
STYLE_SETS, TEXT_RENDER, requirements_block, resolve_style, selling_point_lines,
|
||||
)
|
||||
|
||||
# ── 公共组件(主体参考语义专用)────────────────────────────────────────────
|
||||
|
||||
QUALITY = (
|
||||
"Shot on Sony A7R V with 85mm lens at f/2.0, ultra-detailed, photorealistic, "
|
||||
"8K commercial image quality, professional retouching."
|
||||
)
|
||||
|
||||
PRODUCT_REF_LOCK = (
|
||||
"CRITICAL: The product must look EXACTLY the same as in the reference image — "
|
||||
"identical silhouette, proportions, colors, print pattern, stitching and every design detail. "
|
||||
"Only the background, camera angle, lighting and styling may change. "
|
||||
"Do not redesign, add or remove any element of the product."
|
||||
)
|
||||
|
||||
DEFAULT_NEGATIVE_INTENT = (
|
||||
"no AI-generated look, no CGI quality, no plastic appearance, no watermark, "
|
||||
"no distorted text, no deformed product, no extra limbs, no blurry areas"
|
||||
)
|
||||
|
||||
# ── 各图类型 Prompt ───────────────────────────────────────────────────────
|
||||
|
||||
def _prompt_white_bg(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"E-commerce main product image on pure white background (RGB 255,255,255), "
|
||||
f"product \"{ctx['title']}\" centered and filling about 85% of the frame, "
|
||||
f"front view, even shadowless studio lighting with a faint natural contact shadow, "
|
||||
f"{style['tone']}. No text, no props, no background elements. {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_key_features(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||
return (
|
||||
f"E-commerce key-features infographic for product \"{ctx['title']}\", square layout: "
|
||||
f"product on the left two-thirds ({style['bg']}), right column lists 3 feature callouts "
|
||||
f"with minimal line icons, thin leader lines pointing to product details. "
|
||||
f"Feature callouts: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_selling_pt(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang, 1) or ctx["title"]
|
||||
return (
|
||||
f"Single-selling-point e-commerce poster for product \"{ctx['title']}\": "
|
||||
f"hero product close-up at dynamic angle ({style['bg']}), one large bold headline "
|
||||
f"about \"{sp}\", generous negative space, one small magnified detail circle "
|
||||
f"highlighting material or craft. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_material(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"Macro material close-up of product \"{ctx['title']}\": extreme detail shot revealing "
|
||||
f"fabric weave / surface texture / stitching / finish, shallow depth of field, "
|
||||
f"raking light across the surface, {style['tone']}. Small caption label in corner. "
|
||||
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
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{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}"
|
||||
)
|
||||
|
||||
def _prompt_multi_scene(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang)
|
||||
return (
|
||||
f"Triptych multi-scene e-commerce image for product \"{ctx['title']}\": three vertical panels "
|
||||
f"separated by thin gutters, each panel shows the SAME product in a different usage scene "
|
||||
f"(e.g. home interior / outdoor street / office desk), consistent color grading across panels. "
|
||||
f"Panel captions: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||
params = ctx["params_line"]
|
||||
return (
|
||||
f"E-commerce detail-page hero section for product \"{ctx['title']}\", square layout: "
|
||||
f"top half is a hero banner with the product at a 3/4 angle ({style['bg']}); "
|
||||
f"bottom half is a clean spec card listing 3 feature rows with line icons"
|
||||
+ (f" (specs: {params})" if params else "")
|
||||
+ f" and one highlighted row: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
|
||||
dims = ctx["params_line"]
|
||||
return (
|
||||
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
|
||||
f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, "
|
||||
f"measurement values rendered next to each line"
|
||||
+ (f" (known specs: {dims})" if dims else "")
|
||||
+ f", small caption row, precise technical drawing aesthetic. {style['tone']}. "
|
||||
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_sku_collection(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"Colorway collection image for product \"{ctx['title']}\": the SAME product in all its color/variant "
|
||||
f"options arranged in a neat equal grid (2-4 items per row), each colorway with a small label chip below it, "
|
||||
f"consistent lighting and scale across all items, clean e-commerce presentation. "
|
||||
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
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 "")
|
||||
+ (f": {detail}" if detail else "")
|
||||
+ "."
|
||||
)
|
||||
if hint:
|
||||
composed += f" Composition: {hint}."
|
||||
return f"{composed}{bg} {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
|
||||
_PROMPT_BUILDERS = {
|
||||
"white_bg": _prompt_white_bg,
|
||||
"key_features": _prompt_key_features,
|
||||
"selling_pt": _prompt_selling_pt,
|
||||
"material": _prompt_material,
|
||||
"lifestyle": _prompt_lifestyle,
|
||||
"multi_scene": _prompt_multi_scene,
|
||||
"ecommerce_detail": _prompt_ecommerce_detail,
|
||||
"size_chart": _prompt_size_chart,
|
||||
"sku_collection": _prompt_sku_collection,
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
|
||||
style_prompt: str | None = None, requirements: str | None = None) -> str:
|
||||
"""构造指定图类型的完整生图 prompt(主体参考语义)。
|
||||
|
||||
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
|
||||
预设类型也会把 prompt_hint 作为构图补充注入。
|
||||
style_prompt: 用户改写的风格提示词,覆盖 style_set 内置模板(tone/bg 整体替换)。
|
||||
requirements: 生图要求(最高优先级,强制约束),置于 prompt 最前面,
|
||||
声明覆盖一切冲突指令,用户可在此输入强制要求。
|
||||
"""
|
||||
style = resolve_style(style_set, style_prompt)
|
||||
extra = extra or {}
|
||||
if type_id == "custom":
|
||||
prompt = _prompt_custom(ctx, style, lang, extra)
|
||||
else:
|
||||
builder = _PROMPT_BUILDERS.get(type_id)
|
||||
if builder is None:
|
||||
raise ValueError(f"未知图类型: {type_id}")
|
||||
prompt = builder(ctx, style, lang)
|
||||
hint = (extra.get("prompt_hint") or "").strip()
|
||||
if hint:
|
||||
prompt = prompt.rstrip(".") + f". Additional composition guidance: {hint}."
|
||||
req = requirements_block(requirements)
|
||||
if req:
|
||||
prompt = f"{req} {prompt}"
|
||||
return prompt + ". " + DEFAULT_NEGATIVE_INTENT
|
||||
@@ -0,0 +1,154 @@
|
||||
"""提示词公共层:与模型家族无关的商品上下文、风格模板、图类型名与文案组件。
|
||||
|
||||
各家族模块(alibaba / doubao / gpt / google)只负责"如何对模型说话",
|
||||
商品信息提炼与风格体系统一在这里维护,避免多处漂移。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# ── 风格模板(与插件端 STYLE_SET_OPTIONS 对应;提示词可被用户在插件里改写覆盖)───
|
||||
|
||||
STYLE_SETS: dict[int, dict] = {
|
||||
1: {
|
||||
"name": "北欧极简",
|
||||
"tone": "北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净",
|
||||
"bg": "",
|
||||
},
|
||||
2: {
|
||||
"name": "清新明亮",
|
||||
"tone": "清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净",
|
||||
"bg": "",
|
||||
},
|
||||
3: {
|
||||
"name": "高级感深色",
|
||||
"tone": "高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级",
|
||||
"bg": "",
|
||||
},
|
||||
4: {
|
||||
"name": "暖调生活",
|
||||
"tone": "温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强",
|
||||
"bg": "",
|
||||
},
|
||||
5: {
|
||||
"name": "纯净棚拍",
|
||||
"tone": "标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出",
|
||||
"bg": "",
|
||||
},
|
||||
}
|
||||
|
||||
# ── 图类型中文名(导出文件名用)───────────────────────────────────────────
|
||||
|
||||
TYPE_NAMES_ZH: dict[str, str] = {
|
||||
"white_bg": "白底主图",
|
||||
"key_features": "核心卖点图",
|
||||
"selling_pt": "卖点图",
|
||||
"material": "材质图",
|
||||
"lifestyle": "场景展示图",
|
||||
"multi_scene": "多场景拼图",
|
||||
"ecommerce_detail": "电商详情图",
|
||||
"size_chart": "尺寸标注图",
|
||||
"sku_collection": "SKU合集图",
|
||||
"custom": "创意图",
|
||||
}
|
||||
|
||||
# ── 图内营销文案渲染规范(各家族共用;语言由平台决定)──────────────────────
|
||||
|
||||
TEXT_RENDER = {
|
||||
"zh": (
|
||||
"Render concise Chinese marketing text inside the image: main headline max 8 Chinese characters, "
|
||||
"sub-lines max 12 characters each, font is modern clean sans-serif (Source Han Sans style), "
|
||||
"high legibility, tasteful typography layout, colors harmonized with the composition. "
|
||||
"No spelling errors, no garbled characters."
|
||||
),
|
||||
"en": (
|
||||
"Render concise English marketing text inside the image: headline max 5 words, "
|
||||
"sub-lines max 8 words each, Helvetica Neue style sans-serif, high legibility, "
|
||||
"tasteful typography layout, colors harmonized with the composition. No spelling errors."
|
||||
),
|
||||
"ru": (
|
||||
"Render concise Russian marketing text inside the image: headline max 4 words, "
|
||||
"sub-lines max 6 words each, modern clean sans-serif (Inter / PT Sans style), "
|
||||
"proper Cyrillic typography, high legibility, tasteful layout, colors harmonized with the composition. "
|
||||
"No spelling errors, no mixed latin/cyrillic gibberish."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def resolve_style(style_set: int, style_prompt: str | None = None) -> dict:
|
||||
"""用户改写的风格提示词整体覆盖内置模板(tone/bg 整体替换)。"""
|
||||
if style_prompt and style_prompt.strip():
|
||||
return {"name": "custom", "tone": style_prompt.strip(), "bg": ""}
|
||||
return STYLE_SETS.get(style_set, STYLE_SETS[1])
|
||||
|
||||
|
||||
def requirements_block(requirements: str | None) -> str:
|
||||
"""用户强制要求块:最高优先级、置于提示词最前、覆盖冲突指令(原文保留不翻译)。"""
|
||||
if requirements and requirements.strip():
|
||||
return (
|
||||
"STRICT REQUIREMENTS (highest priority, must be followed exactly, "
|
||||
"override any conflicting instruction): "
|
||||
+ requirements.strip().rstrip(".")
|
||||
+ "."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
|
||||
|
||||
def _shorten(text: str, n: int) -> str:
|
||||
text = re.sub(r"\s+", " ", (text or "")).strip()
|
||||
return text[:n]
|
||||
|
||||
def _clean_title(title: str) -> str:
|
||||
"""去掉常见堆砌词,让标题更可读。"""
|
||||
t = _shorten(title, 60)
|
||||
return re.sub(r"[【【】】\\[\\]|/]", " ", t).strip()
|
||||
|
||||
def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -> dict:
|
||||
"""从采集数据提炼生图上下文:标题、描述行、卖点列表、参数行。
|
||||
|
||||
raw: {title, desc, price, params: [{key, value}], sellingPoints}
|
||||
"""
|
||||
title = _clean_title(raw.get("title") or fallback_name or "product")
|
||||
desc = _shorten(raw.get("desc") or fallback_desc or "", 200)
|
||||
|
||||
# 卖点:优先显式卖点文本;否则从参数表里挑短而有信息量的键值对
|
||||
selling_points: list[dict] = []
|
||||
sp_text = raw.get("sellingPoints") or ""
|
||||
if sp_text:
|
||||
for chunk in re.split(r"[;;\n·]+|(?<!\d)\.(?!\d)", sp_text):
|
||||
c = _shorten(chunk, 20)
|
||||
if c and len(selling_points) < 5:
|
||||
selling_points.append({"zh": c, "en": c})
|
||||
if not selling_points:
|
||||
for p in (raw.get("params") or [])[:12]:
|
||||
k, v = _shorten(p.get("key", ""), 10), _shorten(str(p.get("value", "")), 16)
|
||||
if k and v and k.lower() not in {"货号", "sku", "isbn", "上架时间"}:
|
||||
selling_points.append({"zh": f"{k} {v}", "en": f"{k} {v}"})
|
||||
if len(selling_points) >= 5:
|
||||
break
|
||||
|
||||
params_line = "; ".join(
|
||||
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
|
||||
)
|
||||
return {
|
||||
"title": title,
|
||||
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
|
||||
"desc": desc,
|
||||
"selling_points": selling_points[:3],
|
||||
"params_line": params_line,
|
||||
"price": raw.get("price") or "",
|
||||
}
|
||||
|
||||
def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
|
||||
"""卖点列表 → 单行文案(图内 callout/标题用),无卖点返回空串。"""
|
||||
sps = ctx["selling_points"][:max_n]
|
||||
if not sps:
|
||||
return ""
|
||||
key = "zh" if lang == "zh" else "en"
|
||||
return "; ".join(s[key] for s in sps if s.get(key))
|
||||
|
||||
|
||||
def type_name(type_id: str) -> str:
|
||||
return TYPE_NAMES_ZH.get(type_id, type_id)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""豆包(火山方舟 Seedream)提示词。
|
||||
|
||||
豆包与通义同为国产"主体参考"生图模型:参考图即商品锚、prompt 为场景描述,
|
||||
提示词语义一致,直接复用阿里系装配;差异(去 AI 味后缀)在 generator 层追加。
|
||||
独立成文件便于后续按豆包特性分化。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .alibaba import build_prompt as build_prompt # noqa: F401 主体参考语义与通义共用
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Google 图像模型(nano-banana / nano-banana-2 / nano-banana-2-lite / nano-banana-pro)提示词。
|
||||
|
||||
语义:Gemini 图像编辑 —— 原生主体保持能力强,输入图即"主体 + 底图",
|
||||
对自然语言指令遵循好。不套用 GPT 的编辑契约(冗长的拒绝条款反而稀释指令),
|
||||
也不用负面清单(无 negative_prompt 参数)。要点:
|
||||
- 开头一句话钉死"主体 = 第一张图里的商品,逐像素保持";
|
||||
- 指令自然语言描述目标画面(场景/排版/文案),不重述商品外观;
|
||||
- 标题/参数仅作识别背景并声明以图为准。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import TEXT_RENDER, requirements_block, resolve_style, selling_point_lines
|
||||
|
||||
_SUBJECT_LOCK = (
|
||||
"SUBJECT LOCK (highest priority): the product in the first image is the subject. "
|
||||
"Keep it exactly as photographed — same shape, proportions, colors, print/pattern, "
|
||||
"logo, label and every detail — and place that very product into the result. "
|
||||
"A second image, when present, is another view of the same product for reference only."
|
||||
)
|
||||
|
||||
_QUALITY = (
|
||||
"OUTPUT: photorealistic commercial e-commerce photography, ultra-detailed, "
|
||||
"natural light and shadow, professional retouching."
|
||||
)
|
||||
|
||||
_REMINDER = (
|
||||
"Reminder: keep the product exactly as in the first image; change only its surroundings, "
|
||||
"composition, lighting and overlay graphics."
|
||||
)
|
||||
|
||||
|
||||
def _anchor(ctx: dict) -> str:
|
||||
"""商品文字锚定:仅供识别,明确以图为准(同 gpt 模块,避免文字反噬商品)。"""
|
||||
line = f"Context (identification only): the product is \"{ctx['title']}\""
|
||||
if ctx.get("params_line"):
|
||||
line += f" ({ctx['params_line']})"
|
||||
return line + ". The image, not this text, defines the product's appearance."
|
||||
|
||||
|
||||
# ── 各图类型指令(自然语言编辑口吻)────────────────────────────────────────
|
||||
|
||||
def _task_white_bg(ctx: dict, lang: str) -> str:
|
||||
return (
|
||||
"Replace the background of this product photo with seamless pure white (RGB 255,255,255): "
|
||||
"product centered in front view filling about 85% of the frame, even studio lighting with only "
|
||||
"a faint natural contact shadow. No props, no added text, no background elements."
|
||||
)
|
||||
|
||||
def _task_key_features(ctx: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||
return (
|
||||
"Create a square key-features infographic: the unchanged product on the left two-thirds, "
|
||||
"a clean right-hand panel with 3 feature callouts using minimal line icons and thin leader "
|
||||
f"lines pointing at the product. Callout copy: {sp}."
|
||||
)
|
||||
|
||||
def _task_selling_pt(ctx: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang, 1) or ctx["title"]
|
||||
return (
|
||||
"Turn the photo into a single-selling-point poster: hero close-up of the unchanged product at "
|
||||
f"a dynamic angle, one large bold headline about \"{sp}\", generous negative space, and a small "
|
||||
"magnified circle zooming into an existing detail of the product."
|
||||
)
|
||||
|
||||
def _task_material(ctx: dict, lang: str) -> str:
|
||||
return (
|
||||
"Create an extreme macro close-up of an existing area of the product's surface, showing its "
|
||||
"true fabric weave / texture / stitching exactly as in the photo; shallow depth of field, "
|
||||
"raking light, small caption in a corner."
|
||||
)
|
||||
|
||||
def _task_lifestyle(ctx: dict, lang: str) -> str:
|
||||
return (
|
||||
"Place the unchanged product into a realistic everyday scene where it would naturally be used: "
|
||||
"human-scale surroundings, soft daylight, authentic candid mood, the product as the clear visual focus."
|
||||
)
|
||||
|
||||
def _task_multi_scene(ctx: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang)
|
||||
task = (
|
||||
"Build a triptych of three vertical panels separated by thin gutters: each panel shows an "
|
||||
"identical copy of the product in a different usage scene (home interior / outdoor street / "
|
||||
"office desk), with consistent color grading across panels."
|
||||
)
|
||||
if sp:
|
||||
task += f" Panel captions: {sp}."
|
||||
return task
|
||||
|
||||
def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||
params = ctx["params_line"]
|
||||
return (
|
||||
"Compose a square detail-page hero section: top half a hero banner with the unchanged product "
|
||||
"at a 3/4 angle; bottom half a clean spec card with 3 feature rows and line icons"
|
||||
+ (f" (specs: {params})" if params else "")
|
||||
+ f", one highlighted row: {sp}."
|
||||
)
|
||||
|
||||
def _task_size_chart(ctx: dict, lang: str) -> str:
|
||||
dims = ctx["params_line"]
|
||||
return (
|
||||
"Create a size chart: the unchanged product in clean front and side views on a light background, "
|
||||
"thin measurement annotation lines (arrows) marking length, width and height with values beside "
|
||||
"each line"
|
||||
+ (f" (known specs: {dims})" if dims else "")
|
||||
+ ", small caption row, precise technical-drawing aesthetic."
|
||||
)
|
||||
|
||||
def _task_sku_collection(ctx: dict, lang: str) -> str:
|
||||
# 不展开"全部配色":会凭空造出新商品;只排列同一件的多个副本
|
||||
return (
|
||||
"Arrange several identical copies of the product in a neat equal grid (2-4 per row) with a small "
|
||||
"label chip below each copy; identical lighting and scale across copies. Every copy shows this "
|
||||
"exact product — do not invent other colorways or variants."
|
||||
)
|
||||
|
||||
_TASK_BUILDERS = {
|
||||
"white_bg": (_task_white_bg, False),
|
||||
"key_features": (_task_key_features, True),
|
||||
"selling_pt": (_task_selling_pt, True),
|
||||
"material": (_task_material, True),
|
||||
"lifestyle": (_task_lifestyle, True),
|
||||
"multi_scene": (_task_multi_scene, True),
|
||||
"ecommerce_detail": (_task_ecommerce_detail, True),
|
||||
"size_chart": (_task_size_chart, True),
|
||||
"sku_collection": (_task_sku_collection, True),
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
|
||||
style_prompt: str | None = None, requirements: str | None = None) -> str:
|
||||
"""构造指定图类型的 prompt:要求块 → 指令 → 主体锁 → 锚定 → 风格 → 文案 → 画质 → 提醒。"""
|
||||
style = resolve_style(style_set, style_prompt)
|
||||
extra = extra or {}
|
||||
hint = (extra.get("prompt_hint") or "").strip()
|
||||
|
||||
if type_id == "custom":
|
||||
purpose = extra.get("title") or ""
|
||||
detail = extra.get("detail") or ""
|
||||
task = "Create an e-commerce marketing image featuring the product from the first image"
|
||||
task += f" — {purpose}" if purpose else ""
|
||||
task += f": {detail}" if detail else ""
|
||||
task += "."
|
||||
wants_text = True
|
||||
else:
|
||||
entry = _TASK_BUILDERS.get(type_id)
|
||||
if entry is None:
|
||||
raise ValueError(f"未知图类型: {type_id}")
|
||||
builder, wants_text = entry
|
||||
task = builder(ctx, lang)
|
||||
if hint:
|
||||
task += f" Composition guidance: {hint}."
|
||||
|
||||
parts = [p for p in (requirements_block(requirements),) if p]
|
||||
parts.append(task)
|
||||
parts.append(_SUBJECT_LOCK)
|
||||
parts.append(_anchor(ctx))
|
||||
parts.append(f"Scene style (scene and background only, never the product): {style['tone']}.")
|
||||
if wants_text:
|
||||
parts.append(f"Text overlay (a graphic layer, never printed on the product): {TEXT_RENDER[lang]}")
|
||||
parts.append(_QUALITY)
|
||||
parts.append(_REMINDER)
|
||||
return "\n\n".join(parts)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""GPT 图像模型(gpt-image-2 / gpt-image-2-vip,RightAPI 中转)提示词。
|
||||
|
||||
语义:/v1/images/edits —— 输入图是"被编辑的照片",prompt 是编辑指令;
|
||||
与通义/豆包的"主体参考"语义完全不同:参考图不是商品锚,模型会按文字指令
|
||||
重新渲染整张图。此前与国产模型共用场景提示词,再用文字锚定商品并要求输出
|
||||
"匹配商品描述",导致模型把商品改造成营销关键词描述的样子(必现商品被改)。
|
||||
|
||||
本模块写法原则:
|
||||
1. 商品只由 Image 1 定义;标题/参数仅作识别背景并声明"以图为准",
|
||||
绝不要求输出匹配文字描述(那等于授权模型改商品);
|
||||
2. 指令只说"改什么"(背景/场景/排版/文案),不描述商品外观;
|
||||
3. 分节精简、首尾重申保真;不用负面清单(gpt 无 negative_prompt 参数,
|
||||
罗列畸形反而往上下文植入概念);
|
||||
4. sku 合集 / 多拼图明确"复制同一件商品,禁止发明新配色或变体"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import TEXT_RENDER, requirements_block, resolve_style, selling_point_lines
|
||||
|
||||
# 保真锁:商品由 Image 1 唯一定义,其余指令一律不得触碰商品本体
|
||||
_PRESERVE = (
|
||||
"PRESERVE (absolute, overrides every other instruction below): the product shown in Image 1. "
|
||||
"Reuse the photographed product exactly as it is — identical shape, silhouette, proportions, "
|
||||
"colors, print/pattern, logo and label text, materials, stitching and surface details. "
|
||||
"Do not redesign, restyle, recolor, re-pattern, tidy up or substitute the product, "
|
||||
"and do not let any style or text instruction below alter it. Image 2 is a secondary "
|
||||
"view of the same product for reference only."
|
||||
)
|
||||
|
||||
_STYLE = (
|
||||
"SCENE STYLE (applies to background, scene, props and lighting only — never to the product): "
|
||||
)
|
||||
|
||||
_QUALITY = (
|
||||
"OUTPUT: photorealistic commercial e-commerce photography, ultra-detailed, "
|
||||
"natural light and shadow, professional retouching."
|
||||
)
|
||||
|
||||
_REMINDER = (
|
||||
"FINAL CHECK: the product itself must remain exactly as photographed in Image 1 — "
|
||||
"only its surroundings, composition, lighting and overlay graphics may differ."
|
||||
)
|
||||
|
||||
# 图内文案:明确是"排版图层",不落在商品本体上
|
||||
_TEXT_SCOPE = (
|
||||
"TEXT OVERLAY (a graphic layer on the composition, never printed on the product): "
|
||||
)
|
||||
|
||||
|
||||
def _anchor(ctx: dict) -> str:
|
||||
"""商品文字锚定:仅供识别,明确声明以图为准。
|
||||
|
||||
只放标题 + 参数、不放营销描述——描述里的卖点词("卡通""加固""防水"等)
|
||||
在 edits 语义下会被执行到商品上;官逆通道(-vip)参考图被弱化时,
|
||||
文字锚定用于帮模型认出"是哪件商品",而不是"长什么样"。
|
||||
"""
|
||||
line = f"CONTEXT (identification only): the product is \"{ctx['title']}\""
|
||||
if ctx.get("params_line"):
|
||||
line += f" ({ctx['params_line']})"
|
||||
return (
|
||||
line
|
||||
+ ". Image 1 — not this text — defines the product's appearance; "
|
||||
"if they ever conflict, follow Image 1."
|
||||
)
|
||||
|
||||
|
||||
# ── 各图类型的编辑指令(只描述改动,不描述商品)────────────────────────────
|
||||
|
||||
def _task_white_bg(ctx: dict, lang: str) -> str:
|
||||
return (
|
||||
"TASK: Clean up this product photo for a marketplace listing. Replace the entire "
|
||||
"background with seamless pure white (RGB 255,255,255); recompose with the product "
|
||||
"centered in front view filling about 85% of the frame; keep only a faint natural "
|
||||
"contact shadow. No props, no text, no background elements."
|
||||
)
|
||||
|
||||
def _task_key_features(ctx: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||
return (
|
||||
"TASK: Feature infographic on a square canvas. Keep the product unchanged on the left "
|
||||
"two-thirds; build the right third as a clean info panel listing 3 feature callouts with "
|
||||
f"minimal line icons and thin leader lines pointing at parts of the product. Callout copy: {sp}."
|
||||
)
|
||||
|
||||
def _task_selling_pt(ctx: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang, 1) or ctx["title"]
|
||||
return (
|
||||
"TASK: Single-selling-point poster. Hero close-up of the unchanged product at a dynamic "
|
||||
f"angle, generous negative space, one large bold headline about \"{sp}\", plus one small "
|
||||
"magnified circle zooming into an existing detail of the product (zoom only — do not "
|
||||
"invent details that are not in the photo)."
|
||||
)
|
||||
|
||||
def _task_material(ctx: dict, lang: str) -> str:
|
||||
return (
|
||||
"TASK: Material close-up. Zoom tightly into an existing area of the product's surface and "
|
||||
"show its true texture — fabric weave, surface finish, stitching — exactly as it appears in "
|
||||
"Image 1; shallow depth of field, raking light across the surface; small caption label in a corner."
|
||||
)
|
||||
|
||||
def _task_lifestyle(ctx: dict, lang: str) -> str:
|
||||
return (
|
||||
"TASK: Lifestyle scene. Place the unchanged product into a realistic everyday environment "
|
||||
"where it would naturally be used: human-scale surroundings, soft daylight, authentic candid "
|
||||
"mood, matched shadows and color temperature, the product remaining the clear visual focus."
|
||||
)
|
||||
|
||||
def _task_multi_scene(ctx: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang)
|
||||
task = (
|
||||
"TASK: Triptych showcase. Build three vertical panels separated by thin gutters; every panel "
|
||||
"contains an IDENTICAL copy of the product from Image 1 (do not re-render it differently per "
|
||||
"panel) placed in a different usage scene (e.g. home interior / outdoor street / office desk), "
|
||||
"with consistent color grading across panels."
|
||||
)
|
||||
if sp:
|
||||
task += f" Panel captions: {sp}."
|
||||
return task
|
||||
|
||||
def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||
params = ctx["params_line"]
|
||||
return (
|
||||
"TASK: Detail-page hero section on a square canvas. Top half: hero banner with the unchanged "
|
||||
"product at a 3/4 angle. Bottom half: clean spec card with 3 feature rows and line icons"
|
||||
+ (f" (specs: {params})" if params else "")
|
||||
+ f", one highlighted row: {sp}."
|
||||
)
|
||||
|
||||
def _task_size_chart(ctx: dict, lang: str) -> str:
|
||||
dims = ctx["params_line"]
|
||||
return (
|
||||
"TASK: Measurement chart. Show the unchanged product in clean front and side views on a light "
|
||||
"background; overlay thin technical annotation lines (arrows) marking length, width and height "
|
||||
"with measurement values rendered beside each line"
|
||||
+ (f" (known specs: {dims})" if dims else "")
|
||||
+ "; precise technical-drawing aesthetic, small caption row."
|
||||
)
|
||||
|
||||
def _task_sku_collection(ctx: dict, lang: str) -> str:
|
||||
# 关键差异:不允许像国产模型那样展开"全部配色"——edits 语义下那会凭空造出新商品
|
||||
return (
|
||||
"TASK: Product lineup. Arrange several IDENTICAL copies of the product from Image 1 in a neat "
|
||||
"equal grid (2-4 per row) with a small label chip below each copy; identical lighting and scale "
|
||||
"across copies. Every copy must show this exact product — do NOT invent other colorways, "
|
||||
"variants or versions."
|
||||
)
|
||||
|
||||
_TASK_BUILDERS = {
|
||||
"white_bg": (_task_white_bg, False),
|
||||
"key_features": (_task_key_features, True),
|
||||
"selling_pt": (_task_selling_pt, True),
|
||||
"material": (_task_material, True),
|
||||
"lifestyle": (_task_lifestyle, True),
|
||||
"multi_scene": (_task_multi_scene, True),
|
||||
"ecommerce_detail": (_task_ecommerce_detail, True),
|
||||
"size_chart": (_task_size_chart, True),
|
||||
"sku_collection": (_task_sku_collection, True),
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
|
||||
style_prompt: str | None = None, requirements: str | None = None) -> str:
|
||||
"""构造指定图类型的 edits 语义 prompt:要求块 → 编辑指令 → 保真锁 → 锚定 → 风格 → 文案 → 画质 → 终检。"""
|
||||
style = resolve_style(style_set, style_prompt)
|
||||
extra = extra or {}
|
||||
hint = (extra.get("prompt_hint") or "").strip()
|
||||
|
||||
if type_id == "custom":
|
||||
purpose = extra.get("title") or ""
|
||||
detail = extra.get("detail") or ""
|
||||
task = "TASK: Create an e-commerce marketing image featuring the product from Image 1"
|
||||
task += f" — {purpose}" if purpose else ""
|
||||
task += f": {detail}" if detail else ""
|
||||
task += "."
|
||||
wants_text = True
|
||||
else:
|
||||
entry = _TASK_BUILDERS.get(type_id)
|
||||
if entry is None:
|
||||
raise ValueError(f"未知图类型: {type_id}")
|
||||
builder, wants_text = entry
|
||||
task = builder(ctx, lang)
|
||||
if hint:
|
||||
task += f" Composition guidance: {hint}."
|
||||
|
||||
parts = [p for p in (requirements_block(requirements),) if p]
|
||||
parts.append(task)
|
||||
parts.append(_PRESERVE)
|
||||
parts.append(_anchor(ctx))
|
||||
parts.append(f"{_STYLE}{style['tone']}.")
|
||||
if wants_text:
|
||||
parts.append(f"{_TEXT_SCOPE}{TEXT_RENDER[lang]}")
|
||||
parts.append(_QUALITY)
|
||||
parts.append(_REMINDER)
|
||||
return "\n\n".join(parts)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""内存任务注册表:套图生成任务的生命周期与进程一致(重启即新会话)。
|
||||
|
||||
轮询/导出只服务「当前会话正在跟踪的任务」——前端没有历史记录功能,
|
||||
任务状态无需跨进程持久化;重启后轮询自然 404,前端提示任务已中断。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# 任务状态
|
||||
TASK_PENDING = "pending"
|
||||
TASK_RUNNING = "running"
|
||||
TASK_DONE = "done"
|
||||
TASK_PARTIAL = "partial"
|
||||
TASK_FAILED = "failed"
|
||||
|
||||
# 任务内单张图状态
|
||||
IMG_PENDING = "pending" # 生成中(前端据此隐藏占位格,只渲染 ok/failed 终态)
|
||||
IMG_OK = "ok"
|
||||
IMG_FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskImage:
|
||||
"""任务里单张生成图:完成一张追加一条(前端进度 x/y 依赖此语义)。"""
|
||||
|
||||
type_id: str
|
||||
name: str
|
||||
status: str = IMG_PENDING # 循环里先建后跑,成功改 ok、失败显式改 failed
|
||||
url: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""一次套图生成任务:轮询可见字段 + 仅供 run_suite 消费的执行参数。"""
|
||||
|
||||
id: str
|
||||
status: str = TASK_PENDING
|
||||
platform: str = "cn"
|
||||
lang: str = "zh"
|
||||
ratio: str = "1:1"
|
||||
style_set: int = 1
|
||||
style_prompt: str | None = None
|
||||
requirements: str | None = None
|
||||
provider: str = ""
|
||||
model: str | None = None
|
||||
total: int = 0 # 计划总张数(进度分母)
|
||||
images: list[TaskImage] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
# ── 执行参数(不进轮询响应)──
|
||||
context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入)
|
||||
plan: list[dict] = field(default_factory=list) # 展开后的逐张任务
|
||||
ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先)
|
||||
watermark: dict | None = None # 水印选项(落盘前服务端后处理)
|
||||
|
||||
|
||||
# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小)
|
||||
_TASKS: dict[str, Task] = {}
|
||||
|
||||
|
||||
def create_task(**kwargs) -> Task:
|
||||
task = Task(id=uuid.uuid4().hex, **kwargs)
|
||||
_TASKS[task.id] = task
|
||||
return task
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Task | None:
|
||||
return _TASKS.get(task_id)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""生成图水印:AI 出图返回后、落盘前的后处理合成(不经过生图模型)。
|
||||
|
||||
样式复刻 ozonSeller「图表处理」的默认水印:
|
||||
- 图片水印:徽章图中心裁方 → 圆形遮罩 → 宽度为图宽 15%,右下角,边距约 1% 图宽;
|
||||
- 文字水印:字号为图宽 6%(下限 12px),白色填充 + 黑色描边(alpha 0.55,
|
||||
描边宽 fontSize/8),加粗无衬线。
|
||||
容错原则:字体/资产缺失或合成异常时 log 警告并返回原图,绝不阻断生图。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from config import get_settings
|
||||
|
||||
log = logging.getLogger("suite.watermark")
|
||||
|
||||
# 尺寸比例(与 ozonSeller app.js 常量一致)
|
||||
BADGE_SCALE = 0.15 # 图片水印直径 / 图宽
|
||||
BADGE_MARGIN = 0.01 # 图片水印边距 / 图宽(ozonSeller 固定 10px,按比例更稳)
|
||||
TEXT_SCALE = 0.06 # 文字字号 / 图宽
|
||||
TEXT_MIN_SIZE = 12
|
||||
STROKE_ALPHA = 0.55
|
||||
STROKE_RATIO = 1 / 8 # 描边宽 / 字号
|
||||
|
||||
# CJK/西文字体回退链(macOS 本地服务);命中后模块级缓存
|
||||
_FONT_CANDIDATES = [
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
]
|
||||
_font_path: str | None = None
|
||||
|
||||
|
||||
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
global _font_path
|
||||
if _font_path is None:
|
||||
_font_path = next((p for p in _FONT_CANDIDATES if Path(p).is_file()), "")
|
||||
if _font_path:
|
||||
return ImageFont.truetype(_font_path, size)
|
||||
log.warning("未找到系统字体(%s),文字水印退化为 Pillow 默认字体,中文可能乱码", _FONT_CANDIDATES)
|
||||
return ImageFont.load_default(size) if size >= 10 else ImageFont.load_default()
|
||||
|
||||
|
||||
def _circular_badge(size: int) -> Image.Image | None:
|
||||
"""徽章资产 → 指定直径的圆形 RGBA 贴片;资产缺失返回 None。"""
|
||||
path = get_settings().watermark_image_path
|
||||
try:
|
||||
badge = Image.open(path).convert("RGBA")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("水印图片加载失败(%s),跳过图片水印: %s", path, exc)
|
||||
return None
|
||||
side = min(badge.size) # 中心裁方
|
||||
left, top = (badge.width - side) // 2, (badge.height - side) // 2
|
||||
square = badge.crop((left, top, left + side, top + side)).resize((size, size))
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
|
||||
square.putalpha(mask)
|
||||
return square
|
||||
|
||||
|
||||
def _apply_image_watermark(canvas: Image.Image, opacity: float) -> None:
|
||||
size = max(24, round(canvas.width * BADGE_SCALE))
|
||||
badge = _circular_badge(size)
|
||||
if badge is None:
|
||||
return
|
||||
badge.putalpha(badge.getchannel("A").point(lambda a: round(a * opacity)))
|
||||
margin = max(10, round(canvas.width * BADGE_MARGIN))
|
||||
canvas.alpha_composite(badge, (canvas.width - size - margin, canvas.height - size - margin))
|
||||
|
||||
|
||||
def _apply_text_watermark(canvas: Image.Image, text: str, opacity: float) -> None:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
font_size = max(TEXT_MIN_SIZE, round(canvas.width * TEXT_SCALE))
|
||||
font = _load_font(font_size)
|
||||
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=max(1, round(font_size * STROKE_RATIO)))
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
if tw >= canvas.width: # 文案比图还宽:按比例缩字号重排一次
|
||||
font_size = max(TEXT_MIN_SIZE, round(font_size * canvas.width / tw * 0.94))
|
||||
font = _load_font(font_size)
|
||||
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=max(1, round(font_size * STROKE_RATIO)))
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
margin = max(10, round(canvas.width * BADGE_MARGIN))
|
||||
x = canvas.width - tw - margin - bbox[0]
|
||||
y = canvas.height - th - margin - bbox[1]
|
||||
stroke = (0, 0, 0, round(255 * STROKE_ALPHA))
|
||||
fill = (255, 255, 255, 255)
|
||||
draw.text((x, y), text, font=font, fill=fill, stroke_width=max(1, round(font_size * STROKE_RATIO)),
|
||||
stroke_fill=stroke)
|
||||
layer.putalpha(layer.getchannel("A").point(lambda a: round(a * opacity)))
|
||||
canvas.alpha_composite(layer)
|
||||
|
||||
|
||||
def apply_watermark(data: bytes, opts: dict) -> bytes:
|
||||
"""给图片字节加水印,返回同格式字节;opts: {type, text, opacity(0-100)}。"""
|
||||
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
fmt = "PNG" if is_png else "JPEG"
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
canvas = img.convert("RGBA")
|
||||
opacity = min(100, max(1, int(opts.get("opacity") or 30))) / 100
|
||||
if opts.get("type") == "text":
|
||||
_apply_text_watermark(canvas, opts.get("text") or "", opacity)
|
||||
else:
|
||||
_apply_image_watermark(canvas, opacity)
|
||||
out = io.BytesIO()
|
||||
if fmt == "PNG":
|
||||
canvas.save(out, format="PNG")
|
||||
else:
|
||||
canvas.convert("RGB").save(out, format="JPEG", quality=95)
|
||||
return out.getvalue()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("水印合成失败,返回原图")
|
||||
return data
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/zsh
|
||||
# 双击本文件,或在终端执行:./start.command
|
||||
# 流程:停掉占用 3300 的旧后端 → 重新 build 插件 → 启动后端(http://127.0.0.1:3300)
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# ── 1) 停掉占用 3300 端口的旧后端(上次残留的服务会导致 address already in use)──
|
||||
listen_pids() { lsof -tnP -iTCP:3300 -sTCP:LISTEN 2>/dev/null; }
|
||||
if [[ -n "$(listen_pids)" ]]; then
|
||||
echo "端口 3300 被旧服务占用(PID: $(listen_pids | tr '\n' ' ')),正在停止…"
|
||||
listen_pids | xargs kill 2>/dev/null
|
||||
for _ in {1..10}; do # 等待优雅退出,最多 5s
|
||||
[[ -z "$(listen_pids)" ]] && break
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ -n "$(listen_pids)" ]]; then
|
||||
echo "旧服务未响应退出信号,强制结束…"
|
||||
listen_pids | xargs kill -9 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
echo "端口 3300 已释放"
|
||||
fi
|
||||
|
||||
# ── 2) 重新 build 插件(产物在 extension/.output/chrome-mv3)──
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
echo "正在重新 build 插件…"
|
||||
(
|
||||
cd extension || exit 1
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
pnpm run build
|
||||
) || echo "⚠️ 插件 build 失败,后端照常启动(可稍后手动执行:cd extension && pnpm run build)"
|
||||
echo "提示:build 后需在 chrome://extensions 重新加载插件,并刷新已打开的商品页"
|
||||
else
|
||||
echo "⚠️ 未找到 pnpm,跳过插件 build"
|
||||
fi
|
||||
|
||||
if [[ ! -d server/.venv ]]; then
|
||||
echo "未找到 server/.venv,正在创建并安装依赖…"
|
||||
python3 -m venv server/.venv || exit 1
|
||||
fi
|
||||
# 每次启动同步依赖:代码新增依赖(如 Pillow)装上即可用,已满足时秒过
|
||||
server/.venv/bin/pip install -q -r server/requirements.txt || exit 1
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "未找到 .env,已从 .env.example 复制,请填入 API Key 后再启动。"
|
||||
cp .env.example .env
|
||||
echo "按回车关闭…"
|
||||
read -r
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "启动中:http://127.0.0.1:3300 (插件保持默认后端地址即可)"
|
||||
echo "按 Ctrl+C 可停止服务"
|
||||
echo
|
||||
|
||||
exec server/.venv/bin/python server/main.py
|
||||
Reference in New Issue
Block a user