Files
pod_trend_agent/graph/pinterest_pipeline.py
T
3218485270 5ab5cf6586 v110-v112 自定义模式完善 + 模板导出增强 + 多模态兼容优化
- 自定义模式:分析模型输出 delta 唯一改动指令,生图模板 custom_image_prompt.md({delta} 占位符),不再使用负向提示词;generate_design 按 custom_mode 分支,Pinterest 模式保留原创化指令,两模式互不影响
- 多模态分析 response_format 三级回退(json_schema → json_object → none),兼容 DeepSeek
- 模板导出:details 扩展列(细节1/2/3)、target_audience 扩展列(适用人群1)、固定值风格1=休闲/风格2=运动
- 童装特征库更新 + 标题模板外部化 + 图源映射增强
2026-09-03 18:28:39 +08:00

829 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pinterest 简报池 + 并发生成流水线。
分析节点产出简报后立即推入简报池,后台 worker 逐条并发生成:
生成设计(compose) → 三合一(product) → OSS上传 → 生成种草图(seed_shot)
不等全部分析完,边分析边生成,显著缩短总耗时。
集成:
pinterest_init 创建 PinterestPipeline(存 state["pinterest_pipeline"]
pinterest_analyze 每批产出简报 → pipe.add_briefs(new_briefs)
pinterest_finalize → pipe.finish()(排空 + 合并产品 + 补写报告)→ template_export
并发上限与 product_node 一致(默认 5),避免压垮图像网关。
"""
import concurrent.futures
import random
import re
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.validate import ThreadSafeErrors
class PinterestPipeline:
def __init__(self, state: Dict[str, Any]):
self.config = state["config"]
self.country = state.get("country", "")
self.output_dir = Path(state["output_dir"])
self.cache_dir = Path(state.get("cache_dir") or self.output_dir)
self.task_timestamp = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S"))
# 简报池 + 信号
self._lock = threading.Lock()
self._cond = threading.Condition(self._lock)
self._briefs: List[Dict[str, Any]] = []
self._done = False
self._cursor = 0
# 正在处理(已提交线程池、尚未完成)的简报数,供路由判断简报池是否空闲
self._in_flight = 0
# 结果:每完成一个产品立即落盘追加写入 products_pending.jsonl
# 内存列表仅作缓存(finish 时再读盘合并),中途崩溃也不丢已完成产品。
self._products: List[Dict[str, Any]] = []
self._products_lock = threading.Lock()
self._errors: List[Dict[str, Any]] = []
self._errors_lock = threading.Lock()
# 致命图像服务错误(53/账户不可用):置位后终止分发、丢弃未完成简报,仅保留已完成产品
self._fatal_lock = threading.Lock()
self._fatal_503 = False
# 已完成产品落盘文件(JSONL 追加写):output/<country>/<ts>/products_pending.jsonl
self._pending_file = self.output_dir / "products_pending.jsonl"
# 路径解析(复用 product_node 的 _abs 逻辑:运行根优先,其次数据根)
pcfg = self.config.get("product") or {}
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
self._db_path = _abs("db_path", "db/spu_sku.db")
self._basemap_root = _abs("basemap_dir", "basemap")
self._material_root = _abs("material_library_dir", "material_library")
self._category = pcfg.get("model_category", "T-shirt")
self._prefix = str(pcfg.get("code_prefix") or "DG").strip()
# 任务清单(spu_tasks → [(spu, skus)],简报按序号绑定)
self._worklist = self._build_worklist()
# 图像后端(compose + product 共用)/ 标题后端
self._ib = self._init_image_backend()
self._title_backend = self._init_title_backend()
# 分析后端(失败/侵权时从图池补充图片重新分析用)
self._analyze_backend = self._init_analyze_backend()
self._country_config = state.get("country_config") or {}
# 补充重试次数:设计生成失败/侵权时,从图池取新图重新分析的最多尝试次数
self._supply_attempts = int((self.config.get("pinterest") or {}).get("supply_attempts", 3))
# 自定义模式标志:pinterest_init 先于 pinterest_custom_load 执行(state.custom_mode 尚未置位),
# 故从 config 判断(mode=custom 或 custom_image_dir 非空),供 generate_design 决定是否追加原创化指令
_pcfg = self.config.get("pinterest") or {}
self.custom_mode = bool(state.get("custom_mode")) \
or (str(_pcfg.get("mode") or "").strip().lower() == "custom") \
or bool(str(_pcfg.get("custom_image_dir") or "").strip())
# 400 计数(per 种子词):多模态 + 生图模型合计,超限放弃当前种子词
self._err400_lock = threading.Lock()
self._err400_count = 0
self._err400_limit = int((self.config.get("pinterest") or {}).get("err400_limit", 15))
self._err400_aborted = False
self._err400_term = ""
# 模特分配(一个 SPU 一个模特,SPU>模特数循环兜底)
self._model_assign = self._assign_models()
# 材质映射(seed_shot 用)
self._material_map = self._load_materials()
# 类目 → 性别(seed_shot 用):模版「类目」表头值含「男」→ 男模;含「女」→ 女模;都不含 → 全部随机
self._gender = None
tp = str((pcfg.get("template_path") or "") or "").strip()
if tp:
from graph.seed_shot import read_template_category, gender_from_category
category = read_template_category(tp)
self._gender = gender_from_category(category)
if self._gender:
label = {"male": "男", "female": "女", "boy_kids": "男童", "girl_kids": "女童"}.get(self._gender, self._gender)
print(f"[pinterest_pipeline] 类目「{category[:30]}…」检测到 {label} → 固定 {self._gender} 模特")
elif category:
print(f"[pinterest_pipeline] 类目「{category[:30]}…」无男/女 → 男女模特随机")
# OSS
self._oss_cfg = self.config.get("oss") or {}
self._oss_enabled = bool(self._oss_cfg.get("enabled", True)) and bool(
self._oss_cfg.get("oss_bucket"))
self.oss_seq = int(state.get("oss_seq") or 0)
self._oss_lock = threading.Lock()
# 并发线程池(默认 5,与 product_node 上限一致)
concurrency = int(pcfg.get("concurrency") or 0) or 5
self._pool = concurrent.futures.ThreadPoolExecutor(max_workers=concurrency)
# 分发线程:拉简报 → 逐条提交线程池
self._dispatcher = threading.Thread(target=self._dispatch, daemon=True)
self._dispatcher.start()
print(f"[pinterest_pipeline] 简报池启动:{len(self._worklist)} 个产品任务,"
f"并发 {concurrency}{self.country}")
# ------------------------------------------------------------------ #
# 初始化辅助
# ------------------------------------------------------------------ #
def _build_worklist(self) -> List[tuple]:
pcfg = self.config.get("product") or {}
spu_tasks = pcfg.get("spu_tasks") or []
worklist: List[tuple] = []
if not spu_tasks:
return worklist
try:
from graph.product import list_spus
spus = list_spus(str(self._db_path))
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] SPU 读取失败: {e}")
spus = []
for t in 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"[pinterest_pipeline] 任务款号 {code} 不在 db,跳过")
continue
worklist.append((spu, (t.get("skus") or "").strip()))
return worklist
def _init_image_backend(self):
compose_cfg = self.config.get("compose") or {}
backend_name = (compose_cfg.get("backend") or "").strip()
if not backend_name:
return None
try:
from graph.backends import get_image_backend
ib = get_image_backend(backend_name)
if ib is not None:
ib.bind_config(compose_cfg)
return ib
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 图像后端不可用: {e}")
return None
def _init_title_backend(self):
ls_cfg = self.config.get("llm_screen") or {}
if (ls_cfg.get("provider") or "") in ("", "mock"):
return None
try:
from graph.llms import get_backend as _glb
tb = _glb(ls_cfg.get("provider"))
tb.bind_config(ls_cfg)
if getattr(tb, "has_key", False):
return tb
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 标题后端初始化失败: {e}")
return None
def _init_analyze_backend(self):
"""图片分析后端(失败/侵权时从图池补充图片重新分析用)。"""
pcfg = self.config.get("pinterest") or {}
provider = str(pcfg.get("provider") or "openai").strip().lower()
if provider == "static":
return None
try:
from graph.llms import get_backend
llm = get_backend(provider)
if hasattr(llm, "bind_config"):
llm.bind_config(self.config.get("llm_screen") or {})
if provider not in ("mock",) and not getattr(llm, "has_key", False):
print(f"[pinterest_pipeline] {provider} 未配置 API key,降级 mock")
llm = get_backend("mock")
return llm
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 分析后端初始化失败: {e}")
return None
def _assign_models(self) -> Dict[str, Any]:
"""任务级图源分配:按每个任务 spu.mark 从可配置的「模特图/平铺图」文件夹间随机抽图。
每个任务独立随机抽一张:先合并该 mark 对应「有图的」模特/平铺目录的全部合格图,再从其中随机抽一张,
抽到哪个文件夹的图就返回对应 kindmodel/flat),供 product_node 用对应提示词合成。
某类目录无图则只用另一类;两类都无图则该任务无图源(跳过合成)。
返回 {task_key: {"img": Path, "kind": "model"|"flat", "prompts": {…}}}。
"""
import random as _random
assign: Dict[str, Any] = {}
pcfg = self.config.get("product") or {}
mark_dirs = pcfg.get("mark_dirs") or {}
try:
from graph.product import build_mark_sources
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 图源映射导入失败: {e}")
return assign
# 每个出现过的 mark 各建一个图源池,避免多 mark 错配
pool_by_mark: Dict[str, list] = {}
for _i, (spu, _skus) in enumerate(self._worklist):
mark = str(spu.get("mark") or "").strip() or "1"
if mark in pool_by_mark:
continue
try:
sources = build_mark_sources(self._material_root, mark_dirs, self._category, mark=mark)
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] mark={mark} 图源构建失败: {e}")
sources = {"model": [], "flat": []}
pool = []
for kind in ("model", "flat"):
for p in sources.get(kind) or []:
pool.append((p, kind))
pool_by_mark[mark] = pool
if pool:
print(f"[pinterest_pipeline] mark={mark} 图源池:{len(pool)} 张(模特/平铺)")
for _i, (spu, _skus) in enumerate(self._worklist):
key = f"task_{_i}"
mark = str(spu.get("mark") or "").strip() or "1"
pool = pool_by_mark.get(mark) or []
if not pool:
continue
img, kind = _random.choice(pool) # 每任务独立随机抽一张(含 kind)
assign[key] = {"img": img, "kind": kind}
return assign
def _load_materials(self) -> Dict[str, str]:
material_map: Dict[str, str] = {}
try:
from graph.product import list_spus
for s in list_spus(str(self._db_path)):
m = " ".join(str(s.get("material", "")).replace("\r", " ").replace("\n", " ").split())
material_map[s["code"]] = m
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 材质读取失败(用空): {e}")
return material_map
# ------------------------------------------------------------------ #
# 对外接口
# ------------------------------------------------------------------ #
def reset_400(self, term: str = "") -> None:
"""新一批图爬取完成后调用:重置 400 计数并记录当前种子词。"""
with self._err400_lock:
self._err400_count = 0
self._err400_aborted = False
self._err400_term = term or ""
def record_400(self) -> bool:
"""记录一次 400(含内容/图片)。返回 True 表示本次触发放弃当前种子词。"""
with self._err400_lock:
self._err400_count += 1
if self._err400_count > self._err400_limit and not self._err400_aborted:
self._err400_aborted = True
return True
return False
def is_400_aborted(self) -> bool:
with self._err400_lock:
return self._err400_aborted
# ------------------------------------------------------------------ #
# 致命图像服务错误(503 / 账户不可用):重试无效,提前终止整个任务
# ------------------------------------------------------------------ #
@staticmethod
def is_fatal_503(exc) -> bool:
"""判断异常是否为「图像服务不可用」类致命错误(503 / No available compatible accounts)。
这类错误说明账户配额耗尽或网关故障,重试必然失败,应提前终止任务而非无意义重试。
"""
msg = str(exc)
if "503" in msg:
return True
low = msg.lower()
return "no available compatible accounts" in low or "account" in low and "not available" in low
def record_503(self) -> bool:
"""记录一次致命 503:首次触发即置位终止标志(后续请求直接短路不再提交)。
返回 True 表示本次触发终止(调用方应立即停止当前链路)。
"""
with self._fatal_lock:
first = not self._fatal_503
self._fatal_503 = True
if first:
print("[pinterest_pipeline] ⛔ 检测到图像服务 503No available compatible accounts),"
"重试无效 → 提前终止任务,未完成产品将废弃,仅保留已完成产品")
return first
def is_fatal_503_aborted(self) -> bool:
with self._fatal_lock:
return self._fatal_503
def abort_unfinished(self) -> None:
"""终止分发:丢弃简报池中所有未完成简报(已完成的落盘产品保留)。"""
with self._cond:
dropped = len(self._briefs)
self._briefs = []
self._done = True
self._cond.notify_all()
if dropped:
print(f"[pinterest_pipeline] 503 终止:丢弃未完成简报 {dropped} 条(未完成产品废弃)")
def _abort_current_term(self) -> None:
"""放弃当前种子词:清空其未完成简报 + 图池未消费图片(已完成的保留)。"""
term = self._err400_term
with self._cond:
kept = [b for b in self._briefs if not self._brief_of_term(b, term)]
dropped = len(self._briefs) - len(kept)
self._briefs = kept
if dropped:
print(f"[pinterest_pipeline] 放弃「{term}」未完成简报 {dropped} 条")
self._drop_term_pool(term)
@staticmethod
def _brief_of_term(b: Dict[str, Any], term: str) -> bool:
"""简报是否属于某种子词(topic 去掉 #N 后缀后 == term)。"""
if not term:
return False
topic = str(b.get("topic") or "").strip()
base = re.sub(r"\s+#\d+$", "", topic).strip().lower()
return bool(base) and base == term.strip().lower()
def _drop_term_pool(self, term: str) -> None:
"""清除图池中属于当前种子词的图片,并把它们的 md5 全部拉黑(used_images.json)。
400 超限说明这批图反复触发内容/图片 400,整批拉黑防止下次重新爬取到相同图再次触发。
"""
if not term:
return
try:
from graph.pinterest import (
load_image_pool, save_image_pool,
load_used_images, save_used_images,
)
pool = load_image_pool(str(self.output_dir), self.country)
imgs = pool.get("images") or []
term_imgs = [img for img in imgs
if str(img.get("term") or "").strip().lower() == term.strip().lower()]
kept = [img for img in imgs if img not in term_imgs]
if len(kept) < len(imgs):
pool["images"] = kept
save_image_pool(str(self.output_dir), self.country, pool)
print(f"[pinterest_pipeline] 清除图池「{term}」图片 {len(imgs) - len(kept)} 张")
md5s = [str(img.get("md5") or "").strip().lower() for img in term_imgs]
md5s = [m for m in md5s if m]
if md5s:
used = load_used_images(str(self.output_dir), self.country)
before = len(used)
used.update(md5s)
if len(used) > before:
save_used_images(str(self.output_dir), self.country, used)
print(f"[pinterest_pipeline] 400 超限:拉黑「{term}」图片 md5 {len(md5s)} 个")
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 清除图池失败: {e}")
def add_briefs(self, briefs: List[Dict[str, Any]]) -> None:
if not briefs:
return
with self._cond:
self._briefs.extend(briefs)
self._cond.notify_all()
print(f"[pinterest_pipeline] 简报池 +{len(briefs)} 条(待处理 {len(self._briefs)}")
def pending_count(self) -> int:
"""简报池中待处理 + 正在处理的简报数(供路由判断是否需要补图/补分析)。"""
with self._cond:
queued = len(self._briefs)
return queued + self._in_flight
def wait_idle(self, timeout: Optional[float] = None) -> bool:
"""阻塞等待简报池消化完(无待处理且无在途),返回是否已空闲。
用 Condition 等待(_process_one 完成时 notify_all 唤醒),而非轮询 sleep
避免 wait 循环疯狂刷屏。timeout 为 None 时无限等待(受 _done 保护)。
"""
with self._cond:
while (self._briefs or self._in_flight > 0) and not self._done:
if timeout is not None:
deadline = time.time() + timeout
remaining = deadline - time.time()
if remaining <= 0:
return False
self._cond.wait(min(remaining, 1.0))
else:
self._cond.wait()
return not self._briefs and self._in_flight <= 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
# 异常路径也确保排空并释放线程池,避免 dispatcher/worker 泄漏
try:
self.finish()
except Exception: # noqa: BLE001
pass
return False
def finish(self) -> tuple:
"""排空简报池、等待全部产品完成,返回 (products, errors)。
产品来源:手动已完成(内存缓存)+ 落盘文件(products_pending.jsonl
按货号去重合并——即使中途 503 终止/崩溃,已完成产品也不丢。
"""
with self._cond:
self._done = True
self._cond.notify_all()
self._dispatcher.join()
self._pool.shutdown(wait=True)
# 读盘 + 内存合并去重(内存为准,但以落盘为最终权威——崩溃恢复后走落盘)
pending = self.load_pending()
merged = {str(p.get("img_code", "")): p for p in pending}
with self._products_lock:
for p in self._products:
merged[str(p.get("img_code", ""))] = p
products = [merged[k] for k in merged if k]
with self._errors_lock:
errors = list(self._errors)
print(f"[pinterest_pipeline] 收尾:完成 {len(products)} 个产品,错误 {len(errors)}"
f"{'(含落盘恢复 ' + str(len(pending)) + '' if pending else ''}")
return products, errors
# ------------------------------------------------------------------ #
# 后台线程
# ------------------------------------------------------------------ #
def _dispatch(self) -> None:
while True:
with self._cond:
while not self._briefs and not self._done:
self._cond.wait()
if self._done and not self._briefs:
break
if self._fatal_503:
# 致命 503:不再分发新简报(未完成的废弃,仅保留已完成落盘产品)
self._briefs = []
self._done = True
self._cond.notify_all()
break
batch = self._briefs
self._briefs = []
# 与 _briefs 清空同一临界区递增在途数,避免 wait_idle 误判空闲
self._in_flight += len(batch)
for b in batch:
with self._cond:
idx = self._cursor
self._cursor += 1
self._pool.submit(self._process_one, b, idx)
# ------------------------------------------------------------------ #
# 单条简报完整链路:设计 → 三合一 → OSS → 种草图
# ------------------------------------------------------------------ #
def _process_one(self, brief: Dict[str, Any], idx: int) -> None:
# 每个 worker 用独立线程安全错误收集器,后台调用链(generate_design/_process_spu
# 的 append 与 finish() 的 list(self._errors) 读取不再互相竞态,结束时统一合并。
errs = ThreadSafeErrors()
try:
if self.is_fatal_503_aborted():
return
# 0) 先定货号:整条链路(设计/三合一/种草图)都用它命名与匹配,避免序号错位
if idx >= len(self._worklist):
print(f"[pinterest_pipeline] 简报 {idx} 无对应产品任务,跳过")
return
spu, skus = self._worklist[idx]
img_code = f"{self._prefix}{idx:03d}"
# 1) 生成设计(compose)——直接按货号命名 designs/{img_code}_design.png
design_path = self._gen_design(brief, img_code, errs)
if self.is_fatal_503_aborted():
return
if self.is_400_aborted():
# 当前种子词 400 超限已放弃:正在生成的当个也放弃,不进入后续链路
print(f"[pinterest_pipeline] 当前种子词 400 超限已放弃,跳过简报 {idx}")
return
if not design_path:
# 生成失败/侵权(MD5 全局重复/API 错误)→ 从图池补充图片重新分析,最多尝试 N 次;
# 图池不足 → 返回 None,由路由在下一轮触发搜索
for _ in range(self._supply_attempts):
new_brief = self._supply_from_pool(
reason=f"简报「{brief.get('topic','')}」设计生成失败")
if new_brief is None:
return
brief = new_brief
design_path = self._gen_design(brief, img_code, errs)
if self.is_fatal_503_aborted():
return
if design_path:
break
if not design_path:
return
brief["design_path"] = design_path
# 2) 三合一(product)——同一货号;task_idx=本次简报序号,模特按任务独立随机
prod = self._process_spu(brief, spu, skus, img_code, design_path, task_idx=idx, errors=errs)
if self.is_fatal_503_aborted():
return
if not prod:
return
# 3) OSS 上传
self._upload_product(prod)
# 4) 种草图——同一货号
self._seed_shot(prod)
# 去重记录
try:
from graph.nodes.product_node import _record_used
_record_used(self.cache_dir, prod)
except Exception: # noqa: BLE001
pass
# 5) 落盘:每完成一个产品立即追加写入 products_pending.jsonl(不依赖内存,崩溃不丢)
self._persist_product(prod)
with self._products_lock:
self._products.append(prod)
print(f"[pinterest_pipeline] 产品完成: {prod.get('img_code', '')}"
f"(累计 {len(self._products)}")
except Exception as e: # noqa: BLE001
with self._errors_lock:
self._errors.append({"node": "pinterest_pipeline", "type": type(e).__name__,
"message": f"简报 {idx} 处理失败: {e}", "trace": ""})
print(f"[pinterest_pipeline] 简报 {idx} 处理失败: {e}")
finally:
# 合并本 worker 收集的错误到共享 _errors(持锁,供 finish() 安全读取)
with self._errors_lock:
self._errors.extend(list(errs))
with self._cond:
self._in_flight = max(0, self._in_flight - 1)
self._cond.notify_all()
def _persist_product(self, prod: Dict[str, Any]) -> None:
"""把已完成产品追加写入 products_pending.jsonlJSONL 每行一个产品)。
并发安全:写入持 _products_lock,整行一次写(含换行),避免并发 append 交织;
落盘失败不阻塞主流程(仅告警);finish() 时读盘合并,保证已完成产品不丢。
"""
try:
import json as _json
self._pending_file.parent.mkdir(parents=True, exist_ok=True)
line = _json.dumps(prod, ensure_ascii=False) + "\n"
with self._products_lock:
with open(self._pending_file, "a", encoding="utf-8") as f:
f.write(line)
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 产品落盘失败(不影响流程): {e}")
def load_pending(self) -> List[Dict[str, Any]]:
"""读回 products_pending.jsonl 中已落盘的产品(进程重启/崩溃恢复用)。"""
import json as _json
out: List[Dict[str, Any]] = []
if not self._pending_file.exists():
return out
try:
for line in self._pending_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
out.append(_json.loads(line))
except Exception: # noqa: BLE001
continue
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 读回落盘产品失败: {e}")
return out
def _gen_design(self, brief: Dict[str, Any], img_code: str, errors: ThreadSafeErrors) -> Optional[str]:
if self._ib is None:
return None
try:
from graph.nodes.compose_node import generate_design
design_dir = self.output_dir / "designs"
design_dir.mkdir(parents=True, exist_ok=True)
def _on_400():
if self.record_400():
self._abort_current_term()
def _on_503():
self.record_503()
self.abort_unfinished()
return generate_design(self._ib, brief, design_dir, img_code, errors,
on_400=_on_400, on_503=_on_503,
size=str((self.config.get("compose") or {}).get("design_size") or "1024x1024"),
custom_mode=self.custom_mode)
except Exception as e: # noqa: BLE001
if self.is_fatal_503(e):
self.record_503()
self.abort_unfinished()
return None
errors.append({"node": "compose", "type": type(e).__name__,
"message": f"设计稿生成失败 {brief.get('topic', '')}: {e}",
"trace": ""})
return None
def _supply_from_pool(self, reason: str) -> Optional[Dict[str, Any]]:
"""生成失败/侵权时,从图池取一张未消费图片重新分析,产出新简报。
图池不足 → 返回 None(由路由在下一轮触发搜索)。该图片分析后 md5 一律拉黑
(合适/不合适都拉黑),避免重复分析。
"""
from graph.pinterest import (
compress_image,
load_image_pool,
load_used_images,
pool_unused_images,
save_used_images,
)
pool = load_image_pool(str(self.output_dir), self.country)
used = load_used_images(str(self.output_dir), self.country)
unused = pool_unused_images(pool, used)
if not unused:
print(f"[pinterest_pipeline] 图池无未消费图片,无法补充({reason}),等待路由搜索")
return None
img = unused[0]
compressed = compress_image(img["path"])
llm = self._analyze_backend
if llm is None or not hasattr(llm, "analyze_pinterest_images"):
return None
try:
def _on_400():
if self.record_400():
self._abort_current_term()
res = llm.analyze_pinterest_images([compressed], img.get("term", ""), self.country,
on_400=_on_400) or []
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 补充分析失败: {e}")
res = []
# 该图片已消费 → 拉黑(合适/不合适都拉黑)
if img.get("md5"):
used.add(str(img["md5"]).lower())
save_used_images(str(self.output_dir), self.country, used)
if not res or not isinstance(res[0], dict):
return None
b = res[0]
from graph.nodes.pinterest_analyze_node import _brief_suitable
if not _brief_suitable(b):
print(f"[pinterest_pipeline] 补充简报侵权/不适合印花,丢弃: {b.get('topic','')}")
return None
b["ref_images"] = [img["path"]]
b["source_md5"] = str(img.get("md5") or "").strip().lower()
try:
from graph.nodes.pinterest_analyze_node import _enrich_briefs
from graph.nodes.prompt_node import prompt_node
screened = _enrich_briefs([b], self.country)
if not screened:
return None
r = prompt_node({
"config": self.config, "country": self.country,
"country_config": self._country_config, "screened": screened,
})
new_briefs = r.get("briefs") or []
if new_briefs:
print(f"[pinterest_pipeline] 图池补充成功({reason}: {new_briefs[0].get('topic','')}")
return new_briefs[0]
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 补充简报装配失败: {e}")
return None
def _model_source(self, task_idx: int, spu=None) -> Dict[str, Any]:
"""返回某任务给 _process_spu 的图源参数:model_img / model_kind / prompts。"""
src = self._model_assign.get(f"task_{task_idx}") or {}
img = src.get("img")
if img is None:
return {}
if spu is not None and int(spu.get("mark") or 0) != 1:
return {"model_img": img} # 非 mark=1 走旧逻辑(不传 kindproduct_node 按其 mark 自行判定)
return {"model_img": img,
"model_kind": src.get("kind", "model")}
def _process_spu(self, brief: Dict[str, Any], spu, skus: str, img_code: str,
design_path: str, task_idx: int = 0, errors: Optional[ThreadSafeErrors] = None) -> Optional[Dict[str, Any]]:
from graph.nodes.product_node import _process_spu as _ps
prod_dir = self.output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
# 设计稿已按货号命名(designs/{img_code}_design.png),直接复用,无需再拷贝
brief = dict(brief)
brief["design_path"] = design_path
# 标题生成用:模版「类目」完整路径(注入标题提示词 {category_path}),懒加载缓存一次
if not hasattr(self, "_category_path"):
self._category_path = ""
try:
_tp = str((self.config.get("product") or {}).get("template_path") or "").strip()
if _tp:
from graph.seed_shot import read_template_category
self._category_path = read_template_category(_tp)
except Exception as _e: # noqa: BLE001
print(f"[pinterest_pipeline] 读取模版类目路径失败(标题 category_path 留空): {_e}")
r = _ps(self._db_path, self._basemap_root, self._material_root, self._category,
prod_dir, brief, self._ib, spu, skus, self.config.get("product") or {},
errors, design_path, self._title_backend, self.country,
img_code=img_code,
on_503=lambda: (self.record_503(), self.abort_unfinished()),
category_path=self._category_path,
**self._model_source(task_idx, spu))
if r:
r["img_code"] = img_code
return r
def _upload_product(self, r: Dict[str, Any]) -> None:
if not self._oss_enabled:
return
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
from graph.nodes.oss_upload_node import KIND_ORDER, _gen_rand4
# 货号直接取产品 img_code:同一货号的所有图片(合成/平铺/底图)共用同一货号,
# 避免独立计数器在有产品被跳过时与 img_code 错位
base_code = r.get("img_code") or r.get("oss_code") or ""
if not base_code:
return
for kind in KIND_ORDER:
src = r.get(f"{kind}_path")
if not src or not Path(src).exists():
continue
try:
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(self.country, self.task_timestamp, base_code, _gen_rand4(), local=compressed)
url = upload_to_oss(self._oss_cfg, compressed, key)
if url:
r[f"{kind}_url"] = url
r["oss_code"] = base_code
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] OSS 上传失败 {src}: {e}")
# 多色:首色用主图 url/code,额外色单独上传(同一货号)
comps = r.get("color_composites") or []
if comps and r.get("composite_url"):
comps[0]["url"] = r["composite_url"]
comps[0]["code"] = r.get("oss_code", "")
for cc in comps[1:]:
src = cc.get("composite_path")
if not src or not Path(src).exists():
continue
try:
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(self.country, self.task_timestamp, base_code, _gen_rand4(), local=compressed)
url = upload_to_oss(self._oss_cfg, compressed, key)
if url:
cc["url"] = url
cc["code"] = base_code
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] OSS 颜色图上传失败 {src}: {e}")
def _seed_shot(self, r: Dict[str, Any]) -> None:
ss_cfg = self.config.get("seed_shot") or {}
count = int(ss_cfg.get("count", 1))
if count <= 0 or not bool(ss_cfg.get("enabled", True)) or self._ib is None:
return
from graph.nodes.seed_shot_node import _plan_seed_shots
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 MAX_CODE, _gen_rand4
comps = r.get("color_composites") or []
if not comps and r.get("composite_path") and Path(r["composite_path"]).exists():
comps = [{"sku_code": r.get("sku_code"), "color": r.get("color", ""),
"composite_path": r["composite_path"]}]
if not comps:
return
plan = _plan_seed_shots(comps, count)
cn = (r.get("cn_title") or "").strip() or r.get("topic", "")
material = self._material_map.get(r.get("spu_code", ""), "")
base_prefix = r.get("img_code") or r.get("oss_code") or ""
pfx = base_prefix or "seed"
shot_dir = self.output_dir / "seed_shots"
shot_dir.mkdir(parents=True, exist_ok=True)
size = str(ss_cfg.get("size") or "1536x2048")
paths: List[str] = []
for cc, n in plan:
base = cc.get("composite_path")
if not base or not Path(base).exists():
print(f"[pinterest_pipeline] {r.get('spu_code', '')} 参考图缺失,跳过该色种草图")
continue
try:
generated = generate_seed_shots(self._ib, base, cn, material, n, str(shot_dir),
r.get("composite_negative", ""),
size=size, prefix=pfx, gender=self._gender,
retries=int(ss_cfg.get("retries", 3)))
except Exception as e: # noqa: BLE001
if self.is_fatal_503(e):
self.record_503()
self.abort_unfinished()
return
print(f"[pinterest_pipeline] 种草图生成失败(跳过该色): {e}")
continue
paths.extend(generated)
if not paths:
return
r["seed_shot_paths"] = paths
urls: List[str] = []
for pth in paths:
with self._oss_lock:
if not base_prefix:
if self.oss_seq >= MAX_CODE:
break
code = f"{self._prefix}{self.oss_seq:03d}"
self.oss_seq += 1
else:
code = base_prefix
if self._oss_enabled:
try:
compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg")))
url = upload_to_oss(self._oss_cfg, compressed,
build_oss_key(self.country, self.task_timestamp,
code, _gen_rand4(), local=compressed))
if url:
urls.append(url)
r["seed_shot_urls"] = urls
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 种草图上传失败 {pth}: {e}")