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)
590 lines
27 KiB
Python
590 lines
27 KiB
Python
"""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}")
|