模板导出增强 + 模特性别分组 + 三合一提示词精简
1) 模板导出:识别「基码表-胸围」填 sku.bust(多个胸围列都填);申报价格模糊匹配多列统一按加价后价格填写;详情图文不再拼接 img_url_2;SPU 款式来源统一填「现货款」;商品产地国家简称映射(沙特→沙特阿拉伯) 2) 模特性别分组:model_features 按男女分组,按模板类目含男/女固定取对应性别模特(含 Pinterest 模式 pipeline) 3) 三合一提示词:去掉 DESIGN CONTENT 四要素描述(设计已由设计稿提供) 4) 生图尺寸:全部改为读 config 不再硬编码(设计图 compose.design_size / 合成图 compose.size / 种草图 seed_shot.size)
This commit is contained in:
+110
-57
@@ -10,13 +10,25 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"}
|
||||
|
||||
|
||||
def _notify_400(on_400, exc) -> None:
|
||||
"""HTTP 400(且含「内容/图片」)时触发 on_400 回调(供调用方累计放弃计数)。"""
|
||||
if on_400 is None:
|
||||
return
|
||||
try:
|
||||
from graph.pinterest import is_400_content_image
|
||||
if is_400_content_image(exc):
|
||||
on_400()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _build_briefs_md(briefs: List[Dict[str, Any]], generated_at: str) -> str:
|
||||
lines = [
|
||||
"# POD 印花设计简报(LLM 合规筛选 + 生图提示词)",
|
||||
@@ -96,6 +108,88 @@ def _build_report_md(state: Dict[str, Any]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
|
||||
errors: List[Dict[str, Any]] = None,
|
||||
seed: Optional[int] = None,
|
||||
on_400=None,
|
||||
size: str = "1024x1024") -> Optional[str]:
|
||||
"""生成单张纯印花设计稿(图2)。返回设计稿路径;失败 / 全局 MD5 重复返回 None。
|
||||
|
||||
out_stem: 输出文件名主干(不含扩展名),最终文件 = {out_stem}_design.png。
|
||||
货号模式传 img_code(如 DG000)→ designs/DG000_design.png;
|
||||
旧 compose 模式传 {country}_{idx:02d}(如 JP_01)→ designs/JP_01_design.png。
|
||||
Pinterest 参考模式:简报带 ref_images(爬取图)→ 用 ib.print() 图生图,
|
||||
把爬取图 + 多模态分析简报(已封装进 image_prompt)一起发给生图模型;
|
||||
无参考图或图生图失败 → 回退 ib.generate() 纯文生图。
|
||||
seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。
|
||||
on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。
|
||||
"""
|
||||
try:
|
||||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||||
img_prompt = sanitize_image_prompt(brief.get("image_prompt", ""))
|
||||
img_prompt = ensure_rebrand_hint(brief, img_prompt) # review → 原创化魔改引导
|
||||
out_path = str(design_dir / f"{out_stem}_design.png")
|
||||
ref_images = [str(p) for p in (brief.get("ref_images") or []) if str(p)]
|
||||
if ref_images and hasattr(ib, "print"):
|
||||
try:
|
||||
# 图生图:以爬取图为参考,按分析简报生成原创设计(不复制原图)
|
||||
ref_prompt = img_prompt + (
|
||||
" Create an ORIGINAL, non-copying flat print design inspired ONLY by "
|
||||
"the reference image's style and mood. Do NOT reproduce the reference "
|
||||
"image, its characters, logos, or any text.")
|
||||
out_path = ib.print(
|
||||
ref_prompt, ref_images[0], out_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=ref_images[1:] or None,
|
||||
size=size, seed=seed) # 设计稿尺寸按 config compose.design_size
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[compose] 图生图(参考图)失败,回退文生图 {brief.get('topic','')}: {e}")
|
||||
_notify_400(on_400, e)
|
||||
out_path = ib.generate(
|
||||
img_prompt, str(design_dir / f"{out_stem}_design.png"),
|
||||
brief.get("composite_negative", ""), size=size, seed=seed)
|
||||
else:
|
||||
out_path = ib.generate(
|
||||
img_prompt, str(design_dir / f"{out_stem}_design.png"),
|
||||
brief.get("composite_negative", ""), size=size, seed=seed)
|
||||
# 全局 MD5 去重:生成了设计后,把 MD5 加入全局过滤(对所有国家生效);
|
||||
# 已存在的重复设计 → 跳过(不用于产品),避免跨国家重复使用同一设计
|
||||
from graph.pinterest import design_md5_ok
|
||||
if not design_md5_ok(out_path):
|
||||
print(f"[compose] 设计稿 MD5 全局重复,跳过(不用于产品): {out_path}")
|
||||
return None
|
||||
return out_path
|
||||
except Exception as e: # noqa: BLE001
|
||||
_notify_400(on_400, e)
|
||||
if errors is not None:
|
||||
errors.append({"node": "compose", "type": type(e).__name__,
|
||||
"message": f"设计稿生成失败 {brief.get('topic','')}: {e}", "trace": ""})
|
||||
print(f"[compose] 设计稿生成失败 {brief.get('topic', '')}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def write_compose_reports(state: Dict[str, Any], briefs: List[Dict[str, Any]]) -> None:
|
||||
"""写 compose 阶段简报报告(design_briefs / composite_prompts / report.md)。
|
||||
|
||||
Pinterest 并发生成模式下 compose_node 不再整体执行,由收尾节点调用本函数补写报告。
|
||||
"""
|
||||
output_dir = Path(state["output_dir"])
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_dir = Path(state.get("cache_dir") or output_dir)
|
||||
generated_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
(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")
|
||||
(cache_dir / "design_briefs.md").write_text(
|
||||
_build_briefs_md(briefs, generated_at), encoding="utf-8")
|
||||
(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")
|
||||
(output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8")
|
||||
|
||||
|
||||
@with_fallback("compose")
|
||||
def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
briefs: List[Dict[str, Any]] = state.get("briefs") or []
|
||||
@@ -105,26 +199,8 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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")
|
||||
# 1-4) 简报报告(design_briefs / composite_prompts / report.md)
|
||||
write_compose_reports(state, briefs)
|
||||
|
||||
# 5) 生成纯印花设计稿(图2):前 N 个 safe 简报用 image_prompt 文生图
|
||||
designs: List[Dict[str, Any]] = []
|
||||
@@ -153,45 +229,20 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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]):
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。
|
||||
# 随机种子:config compose.seed >0 时固定(可复现,网关支持才生效);0/留空=每次随机
|
||||
_seed = int(compose_cfg.get("seed") or 0)
|
||||
_seed = _seed if _seed > 0 else None
|
||||
|
||||
Pinterest 参考模式:简报带 ref_images(爬取图)→ 用 ib.print() 图生图,
|
||||
把爬取图 + 多模态分析简报(已封装进 image_prompt)一起发给生图模型;
|
||||
无参考图或图生图失败 → 回退 ib.generate() 纯文生图。
|
||||
"""
|
||||
try:
|
||||
img_prompt = sanitize_image_prompt(b.get("image_prompt", ""))
|
||||
img_prompt = ensure_rebrand_hint(b, img_prompt) # review → 原创化魔改引导
|
||||
out_path = str(design_dir / f"{country}_{i:02d}_design.png")
|
||||
ref_images = [str(p) for p in (b.get("ref_images") or []) if str(p)]
|
||||
if ref_images and hasattr(ib, "print"):
|
||||
try:
|
||||
# 图生图:以爬取图为参考,按分析简报生成原创设计(不复制原图)
|
||||
ref_prompt = img_prompt + (
|
||||
" Create an ORIGINAL, non-copying flat print design inspired ONLY by "
|
||||
"the reference image's style and mood. Do NOT reproduce the reference "
|
||||
"image, its characters, logos, or any text.")
|
||||
out_path = ib.print(
|
||||
ref_prompt, ref_images[0], out_path,
|
||||
b.get("composite_negative", ""),
|
||||
extra_images=ref_images[1:] or None,
|
||||
size="1024x1024") # 印花设计统一 1024x1024
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[compose] 图生图(参考图)失败,回退文生图 {b.get('topic','')}: {e}")
|
||||
out_path = ib.generate(
|
||||
img_prompt, str(design_dir / f"{country}_{i:02d}_design.png"),
|
||||
b.get("composite_negative", ""), size="1024x1024")
|
||||
else:
|
||||
out_path = ib.generate(
|
||||
img_prompt, str(design_dir / f"{country}_{i:02d}_design.png"),
|
||||
b.get("composite_negative", ""), size="1024x1024")
|
||||
return i, b, out_path, None
|
||||
except Exception as e: # noqa: BLE001
|
||||
return i, b, None, e
|
||||
def _gen_one(i: int, b: Dict[str, Any]):
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
|
||||
out_path = generate_design(ib, b, design_dir, f"{country}_{i:02d}",
|
||||
state.get("errors"), seed=_seed,
|
||||
size=compose_cfg.get("design_size", "1024x1024"))
|
||||
if out_path is None:
|
||||
return i, b, None, None
|
||||
return i, b, out_path, None
|
||||
|
||||
targets = [(i, b) for i, b in enumerate(safe_briefs[:design_count], 1)]
|
||||
# 并发生成:每张设计一个线程(并行调图像网关),数量多时不串行等待
|
||||
@@ -206,6 +257,8 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
state.setdefault("errors", []).append({
|
||||
"node": "compose", "type": type(err).__name__,
|
||||
"message": f"设计稿生成失败 {b.get('topic','')}: {err}", "trace": ""})
|
||||
elif out_path is None:
|
||||
print(f"[compose] 设计稿跳过(MD5 全局去重): {b.get('topic', '')}")
|
||||
else:
|
||||
b["design_path"] = out_path
|
||||
designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path})
|
||||
|
||||
@@ -1,28 +1,71 @@
|
||||
"""Pinterest 参考模式节点 3/3:LLM 多模态分析图片 → 原创设计简报(pinterest_analyze)。
|
||||
"""Pinterest 参考模式节点 3/3:从图池取图 → 多并发 LLM 分析 → 原创设计简报(pinterest_analyze)。
|
||||
|
||||
对 pinterest_scrape 爬到的每个搜索词图片,调 LLM 多模态分析(analyze_pinterest_images)
|
||||
提取视觉概念(风格/情绪/主体/配色/构图)→ 生成原创设计简报
|
||||
(motif/art_style/color_palette/composition/concept/negative_prompt),
|
||||
再经 prompt_node 装配最终 image/wearable/composite 提示词,产出标准 briefs 供 compose 用。
|
||||
图池机制:
|
||||
- 从持久化图池(image_pool.json)取「未消费」图片(md5 不在 used_images.json)。
|
||||
- 大图先压缩(内存占用过大 → 缩放/重编码)再送 LLM。
|
||||
- 多并发分析(每批 analyze_per_term 张,并发 analyze_concurrency 线程)。
|
||||
- 每张被分析的图片 md5 一律拉黑(used_images.json)——合适→产出简报→生成设计(设计 md5 全局拉黑见 compose);
|
||||
不合适→图片 md5 已拉黑→下一轮自动取下一张,不重复分析。
|
||||
- 图池无未消费图片时返回空,由路由触发新一轮搜索。
|
||||
|
||||
兜底链:LLM 多模态 → 纯文本降级(后端内部)→ mock 规则简报 → 空列表(下游跳过)。
|
||||
带 with_fallback:任何异常都不中断。
|
||||
"""
|
||||
import concurrent.futures
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.llms import get_backend
|
||||
from graph.nodes.prompt_node import prompt_node
|
||||
from graph.pinterest import (
|
||||
compress_image,
|
||||
load_image_pool,
|
||||
load_used_images,
|
||||
pool_unused_images,
|
||||
save_used_images,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
# 明显不适合 T 恤印花的简报主体(启发式过滤;真实判定交给 LLM 搜索词引导)
|
||||
_BRIEF_UNSUITABLE = re.compile(
|
||||
r"\b(landscape|panorama|scenery|cityscape|street scene|interior|room decor|"
|
||||
r"food photography|meal|dinner plate|recipe|makeup|nails|manicure|"
|
||||
r"weather forecast|map|directions|photorealistic scene|realistic portrait)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[str, Any]]:
|
||||
|
||||
def _brief_suitable(b: Dict[str, Any]) -> bool:
|
||||
"""简报是否适合做 T 恤印花:非侵权(blocked 拦截)+ 有主体 + 非明显非印花概念。"""
|
||||
if str(b.get("risk_level") or "").strip().lower() == "blocked":
|
||||
return False
|
||||
motif = str(b.get("motif") or "").strip()
|
||||
if not motif:
|
||||
return False
|
||||
if _BRIEF_UNSUITABLE.search(motif):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str,
|
||||
existing_topics: List[str] = None) -> List[Dict[str, Any]]:
|
||||
"""富化原始简报 → screened 格式(唯一 topic / safe / 分类 / 分数),供 prompt_node 装配。
|
||||
|
||||
同一搜索词的多张图会产出多条简报,topic 相同 → 追加序号保证唯一
|
||||
(product_node 按 topic 绑定简报,重复 topic 会互相覆盖)。
|
||||
existing_topics: 已累计简报的 topic 列表;用它初始化计数实现跨轮次去重——
|
||||
直接搜固定词时每轮 LLM 都返回相同 topic,若每轮从 #1 重新计数,
|
||||
30 个产品会因 topic 重复只用到前几个唯一设计(其余全复制)。
|
||||
"""
|
||||
from graph.classify import classify
|
||||
import re as _re
|
||||
seen_topics: Dict[str, int] = {}
|
||||
# 已累计简报按「基础词」计数(去掉 #N 后缀),保证跨轮次序号连续递增
|
||||
for t in existing_topics or []:
|
||||
key = _re.sub(r"\s+#\d+$", "", str(t).strip().lower())
|
||||
if key:
|
||||
seen_topics[key] = seen_topics.get(key, 0) + 1
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(raw_briefs):
|
||||
if not isinstance(b, dict):
|
||||
@@ -38,9 +81,9 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[
|
||||
out.append({
|
||||
"country": country,
|
||||
"topic": topic,
|
||||
"risk_level": "safe",
|
||||
"safe_for_print": True,
|
||||
"suitable_for_print": True,
|
||||
"risk_level": str(b.get("risk_level") or "safe").strip().lower() or "safe",
|
||||
"safe_for_print": bool(b.get("safe_for_print", True)),
|
||||
"suitable_for_print": bool(b.get("suitable_for_print", True)),
|
||||
"design_category": classify(term),
|
||||
"concept": str(b.get("concept") or "").strip() or f"围绕「{term}」的原创印花设计",
|
||||
"motif": motif,
|
||||
@@ -48,7 +91,9 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[
|
||||
"color_palette": str(b.get("color_palette") or "").strip(),
|
||||
"composition": str(b.get("composition") or "").strip(),
|
||||
"negative_prompt": str(b.get("negative_prompt") or "").strip(),
|
||||
"image_prompt": str(b.get("image_prompt") or "").strip(),
|
||||
"ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)],
|
||||
"source_md5": str(b.get("source_md5") or "").strip().lower(),
|
||||
"slogan": "",
|
||||
"score": 1.0,
|
||||
"confidence": 1.0,
|
||||
@@ -59,21 +104,89 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[
|
||||
|
||||
@with_fallback("pinterest_analyze")
|
||||
def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
images: Dict[str, List[str]] = state.get("pinterest_images") or {}
|
||||
if not images:
|
||||
print("[pinterest_analyze] 无爬取图片,跳过分析")
|
||||
return {"pinterest_briefs": [], "briefs": [], "errors": state.get("errors") or []}
|
||||
|
||||
country = state["country"]
|
||||
config = state["config"]
|
||||
output_dir = state["output_dir"]
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
pcfg = config.get("pinterest") or {}
|
||||
analyze_per_term = int(pcfg.get("analyze_per_term", 6))
|
||||
analyze_per_term = int(pcfg.get("analyze_per_term", 1))
|
||||
concurrency = int(pcfg.get("analyze_concurrency", 3))
|
||||
max_designs = int(pcfg.get("max_designs", 10))
|
||||
n_ref = max(1, int(pcfg.get("ref_images_per_design", 1)))
|
||||
provider = str(pcfg.get("provider") or "openai").strip().lower()
|
||||
|
||||
# 1) LLM 后端(openai → 真多模态;mock → 规则兜底)
|
||||
# 按需分析:只取补齐到目标所需的图片数(batch_size 为上限,不超额分析),并按 md5 去重,
|
||||
# 保证同一图片内容(md5)不会同时被多条简报使用
|
||||
target = int(state.get("pinterest_target") or 0)
|
||||
existing = state.get("briefs") or []
|
||||
remaining = max(0, target - len(existing))
|
||||
batch_size = int(pcfg.get("analyze_batch", 0))
|
||||
if batch_size <= 0:
|
||||
# 自动:一次分析补齐到「目标所需」或「每词简报上限」的较小值(每张图→1条简报),
|
||||
# 让 pipeline 队列一次有足够任务,时刻保持并发生成(避免每轮只推 6 条导致线程空转)
|
||||
batch_size = min(remaining, max_designs)
|
||||
need = min(batch_size, remaining) if remaining > 0 else 0
|
||||
|
||||
# 1) 图池取未消费图片(md5 不在 used_images);无 → 返回空,路由触发搜索
|
||||
pool = load_image_pool(output_dir, country)
|
||||
used = load_used_images(output_dir, country)
|
||||
unused = pool_unused_images(pool, used)
|
||||
if not unused:
|
||||
print("[pinterest_analyze] 图池无未消费图片,跳过分析(路由将触发新一轮搜索)")
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
|
||||
if need <= 0:
|
||||
print("[pinterest_analyze] 简报已达标,无需分析")
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
seen_md5: set = set()
|
||||
batch: List[Dict[str, Any]] = []
|
||||
for img in unused:
|
||||
m = str(img.get("md5") or "").strip().lower()
|
||||
if m and m in seen_md5:
|
||||
continue # 同一图片内容(md5)不重复分析
|
||||
seen_md5.add(m)
|
||||
batch.append(img)
|
||||
if len(batch) >= need:
|
||||
break
|
||||
print(f"[pinterest_analyze] 图池取 {len(batch)} 张未消费图片分析(按需 {need},"
|
||||
f"池剩余未消费 {len(unused) - len(batch)} 张,已消费 {len(used)} 张)")
|
||||
|
||||
def _assign_refs(res: List[Dict[str, Any]], chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""按简报的 image_index(LLM 返回)匹配它实际分析的图,写入 source_md5 + ref_images。
|
||||
|
||||
全局 id 校验:简报必须带 image_index(对应输入第几张图,0-based);
|
||||
无 image_index(mock 兜底)→ 回退按顺序;无效/越界/重复 → 丢弃该简报(避免错位)。
|
||||
这样 analyze_per_term 可 >1 一次分析多张图提速,简报仍严格对应各自的图。
|
||||
"""
|
||||
chunk_paths = [img["path"] for img in chunk]
|
||||
chunk_md5s = [str(img.get("md5") or "").strip().lower() for img in chunk]
|
||||
used_idx: set = set()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(res):
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
try:
|
||||
idx = int(b.get("image_index"))
|
||||
except (TypeError, ValueError):
|
||||
idx = i # 无 image_index → 回退按顺序
|
||||
if idx < 0 or idx >= len(chunk_paths) or idx in used_idx:
|
||||
print(f"[pinterest_analyze] 简报 image_index={idx} 无效/重复,丢弃(避免图-简报错位)")
|
||||
continue
|
||||
used_idx.add(idx)
|
||||
refs: List[str] = []
|
||||
for k in range(n_ref):
|
||||
src = chunk_paths[(idx + k) % len(chunk_paths)]
|
||||
if src not in refs:
|
||||
refs.append(src)
|
||||
b["ref_images"] = refs
|
||||
b["source_md5"] = chunk_md5s[idx] if idx < len(chunk_md5s) else ""
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
# 2) LLM 后端(openai → 真多模态;mock → 规则兜底)
|
||||
llm = None
|
||||
if provider != "static":
|
||||
try:
|
||||
@@ -87,64 +200,123 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
print(f"[pinterest_analyze] LLM 初始化失败: {e}")
|
||||
llm = None
|
||||
|
||||
# 2) 逐搜索词分析图片 → 原始设计简报
|
||||
# 3) 大图先压缩(内存占用过大 → 缩放/重编码),再按批分组
|
||||
compressed_map: Dict[str, str] = {}
|
||||
for img in batch:
|
||||
compressed_map[img["path"]] = compress_image(img["path"])
|
||||
chunks: List[List[Dict[str, Any]]] = [
|
||||
batch[i:i + analyze_per_term] for i in range(0, len(batch), analyze_per_term)
|
||||
]
|
||||
|
||||
# 4) 多并发分析(每线程分析一个 chunk;LLM 后端只读 self._cfg,线程安全)
|
||||
raw_briefs: List[Dict[str, Any]] = []
|
||||
if llm is not None and hasattr(llm, "analyze_pinterest_images"):
|
||||
for term, paths in images.items():
|
||||
sample = list(paths)[:analyze_per_term]
|
||||
if not sample:
|
||||
continue
|
||||
|
||||
def _analyze_chunk(chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
term = str(chunk[0].get("term") or "")
|
||||
paths = [compressed_map.get(img["path"], img["path"]) for img in chunk]
|
||||
if llm is not None and hasattr(llm, "analyze_pinterest_images"):
|
||||
try:
|
||||
res = llm.analyze_pinterest_images(sample, term, country)
|
||||
res = res or []
|
||||
raw_briefs.extend(res)
|
||||
print(f"[pinterest_analyze] 「{term}」分析 {len(sample)} 张图 → {len(res)} 条简报")
|
||||
def _on_400():
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
if pipe is not None and hasattr(pipe, "record_400"):
|
||||
if pipe.record_400():
|
||||
pipe._abort_current_term()
|
||||
res = llm.analyze_pinterest_images(paths, term, country, on_400=_on_400) or []
|
||||
# 把每条简报的来源图路径回填为原始图(压缩图仅用于分析,参考图用原图)
|
||||
return _assign_refs(res, chunk)
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "pinterest_analyze", "type": type(e).__name__,
|
||||
"message": f"term[{term}]: {e}", "trace": ""})
|
||||
"message": f"term[{term}] batch@{len(chunk)}: {e}", "trace": ""})
|
||||
print(f"[pinterest_analyze] 「{term}」分析失败: {e}")
|
||||
return []
|
||||
return []
|
||||
|
||||
# 3) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成)。
|
||||
# 注意必须切到 mock 后端,不能再调回失败的 llm(否则同样报错)。
|
||||
workers = max(1, min(concurrency, len(chunks)))
|
||||
if len(chunks) > 1:
|
||||
print(f"[pinterest_analyze] 并发分析 {len(chunks)} 批({workers} 线程,每批 {analyze_per_term} 张)…")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as _ex:
|
||||
_futs = [_ex.submit(_analyze_chunk, c) for c in chunks]
|
||||
for _f in concurrent.futures.as_completed(_futs):
|
||||
raw_briefs.extend(_f.result())
|
||||
|
||||
# 5) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成)
|
||||
if not raw_briefs:
|
||||
try:
|
||||
mock = get_backend("mock")
|
||||
for term, paths in images.items():
|
||||
sample = list(paths)[:analyze_per_term]
|
||||
if sample:
|
||||
raw_briefs.extend(mock.analyze_pinterest_images(sample, term, country) or [])
|
||||
for chunk in chunks:
|
||||
term = str(chunk[0].get("term") or "")
|
||||
paths = [compressed_map.get(img["path"], img["path"]) for img in chunk]
|
||||
res = mock.analyze_pinterest_images(paths, term, country) or []
|
||||
_assign_refs(res, chunk)
|
||||
raw_briefs.extend(res)
|
||||
print(f"[pinterest_analyze] 兜底:mock 规则简报 {len(raw_briefs)} 条")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] mock 兜底失败: {e}")
|
||||
|
||||
# 4) 上限 + 去重(同 motif+style 指纹只留一条)
|
||||
raw_briefs = raw_briefs[:max_designs]
|
||||
# 6) 本批所有图片 md5 一律拉黑(已消费,不再复用)——合适/不合适都拉黑
|
||||
for img in batch:
|
||||
if img.get("md5"):
|
||||
used.add(str(img["md5"]).lower())
|
||||
save_used_images(output_dir, country, used)
|
||||
|
||||
# 7) 简报过滤:只留适合印花的(有主体 + 非明显非印花概念)
|
||||
kept: List[Dict[str, Any]] = []
|
||||
for b in raw_briefs:
|
||||
if isinstance(b, dict) and _brief_suitable(b):
|
||||
kept.append(b)
|
||||
if len(kept) < len(raw_briefs):
|
||||
print(f"[pinterest_analyze] 简报过滤:{len(raw_briefs)} → {len(kept)} 条适合印花")
|
||||
|
||||
# 8) 上限 + 去重(同 motif+style 指纹只留一条)
|
||||
kept = kept[:max_designs]
|
||||
seen: set = set()
|
||||
uniq: List[Dict[str, Any]] = []
|
||||
for b in raw_briefs:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
for b in kept:
|
||||
fp = f"{str(b.get('motif', '')).strip().lower()}|{str(b.get('art_style', '')).strip().lower()}"
|
||||
if fp in seen:
|
||||
continue
|
||||
seen.add(fp)
|
||||
uniq.append(b)
|
||||
raw_briefs = uniq
|
||||
kept = uniq
|
||||
|
||||
# 5) 富化 → screened → prompt_node 装配提示词 → 标准 briefs
|
||||
screened = _enrich_briefs(raw_briefs, country)
|
||||
if not screened:
|
||||
print("[pinterest_analyze] 无有效设计简报,跳过")
|
||||
return {"pinterest_briefs": [], "briefs": [], "errors": errors}
|
||||
# 9) 富化 → screened → prompt_node 装配提示词 → 标准 briefs(追加到累计,按需截断到目标数)
|
||||
existing_topics = [str(b.get("topic", "")).strip() for b in (state.get("briefs") or [])]
|
||||
screened = _enrich_briefs(kept, country, existing_topics)
|
||||
new_briefs: List[Dict[str, Any]] = []
|
||||
if screened:
|
||||
r = prompt_node({**state, "screened": screened})
|
||||
new_briefs = r.get("briefs") or []
|
||||
|
||||
r = prompt_node({**state, "screened": screened})
|
||||
briefs = r.get("briefs") or []
|
||||
old_count = len(state.get("briefs") or [])
|
||||
accumulated = list(state.get("briefs") or [])
|
||||
accumulated.extend(new_briefs)
|
||||
target = int(state.get("pinterest_target") or 0)
|
||||
if target > 0 and len(accumulated) > target:
|
||||
accumulated = accumulated[:target]
|
||||
print(f"[pinterest_analyze] 简报已达目标 {target} 条,截断多余部分")
|
||||
|
||||
# 推送实际新增且保留的简报到并发生成流水线(简报池):边分析边生成设计/三合一/种草图
|
||||
pushed = accumulated[old_count:]
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
if pipe is not None and pushed:
|
||||
if getattr(pipe, "is_400_aborted", lambda: False)():
|
||||
print("[pinterest_analyze] 当前种子词 400 超限已放弃,本轮简报不推送")
|
||||
pushed = []
|
||||
else:
|
||||
try:
|
||||
pipe.add_briefs(pushed)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] 简报入池失败: {e}")
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_analyze"] = {
|
||||
"provider": provider,
|
||||
"images_analyzed": sum(len(v) for v in images.values()),
|
||||
"briefs": len(briefs),
|
||||
"images_analyzed": len(batch),
|
||||
"pool_unused": len(unused),
|
||||
"used_images": len(used),
|
||||
"briefs": len(new_briefs),
|
||||
"accumulated": len(accumulated),
|
||||
}
|
||||
print(f"[pinterest_analyze] 设计简报 {len(briefs)} 条({country})")
|
||||
return {"pinterest_briefs": raw_briefs, "briefs": briefs, "stats": stats, "errors": errors}
|
||||
print(f"[pinterest_analyze] 本轮分析 {len(batch)} 张图 → 简报 {len(new_briefs)} 条,"
|
||||
f"累计 {len(accumulated)} 条({country})")
|
||||
return {"pinterest_briefs": kept, "briefs": accumulated, "stats": stats, "errors": errors}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Pinterest 并发生成流水线节点 3/3:收尾(pinterest_finalize)。
|
||||
|
||||
分析循环结束后:排空简报池、等待后台全部产品完成(设计→三合一→OSS→种草图),
|
||||
合并产品到 state,补写 compose 简报报告与 products.json,再交给 template_export。
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("pinterest_finalize")
|
||||
def pinterest_finalize_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
products: list = []
|
||||
perr: list = []
|
||||
if pipe is not None:
|
||||
products, perr = pipe.finish()
|
||||
|
||||
# 合并后台产出的产品(与已存在的合并,避免覆盖)
|
||||
state_products = list(state.get("product") or [])
|
||||
state_products.extend(products)
|
||||
|
||||
# 补写 compose 简报报告(design_briefs / composite_prompts / report.md)
|
||||
try:
|
||||
from graph.nodes.compose_node import write_compose_reports
|
||||
write_compose_reports(state, state.get("briefs") or [])
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_finalize] 简报报告写入失败: {e}")
|
||||
|
||||
# 写 products.json(product 节点原职责)
|
||||
try:
|
||||
from graph.nodes.product_node import _write_products
|
||||
prod_dir = Path(state["output_dir"]) / "product"
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
_write_products(prod_dir, state_products)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_finalize] products.json 写入失败: {e}")
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_pipeline"] = {"products": len(products), "errors": len(perr)}
|
||||
errors = list(state.get("errors") or []) + perr
|
||||
oss_seq = getattr(pipe, "oss_seq", state.get("oss_seq", 0)) if pipe is not None \
|
||||
else state.get("oss_seq", 0)
|
||||
print(f"[pinterest_finalize] 收尾完成:合并 {len(state_products)} 个产品,"
|
||||
f"后台错误 {len(perr)},oss_seq={oss_seq}")
|
||||
return {"product": state_products, "oss_seq": oss_seq, "errors": errors, "stats": stats}
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Pinterest 并发生成流水线节点 0/3:初始化简报池(pinterest_init)。
|
||||
|
||||
创建 PinterestPipeline(简报池 + 后台并发生成线程),存 state["pinterest_pipeline"],
|
||||
供 pinterest_analyze 推送简报、pinterest_finalize 收尾。
|
||||
"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("pinterest_init")
|
||||
def pinterest_init_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from graph.pinterest_pipeline import PinterestPipeline
|
||||
pipe = PinterestPipeline(state)
|
||||
return {"pinterest_pipeline": pipe}
|
||||
@@ -1,17 +1,26 @@
|
||||
"""Pinterest 参考模式节点 2/3:爬取图片(pinterest_scrape)。
|
||||
|
||||
对 pinterest_search 生成的每个搜索词,调 pinterest_scraper.scraper.scrape_pinterest
|
||||
(Playwright 启动本地 Chrome)搜索 Pinterest 并下载图片到
|
||||
output/pinterest_ref/<国家>/<搜索词>/。
|
||||
对 pinterest_search 生成的搜索词(按需:每次 1 个),调 pinterest_scraper.scraper.scrape_pinterest
|
||||
(Playwright 启动本地 Chrome)搜索 Pinterest 并下载图片到 output/pinterest_ref/<国家>/<搜索词>/。
|
||||
|
||||
- 单个搜索词失败(未登录/网络/无结果)跳过,不中断整批。
|
||||
- 并发数由 config.pinterest.scrape_concurrency 控制(每个并发开一个 Chrome 窗口)。
|
||||
- 只有用了才标记已用:爬取成功(真正用掉该搜索词)→ 持久化已用词;
|
||||
爬取失败 → 记入本轮 attempted(不持久化),避免同轮重复生成。
|
||||
- 已爬取过且图片数达标的搜索词跳过(断点续爬,避免重复开 Chrome)。
|
||||
"""
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.pinterest import (
|
||||
image_md5,
|
||||
load_image_pool,
|
||||
load_used_images,
|
||||
load_used_terms,
|
||||
merge_used,
|
||||
save_image_pool,
|
||||
save_used_terms,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@@ -45,6 +54,7 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
concurrency = int(pcfg.get("scrape_concurrency", 2))
|
||||
headless = bool(pcfg.get("headless", False))
|
||||
proxy = pcfg.get("proxy") or None
|
||||
search_mode = str(pcfg.get("search_mode") or "direct").strip().lower()
|
||||
|
||||
# 所有搜索词共享同一个 .chrome_session 登录态目录,Chrome 对同一 user-data-dir 是单例,
|
||||
# 并发启动会互相抢占导致 "browser has been closed",必须串行爬取。
|
||||
@@ -59,16 +69,22 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
proxy = detect_proxy() or get_system_proxy()
|
||||
except Exception: # noqa: BLE001
|
||||
proxy = None
|
||||
if proxy and not _validate_proxy(proxy):
|
||||
if not proxy:
|
||||
print("[pinterest_scrape] 警告:未检测到代理,将直连下载。国内网络通常无法访问 "
|
||||
"i.pinimg.com,请先开启代理/VPN(Clash/v2ray 等)再运行,否则图片下载会全部失败")
|
||||
elif not _validate_proxy(proxy):
|
||||
print(f"[pinterest_scrape] 警告:代理 {proxy} 无法连通外网,请检查代理/VPN 是否正常,"
|
||||
f"否则 Pinterest 将无法访问(爬取会失败)")
|
||||
|
||||
results: Dict[str, List[str]] = {}
|
||||
skipped: List[str] = []
|
||||
failed: List[str] = []
|
||||
|
||||
def _one(term: str) -> None:
|
||||
term_dir = _term_dir(output_dir, country, term)
|
||||
if _already_scraped(term_dir):
|
||||
# direct 模式:固定关键词允许重复爬取(图池不足时自动再搜,Pinterest 每次可能返回不同图);
|
||||
# llm 模式:已爬取过且达标 → 跳过(断点续爬,避免重复开 Chrome)
|
||||
if search_mode != "direct" and _already_scraped(term_dir):
|
||||
skipped.append(term)
|
||||
print(f"[pinterest_scrape] 已爬取过(跳过): {term}")
|
||||
return
|
||||
@@ -78,6 +94,7 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
save_dir=str(term_dir), proxy=proxy, headless=headless)
|
||||
results[term] = files
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed.append(term)
|
||||
errors.append({"node": "pinterest_scrape", "type": type(e).__name__,
|
||||
"message": f"term[{term}]: {e}", "trace": ""})
|
||||
print(f"[pinterest_scrape] 爬取失败(跳过): {term}: {e}")
|
||||
@@ -86,12 +103,55 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, concurrency)) as ex:
|
||||
list(ex.map(_one, terms))
|
||||
|
||||
# 只有用了才标记已用:爬取成功(含已爬取跳过)的词 → 持久化已用;失败词 → 本轮 attempted(不持久化)
|
||||
# direct 模式:固定关键词不拉黑(可跨轮复用),仅 llm 模式持久化已用词
|
||||
used = load_used_terms(output_dir, country)
|
||||
consumed = (list(results.keys()) + skipped) if search_mode != "direct" else []
|
||||
new_used = merge_used(used, consumed)
|
||||
if new_used != used:
|
||||
save_used_terms(output_dir, country, new_used)
|
||||
print(f"[pinterest_scrape] 已用搜索词更新:新增 {len(consumed)} 个,累计 {len(new_used)}")
|
||||
attempted = merge_used(state.get("pinterest_attempted") or [], failed)
|
||||
|
||||
# 新爬取的图片注册进图池(含 md5),供分析节点按需取用;
|
||||
# 进图池前做 md5 校验去重:md5 已存在于图池 / 已拉黑(used_images)/ 本批重复 → 跳过
|
||||
pool = load_image_pool(output_dir, country)
|
||||
existing = pool.get("images") or []
|
||||
known_paths = {str(img.get("path")) for img in existing}
|
||||
known_md5s = {str(img.get("md5") or "").strip().lower() for img in existing}
|
||||
used_md5s = load_used_images(output_dir, country)
|
||||
new_imgs: List[Dict[str, Any]] = []
|
||||
seen_md5: set = set()
|
||||
for term, files in results.items():
|
||||
for f in files:
|
||||
if f in known_paths:
|
||||
continue
|
||||
m = str(image_md5(f) or "").strip().lower()
|
||||
if not m:
|
||||
continue
|
||||
if m in known_md5s or m in used_md5s or m in seen_md5:
|
||||
print(f"[pinterest_scrape] 图池 md5 去重跳过: {f}")
|
||||
continue
|
||||
seen_md5.add(m)
|
||||
new_imgs.append({"path": f, "md5": m, "term": term})
|
||||
if new_imgs:
|
||||
pool["images"] = existing + new_imgs
|
||||
save_image_pool(output_dir, country, pool)
|
||||
print(f"[pinterest_scrape] 图池新增 {len(new_imgs)} 张图片,累计 {len(pool['images'])} 张")
|
||||
|
||||
# 新一批图爬取完成 → 重置 400 计数(per 种子词),记录当前种子词
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
if pipe is not None and hasattr(pipe, "reset_400"):
|
||||
for term in results.keys():
|
||||
pipe.reset_400(term)
|
||||
|
||||
total = sum(len(v) for v in results.values())
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_scrape"] = {
|
||||
"terms": len(terms), "scraped": len(results), "skipped": len(skipped),
|
||||
"images": total,
|
||||
"failed": len(failed), "images": total, "pool": len(pool.get("images") or []),
|
||||
}
|
||||
print(f"[pinterest_scrape] 完成:{len(results)} 个搜索词,共 {total} 张图(跳过 {len(skipped)})")
|
||||
print(f"[pinterest_scrape] 完成:{len(results)} 个搜索词,共 {total} 张图(跳过 {len(skipped)},失败 {len(failed)})")
|
||||
|
||||
return {"pinterest_images": results, "stats": stats, "errors": errors}
|
||||
return {"pinterest_images": results, "pinterest_attempted": attempted,
|
||||
"stats": stats, "errors": errors}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Pinterest 参考模式节点 1/3:LLM 生成搜索词(pinterest_search)。
|
||||
"""Pinterest 参考模式节点 1/3:按需生成单个搜索词(pinterest_search)。
|
||||
|
||||
流程:国家 Pinterest 种子词池 → LLM 生成搜索词(json_schema 结构化 + 动态注入已用词防重复)
|
||||
→ 全局过滤(已用/黑名单/不适合T恤/去重)→ 持久化已用词。
|
||||
按需搜索:每次只生成 1 个搜索词(LLM json_schema + 动态注入已用词防重复),
|
||||
带短袖/印花设计引导,保证搜索词适合短袖 T 恤印花。
|
||||
不在此处持久化已用词 —— 只有爬取成功(真正用掉)才标记已用(见 pinterest_scrape)。
|
||||
|
||||
兜底链:LLM json_schema → json_object → 解析失败/调用失败 → 回退种子词池随机抽样。
|
||||
带 with_fallback:任何异常都不中断,返回空列表由下游跳过。
|
||||
@@ -15,7 +16,6 @@ from graph.pinterest import (
|
||||
load_used_terms,
|
||||
merge_used,
|
||||
sample_seeds,
|
||||
save_used_terms,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
@@ -32,72 +32,88 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"pinterest_search_terms": [], "errors": errors}
|
||||
|
||||
provider = str(pcfg.get("provider") or "openai").strip().lower()
|
||||
want = int(pcfg.get("search_terms_per_run", 10))
|
||||
search_mode = str(pcfg.get("search_mode") or "direct").strip().lower()
|
||||
want = int(pcfg.get("search_terms_per_run", 1)) # 每次搜索词数量
|
||||
seed_sample = int(pcfg.get("seed_sample", 40))
|
||||
max_used_in_prompt = int(pcfg.get("max_used_terms_in_prompt", 100))
|
||||
blacklist = config.get("blacklist") or []
|
||||
|
||||
# 1) 种子词池(随机抽样)+ 已用搜索词
|
||||
# 1) 种子词池 + 已用搜索词 + 本轮已尝试词(防同轮重复,不持久化)
|
||||
seeds = sample_seeds(country, seed_sample)
|
||||
used = load_used_terms(output_dir, country)
|
||||
attempted = [str(t).strip() for t in (state.get("pinterest_attempted") or []) if str(t).strip()]
|
||||
rounds = int(state.get("pinterest_rounds") or 0) + 1
|
||||
if not seeds:
|
||||
print(f"[pinterest_search] {country} 无种子词,跳过搜索词生成")
|
||||
return {"pinterest_search_terms": [], "errors": errors}
|
||||
return {"pinterest_search_terms": [], "pinterest_rounds": rounds, "errors": errors}
|
||||
|
||||
# 2) LLM 生成(json_schema + 动态注入已用词)
|
||||
# 已用词只取最近 N 个(默认 100)注入提示词,防 token 超限;过滤仍用全量。
|
||||
used_llm = used[-max_used_in_prompt:] if max_used_in_prompt > 0 else []
|
||||
# 2) 生成搜索词:种子词不再由 LLM 给出,直接由内置国家种子词库随机抽取(优先未用过),
|
||||
# 追加 " t-shirt design"(保证 Pinterest 返回真正的 T 恤印花图);llm 模式保留兼容
|
||||
terms: List[str] = []
|
||||
llm = None
|
||||
if provider != "static":
|
||||
try:
|
||||
llm = get_backend(provider)
|
||||
if hasattr(llm, "bind_config"):
|
||||
llm.bind_config(config.get("llm_screen") or {})
|
||||
if provider not in ("mock",) and not getattr(llm, "has_key", False):
|
||||
print(f"[pinterest_search] {provider} 未配置 API key,降级 mock")
|
||||
llm = get_backend("mock")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_search] LLM 初始化失败: {e}")
|
||||
llm = None
|
||||
if search_mode == "direct":
|
||||
from graph.pinterest import load_pinterest_seeds
|
||||
pool = load_pinterest_seeds(country)
|
||||
used_set = {str(u).strip().lower() for u in merge_used(used, attempted)}
|
||||
fresh = [s for s in pool if s.lower() not in used_set]
|
||||
if not fresh:
|
||||
fresh = pool # 库内词全部用过 → 允许复用(词库有限)
|
||||
terms = [f"{s} t-shirt design" if "t-shirt design" not in s.lower() else s
|
||||
for s in random.sample(fresh, min(want, len(fresh)))]
|
||||
print(f"[pinterest_search] direct 模式:国家种子词库随机抽 {len(terms)} 个 + t-shirt design({country})")
|
||||
else:
|
||||
used_llm = merge_used(used, attempted)
|
||||
if max_used_in_prompt > 0:
|
||||
used_llm = used_llm[-max_used_in_prompt:]
|
||||
llm = None
|
||||
if provider != "static":
|
||||
try:
|
||||
llm = get_backend(provider)
|
||||
if hasattr(llm, "bind_config"):
|
||||
llm.bind_config(config.get("llm_screen") or {})
|
||||
if provider not in ("mock",) and not getattr(llm, "has_key", False):
|
||||
print(f"[pinterest_search] {provider} 未配置 API key,降级 mock")
|
||||
llm = get_backend("mock")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_search] LLM 初始化失败: {e}")
|
||||
llm = None
|
||||
|
||||
if llm is not None and hasattr(llm, "generate_pinterest_terms"):
|
||||
try:
|
||||
ctx = {"country": country, "seeds": seeds, "used_terms": used_llm, "count": want}
|
||||
res = llm.generate_pinterest_terms(ctx)
|
||||
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)}/{len(used)})")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_search] LLM 生成失败,回退种子词池: {e}")
|
||||
terms = []
|
||||
if llm is not None and hasattr(llm, "generate_pinterest_terms"):
|
||||
try:
|
||||
ctx = {"country": country, "seeds": seeds, "used_terms": used_llm, "count": want}
|
||||
res = llm.generate_pinterest_terms(ctx)
|
||||
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)})")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_search] LLM 生成失败,回退种子词池: {e}")
|
||||
terms = []
|
||||
|
||||
# 3) 兜底:LLM 无结果 → 种子词池随机抽样
|
||||
if not terms:
|
||||
terms = random.sample(seeds, min(want, len(seeds))) if seeds else []
|
||||
print(f"[pinterest_search] 兜底:从种子词池取 {len(terms)} 个")
|
||||
# 3) 兜底:LLM 无结果 → 种子词池随机抽样
|
||||
if not terms:
|
||||
terms = [f"{s} t-shirt design" if "t-shirt design" not in s.lower() else s
|
||||
for s in random.sample(seeds, min(want, len(seeds)))]
|
||||
print(f"[pinterest_search] 兜底:从种子词池取 {len(terms)} 个")
|
||||
|
||||
# 4) 全局过滤(已用/黑名单/不适合T恤/去重)
|
||||
filtered = filter_search_terms(terms, used, blacklist)
|
||||
if len(filtered) < want and seeds:
|
||||
# 不足时用种子词池补充(同样过滤),保证数量
|
||||
extra = filter_search_terms(seeds, merge_used(used, filtered), blacklist)
|
||||
for t in extra:
|
||||
if len(filtered) >= want:
|
||||
break
|
||||
filtered.append(t)
|
||||
|
||||
# 5) 持久化已用词
|
||||
new_used = merge_used(used, filtered)
|
||||
save_used_terms(output_dir, country, new_used)
|
||||
# 4) 全局过滤(已用/本轮已尝试/黑名单/不适合T恤/去重)——注意:不在此处持久化已用词
|
||||
# direct 模式:抽样时已避开已用词(库内词有限,全部用过后允许复用),不再额外过滤
|
||||
if search_mode == "direct":
|
||||
filtered = terms
|
||||
else:
|
||||
filtered = filter_search_terms(terms, merge_used(used, attempted), blacklist)
|
||||
if not filtered and seeds:
|
||||
# 生成词全被过滤 → 从种子词池补充(同样过滤)
|
||||
extra = filter_search_terms(seeds, merge_used(used, attempted), blacklist)
|
||||
filtered = extra[:want]
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_search"] = {
|
||||
"provider": provider,
|
||||
"round": rounds,
|
||||
"generated": len(terms),
|
||||
"filtered": len(filtered),
|
||||
"used_total": len(new_used),
|
||||
"used_total": len(used),
|
||||
}
|
||||
print(f"[pinterest_search] 搜索词 {len(filtered)} 个(已用累计 {len(new_used)}): "
|
||||
f"{', '.join(filtered[:6])}{'...' if len(filtered) > 6 else ''}")
|
||||
print(f"[pinterest_search] 第 {rounds} 轮搜索词 {len(filtered)} 个(已用累计 {len(used)}): "
|
||||
f"{', '.join(filtered[:3])}{'...' if len(filtered) > 3 else ''}")
|
||||
|
||||
return {"pinterest_search_terms": filtered, "stats": stats, "errors": errors}
|
||||
return {"pinterest_search_terms": filtered, "pinterest_rounds": rounds,
|
||||
"stats": stats, "errors": errors}
|
||||
|
||||
+19
-10
@@ -134,7 +134,7 @@ def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
|
||||
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,
|
||||
img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
|
||||
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
|
||||
@@ -210,7 +210,12 @@ def _process_spu(
|
||||
prompt = ensure_rebrand_hint(brief, sanitize_image_prompt(brief.get("image_prompt", "")))
|
||||
ib.generate(prompt, design_path,
|
||||
brief.get("composite_negative", ""),
|
||||
size="1024x1024") # 印花设计统一 1024x1024
|
||||
size=design_size) # 设计稿尺寸按 config compose.design_size
|
||||
# 全局 MD5 去重:生成了设计后,把 MD5 加入全局过滤(对所有国家生效);重复 → 跳过该产品
|
||||
from graph.pinterest import design_md5_ok
|
||||
if not design_md5_ok(design_path):
|
||||
print(f"{tag} 设计稿 MD5 全局重复,跳过该产品: {design_path}")
|
||||
return None
|
||||
result["design_path"] = design_path
|
||||
result["design_from"] = "product"
|
||||
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
|
||||
@@ -252,7 +257,7 @@ def _process_spu(
|
||||
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
|
||||
size=compose_size) # 合成图尺寸按 config compose.size
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -260,7 +265,7 @@ def _process_spu(
|
||||
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")
|
||||
extra_images=[design_path, str(basemap_img)], size=compose_size)
|
||||
if retried is not None:
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
@@ -280,14 +285,14 @@ def _process_spu(
|
||||
ib.print(flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], # 图2印花
|
||||
size="1504x2000") # 合成统一 1504x2000
|
||||
size=compose_size) # 合成图尺寸按 config compose.size
|
||||
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")
|
||||
extra_images=[design_path], size=compose_size)
|
||||
if retried is not None:
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图重试成功: {printed_path}")
|
||||
@@ -312,7 +317,7 @@ def _process_spu(
|
||||
ib.print(MODEL_WEAR_PROMPT, str(model_img), cp,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
|
||||
size="1504x2000") # 三合一统一 1504x2000
|
||||
size=compose_size) # 合成图尺寸按 config compose.size
|
||||
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}")
|
||||
@@ -327,12 +332,14 @@ def _process_spu(
|
||||
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"):
|
||||
if t.get("en_title") or t.get("cn_title") or t.get("ja_title") or t.get("es_title"):
|
||||
result["en_title"] = t.get("en_title", "")
|
||||
result["cn_title"] = t.get("cn_title", "")
|
||||
result["ja_title"] = t.get("ja_title", "")
|
||||
result["es_title"] = t.get("es_title", "")
|
||||
print(f"{tag} 标题已生成: EN={t.get('en_title','')[:50]}... "
|
||||
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}...")
|
||||
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}... "
|
||||
f"ES={t.get('es_title','')[:30]}...")
|
||||
|
||||
return result
|
||||
|
||||
@@ -514,7 +521,9 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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", "")))
|
||||
model_img=model_assign.get(spu.get("code", "")),
|
||||
design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"),
|
||||
compose_size=str((config.get("compose") or {}).get("size") or "1536x2048"))
|
||||
if r:
|
||||
r["img_code"] = img_code
|
||||
return r, img_code
|
||||
|
||||
@@ -11,6 +11,17 @@ from graph.style_rules import derive_style_palette, derive_composition
|
||||
from graph.templates import assemble_prompts
|
||||
from graph.validate import validate_brief, with_fallback
|
||||
|
||||
# —— Pinterest 图生图生最终设计稿时统一追加的「小印花 + 纯白底」约束段 ——
|
||||
# (从热点搜集的文字生图模板里提炼:尺寸缩小、禁止自带背景/满幅)
|
||||
PINTEREST_PRINT_SUFFIX = (
|
||||
" standalone pure print design on a pure white background, "
|
||||
"the print artwork is SMALL and CENTERED with clearly larger white margins around it, "
|
||||
"print area between about 15x18 cm and 26x32 cm, "
|
||||
"do NOT fill the entire canvas, do NOT force full-bleed, "
|
||||
"do NOT add any gradient, texture or background color behind the artwork, "
|
||||
"no garment, no shirt, no model, no mannequin, no watermark"
|
||||
)
|
||||
|
||||
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
|
||||
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
|
||||
_IMG_RISKY_SWAP = {
|
||||
@@ -62,6 +73,12 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
composition = (r.get("composition") or derive_composition(r["topic"], r.get("design_category"))).strip()
|
||||
|
||||
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
|
||||
# Pinterest 简报:直接用 LLM 多模态对图片的描述拼接的 image_prompt(跳过四要素模板),
|
||||
# 仅追加统一的「小印花 + 纯白底」约束段;wearable/composite 仍用模板装配。
|
||||
llm_ip = (r.get("image_prompt") or "").strip()
|
||||
if r.get("source") == "pinterest" and llm_ip:
|
||||
prompts["image_prompt"] = llm_ip + PINTEREST_PRINT_SUFFIX
|
||||
print(f"[prompt] Pinterest 简报用 LLM 多模态描述作为 image_prompt(跳过四要素模板): 「{r['topic']}」")
|
||||
# 文字印花(约 30% 概率):简报有 slogan 时,随机注入文字段到设计稿提示词
|
||||
slogan = (r.get("slogan") or "").strip()
|
||||
if slogan and random.random() < float(config.get("prompt_templates", {}).get("text_ratio", 0.3)):
|
||||
|
||||
@@ -40,16 +40,6 @@ def _plan_seed_shots(comps: List[Dict[str, Any]], count: int) -> List[tuple]:
|
||||
return plan
|
||||
|
||||
|
||||
def _color_tag(cc: Dict[str, Any], idx: int) -> str:
|
||||
"""种草图文件名里的颜色标识:优先 sku_code 的颜色段,回退颜色名/序号。"""
|
||||
sku = str(cc.get("sku_code") or "")
|
||||
if "-" in sku:
|
||||
tag = sku.split("-", 1)[1]
|
||||
else:
|
||||
tag = str(cc.get("color") or "") or f"c{idx}"
|
||||
return "".join(ch for ch in tag if ch.isalnum() or ch in "-_") or f"c{idx}"
|
||||
|
||||
|
||||
@with_fallback("seed_shot")
|
||||
def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
products: List[Dict[str, Any]] = state.get("product") or []
|
||||
@@ -95,7 +85,7 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 材质读取失败(用空): {e}")
|
||||
|
||||
from graph.seed_shot import generate_seed_shots
|
||||
from graph.seed_shot import generate_seed_shots, read_template_category, gender_from_category
|
||||
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
|
||||
|
||||
@@ -104,7 +94,18 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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"))
|
||||
size = str(ss_cfg.get("size") or "1504x2000")
|
||||
size = str(ss_cfg.get("size") or "1536x2048")
|
||||
|
||||
# 类目 → 性别:模版「类目」表头值含「男」→ 男模;含「女」→ 女模;都不含 → 全部随机
|
||||
gender = None
|
||||
tp = str(((config.get("product") or {}).get("template_path")) or "").strip()
|
||||
if tp:
|
||||
category = read_template_category(tp)
|
||||
gender = gender_from_category(category)
|
||||
if gender:
|
||||
print(f"[seed_shot] 类目「{category[:30]}…」含{'男' if gender == 'male' else '女'} → 固定 {gender} 模特")
|
||||
elif category:
|
||||
print(f"[seed_shot] 类目「{category[:30]}…」无男/女 → 男女模特随机")
|
||||
|
||||
all_shots: List[Dict[str, Any]] = []
|
||||
shot_dir = output_dir / "seed_shots"
|
||||
@@ -128,7 +129,9 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
plan = _plan_seed_shots(comps, count)
|
||||
cn = (r.get("cn_title") or "").strip() or r.get("topic", "")
|
||||
material = material_map.get(r.get("spu_code", ""), "")
|
||||
# 按对应货号命名(img_code=货号,如 DG000);无货号时回退 seed
|
||||
base_prefix = r.get("img_code") or r.get("oss_code") or ""
|
||||
pfx = base_prefix or "seed"
|
||||
|
||||
paths: List[str] = []
|
||||
for ci, (cc, n) in enumerate(plan, start=1):
|
||||
@@ -136,23 +139,25 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not base or not Path(base).exists():
|
||||
print(f"[seed_shot] {r.get('spu_code', '')} 参考图缺失({base}),跳过该颜色种草图")
|
||||
continue
|
||||
tag = _color_tag(cc, ci)
|
||||
pfx = f"{base_prefix}_{tag}" if base_prefix else f"seed_{tag}"
|
||||
generated = generate_seed_shots(ib, base, cn, material, n, str(shot_dir),
|
||||
r.get("composite_negative", ""),
|
||||
size=size, prefix=pfx)
|
||||
size=size, prefix=pfx, gender=gender)
|
||||
paths.extend(generated)
|
||||
if not paths:
|
||||
return None
|
||||
r["seed_shot_paths"] = paths
|
||||
urls: List[str] = []
|
||||
for pth in paths:
|
||||
# OSS key 用对应货号(img_code),不再自增;无货号时回退自增计数
|
||||
with seq_lock:
|
||||
if seq >= MAX_CODE:
|
||||
print(f"[seed_shot] 货号计数达上限 999,停止上传种草图")
|
||||
break
|
||||
code = f"{prefix}{seq:03d}"
|
||||
seq += 1
|
||||
if not base_prefix:
|
||||
if seq >= MAX_CODE:
|
||||
print(f"[seed_shot] 货号计数达上限 999,停止上传种草图")
|
||||
break
|
||||
code = f"{prefix}{seq:03d}"
|
||||
seq += 1
|
||||
else:
|
||||
code = base_prefix
|
||||
if oss_enabled:
|
||||
try:
|
||||
compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg")))
|
||||
|
||||
@@ -73,19 +73,18 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
db_path = root / db_path
|
||||
break
|
||||
|
||||
from graph.template_export import export_product
|
||||
from graph.template_export import export_products
|
||||
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] = []
|
||||
# 批量合并导出:所有产品一次性写入同一模板,只打开/保存一次(避免逐产品频繁读写)
|
||||
batch: List[Dict[str, Any]] = []
|
||||
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())
|
||||
has_title = bool((r.get("en_title") or "").strip()) # 商品名称统一用 en_title
|
||||
if not (has_img and has_title):
|
||||
skipped += 1
|
||||
print(f"[template] 跳过失败产品 {r.get('spu_code')}/{r.get('img_code','')}: "
|
||||
@@ -94,31 +93,37 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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 ""]
|
||||
batch.append({
|
||||
"spu_code": r.get("spu_code", ""),
|
||||
"sku_codes": sku_codes,
|
||||
"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", ""),
|
||||
"es_title": r.get("es_title", ""),
|
||||
"composite_urls": r.get("color_composites") or [],
|
||||
"seed_shot_urls": r.get("seed_shot_urls") or [],
|
||||
})
|
||||
|
||||
exported: List[str] = []
|
||||
if batch:
|
||||
out = _template_out_path(prod_dir, "商品上传")
|
||||
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, # 首个产品从模板创建,后续追加合并
|
||||
out = export_products(
|
||||
db_path, batch, tdir, tp, str(out),
|
||||
markup_percent=float(pcfg.get("markup_percent") or 0),
|
||||
)
|
||||
r["template_path"] = str(out)
|
||||
for r in products:
|
||||
if (r.get("composite_path") or r.get("printed_path")) and (r.get("en_title") or "").strip():
|
||||
r["template_path"] = str(out)
|
||||
exported.append(str(out))
|
||||
print(f"[template] 商品上传模板已生成({len(exported)}/{len(products)} 合并): {out}")
|
||||
print(f"[template] 商品上传模板已生成({len(batch)} 个产品一次合并): {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
|
||||
"message": f"模板批量导出失败: {e}", "trace": ""})
|
||||
print(f"[template] 模板批量导出失败: {e}")
|
||||
|
||||
stats["template_export"] = {"exported": len(exported)}
|
||||
return {"product": products, "errors": errors, "stats": stats}
|
||||
|
||||
Reference in New Issue
Block a user