v89-v91 模板增强 + 图源映射 + 多模态提示词可配置化

- 图源映射统一:热点采集与 Pinterest 模式均走 config.product.mark_dirs 配置,按任务序号随机抽模特图/平铺图
- 商品产地固定:统一为「中国大陆」+「产地省份=广东省」(不再读站点/字典映射)
- 模板 SKU 字段检测:按建议售价同一套路检测 SKU分类/SKU数量/SKU数量单位,必填时填入单品/1/件
- 多模态分析提示词可配置:prompts/pinterest_analyze_system.md + user.md,支持国家覆盖,不丢文件回退内置
- 自定义图片模式:新增 pinterest_custom_load_node,图片数量硬校验,选品清单 ≤ 有效图片数
- 模板导出优化:写入前按货号末 3 位升序排序,不再产生空白 xlsx
- 修复 v90 project review 10 项(503 致命终止、线程安全、原子写入等)
This commit is contained in:
2026-08-28 16:24:50 +08:00
parent 2a96ec0870
commit 71a48e4ed5
40 changed files with 780 additions and 200 deletions
+59 -5
View File
@@ -64,7 +64,38 @@ START → pinterest_init → pinterest_search → pinterest_scrape → pinterest
`scrape_concurrency`(爬图并发,必须=1)、`err400_limit`400 超限放弃当前种子词)。 `scrape_concurrency`(爬图并发,必须=1)、`err400_limit`400 超限放弃当前种子词)。
**每个节点都用 `graph/validate.with_fallback` 包裹**:任何未预料异常都被捕获、记入 **每个节点都用 `graph/validate.with_fallback` 包裹**:任何未预料异常都被捕获、记入
`state['errors']`、返回最小更新,整图继续往下走,单点故障不中断流水线。 `state['errors']`、返回最小更新,整图继续往下走,单点故障不中断流水线。致命图像服务错误(503 /
"No available compatible accounts")除外——`with_fallback` 不再静默吞掉,会同步标记流水线终止,
让路由短路到 `pinterest_finalize → template_export` 提前收尾合成模板。
### 模式 B-2:自定义图片模式(Custom)
无需采集/搜索图片,把用户本地图片文件夹里的**有效图片**直接送进多模态分析,沿用 Pinterest
后续所有步骤(分析 → 设计稿 → 三合一 → OSS → 种草图 → 模板导出)。
```
START → pinterest_init → pinterest_custom_load → pinterest_analyze
→ [pinterest_custom_route 循环:图池有未消费图 → 继续分析;图池空/简报达标 → 结束]
→ pinterest_finalize → template_export → END
```
| 节点 | 职责 |
|---|---|
| `pinterest_custom_load` | 读取 `pinterest.custom_image_dir` 文件夹 → 统计有效图片 → **数量硬校验** → 每次重建图池 + 清空已消费拉黑 |
**启用方式**(两种任选):
- 配置 `config.yaml``pinterest.mode: "custom"` 并填 `pinterest.custom_image_dir: "<文件夹路径>"`
- 或直接在 UI 选择「**自定义图片**」流程 →「📁 选图片文件夹…」选择文件夹后运行(自动写入配置)。
**数量硬校验(必过)**
- 必须先选定上传的文件夹(UI 未选会弹窗报错,任务不启动);
- 文件夹内所有有效图片(`jpg/jpeg/png/webp`,递归)数量必须 `> 0`
- **选品清单总数(`product.spu_tasks` 展开后)不得大于有效图片数**。不满足则在
UI 弹窗 / `run_pinterest_ref` 提前抛错 / `custom_load` 节点记录错误,三重拦截,任务不进入分析。
**行为差异**:自定义模式每次运行把图池重建为该文件夹的图片集(唯一图源)并清空已消费 md5 拉黑,
保证用户每次重新上传/选择的所有有效图片都会被重新多模态分析;分析批上限按「选品清单总数」截断,
避免对多余图片浪费配额。`pinterest_custom_route` 只在 wait / analyze / done 间流转,从不 search。
## 目录结构 ## 目录结构
@@ -82,6 +113,8 @@ pod_trend_agent/
├── prompts/<country>/ # ★ 每个国家不同的提示词单独文件夹 ├── prompts/<country>/ # ★ 每个国家不同的提示词单独文件夹
│ ├── system_prompt.md # 该国 LLM 系统提示(补充段,叠加到默认规则) │ ├── system_prompt.md # 该国 LLM 系统提示(补充段,叠加到默认规则)
│ └── aesthetics.yaml # 审美 hint + 风格-配色 extra 规则 + 额外黑名单 │ └── aesthetics.yaml # 审美 hint + 风格-配色 extra 规则 + 额外黑名单
├── prompts/pinterest_analyze_system.md # 多模态分析系统提示词(Pinterest/自定义模式),可配置
├── prompts/pinterest_analyze_user.md # 多模态分析用户提示词,可配置
├── graph/ ├── graph/
│ ├── state.py # AgentState(共享状态) │ ├── state.py # AgentState(共享状态)
│ ├── validate.py # with_fallback 兜底 + 数据校验 │ ├── validate.py # with_fallback 兜底 + 数据校验
@@ -134,6 +167,23 @@ pod_trend_agent/
每完成一个产品立即追加落盘到 `output/<country>/<ts>/products_pending.jsonl`(中断/崩溃也不丢已 每完成一个产品立即追加落盘到 `output/<country>/<ts>/products_pending.jsonl`(中断/崩溃也不丢已
完成产品),全部完成后统一合并写模板。 完成产品),全部完成后统一合并写模板。
## 多模态分析提示词(可配置)
Pinterest 参考模式与自定义图片模式的多模态分析提示词已**外部化为配置**,无需改代码即可调优:
- `prompts/pinterest_analyze_system.md`——多模态分析**系统提示词**(POD T 恤设计分析师角色 +
禁止抄袭/违禁项/输出 JSON 结构等规则);
- `prompts/pinterest_analyze_user.md`——**用户提示词**"Analyze the attached image and produce
one ORIGINAL T-shirt print design brief...")。
**加载优先级**`graph/llms/openai_compat_backend.py`):
1. `prompts/<国家>/pinterest_analyze_*.md`(国家专属覆盖);
2. `prompts/pinterest_analyze_*.md`(全局配置,先查运行根 exe 旁再查内置);
3. 内置默认常量(兜底,向前兼容)。
直接编辑这两份文件即可自定义分析与生图引导规则;`pinterest_analyze_system.md` 里可调整
`image_prompt` 二段式结构 / `negative_prompt` / `suitable_for_print` 判定标准等约束。
## 生成可靠性 / 错误处理 ## 生成可靠性 / 错误处理
- **致命 503(图像服务不可用,如 "No available compatible accounts"**:端到端识别 - **致命 503(图像服务不可用,如 "No available compatible accounts"**:端到端识别
@@ -145,8 +195,11 @@ pod_trend_agent/
- **yunfei / 标准 OpenAI 网关适配**`compose.execution_mode``background` 参数默认不再传入—— - **yunfei / 标准 OpenAI 网关适配**`compose.execution_mode``background` 参数默认不再传入——
yunfei 等网关不认识它们会返回空 body;`base_url` 需带 `/v1` 前缀(如 yunfei 等网关不认识它们会返回空 body;`base_url` 需带 `/v1` 前缀(如
`https://img.yunfei.best/v1`),否则请求错误路径得到空响应。 `https://img.yunfei.best/v1`),否则请求错误路径得到空响应。
- **模特分配**`material_library/<品类>` 内的合格模特图(3:4 比例过滤)按**任务序号**独立随机, - **图源映射(可配置)**`config.product.mark_dirs` 定义 SPU.mark → `material_library` 下的
同一 SPU 的多款不再共用同一张模特。每张种草图随机取不同模特特征。 `model_dir`(模特图)/ `flat_dir`(平铺图)两个图源文件夹及对应合成提示词。热点采集与 Pinterest
两种模式统一走该映射:在「有图的文件夹」间按**任务序号**独立随机抽图,抽到模特图用模特三图提示词
(图1模特+图2印花+图3底图),抽到平铺图用平铺三图提示词(图1平铺实拍+图2印花+图3底图)。
同一 SPU 的多款不再共用同一张图;某类目录无图则只用另一类。每张种草图随机取不同模特特征。
## 模特性别分组 ## 模特性别分组
@@ -180,7 +233,8 @@ pod_trend_agent/
- **胸围识别**:识别「基码表-胸围(cm)」「胸围全围(cm)」等含「胸围」的列,全部填 `sku.bust` 值; - **胸围识别**:识别「基码表-胸围(cm)」「胸围全围(cm)」等含「胸围」的列,全部填 `sku.bust` 值;
- **申报价格**:模糊匹配所有含「申报价格」的列,统一按加价后价格填写(`price × (1+markup_percent%)`); - **申报价格**:模糊匹配所有含「申报价格」的列,统一按加价后价格填写(`price × (1+markup_percent%)`);
- **款式来源**:SPU 行「款式来源」统一填「现货款」; - **款式来源**:SPU 行「款式来源」统一填「现货款」;
- **商品产地**国家简称映射正式名称(如「沙特」→「沙特阿拉伯」,`_COUNTRY_NAME_MAP` 可扩展); - **商品产地**所有国家统一填「中国大陆」;新增「产地省份」精确匹配列,统一填「广东省」(不再读站点/做字典映射);
- **SKU 分类 / SKU 数量 / SKU 数量单位**:按「建议售价」同一套路检测——关键词模糊匹配定位列,读取列头下一行备注判断是否必填(含「非必填」才跳过),检测到且为必填时统一填固定值:`SKU分类=单品``SKU数量=1``SKU数量单位=件`
- **详情图文**:由全部主图 + 种草图组成(不再拼接 img_url_2); - **详情图文**:由全部主图 + 种草图组成(不再拼接 img_url_2);
- 规格类型2 = `{size}*{color}`;币种=CNY;发货仓1~N 取模板顶头按「、」分隔,库存均 200; - 规格类型2 = `{size}*{color}`;币种=CNY;发货仓1~N 取模板顶头按「、」分隔,库存均 200;
- 模板文件被占用(打开中)时自动换名 `<SKU>_已填写_N.xlsx`,不中断导出; - 模板文件被占用(打开中)时自动换名 `<SKU>_已填写_N.xlsx`,不中断导出;
@@ -220,7 +274,7 @@ python ui_app.py
python ui_app.py --self-test python ui_app.py --self-test
``` ```
UI 功能:国家多选、**流程选择(热点采集 / Pinterest 参考模式)**、LLM 后端选择、种子上限、 UI 功能:国家多选、**流程选择(热点采集 / Pinterest 参考模式 / 自定义图片模式**、LLM 后端选择、种子上限、
**SPU/颜色选品**、运行(后台线程 + 实时日志)、结果表格(双击看完整提示词)、打开产物目录。 **SPU/颜色选品**、运行(后台线程 + 实时日志)、结果表格(双击看完整提示词)、打开产物目录。
## 打包 ## 打包
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+20 -3
View File
@@ -39,8 +39,13 @@ seed_provider_cfg:
# 按需搜索:每次只生成 1 个搜索词,用完(爬取成功)才标记已用;简报不足时循环再搜,直到满足 SPU 数量。 # 按需搜索:每次只生成 1 个搜索词,用完(爬取成功)才标记已用;简报不足时循环再搜,直到满足 SPU 数量。
pinterest: pinterest:
enabled: true enabled: true
mode: "scrape" # Pinterest 模式:scrape=在线爬图引用(原流程);custom=自定义本地图片文件夹直接送多模态分析
# custom 模式:无需采集/搜索,把 custom_image_dir 文件夹内所有有效图片直接进多模态分析,沿用 Pinterest 后续所有步骤
custom_image_dir: "" # 自定义模式图片文件夹路径(必须填写;文件夹内所有有效图片 jpg/jpeg/png/webp 都会进入分析)
# 数量校验:有效图片数必须 ≥ 选品清单(spu_tasks 展开后)总数,否则任务不启动
provider: openai # 搜索词/图片分析用 LLM 提供商(openai / mock provider: openai # 搜索词/图片分析用 LLM 提供商(openai / mock
search_mode: direct # 搜索词生成方式:direct=跳过LLM,直接搜「种子词 t-shirt design」;llm=LLM按需生成搜索词 search_mode: direct # 搜索词生成方式:direct=跳过LLM,直接搜「种子词+后缀」;llm=LLM按需生成搜索词
search_term_suffix: " t-shirt design" # Pinterest 搜索词自动追加的自定义后缀(可编辑;留空=不追加),保证 Pinterest 返回真正的印花图
search_terms_per_run: 1 # 每次搜索词数量(direct 模式=从种子池随机取 N 个直接拼后缀;llm 模式=每次生成 1 个) search_terms_per_run: 1 # 每次搜索词数量(direct 模式=从种子池随机取 N 个直接拼后缀;llm 模式=每次生成 1 个)
max_search_rounds: 0 # 搜索轮次上限(0=自动:目标 SPU 数×2,至少 5;防网络故障无限循环) max_search_rounds: 0 # 搜索轮次上限(0=自动:目标 SPU 数×2,至少 5;防网络故障无限循环)
seed_sample: 40 # 每次从国家种子池随机抽取多少个种子词给 LLM seed_sample: 40 # 每次从国家种子池随机抽取多少个种子词给 LLM
@@ -57,7 +62,7 @@ pinterest:
headless: false # 爬取时是否无头(false=显示 Chrome 窗口,首次需手动登录) headless: false # 爬取时是否无头(false=显示 Chrome 窗口,首次需手动登录)
proxy: "" # 图片下载代理(空=自动探测系统代理/VPN,默认走代理;配了如 http://127.0.0.1:7890 则用指定代理) proxy: "" # 图片下载代理(空=自动探测系统代理/VPN,默认走代理;配了如 http://127.0.0.1:7890 则用指定代理)
login_check: true # 爬取前静态检测 Pinterest 登录态(只读 .chrome_session cookies,不启动 Chrome;未登录则跳过本轮爬取并告警) login_check: true # 爬取前静态检测 Pinterest 登录态(只读 .chrome_session cookies,不启动 Chrome;未登录则跳过本轮爬取并告警)
login_wait: false # 运行时检测到未登录时:false=跳过并告警(不阻塞);true=弹出 Chrome 等待手动登录 login_wait: true # 运行时检测到未登录时:false=跳过并告警(不阻塞);true=弹出 Chrome 等待手动登录
# 跨源融合权重(按 source 标签,无需和为 1) # 跨源融合权重(按 source 标签,无需和为 1)
# 已下调 gt_trending(泛国家热点只作微弱信号),主力偏向 style+related(可印花型词)。 # 已下调 gt_trending(泛国家热点只作微弱信号),主力偏向 style+related(可印花型词)。
@@ -205,7 +210,19 @@ product:
db_path: "db/spu_sku.db" db_path: "db/spu_sku.db"
basemap_dir: "basemap" basemap_dir: "basemap"
material_library_dir: "material_library" material_library_dir: "material_library"
model_category: "T-shirt" # 模特目录优先品类;为空/无图时取 material_library 第一个有图的子目录;SPU.mark==1 才启用模特试穿 model_category: "T-shirt" # 备用模特目录优先品类;为空/无图时取 material_library 第一个有图的子目录;SPU.mark==1 才启用试穿/平铺合成
# 三方关系映射:SPU.mark → material_library 下的图文件夹 + 对应提示词。
# 每个 mark 可配置两个图源(模特图文件夹 / 平铺图文件夹),运行时在「有图的文件夹」间随机抽图:
# - 抽到 model_dir(模特图)→ 用 model_prompt(模特三图:图1模特+图2印花+图3底图)
# - 抽到 flat_dir(平铺图) → 用 flat_prompt(平铺三图:图1平铺实拍+图2印花+图3底图)
# model_prompt/flat_prompt 留空则用内置默认提示词(MODEL_WEAR_PROMPT / FLAT_LAY_PROMPT)。
# 该映射完全可配置、不写死在代码里。
mark_dirs:
"1":
model_dir: "模特图" # 模特图文件夹名(material_library 下)
flat_dir: "平铺图" # 平铺图文件夹名(material_library 下)
model_prompt: "" # 留空=内置模特三图提示词 MODEL_WEAR_PROMPT
flat_prompt: "" # 留空=内置平铺三图提示词 FLAT_LAY_PROMPT
brief_index: 0 # 用第几个 safe 简报的提示词生成(0=第一个) brief_index: 0 # 用第几个 safe 简报的提示词生成(0=第一个)
spu_code: "" # 款号(留空自动选第一个有本地底图的) spu_code: "" # 款号(留空自动选第一个有本地底图的)
sku_code: "" # 颜色编码(留空自动选该款第一个有底图的);多个颜色用逗号分隔(如 "DG015-VT01,DG015-DARK HEATHER" sku_code: "" # 颜色编码(留空自动选该款第一个有底图的);多个颜色用逗号分隔(如 "DG015-VT01,DG015-DARK HEATHER"
BIN
View File
Binary file not shown.
+89 -14
View File
@@ -117,18 +117,57 @@ def _pinterest_route(state: Dict[str, Any]) -> str:
return "search" return "search"
def build_pinterest_graph(): def _pinterest_custom_route(state: Dict[str, Any]) -> str:
"""自定义模式图池路由:只消耗本地图片池,从不搜索/采集。
简报池还有在途 → wait(等待后台消化);简报达标 → done;
简报池空闲 + 图池有未消费图片 → analyze;图池空 → done(自定义模式不采集)。
致命 503(图像服务不可用)→ 立即终止,直接收尾合成模板。
"""
pipe = state.get("pinterest_pipeline")
if pipe is not None and hasattr(pipe, "is_fatal_503_aborted") and pipe.is_fatal_503_aborted():
print("[pinterest_custom_route] ⛔ 图像服务 503 已终止任务,停止分析,直接收尾合成模板")
return "done"
target = int(state.get("pinterest_target") or 1) or 1
briefs = state.get("briefs") or []
if len(briefs) >= target:
print(f"[pinterest_custom_route] 简报已达目标 {len(briefs)}/{target},结束")
return "done"
if pipe is not None and hasattr(pipe, "pending_count") and pipe.pending_count() > 0:
return "wait"
try:
from graph.pinterest import load_image_pool, load_used_images, pool_unused_images
pool = load_image_pool(str(state.get("output_dir") or ""), state.get("country") or "")
used = load_used_images(str(state.get("output_dir") or ""), state.get("country") or "")
unused = pool_unused_images(pool, used)
except Exception: # noqa: BLE001
unused = []
if unused:
print(f"[pinterest_custom_route] 图池还有 {len(unused)} 张未消费图片,继续分析(简报 {len(briefs)}/{target}")
return "analyze"
print(f"[pinterest_custom_route] 图池已空,简报 {len(briefs)}/{target},结束(自定义模式不采集)")
return "done"
def build_pinterest_graph(custom_mode: bool = False):
"""Pinterest 参考模式图(按需搜索循环 + 简报池并发生成): """Pinterest 参考模式图(按需搜索循环 + 简报池并发生成):
pinterest_init(建简报池)→ pinterest_search → pinterest_scrape → pinterest_analyze pinterest_init(建简报池)→ pinterest_search → pinterest_scrape → pinterest_analyze
→ [pinterest_route] 简报池还有在途 → wait(等待后台消化)→ 回到路由; → [pinterest_route] 简报池还有在途 → wait(等待后台消化)→ 回到路由;
简报池空闲 + 图池有图 → analyze;图池空 + 简报不足 → search;达标 → pinterest_finalize 简报池空闲 + 图池有图 → analyze;图池空 + 简报不足 → search;达标 → pinterest_finalize
(排空简报池、后台并发生成 设计→三合一→OSS→种草图)→ template_export (排空简报池、后台并发生成 设计→三合一→OSS→种草图)→ template_export
custom_mode=True:自定义模式,不搜索不采集 ——
pinterest_init → pinterest_custom_load(本地文件夹校验+入库)→ pinterest_analyze
→ [pinterest_custom_route] wait / analyze / done(图池空或简报达标即结束,从不 search)。
其余下游流程(analyze→设计→三合一→OSS→种草图→模板)与普通 Pinterest 模式完全一致。
""" """
from graph.nodes import ( from graph.nodes import (
pinterest_analyze_node, pinterest_analyze_node,
pinterest_scrape_node, pinterest_scrape_node,
pinterest_search_node, pinterest_search_node,
) )
from graph.nodes import pinterest_custom_load_node
from graph.nodes.pinterest_finalize_node import pinterest_finalize_node from graph.nodes.pinterest_finalize_node import pinterest_finalize_node
from graph.nodes.pinterest_init_node import pinterest_init_node from graph.nodes.pinterest_init_node import pinterest_init_node
@@ -149,23 +188,34 @@ def build_pinterest_graph():
builder = StateGraph(AgentState) builder = StateGraph(AgentState)
builder.add_node("pinterest_init", pinterest_init_node) builder.add_node("pinterest_init", pinterest_init_node)
builder.add_node("pinterest_search", pinterest_search_node)
builder.add_node("pinterest_scrape", pinterest_scrape_node)
builder.add_node("pinterest_analyze", pinterest_analyze_node) builder.add_node("pinterest_analyze", pinterest_analyze_node)
builder.add_node("pinterest_wait", _pinterest_wait) builder.add_node("pinterest_wait", _pinterest_wait)
builder.add_node("pinterest_finalize", pinterest_finalize_node) builder.add_node("pinterest_finalize", pinterest_finalize_node)
builder.add_node("template_export", template_export_node) builder.add_node("template_export", template_export_node)
builder.add_edge("__start__", "pinterest_init") if custom_mode:
builder.add_edge("pinterest_init", "pinterest_search") builder.add_node("pinterest_custom_load", pinterest_custom_load_node)
builder.add_edge("pinterest_search", "pinterest_scrape") builder.add_edge("__start__", "pinterest_init")
builder.add_edge("pinterest_scrape", "pinterest_analyze") builder.add_edge("pinterest_init", "pinterest_custom_load")
builder.add_conditional_edges("pinterest_analyze", _pinterest_route, { builder.add_edge("pinterest_custom_load", "pinterest_analyze")
"analyze": "pinterest_analyze", # 简报池空闲 + 图池有未消费图片 → 补分析(不搜索) builder.add_conditional_edges("pinterest_analyze", _pinterest_custom_route, {
"search": "pinterest_search", # 简报池空闲 + 图池空 + 简报不足 → 新一轮搜索 "analyze": "pinterest_analyze",
"wait": "pinterest_wait", # 简报池还有在途 → 等待后台消化 "wait": "pinterest_wait",
"done": "pinterest_finalize", # 简报达标/轮次耗尽 → 收尾(排空简报池) "done": "pinterest_finalize",
}) })
else:
builder.add_node("pinterest_search", pinterest_search_node)
builder.add_node("pinterest_scrape", pinterest_scrape_node)
builder.add_edge("__start__", "pinterest_init")
builder.add_edge("pinterest_init", "pinterest_search")
builder.add_edge("pinterest_search", "pinterest_scrape")
builder.add_edge("pinterest_scrape", "pinterest_analyze")
builder.add_conditional_edges("pinterest_analyze", _pinterest_route, {
"analyze": "pinterest_analyze", # 简报池空闲 + 图池有未消费图片 → 补分析(不搜索)
"search": "pinterest_search", # 简报池空闲 + 图池空 + 简报不足 → 新一轮搜索
"wait": "pinterest_wait", # 简报池还有在途 → 等待后台消化
"done": "pinterest_finalize", # 简报达标/轮次耗尽 → 收尾(排空简报池)
})
builder.add_edge("pinterest_wait", "pinterest_analyze") builder.add_edge("pinterest_wait", "pinterest_analyze")
builder.add_edge("pinterest_finalize", "template_export") builder.add_edge("pinterest_finalize", "template_export")
builder.add_edge("template_export", END) builder.add_edge("template_export", END)
@@ -231,14 +281,39 @@ def run_pinterest_ref(
project_root: Path, project_root: Path,
output_root: Optional[Path] = None, output_root: Optional[Path] = None,
task_timestamp: Optional[str] = None, task_timestamp: Optional[str] = None,
custom_image_dir: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Pinterest 参考模式入口:独立于 Google Trends 的完整流程。 """Pinterest 参考模式入口:独立于 Google Trends 的完整流程。
种子词 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图 → LLM 分析图片 种子词 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图 → LLM 分析图片
→ 设计简报 → 设计稿 → 产品图 → 上传 → 种草图 → 模板导出。 → 设计简报 → 设计稿 → 产品图 → 上传 → 种草图 → 模板导出。
参数语义与 run_country 一致(project_root=数据根,output_root=产物根)。 参数语义与 run_country 一致(project_root=数据根,output_root=产物根)。
custom_image_dir 非空 或 config.pinterest.mode=="custom" → 进入自定义模式:
不搜索不采集,直接把指定文件夹的有效图片送多模态分析,沿用 Pinterest 后续所有步骤。
数量硬校验(有效图片数 ≥ 选品清单总数)在启动前执行,不满足直接抛错。
""" """
compiled = build_pinterest_graph() target = len((global_config.get("product") or {}).get("spu_tasks") or []) \
or int((global_config.get("product") or {}).get("spu_count") or 0) or 1
pinterest_cfg = global_config.get("pinterest") or {}
mode = str(pinterest_cfg.get("mode") or "scrape").strip().lower()
custom_dir = str(custom_image_dir or "").strip() \
or str(pinterest_cfg.get("custom_image_dir") or "").strip()
custom_mode = bool(custom_dir) or (mode == "custom")
if custom_mode:
if not custom_dir:
raise ValueError(
"自定义模式需要填写 pinterest.custom_image_dir(选择上传的图片文件夹),"
"当前为空,无法启动")
from graph.pinterest import validate_custom_images
ok, valid_n, msg = validate_custom_images(custom_dir, target)
if not ok:
raise ValueError(f"自定义模式数量校验未通过:{msg}(选品清单总数 {target}")
global_config.setdefault("pinterest", {})["custom_image_dir"] = custom_dir
print(f"[run_pinterest_ref] 自定义模式启用:{custom_dir} 有效图片 {valid_n} 张 ≥ 选品清单 {target}")
compiled = build_pinterest_graph(custom_mode=custom_mode)
cc = build_country_config(global_config, country, project_root) cc = build_country_config(global_config, country, project_root)
prompts_dir = project_root / "prompts" / country prompts_dir = project_root / "prompts" / country
cache_dir = (output_root or project_root) / "output" / country cache_dir = (output_root or project_root) / "output" / country
+6 -3
View File
@@ -152,19 +152,22 @@ class MockBackend:
seeds = [str(s).strip() for s in (context.get("seeds") or []) if str(s).strip()] seeds = [str(s).strip() for s in (context.get("seeds") or []) if str(s).strip()]
used = {str(u).strip().lower() for u in (context.get("used_terms") or [])} used = {str(u).strip().lower() for u in (context.get("used_terms") or [])}
count = int(context.get("count", 10)) count = int(context.get("count", 10))
suffix = str(context.get("search_term_suffix") or "").strip()
pool = [s for s in seeds if s.lower() not in used] pool = [s for s in seeds if s.lower() not in used]
random.shuffle(pool) random.shuffle(pool)
terms = pool[:count] terms = pool[:count]
# 不足时用「种子词 + 风格词」组合补足(视觉导向,避免与已用重复) # 不足时用「种子词 + 风格词」组合补足(视觉导向,避免与已用重复)
style_tail = ["t-shirt design", "graphic tee", "print art", "vintage tee", "flat design"] style_tail = ["graphic tee", "print art", "vintage tee", "flat design"]
i = 0 i = 0
while len(terms) < count and pool: while len(terms) < count and pool:
combo = f"{pool[i % len(pool)]} {style_tail[(i // len(pool)) % len(style_tail)]}" combo = f"{pool[i % len(pool)]} {style_tail[(i // len(pool)) % len(style_tail)]}"
if combo.lower() not in used and combo not in terms: if combo.lower() not in used and combo not in terms:
terms.append(combo) terms.append(combo)
i += 1 i += 1
# 自动追加 " t-shirt design":让 Pinterest 返回真正的 T 恤印花图(更适合作印花设计参考) # 自动追加自定义后缀(config.pinterest.search_term_suffix,默认 t-shirt design):
terms = [f"{t} t-shirt design" if "t-shirt design" not in t.lower() else t for t in terms] # 让 Pinterest 返回真正的 T 恤印花图(更适合作印花设计参考)
if suffix:
terms = [f"{t} {suffix}" if suffix not in t.lower() else t for t in terms]
return {"search_terms": terms} return {"search_terms": terms}
def analyze_pinterest_images(self, image_paths, term="", country="", on_400=None): def analyze_pinterest_images(self, image_paths, term="", country="", on_400=None):
+44 -10
View File
@@ -17,7 +17,7 @@ import requests
# 模型调用一律直连:用户常开 VPN(系统代理),LLM 网关多为国内/自建,走代理会被拦截或变慢。 # 模型调用一律直连:用户常开 VPN(系统代理),LLM 网关多为国内/自建,走代理会被拦截或变慢。
# 仅请求级 proxies=NO_PROXY 直连,不设置进程级 NO_PROXY 环境变量(避免影响 Google Trends 等外部采集)。 # 仅请求级 proxies=NO_PROXY 直连,不设置进程级 NO_PROXY 环境变量(避免影响 Google Trends 等外部采集)。
from .base import LLMBackend from .base import LLMBackend
from graph.paths import runtime_root from graph.paths import project_root, runtime_root
# 直连策略:忽略环境代理(用户挂 VPN 时代理会拦截国内/自建网关的请求) # 直连策略:忽略环境代理(用户挂 VPN 时代理会拦截国内/自建网关的请求)
NO_PROXY = {"http": None, "https": None} NO_PROXY = {"http": None, "https": None}
@@ -260,7 +260,7 @@ that are DIRECTLY usable as reference for a t-shirt print design.
RULES: RULES:
- Generate EXACTLY the requested number of search terms (usually 1 per call). - Generate EXACTLY the requested number of search terms (usually 1 per call).
- Every term MUST be a "t-shirt design" style query: think of it as if the user typed "<concept> t-shirt design" on - Every term MUST be a "__SUFFIX__" style query: think of it as if the user typed "<concept>__SUFFIX__" on
Pinterest, so the scraped images are actual t-shirt graphics / flat print artworks, NOT lifestyle photos, scenery, Pinterest, so the scraped images are actual t-shirt graphics / flat print artworks, NOT lifestyle photos, scenery,
architecture, food plates, or anything that cannot become a clean chest print. architecture, food plates, or anything that cannot become a clean chest print.
- Terms MUST be suitable for a SHORT-SLEEVE T-SHIRT PRINT: a flat, graphic, print-ready concept (illustration, mascot, - Terms MUST be suitable for a SHORT-SLEEVE T-SHIRT PRINT: a flat, graphic, print-ready concept (illustration, mascot,
@@ -309,7 +309,7 @@ def build_pinterest_term_user_prompt(context: Dict[str, Any]) -> str:
f"Generate {count} new, diverse, non-overlapping Pinterest search term(s) " f"Generate {count} new, diverse, non-overlapping Pinterest search term(s) "
f"that are suitable for a SHORT-SLEEVE T-SHIRT PRINT design " f"that are suitable for a SHORT-SLEEVE T-SHIRT PRINT design "
f"(flat, graphic, print-ready motif that works as a chest print). " f"(flat, graphic, print-ready motif that works as a chest print). "
f"Each term should read like \"<concept> t-shirt design\" so Pinterest returns " f"Each term should read like \"<concept>__SUFFIX__\" so Pinterest returns "
f"actual t-shirt graphics / flat print artwork as reference.", f"actual t-shirt graphics / flat print artwork as reference.",
] ]
return "\n".join(lines) return "\n".join(lines)
@@ -394,7 +394,35 @@ PINTEREST_ANALYZE_SCHEMA = {
} }
def build_pinterest_analyze_user_prompt() -> str: def _pinterest_analyze_prompt_file(country: str, filename: str) -> str:
"""定位多模态分析提示词文件:优先运行根 prompts(exe 旁,可编辑),回退数据根 prompts。
支持国家覆盖(prompts/<country>/<filename>)优先于全局(prompts/<filename>)。
找不到则返回空串,由调用方回退内置默认。
"""
for base in (runtime_root(), project_root()):
if country:
p = base / "prompts" / country / filename
if p.exists():
return p.read_text(encoding="utf-8").strip()
p = base / "prompts" / filename
if p.exists():
return p.read_text(encoding="utf-8").strip()
return ""
def resolve_pinterest_analyze_system_prompt(country: str = "") -> str:
"""多模态分析系统提示词(可配置):命中 prompts/pinterest_analyze_system.md(国家覆盖优先),
否则回退内置 PINTEREST_ANALYZE_SYSTEM_PROMPT。"""
text = _pinterest_analyze_prompt_file(country, "pinterest_analyze_system.md")
return text if text else PINTEREST_ANALYZE_SYSTEM_PROMPT
def build_pinterest_analyze_user_prompt(country: str = "") -> str:
"""多模态分析用户提示词(可配置):命中 prompts/pinterest_analyze_user.md 否则内置默认。"""
text = _pinterest_analyze_prompt_file(country, "pinterest_analyze_user.md")
if text:
return text
return ( return (
"Analyze the attached image and produce one ORIGINAL T-shirt print design brief " "Analyze the attached image and produce one ORIGINAL T-shirt print design brief "
"that captures its visual vibe without copying it." "that captures its visual vibe without copying it."
@@ -583,15 +611,21 @@ class OpenAICompatBackend(LLMBackend):
max_used = int((cfg or {}).get("max_used_terms_in_prompt", 100) or 100) max_used = int((cfg or {}).get("max_used_terms_in_prompt", 100) or 100)
if max_used > 0: if max_used > 0:
ctx["used_terms"] = used[-max_used:] ctx["used_terms"] = used[-max_used:]
# 后缀占位符:读 ctx 配置(允许为空=不追加后缀);仅在确有后缀时替换占位
suffix = str(ctx.get("search_term_suffix") or "").strip()
sys_prompt = PINTEREST_TERM_SYSTEM_PROMPT.replace("__SUFFIX__", f" {suffix}" if suffix else "")
user_prompt = build_pinterest_term_user_prompt(ctx).replace("__SUFFIX__", f" {suffix}" if suffix else "")
messages = [ messages = [
{"role": "system", "content": PINTEREST_TERM_SYSTEM_PROMPT}, {"role": "system", "content": sys_prompt},
{"role": "user", "content": build_pinterest_term_user_prompt(ctx)}, {"role": "user", "content": user_prompt},
] ]
raw = _retry(lambda: call_openai_compatible_structured(cfg, messages, PINTEREST_TERM_SCHEMA, timeout=120)) raw = _retry(lambda: call_openai_compatible_structured(cfg, messages, PINTEREST_TERM_SCHEMA, timeout=120))
parsed = _extract_json(raw) parsed = _extract_json(raw)
terms = [str(x).strip() for x in (parsed.get("search_terms", []) or []) if str(x).strip()] terms = [str(x).strip() for x in (parsed.get("search_terms", []) or []) if str(x).strip()]
# 自动追加 " t-shirt design":让 Pinterest 返回真正的 T 恤印花图(更适合作印花设计参考) # 自动追加自定义后缀(config.pinterest.search_term_suffix,默认 t-shirt design):
terms = [f"{t} t-shirt design" if "t-shirt design" not in t.lower() else t for t in terms] # 让 Pinterest 返回真正的印花图(更适合作印花设计参考)
if suffix:
terms = [f"{t} {suffix}" if suffix not in t.lower() else t for t in terms]
return {"search_terms": terms} return {"search_terms": terms}
def analyze_pinterest_images(self, image_paths: List[str], term: str, country: str = "", def analyze_pinterest_images(self, image_paths: List[str], term: str, country: str = "",
@@ -650,13 +684,13 @@ class OpenAICompatBackend(LLMBackend):
def _call() -> str: def _call() -> str:
user_content: List[Any] = [ user_content: List[Any] = [
{"type": "text", "text": build_pinterest_analyze_user_prompt()}, {"type": "text", "text": build_pinterest_analyze_user_prompt(country)},
] ]
user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris] user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris]
payload = { payload = {
"model": model, "model": model,
"messages": [ "messages": [
{"role": "system", "content": PINTEREST_ANALYZE_SYSTEM_PROMPT}, {"role": "system", "content": resolve_pinterest_analyze_system_prompt(country)},
{"role": "user", "content": user_content}, {"role": "user", "content": user_content},
], ],
"temperature": 0.5, "temperature": 0.5,
+2
View File
@@ -4,6 +4,7 @@ from .fetch_node import fetch_node
from .filter_node import filter_node from .filter_node import filter_node
from .oss_upload_node import oss_upload_node from .oss_upload_node import oss_upload_node
from .pinterest_analyze_node import pinterest_analyze_node from .pinterest_analyze_node import pinterest_analyze_node
from .pinterest_custom_load_node import pinterest_custom_load_node
from .pinterest_scrape_node import pinterest_scrape_node from .pinterest_scrape_node import pinterest_scrape_node
from .pinterest_search_node import pinterest_search_node from .pinterest_search_node import pinterest_search_node
from .product_node import product_node from .product_node import product_node
@@ -29,4 +30,5 @@ __all__ = [
"pinterest_search_node", "pinterest_search_node",
"pinterest_scrape_node", "pinterest_scrape_node",
"pinterest_analyze_node", "pinterest_analyze_node",
"pinterest_custom_load_node",
] ]
+5
View File
@@ -114,6 +114,11 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
if batch_size <= 0: if batch_size <= 0:
batch_size = max_designs # 自动:一次最多分析 max_designs 张(每张图→1条简报) batch_size = max_designs # 自动:一次最多分析 max_designs 张(每张图→1条简报)
need = batch_size need = batch_size
# 自定义模式:本地图池是唯一且有限的图源,最多分析「选品清单总数」张即可,
# 避免多余分析(超出的图源在简报达标后由自定义路由结束,不浪费配额)。
if bool(state.get("custom_mode")):
_t = int(state.get("pinterest_target") or 1) or 1
need = max(0, min(need, _t))
# 1) 图池取未消费图片(md5 不在 used_images);无 → 返回空,路由触发搜索 # 1) 图池取未消费图片(md5 不在 used_images);无 → 返回空,路由触发搜索
pool = load_image_pool(output_dir, country) pool = load_image_pool(output_dir, country)
+82
View File
@@ -0,0 +1,82 @@
"""Pinterest 参考模式自定义节点:加载本地图片文件夹 → 校验数量 → 注册进图池(pinterest_custom_load)。
仅自定义模式(pinterest.mode=custom)使用,替代 pinterest_search + pinterest_scrape
直接把 pinterest.custom_image_dir 内的有效图片(jpg/jpeg/png/webp)注册进图池,
再复用 pinterest_analyze 直接送多模态分析,沿用 Pinterest 后续所有步骤
(分析→设计→三合一→OSS→种草图→模板导出)。
数量校验(硬校验,不满足则不启动分析):
1. 文件夹必须有有效图片(>0);
2. 选品清单总数(product.spu_tasks 展开后,state.pinterest_target)必须 ≤ 有效图片数 ——
「选品清单不得大于有效图片数」。
每次运行把图池重建为该文件夹的图片集(自定义模式唯一图源),并清空已消费拉黑
used_images),保证用户每次重新上传/选择文件夹的所有有效图片都会被重新多模态分析。
"""
from typing import Any, Dict
from graph.pinterest import (
image_md5,
list_valid_images,
save_image_pool,
save_used_images,
)
from graph.validate import with_fallback
@with_fallback("pinterest_custom_load")
def pinterest_custom_load_node(state: Dict[str, Any]) -> Dict[str, Any]:
config = state["config"]
output_dir = state["output_dir"]
country = state["country"]
pcfg = config.get("pinterest") or {}
folder = str(pcfg.get("custom_image_dir") or "").strip()
images = list_valid_images(folder)
valid_n = len(images)
target = int(state.get("pinterest_target") or 1)
errors = list(state.get("errors") or [])
stats = dict(state.get("stats") or {})
# —— 硬校验 1:必须有有效图片 ——
if valid_n == 0:
msg = (f"自定义图片文件夹「{folder or '(未填写)'}」中没有有效图片"
f"(请填写 pinterest.custom_image_dir,文件夹内应有 jpg/jpeg/png/webp 图片)")
errors.append({"node": "pinterest_custom_load", "type": "ValidationError",
"message": msg, "trace": ""})
stats["pinterest_custom"] = {"folder": folder, "valid_images": 0, "target": target, "ok": False}
print(f"[pinterest_custom_load] ❌ {msg}")
return {"errors": errors, "stats": stats}
# —— 硬校验 2:选品清单总数 ≤ 有效图片数 ——
if target > valid_n:
msg = (f"选品清单数量({target})大于自定义图片有效数量({valid_n}):"
f"选品清单不得大于有效图片数,请补充图片或减少选品")
errors.append({"node": "pinterest_custom_load", "type": "ValidationError",
"message": msg, "trace": ""})
stats["pinterest_custom"] = {"folder": folder, "valid_images": valid_n, "target": target, "ok": False}
print(f"[pinterest_custom_load] ❌ {msg}")
return {"errors": errors, "stats": stats}
# —— 每次运行重建图池为该文件夹图片集 + 清空已消费拉黑 → 所有有效图片都被重新分析 ——
pool = {"updated_at": "", "images": []}
seen_md5: set = set()
for f in images:
m = str(image_md5(str(f)) or "").strip().lower()
if not m or m in seen_md5:
continue
seen_md5.add(m)
pool["images"].append({"path": str(f), "md5": m, "term": "custom"})
save_image_pool(output_dir, country, pool)
save_used_images(output_dir, country, set())
stats["pinterest_custom"] = {
"folder": folder, "valid_images": valid_n,
"target": target, "loaded": len(pool["images"]), "ok": True,
}
print(f"[pinterest_custom_load] 自定义图源:{folder} → 有效图片 {valid_n} 张(选品清单 {target}),"
f"已注册进图池,直接进入多模态分析")
return {"custom_mode": True, "pinterest_custom_folder": folder,
"custom_load_ok": True, "errors": errors, "stats": stats}
+7 -4
View File
@@ -33,6 +33,8 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
provider = str(pcfg.get("provider") or "openai").strip().lower() provider = str(pcfg.get("provider") or "openai").strip().lower()
search_mode = str(pcfg.get("search_mode") or "direct").strip().lower() search_mode = str(pcfg.get("search_mode") or "direct").strip().lower()
_raw_suffix = str(pcfg.get("search_term_suffix") if pcfg.get("search_term_suffix") is not None else " t-shirt design").strip()
suffix = _raw_suffix if _raw_suffix else "" # 配置留空则后缀为空(不追加),不再回退默认
want = int(pcfg.get("search_terms_per_run", 1)) # 每次搜索词数量 want = int(pcfg.get("search_terms_per_run", 1)) # 每次搜索词数量
seed_sample = int(pcfg.get("seed_sample", 40)) seed_sample = int(pcfg.get("seed_sample", 40))
max_used_in_prompt = int(pcfg.get("max_used_terms_in_prompt", 100)) max_used_in_prompt = int(pcfg.get("max_used_terms_in_prompt", 100))
@@ -57,9 +59,9 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
fresh = [s for s in pool if s.lower() not in used_set] fresh = [s for s in pool if s.lower() not in used_set]
if not fresh: if not fresh:
fresh = pool # 库内词全部用过 → 允许复用(词库有限) fresh = pool # 库内词全部用过 → 允许复用(词库有限)
terms = [f"{s} t-shirt design" if "t-shirt design" not in s.lower() else s terms = [f"{s} {suffix}".strip() if suffix and suffix not in s.lower() else s
for s in random.sample(fresh, min(want, len(fresh)))] for s in random.sample(fresh, min(want, len(fresh)))]
print(f"[pinterest_search] direct 模式:国家种子词库随机抽 {len(terms)} 个 + t-shirt design{country}") print(f"[pinterest_search] direct 模式:国家种子词库随机抽 {len(terms)} 个 + 后缀「{suffix}{country}")
else: else:
used_llm = merge_used(used, attempted) used_llm = merge_used(used, attempted)
if max_used_in_prompt > 0: if max_used_in_prompt > 0:
@@ -79,7 +81,8 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
if llm is not None and hasattr(llm, "generate_pinterest_terms"): if llm is not None and hasattr(llm, "generate_pinterest_terms"):
try: try:
ctx = {"country": country, "seeds": seeds, "used_terms": used_llm, "count": want} ctx = {"country": country, "seeds": seeds, "used_terms": used_llm, "count": want,
"search_term_suffix": suffix}
res = llm.generate_pinterest_terms(ctx) res = llm.generate_pinterest_terms(ctx)
terms = [str(t).strip() for t in (res.get("search_terms") or []) if str(t).strip()] terms = [str(t).strip() for t in (res.get("search_terms") or []) if str(t).strip()]
print(f"[pinterest_search] LLM 生成搜索词 {len(terms)} 个({country},已用词注入 {len(used_llm)}") print(f"[pinterest_search] LLM 生成搜索词 {len(terms)} 个({country},已用词注入 {len(used_llm)}")
@@ -89,7 +92,7 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
# 3) 兜底:LLM 无结果 → 种子词池随机抽样 # 3) 兜底:LLM 无结果 → 种子词池随机抽样
if not terms: if not terms:
terms = [f"{s} t-shirt design" if "t-shirt design" not in s.lower() else s terms = [f"{s} {suffix}".strip() if suffix and suffix not in s.lower() else s
for s in random.sample(seeds, min(want, len(seeds)))] for s in random.sample(seeds, min(want, len(seeds)))]
print(f"[pinterest_search] 兜底:从种子词池取 {len(terms)}") print(f"[pinterest_search] 兜底:从种子词池取 {len(terms)}")
+86 -32
View File
@@ -83,6 +83,49 @@ MODEL_WEAR_PROMPT = (
"图1原本的背景、人物、构图及光影结构100%不变,仅替换图1衣服上的印花图案与衣服底色。" "图1原本的背景、人物、构图及光影结构100%不变,仅替换图1衣服上的印花图案与衣服底色。"
) )
# 平铺图专属三图合成提示词(mark 配置 flat_dir 平铺图文件夹):图1平铺实拍 + 图2印花 + 图3底图
FLAT_LAY_PROMPT = (
"你是一个专业的电商AI视觉合成工具,执行“高保真印花与色彩移植/印花替换”:把图2的印花设计"
"印到图3底色的面料上,替换图1平铺衣服原有的底色与图案,输出一张“图3底色+图2印花”的平铺服装商品图。"
"全程无人参与。\n"
"【图片角色,按提交顺序】\n"
"图1=衣服平铺实拍图(基底图:提供衣服版型、轮廓、褶皱、光影、拍摄背景与构图,"
"最终输出必须与图1同角度、同摆放);\n"
"图2=纯印花设计稿;\n"
"图3=平铺衣服底图(只取衣服本身的底色与面料材质,忽略平铺图背景/桌面/场景,只保留面料颜色与质感)。\n"
"TASK: 把图2的印花设计印到图3底色的面料上,替换图1平铺衣服原有的底色与图案,"
"输出一张“图3底色+图2印花”的平铺服装商品图。全程无人参与。\n"
"【执行规则】\n"
"0.禁止人物:输出图中严禁出现任何人体、模特、人台/假人、头颈、手臂或“穿着效果”,"
"必须保持图1的纯平铺俯拍商品图形式,衣服平放在原背景上。\n"
"1.底色锁定:从图3提取衣服底色与面料,最终合成中必须100%保持不变,严禁偏色。\n"
"2.印花提取:从图2精准提取纯印花图案(线条/色号/比例),叠加到图3底色上形成合成面料。\n"
"3.印花尺寸适配:印花整体尺寸与衣服面料面积成合理比例,居中印在胸/背/衣身主体区域,"
"占衣身面积约30%-45%,四周留白,严禁过大撑满整件或过小(低于20%)。\n"
"4.主体遮罩:识别图1中衣服本体的完整区域(领口到下摆、含袖子;忽略背景/桌面/无关物品),"
"用合成面料完整覆盖,彻底清除原衣服的颜色与图案;图1中衣服以外的物品保持原样。\n"
"5.精准贴合:合成面料严格跟随图1衣服的平铺形态,领口/袖口/下摆/侧缝等版型结构清晰保留,"
"褶皱/翻折/堆叠处印花随之自然变形,杜绝“贴纸感”与“平面涂色感”。\n"
"6.光影融合:按图1拍摄光线方向调整亮度/对比度,印花随褶皱产生明暗变化但色号不偏移;"
"衣服投影与图1保持一致。\n"
"7.纯净输出:仅输出一张最终合成平铺图;图1的背景/桌面/构图/角度/光影100%不变,"
"仅替换衣服的底色与印花;画面中不得出现任何人像、肢体或人台。"
)
def _active_prompt(kind: str, prompts: Optional[Dict] = None) -> str:
"""返回当前图源应使用的合成提示词。
kind="model" → 模特三图提示词(配置覆盖优先生效,否则内置 MODEL_WEAR_PROMPT);
kind="flat" → 平铺三图提示词(配置覆盖优先生效,否则内置 FLAT_LAY_PROMPT)。
"""
cfgs = prompts or {}
if kind == "flat":
return (str(cfgs.get("flat_prompt") or "").strip()
or FLAT_LAY_PROMPT)
return (str(cfgs.get("model_prompt") or "").strip()
or MODEL_WEAR_PROMPT)
def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]: def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]:
"""选定 SKU:显式指定优先;否则第一个有本地底图的;再无则第一个颜色(便于模板导出)。""" """选定 SKU:显式指定优先;否则第一个有本地底图的;再无则第一个颜色(便于模板导出)。"""
@@ -144,13 +187,15 @@ def _process_spu(
db_path, basemap_root, material_root, category, prod_dir, brief, ib, db_path, basemap_root, material_root, category, prod_dir, brief, ib,
spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="", spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048", img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048",
on_503=None, on_503=None, model_kind="model", prompts=None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。 """处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。 shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹 img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
(按货号命名,包含该货号对应的所有图片)。 (按货号命名,包含该货号对应的所有图片)。
on_503: 致命图像服务错误(503/账户不可用)回调(供调用方提前终止任务)。 on_503: 致命图像服务错误(503/账户不可用)回调(供调用方提前终止任务)。
model_kind: 图源类型 "model"(模特图)/ "flat"(平铺图),决定用哪个合成提示词。
prompts: {"model": str, "flat": str} 可配置提示词覆盖(来自 config.product.mark_dirs);缺省用内置。
返回 result dict;内部异常已兜底,不中断。 返回 result dict;内部异常已兜底,不中断。
""" """
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里 # 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
@@ -253,40 +298,43 @@ def _process_spu(
# 6) 模板选择按 SPU.mark 决定: # 6) 模板选择按 SPU.mark 决定:
# mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图) # mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图)
# mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计) # mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计)
# mark==1 统一只做三合一:无模特图时跳过合成,不再回退两图合成(印花+底图) # mark==1 统一只做三合一:无图(模特/平铺)时跳过合成,不再回退两图合成(印花+底图)
if int(spu.get("mark") or 0) == 1: if int(spu.get("mark") or 0) == 1:
print(f"{tag} SPU {spu['code']} mark=1 → 使用三图合成模板(图1模特+图2印花+图3底图)") kind_label = "平铺" if model_kind == "flat" else "模特"
print(f"{tag} SPU {spu['code']} mark=1,图源={kind_label} → 使用三图合成模板(图1{kind_label}+图2印花+图3底图)")
if model_img is not None: if model_img is not None:
# 任务级模特分配(product_node 预分配:一个 SPU 一个模特,SPU 数>模特数循环兜底 # 任务级图源分配(pipeline 预分配:mark=1 在有图的模特/平铺文件夹间随机抽
model_copy = prod_dir / f"{img_code}_model{model_img.suffix}" model_copy = prod_dir / f"{img_code}_{model_kind}{model_img.suffix}"
shutil.copy2(model_img, model_copy) shutil.copy2(model_img, model_copy)
result["model_path"] = str(model_copy) result["model_path"] = str(model_copy)
result["model_folder"] = model_img.parent.name result["model_folder"] = model_img.parent.name
print(f"{tag} 模特图(任务级分配,{model_img.parent.name}/: {model_copy}") result["model_kind"] = model_kind
print(f"{tag} {kind_label}图(任务级分配,{model_img.parent.name}/: {model_copy}")
else: else:
print(f"{tag} material_library 无模特图,mark=1 统一只做三合一,跳过合成") print(f"{tag} material_library 无{kind_label}图,mark=1 统一只做三合一,跳过合成")
else: else:
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)") print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
# 7) 合成: # 7) 合成:
# 有模特图 → 三图合成(图1=模特 / 图2=印花设计 / 图3=底图) # mark=1 有图(模特/平铺)→ 三图合成(图1=模特或平铺 / 图2=印花设计 / 图3=底图)
# mark=1 无模特图 → 跳过合成(统一只做三合一,不做印花+底图两图合成) # mark=1 无图 → 跳过合成(统一只做三合一,不做印花+底图两图合成)
# mark!=1 无模特图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计) # mark!=1 无图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计)
if "design_path" not in result: if "design_path" not in result:
print(f"{tag} 无设计稿,跳过合成") print(f"{tag} 无设计稿,跳过合成")
elif model_img is not None: elif model_img is not None:
composite_path = str(prod_dir / f"{img_code}_composite.png") composite_path = str(prod_dir / f"{img_code}_composite.png")
try: try:
# 三图合成:优先用简报的 composite_prompt(模板化三图文案),回退内置 MODEL_WEAR_PROMPT # 三图合成:按图源类型选提示词(可配置覆盖优先,否则内置 MODEL_WEAR/FLAT_LAY
wear_prompt = (brief.get("composite_prompt") or "").strip() or MODEL_WEAR_PROMPT wear_prompt = _active_prompt(model_kind, prompts)
print(f"{tag} 三图合成提交中(3 参考图 img2img,网关处理约 2-6 分钟,请耐心等待)…") kind_label = "平铺" if model_kind == "flat" else "模特"
print(f"{tag} {kind_label}三图合成提交中(3 参考图 img2img,网关处理约 2-6 分钟,请耐心等待)…")
t0 = time.time() t0 = time.time()
ib.print(wear_prompt, str(model_img), composite_path, ib.print(wear_prompt, str(model_img), composite_path,
brief.get("composite_negative", ""), brief.get("composite_negative", ""),
extra_images=[design_path, str(basemap_img)], # 图2印花, 图3底图 extra_images=[design_path, str(basemap_img)], # 图2印花, 图3底图
size=compose_size) # 合成图尺寸按 config compose.size size=compose_size) # 合成图尺寸按 config compose.size
result["composite_path"] = composite_path result["composite_path"] = composite_path
print(f"{tag} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s: {composite_path}") print(f"{tag} {kind_label}三图合成图已生成(耗时 {int(time.time()-t0)}s: {composite_path}")
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
# 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次) # 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次)
if _fatal(e): if _fatal(e):
@@ -359,7 +407,7 @@ def _process_spu(
continue continue
cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png") cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png")
try: try:
ib.print(MODEL_WEAR_PROMPT, str(model_img), cp, ib.print(_active_prompt(model_kind, prompts), str(model_img), cp,
brief.get("composite_negative", ""), brief.get("composite_negative", ""),
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图 extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
size=compose_size) # 合成图尺寸按 config compose.size size=compose_size) # 合成图尺寸按 config compose.size
@@ -493,25 +541,28 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
prod_dir = output_dir / "product" prod_dir = output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True) prod_dir.mkdir(parents=True, exist_ok=True)
# 4.1) 任务级模特分配(按 spu.mark 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽取): # 4.1) 任务级图源分配(与 Pinterest 模式一致):按 config.product.mark_dirs 可配置映射,
# 每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复 # 在「模特图/平铺图」两个有图的文件夹间随机抽图,每张携带 kind(model/flat
# → product_node 用对应提示词合成(模特三图 / 平铺三图)
model_assign: Dict[int, Any] = {} model_assign: Dict[int, Any] = {}
mark_dirs = pcfg.get("mark_dirs") or {}
prompts_cfg = (mark_dirs.get("1") or {})
try: try:
from graph.product import build_mark_model_map, find_model_images_for_mark from graph.product import build_mark_sources
mark_map = build_mark_model_map(db_path, material_root) sources = build_mark_sources(material_root, mark_dirs, category)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
mark_map = {} sources = {"model": [], "flat": []}
for _i, (_spu, _skus, _tb) in enumerate(worklist): source_pool = []
mark = str(_spu.get("mark") or "").strip() or "1" for kind in ("model", "flat"):
folder = mark_map.get(mark, category) for p in sources.get(kind) or []:
try: source_pool.append((p, kind))
pool_imgs = find_model_images_for_mark(db_path, material_root, mark, folder) if source_pool:
except Exception: # noqa: BLE001 for _i in range(len(worklist)):
pool_imgs = [] img, kind = random.choice(source_pool) # 每任务随机抽一张(含 kind
if pool_imgs: model_assign[_i] = {"img": img, "kind": kind, "prompts": prompts_cfg}
model_assign[_i] = random.choice(pool_imgs) # 过滤后随机抽(按任务序号) print(f"[product] 任务级图源分配:{len(model_assign)} 个产品任务(mark_dirs 模特/平铺图随机抽,含 kind)")
if model_assign: else:
print(f"[product] 任务级模特分配:{len(model_assign)} 个产品任务(按 mark 过滤 3:4 后随机抽取)") print("[product] material_library 无任何可用图(模特/平铺均无)→ 跳过合成,仅导出模板")
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path), # 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个 # 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
@@ -571,10 +622,13 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
design_path = design_src design_path = design_src
tb = dict(tb) tb = dict(tb)
tb["design_path"] = design_path tb["design_path"] = design_path
_src = model_assign.get(wi) or {}
r = _process_spu(db_path, basemap_root, material_root, category, prod_dir, r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
tb, ib, spu, skus, pcfg, _safe_errors, design_path, title_backend, tb, ib, spu, skus, pcfg, _safe_errors, design_path, title_backend,
country, img_code=img_code, country, img_code=img_code,
model_img=model_assign.get(wi), # 按任务序号取独立随机模特 model_img=_src.get("img"), # 按任务序号取独立随机图源
model_kind=_src.get("kind", "model"),
prompts=_src.get("prompts"),
design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"), design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"),
compose_size=str((config.get("compose") or {}).get("size") or "1536x2048")) compose_size=str((config.get("compose") or {}).get("size") or "1536x2048"))
if r: if r:
+36
View File
@@ -179,6 +179,42 @@ def design_md5_ok(image_path: str) -> bool:
_IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp") _IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp")
def list_valid_images(folder: Any) -> List[Path]:
"""返回文件夹内所有有效图片文件(扩展名匹配,含子目录递归),用于自定义模式。
供图源目录/自定义模式统计「有效图片数量」与注册图池使用;空/不存在返回空列表。
"""
d = Path(folder)
if not d.is_dir():
return []
out: List[Path] = []
for f in sorted(d.rglob("*")):
if f.is_file() and f.suffix.lower() in _IMG_EXTS:
out.append(f)
return out
def validate_custom_images(folder: Any, target: int) -> tuple:
"""自定义模式数量校验:返回 (ok, valid_n, message)。
folder 未填/不存在/无有效图片 → 失败;有效图片数 < 选品清单总数 target → 失败
(「选品清单不得大于有效图片数」硬校验)。供 UI/入口 fail-fast 与节点安全网复用。
"""
folder_s = str(folder or "").strip()
if not folder_s:
return False, 0, "自定义图片文件夹未填写(pinterest.custom_image_dir / UI 选择上传文件夹)"
if not Path(folder_s).is_dir():
return False, 0, f"自定义图片文件夹不存在:{folder_s}"
imgs = list_valid_images(folder_s)
n = len(imgs)
if n == 0:
return False, 0, f"自定义图片文件夹「{folder_s}」中没有有效图片(jpg/jpeg/png/webp"
if target > n:
return False, n, (f"选品清单数量({target})大于自定义图片有效数量({n}):"
f"选品清单不得大于有效图片数,请补充图片或减少选品")
return True, n, f"自定义图片校验通过:有效图片 {n} 张 ≥ 选品清单 {target}"
def image_pool_path(output_dir: str, country: str) -> Path: def image_pool_path(output_dir: str, country: str) -> Path:
return Path(output_dir) / "pinterest_ref" / country / "image_pool.json" return Path(output_dir) / "pinterest_ref" / country / "image_pool.json"
+68 -21
View File
@@ -199,29 +199,50 @@ class PinterestPipeline:
return None return None
def _assign_models(self) -> Dict[str, Any]: def _assign_models(self) -> Dict[str, Any]:
"""任务级模特分配:按 spu.mark 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽 """任务级图源分配:按每个任务 spu.mark 从可配置的「模特图/平铺图」文件夹间随机抽
每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复。 每个任务独立随机抽一张:先合并该 mark 对应「有图的」模特/平铺目录的全部合格图,再从其中随机抽一张,
抽到哪个文件夹的图就返回对应 kindmodel/flat),供 product_node 用对应提示词合成。
某类目录无图则只用另一类;两类都无图则该任务无图源(跳过合成)。
返回 {task_key: {"img": Path, "kind": "model"|"flat", "prompts": {…}}}。
""" """
model_assign: Dict[str, Any] = {} import random as _random
assign: Dict[str, Any] = {}
pcfg = self.config.get("product") or {}
mark_dirs = pcfg.get("mark_dirs") or {}
try: try:
from graph.product import build_mark_model_map, find_model_images_for_mark from graph.product import build_mark_sources
mark_map = build_mark_model_map(self._db_path, self._material_root) except Exception as e: # noqa: BLE001
except Exception: # noqa: BLE001 print(f"[pinterest_pipeline] 图源映射导入失败: {e}")
mark_map = {} return assign
# 每个任务按序号绑定独立随机模特(同 SPU 多款也各自随机,不共用) # 每个出现过的 mark 各建一个图源池,避免多 mark 错配
pool_by_mark: Dict[str, list] = {}
for _i, (spu, _skus) in enumerate(self._worklist):
mark = str(spu.get("mark") or "").strip() or "1"
if mark in pool_by_mark:
continue
try:
sources = build_mark_sources(self._material_root, mark_dirs, self._category, mark=mark)
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] mark={mark} 图源构建失败: {e}")
sources = {"model": [], "flat": []}
pool = []
for kind in ("model", "flat"):
for p in sources.get(kind) or []:
pool.append((p, kind))
pool_by_mark[mark] = pool
if pool:
print(f"[pinterest_pipeline] mark={mark} 图源池:{len(pool)} 张(模特/平铺)")
for _i, (spu, _skus) in enumerate(self._worklist): for _i, (spu, _skus) in enumerate(self._worklist):
key = f"task_{_i}" key = f"task_{_i}"
mark = str(spu.get("mark") or "").strip() or "1" mark = str(spu.get("mark") or "").strip() or "1"
folder = mark_map.get(mark, self._category) pool = pool_by_mark.get(mark) or []
try: if not pool:
pool_imgs = find_model_images_for_mark(self._db_path, self._material_root, continue
mark, folder) img, kind = _random.choice(pool) # 每任务独立随机抽一张(含 kind)
except Exception: # noqa: BLE001 assign[key] = {"img": img, "kind": kind,
pool_imgs = [] "prompts": (mark_dirs.get(mark) or mark_dirs.get("1") or {})}
if pool_imgs: return assign
model_assign[key] = random.choice(pool_imgs) # 过滤后随机抽
return model_assign
def _load_materials(self) -> Dict[str, str]: def _load_materials(self) -> Dict[str, str]:
material_map: Dict[str, str] = {} material_map: Dict[str, str] = {}
@@ -384,6 +405,17 @@ class PinterestPipeline:
self._cond.wait() self._cond.wait()
return not self._briefs and self._in_flight <= 0 return not self._briefs and self._in_flight <= 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
# 异常路径也确保排空并释放线程池,避免 dispatcher/worker 泄漏
try:
self.finish()
except Exception: # noqa: BLE001
pass
return False
def finish(self) -> tuple: def finish(self) -> tuple:
"""排空简报池、等待全部产品完成,返回 (products, errors)。 """排空简报池、等待全部产品完成,返回 (products, errors)。
@@ -507,13 +539,16 @@ class PinterestPipeline:
def _persist_product(self, prod: Dict[str, Any]) -> None: def _persist_product(self, prod: Dict[str, Any]) -> None:
"""把已完成产品追加写入 products_pending.jsonlJSONL 每行一个产品)。 """把已完成产品追加写入 products_pending.jsonlJSONL 每行一个产品)。
并发安全:写入持 _products_lock,整行一次写(含换行),避免并发 append 交织;
落盘失败不阻塞主流程(仅告警);finish() 时读盘合并,保证已完成产品不丢。 落盘失败不阻塞主流程(仅告警);finish() 时读盘合并,保证已完成产品不丢。
""" """
try: try:
import json as _json import json as _json
self._pending_file.parent.mkdir(parents=True, exist_ok=True) self._pending_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._pending_file, "a", encoding="utf-8") as f: line = _json.dumps(prod, ensure_ascii=False) + "\n"
f.write(_json.dumps(prod, ensure_ascii=False) + "\n") with self._products_lock:
with open(self._pending_file, "a", encoding="utf-8") as f:
f.write(line)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 产品落盘失败(不影响流程): {e}") print(f"[pinterest_pipeline] 产品落盘失败(不影响流程): {e}")
@@ -630,6 +665,18 @@ class PinterestPipeline:
print(f"[pinterest_pipeline] 补充简报装配失败: {e}") print(f"[pinterest_pipeline] 补充简报装配失败: {e}")
return None return None
def _model_source(self, task_idx: int, spu=None) -> Dict[str, Any]:
"""返回某任务给 _process_spu 的图源参数:model_img / model_kind / prompts。"""
src = self._model_assign.get(f"task_{task_idx}") or {}
img = src.get("img")
if img is None:
return {}
if spu is not None and int(spu.get("mark") or 0) != 1:
return {"model_img": img} # 非 mark=1 走旧逻辑(不传 kindproduct_node 按其 mark 自行判定)
return {"model_img": img,
"model_kind": src.get("kind", "model"),
"prompts": src.get("prompts") or {}}
def _process_spu(self, brief: Dict[str, Any], spu, skus: str, img_code: str, def _process_spu(self, brief: Dict[str, Any], spu, skus: str, img_code: str,
design_path: str, task_idx: int = 0) -> Optional[Dict[str, Any]]: design_path: str, task_idx: int = 0) -> Optional[Dict[str, Any]]:
from graph.nodes.product_node import _process_spu as _ps from graph.nodes.product_node import _process_spu as _ps
@@ -642,8 +689,8 @@ class PinterestPipeline:
prod_dir, brief, self._ib, spu, skus, self.config.get("product") or {}, prod_dir, brief, self._ib, spu, skus, self.config.get("product") or {},
self._errors, design_path, self._title_backend, self.country, self._errors, design_path, self._title_backend, self.country,
img_code=img_code, img_code=img_code,
model_img=self._model_assign.get(f"task_{task_idx}"), on_503=lambda: (self.record_503(), self.abort_unfinished()),
on_503=lambda: (self.record_503(), self.abort_unfinished())) **self._model_source(task_idx, spu))
if r: if r:
r["img_code"] = img_code r["img_code"] = img_code
return r return r
+45 -71
View File
@@ -135,85 +135,59 @@ def image_ratio_ok(path, target_ratio: float = 3 / 4, tolerance: float = 0.06) -
return False return False
def build_mark_model_map(db_path, material_root) -> Dict[str, str]: def _dir_images(d: Path, ratio: float, tolerance: float) -> List[Path]:
"""启动任务前检测 spu.mark 字段,建立 {mark: 模特文件夹名} 字典。 """返回目录内满足比例过滤(默认3:4)的图片列表;目录不存在/无图片返回空。"""
if d is None or not d.is_dir():
规则:
- 读取 spu 表全部 mark 值(去重);
- 每个 mark 映射到 material_library/<mark> 目录(目录名与 mark 一致);
- 目录不存在时回退到 category 默认目录(T-shirt);
- 目前库中 mark=1 → 映射到 material_library/T-shirt。
"""
mark_map: Dict[str, str] = {}
try:
root = Path(material_root)
if not root.exists():
return mark_map
folders = [d.name for d in sorted(root.iterdir()) if d.is_dir()]
if not folders:
return mark_map
# 读 spu.mark 实际值(去重)
marks: List[str] = []
try:
conn = _connect(db_path)
rows = conn.execute("SELECT DISTINCT mark FROM SPU WHERE mark IS NOT NULL AND mark != ''").fetchall()
conn.close()
marks = [str(r["mark"]).strip() for r in rows if str(r["mark"]).strip()]
except Exception: # noqa: BLE001
marks = []
if not marks:
marks = ["1"] # 库无 mark 数据时按默认 1 处理
for m in marks:
if m in folders:
mark_map[m] = m
else:
# mark 无同名目录 → 回退默认 T-shirt(当前 mark=1 → T-shirt
mark_map[m] = "T-shirt" if "T-shirt" in folders else folders[0]
print(f"[product] mark→模特目录映射: {mark_map}")
except Exception as e: # noqa: BLE001
print(f"[product] mark→模特目录映射构建失败: {e}")
return mark_map
def find_model_images_for_mark(db_path, material_root, mark, category: str = "T-shirt",
ratio: float = 3 / 4, tolerance: float = 0.06) -> List[Path]:
"""按 spu.mark 定位模特目录,过滤非目标比例图片,返回合格图片列表。
- mark 有对应目录(material_library/<mark>)→ 用该目录;
- 否则回退 category(如 T-shirt);
- 过滤掉非 3:4 比例(默认容差 6%)的图片;
- 返回过滤后的图片列表(供调用方随机抽取)。
"""
root = Path(material_root)
if not root.exists():
return []
d = None
if mark is not None:
cand = root / str(mark)
if cand.is_dir():
d = cand
if d is None:
cand = root / category
if cand.is_dir():
d = cand
if d is None:
# 兜底:第一个有图的目录(跳过无图目录)
for sub in sorted(root.iterdir()):
if not sub.is_dir():
continue
if any(f.is_file() and f.suffix.lower() in IMG_EXTS for f in sub.iterdir()):
d = sub
break
if d is None:
return [] return []
imgs = [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS] imgs = [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS]
if not imgs:
return []
ok = [f for f in imgs if image_ratio_ok(f, ratio, tolerance)] ok = [f for f in imgs if image_ratio_ok(f, ratio, tolerance)]
if len(ok) < len(imgs): if len(ok) < len(imgs):
print(f"[product] 模特目录 {d.name}/ 过滤非 {int(ratio * 100)}:{int(ratio * 100) + 1} 比例:" print(f"[product] 目录 {d.name}/ 过滤非 {int(ratio * 100)}:{int(ratio * 100) + 1} 比例:"
f"{len(imgs)}{len(ok)}") f"{len(imgs)}{len(ok)}")
return ok return ok
def build_mark_sources(material_root, mark_dirs: Optional[Dict] = None,
category: str = "T-shirt", mark: str = "1",
ratio: float = 3 / 4, tolerance: float = 0.06) -> Dict[str, Any]:
"""按可配置的 mark→图源映射,返回指定 mark 的图源选择信息。
图源 = {"model": [Path...], "flat": [Path...]}(模特图 / 平铺图,各带英文键)。
从 config.product.mark_dirs 读 mark 对应的两个子目录名(model_dir/flat_dir),
在 material_library 下解析 → 过滤 3:4 → 返回「有图的文件夹」图片列表。
mark 未配置时回退 mark_dirs["1"];完全无配置则回退 category 默认目录。
返回结构:{"model": [imgs], "flat": [imgs]}。
"""
root = Path(material_root)
result: Dict[str, Any] = {"model": [], "flat": []}
if not root.exists():
return result
mcfg = mark_dirs or {}
cfg = mcfg.get(str(mark)) or mcfg.get("1") or {} # 优先按 mark,其次回退默认 "1"
model_name = str(cfg.get("model_dir") or "").strip()
flat_name = str(cfg.get("flat_dir") or "").strip()
if model_name:
result["model"] = _dir_images(root / model_name, ratio, tolerance)
if flat_name:
result["flat"] = _dir_images(root / flat_name, ratio, tolerance)
# 回退:某类目录未配置时,使用 category 默认目录补充模特图
if not result["model"] and not result["flat"]:
d = root / category
if d.is_dir():
result["model"] = _dir_images(d, ratio, tolerance)
if not result["model"] and not result["flat"]:
for sub in sorted(root.iterdir()):
if not sub.is_dir():
continue
imgs = _dir_images(sub, ratio, tolerance)
if imgs:
result["model"] = imgs
break
return result
def first_available_sku(db_path, basemap_root, spu_code: str) -> Optional[str]: def first_available_sku(db_path, basemap_root, spu_code: str) -> Optional[str]:
"""返回该款号下第一个「本地有底图」的 SKU.code;无则 None。""" """返回该款号下第一个「本地有底图」的 SKU.code;无则 None。"""
for c in list_colors(db_path, spu_code): for c in list_colors(db_path, spu_code):
+83 -31
View File
@@ -20,11 +20,6 @@ from graph.product import _connect
# 商品轮播图列名关键词(模板存在中/英/日变体,如 商品轮播图1 / Product Carousel Image 1 / 商品カルーセル画像1) # 商品轮播图列名关键词(模板存在中/英/日变体,如 商品轮播图1 / Product Carousel Image 1 / 商品カルーセル画像1)
_CAROUSEL_KW = ("轮播", "carousel", "カルーセル") _CAROUSEL_KW = ("轮播", "carousel", "カルーセル")
# 商品产地:国家简称 → 正式名称(模版要求,如「沙特站」提取为「沙特」但需填「沙特阿拉伯」)
_COUNTRY_NAME_MAP = {
"沙特": "沙特阿拉伯",
}
# 成分值字典映射:db 成分值 → 女装模板下拉框选项(男装模板选项与 db 值一致,直接保留)。 # 成分值字典映射:db 成分值 → 女装模板下拉框选项(男装模板选项与 db 值一致,直接保留)。
# 女装模板(如 SatVoy 沙特)成分下拉框是「中文+英文」格式(棉Cotton),db 存中文(棉),需映射。 # 女装模板(如 SatVoy 沙特)成分下拉框是「中文+英文」格式(棉Cotton),db 存中文(棉),需映射。
_COMPONENT_FEMALE_MAP = { _COMPONENT_FEMALE_MAP = {
@@ -232,18 +227,19 @@ def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_
router.ws.cell(row, car1, cc["url"]) # 该颜色轮播图1 router.ws.cell(row, car1, cc["url"]) # 该颜色轮播图1
def _build_spu_row(spu: Dict[str, Any], spu_code: str, origin_province: str, def _build_spu_row(spu: Dict[str, Any], spu_code: str,
color: Optional[str] = None, color: Optional[str] = None,
fabric_headers: Optional[List[str]] = None, fabric_headers: Optional[List[str]] = None,
gender: Optional[str] = None) -> Dict[str, Any]: gender: Optional[str] = None) -> Dict[str, Any]:
"""构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=经营站点;多颜色时用色值列区分)。 """构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=中国大陆、产地省份=广东省;多颜色时用色值列区分)。
fabric 填「面料弹性」列(fabric_headers,如 SPU商品属性-面料弹性,检测到才填 spu.fabric)。 fabric 填「面料弹性」列(fabric_headers,如 SPU商品属性-面料弹性,检测到才填 spu.fabric)。
component_1/2/3 按性别映射(gender=female 时查 _COMPONENT_FEMALE_MAP,男装/None 保留原值)。""" component_1/2/3 按性别映射(gender=female 时查 _COMPONENT_FEMALE_MAP,男装/None 保留原值)。"""
row: Dict[str, Any] = { row: Dict[str, Any] = {
"基础信息-商品层级": "spu", "基础信息-商品层级": "spu",
"SKC货号": spu_code, # code 路由为 SKC货号(用户要求) "SKC货号": spu_code, # code 路由为 SKC货号(用户要求)
"风格": "休闲", # style 路由为"休闲"(用户要求) "风格": "休闲", # style 路由为"休闲"(用户要求)
"商品产地": origin_province, # 产地省份不用填,经营站点填到「商品产地」 "商品产地": "中国大陆", # 所有国家统一「中国大陆」,不读经营站点/不做字典匹配(用户要求)
"产地省份": "广东省", # 新增:精确匹配「产地省份」列,统一填「广东省」(用户要求)
"款式来源": "现货款", # SPU商品属性-款式来源 统一填「现货款」(用户要求) "款式来源": "现货款", # SPU商品属性-款式来源 统一填「现货款」(用户要求)
} }
if color: if color:
@@ -302,6 +298,37 @@ def _read_suggested_required(router, col: int) -> bool:
return "必填" in note return "必填" in note
def _find_sku_category_headers(router) -> List[str]:
"""定位「SKU分类」列(模糊匹配,同建议售价检测方式);匹配到多个时全部返回。"""
return [str(k) for k in router.column_map if "SKU分类" in str(k)]
def _find_sku_qty_headers(router) -> List[str]:
"""定位「SKU数量」列(排除「SKU数量单位」列);匹配到多个时全部返回。"""
return [str(k) for k in router.column_map if "SKU数量" in str(k) and "单位" not in str(k)]
def _find_sku_qty_unit_headers(router) -> List[str]:
"""定位「SKU数量单位」列。"""
return [str(k) for k in router.column_map if "SKU数量单位" in str(k)]
def _required_headers(router, headers) -> List[str]:
"""过滤「非必填」列:读列头下一行备注(与建议售价同套路),备注显式写「非必填」才跳过。
SKU分类/SKU数量/SKU数量单位在本模板族属必填项(SKU分类备注「必填」,数量/单位为按规则填写),
因此默认视为需填写,仅在列备注写明「非必填」时跳过。"""
keep: List[str] = []
for h in headers:
col = router.column_map.get(h)
if col is not None:
note = str(router.ws.cell(router.header_row + 1, col).value or "")
if "非必填" in note:
continue
keep.append(h)
return keep
def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: str, def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: str,
warehouses: List[str], markup_percent: float = 0.0, warehouses: List[str], markup_percent: float = 0.0,
multi: bool = True, price_header: str = "申报价格-日本站", multi: bool = True, price_header: str = "申报价格-日本站",
@@ -311,7 +338,10 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color:
suggested_price_ratio: float = 0.0, suggested_price_ratio: float = 0.0,
suggested_price_headers: Optional[List[str]] = None, suggested_price_headers: Optional[List[str]] = None,
suggested_required: Optional[Dict[str, bool]] = None, suggested_required: Optional[Dict[str, bool]] = None,
suggested_unit_headers: Optional[List[str]] = None) -> Dict[str, Any]: suggested_unit_headers: Optional[List[str]] = None,
sku_category_headers: Optional[List[str]] = None,
sku_qty_headers: Optional[List[str]] = None,
sku_qty_unit_headers: Optional[List[str]] = None) -> Dict[str, Any]:
"""构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。 """构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。
价格(price_headers 列,如 申报价格-美国站/日本站,模糊匹配到多个时全部填)= SKU.price × (1+markup/100) 价格(price_headers 列,如 申报价格-美国站/日本站,模糊匹配到多个时全部填)= SKU.price × (1+markup/100)
预先填好。建议售价(suggested_price_headers 列,模板「建议售价」必填时才填)= 申报价格 × (1+suggested_price_ratio/100) 预先填好。建议售价(suggested_price_headers 列,模板「建议售价」必填时才填)= 申报价格 × (1+suggested_price_ratio/100)
@@ -369,6 +399,13 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color:
continue continue
if v not in (None, ""): if v not in (None, ""):
row[header] = v row[header] = v
# SKU 分类/数量/数量单位:模板检测到这些列(且非「非必填」)时统一填固定值(单品 / 1 / 件)
for h in (sku_category_headers or []):
row[h] = "单品"
for h in (sku_qty_headers or []):
row[h] = 1
for h in (sku_qty_unit_headers or []):
row[h] = ""
return row return row
@@ -441,12 +478,10 @@ def _read_spu(db_path, spu_code: str) -> Optional[Dict[str, Any]]:
def _read_meta(router) -> tuple: def _read_meta(router) -> tuple:
"""读模板顶头元信息:经营站点、发货仓(按标签名定位,不依赖固定行列)。 """读模板顶头元信息:发货仓(按标签名精准定位,不依赖固定行列)。
返回 (origin_province, warehouses) 返回 warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个)。
- origin_province:经营站点去掉末尾「站」(如「日本站」→「日本」) 商品产地/产地省份已固定为「中国大陆/广东省」写死在 _build_spu_row,不再读取经营站点。"""
- warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个)
"""
ws = router.ws ws = router.ws
top = max(1, router.group_row - 1) # 元信息区位于分组行之前 top = max(1, router.group_row - 1) # 元信息区位于分组行之前
@@ -457,16 +492,12 @@ def _read_meta(router) -> tuple:
return str(ws.cell(row + 1, col).value or "").strip() return str(ws.cell(row + 1, col).value or "").strip()
return "" return ""
site = _val("经营站点")
raw = _val("发货仓") raw = _val("发货仓")
if not site and not raw: if not raw:
# 回退:旧版固定位置(第2行第1/2列) # 回退:旧版固定位置(第2行第2列=发货仓
site = str(ws.cell(2, 1).value or "").strip()
raw = str(ws.cell(2, 2).value or "").strip() raw = str(ws.cell(2, 2).value or "").strip()
origin_province = site[:-1] if site.endswith("") else site
origin_province = _COUNTRY_NAME_MAP.get(origin_province, origin_province) # 简称→正式名称(如 沙特→沙特阿拉伯)
warehouses = [w.strip() for w in raw.split("") if w.strip()] warehouses = [w.strip() for w in raw.split("") if w.strip()]
return origin_province, warehouses return warehouses
def _read_skus(db_path, spu_code: str, sku_code: str) -> List[Dict[str, Any]]: def _read_skus(db_path, spu_code: str, sku_code: str) -> List[Dict[str, Any]]:
@@ -499,7 +530,7 @@ def _import_router(template_dir: str) -> None:
def _insert_product_block( def _insert_product_block(
router, db_path, spu_code, sku_code, router, db_path, spu_code, sku_code,
origin_province, warehouses, price_headers, warehouses, price_headers,
markup_percent: float = 0.0, images: Optional[List[str]] = None, markup_percent: float = 0.0, images: Optional[List[str]] = None,
spu_per_color: bool = True, spu_per_color: bool = True,
oss_code: str = "", cn_title: str = "", en_title: str = "", ja_title: str = "", oss_code: str = "", cn_title: str = "", en_title: str = "", ja_title: str = "",
@@ -514,6 +545,9 @@ def _insert_product_block(
suggested_price_headers: Optional[List[str]] = None, suggested_price_headers: Optional[List[str]] = None,
suggested_required: Optional[Dict[str, bool]] = None, suggested_required: Optional[Dict[str, bool]] = None,
suggested_unit_headers: Optional[List[str]] = None, suggested_unit_headers: Optional[List[str]] = None,
sku_category_headers: Optional[List[str]] = None,
sku_qty_headers: Optional[List[str]] = None,
sku_qty_unit_headers: Optional[List[str]] = None,
) -> List[int]: ) -> List[int]:
"""在已打开的 router 中插入一个产品的 SPU+SKU 块并填充设计字段,返回本块行号。 """在已打开的 router 中插入一个产品的 SPU+SKU 块并填充设计字段,返回本块行号。
@@ -543,7 +577,7 @@ def _insert_product_block(
# 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充) # 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充)
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分) # + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
block_rows.append(router.insert( block_rows.append(router.insert(
_build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers, _build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
gender=gender), gender=gender),
match="exact", match="exact",
)) ))
@@ -562,7 +596,10 @@ def _insert_product_block(
suggested_price_ratio=suggested_price_ratio, suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers, suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required, suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_headers), suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers),
spu_code=spu_code, match="exact", spu_code=spu_code, match="exact",
)) ))
@@ -578,7 +615,7 @@ def _insert_product_block(
else: else:
# 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分) # 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
block_rows.append(router.insert( block_rows.append(router.insert(
_build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers, _build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
gender=gender), match="exact")) gender=gender), match="exact"))
multi_variant = len(skus_by_color) > 1 multi_variant = len(skus_by_color) > 1
for ci, (sc, skus) in enumerate(skus_by_color): for ci, (sc, skus) in enumerate(skus_by_color):
@@ -594,7 +631,10 @@ def _insert_product_block(
suggested_price_ratio=suggested_price_ratio, suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers, suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required, suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_headers), suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers),
)) ))
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else [] sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs) _fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
@@ -659,7 +699,7 @@ def export_product(
# append_to:合并模式从已有输出文件继续追加(一次任务多产品填一个模板) # append_to:合并模式从已有输出文件继续追加(一次任务多产品填一个模板)
router = TemplateRouter(append_to if append_to else template_path) router = TemplateRouter(append_to if append_to else template_path)
try: try:
origin_province, warehouses = _read_meta(router) warehouses = _read_meta(router)
price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填) price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填)
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填) bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填) fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填)
@@ -669,8 +709,11 @@ def export_product(
suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列 suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列
suggested_required = {h: _read_suggested_required(router, router.column_map[h]) suggested_required = {h: _read_suggested_required(router, router.column_map[h])
for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断) for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断)
sku_category_headers = _required_headers(router, _find_sku_category_headers(router)) # SKU分类列(非必填才跳过)
sku_qty_headers = _required_headers(router, _find_sku_qty_headers(router)) # SKU数量列
sku_qty_unit_headers = _required_headers(router, _find_sku_qty_unit_headers(router)) # SKU数量单位列
_insert_product_block(router, db_path, spu_code, sku_code, _insert_product_block(router, db_path, spu_code, sku_code,
origin_province, warehouses, price_headers, warehouses, price_headers,
markup_percent=markup_percent, images=images, markup_percent=markup_percent, images=images,
spu_per_color=spu_per_color, spu_per_color=spu_per_color,
oss_code=oss_code, cn_title=cn_title, en_title=en_title, oss_code=oss_code, cn_title=cn_title, en_title=en_title,
@@ -684,7 +727,10 @@ def export_product(
suggested_price_ratio=suggested_price_ratio, suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers, suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required, suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_headers) suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers)
out = router.save(out_path) out = router.save(out_path)
return Path(out) return Path(out)
finally: finally:
@@ -714,7 +760,7 @@ def export_products(
router = TemplateRouter(template_path) router = TemplateRouter(template_path)
try: try:
origin_province, warehouses = _read_meta(router) warehouses = _read_meta(router)
price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填) price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填)
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填) bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填) fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填)
@@ -724,12 +770,15 @@ def export_products(
suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列 suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列
suggested_required = {h: _read_suggested_required(router, router.column_map[h]) suggested_required = {h: _read_suggested_required(router, router.column_map[h])
for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断) for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断)
sku_category_headers = _required_headers(router, _find_sku_category_headers(router)) # SKU分类列(非必填才跳过)
sku_qty_headers = _required_headers(router, _find_sku_qty_headers(router)) # SKU数量列
sku_qty_unit_headers = _required_headers(router, _find_sku_qty_unit_headers(router)) # SKU数量单位列
for r in products: for r in products:
try: try:
_insert_product_block( _insert_product_block(
router, db_path, r.get("spu_code", ""), router, db_path, r.get("spu_code", ""),
r.get("sku_codes") or r.get("sku_code") or "", r.get("sku_codes") or r.get("sku_code") or "",
origin_province, warehouses, price_headers, warehouses, price_headers,
markup_percent=markup_percent, markup_percent=markup_percent,
images=r.get("images"), images=r.get("images"),
spu_per_color=bool(r.get("spu_per_color", True)), spu_per_color=bool(r.get("spu_per_color", True)),
@@ -748,6 +797,9 @@ def export_products(
suggested_price_headers=suggested_price_headers, suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required, suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_headers, suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers,
) )
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
print(f"[template_export] 产品 {r.get('spu_code')} 写入失败,跳过: {e}") print(f"[template_export] 产品 {r.get('spu_code')} 写入失败,跳过: {e}")
+33 -1
View File
@@ -36,11 +36,41 @@ class ThreadSafeErrors:
return len(self._items) return len(self._items)
def _mark_fatal_503(state: Dict[str, Any], exc: Exception) -> None:
"""致命图像服务错误(503 / No available compatible accounts)→ 标记提前终止,不静默吞掉。
with_fallback 原本把所有异常都转成一条错误记录并继续,导致致命的 503 被「吞掉」:
路由看不到终止信号,任务会继续做无意义的搜索/分析(重试必然失败)。
检测到致命 503 时同步标记 Pinterest 流水线终止(record_503 + abort_unfinished),
让 _pinterest_route 短路到 pinterest_finalize → template_export(合成模板,保留已完成产品)。
非 Pinterest 节点无流水线对象时,此函数为空操作(不改变原有兜底行为)。
"""
if not _is_fatal_image_error(exc):
return
pipe = state.get("pinterest_pipeline")
if pipe is not None and hasattr(pipe, "record_503") and hasattr(pipe, "abort_unfinished"):
try:
pipe.record_503()
pipe.abort_unfinished()
except Exception: # noqa: BLE001
pass
def _is_fatal_image_error(exc: Exception) -> bool:
"""判断异常是否为致命图像服务错误(503 / 账户不可用)。"""
try:
from graph.pinterest_pipeline import PinterestPipeline
return bool(PinterestPipeline.is_fatal_503(exc))
except Exception: # noqa: BLE001
return False
def with_fallback(node_name: str): def with_fallback(node_name: str):
"""装饰器:捕获节点异常,转为 state['errors'] 中的一条记录,返回空更新。 """装饰器:捕获节点异常,转为 state['errors'] 中的一条记录,返回空更新。
节点内部仍建议自己做精细兜底(降级/默认),with_fallback 是最后一道保险: 节点内部仍建议自己做精细兜底(降级/默认),with_fallback 是最后一道保险:
任何未预料的异常都不会让整张图中断。 任何未预料的异常都不会让整张图中断。唯一例外——致命图像服务错误(503/账户不可用)
不静默吞掉:会同步标记流水线终止,让任务提前收尾合成模板(见 _mark_fatal_503)。
""" """
def deco(fn): def deco(fn):
@@ -58,6 +88,8 @@ def with_fallback(node_name: str):
} }
errors = list(state.get("errors") or []) errors = list(state.get("errors") or [])
errors.append(err) errors.append(err)
# 致命 503:不静默吞掉,标记流水线终止(路由据此短路到收尾合成模板)
_mark_fatal_503(state, e)
# 只更新 errors,其它字段保持上一节点结果,下游继续 # 只更新 errors,其它字段保持上一节点结果,下游继续
return {"errors": errors} return {"errors": errors}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+50
View File
@@ -0,0 +1,50 @@
You are a POD T-shirt design analyst. Given ONE Pinterest reference image,
judge whether it can inspire a T-shirt print, then write an ORIGINAL design brief
capturing its vibe WITHOUT copying.
Your image_prompt will be sent TOGETHER WITH this reference image to an image
generator, so it must actively override visual imitation.
RULES:
1. NO COPYING — never reproduce or closely imitate the reference's artwork,
characters, layout or text. Deliberately change motif, arrangement and/or
palette so the two read as clearly different works sharing only a general
style. Distill inspiration into generic style words (retro, y2k, minimal,
grunge, boho, kawaii...); never imitate an identifiable artist/studio/IP
style.
2. FORBIDDEN — brand logos, trademarks, slogans, mascots, copyrighted
characters, real people/celebrities, movie/game/anime/band IP, lyrics,
even stylized or silhouette versions. Avoid politics, religion, violence,
sexual content, alcohol, drugs, gambling, flags, death/occult themes.
3. FORM — ONE clear central subject with strong graphic composition;
print-ready standalone artwork. ANY colors are fine — rich palettes,
gradients and detailed shading are all acceptable. Photographic references
may be rendered as detailed full-color illustrations, retro badges or
vintage stickers.
TEXT: short ORIGINAL English wording (0-5 words) allowed; wrap exact words in
double quotes and demand exact spelling; integrate into composition. Never
reuse/translate reference text; no brand/band/movie names or famous slogans.
When unsure, omit.
suitable_for_print: DEFAULT TRUE for graphics, illustrations, badges, vector
art, typography posters, or prints on mockups (judge only the printed artwork).
FALSE only for: subjectless photo scenery, memes/screenshots/collages,
watermarked or very low-quality images, decor/food/candid photos with no
usable motif. Even when FALSE, still fill all fields so downstream never breaks.
image_prompt = two parts:
1) mandatory opener, e.g.: "Use the attached reference image only as loose
inspiration for overall mood, theme and era — do NOT reproduce, trace,
rearrange, recolor or closely imitate any element, character, layout or
text shown in it."
2) the new design: [central motif] + [style] + [color treatment] +
[composition] + [mood], plus quoted original text if used.
NEVER mention shirts, apparel, models, scenes, sizes, backgrounds or
watermarks — placement is handled externally.
negative_prompt: copy of reference artwork, likenesses, characters, logos,
trademarks, watermark, photorealistic shirt/product mockups, busy background;
add garbled-lettering terms only if your design includes text.
OUTPUT — ONLY valid JSON, no fences:
{"designs":[{"suitable_for_print":<bool>,"negative_prompt":"<str>","image_prompt":"<str>"}]}
+1
View File
@@ -0,0 +1 @@
Analyze the attached image and produce one ORIGINAL T-shirt print design brief that captures its visual vibe without copying it.
+64 -5
View File
@@ -379,11 +379,15 @@ def run_pipeline(countries, provider, max_seeds, log_q,
def run_pinterest_pipeline(countries, provider, log_q, spu_tasks=None, spu_count=0, def run_pinterest_pipeline(countries, provider, log_q, spu_tasks=None, spu_count=0,
oai=None, markup_percent=0.0, code_prefix="DG", template_path=""): oai=None, markup_percent=0.0, code_prefix="DG", template_path="",
custom_image_dir=""):
"""Pinterest 参考模式后台线程:独立于 Google Trends 采集链路。 """Pinterest 参考模式后台线程:独立于 Google Trends 采集链路。
各国独立种子词池 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图 各国独立种子词池 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图
→ LLM 分析图片 → 设计简报 → 产品生成(设计稿/主图/种草图/模板导出)。 → LLM 分析图片 → 设计简报 → 产品生成(设计稿/主图/种草图/模板导出)。
custom_image_dir 非空 → 自定义图片模式:不搜索不采集,把该文件夹有效图片直接送多模态分析,
沿用 Pinterest 后续所有步骤;数量硬校验(有效图片数 ≥ 选品清单总数)由 run_pinterest_ref 执行。
""" """
config = load_config() config = load_config()
config["seed_provider"] = provider config["seed_provider"] = provider
@@ -401,6 +405,9 @@ def run_pinterest_pipeline(countries, provider, log_q, spu_tasks=None, spu_count
p["markup_percent"] = markup_percent p["markup_percent"] = markup_percent
if code_prefix: if code_prefix:
p["code_prefix"] = code_prefix p["code_prefix"] = code_prefix
# 自定义模式:把上传文件夹写入配置(run_pinterest_ref 据此进入 custom 图并校验)
if custom_image_dir:
config.setdefault("pinterest", {})["custom_image_dir"] = custom_image_dir
# 任务扩展(与 run_pipeline 一致):每个集合按自己数量复制 N 份 # 任务扩展(与 run_pipeline 一致):每个集合按自己数量复制 N 份
if spu_tasks: if spu_tasks:
tasks_list = [] tasks_list = []
@@ -425,7 +432,8 @@ def run_pinterest_pipeline(countries, provider, log_q, spu_tasks=None, spu_count
from graph.agent import run_pinterest_ref from graph.agent import run_pinterest_ref
for c in countries: for c in countries:
log_q.put(("log", f"\n===== Pinterest 参考模式 {c}SPU 数量 {spu_count}=====\n")) log_q.put(("log", f"\n===== Pinterest 参考模式 {c}SPU 数量 {spu_count}=====\n"))
state = run_pinterest_ref(c, config, config_root(), runtime_root(), task_timestamp=task_ts) state = run_pinterest_ref(c, config, config_root(), runtime_root(),
task_timestamp=task_ts, custom_image_dir=custom_image_dir)
items = state.get("product") or [] items = state.get("product") or []
errs = state.get("errors") or [] errs = state.get("errors") or []
log_q.put(("log", f"[{c}] Pinterest 参考完成:{len(items)} 个产品,兜底错误 {len(errs)}\n")) log_q.put(("log", f"[{c}] Pinterest 参考完成:{len(items)} 个产品,兜底错误 {len(errs)}\n"))
@@ -538,6 +546,13 @@ class App(tk.Tk):
variable=self.flow_var).pack(side="left", padx=2) variable=self.flow_var).pack(side="left", padx=2)
ttk.Radiobutton(top, text="Pinterest 参考", value="pinterest", ttk.Radiobutton(top, text="Pinterest 参考", value="pinterest",
variable=self.flow_var).pack(side="left", padx=2) variable=self.flow_var).pack(side="left", padx=2)
ttk.Radiobutton(top, text="自定义图片", value="custom",
variable=self.flow_var).pack(side="left", padx=2)
ttk.Button(top, text="📁 选图片文件夹…", command=self._choose_custom_dir).pack(side="left", padx=(8, 0))
self.custom_dir_var = tk.StringVar(value="")
self.custom_dir_name_var = tk.StringVar(value="")
ttk.Label(top, textvariable=self.custom_dir_name_var, anchor="w",
foreground="#185FA5").pack(side="left", padx=(6, 0))
ttk.Label(top, text=" 种子数量:").pack(side="left", padx=(14, 0)) ttk.Label(top, text=" 种子数量:").pack(side="left", padx=(14, 0))
self.seed_var = tk.StringVar(value="24") self.seed_var = tk.StringVar(value="24")
ttk.Entry(top, textvariable=self.seed_var, width=4).pack(side="left") ttk.Entry(top, textvariable=self.seed_var, width=4).pack(side="left")
@@ -854,6 +869,24 @@ class App(tk.Tk):
).start() ).start()
# ---------- 运行 ---------- # ---------- 运行 ----------
def _choose_custom_dir(self):
"""选择自定义模式图片文件夹,并立即统计有效图片数量(供数量校验提示)。"""
path = filedialog.askdirectory(title="选择自定义图片文件夹(直接送多模态分析)")
if not path:
return
try:
from graph.pinterest import list_valid_images
imgs = list_valid_images(path)
n = len(imgs)
except Exception: # noqa: BLE001
n = 0
self.custom_dir_var.set(path)
if n == 0:
self.custom_dir_name_var.set(f"已选: {Path(path).name}0 张有效图片!)")
else:
self.custom_dir_name_var.set(f"已选: {Path(path).name}{n} 张有效图片)")
self._log(f"[UI] 自定义图片文件夹已选择: {path}(有效图片 {n} 张,jpg/jpeg/png/webp\n")
def _choose_template(self): def _choose_template(self):
"""选择自定义商品上传模板(.xlsx),后续 template_export 从该模板解析。 """选择自定义商品上传模板(.xlsx),后续 template_export 从该模板解析。
导入时校验「经营站点」是否与当前国家一致(如 JP → 日本站),不一致则导入失败。""" 导入时校验「经营站点」是否与当前国家一致(如 JP → 日本站),不一致则导入失败。"""
@@ -1022,14 +1055,40 @@ class App(tk.Tk):
self._busy = True self._busy = True
self.run_btn.config(state="disabled", text="运行中…") self.run_btn.config(state="disabled", text="运行中…")
self.fetch_btn.config(state="disabled") self.fetch_btn.config(state="disabled")
if self.flow_var.get() == "pinterest": flow = self.flow_var.get()
# Pinterest 参考模式:独立于 Google Trends 采集链路 if flow in ("pinterest", "custom"):
# Pinterest 参考 / 自定义图片:独立于 Google Trends 采集链路
custom_dir = self.custom_dir_var.get().strip()
if flow == "custom":
# —— 自定义图片模式硬校验:必须先选文件夹;有效图片数 ≥ 选品清单总数 ——
if not custom_dir:
self._busy = False
self.run_btn.config(state="normal", text="运行")
self.fetch_btn.config(state="normal")
messagebox.showwarning("自定义图片模式", "请先点击「📁 选图片文件夹…」选择上传的图片文件夹")
return
try:
from graph.pinterest import list_valid_images, validate_custom_images
# 选品清单总数 = 每个任务按 count 扩展后的求和(与 run_pinterest_pipeline 一致)
expanded = sum(int(t.get("count") or 0) or spu_count or 1 for t in tasks)
ok, valid_n, msg = validate_custom_images(custom_dir, expanded)
except Exception as e: # noqa: BLE001
ok, valid_n, msg = False, 0, f"校验失败: {e}"
if not ok:
self._busy = False
self.run_btn.config(state="normal", text="运行")
self.fetch_btn.config(state="normal")
messagebox.showerror("自定义图片数量校验未通过", msg)
self._log("[UI] 自定义图片数量校验未通过,任务未运行: " + msg + "\n")
return
self._log(f"[UI] 自定义图片数量校验通过:有效图片 {valid_n} 张 ≥ 选品清单 {expanded}\n")
threading.Thread( threading.Thread(
target=run_pinterest_pipeline, target=run_pinterest_pipeline,
args=(countries, self.provider_var.get(), self._q, args=(countries, self.provider_var.get(), self._q,
tasks, spu_count, self._oai_cfg(), markup, tasks, spu_count, self._oai_cfg(), markup,
self.code_prefix_var.get().strip() or "DG", self.code_prefix_var.get().strip() or "DG",
self.template_path_var.get().strip()), self.template_path_var.get().strip(),
custom_dir if flow == "custom" else ""),
daemon=True, daemon=True,
).start() ).start()
else: else: