POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底

- 缓存热点批量流程(有采集缓存不触发 Google)
- 简报不足直接从采集缓存生成(轻量补齐)
- 三图合成(模特/印花/底图)+ 底图压缩 <2MB
- 热点去重→风格去重自动切换 + 不适合类目 review 兜底
- 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述)
- 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
2026-08-22 14:14:01 +08:00
commit f493bde8a9
98 changed files with 10280 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
"""流水线节点集合。"""
from .compose_node import compose_node
from .fetch_node import fetch_node
from .filter_node import filter_node
from .oss_upload_node import oss_upload_node
from .product_node import product_node
from .prompt_node import prompt_node
from .score_node import score_node
from .screen_node import screen_node
from .seed_node import seed_node
from .seed_shot_node import seed_shot_node
from .template_export_node import template_export_node
__all__ = [
"fetch_node",
"filter_node",
"score_node",
"screen_node",
"prompt_node",
"product_node",
"compose_node",
"seed_node",
"oss_upload_node",
"seed_shot_node",
"template_export_node",
]
+194
View File
@@ -0,0 +1,194 @@
"""节点 5.5/6:生成印花设计稿 + 导出简报包(compose)。
流程位置:prompt_build → compose → productcompose 在 product 之前)。
职责
1. 生成纯印花设计稿:对前 N 个 safe 简报(N=config.compose.design_count,默认 1),
用 image_prompt 调图像后端 generate()(白底、可直接打印),产物存 output/<country>/designs/
设计稿路径写回 brief.design_path,并汇总返回 designs 列表供 product 节点使用(图2)。
2. 导出简报包:design_briefs.json/md、composite_prompts.json/md、report.md。
"""
import json
import time
from pathlib import Path
from typing import Any, Dict, List
from graph.validate import with_fallback
RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"}
def _build_briefs_md(briefs: List[Dict[str, Any]], generated_at: str) -> str:
lines = [
"# POD 印花设计简报(LLM 合规筛选 + 生图提示词)",
"",
f"- 生成时间: {generated_at}",
f"- 通过筛选: {len(briefs)}",
"",
"## 一、安全设计清单(按综合分排序)",
"",
"| 排名 | 国家 | 热点词 | 类别 | 风险 | 设计概念 |",
"|---|---|---|---|---|---|",
]
for i, r in enumerate(briefs, 1):
flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level"))
lines.append(
f"| {i} | {r.get('country','')} | {r.get('topic','')} | {r.get('design_category','')} | {flag} | {r.get('concept','')} |"
)
lines += ["", "## 二、设计要素 + 封装提示词", ""]
lines.append("> 工作流:① `image_prompt` = 印花设计稿(白底,单独生图);② 上传平铺衣服底图(图1)后,")
lines.append("> 用 `composite_prompt` + 图1 经 img2img 把设计印到衣服;规则写死:保留衣服、胸前居中印花、真实丝网质感。")
lines.append("")
for i, r in enumerate(briefs, 1):
flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level"))
lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')} {flag}")
lines.append(f"- 类别: {r.get('design_category','')}")
lines.append(f"- 设计要素: 主体=「{r.get('motif','')}」 | 风格=「{r.get('art_style','')}」 | 配色=「{r.get('color_palette','')}」 | 构图=「{r.get('composition','')}")
lines.append(f"- 概念: {r.get('concept','')}")
if r.get("risk_reasons"):
lines.append(f"- 风险提示: {'; '.join(r['risk_reasons'])}")
if r.get("design_path"):
lines.append(f"- **设计稿**: {r['design_path']}")
lines.append(f"- **设计稿 Prompt (image_prompt)**: {r.get('image_prompt','')}")
lines.append(f"- **印到底图 Prompt (composite_prompt)**: {r.get('composite_prompt','')}")
lines.append(f"- **Composite Negative**: {r.get('composite_negative','')}")
lines.append("")
return "\n".join(lines)
def _build_composite_md(briefs: List[Dict[str, Any]]) -> str:
lines = [
"# 封装提示词包(印到平铺衣服底图 图1)",
"",
f"- 共 {len(briefs)} 条,每条含 `composite_prompt`(印图指令)+ `composite_negative`。",
"- 用法:将你的平铺衣服参考图作为图1,连同 `composite_prompt` 送入任意 img2img / inpaint 模型。",
"",
]
for i, r in enumerate(briefs, 1):
lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')}")
lines.append(f"- composite_prompt: {r.get('composite_prompt','')}")
lines.append(f"- composite_negative: {r.get('composite_negative','')}")
lines.append("")
return "\n".join(lines)
def _build_report_md(state: Dict[str, Any]) -> str:
country = state.get("country", "")
stats = state.get("stats") or {}
errors = state.get("errors") or []
lines = [
f"# POD 热点抓取报告 - {country}",
"",
f"- 生成时间: {time.strftime('%Y-%m-%dT%H:%M:%S')}",
"",
"## 各阶段统计",
"",
"| 阶段 | 指标 |",
"|---|---|",
]
for k, v in stats.items():
lines.append(f"| {k} | {v} |")
lines += ["", "## 兜底错误记录(节点级 fallback 捕获)", ""]
if errors:
for e in errors:
lines.append(f"- [{e.get('node')}] {e.get('type')}: {e.get('message')}")
else:
lines.append("- 无(全部节点正常)")
return "\n".join(lines)
@with_fallback("compose")
def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
briefs: List[Dict[str, Any]] = state.get("briefs") or []
output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹)
output_dir.mkdir(parents=True, exist_ok=True)
cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录)
config = state["config"]
country = state.get("country", "")
generated_at = time.strftime("%Y-%m-%dT%H:%M:%S")
# 1) design_briefs.json(缓存 → 根目录,不进时间戳任务文件夹)
(cache_dir / "design_briefs.json").write_text(
json.dumps({"generated_at": generated_at, "total": len(briefs), "design_briefs": briefs},
ensure_ascii=False, indent=2), encoding="utf-8")
# 2) design_briefs.md
(cache_dir / "design_briefs.md").write_text(
_build_briefs_md(briefs, generated_at), encoding="utf-8")
# 3) composite_prompts.json / .md
(cache_dir / "composite_prompts.json").write_text(
json.dumps({"generated_at": generated_at, "total": len(briefs), "composite_prompts": briefs},
ensure_ascii=False, indent=2), encoding="utf-8")
(cache_dir / "composite_prompts.md").write_text(
_build_composite_md(briefs), encoding="utf-8")
# 4) report.md(本次任务报告 → 产物目录)
(output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8")
# 5) 生成纯印花设计稿(图2):前 N 个 safe 简报用 image_prompt 文生图
designs: List[Dict[str, Any]] = []
compose_cfg = config.get("compose") or {}
backend_name = compose_cfg.get("backend", "")
ib = None
if backend_name:
try:
from graph.backends import get_image_backend
ib = get_image_backend(backend_name)
if ib is not None:
ib.bind_config(compose_cfg)
except Exception as e: # noqa: BLE001
print(f"[compose] 图像后端 {backend_name} 不可用: {e}")
if ib is None:
print("[compose] 未配置 compose.backendopenai/mock),跳过印花设计稿生成。")
else:
# 设计稿覆盖所有简报(含 review):每个热点一张设计,避免 review 热点无设计
# 导致 product 回退生成重复占位图;风险由 assign 层(allow_review)控制是否分配
safe_briefs = briefs
design_count = int(compose_cfg.get("design_count", 1))
# 联动总任务数:每个产品一张设计 → 生成 扩展后 spu_tasks 总数 张设计
task_n = int(len((state.get("config") or {}).get("product", {}).get("spu_tasks") or []))
if task_n > design_count:
design_count = task_n
design_dir = output_dir / "designs"
design_dir.mkdir(exist_ok=True)
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
from concurrent.futures import ThreadPoolExecutor, as_completed
def _gen_one(i: int, b: Dict[str, Any]):
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
try:
img_prompt = sanitize_image_prompt(b.get("image_prompt", ""))
img_prompt = ensure_rebrand_hint(b, img_prompt) # review → 原创化魔改引导
out_path = ib.generate(
img_prompt,
str(design_dir / f"{country}_{i:02d}_design.png"),
b.get("composite_negative", ""),
size="1024x1024") # 印花设计统一 1024x1024
return i, b, out_path, None
except Exception as e: # noqa: BLE001
return i, b, None, e
targets = [(i, b) for i, b in enumerate(safe_briefs[:design_count], 1)]
# 并发生成:每张设计一个线程(并行调图像网关),数量多时不串行等待
workers = max(1, min(len(targets), int((config.get("compose") or {}).get("design_workers", 5))))
print(f"[compose] 并发生成 {len(targets)} 张设计稿({workers} 线程)…")
with ThreadPoolExecutor(max_workers=workers) as _ex:
_futs = [_ex.submit(_gen_one, i, b) for i, b in targets]
for _f in as_completed(_futs):
i, b, out_path, err = _f.result()
if err is not None:
print(f"[compose] 设计稿生成失败 {b.get('topic', '')}: {err}")
state.setdefault("errors", []).append({
"node": "compose", "type": type(err).__name__,
"message": f"设计稿生成失败 {b.get('topic','')}: {err}", "trace": ""})
else:
b["design_path"] = out_path
designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path})
print(f"[compose] 印花设计稿已生成: {out_path}")
stats = dict(state.get("stats") or {})
stats["compose"] = {"written": len(briefs), "designs": len(designs), "output_dir": str(output_dir)}
return {"composite": briefs, "designs": designs, "stats": stats,
"errors": state.get("errors") or []}
+55
View File
@@ -0,0 +1,55 @@
"""节点 1/6:抓取(fetch)。
按 config.sources 启用各可插拔数据源,汇总统一格式行。
单源失败不影响其它源(内部逐个 try),整体再套 with_fallback 兜底。
"""
from typing import Any, Dict, List
from graph.sources import get_source
from graph.validate import validate_rows, with_fallback
@with_fallback("fetch")
def fetch_node(state: Dict[str, Any]) -> Dict[str, Any]:
country = state["country"]
config = state["config"]
cc = state["country_config"]
enabled = config.get("sources") or ["google_trends"]
rows: List[Dict[str, Any]] = []
errors = list(state.get("errors") or [])
# 采集缓存优先:采集(fetch_keywords)成功后写入 output/<国>/collected_keywords.json
# 这里直接用(跳过 Google 重抓),避免重复撞限流;无缓存才走数据源抓取
use_collected = (config.get("fetch") or {}).get("use_collected", True)
if use_collected:
try:
import json as _json
from pathlib import Path as _Path
p = _Path(state.get("cache_dir") or state.get("output_dir", "")) / "collected_keywords.json"
if p.exists():
data = _json.loads(p.read_text(encoding="utf-8"))
cached_rows = data.get("keywords") or []
if cached_rows:
rows = [dict(r) for r in cached_rows] # 已过滤去重的关键词
print(f"[fetch] 使用采集缓存 {len(rows)} 条({country},跳过 Google 抓取)")
stats = dict(state.get("stats") or {})
stats["fetch"] = {"raw_rows": len(rows), "sources": ["collected_cache"], "errors": 0}
return {"raw_rows": rows, "errors": errors, "stats": stats}
except Exception as e: # noqa: BLE001
print(f"[fetch] 读取采集缓存失败(回退数据源): {e}")
for name in enabled:
try:
src = get_source(name)
rows.extend(src.fetch(country, cc, config))
except Exception as e: # noqa: BLE001
errors.append({
"node": "fetch", "type": type(e).__name__,
"message": f"source[{name}]: {e}", "trace": "",
})
print(f"[fetch] 数据源 {name} 失败(跳过): {e}")
rows = validate_rows(rows, "fetch")
stats = dict(state.get("stats") or {})
stats["fetch"] = {"raw_rows": len(rows), "sources": enabled, "errors": len(errors)}
return {"raw_rows": rows, "errors": errors, "stats": stats}
+67
View File
@@ -0,0 +1,67 @@
"""节点 2/6:过滤(filter)。
三级过滤,全部带兜底、单级失败不影响其它级:
1) 合规黑名单(全局 + 国家 extra)
2) 真实人物(名单 + Firstname Lastname 模式,仅对 gt_trending 源,避免误删风格词)
3) 设计相关性(剔除泛新闻/科技/赛事词)
"""
from typing import Any, Dict, List
from graph.scoring import apply_blacklist, filter_design_relevance, filter_person_names, filter_query_noise
from graph.validate import validate_rows, with_fallback
@with_fallback("filter")
def filter_node(state: Dict[str, Any]) -> Dict[str, Any]:
rows: List[Dict[str, Any]] = state.get("raw_rows") or []
config = state["config"]
cc = state["country_config"]
country = state.get("country", "")
# 黑名单:全局 + 国家专属
bl = [str(b).lower() for b in (config.get("blacklist") or [])]
bl += [str(b).lower() for b in (cc.get("extra_blacklist") or [])]
bl = list(set(bl))
name_cfg = config.get("name_filter") or {}
rel_cfg = config.get("relevance") or {}
dropped_total = 0
kept, dropped = apply_blacklist(rows, bl)
dropped_total += len(dropped)
if name_cfg.get("enabled", True):
kept, dropped = filter_person_names(
kept,
extra_names=name_cfg.get("extra_names"),
patterns=name_cfg.get("patterns"),
exemptions=name_cfg.get("exemptions"),
pattern_sources=set(name_cfg.get("pattern_sources") or ["gt_trending"]),
)
dropped_total += len(dropped)
if rel_cfg.get("enabled", True):
kept, dropped = filter_design_relevance(
kept,
drop_patterns=rel_cfg.get("drop_patterns"),
keep_patterns=rel_cfg.get("keep_patterns"),
)
dropped_total += len(dropped)
# ③ 新闻类热点(天气/灾害/政治/事故等突发新闻,非印花主题;按国家语言过滤)
if kept:
from graph.scoring import filter_news
kept, dropped = filter_news(kept, country)
dropped_total += len(dropped)
# ④ 查询噪声(问句/命名清单/损坏碎片/模糊名词)—— 防止被 Mock 误标 safe
qn_cfg = config.get("query_noise") or {}
if qn_cfg.get("enabled", True):
kept, dropped = filter_query_noise(kept, enabled=True)
dropped_total += len(dropped)
kept = validate_rows(kept, "filter")
stats = dict(state.get("stats") or {})
stats["filter"] = {"kept": len(kept), "dropped": dropped_total}
return {"filtered_rows": kept, "stats": stats}
+109
View File
@@ -0,0 +1,109 @@
"""节点 7/7:压缩 + 上传阿里云 OSSoss_upload)。
在 product 之后运行:把 product 生成的成品图(composite / printed / design / basemap
压缩为 3:4 / ≥1340×1785 / <2MB 的 JPEG,上传到 config.oss 图床。
上传 key(图床路径):{国家}/{任务时间戳}/{货号}_{4位随机}.jpg
- 任务时间戳:任务开始记录(YYYYMMDDHHMMSS),state["task_timestamp"],缺失时取当前时间
- 货号:用户自定义前缀(config.product.code_prefix,默认 DG+ 3 位计数(000 起,最多 999)
- 4 位随机:大小写英文 + 数字
压缩/上传均带兜底:单图失败不影响其它;未配置 oss 或 enabled=false 时静默跳过。
"""
import random
import string
import time
from pathlib import Path
from typing import Any, Dict, List
from graph.validate import with_fallback
# design 设计稿是过程稿(不进模板),不压缩不上传;只传最终商品图
KIND_ORDER = ["composite", "printed", "basemap"]
MAX_CODE = 999 # 货号计数上限(000~998 共 999 张)
def _gen_rand4() -> str:
return "".join(random.choices(string.ascii_letters + string.digits, k=4))
@with_fallback("oss_upload")
def oss_upload_node(state: Dict[str, Any]) -> Dict[str, Any]:
products: List[Dict[str, Any]] = state.get("product") or []
config = state["config"] or {}
oss_cfg = config.get("oss") or {}
country = state.get("country", "")
if not (oss_cfg.get("oss_bucket") and oss_cfg.get("oss_key_id")):
print("[oss] 未配置 ossconfig.oss),跳过压缩上传节点。")
return {"oss": [], "stats": state.get("stats") or {}}
if not bool(oss_cfg.get("enabled", True)):
print("[oss] config.oss.enabled=false,跳过上传。")
return {"oss": [], "stats": state.get("stats") or {}}
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
# 任务时间戳:任务开始记录;缺失则当前时间
ts = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S"))
# 货号前缀:config.product.code_prefix(默认 DG
prefix = str(((config.get("product") or {}).get("code_prefix")) or "DG").strip()
# 序号从 state 续接(一次任务内跨多次节点调用不重号)
seq = int(state.get("oss_seq") or 0)
uploaded: List[Dict[str, Any]] = []
stats = dict(state.get("stats") or {})
for r in products:
spu = r.get("spu_code", "")
sku = r.get("sku_code", "")
for kind in KIND_ORDER:
src = r.get(f"{kind}_path")
if not src or not Path(src).exists():
continue
if seq >= MAX_CODE:
print(f"[oss] 货号计数已达上限 999,停止上传后续图片({src}")
break
try:
code = f"{prefix}{seq:03d}" # 货号:前缀 + 3 位计数(000 起)
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(country, ts, code, _gen_rand4())
url = upload_to_oss(oss_cfg, compressed, key)
if url:
r[f"{kind}_url"] = url
r["oss_code"] = code
uploaded.append({"spu_code": spu, "sku_code": sku, "kind": kind,
"code": code, "url": url})
seq += 1
except Exception as e: # noqa: BLE001
print(f"[oss] 处理失败 {src}: {e}")
# 多色:color_composites 用于模板按颜色路由——首色用主图 url/code,额外色单独上传(独立货号)
color_ups: List[Dict[str, Any]] = []
comps = r.get("color_composites") or []
if comps and r.get("composite_url"):
color_ups.append({**comps[0], "url": r["composite_url"], "code": r.get("oss_code", "")})
for cc in comps[1:]:
src = cc.get("composite_path")
if not src or not Path(src).exists():
continue
if seq >= MAX_CODE:
print("[oss] 货号计数已达上限 999,停止上传颜色图")
break
try:
code = f"{prefix}{seq:03d}"
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(country, ts, code, _gen_rand4())
url = upload_to_oss(oss_cfg, compressed, key)
if url:
cc["url"] = url
cc["code"] = code
color_ups.append(cc)
uploaded.append({"spu_code": spu, "sku_code": cc.get("sku_code"),
"kind": "composite_color", "code": code, "url": url})
seq += 1
except Exception as e: # noqa: BLE001
print(f"[oss] 颜色图上传失败 {src}: {e}")
if color_ups:
r["color_composites"] = color_ups
stats["oss"] = {"uploaded": len(uploaded), "timestamp": ts, "prefix": prefix, "seq": seq}
return {"oss": uploaded, "product": products, "oss_seq": seq, "stats": stats}
+584
View File
@@ -0,0 +1,584 @@
"""节点 6.5:产品图生成(product)。
在 prompt_build 之后、compose 之前运行(prompt_build → product → compose):
热点提示词 → SPU/颜色选品 → basemap 底图 → 纯印花设计稿 → 模特试穿合成图。
产物写入 output/<country>/product/(底图拷贝 / *_design.png / *_model / *_composite.png / products.json)。
模板选择按 SPU.mark 驱动:
mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1=模特实拍 / 图2=纯印花设计 / 图3=平铺底图)
mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计 → 平铺服装图)
配置(config.yaml product 段):
enabled 开关(默认 true)
db_path SPU/SKU 数据库(默认 db/spu_sku.db,相对路径按运行根解析)
basemap_dir 底图目录(默认 basemap)
material_library_dir 模特图库(默认 material_library
model_category 模特品类子目录(T-shirt);为空/无图时取 material_library 第一个有图子目录
brief_index 用第几个 safe 简报的提示词(0=第一个)
spu_code / sku_code 指定款号/颜色编码(留空自动选第一个有本地底图的)
spu_tasks [{"spu": "DG004", "skus": "DG004-BL01,..."}] 多款号批量选品(优先于 spu_code)
spu_count 款号数量上限(0=不限;取任务清单前 N 个)
spu_per_color true=每颜色一个 SPU 块;false=单 SPU 下挂所有颜色 SKU 变体
backend 图像后端:openai(真生图,需 key) / mock(占位) / 留空=跳过生成仅存底图
缺底图/模特图时跳过对应步骤并提示,不中断流水线;多款号逐个处理,单个失败不影响其它。
"""
import json
import random
import shutil
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from graph.paths import project_root, runtime_root
from graph.product import (
find_basemap,
find_first_model_folder,
first_available_sku,
list_colors,
list_spus,
)
from graph.validate import with_fallback
_USED_LOCK = threading.Lock() # used_designs.json 并发写锁
_MODEL_LOCK = threading.Lock() # 同款共用模特缓存并发锁
_MODEL_CACHE: Dict[str, Any] = {} # 同款共用模特:spu_code → model 路径
def _next_img_idx(prod_dir: Path, prefix: str) -> int:
"""货号续号:扫 prod_dir 已有 {prefix}{数字}* 文件,返回下一个起始序号(不覆盖旧产物)。"""
import re
max_n = -1
try:
if prod_dir.exists():
for f in prod_dir.iterdir():
m = re.match(rf"{re.escape(prefix)}(\d+)", f.stem)
if m:
max_n = max(max_n, int(m.group(1)))
except Exception: # noqa: BLE001
pass
return max_n + 1
MODEL_WEAR_PROMPT = (
"你是一个专业的电商AI视觉合成工具,执行“高保真印花与色彩移植/印花替换”:把图2的印花设计印到图3的衣服底图上,"
"并让图1的模特穿上这件带有图2印花设计的图3底图衣服。\n"
"【图片角色,按提交顺序,不要弄反】\n"
"第一张图(图1)=模特实拍图(基底,要被替换衣服图案和颜色的目标区域,保留原有背景/人物/光影);\n"
"第二张图(图2)=纯印花设计稿(要印上去的图案内容,忽略其背景环境与无关元素,保留图案原始线条与色号);\n"
"第三张图(图3)=平铺衣服底图(只提取衣服本身的底色与面料材质;忽略平铺图的背景、桌面、环境、场景阴影等一切与衣服无关的元素,只保留衣服面料的颜色、质感和纹理)。\n"
"最终效果为图1的模特穿着一件“颜色为图3底色、印有图2图案”的衣服。\n"
"【执行规则】\n"
"1.底色锁定:从图3平铺衣服中提取衣服底色与面料属性,该底色在最终合成中必须100%保持不变,"
"严禁偏色、混入图1原衣服颜色或图2背景色。\n"
"2.印花提取与叠加:从图2中精准提取纯印花图案主体,保留原始线条、色号、比例关系;"
"将印花叠加到图3底色衣服上,形成“图3底色+图2印花”的合成面料。\n"
"3.印花尺寸适配:印花的整体尺寸必须与衣服(图3)的面料面积成合理比例——"
"居中印在胸/背/衣身的主体区域,占衣身面积约30%-45%,四周保留自然留白与衣摆、领口、肩线余量;"
"严禁印花过大(撑满整件衣服、溢出领口袖口下摆)或过小(占比低于20%)。\n"
"4.主体识别与遮罩:识别图1模特的服装穿着区域,忽略皮肤、头发、背景、配饰;"
"将该区域视为“空白画布”,用上述合成面料(图3底色+图2印花)完整覆盖。图1原有衣服颜色与图案全部清除。\n"
"5.精准贴合:合成面料严格跟随图1衣服的立体结构——有褶皱、身体扭转时印花相应变形;"
"印花与新底色须“沉入”褶皱中,保留布料原有明暗纹理与物理属性,杜绝“贴纸感”与“平面涂色感”。\n"
"6.光影融合:提取图1的环境光方向,调整合成面料的亮度/对比度与环境光匹配;"
"图3底色在阴影区须自然变暗,在高光区须有布料反光;印花色彩受环境光影响产生相应明暗变化,但色号本身不偏移。\n"
"7.纯净输出:仅输出一张最终合成图;严禁文字/水印/额外装饰;"
"图1原本的背景、人物、构图及光影结构100%不变,仅替换图1衣服上的印花图案与衣服底色。"
)
def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]:
"""选定 SKU:显式指定优先;否则第一个有本地底图的;再无则第一个颜色(便于模板导出)。"""
if sku_code:
return sku_code
s = first_available_sku(db_path, basemap_root, spu_code)
if s:
return s
if colors:
return colors[0]["sku_code"]
return None
def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path:
"""模板输出路径:默认 {sku}_已填写.xlsx;若文件被其它程序占用(如已打开),自动换名加序号,避免导出失败。"""
base = prod_dir / f"{chosen_sku}_已填写.xlsx"
try:
with open(base, "ab"):
pass
return base
except OSError:
pass
for i in range(2, 100):
cand = prod_dir / f"{chosen_sku}_已填写_{i}.xlsx"
if not cand.exists():
return cand
return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx"
def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。"""
import time as _t
last = None
for i in range(attempts):
try:
return fn(*args, **kwargs)
except Exception as e: # noqa: BLE001
last = e
if i < attempts - 1:
_t.sleep(backoff[i])
print(f"[product] 图像合成重试 {attempts} 次均失败: {last}")
return None
def _process_spu(
db_path, basemap_root, material_root, category, prod_dir, brief, ib,
spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
img_code="", model_img=None,
) -> Optional[Dict[str, Any]]:
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
(按货号命名,包含该货号对应的所有图片)。
返回 result dict;内部异常已兜底,不中断。
"""
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
prod_dir = prod_dir / img_code
prod_dir.mkdir(parents=True, exist_ok=True)
colors = list_colors(db_path, spu["code"])
valid_codes = {c["sku_code"] for c in colors}
if sku_code:
sku_codes = [s.strip() for s in sku_code.split(",") if s.strip() and s.strip() in valid_codes]
if not sku_codes:
print(f"[product] 颜色 {sku_code!r} 均不在款号 {spu['code']} 下,可选: {[c['sku_code'] for c in colors]}")
return None
else:
first = _resolve_sku(db_path, basemap_root, spu["code"], "", colors)
if first is None:
print(f"[product] 款号 {spu['code']} 无颜色数据,可选: {[c['sku_code'] for c in colors]}")
return None
sku_codes = [first]
# 模板模式自动判定(UI 不再选择):单色=每色一SPU;多色=单SPU多色;CLI --single-spu 显式覆盖
spu_per_color = pcfg.get("spu_per_color")
if spu_per_color is None:
spu_per_color = len(sku_codes) <= 1
else:
spu_per_color = bool(spu_per_color)
chosen_sku = sku_codes[0] # 生图用第一个颜色
tag = f"[product/{img_code or chosen_sku}]" # 日志前缀用货号(DG000),失败/进度一眼定位
basemap_img = find_basemap(basemap_root, spu["code"], chosen_sku)
if basemap_img is None:
print(f"{tag} 底图缺失({basemap_root}/{spu['code']}/{chosen_sku}/),将跳过印花/模特,仅导出模板")
result: Dict[str, Any] = {
"spu_code": spu["code"],
"sku_code": chosen_sku,
"color": next((c["color"] for c in colors if c["sku_code"] == chosen_sku), ""),
"topic": brief.get("topic", ""),
"art_style": brief.get("art_style", ""),
"color_palette": brief.get("color_palette", ""),
"basemap": str(basemap_img) if basemap_img else "",
"composite_prompt": brief.get("composite_prompt", ""),
"markup_percent": pcfg.get("markup_percent", 0), # 加价%(后续定价用)
}
# 拷贝底图(图3):命名 = 货号 + SKU code(如 DG003_DG004-BL01_basemap.jpg),
# 同一 SKU 多个设计(数量 N)时底图文件也各自独立,不覆盖、不混淆
if basemap_img is not None:
base_copy = prod_dir / f"{img_code}_{chosen_sku}_basemap{basemap_img.suffix}"
shutil.copy2(basemap_img, base_copy)
result["basemap_copy"] = str(base_copy)
print(f"{tag} 底图: {base_copy}")
if ib is None:
print(f"{tag} 未配置 product.backendopenai/mock),跳过印花/模特生成。")
elif basemap_img is None:
print(f"{tag} 无底图,跳过印花/模特生成。")
else:
# 5) 纯印花设计稿(图2):直接用 compose 节点生成的共享设计稿(designs/ 已有,不拷贝)
if shared_design and Path(shared_design).exists():
design_path = shared_design
result["design_path"] = design_path
result["design_from"] = "compose"
print(f"{tag} 设计稿(来自 compose 节点,designs/ 已有): {design_path}")
else:
design_path = str(prod_dir / f"{img_code}_design.png")
try:
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
prompt = ensure_rebrand_hint(brief, sanitize_image_prompt(brief.get("image_prompt", "")))
ib.generate(prompt, design_path,
brief.get("composite_negative", ""),
size="1024x1024") # 印花设计统一 1024x1024
result["design_path"] = design_path
result["design_from"] = "product"
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
except Exception as e: # noqa: BLE001
errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""})
print(f"{tag} 设计稿生成失败: {e}")
# 6) 模板选择按 SPU.mark 决定:
# mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图)
# mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计)
if int(spu.get("mark") or 0) == 1:
print(f"{tag} SPU {spu['code']} mark=1 → 使用三图合成模板(图1模特+图2印花+图3底图)")
if model_img is not None:
# 任务级模特分配(product_node 预分配:一个 SPU 一个模特,SPU 数>模特数循环兜底)
model_copy = prod_dir / f"{img_code}_model{model_img.suffix}"
shutil.copy2(model_img, model_copy)
result["model_path"] = str(model_copy)
result["model_folder"] = model_img.parent.name
print(f"{tag} 模特图(任务级分配,{model_img.parent.name}/: {model_copy}")
else:
print(f"{tag} material_library 无模特图,回退两图合成(composite_prompt")
else:
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
# 7) 合成:
# 有模特图 → 三图合成(图1=模特 / 图2=印花设计 / 图3=底图)
# 无模特图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计)
if "design_path" not in result:
print(f"{tag} 无设计稿,跳过合成")
elif model_img is not None:
composite_path = str(prod_dir / f"{img_code}_composite.png")
try:
# 三图合成:优先用简报的 composite_prompt(模板化三图文案),回退内置 MODEL_WEAR_PROMPT
wear_prompt = (brief.get("composite_prompt") or "").strip() or MODEL_WEAR_PROMPT
print(f"{tag} 三图合成提交中(3 参考图 img2img,网关处理约 2-6 分钟,请耐心等待)…")
t0 = time.time()
ib.print(wear_prompt, str(model_img), composite_path,
brief.get("composite_negative", ""),
extra_images=[design_path, str(basemap_img)], # 图2印花, 图3底图
size="1504x2000") # 三合一统一 1504x2000
result["composite_path"] = composite_path
print(f"{tag} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s: {composite_path}")
except Exception as e: # noqa: BLE001
# 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次)
print(f"{tag} 三图合成失败,退避重试…: {e}")
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
brief.get("composite_negative", ""),
extra_images=[design_path, str(basemap_img)], size="1504x2000")
if retried is not None:
result["composite_path"] = composite_path
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s: {composite_path}")
else:
errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""})
print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
return None
else:
printed_path = str(prod_dir / f"{img_code}_printed.png")
try:
# 两图合成(无模特):用平铺印图文案(wearable_prompt),回退旧 composite_prompt
flat_prompt = (brief.get("wearable_prompt") or "").strip() or brief.get("composite_prompt", "")
ib.print(flat_prompt, str(basemap_img), printed_path,
brief.get("composite_negative", ""),
extra_images=[design_path], # 图2印花
size="1504x2000") # 合成统一 1504x2000
result["printed_path"] = printed_path
print(f"{tag} 平铺服装图已生成(无模特,底图+印花): {printed_path}")
except Exception as e: # noqa: BLE001
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
brief.get("composite_negative", ""),
extra_images=[design_path], size="1504x2000")
if retried is not None:
result["printed_path"] = printed_path
print(f"{tag} 平铺服装图重试成功: {printed_path}")
else:
errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""})
print(f"{tag} 平铺服装图重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
return None
# 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由
color_composites: List[Dict[str, Any]] = []
if model_img is not None and result.get("composite_path"):
# 首色主图始终记录(单色/多色都走模板填充)
color_composites.append({"sku_code": chosen_sku, "color": result.get("color", ""),
"composite_path": result["composite_path"]})
for sc in sku_codes[1:]:
bm = find_basemap(basemap_root, spu["code"], sc)
if bm is None:
print(f"{tag} 颜色 {sc} 无底图,跳过该色三合一")
continue
cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png")
try:
ib.print(MODEL_WEAR_PROMPT, str(model_img), cp,
brief.get("composite_negative", ""),
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
size="1504x2000") # 三合一统一 1504x2000
col = next((c["color"] for c in colors if c["sku_code"] == sc), sc)
color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
print(f"{tag} 颜色 {sc}{col})三合一已生成: {cp}")
except Exception as e: # noqa: BLE001
errors.append({"node": "product", "type": type(e).__name__,
"message": f"颜色 {sc} 三合一失败: {e}", "trace": ""})
result["color_composites"] = color_composites
# 7.5) 多模态标题生成:合成图/平铺图/设计稿 → 中英双语 SEO 标题(按国家路由模板)
if title_backend is not None:
title_img = (result.get("composite_path") or result.get("printed_path")
or result.get("design_path"))
if title_img:
t = title_backend.generate_title(title_img, country=country)
if t.get("en_title") or t.get("cn_title") or t.get("ja_title"):
result["en_title"] = t.get("en_title", "")
result["cn_title"] = t.get("cn_title", "")
result["ja_title"] = t.get("ja_title", "")
print(f"{tag} 标题已生成: EN={t.get('en_title','')[:50]}... "
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}...")
return result
@with_fallback("product")
def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
config = state["config"]
pcfg = config.get("product") or {}
stats = dict(state.get("stats") or {})
if not pcfg.get("enabled", True):
return {"product": [], "stats": stats}
country = state["country"]
briefs = state.get("briefs") or []
output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹)
cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录)
errors = list(state.get("errors") or [])
# 路径解析:相对路径 → 优先运行根(exe 旁自定义数据),其次数据根(打包=_MEIPASS 内置)
def _abs(key: str, default: str) -> Path:
p = Path(pcfg.get(key, default))
if p.is_absolute():
return p
for root in (runtime_root(), project_root()):
cand = root / p
if cand.exists():
return cand
return project_root() / p
db_path = _abs("db_path", "db/spu_sku.db")
basemap_root = _abs("basemap_dir", "basemap")
material_root = _abs("material_library_dir", "material_library")
category = pcfg.get("model_category", "T-shirt")
brief_index = int(pcfg.get("brief_index", 0))
spu_code = (pcfg.get("spu_code") or "").strip()
sku_code = (pcfg.get("sku_code") or "").strip()
spu_tasks = pcfg.get("spu_tasks") or []
spu_count = int(pcfg.get("spu_count") or 0)
# 1) 选简报(优先 safe
safe = [b for b in briefs if b.get("risk_level") == "safe"] or briefs
if not safe:
print("[product] 无可用简报,跳过产品图生成")
return {"product": [], "stats": stats}
brief = safe[brief_index] if brief_index < len(safe) else safe[0]
# 2) 图像后端
backend_name = (pcfg.get("backend") or "").strip()
ib = None
if backend_name:
from graph.backends import get_image_backend
ib = get_image_backend(backend_name)
if ib is not None:
ib.bind_config(config.get("compose") or {}) # 复用 compose.api_key/model/size
# 3) 构造款号工作清单:spu_tasks(多款号)优先;否则 spu_code / 自动第一个
spus = list_spus(db_path)
worklist: List[tuple] = [] # (spu, skus, brief) —— 每个款号可绑定自己的热点简报
by_topic = {str(b.get("topic", "")).lower(): b for b in briefs}
if not spus:
print(f"[product] db 无 SPU 数据({db_path}),跳过")
return {"product": [], "stats": stats}
if spu_tasks:
for ti, t in enumerate(spu_tasks):
code = (t.get("spu") or t.get("spu_code") or "").strip()
spu = next((s for s in spus if s["code"] == code), None)
if spu is None:
print(f"[product] 任务款号 {code} 不在 db,跳过(可选: {[s['code'] for s in spus][:12]}")
continue
tb = None
tp = (t.get("topic") or "").strip()
if tp:
tb = by_topic.get(tp.lower())
if tb is None:
print(f"[product] 任务热点「{tp}」不在简报中,回退按序号分配")
if tb is None:
# 未指定热点(完整流水线):按任务序号取不同简报,避免多个产品用同一个
idx = min(ti, len(safe) - 1) if safe else brief_index
tb = safe[idx] if safe else None
if tb is None:
print(f"[product] 无可用简报,跳过任务 {code}")
continue
worklist.append((spu, (t.get("skus") or "").strip(), tb))
if not worklist:
print("[product] 任务清单无有效款号,跳过产品图生成")
return {"product": [], "stats": stats}
elif spu_code:
spu = next((s for s in spus if s["code"] == spu_code), None)
if spu is None:
print(f"[product] 款号 {spu_code} 不在 db,可选: {[s['code'] for s in spus][:12]}")
return {"product": [], "stats": stats}
worklist.append((spu, sku_code, brief))
else:
spu = next((s for s in spus if first_available_sku(db_path, basemap_root, s["code"])), None)
if spu is None:
print(f"[product] 没有任何款号存在本地底图({basemap_root}/<款号>/<SKU.code>/")
return {"product": [], "stats": stats}
worklist.append((spu, sku_code, brief))
# 4) 不再按 spu_count 截断:worklist 已是扩展后的完整任务(数量 N = 每款设计数),
# 全部任务进入队列处理(并发 5)。
prod_dir = output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
# 4.1) 任务级模特分配(material_library-<category>):
# 一个 SPU 对应一个模特;SPU(不同款)数 > 模特数 → 从全部模特循环兜底(允许重复)
model_assign: Dict[str, Any] = {}
_all_models: List[str] = []
try:
_folder, _all_models = find_first_model_folder(material_root, category)
except Exception: # noqa: BLE001
_all_models = []
if _all_models:
seen_spu: Dict[str, str] = {}
for _i, (_spu, _skus, _tb) in enumerate(worklist):
code = _spu.get("code", "")
if code not in seen_spu:
seen_spu[code] = _all_models[_i % len(_all_models)] # SPU>模特数 → 循环兜底
model_assign[code] = seen_spu[code]
print(f"[product] 任务级模特分配:{len(seen_spu)} 个 SPU,模特池 {len(_all_models)}"
f"{'SPU>模特,循环兜底)' if len(seen_spu) > len(_all_models) else ''}")
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
designs_dir = output_dir / "designs"
designs_dir.mkdir(parents=True, exist_ok=True)
designs_map = {str(d.get("topic", "")).strip().lower(): d.get("path", "")
for d in (state.get("designs") or []) if isinstance(d, dict)}
for _d in (state.get("briefs") or []):
if isinstance(_d, dict) and _d.get("design_path"):
designs_map.setdefault(str(_d.get("topic", "")).strip().lower(), _d.get("design_path"))
def _resolve_design(tb) -> Optional[str]:
"""任务绑定的简报 → 该热点自己的设计稿路径(按货号命名拷贝到 designs/)。"""
topic = str(tb.get("topic", "")).strip().lower()
src = designs_map.get(topic) or tb.get("design_path")
if not src or not Path(src).exists():
return None
return src
# 6) 逐个款号处理(每款号用自己绑定的热点简报)
title_backend = None
ls_cfg = config.get("llm_screen") or {}
if (ls_cfg.get("provider") or "") not in ("", "mock"):
try:
from graph.llms import get_backend as _glb
_tb = _glb(ls_cfg.get("provider"))
_tb.bind_config(ls_cfg)
if _tb.has_key:
title_backend = _tb
except Exception as e: # noqa: BLE001
print(f"[product] 标题后端初始化失败: {e}")
results: List[Dict[str, Any]] = []
prefix = str(pcfg.get("code_prefix") or "DG").strip()
# 并发:默认每个 SPU 一个独立线程(任务数即并发数,提速);
# config.product.concurrency 显式配置可覆盖(如限流时设 3-5);
# 默认并发上限 5:全量并发(任务数)会压垮图像网关(10 并发 → 全部超时),
# 超出上限的任务在线程池排队,逐批处理
concurrency = int(pcfg.get("concurrency") or 0) or min(len(worklist), 5)
print(f"[product] 并发 {concurrency}(每 SPU 一线程,上限 {concurrency})处理 {len(worklist)} 个产品任务")
def _run_one(idx: int, spu, skus, tb):
"""并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
img_code = f"{prefix}{idx:03d}" # 货号:图片按此命名(DG000_design.png…)
try:
# 每个任务用自己的热点设计(designs_map),并拷贝为货号命名(designs/DG000_design.png
design_src = _resolve_design(tb)
design_path = None
if design_src:
design_path = str(designs_dir / f"{img_code}_design.png")
try:
shutil.copy2(design_src, design_path)
except Exception: # noqa: BLE001
design_path = design_src
tb = dict(tb)
tb["design_path"] = design_path
r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
tb, ib, spu, skus, pcfg, errors, design_path, title_backend,
country, img_code=img_code,
model_img=model_assign.get(spu.get("code", "")))
if r:
r["img_code"] = img_code
return r, img_code
except Exception as e: # noqa: BLE001 # 单产品任何异常都不拖垮整体
print(f"[product/{img_code}] 产品处理异常(跳过该产品): {e}")
return None, img_code
import concurrent.futures
# 货号自动续号:任务一开始全部按序分配(start_idx 起),不覆盖已生成的产物
start_idx = _next_img_idx(prod_dir, prefix)
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
futures = [ex.submit(_run_one, start_idx + i, spu, skus, tb)
for i, (spu, skus, tb) in enumerate(worklist)]
for f in concurrent.futures.as_completed(futures):
r, img_code = f.result()
if r:
results.append(r)
try:
_record_used(cache_dir, r) # (热点-风格) 去重记录 → 缓存根目录
except Exception: # noqa: BLE001
pass
results.sort(key=lambda x: x.get("img_code", "")) # 按货号排序,模板/清单顺序稳定
_write_products(prod_dir, results)
stats["product"] = {
"spus": [r.get("spu_code") for r in results],
"skus": [r.get("sku_code") for r in results],
"topic": brief.get("topic", ""),
"count": len(results),
"composite": sum(1 for r in results if r.get("composite_path")),
"printed": sum(1 for r in results if r.get("printed_path")),
"templates": sum(1 for r in results if r.get("template_path")),
"output_dir": str(prod_dir),
}
return {"product": results, "stats": stats, "errors": errors}
def _record_used(output_dir: Path, r: Dict[str, Any]):
"""记录已用 (热点-风格-配色),供后续去重:output/<国家>/used_designs.json。"""
topic = r.get("topic", "")
if not topic:
return
with _USED_LOCK: # 并发下 used_designs.json 读写互斥
p = output_dir / "used_designs.json"
used = []
if p.exists():
try:
used = json.loads(p.read_text(encoding="utf-8")).get("used", []) or []
except Exception:
used = []
entry = {
"topic": topic,
"art_style": r.get("art_style", ""),
"color_palette": r.get("color_palette", ""),
"spu_code": r.get("spu_code", ""),
"sku_code": r.get("sku_code", ""),
"date": time.strftime("%Y-%m-%d"),
}
# 同 (topic, art_style) 已记录则跳过,避免去重记录重复堆积
if any(str(u.get("topic", "")).strip().lower() == str(entry["topic"]).strip().lower()
and str(u.get("art_style", "")).strip().lower() == str(entry["art_style"]).strip().lower()
for u in used):
return
used.append(entry)
p.write_text(json.dumps(
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "used": used},
ensure_ascii=False, indent=2), encoding="utf-8")
def _write_products(prod_dir: Path, products: List[Dict[str, Any]]):
(prod_dir / "products.json").write_text(
json.dumps({"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "products": products},
ensure_ascii=False, indent=2), encoding="utf-8")
+90
View File
@@ -0,0 +1,90 @@
"""节点 5/6:提示词构造(prompt_build)。
读取 prompts/<country>/ 的 extra 风格规则,用固定模板装配四种最终提示词
image_prompt / wearable_prompt / composite_prompt / composite_negative)。
四要素缺失时用 derive_style_palette 动态兜底,保证每条提示词结构一致、有规则。
"""
from typing import Any, Dict, List
import random
from graph.style_rules import derive_style_palette, derive_composition
from graph.templates import assemble_prompts
from graph.validate import validate_brief, with_fallback
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
_IMG_RISKY_SWAP = {
"skull": "smiley mascot", "skeleton": "cute mascot", "blood": "red accents",
"gore": "bold shapes", "gun": "star", "weapon": "tool", "bomb": "firework",
"drug": "confetti", "demon": "cute monster", "devil": "mischievous imp",
"occult": "mystic pattern", "satanic": "dark pattern", "nazi": "retro emblem",
"hitler": "retro emblem", "zombie": "friendly ghoul", "horror": "spooky-cute",
"vampire": "night owl", "politics": "abstract shapes", "political": "abstract",
"president": "captain", "army": "team", "police": "officer",
}
def _safe_motif(motif: str) -> str:
"""清洗 motif 中的图像策略敏感词(替换为安全等效描述),降低生图内容政策拦截率。"""
low = motif.lower()
for k, v in _IMG_RISKY_SWAP.items():
if k in low:
# 按词边界替换(避免误伤 "letterhead" 等)
import re
motif = re.sub(rf"\b{re.escape(k)}\b", v, motif, flags=re.IGNORECASE)
low = motif.lower()
return motif
@with_fallback("prompt_build")
def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
screened: List[Dict[str, Any]] = state.get("screened") or []
config = state["config"]
country = state["country"]
cc = state["country_config"]
extra_rules = cc.get("extra_style_rules") or []
tpls = config.get("prompt_templates") or {}
briefs: List[Dict[str, Any]] = []
for r in screened:
r = validate_brief(r)
art, pal = derive_style_palette(
r["topic"], country, extra_rules=extra_rules, category=r.get("design_category")
)
motif = (r.get("motif") or "").strip() or r.get("topic", "")
cleaned = _safe_motif(motif)
if cleaned != motif:
print(f"[prompt] motif 敏感词清洗: 「{motif}」→「{cleaned}」(降低生图内容政策拦截)")
r["motif"] = cleaned
motif = cleaned
art_style = (r.get("art_style") or art).strip()
palette = (r.get("color_palette") or pal).strip()
composition = (r.get("composition") or derive_composition(r["topic"], r.get("design_category"))).strip()
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
# 文字印花(约 30% 概率):简报有 slogan 时,随机注入文字段到设计稿提示词
slogan = (r.get("slogan") or "").strip()
if slogan and random.random() < float(config.get("prompt_templates", {}).get("text_ratio", 0.3)):
text_seg = (f', with the text "{slogan}" rendered as bold retro typography, '
f'lettering clean and correctly spelled, high contrast, as the focal text of the print')
prompts["image_prompt"] = prompts["image_prompt"] + text_seg
r["used_slogan"] = slogan
# review(疑似商标/受保护主题)→ 动态注入「原创化魔改」引导:只做风格参考,禁止复刻品牌/商标/角色,
# 换名换细节,生成通用非侵权的致敬式设计
if str(r.get("risk_level", "")).strip().lower() == "review":
prompts["image_prompt"] = (prompts["image_prompt"]
+ " IMPORTANT: this theme is ONLY a loose stylistic reference. "
"Do NOT reproduce any brand logo, trademark, character, mascot, copyrighted artwork or real person. "
"Create a fully ORIGINAL design with a different name and distinct visual details and colors — "
"a generic, non-infringing homage in the same mood, clearly distinct from the original.")
print(f"[prompt] review 简报注入原创化魔改引导: 「{r['topic']}")
r.update(prompts)
r["motif"] = motif
r["art_style"] = art_style
r["color_palette"] = palette
r["composition"] = composition
briefs.append(r)
stats = dict(state.get("stats") or {})
stats["prompt"] = {"briefs": len(briefs)}
return {"briefs": briefs, "stats": stats}
+26
View File
@@ -0,0 +1,26 @@
"""节点 3/6:打分(score)。
归一化(按 source/kind 分组 min-max-> 跨源融合(combine-> 综合分阈值预筛。
纯逻辑节点,with_fallback 兜底。
"""
from typing import Any, Dict, List
from graph.scoring import combine, normalize
from graph.validate import with_fallback
@with_fallback("score")
def score_node(state: Dict[str, Any]) -> Dict[str, Any]:
rows: List[Dict[str, Any]] = state.get("filtered_rows") or []
config = state["config"]
weights = config.get("weights") or {}
llm_cfg = config.get("llm_screen") or {}
min_score = float(llm_cfg.get("min_score", 0.0))
normalize(rows)
combined = combine(rows, weights)
combined = [c for c in combined if float(c.get("score", 0)) >= min_score]
stats = dict(state.get("stats") or {})
stats["score"] = {"combined": len(combined)}
return {"scored_rows": combined, "stats": stats}
+127
View File
@@ -0,0 +1,127 @@
"""节点 4/6:合规筛选(screen)。
调用可插拔 LLM 后端做合规筛查 + 结构化四要素;后端调用失败时降级 MockBackend。
按 topic 把筛查结果映射回 scored 候选(补 score/sources/country),再做最终风险过滤
blocked 丢弃;review 按 keep_review 决定)。
"""
from pathlib import Path
from typing import Any, Dict, List
from graph.loader import load_system_prompt
from graph.llms import DEFAULT_SYSTEM_PROMPT, get_backend
from graph.style_rules import COUNTRY_AESTHETICS
from graph.validate import with_fallback
@with_fallback("screen")
def screen_node(state: Dict[str, Any]) -> Dict[str, Any]:
country = state["country"]
scored: List[Dict[str, Any]] = state.get("scored_rows") or []
config = state["config"]
cc = state["country_config"]
llm_cfg = config.get("llm_screen") or {}
provider = llm_cfg.get("provider", "mock")
keep_review = bool(llm_cfg.get("keep_review", False))
batch_size = int(llm_cfg.get("max_topics_per_call", 12))
blacklist = [str(b).lower() for b in (config.get("blacklist") or [])]
prompts_dir = Path(state["prompts_dir"])
system_prompt = load_system_prompt(prompts_dir, DEFAULT_SYSTEM_PROMPT)
aesthetic_hint = cc.get("style_hint") or COUNTRY_AESTHETICS.get(country, {}).get("style_hint", "")
# 排除已用热点(去重生效:已用 topic 不再进入本次简报,每次跑都用新热点)
try:
import json as _json
used_p = Path(state.get("cache_dir") or state.get("output_dir", "")) / "used_designs.json"
if used_p.exists():
ud = _json.loads(used_p.read_text(encoding="utf-8")).get("used", []) or []
used_topics = {str(u.get("topic", "")).strip().lower() for u in ud}
before = len(scored)
scored = [c for c in scored if str(c.get("topic", "")).strip().lower() not in used_topics]
if len(scored) < before:
print(f"[screen] 排除已用热点 {before - len(scored)} 条(去重),剩余 {len(scored)} 条可选")
except Exception: # noqa: BLE001
pass
# 简报数量 = 用多少生成多少:llm_screen.max_briefs 配置优先,否则按扩展后的总任务数
#(每个产品一个热点;数量 N=每个款-颜色条目的设计数 → 总任务=条目数×N);
# 前台显示不依赖简报(读 collected 完整池 + used_designs 剔除已用,用完即从前台消失)。
pcfg = config.get("product") or {}
ls_cfg = config.get("llm_screen") or {}
limit = int(ls_cfg.get("max_briefs") or 0)
if not limit and pcfg.get("spu_tasks"):
limit = len(pcfg.get("spu_tasks") or []) # 扩展后总任务数(=产品数)
if not limit:
limit = int(pcfg.get("spu_count") or 0)
if limit > 0:
ordered = sorted(scored, key=lambda c: -(float(c.get("score") or 0)))
scored_limited = ordered[:limit]
print(f"[screen] 简报限量 {limit} → 筛前 {len(scored_limited)} 个高分热点(共 {len(scored)} 个)")
else:
scored_limited = scored
print(f"[screen] 简报全量 {len(scored_limited)} 条(未限量,全部生成简报)")
topics = [c["topic"] for c in scored_limited]
backend = get_backend(provider)
if provider != "mock":
backend.bind_config(llm_cfg)
if not backend.has_key:
print("[screen] 未检测到 LLM api_key(请配置 llm_screen.api_key 或环境变量 "
"LLM_API_KEY/OPENAI_API_KEY),降级 Mock 兜底。")
backend = get_backend("mock")
try:
screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size)
except Exception as e: # noqa: BLE001
print(f"[screen] {provider} 调用失败,降级 Mock: {e}")
backend = get_backend("mock")
screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size)
# 映射回 scored 候选
by_topic = {s.get("topic", "").lower(): s for s in screened}
out: List[Dict[str, Any]] = []
missing: List[Dict[str, Any]] = []
for it in scored_limited:
s = by_topic.get(it["topic"].lower())
if s is None:
missing.append(it)
continue
s = dict(s)
s["country"] = country
s["score"] = it.get("score", 0)
s["sources"] = it.get("sources", "")
out.append(s)
# LLM 漏判的主题用 Mock 单独补全,避免丢数据(而非整批降级)
if missing:
print(f"[screen] LLM 漏判 {len(missing)} 个主题,用 Mock 单独补全:"
f"{[m['topic'] for m in missing]}")
mock = get_backend("mock")
m_res = mock.screen(
[m["topic"] for m in missing], country, aesthetic_hint,
system_prompt, blacklist, batch_size,
)
m_by = {r.get("topic", "").lower(): r for r in m_res}
for it in missing:
s = m_by.get(it["topic"].lower())
if s is None:
continue
s = dict(s)
s["country"] = country
s["score"] = it.get("score", 0)
s["sources"] = it.get("sources", "")
out.append(s)
# 最终风险过滤:只滤 blocked(硬拦截);review(待复核)保留——
# 由 assign_hotspots 的 allow_review 决定是否参与分配(openai 模式 review+concept 可用),
# 避免 review 被静默丢弃导致"任务 N 个但简报不足、设计缺失"
kept: List[Dict[str, Any]] = []
for r in out:
lvl = r.get("risk_level", "safe")
if lvl == "blocked":
continue
kept.append(r)
stats = dict(state.get("stats") or {})
stats["screen"] = {"screened": len(out), "kept": len(kept)}
return {"screened": kept, "stats": stats}
+190
View File
@@ -0,0 +1,190 @@
"""节点 0/6:动态种子词(seed)。
在 fetch 之前运行:收集「trending 派生 + 历史 safe 热点 + 月份/节日」上下文,
按 seed_provider 策略(static / mock / LLM)生成/合并种子词,注入 country_config 的
style.seeds / related.seed_keywords,供后续 fetch 的 related_queries 展开使用。
关键机制:动态种子词按 (国家, provider, 日期) 缓存(.cache/seeds/)。
同一天内多次运行使用同一套种子词 → related_queries 的 24h 缓存稳定命中,
避免「history 每次跑完都变 → 种子词震荡 → Google 反复全量重抓 → 429 限流」。
带 with_fallback:任何异常都降级为"仅用 yaml 静态种子",不阻塞整图。
"""
import datetime
import hashlib
import json
import os
from pathlib import Path
from typing import Any, Dict
from graph.llms import get_backend
from graph.llms.mock_backend import COMMON_RISK_WORDS
from graph.paths import runtime_root
from graph.scoring import filter_person_names, filter_query_noise
from graph.seeds import get_seed_strategy
from graph.seeds.holidays import build_holiday_context
from graph.sources.google_trends_source import fetch_trending
from graph.validate import with_fallback
_CACHE_DIR = runtime_root() / ".cache" / "seeds"
def _cache_key(country: str, provider: str, cfg: Dict[str, Any]) -> str:
"""缓存键含「配置指纹」:改了种子相关参数(数量/上下文上限)即换新键重新生成,
避免命中旧参数生成的种子;旧文件保留(不删缓存,取最新)。"""
fp = hashlib.md5(
json.dumps(
{k: cfg.get(k) for k in ("max_style_seeds", "max_related_seeds",
"trending_context_limit", "history_limit")},
sort_keys=True, ensure_ascii=False,
).encode("utf-8")
).hexdigest()[:8]
return f"{country}-{provider}-{fp}-{datetime.date.today().isoformat()}"
def _cache_get(key: str):
try:
p = _CACHE_DIR / f"{key}.json"
if p.exists():
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
pass
return None
def _cache_set(key: str, val: Dict[str, Any]):
try:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
(_CACHE_DIR / f"{key}.json").write_text(
json.dumps(val, ensure_ascii=False), encoding="utf-8")
except Exception:
pass
@with_fallback("seed")
def seed_node(state: Dict[str, Any]) -> Dict[str, Any]:
country = state["country"]
config = state["config"]
cc = dict(state.get("country_config") or {})
errors = list(state.get("errors") or [])
provider = (config.get("seed_provider") or "mock").strip().lower()
cfg = config.get("seed_provider_cfg") or {}
trending_limit = int(cfg.get("trending_context_limit", 15))
history_limit = int(cfg.get("history_limit", 20))
max_style = int(cfg.get("max_style_seeds", 12))
max_related = int(cfg.get("max_related_seeds", 12))
guard = COMMON_RISK_WORDS + [b.lower() for b in (config.get("blacklist") or [])]
ckey = _cache_key(country, provider, cfg)
cached = _cache_get(ckey) if provider != "static" else None
from_cache = cached is not None
if cached is not None:
res = cached
context: Dict[str, Any] = {
"country": country,
"max_style_seeds": max_style,
"max_related_seeds": max_related,
}
else:
# 1) 收集上下文
context: Dict[str, Any] = {
"country": country,
"max_style_seeds": max_style,
"max_related_seeds": max_related,
}
try:
tl = int((cc.get("trending") or {}).get("limit", 40))
rows = fetch_trending(geo=country, limit=min(tl, 40))
# 保留 rows 自带的 source=gt_trendingfilter_person_names 的人名模式仅对该源生效
kept, _ = filter_query_noise(rows, enabled=True)
kept, _ = filter_person_names(kept)
kept = [r for r in kept if not any(w and w in r["topic"].lower() for w in guard)]
context["trending_seeds"] = [r["topic"] for r in kept][:trending_limit]
except Exception as e: # noqa: BLE001
print(f"[seed] trending 上下文收集失败(跳过): {e}")
context["trending_seeds"] = []
try:
p = os.path.join(state.get("output_dir", ""), "design_briefs.json")
if os.path.exists(p):
data = json.load(open(p, encoding="utf-8")).get("design_briefs", [])
safe = [d for d in data if d.get("risk_level") == "safe"]
safe.sort(key=lambda d: -(d.get("score") or 0))
hrows = [{"topic": d["topic"], "source": "history"} for d in safe]
hrows, _ = filter_person_names(hrows, pattern_sources={"history"})
hrows = [r for r in hrows if not any(w and w in r["topic"].lower() for w in guard)]
context["history_hotspots"] = [r["topic"] for r in hrows][:history_limit]
else:
context["history_hotspots"] = []
except Exception as e: # noqa: BLE001
print(f"[seed] 历史热点读取失败(跳过): {e}")
context["history_hotspots"] = []
# 月份/节日(按国家:各国节日表不同)
hol = build_holiday_context(country)
context["season"] = hol["season"]
context["year"] = hol["year"]
context["month"] = hol["month"]
context["date"] = hol["date"]
context["month_themes"] = hol["month_themes"]
context["upcoming_holidays"] = hol["upcoming_holidays"]
# 2) 选策略 + LLM 后端
strategy = get_seed_strategy(provider)
llm_backend = None
if provider != "static":
llm_backend = get_backend(provider)
# 注入 llm_screen 配置(api_key/base_url/model),否则 has_key 永远 False 降级 mock
try:
llm_backend.bind_config(config.get("llm_screen") or {})
except Exception as e: # noqa: BLE001
print(f"[seed] LLM 配置绑定失败: {e}")
if provider not in ("mock",) and not getattr(llm_backend, "has_key", False):
print(f"[seed] {provider} 未配置 API key,降级 mock 规则生成种子词")
llm_backend = get_backend("mock")
# 3) 生成/合并种子词
try:
res = strategy.resolve(country, cc, context, llm_backend)
except Exception as e: # noqa: BLE001
print(f"[seed] 策略解析失败,回退静态种子: {e}")
res = {
"style_seeds": list((cc.get("style", {}) or {}).get("seeds", []) or []),
"related_seeds": list((cc.get("related", {}) or {}).get("seed_keywords", []) or []),
"dynamic": False,
}
if provider != "static":
_cache_set(ckey, res)
# 4) 注入 cc
style_block = dict(cc.get("style") or {})
related_block = dict(cc.get("related") or {})
style_block["seeds"] = res["style_seeds"]
related_block["seed_keywords"] = res["related_seeds"]
cc["style"] = style_block
cc["related"] = related_block
stats = dict(state.get("stats") or {})
stats["seed"] = {
"provider": provider,
"dynamic": res.get("dynamic", False),
"from_cache": from_cache,
"style_count": len(res["style_seeds"]),
"related_count": len(res["related_seeds"]),
"trending_ctx": len(context.get("trending_seeds", [])),
"history_ctx": len(context.get("history_hotspots", [])),
"holidays": context.get("upcoming_holidays", []),
}
print(
f"[seed] provider={provider}{'(当日缓存命中)' if from_cache else ''} "
f"种子词 style={len(res['style_seeds'])} related={len(res['related_seeds'])}"
)
return {
"country_config": cc,
"seed_words": res,
"stats": stats,
"errors": errors,
}
+133
View File
@@ -0,0 +1,133 @@
"""节点 8/8:种草图生成(seed_shot)——在 oss_upload 之后。
对每个 product 的合成图(图1),按 seed_shot_templates.yaml 模板 + model_features.yaml 随机模特特征
生成 N 张种草图(config.seed_shot.count,默认 1):
- [商品名称] ← product 的 cn_title(上一节点多模态生成)
- [材质] ← 数据库 SPU.material 字段
- [模特特征] ← model_features.yaml 随机一条
种草图同样压缩上传到 OSS(货号计数与 oss_upload 共用 state["oss_seq"] 续接)。
未配置图像后端 / 无合成图 / count=0 时跳过,不中断。
"""
import time
from pathlib import Path
from typing import Any, Dict, List
from graph.validate import with_fallback
@with_fallback("seed_shot")
def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
products: List[Dict[str, Any]] = state.get("product") or []
config = state["config"] or {}
country = state.get("country", "")
output_dir = Path(state["output_dir"])
ss_cfg = config.get("seed_shot") or {}
count = int(ss_cfg.get("count", 1))
if not bool(ss_cfg.get("enabled", True)) or count <= 0 or not products:
return {"seed_shots": [], "stats": state.get("stats") or {}}
# 图像后端(复用 compose 配置)
compose_cfg = config.get("compose") or {}
ib = None
if compose_cfg.get("backend"):
from graph.backends import get_image_backend
try:
ib = get_image_backend(compose_cfg["backend"])
if ib is not None:
ib.bind_config(compose_cfg)
except Exception as e: # noqa: BLE001
print(f"[seed_shot] 图像后端不可用: {e}")
if ib is None:
print("[seed_shot] 未配置 compose.backendopenai/mock),跳过种草图生成")
return {"seed_shots": [], "stats": state.get("stats") or {}}
# 材质映射:db SPU.material(清洗换行)
material_map: Dict[str, str] = {}
try:
from graph.product import list_spus
import yaml
dbp = (config.get("product") or {}).get("db_path", "db/spu_sku.db")
p = Path(dbp)
if not p.is_absolute():
from graph.paths import project_root, runtime_root
for root in (runtime_root(), project_root()):
if (root / p).exists():
p = root / p
break
for s in list_spus(str(p)):
m = " ".join(str(s.get("material", "")).replace("\r", " ").replace("\n", " ").split())
material_map[s["code"]] = m
except Exception as e: # noqa: BLE001
print(f"[seed_shot] 材质读取失败(用空): {e}")
from graph.seed_shot import generate_seed_shots
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
from graph.nodes.oss_upload_node import _gen_rand4, MAX_CODE
ts = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S"))
prefix = str(((config.get("product") or {}).get("code_prefix")) or "DG").strip()
seq = int(state.get("oss_seq") or 0)
oss_cfg = config.get("oss") or {}
oss_enabled = bool(oss_cfg.get("enabled", True)) and bool(oss_cfg.get("oss_bucket"))
all_shots: List[Dict[str, Any]] = []
shot_dir = output_dir / "seed_shots"
shot_dir.mkdir(parents=True, exist_ok=True)
import concurrent.futures
import threading as _th
seq_lock = _th.Lock() # seq(货号计数)跨线程共享,需加锁
def _shot_one(r: Dict[str, Any]):
"""单个产品的种草图生成+上传(每产品独立线程)。"""
nonlocal seq
base = r.get("composite_path") or r.get("printed_path")
if not base or not Path(base).exists():
print(f"[seed_shot] {r.get('spu_code', '')} 无合成图,跳过种草图")
return None
cn = (r.get("cn_title") or "").strip() or r.get("topic", "")
material = material_map.get(r.get("spu_code", ""), "")
paths = generate_seed_shots(ib, base, cn, material, count, str(shot_dir),
r.get("composite_negative", ""),
size=str((config.get("seed_shot") or {}).get("size") or "1504x2000"),
prefix=r.get("img_code") or r.get("oss_code") or "")
if not paths:
return None
r["seed_shot_paths"] = paths
urls: List[str] = []
for pth in paths:
with seq_lock:
if seq >= MAX_CODE:
print(f"[seed_shot] 货号计数达上限 999,停止上传种草图")
break
code = f"{prefix}{seq:03d}"
seq += 1
if oss_enabled:
try:
compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg")))
url = upload_to_oss(oss_cfg, compressed,
build_oss_key(country, ts, code, _gen_rand4()))
if url:
urls.append(url)
r["seed_shot_urls"] = urls
except Exception as e: # noqa: BLE001
print(f"[seed_shot] 种草图上传失败 {pth}: {e}")
else:
print(f"[seed_shot] oss 未启用,仅本地保存: {pth}")
return {"spu_code": r.get("spu_code"), "sku_code": r.get("sku_code"),
"paths": paths, "urls": urls}
# 并发:每个产品一个独立线程(默认);config.seed_shot.concurrency 可覆盖
seed_concurrency = int((config.get("seed_shot") or {}).get("concurrency") or 0) or len(products) or 1
if len(products) > 1:
print(f"[seed_shot] 并发 {seed_concurrency} 生成种草图({len(products)} 个产品)")
with concurrent.futures.ThreadPoolExecutor(max_workers=seed_concurrency) as _ex:
for item in _ex.map(_shot_one, products):
if item:
all_shots.append(item)
stats = dict(state.get("stats") or {})
stats["seed_shot"] = {"count": len(all_shots), "seq": seq}
return {"seed_shots": all_shots, "product": products, "oss_seq": seq, "stats": stats}
+124
View File
@@ -0,0 +1,124 @@
"""节点 9/9:商品上传模板导出(template_export)——在 seed_shot 之后。
把最终结果导入模板:
- SPU货号 / SKU货号 = 设计货号(oss_code,前缀+3位计数)
- 商品名称 = cn_title(多模态标题生成)
- 英文名称 = en_title
- 商品轮播图1:SKU 行按颜色路由(该颜色三合一链接),SPU 行随机一张
- 详情图文(SPU 行):全部三合一主图链接 + 种草图链接,| 分割
需在 oss_upload / seed_shot 之后运行(图床链接与货号已生成)。
"""
import time
from pathlib import Path
from typing import Any, Dict, List
from graph.paths import project_root, runtime_root
from graph.validate import with_fallback
def _template_out_path(prod_dir: Path, tpl_name: str) -> Path:
"""模板输出路径:默认 {tpl_name}_已填写.xlsx;已存在/被占用则自动换名加序号(同款号多产品不互相覆盖)。"""
base = prod_dir / f"{tpl_name}_已填写.xlsx"
try:
with open(base, "ab"):
pass
except OSError:
pass
else:
if not base.exists():
return base
for i in range(2, 100):
cand = prod_dir / f"{tpl_name}_已填写_{i}.xlsx"
if not cand.exists():
return cand
return prod_dir / f"{tpl_name}_已填写_{int(time.time())}.xlsx"
@with_fallback("template_export")
def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
products: List[Dict[str, Any]] = state.get("product") or []
config = state["config"] or {}
pcfg = config.get("product") or {}
output_dir = Path(state["output_dir"])
errors = list(state.get("errors") or [])
stats = dict(state.get("stats") or {})
# 模板写入时机:所有集合/产品(含种草图、OSS)全部完成后才执行本节点
print(f"[template] 全部集合({len(products)} 个产品)处理完成,开始统一写入模板…")
tp = (pcfg.get("template_path") or "").strip()
if not tp:
print("[template] 未配置 product.template_path,跳过模板导出")
return {"stats": stats, "errors": errors}
if not Path(tp).exists():
cand = None
for root in (runtime_root(), project_root()):
c = root / tp
if c.exists():
cand = str(c)
break
if cand:
tp = cand
else:
print(f"[template] 模板文件不存在: {tp}")
return {"stats": stats, "errors": errors}
# db 路径
dbp = (pcfg.get("db_path") or "db/spu_sku.db")
db_path = Path(dbp)
if not db_path.is_absolute():
for root in (runtime_root(), project_root()):
if (root / db_path).exists():
db_path = root / db_path
break
from graph.template_export import export_product
tdir = (pcfg.get("template_dir") or "").strip() or str(Path(tp).parent)
prod_dir = output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
exported: List[str] = []
skipped = 0
merged_out: Optional[str] = None # 合并模式:一次任务所有产品填同一个模板
is_first = True
for r in products:
# 失败跳过:合成图(composite/printed)与标题都失败的产品不写进模板
has_img = bool(r.get("composite_path") or r.get("printed_path"))
has_title = bool((r.get("cn_title") or "").strip())
if not (has_img and has_title):
skipped += 1
print(f"[template] 跳过失败产品 {r.get('spu_code')}/{r.get('img_code','')}: "
f"合成图={'' if has_img else ''} 标题={'' if has_title else ''}(不写入模板)")
continue
sku_codes = [cc.get("sku_code") for cc in (r.get("color_composites") or [])]
if not sku_codes:
sku_codes = [r.get("sku_code") or ""]
try:
if is_first:
merged_out = str(_template_out_path(prod_dir, "商品上传"))
out = export_product(
db_path, r.get("spu_code", ""), sku_codes, tdir, tp,
merged_out,
images=[],
spu_per_color=True, # 每颜色一个独立 SPU 块(单色多 SPU)
oss_code=r.get("oss_code") or (r.get("color_composites") or [{}])[0].get("code", ""),
cn_title=r.get("cn_title", ""),
en_title=r.get("en_title", ""),
ja_title=r.get("ja_title", ""),
composite_urls=r.get("color_composites") or [],
seed_shot_urls=r.get("seed_shot_urls") or [],
append_to="" if is_first else merged_out, # 首个产品从模板创建,后续追加合并
markup_percent=float(pcfg.get("markup_percent") or 0),
)
r["template_path"] = str(out)
exported.append(str(out))
print(f"[template] 商品上传模板已生成({len(exported)}/{len(products)} 合并): {out}")
except Exception as e: # noqa: BLE001
errors.append({"node": "template_export", "type": type(e).__name__,
"message": f"模板导出失败 {r.get('spu_code')}: {e}", "trace": ""})
print(f"[template] 模板导出失败 {r.get('spu_code')}: {e}")
is_first = False
stats["template_export"] = {"exported": len(exported)}
return {"product": products, "errors": errors, "stats": stats}