Files
pod_trend_agent/graph/pinterest_pipeline.py
T
3218485270 2a96ec0870 v88 功能增强:产品落盘持久化 + 生图网关适配 + 模板导出优化
- 产品持久化:每完成一个产品立即追加写入 products_pending.jsonl,崩溃不丢已完成产品,finish 读盘合并后统一写模板
- 503 致命错误提前终止:compose/product/seed_shot 端到端识别,提前终止搜索分析,丢弃未完成简报,保留已完成落盘产品直接合成模板
- 模特分配:material_library 合格模特图按任务序号独立随机,同 SPU 多款不再共用同一模特
- 图像网关适配:execution_mode/background 默认不再传入 yunfei 等标准网关,base_url 需带 /v1;429/5xx/空响应退避重试
- Pinterest 分析:删除 term 注入与纯文本降级,失败直接放弃;图片上传前 PIL 完整性校验;suitable_for_print=False 过滤丢弃
- 模板导出:不再产生空白 xlsx,文件名=模板原文件名_已填写;写入前按货号末 3 位升序排序
- 删除对接文档.md,更新 README,gitignore 排除测试产物
2026-08-28 10:28:35 +08:00

758 lines
35 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
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))
# 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:
print(f"[pinterest_pipeline] 类目「{category[:30]}…」含{'男' if self._gender == 'male' else '女'} → 固定 {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 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽取。
每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复。
"""
model_assign: Dict[str, Any] = {}
try:
from graph.product import build_mark_model_map, find_model_images_for_mark
mark_map = build_mark_model_map(self._db_path, self._material_root)
except Exception: # noqa: BLE001
mark_map = {}
# 每个任务按序号绑定独立随机模特(同 SPU 多款也各自随机,不共用)
for _i, (spu, _skus) in enumerate(self._worklist):
key = f"task_{_i}"
mark = str(spu.get("mark") or "").strip() or "1"
folder = mark_map.get(mark, self._category)
try:
pool_imgs = find_model_images_for_mark(self._db_path, self._material_root,
mark, folder)
except Exception: # noqa: BLE001
pool_imgs = []
if pool_imgs:
model_assign[key] = random.choice(pool_imgs) # 过滤后随机抽
return model_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 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:
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)
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)
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)
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:
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 每行一个产品)。
落盘失败不阻塞主流程(仅告警);finish() 时读盘合并,保证已完成产品不丢。
"""
try:
import json as _json
self._pending_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._pending_file, "a", encoding="utf-8") as f:
f.write(_json.dumps(prod, ensure_ascii=False) + "\n")
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) -> 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, self._errors,
on_400=_on_400, on_503=_on_503,
size=str((self.config.get("compose") or {}).get("design_size") or "1024x1024"))
except Exception as e: # noqa: BLE001
if self.is_fatal_503(e):
self.record_503()
self.abort_unfinished()
return None
with self._errors_lock:
self._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 _process_spu(self, brief: Dict[str, Any], spu, skus: str, img_code: str,
design_path: str, task_idx: int = 0) -> 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
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 {},
self._errors, design_path, self._title_backend, self.country,
img_code=img_code,
model_img=self._model_assign.get(f"task_{task_idx}"),
on_503=lambda: (self.record_503(), self.abort_unfinished()))
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())
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())
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)
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()))
if url:
urls.append(url)
r["seed_shot_urls"] = urls
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 种草图上传失败 {pth}: {e}")