模板导出增强 + 模特性别分组 + 三合一提示词精简

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:
2026-08-26 18:05:11 +08:00
parent e317547b8b
commit b7f429db89
93 changed files with 2708 additions and 583 deletions
+589
View File
@@ -0,0 +1,589 @@
"""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 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._products: List[Dict[str, Any]] = []
self._products_lock = threading.Lock()
self._errors: List[Dict[str, Any]] = []
self._errors_lock = threading.Lock()
# 路径解析(复用 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]:
model_assign: Dict[str, Any] = {}
try:
from graph.product import find_first_model_folder
_folder, _all_models = find_first_model_folder(self._material_root, self._category)
except Exception: # noqa: BLE001
_all_models = []
if _all_models:
seen: Dict[str, str] = {}
for _i, (spu, _skus) in enumerate(self._worklist):
code = spu.get("code", "")
if code not in seen:
seen[code] = _all_models[_i % len(_all_models)]
model_assign[code] = seen[code]
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
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 finish(self) -> tuple:
"""排空简报池、等待全部产品完成,返回 (products, errors)。"""
with self._cond:
self._done = True
self._cond.notify_all()
self._dispatcher.join()
self._pool.shutdown(wait=True)
with self._products_lock:
products = list(self._products)
with self._errors_lock:
errors = list(self._errors)
print(f"[pinterest_pipeline] 收尾:完成 {len(products)} 个产品,错误 {len(errors)}")
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
batch = self._briefs
self._briefs = []
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:
# 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_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 design_path:
break
if not design_path:
return
brief["design_path"] = design_path
# 2) 三合一(product)——同一货号
prod = self._process_spu(brief, spu, skus, img_code, design_path)
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
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}")
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()
return generate_design(self._ib, brief, design_dir, img_code, self._errors,
on_400=_on_400,
size=str((self.config.get("compose") or {}).get("design_size") or "1024x1024"))
except Exception as e: # noqa: BLE001
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) -> 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(spu.get("code", "")))
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
generated = generate_seed_shots(self._ib, base, cn, material, n, str(shot_dir),
r.get("composite_negative", ""),
size=size, prefix=pfx, gender=self._gender)
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}")