模板导出增强 + 模特性别分组 + 三合一提示词精简
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:
+62
-13
@@ -58,35 +58,79 @@ def build_graph():
|
||||
return builder.compile()
|
||||
|
||||
|
||||
def _pinterest_route(state: Dict[str, Any]) -> str:
|
||||
"""图池路由:简报达标 → done;图池还有未消费图片 → analyze(继续分析,不搜索);
|
||||
图池不足 → search(新一轮搜索);轮次耗尽 → done。"""
|
||||
target = int(state.get("pinterest_target") or 0)
|
||||
if target <= 0:
|
||||
target = 1
|
||||
briefs = state.get("briefs") or []
|
||||
rounds = int(state.get("pinterest_rounds") or 0)
|
||||
terms = state.get("pinterest_search_terms") or []
|
||||
pcfg = (state.get("config") or {}).get("pinterest") or {}
|
||||
max_rounds = int(pcfg.get("max_search_rounds") or 0)
|
||||
if max_rounds <= 0:
|
||||
max_rounds = max(target * 2, 5)
|
||||
|
||||
if len(briefs) >= target:
|
||||
print(f"[pinterest_route] 简报已达目标 {len(briefs)}/{target},结束")
|
||||
return "done"
|
||||
|
||||
# 图池还有未消费图片 → 继续分析(不搜索)
|
||||
try:
|
||||
from graph.pinterest import load_image_pool, load_used_images, pool_unused_images
|
||||
pool = load_image_pool(str(state.get("output_dir") or ""), state.get("country") or "")
|
||||
used = load_used_images(str(state.get("output_dir") or ""), state.get("country") or "")
|
||||
unused = pool_unused_images(pool, used)
|
||||
except Exception: # noqa: BLE001
|
||||
unused = []
|
||||
if unused:
|
||||
print(f"[pinterest_route] 图池还有 {len(unused)} 张未消费图片,继续分析(简报 {len(briefs)}/{target})")
|
||||
return "analyze"
|
||||
|
||||
# 图池不足 → 搜索
|
||||
if rounds >= max_rounds:
|
||||
print(f"[pinterest_route] 已达最大轮次 {max_rounds},简报 {len(briefs)}/{target},按现有结果继续")
|
||||
return "done"
|
||||
if not terms and rounds > 0:
|
||||
print(f"[pinterest_route] 无可用搜索词,停止搜索(简报 {len(briefs)}/{target})")
|
||||
return "done"
|
||||
print(f"[pinterest_route] 图池不足,新一轮搜索(第 {rounds} 轮,简报 {len(briefs)}/{target})")
|
||||
return "search"
|
||||
|
||||
|
||||
def build_pinterest_graph():
|
||||
"""Pinterest 参考模式图(独立于 Google Trends 采集链路):
|
||||
pinterest_search → pinterest_scrape → pinterest_analyze → compose → product
|
||||
→ oss_upload → seed_shot → template_export
|
||||
"""Pinterest 参考模式图(按需搜索循环 + 简报池并发生成):
|
||||
pinterest_init(建简报池)→ pinterest_search → pinterest_scrape → pinterest_analyze
|
||||
→ [pinterest_route] 简报不足 → 回到 pinterest_search;达标 → pinterest_finalize
|
||||
(排空简报池、后台并发生成 设计→三合一→OSS→种草图)→ template_export
|
||||
"""
|
||||
from graph.nodes import (
|
||||
pinterest_analyze_node,
|
||||
pinterest_scrape_node,
|
||||
pinterest_search_node,
|
||||
)
|
||||
from graph.nodes.pinterest_finalize_node import pinterest_finalize_node
|
||||
from graph.nodes.pinterest_init_node import pinterest_init_node
|
||||
|
||||
builder = StateGraph(AgentState)
|
||||
builder.add_node("pinterest_init", pinterest_init_node)
|
||||
builder.add_node("pinterest_search", pinterest_search_node)
|
||||
builder.add_node("pinterest_scrape", pinterest_scrape_node)
|
||||
builder.add_node("pinterest_analyze", pinterest_analyze_node)
|
||||
builder.add_node("compose", compose_node)
|
||||
builder.add_node("product", product_node)
|
||||
builder.add_node("oss_upload", oss_upload_node)
|
||||
builder.add_node("seed_shot", seed_shot_node)
|
||||
builder.add_node("pinterest_finalize", pinterest_finalize_node)
|
||||
builder.add_node("template_export", template_export_node)
|
||||
|
||||
builder.add_edge("__start__", "pinterest_search")
|
||||
builder.add_edge("__start__", "pinterest_init")
|
||||
builder.add_edge("pinterest_init", "pinterest_search")
|
||||
builder.add_edge("pinterest_search", "pinterest_scrape")
|
||||
builder.add_edge("pinterest_scrape", "pinterest_analyze")
|
||||
builder.add_edge("pinterest_analyze", "compose")
|
||||
builder.add_edge("compose", "product")
|
||||
builder.add_edge("product", "oss_upload")
|
||||
builder.add_edge("oss_upload", "seed_shot")
|
||||
builder.add_edge("seed_shot", "template_export")
|
||||
builder.add_conditional_edges("pinterest_analyze", _pinterest_route, {
|
||||
"analyze": "pinterest_analyze", # 图池还有未消费图片 → 继续分析(不搜索)
|
||||
"search": "pinterest_search", # 图池不足 → 新一轮搜索
|
||||
"done": "pinterest_finalize", # 简报达标/轮次耗尽 → 收尾(排空简报池)
|
||||
})
|
||||
builder.add_edge("pinterest_finalize", "template_export")
|
||||
builder.add_edge("template_export", END)
|
||||
return builder.compile()
|
||||
|
||||
@@ -183,5 +227,10 @@ def run_pinterest_ref(
|
||||
"stats": {},
|
||||
"task_timestamp": ts,
|
||||
"oss_seq": 0,
|
||||
# 按需搜索目标:简报数 = spu_tasks 数量(每款一个设计);无任务时回退 spu_count/1
|
||||
"pinterest_target": len((global_config.get("product") or {}).get("spu_tasks") or [])
|
||||
or int((global_config.get("product") or {}).get("spu_count") or 0) or 1,
|
||||
"pinterest_rounds": 0,
|
||||
"pinterest_attempted": [],
|
||||
}
|
||||
return compiled.invoke(state)
|
||||
|
||||
@@ -19,7 +19,7 @@ class ImageBackend(ABC):
|
||||
def print(self, prompt: str, base_image: str, out_path: str, negative: str = "",
|
||||
extra_images: Optional[Sequence[str]] = None, size: str = "") -> str:
|
||||
"""返回生成的成品图路径。实现内部应处理调用失败/超时并抛异常由调用方兜底。
|
||||
size: 显式尺寸覆盖(如 "1504x2000");留空则用后端配置的 size。"""
|
||||
size: 显式尺寸覆盖(如 "1536x2048");留空则用后端配置的 size。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str:
|
||||
|
||||
@@ -20,7 +20,7 @@ class MockImageBackend(ImageBackend):
|
||||
self._cfg = cfg or {}
|
||||
|
||||
def print(self, prompt: str, base_image: str, out_path: str, negative: str = "",
|
||||
extra_images=None, size: str = "") -> str:
|
||||
extra_images=None, size: str = "", seed: int = None) -> str:
|
||||
size_px = (1024, 1024)
|
||||
try:
|
||||
with Image.open(base_image) as im:
|
||||
@@ -45,7 +45,8 @@ class MockImageBackend(ImageBackend):
|
||||
img.save(out_path)
|
||||
return out_path
|
||||
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str:
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "",
|
||||
seed: int = None) -> str:
|
||||
"""纯文生图(mock):白底 + 文字标注,模拟纯印花设计稿。"""
|
||||
size_px = (1024, 1024)
|
||||
if size:
|
||||
|
||||
@@ -137,13 +137,14 @@ class OpenAIImageBackend(ImageBackend):
|
||||
return str(self._cfg.get("base_url") or "https://api.openai.com/v1").rstrip("/")
|
||||
|
||||
def print(self, prompt: str, base_image: str, out_path: str, negative: str = "",
|
||||
extra_images=None, size: str = "") -> str:
|
||||
extra_images=None, size: str = "", seed: Optional[int] = None) -> str:
|
||||
"""img2img 编辑:参考图 base_image(+ 可选 extra_images 多参考图)按 prompt 生成新图。
|
||||
|
||||
- 平铺服装图:base_image=平铺衣服底图(图3),extra_images=[印花设计稿(图2)]
|
||||
- 三图模特合成:base_image=模特图(图1),extra_images=[印花设计稿(图2), 平铺底图(图3)]
|
||||
提交顺序即图1→图2→图3,与提示词中的图片角色一一对应。
|
||||
size: 显式尺寸覆盖(如 "1504x2000");留空用配置 size(默认 1024x1024)。
|
||||
size: 显式尺寸覆盖(如 "1536x2048");留空用配置 size(默认 1024x1024)。
|
||||
seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。
|
||||
"""
|
||||
cfg = self._cfg
|
||||
api_key = cfg.get("api_key", "")
|
||||
@@ -172,6 +173,8 @@ class OpenAIImageBackend(ImageBackend):
|
||||
"model": model,
|
||||
"execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定)
|
||||
}
|
||||
if seed is not None:
|
||||
data["seed"] = seed
|
||||
# 提交重试:异步路径不稳定 → 失败重试同步提交(最多 3 次);
|
||||
# 内容政策拦截(content_policy_violation)多为网关误判 → 等待后重试
|
||||
last_err: Optional[str] = None
|
||||
@@ -195,9 +198,11 @@ class OpenAIImageBackend(ImageBackend):
|
||||
print(f"[img] 第 {attempt + 1} 次提交异步失败,重试同步提交: {e}")
|
||||
raise RuntimeError(f"图像合成多次提交均失败: {last_err}")
|
||||
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str:
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "",
|
||||
seed: Optional[int] = None) -> str:
|
||||
"""纯文生图:生成白底纯印花设计稿(standalone pure print design)。
|
||||
size: 显式尺寸覆盖(印花设计统一 1024x1024);留空用配置 size。
|
||||
seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。
|
||||
background: 配置 compose.background="transparent" 时传 background 参数 → 透明背景 PNG
|
||||
(gpt-image-1/2 等模型支持;网关不支持该参数时会被忽略或由网关兜底)。"""
|
||||
cfg = self._cfg
|
||||
@@ -217,6 +222,8 @@ class OpenAIImageBackend(ImageBackend):
|
||||
"response_format": "b64_json",
|
||||
"execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定)
|
||||
}
|
||||
if seed is not None:
|
||||
data["seed"] = seed
|
||||
bg = str(cfg.get("background") or "").strip()
|
||||
if bg:
|
||||
data["background"] = bg # 如 "transparent"(透明背景 PNG)
|
||||
|
||||
@@ -156,16 +156,18 @@ class MockBackend:
|
||||
random.shuffle(pool)
|
||||
terms = pool[:count]
|
||||
# 不足时用「种子词 + 风格词」组合补足(视觉导向,避免与已用重复)
|
||||
style_tail = ["aesthetic", "style", "inspiration", "design", "vibe", "art"]
|
||||
style_tail = ["t-shirt design", "graphic tee", "print art", "vintage tee", "flat design"]
|
||||
i = 0
|
||||
while len(terms) < count and pool:
|
||||
combo = f"{pool[i % len(pool)]} {style_tail[(i // len(pool)) % len(style_tail)]}"
|
||||
if combo.lower() not in used and combo not in terms:
|
||||
terms.append(combo)
|
||||
i += 1
|
||||
# 自动追加 " t-shirt design":让 Pinterest 返回真正的 T 恤印花图(更适合作印花设计参考)
|
||||
terms = [f"{t} t-shirt design" if "t-shirt design" not in t.lower() else t for t in terms]
|
||||
return {"search_terms": terms}
|
||||
|
||||
def analyze_pinterest_images(self, image_paths, term="", country=""):
|
||||
def analyze_pinterest_images(self, image_paths, term="", country="", on_400=None):
|
||||
"""规则生成设计简报(零 API 成本):按搜索词启发式推导风格/配色/构图。"""
|
||||
from ..classify import classify, prompt_suggestion
|
||||
cat = classify(term)
|
||||
@@ -184,6 +186,8 @@ class MockBackend:
|
||||
"color_palette": palette,
|
||||
"composition": composition,
|
||||
"negative_prompt": negative,
|
||||
"image_prompt": (f"{motif}, {art_style}, {palette}, {composition}, "
|
||||
f"original {art_style} t-shirt print design"),
|
||||
# 生图参考:每条简报对应其来源爬取图(mock 按图逐张产出简报,顺序一一对应)
|
||||
"ref_images": [str(paths[i])] if i < len(paths) else [],
|
||||
"source": "pinterest",
|
||||
|
||||
@@ -15,9 +15,7 @@ from typing import Any, Dict, List
|
||||
import requests
|
||||
|
||||
# 模型调用一律直连:用户常开 VPN(系统代理),LLM 网关多为国内/自建,走代理会被拦截或变慢。
|
||||
# 环境变量级 NO_PROXY 双保险(requests/urllib3 均读取),Google 采集(pytrends)不受影响。
|
||||
os.environ.setdefault("NO_PROXY", "*")
|
||||
os.environ.setdefault("no_proxy", "*")
|
||||
# 仅请求级 proxies=NO_PROXY 直连,不设置进程级 NO_PROXY 环境变量(避免影响 Google Trends 等外部采集)。
|
||||
from .base import LLMBackend
|
||||
from graph.paths import runtime_root
|
||||
|
||||
@@ -107,6 +105,7 @@ Rules:
|
||||
# 模板字典按编号存放;TITLE_TEMPLATE_ROUTE 按国家路由到模板编号。
|
||||
# 模板 1:英语市场(US/GB/AU/MX)→ en_title + cn_title
|
||||
# 模板 2:日本市场(JP)→ en_title + cn_title + ja_title
|
||||
# 模板 3:西班牙市场(ES)→ es_title + cn_title
|
||||
TITLE_TEMPLATES: Dict[str, str] = {
|
||||
"1": '''# Role
|
||||
你是一位资深的跨境服装运营专家,精通英语电商的SEO标题逻辑。你的任务是通过分析服装图片,生成高权重的英语-中文商品标题。
|
||||
@@ -151,15 +150,36 @@ TITLE_TEMPLATES: Dict[str, str] = {
|
||||
|
||||
- **Output**: 必须严格返回 JSON 格式,不要包含 Markdown 代码块标记,格式如下:
|
||||
{"en_title": "Title in English", "cn_title": "中文标题", "ja_title": "日本語タイトル"}''',
|
||||
"3": '''# Role
|
||||
你是一位资深的跨境服装运营专家,精通西班牙语电商(Amazon ES, MercadoLibre)的SEO标题逻辑。你的任务是通过分析服装图片,生成高权重的西班牙语-中文商品标题。
|
||||
|
||||
# Task
|
||||
请深度分析图片中的服装特征(品类、风格、材质、剪裁、细节、受众),生成符合西语电商搜索逻辑的中西文标题。
|
||||
|
||||
# 当前时间(标题须贴合当下,季节/年份词以此为准)
|
||||
- **Current time**: {year}-{month}({season}),标题中的年份/季节等时效词必须使用以上时间。
|
||||
|
||||
# Analysis Focus (视觉分析重点)
|
||||
- 品类识别:准确判断西班牙语核心词(如 Vestido, Blusa, Sudadera)和中文核心词(如 连衣裙, 卫衣)。
|
||||
- 风格定位:判断风格流派(如 Boho, Vintage, Minimalista / 法式, 复古, 极简)。
|
||||
- 设计细节:提取领型、袖型、裙长等(如 Escote en V, Manga abullonada / V领, 阔袖)。
|
||||
- 适用场景:推断穿着场景(如 Playa, Oficina, Fiesta / 度假, 通勤, 约会)。
|
||||
|
||||
# Constraints (生成规则)
|
||||
- Spanish Title: 遵循 Amazon ES/MercadoLibre 风格,核心词前置,包含材质、风格、场景等长尾词,符合西语搜索习惯。
|
||||
- Chinese Title: 遵循淘宝/1688风格,关键词权重递减,包含年份/季节+风格+核心词+卖点+人群。
|
||||
- Output: 必须严格返回 JSON 格式,不要包含 Markdown 代码块标记,格式如下:
|
||||
{"es_title": "Título en español", "cn_title": "中文标题"}''',
|
||||
}
|
||||
|
||||
# 国家 → 标题模板编号(JP 路由到模板 2,其余默认模板 1;后续可按国家新增模板 3...)
|
||||
# 国家 → 标题模板编号(JP 路由到模板 2,ES 路由到模板 3,其余默认模板 1;后续可按国家新增模板)
|
||||
TITLE_TEMPLATE_ROUTE: Dict[str, str] = {
|
||||
"US": "1",
|
||||
"GB": "1",
|
||||
"JP": "2",
|
||||
"AU": "1",
|
||||
"MX": "1",
|
||||
"ES": "3",
|
||||
}
|
||||
|
||||
|
||||
@@ -234,11 +254,19 @@ def build_user_prompt(country, topics, aesthetic_hint):
|
||||
|
||||
|
||||
# —— Pinterest 参考模式:搜索词生成(json_schema 结构化 + 动态注入已用词防重复)——
|
||||
PINTEREST_TERM_SYSTEM_PROMPT = """You are a Pinterest search-term generator for print-on-demand (POD) T-shirt design.
|
||||
You turn seed words into diverse, visual, Pinterest-friendly search terms that will be used to scrape inspiration images.
|
||||
PINTEREST_TERM_SYSTEM_PROMPT = """You are a Pinterest search-term generator for print-on-demand (POD) SHORT-SLEEVE T-SHIRT print design.
|
||||
You turn seed words into diverse, visual, Pinterest-friendly search terms that will be used to scrape inspiration images
|
||||
that are DIRECTLY usable as reference for a t-shirt print design.
|
||||
|
||||
RULES:
|
||||
- Generate EXACTLY the requested number of search terms.
|
||||
- Generate EXACTLY the requested number of search terms (usually 1 per call).
|
||||
- Every term MUST be a "t-shirt design" style query: think of it as if the user typed "<concept> t-shirt design" on
|
||||
Pinterest, so the scraped images are actual t-shirt graphics / flat print artworks, NOT lifestyle photos, scenery,
|
||||
architecture, food plates, or anything that cannot become a clean chest print.
|
||||
- Terms MUST be suitable for a SHORT-SLEEVE T-SHIRT PRINT: a flat, graphic, print-ready concept (illustration, mascot,
|
||||
emblem, pattern, typography, slogan) that works as a chest print between about 15x18 cm and 26x32 cm.
|
||||
- Prefer a clear central subject with a strong silhouette and balanced composition that reads well as a standalone print.
|
||||
- AVOID terms that lead to full-scene photos, landscapes, architecture, food plates, or anything that cannot become a clean t-shirt print.
|
||||
- Terms must be VISUAL / AESTHETIC concepts (style, motif, scene, color) suitable as T-shirt print inspiration.
|
||||
- Terms must be DIVERSE and NON-OVERLAPPING: never repeat a concept, never give near-synonyms of each other.
|
||||
- DO NOT repeat or closely paraphrase ANY of the "already used terms" provided in the user message.
|
||||
@@ -257,7 +285,7 @@ PINTEREST_TERM_SCHEMA = {
|
||||
"search_terms": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Diverse, non-overlapping Pinterest search terms for T-shirt design inspiration",
|
||||
"description": "Diverse, non-overlapping Pinterest search terms for short-sleeve t-shirt print design inspiration",
|
||||
}
|
||||
},
|
||||
"required": ["search_terms"],
|
||||
@@ -267,10 +295,10 @@ PINTEREST_TERM_SCHEMA = {
|
||||
|
||||
|
||||
def build_pinterest_term_user_prompt(context: Dict[str, Any]) -> str:
|
||||
"""动态注入:种子词(灵感)+ 已用搜索词(禁止重复)+ 数量要求。"""
|
||||
"""动态注入:种子词(灵感)+ 已用搜索词(禁止重复)+ 数量要求(按需每次 1 个)。"""
|
||||
seeds = context.get("seeds", []) or []
|
||||
used = context.get("used_terms", []) or []
|
||||
count = int(context.get("count", 10))
|
||||
count = int(context.get("count", 1))
|
||||
lines = [
|
||||
f"Country: {context.get('country', '')}",
|
||||
f"Seed words (inspiration, may combine or extend): {', '.join(seeds)}",
|
||||
@@ -278,12 +306,18 @@ def build_pinterest_term_user_prompt(context: Dict[str, Any]) -> str:
|
||||
f"Already used terms — DO NOT repeat or paraphrase ANY of these: "
|
||||
f"{', '.join(used) if used else '(none yet)'}",
|
||||
"",
|
||||
f"Generate {count} new, diverse, non-overlapping Pinterest search terms.",
|
||||
f"Generate {count} new, diverse, non-overlapping Pinterest search term(s) "
|
||||
f"that are suitable for a SHORT-SLEEVE T-SHIRT PRINT design "
|
||||
f"(flat, graphic, print-ready motif that works as a chest print). "
|
||||
f"Each term should read like \"<concept> t-shirt design\" so Pinterest returns "
|
||||
f"actual t-shirt graphics / flat print artwork as reference.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# —— Pinterest 参考模式:图片分析 → 原创设计简报(多模态)——
|
||||
# image_prompt 由 LLM 直接输出完整的英文生图提示词(多模态对图片的描述拼接),
|
||||
# 不再走「四要素 + 固定模板」装配;尺寸/白底等统一约束段由 prompt_node 自动追加。
|
||||
PINTEREST_ANALYZE_SYSTEM_PROMPT = """You are a POD (print-on-demand) T-shirt design analyst.
|
||||
You receive Pinterest reference images for one search term. For each image, extract the VISUAL CONCEPT
|
||||
(style, mood, motif, color palette, composition) that makes it appealing, then produce an ORIGINAL
|
||||
@@ -301,9 +335,14 @@ RULES:
|
||||
- composition: English layout (e.g. "centered emblem with balanced negative space").
|
||||
- concept: Chinese, one sentence describing the design idea.
|
||||
- negative_prompt: what to avoid (real people, likeness, characters, logos, text).
|
||||
- image_prompt: a COMPLETE, fluent English text-to-image prompt for generating the ORIGINAL flat print
|
||||
design artwork (the print itself, NOT a garment photo). Describe the motif, art style, colors, layout
|
||||
and mood in natural English, as a standalone print. Do NOT include garment / shirt / model / mannequin /
|
||||
background-scene / watermark words. Do NOT mention any size or white-background suffix — a fixed
|
||||
"small centered print on pure white" suffix will be appended automatically by the system.
|
||||
|
||||
Return JSON with the field "designs" (array of objects with keys:
|
||||
motif, art_style, color_palette, composition, concept, negative_prompt)."""
|
||||
motif, art_style, color_palette, composition, concept, negative_prompt, image_prompt)."""
|
||||
|
||||
PINTEREST_ANALYZE_SCHEMA = {
|
||||
"name": "pinterest_design_briefs",
|
||||
@@ -315,15 +354,17 @@ PINTEREST_ANALYZE_SCHEMA = {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_index": {"type": "integer"},
|
||||
"motif": {"type": "string"},
|
||||
"art_style": {"type": "string"},
|
||||
"color_palette": {"type": "string"},
|
||||
"composition": {"type": "string"},
|
||||
"concept": {"type": "string"},
|
||||
"negative_prompt": {"type": "string"},
|
||||
"image_prompt": {"type": "string"},
|
||||
},
|
||||
"required": ["motif", "art_style", "color_palette", "composition",
|
||||
"concept", "negative_prompt"],
|
||||
"required": ["image_index", "motif", "art_style", "color_palette",
|
||||
"composition", "concept", "negative_prompt", "image_prompt"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
@@ -341,7 +382,11 @@ def build_pinterest_analyze_user_prompt(term: str, country: str, image_count: in
|
||||
f"Reference images attached: {image_count} images.\n\n"
|
||||
f"Analyze the attached images and produce {image_count} ORIGINAL design briefs "
|
||||
f"(one per image), each capturing the visual vibe as an original T-shirt print design. "
|
||||
f"Do NOT copy the images."
|
||||
f"Do NOT copy the images.\n"
|
||||
f"For EACH brief you MUST set image_index to the 0-based position of the input image "
|
||||
f"it was derived from (first image = 0, second = 1, ...). Every image_index from 0 to "
|
||||
f"{max(image_count - 1, 0)} must appear exactly once — this links each brief to its "
|
||||
f"source image so the design is generated from the SAME image that was analyzed."
|
||||
)
|
||||
|
||||
|
||||
@@ -419,9 +464,31 @@ def _extract_json(text):
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r"\{.*\}", text, re.S)
|
||||
if m:
|
||||
return json.loads(m.group(0))
|
||||
# 找第一个 { 到与之平衡的 },逐字符跳过字符串内的花括号,避免贪婪匹配截断 JSON
|
||||
start = text.find("{")
|
||||
if start == -1:
|
||||
raise
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif ch == "\\":
|
||||
esc = True
|
||||
elif ch == '"':
|
||||
in_str = False
|
||||
else:
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return json.loads(text[start:i + 1])
|
||||
raise
|
||||
|
||||
|
||||
@@ -512,13 +579,17 @@ class OpenAICompatBackend(LLMBackend):
|
||||
raw = _retry(lambda: call_openai_compatible_structured(cfg, messages, PINTEREST_TERM_SCHEMA, timeout=120))
|
||||
parsed = _extract_json(raw)
|
||||
terms = [str(x).strip() for x in (parsed.get("search_terms", []) or []) if str(x).strip()]
|
||||
# 自动追加 " t-shirt design":让 Pinterest 返回真正的 T 恤印花图(更适合作印花设计参考)
|
||||
terms = [f"{t} t-shirt design" if "t-shirt design" not in t.lower() else t for t in terms]
|
||||
return {"search_terms": terms}
|
||||
|
||||
def analyze_pinterest_images(self, image_paths: List[str], term: str, country: str = "") -> List[Dict[str, Any]]:
|
||||
def analyze_pinterest_images(self, image_paths: List[str], term: str, country: str = "",
|
||||
on_400=None) -> List[Dict[str, Any]]:
|
||||
"""多模态分析 Pinterest 图片 → 原创设计简报列表。
|
||||
|
||||
图片输入不被模型支持(纯文本模型 400)时自动降级为纯文本分析(仅用搜索词)。
|
||||
失败返回 [],由节点兜底(回退 mock 规则简报)。
|
||||
on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。
|
||||
"""
|
||||
cfg = self._cfg
|
||||
api_key = cfg.get("api_key", "")
|
||||
@@ -542,6 +613,16 @@ class OpenAICompatBackend(LLMBackend):
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] 图片读取失败 {p}: {e}")
|
||||
|
||||
def _notify_400(exc) -> None:
|
||||
if on_400 is None:
|
||||
return
|
||||
try:
|
||||
from graph.pinterest import is_400_content_image
|
||||
if is_400_content_image(exc):
|
||||
on_400()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _call(use_images: bool) -> str:
|
||||
user_content: List[Any] = [
|
||||
{"type": "text", "text": build_pinterest_analyze_user_prompt(term, country, len(data_uris))},
|
||||
@@ -568,7 +649,8 @@ class OpenAICompatBackend(LLMBackend):
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=180, proxies=NO_PROXY)
|
||||
resp.raise_for_status()
|
||||
return str(resp.json()["choices"][0]["message"].get("content") or "")
|
||||
except Exception: # noqa: BLE001 兼容厂商不支持 json_schema
|
||||
except Exception as e: # noqa: BLE001 兼容厂商不支持 json_schema
|
||||
_notify_400(e)
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=180, proxies=NO_PROXY)
|
||||
resp.raise_for_status()
|
||||
@@ -611,21 +693,20 @@ class OpenAICompatBackend(LLMBackend):
|
||||
"color_palette": str(d.get("color_palette", "")).strip(),
|
||||
"composition": str(d.get("composition", "")).strip(),
|
||||
"negative_prompt": str(d.get("negative_prompt", "")).strip(),
|
||||
"image_prompt": str(d.get("image_prompt", "")).strip(),
|
||||
# 生图参考:每条简报对应其来源爬取图(LLM 按图逐张产出简报,顺序一一对应)
|
||||
"ref_images": [str(image_paths[i])] if i < len(image_paths) else [],
|
||||
"source": "pinterest",
|
||||
})
|
||||
return designs
|
||||
|
||||
def generate_title(self, image_path: str, system_prompt: str = "", country: str = "",
|
||||
fallback_text: str = "") -> Dict[str, Any]:
|
||||
"""多模态标题生成;图片输入不被模型支持(如 qwen 纯文本模型 400)时,
|
||||
自动降级为纯文本生成(fallback_text 为商品描述/热点主题)。"""
|
||||
def generate_title(self, image_path: str, system_prompt: str = "", country: str = "") -> Dict[str, Any]:
|
||||
"""多模态:分析服装图片,生成商品标题(按国家路由模板)。
|
||||
|
||||
系统提示词:显式传入优先;否则按 country 经 TITLE_TEMPLATE_ROUTE 路由到对应模板。
|
||||
模板 1(US/GB/AU/MX)返回 {"en_title","cn_title"};
|
||||
模板 2(JP)额外返回 {"ja_title"}。
|
||||
模板 2(JP)额外返回 {"ja_title"};
|
||||
模板 3(ES)返回 {"es_title","cn_title"}。
|
||||
无 key/调用失败返回 {}(调用方兜底不中断)。
|
||||
"""
|
||||
cfg = self._cfg
|
||||
@@ -678,6 +759,7 @@ class OpenAICompatBackend(LLMBackend):
|
||||
"en_title": str(parsed.get("en_title", "")).strip(),
|
||||
"cn_title": str(parsed.get("cn_title", "")).strip(),
|
||||
"ja_title": str(parsed.get("ja_title", "")).strip(),
|
||||
"es_title": str(parsed.get("es_title", "")).strip(),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[titles] 标题生成失败: {e}")
|
||||
|
||||
+110
-57
@@ -10,13 +10,25 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"}
|
||||
|
||||
|
||||
def _notify_400(on_400, exc) -> None:
|
||||
"""HTTP 400(且含「内容/图片」)时触发 on_400 回调(供调用方累计放弃计数)。"""
|
||||
if on_400 is None:
|
||||
return
|
||||
try:
|
||||
from graph.pinterest import is_400_content_image
|
||||
if is_400_content_image(exc):
|
||||
on_400()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _build_briefs_md(briefs: List[Dict[str, Any]], generated_at: str) -> str:
|
||||
lines = [
|
||||
"# POD 印花设计简报(LLM 合规筛选 + 生图提示词)",
|
||||
@@ -96,6 +108,88 @@ def _build_report_md(state: Dict[str, Any]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
|
||||
errors: List[Dict[str, Any]] = None,
|
||||
seed: Optional[int] = None,
|
||||
on_400=None,
|
||||
size: str = "1024x1024") -> Optional[str]:
|
||||
"""生成单张纯印花设计稿(图2)。返回设计稿路径;失败 / 全局 MD5 重复返回 None。
|
||||
|
||||
out_stem: 输出文件名主干(不含扩展名),最终文件 = {out_stem}_design.png。
|
||||
货号模式传 img_code(如 DG000)→ designs/DG000_design.png;
|
||||
旧 compose 模式传 {country}_{idx:02d}(如 JP_01)→ designs/JP_01_design.png。
|
||||
Pinterest 参考模式:简报带 ref_images(爬取图)→ 用 ib.print() 图生图,
|
||||
把爬取图 + 多模态分析简报(已封装进 image_prompt)一起发给生图模型;
|
||||
无参考图或图生图失败 → 回退 ib.generate() 纯文生图。
|
||||
seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。
|
||||
on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。
|
||||
"""
|
||||
try:
|
||||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||||
img_prompt = sanitize_image_prompt(brief.get("image_prompt", ""))
|
||||
img_prompt = ensure_rebrand_hint(brief, img_prompt) # review → 原创化魔改引导
|
||||
out_path = str(design_dir / f"{out_stem}_design.png")
|
||||
ref_images = [str(p) for p in (brief.get("ref_images") or []) if str(p)]
|
||||
if ref_images and hasattr(ib, "print"):
|
||||
try:
|
||||
# 图生图:以爬取图为参考,按分析简报生成原创设计(不复制原图)
|
||||
ref_prompt = img_prompt + (
|
||||
" Create an ORIGINAL, non-copying flat print design inspired ONLY by "
|
||||
"the reference image's style and mood. Do NOT reproduce the reference "
|
||||
"image, its characters, logos, or any text.")
|
||||
out_path = ib.print(
|
||||
ref_prompt, ref_images[0], out_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=ref_images[1:] or None,
|
||||
size=size, seed=seed) # 设计稿尺寸按 config compose.design_size
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[compose] 图生图(参考图)失败,回退文生图 {brief.get('topic','')}: {e}")
|
||||
_notify_400(on_400, e)
|
||||
out_path = ib.generate(
|
||||
img_prompt, str(design_dir / f"{out_stem}_design.png"),
|
||||
brief.get("composite_negative", ""), size=size, seed=seed)
|
||||
else:
|
||||
out_path = ib.generate(
|
||||
img_prompt, str(design_dir / f"{out_stem}_design.png"),
|
||||
brief.get("composite_negative", ""), size=size, seed=seed)
|
||||
# 全局 MD5 去重:生成了设计后,把 MD5 加入全局过滤(对所有国家生效);
|
||||
# 已存在的重复设计 → 跳过(不用于产品),避免跨国家重复使用同一设计
|
||||
from graph.pinterest import design_md5_ok
|
||||
if not design_md5_ok(out_path):
|
||||
print(f"[compose] 设计稿 MD5 全局重复,跳过(不用于产品): {out_path}")
|
||||
return None
|
||||
return out_path
|
||||
except Exception as e: # noqa: BLE001
|
||||
_notify_400(on_400, e)
|
||||
if errors is not None:
|
||||
errors.append({"node": "compose", "type": type(e).__name__,
|
||||
"message": f"设计稿生成失败 {brief.get('topic','')}: {e}", "trace": ""})
|
||||
print(f"[compose] 设计稿生成失败 {brief.get('topic', '')}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def write_compose_reports(state: Dict[str, Any], briefs: List[Dict[str, Any]]) -> None:
|
||||
"""写 compose 阶段简报报告(design_briefs / composite_prompts / report.md)。
|
||||
|
||||
Pinterest 并发生成模式下 compose_node 不再整体执行,由收尾节点调用本函数补写报告。
|
||||
"""
|
||||
output_dir = Path(state["output_dir"])
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_dir = Path(state.get("cache_dir") or output_dir)
|
||||
generated_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
(cache_dir / "design_briefs.json").write_text(
|
||||
json.dumps({"generated_at": generated_at, "total": len(briefs), "design_briefs": briefs},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
(cache_dir / "design_briefs.md").write_text(
|
||||
_build_briefs_md(briefs, generated_at), encoding="utf-8")
|
||||
(cache_dir / "composite_prompts.json").write_text(
|
||||
json.dumps({"generated_at": generated_at, "total": len(briefs), "composite_prompts": briefs},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
(cache_dir / "composite_prompts.md").write_text(
|
||||
_build_composite_md(briefs), encoding="utf-8")
|
||||
(output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8")
|
||||
|
||||
|
||||
@with_fallback("compose")
|
||||
def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
briefs: List[Dict[str, Any]] = state.get("briefs") or []
|
||||
@@ -105,26 +199,8 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
config = state["config"]
|
||||
country = state.get("country", "")
|
||||
|
||||
generated_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
# 1) design_briefs.json(缓存 → 根目录,不进时间戳任务文件夹)
|
||||
(cache_dir / "design_briefs.json").write_text(
|
||||
json.dumps({"generated_at": generated_at, "total": len(briefs), "design_briefs": briefs},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
# 2) design_briefs.md
|
||||
(cache_dir / "design_briefs.md").write_text(
|
||||
_build_briefs_md(briefs, generated_at), encoding="utf-8")
|
||||
|
||||
# 3) composite_prompts.json / .md
|
||||
(cache_dir / "composite_prompts.json").write_text(
|
||||
json.dumps({"generated_at": generated_at, "total": len(briefs), "composite_prompts": briefs},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
(cache_dir / "composite_prompts.md").write_text(
|
||||
_build_composite_md(briefs), encoding="utf-8")
|
||||
|
||||
# 4) report.md(本次任务报告 → 产物目录)
|
||||
(output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8")
|
||||
# 1-4) 简报报告(design_briefs / composite_prompts / report.md)
|
||||
write_compose_reports(state, briefs)
|
||||
|
||||
# 5) 生成纯印花设计稿(图2):前 N 个 safe 简报用 image_prompt 文生图
|
||||
designs: List[Dict[str, Any]] = []
|
||||
@@ -153,45 +229,20 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
design_dir = output_dir / "designs"
|
||||
design_dir.mkdir(exist_ok=True)
|
||||
|
||||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _gen_one(i: int, b: Dict[str, Any]):
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。
|
||||
# 随机种子:config compose.seed >0 时固定(可复现,网关支持才生效);0/留空=每次随机
|
||||
_seed = int(compose_cfg.get("seed") or 0)
|
||||
_seed = _seed if _seed > 0 else None
|
||||
|
||||
Pinterest 参考模式:简报带 ref_images(爬取图)→ 用 ib.print() 图生图,
|
||||
把爬取图 + 多模态分析简报(已封装进 image_prompt)一起发给生图模型;
|
||||
无参考图或图生图失败 → 回退 ib.generate() 纯文生图。
|
||||
"""
|
||||
try:
|
||||
img_prompt = sanitize_image_prompt(b.get("image_prompt", ""))
|
||||
img_prompt = ensure_rebrand_hint(b, img_prompt) # review → 原创化魔改引导
|
||||
out_path = str(design_dir / f"{country}_{i:02d}_design.png")
|
||||
ref_images = [str(p) for p in (b.get("ref_images") or []) if str(p)]
|
||||
if ref_images and hasattr(ib, "print"):
|
||||
try:
|
||||
# 图生图:以爬取图为参考,按分析简报生成原创设计(不复制原图)
|
||||
ref_prompt = img_prompt + (
|
||||
" Create an ORIGINAL, non-copying flat print design inspired ONLY by "
|
||||
"the reference image's style and mood. Do NOT reproduce the reference "
|
||||
"image, its characters, logos, or any text.")
|
||||
out_path = ib.print(
|
||||
ref_prompt, ref_images[0], out_path,
|
||||
b.get("composite_negative", ""),
|
||||
extra_images=ref_images[1:] or None,
|
||||
size="1024x1024") # 印花设计统一 1024x1024
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[compose] 图生图(参考图)失败,回退文生图 {b.get('topic','')}: {e}")
|
||||
out_path = ib.generate(
|
||||
img_prompt, str(design_dir / f"{country}_{i:02d}_design.png"),
|
||||
b.get("composite_negative", ""), size="1024x1024")
|
||||
else:
|
||||
out_path = ib.generate(
|
||||
img_prompt, str(design_dir / f"{country}_{i:02d}_design.png"),
|
||||
b.get("composite_negative", ""), size="1024x1024")
|
||||
return i, b, out_path, None
|
||||
except Exception as e: # noqa: BLE001
|
||||
return i, b, None, e
|
||||
def _gen_one(i: int, b: Dict[str, Any]):
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
|
||||
out_path = generate_design(ib, b, design_dir, f"{country}_{i:02d}",
|
||||
state.get("errors"), seed=_seed,
|
||||
size=compose_cfg.get("design_size", "1024x1024"))
|
||||
if out_path is None:
|
||||
return i, b, None, None
|
||||
return i, b, out_path, None
|
||||
|
||||
targets = [(i, b) for i, b in enumerate(safe_briefs[:design_count], 1)]
|
||||
# 并发生成:每张设计一个线程(并行调图像网关),数量多时不串行等待
|
||||
@@ -206,6 +257,8 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
state.setdefault("errors", []).append({
|
||||
"node": "compose", "type": type(err).__name__,
|
||||
"message": f"设计稿生成失败 {b.get('topic','')}: {err}", "trace": ""})
|
||||
elif out_path is None:
|
||||
print(f"[compose] 设计稿跳过(MD5 全局去重): {b.get('topic', '')}")
|
||||
else:
|
||||
b["design_path"] = out_path
|
||||
designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path})
|
||||
|
||||
@@ -1,28 +1,71 @@
|
||||
"""Pinterest 参考模式节点 3/3:LLM 多模态分析图片 → 原创设计简报(pinterest_analyze)。
|
||||
"""Pinterest 参考模式节点 3/3:从图池取图 → 多并发 LLM 分析 → 原创设计简报(pinterest_analyze)。
|
||||
|
||||
对 pinterest_scrape 爬到的每个搜索词图片,调 LLM 多模态分析(analyze_pinterest_images)
|
||||
提取视觉概念(风格/情绪/主体/配色/构图)→ 生成原创设计简报
|
||||
(motif/art_style/color_palette/composition/concept/negative_prompt),
|
||||
再经 prompt_node 装配最终 image/wearable/composite 提示词,产出标准 briefs 供 compose 用。
|
||||
图池机制:
|
||||
- 从持久化图池(image_pool.json)取「未消费」图片(md5 不在 used_images.json)。
|
||||
- 大图先压缩(内存占用过大 → 缩放/重编码)再送 LLM。
|
||||
- 多并发分析(每批 analyze_per_term 张,并发 analyze_concurrency 线程)。
|
||||
- 每张被分析的图片 md5 一律拉黑(used_images.json)——合适→产出简报→生成设计(设计 md5 全局拉黑见 compose);
|
||||
不合适→图片 md5 已拉黑→下一轮自动取下一张,不重复分析。
|
||||
- 图池无未消费图片时返回空,由路由触发新一轮搜索。
|
||||
|
||||
兜底链:LLM 多模态 → 纯文本降级(后端内部)→ mock 规则简报 → 空列表(下游跳过)。
|
||||
带 with_fallback:任何异常都不中断。
|
||||
"""
|
||||
import concurrent.futures
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.llms import get_backend
|
||||
from graph.nodes.prompt_node import prompt_node
|
||||
from graph.pinterest import (
|
||||
compress_image,
|
||||
load_image_pool,
|
||||
load_used_images,
|
||||
pool_unused_images,
|
||||
save_used_images,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
# 明显不适合 T 恤印花的简报主体(启发式过滤;真实判定交给 LLM 搜索词引导)
|
||||
_BRIEF_UNSUITABLE = re.compile(
|
||||
r"\b(landscape|panorama|scenery|cityscape|street scene|interior|room decor|"
|
||||
r"food photography|meal|dinner plate|recipe|makeup|nails|manicure|"
|
||||
r"weather forecast|map|directions|photorealistic scene|realistic portrait)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[str, Any]]:
|
||||
|
||||
def _brief_suitable(b: Dict[str, Any]) -> bool:
|
||||
"""简报是否适合做 T 恤印花:非侵权(blocked 拦截)+ 有主体 + 非明显非印花概念。"""
|
||||
if str(b.get("risk_level") or "").strip().lower() == "blocked":
|
||||
return False
|
||||
motif = str(b.get("motif") or "").strip()
|
||||
if not motif:
|
||||
return False
|
||||
if _BRIEF_UNSUITABLE.search(motif):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str,
|
||||
existing_topics: List[str] = None) -> List[Dict[str, Any]]:
|
||||
"""富化原始简报 → screened 格式(唯一 topic / safe / 分类 / 分数),供 prompt_node 装配。
|
||||
|
||||
同一搜索词的多张图会产出多条简报,topic 相同 → 追加序号保证唯一
|
||||
(product_node 按 topic 绑定简报,重复 topic 会互相覆盖)。
|
||||
existing_topics: 已累计简报的 topic 列表;用它初始化计数实现跨轮次去重——
|
||||
直接搜固定词时每轮 LLM 都返回相同 topic,若每轮从 #1 重新计数,
|
||||
30 个产品会因 topic 重复只用到前几个唯一设计(其余全复制)。
|
||||
"""
|
||||
from graph.classify import classify
|
||||
import re as _re
|
||||
seen_topics: Dict[str, int] = {}
|
||||
# 已累计简报按「基础词」计数(去掉 #N 后缀),保证跨轮次序号连续递增
|
||||
for t in existing_topics or []:
|
||||
key = _re.sub(r"\s+#\d+$", "", str(t).strip().lower())
|
||||
if key:
|
||||
seen_topics[key] = seen_topics.get(key, 0) + 1
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(raw_briefs):
|
||||
if not isinstance(b, dict):
|
||||
@@ -38,9 +81,9 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[
|
||||
out.append({
|
||||
"country": country,
|
||||
"topic": topic,
|
||||
"risk_level": "safe",
|
||||
"safe_for_print": True,
|
||||
"suitable_for_print": True,
|
||||
"risk_level": str(b.get("risk_level") or "safe").strip().lower() or "safe",
|
||||
"safe_for_print": bool(b.get("safe_for_print", True)),
|
||||
"suitable_for_print": bool(b.get("suitable_for_print", True)),
|
||||
"design_category": classify(term),
|
||||
"concept": str(b.get("concept") or "").strip() or f"围绕「{term}」的原创印花设计",
|
||||
"motif": motif,
|
||||
@@ -48,7 +91,9 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[
|
||||
"color_palette": str(b.get("color_palette") or "").strip(),
|
||||
"composition": str(b.get("composition") or "").strip(),
|
||||
"negative_prompt": str(b.get("negative_prompt") or "").strip(),
|
||||
"image_prompt": str(b.get("image_prompt") or "").strip(),
|
||||
"ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)],
|
||||
"source_md5": str(b.get("source_md5") or "").strip().lower(),
|
||||
"slogan": "",
|
||||
"score": 1.0,
|
||||
"confidence": 1.0,
|
||||
@@ -59,21 +104,89 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[
|
||||
|
||||
@with_fallback("pinterest_analyze")
|
||||
def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
images: Dict[str, List[str]] = state.get("pinterest_images") or {}
|
||||
if not images:
|
||||
print("[pinterest_analyze] 无爬取图片,跳过分析")
|
||||
return {"pinterest_briefs": [], "briefs": [], "errors": state.get("errors") or []}
|
||||
|
||||
country = state["country"]
|
||||
config = state["config"]
|
||||
output_dir = state["output_dir"]
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
pcfg = config.get("pinterest") or {}
|
||||
analyze_per_term = int(pcfg.get("analyze_per_term", 6))
|
||||
analyze_per_term = int(pcfg.get("analyze_per_term", 1))
|
||||
concurrency = int(pcfg.get("analyze_concurrency", 3))
|
||||
max_designs = int(pcfg.get("max_designs", 10))
|
||||
n_ref = max(1, int(pcfg.get("ref_images_per_design", 1)))
|
||||
provider = str(pcfg.get("provider") or "openai").strip().lower()
|
||||
|
||||
# 1) LLM 后端(openai → 真多模态;mock → 规则兜底)
|
||||
# 按需分析:只取补齐到目标所需的图片数(batch_size 为上限,不超额分析),并按 md5 去重,
|
||||
# 保证同一图片内容(md5)不会同时被多条简报使用
|
||||
target = int(state.get("pinterest_target") or 0)
|
||||
existing = state.get("briefs") or []
|
||||
remaining = max(0, target - len(existing))
|
||||
batch_size = int(pcfg.get("analyze_batch", 0))
|
||||
if batch_size <= 0:
|
||||
# 自动:一次分析补齐到「目标所需」或「每词简报上限」的较小值(每张图→1条简报),
|
||||
# 让 pipeline 队列一次有足够任务,时刻保持并发生成(避免每轮只推 6 条导致线程空转)
|
||||
batch_size = min(remaining, max_designs)
|
||||
need = min(batch_size, remaining) if remaining > 0 else 0
|
||||
|
||||
# 1) 图池取未消费图片(md5 不在 used_images);无 → 返回空,路由触发搜索
|
||||
pool = load_image_pool(output_dir, country)
|
||||
used = load_used_images(output_dir, country)
|
||||
unused = pool_unused_images(pool, used)
|
||||
if not unused:
|
||||
print("[pinterest_analyze] 图池无未消费图片,跳过分析(路由将触发新一轮搜索)")
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
|
||||
if need <= 0:
|
||||
print("[pinterest_analyze] 简报已达标,无需分析")
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
seen_md5: set = set()
|
||||
batch: List[Dict[str, Any]] = []
|
||||
for img in unused:
|
||||
m = str(img.get("md5") or "").strip().lower()
|
||||
if m and m in seen_md5:
|
||||
continue # 同一图片内容(md5)不重复分析
|
||||
seen_md5.add(m)
|
||||
batch.append(img)
|
||||
if len(batch) >= need:
|
||||
break
|
||||
print(f"[pinterest_analyze] 图池取 {len(batch)} 张未消费图片分析(按需 {need},"
|
||||
f"池剩余未消费 {len(unused) - len(batch)} 张,已消费 {len(used)} 张)")
|
||||
|
||||
def _assign_refs(res: List[Dict[str, Any]], chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""按简报的 image_index(LLM 返回)匹配它实际分析的图,写入 source_md5 + ref_images。
|
||||
|
||||
全局 id 校验:简报必须带 image_index(对应输入第几张图,0-based);
|
||||
无 image_index(mock 兜底)→ 回退按顺序;无效/越界/重复 → 丢弃该简报(避免错位)。
|
||||
这样 analyze_per_term 可 >1 一次分析多张图提速,简报仍严格对应各自的图。
|
||||
"""
|
||||
chunk_paths = [img["path"] for img in chunk]
|
||||
chunk_md5s = [str(img.get("md5") or "").strip().lower() for img in chunk]
|
||||
used_idx: set = set()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(res):
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
try:
|
||||
idx = int(b.get("image_index"))
|
||||
except (TypeError, ValueError):
|
||||
idx = i # 无 image_index → 回退按顺序
|
||||
if idx < 0 or idx >= len(chunk_paths) or idx in used_idx:
|
||||
print(f"[pinterest_analyze] 简报 image_index={idx} 无效/重复,丢弃(避免图-简报错位)")
|
||||
continue
|
||||
used_idx.add(idx)
|
||||
refs: List[str] = []
|
||||
for k in range(n_ref):
|
||||
src = chunk_paths[(idx + k) % len(chunk_paths)]
|
||||
if src not in refs:
|
||||
refs.append(src)
|
||||
b["ref_images"] = refs
|
||||
b["source_md5"] = chunk_md5s[idx] if idx < len(chunk_md5s) else ""
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
# 2) LLM 后端(openai → 真多模态;mock → 规则兜底)
|
||||
llm = None
|
||||
if provider != "static":
|
||||
try:
|
||||
@@ -87,64 +200,123 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
print(f"[pinterest_analyze] LLM 初始化失败: {e}")
|
||||
llm = None
|
||||
|
||||
# 2) 逐搜索词分析图片 → 原始设计简报
|
||||
# 3) 大图先压缩(内存占用过大 → 缩放/重编码),再按批分组
|
||||
compressed_map: Dict[str, str] = {}
|
||||
for img in batch:
|
||||
compressed_map[img["path"]] = compress_image(img["path"])
|
||||
chunks: List[List[Dict[str, Any]]] = [
|
||||
batch[i:i + analyze_per_term] for i in range(0, len(batch), analyze_per_term)
|
||||
]
|
||||
|
||||
# 4) 多并发分析(每线程分析一个 chunk;LLM 后端只读 self._cfg,线程安全)
|
||||
raw_briefs: List[Dict[str, Any]] = []
|
||||
if llm is not None and hasattr(llm, "analyze_pinterest_images"):
|
||||
for term, paths in images.items():
|
||||
sample = list(paths)[:analyze_per_term]
|
||||
if not sample:
|
||||
continue
|
||||
|
||||
def _analyze_chunk(chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
term = str(chunk[0].get("term") or "")
|
||||
paths = [compressed_map.get(img["path"], img["path"]) for img in chunk]
|
||||
if llm is not None and hasattr(llm, "analyze_pinterest_images"):
|
||||
try:
|
||||
res = llm.analyze_pinterest_images(sample, term, country)
|
||||
res = res or []
|
||||
raw_briefs.extend(res)
|
||||
print(f"[pinterest_analyze] 「{term}」分析 {len(sample)} 张图 → {len(res)} 条简报")
|
||||
def _on_400():
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
if pipe is not None and hasattr(pipe, "record_400"):
|
||||
if pipe.record_400():
|
||||
pipe._abort_current_term()
|
||||
res = llm.analyze_pinterest_images(paths, term, country, on_400=_on_400) or []
|
||||
# 把每条简报的来源图路径回填为原始图(压缩图仅用于分析,参考图用原图)
|
||||
return _assign_refs(res, chunk)
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "pinterest_analyze", "type": type(e).__name__,
|
||||
"message": f"term[{term}]: {e}", "trace": ""})
|
||||
"message": f"term[{term}] batch@{len(chunk)}: {e}", "trace": ""})
|
||||
print(f"[pinterest_analyze] 「{term}」分析失败: {e}")
|
||||
return []
|
||||
return []
|
||||
|
||||
# 3) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成)。
|
||||
# 注意必须切到 mock 后端,不能再调回失败的 llm(否则同样报错)。
|
||||
workers = max(1, min(concurrency, len(chunks)))
|
||||
if len(chunks) > 1:
|
||||
print(f"[pinterest_analyze] 并发分析 {len(chunks)} 批({workers} 线程,每批 {analyze_per_term} 张)…")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as _ex:
|
||||
_futs = [_ex.submit(_analyze_chunk, c) for c in chunks]
|
||||
for _f in concurrent.futures.as_completed(_futs):
|
||||
raw_briefs.extend(_f.result())
|
||||
|
||||
# 5) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成)
|
||||
if not raw_briefs:
|
||||
try:
|
||||
mock = get_backend("mock")
|
||||
for term, paths in images.items():
|
||||
sample = list(paths)[:analyze_per_term]
|
||||
if sample:
|
||||
raw_briefs.extend(mock.analyze_pinterest_images(sample, term, country) or [])
|
||||
for chunk in chunks:
|
||||
term = str(chunk[0].get("term") or "")
|
||||
paths = [compressed_map.get(img["path"], img["path"]) for img in chunk]
|
||||
res = mock.analyze_pinterest_images(paths, term, country) or []
|
||||
_assign_refs(res, chunk)
|
||||
raw_briefs.extend(res)
|
||||
print(f"[pinterest_analyze] 兜底:mock 规则简报 {len(raw_briefs)} 条")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] mock 兜底失败: {e}")
|
||||
|
||||
# 4) 上限 + 去重(同 motif+style 指纹只留一条)
|
||||
raw_briefs = raw_briefs[:max_designs]
|
||||
# 6) 本批所有图片 md5 一律拉黑(已消费,不再复用)——合适/不合适都拉黑
|
||||
for img in batch:
|
||||
if img.get("md5"):
|
||||
used.add(str(img["md5"]).lower())
|
||||
save_used_images(output_dir, country, used)
|
||||
|
||||
# 7) 简报过滤:只留适合印花的(有主体 + 非明显非印花概念)
|
||||
kept: List[Dict[str, Any]] = []
|
||||
for b in raw_briefs:
|
||||
if isinstance(b, dict) and _brief_suitable(b):
|
||||
kept.append(b)
|
||||
if len(kept) < len(raw_briefs):
|
||||
print(f"[pinterest_analyze] 简报过滤:{len(raw_briefs)} → {len(kept)} 条适合印花")
|
||||
|
||||
# 8) 上限 + 去重(同 motif+style 指纹只留一条)
|
||||
kept = kept[:max_designs]
|
||||
seen: set = set()
|
||||
uniq: List[Dict[str, Any]] = []
|
||||
for b in raw_briefs:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
for b in kept:
|
||||
fp = f"{str(b.get('motif', '')).strip().lower()}|{str(b.get('art_style', '')).strip().lower()}"
|
||||
if fp in seen:
|
||||
continue
|
||||
seen.add(fp)
|
||||
uniq.append(b)
|
||||
raw_briefs = uniq
|
||||
kept = uniq
|
||||
|
||||
# 5) 富化 → screened → prompt_node 装配提示词 → 标准 briefs
|
||||
screened = _enrich_briefs(raw_briefs, country)
|
||||
if not screened:
|
||||
print("[pinterest_analyze] 无有效设计简报,跳过")
|
||||
return {"pinterest_briefs": [], "briefs": [], "errors": errors}
|
||||
# 9) 富化 → screened → prompt_node 装配提示词 → 标准 briefs(追加到累计,按需截断到目标数)
|
||||
existing_topics = [str(b.get("topic", "")).strip() for b in (state.get("briefs") or [])]
|
||||
screened = _enrich_briefs(kept, country, existing_topics)
|
||||
new_briefs: List[Dict[str, Any]] = []
|
||||
if screened:
|
||||
r = prompt_node({**state, "screened": screened})
|
||||
new_briefs = r.get("briefs") or []
|
||||
|
||||
r = prompt_node({**state, "screened": screened})
|
||||
briefs = r.get("briefs") or []
|
||||
old_count = len(state.get("briefs") or [])
|
||||
accumulated = list(state.get("briefs") or [])
|
||||
accumulated.extend(new_briefs)
|
||||
target = int(state.get("pinterest_target") or 0)
|
||||
if target > 0 and len(accumulated) > target:
|
||||
accumulated = accumulated[:target]
|
||||
print(f"[pinterest_analyze] 简报已达目标 {target} 条,截断多余部分")
|
||||
|
||||
# 推送实际新增且保留的简报到并发生成流水线(简报池):边分析边生成设计/三合一/种草图
|
||||
pushed = accumulated[old_count:]
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
if pipe is not None and pushed:
|
||||
if getattr(pipe, "is_400_aborted", lambda: False)():
|
||||
print("[pinterest_analyze] 当前种子词 400 超限已放弃,本轮简报不推送")
|
||||
pushed = []
|
||||
else:
|
||||
try:
|
||||
pipe.add_briefs(pushed)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] 简报入池失败: {e}")
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_analyze"] = {
|
||||
"provider": provider,
|
||||
"images_analyzed": sum(len(v) for v in images.values()),
|
||||
"briefs": len(briefs),
|
||||
"images_analyzed": len(batch),
|
||||
"pool_unused": len(unused),
|
||||
"used_images": len(used),
|
||||
"briefs": len(new_briefs),
|
||||
"accumulated": len(accumulated),
|
||||
}
|
||||
print(f"[pinterest_analyze] 设计简报 {len(briefs)} 条({country})")
|
||||
return {"pinterest_briefs": raw_briefs, "briefs": briefs, "stats": stats, "errors": errors}
|
||||
print(f"[pinterest_analyze] 本轮分析 {len(batch)} 张图 → 简报 {len(new_briefs)} 条,"
|
||||
f"累计 {len(accumulated)} 条({country})")
|
||||
return {"pinterest_briefs": kept, "briefs": accumulated, "stats": stats, "errors": errors}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Pinterest 并发生成流水线节点 3/3:收尾(pinterest_finalize)。
|
||||
|
||||
分析循环结束后:排空简报池、等待后台全部产品完成(设计→三合一→OSS→种草图),
|
||||
合并产品到 state,补写 compose 简报报告与 products.json,再交给 template_export。
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("pinterest_finalize")
|
||||
def pinterest_finalize_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
products: list = []
|
||||
perr: list = []
|
||||
if pipe is not None:
|
||||
products, perr = pipe.finish()
|
||||
|
||||
# 合并后台产出的产品(与已存在的合并,避免覆盖)
|
||||
state_products = list(state.get("product") or [])
|
||||
state_products.extend(products)
|
||||
|
||||
# 补写 compose 简报报告(design_briefs / composite_prompts / report.md)
|
||||
try:
|
||||
from graph.nodes.compose_node import write_compose_reports
|
||||
write_compose_reports(state, state.get("briefs") or [])
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_finalize] 简报报告写入失败: {e}")
|
||||
|
||||
# 写 products.json(product 节点原职责)
|
||||
try:
|
||||
from graph.nodes.product_node import _write_products
|
||||
prod_dir = Path(state["output_dir"]) / "product"
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
_write_products(prod_dir, state_products)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_finalize] products.json 写入失败: {e}")
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_pipeline"] = {"products": len(products), "errors": len(perr)}
|
||||
errors = list(state.get("errors") or []) + perr
|
||||
oss_seq = getattr(pipe, "oss_seq", state.get("oss_seq", 0)) if pipe is not None \
|
||||
else state.get("oss_seq", 0)
|
||||
print(f"[pinterest_finalize] 收尾完成:合并 {len(state_products)} 个产品,"
|
||||
f"后台错误 {len(perr)},oss_seq={oss_seq}")
|
||||
return {"product": state_products, "oss_seq": oss_seq, "errors": errors, "stats": stats}
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Pinterest 并发生成流水线节点 0/3:初始化简报池(pinterest_init)。
|
||||
|
||||
创建 PinterestPipeline(简报池 + 后台并发生成线程),存 state["pinterest_pipeline"],
|
||||
供 pinterest_analyze 推送简报、pinterest_finalize 收尾。
|
||||
"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("pinterest_init")
|
||||
def pinterest_init_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from graph.pinterest_pipeline import PinterestPipeline
|
||||
pipe = PinterestPipeline(state)
|
||||
return {"pinterest_pipeline": pipe}
|
||||
@@ -1,17 +1,26 @@
|
||||
"""Pinterest 参考模式节点 2/3:爬取图片(pinterest_scrape)。
|
||||
|
||||
对 pinterest_search 生成的每个搜索词,调 pinterest_scraper.scraper.scrape_pinterest
|
||||
(Playwright 启动本地 Chrome)搜索 Pinterest 并下载图片到
|
||||
output/pinterest_ref/<国家>/<搜索词>/。
|
||||
对 pinterest_search 生成的搜索词(按需:每次 1 个),调 pinterest_scraper.scraper.scrape_pinterest
|
||||
(Playwright 启动本地 Chrome)搜索 Pinterest 并下载图片到 output/pinterest_ref/<国家>/<搜索词>/。
|
||||
|
||||
- 单个搜索词失败(未登录/网络/无结果)跳过,不中断整批。
|
||||
- 并发数由 config.pinterest.scrape_concurrency 控制(每个并发开一个 Chrome 窗口)。
|
||||
- 只有用了才标记已用:爬取成功(真正用掉该搜索词)→ 持久化已用词;
|
||||
爬取失败 → 记入本轮 attempted(不持久化),避免同轮重复生成。
|
||||
- 已爬取过且图片数达标的搜索词跳过(断点续爬,避免重复开 Chrome)。
|
||||
"""
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.pinterest import (
|
||||
image_md5,
|
||||
load_image_pool,
|
||||
load_used_images,
|
||||
load_used_terms,
|
||||
merge_used,
|
||||
save_image_pool,
|
||||
save_used_terms,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@@ -45,6 +54,7 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
concurrency = int(pcfg.get("scrape_concurrency", 2))
|
||||
headless = bool(pcfg.get("headless", False))
|
||||
proxy = pcfg.get("proxy") or None
|
||||
search_mode = str(pcfg.get("search_mode") or "direct").strip().lower()
|
||||
|
||||
# 所有搜索词共享同一个 .chrome_session 登录态目录,Chrome 对同一 user-data-dir 是单例,
|
||||
# 并发启动会互相抢占导致 "browser has been closed",必须串行爬取。
|
||||
@@ -59,16 +69,22 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
proxy = detect_proxy() or get_system_proxy()
|
||||
except Exception: # noqa: BLE001
|
||||
proxy = None
|
||||
if proxy and not _validate_proxy(proxy):
|
||||
if not proxy:
|
||||
print("[pinterest_scrape] 警告:未检测到代理,将直连下载。国内网络通常无法访问 "
|
||||
"i.pinimg.com,请先开启代理/VPN(Clash/v2ray 等)再运行,否则图片下载会全部失败")
|
||||
elif not _validate_proxy(proxy):
|
||||
print(f"[pinterest_scrape] 警告:代理 {proxy} 无法连通外网,请检查代理/VPN 是否正常,"
|
||||
f"否则 Pinterest 将无法访问(爬取会失败)")
|
||||
|
||||
results: Dict[str, List[str]] = {}
|
||||
skipped: List[str] = []
|
||||
failed: List[str] = []
|
||||
|
||||
def _one(term: str) -> None:
|
||||
term_dir = _term_dir(output_dir, country, term)
|
||||
if _already_scraped(term_dir):
|
||||
# direct 模式:固定关键词允许重复爬取(图池不足时自动再搜,Pinterest 每次可能返回不同图);
|
||||
# llm 模式:已爬取过且达标 → 跳过(断点续爬,避免重复开 Chrome)
|
||||
if search_mode != "direct" and _already_scraped(term_dir):
|
||||
skipped.append(term)
|
||||
print(f"[pinterest_scrape] 已爬取过(跳过): {term}")
|
||||
return
|
||||
@@ -78,6 +94,7 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
save_dir=str(term_dir), proxy=proxy, headless=headless)
|
||||
results[term] = files
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed.append(term)
|
||||
errors.append({"node": "pinterest_scrape", "type": type(e).__name__,
|
||||
"message": f"term[{term}]: {e}", "trace": ""})
|
||||
print(f"[pinterest_scrape] 爬取失败(跳过): {term}: {e}")
|
||||
@@ -86,12 +103,55 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, concurrency)) as ex:
|
||||
list(ex.map(_one, terms))
|
||||
|
||||
# 只有用了才标记已用:爬取成功(含已爬取跳过)的词 → 持久化已用;失败词 → 本轮 attempted(不持久化)
|
||||
# direct 模式:固定关键词不拉黑(可跨轮复用),仅 llm 模式持久化已用词
|
||||
used = load_used_terms(output_dir, country)
|
||||
consumed = (list(results.keys()) + skipped) if search_mode != "direct" else []
|
||||
new_used = merge_used(used, consumed)
|
||||
if new_used != used:
|
||||
save_used_terms(output_dir, country, new_used)
|
||||
print(f"[pinterest_scrape] 已用搜索词更新:新增 {len(consumed)} 个,累计 {len(new_used)}")
|
||||
attempted = merge_used(state.get("pinterest_attempted") or [], failed)
|
||||
|
||||
# 新爬取的图片注册进图池(含 md5),供分析节点按需取用;
|
||||
# 进图池前做 md5 校验去重:md5 已存在于图池 / 已拉黑(used_images)/ 本批重复 → 跳过
|
||||
pool = load_image_pool(output_dir, country)
|
||||
existing = pool.get("images") or []
|
||||
known_paths = {str(img.get("path")) for img in existing}
|
||||
known_md5s = {str(img.get("md5") or "").strip().lower() for img in existing}
|
||||
used_md5s = load_used_images(output_dir, country)
|
||||
new_imgs: List[Dict[str, Any]] = []
|
||||
seen_md5: set = set()
|
||||
for term, files in results.items():
|
||||
for f in files:
|
||||
if f in known_paths:
|
||||
continue
|
||||
m = str(image_md5(f) or "").strip().lower()
|
||||
if not m:
|
||||
continue
|
||||
if m in known_md5s or m in used_md5s or m in seen_md5:
|
||||
print(f"[pinterest_scrape] 图池 md5 去重跳过: {f}")
|
||||
continue
|
||||
seen_md5.add(m)
|
||||
new_imgs.append({"path": f, "md5": m, "term": term})
|
||||
if new_imgs:
|
||||
pool["images"] = existing + new_imgs
|
||||
save_image_pool(output_dir, country, pool)
|
||||
print(f"[pinterest_scrape] 图池新增 {len(new_imgs)} 张图片,累计 {len(pool['images'])} 张")
|
||||
|
||||
# 新一批图爬取完成 → 重置 400 计数(per 种子词),记录当前种子词
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
if pipe is not None and hasattr(pipe, "reset_400"):
|
||||
for term in results.keys():
|
||||
pipe.reset_400(term)
|
||||
|
||||
total = sum(len(v) for v in results.values())
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_scrape"] = {
|
||||
"terms": len(terms), "scraped": len(results), "skipped": len(skipped),
|
||||
"images": total,
|
||||
"failed": len(failed), "images": total, "pool": len(pool.get("images") or []),
|
||||
}
|
||||
print(f"[pinterest_scrape] 完成:{len(results)} 个搜索词,共 {total} 张图(跳过 {len(skipped)})")
|
||||
print(f"[pinterest_scrape] 完成:{len(results)} 个搜索词,共 {total} 张图(跳过 {len(skipped)},失败 {len(failed)})")
|
||||
|
||||
return {"pinterest_images": results, "stats": stats, "errors": errors}
|
||||
return {"pinterest_images": results, "pinterest_attempted": attempted,
|
||||
"stats": stats, "errors": errors}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Pinterest 参考模式节点 1/3:LLM 生成搜索词(pinterest_search)。
|
||||
"""Pinterest 参考模式节点 1/3:按需生成单个搜索词(pinterest_search)。
|
||||
|
||||
流程:国家 Pinterest 种子词池 → LLM 生成搜索词(json_schema 结构化 + 动态注入已用词防重复)
|
||||
→ 全局过滤(已用/黑名单/不适合T恤/去重)→ 持久化已用词。
|
||||
按需搜索:每次只生成 1 个搜索词(LLM json_schema + 动态注入已用词防重复),
|
||||
带短袖/印花设计引导,保证搜索词适合短袖 T 恤印花。
|
||||
不在此处持久化已用词 —— 只有爬取成功(真正用掉)才标记已用(见 pinterest_scrape)。
|
||||
|
||||
兜底链:LLM json_schema → json_object → 解析失败/调用失败 → 回退种子词池随机抽样。
|
||||
带 with_fallback:任何异常都不中断,返回空列表由下游跳过。
|
||||
@@ -15,7 +16,6 @@ from graph.pinterest import (
|
||||
load_used_terms,
|
||||
merge_used,
|
||||
sample_seeds,
|
||||
save_used_terms,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
@@ -32,72 +32,88 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"pinterest_search_terms": [], "errors": errors}
|
||||
|
||||
provider = str(pcfg.get("provider") or "openai").strip().lower()
|
||||
want = int(pcfg.get("search_terms_per_run", 10))
|
||||
search_mode = str(pcfg.get("search_mode") or "direct").strip().lower()
|
||||
want = int(pcfg.get("search_terms_per_run", 1)) # 每次搜索词数量
|
||||
seed_sample = int(pcfg.get("seed_sample", 40))
|
||||
max_used_in_prompt = int(pcfg.get("max_used_terms_in_prompt", 100))
|
||||
blacklist = config.get("blacklist") or []
|
||||
|
||||
# 1) 种子词池(随机抽样)+ 已用搜索词
|
||||
# 1) 种子词池 + 已用搜索词 + 本轮已尝试词(防同轮重复,不持久化)
|
||||
seeds = sample_seeds(country, seed_sample)
|
||||
used = load_used_terms(output_dir, country)
|
||||
attempted = [str(t).strip() for t in (state.get("pinterest_attempted") or []) if str(t).strip()]
|
||||
rounds = int(state.get("pinterest_rounds") or 0) + 1
|
||||
if not seeds:
|
||||
print(f"[pinterest_search] {country} 无种子词,跳过搜索词生成")
|
||||
return {"pinterest_search_terms": [], "errors": errors}
|
||||
return {"pinterest_search_terms": [], "pinterest_rounds": rounds, "errors": errors}
|
||||
|
||||
# 2) LLM 生成(json_schema + 动态注入已用词)
|
||||
# 已用词只取最近 N 个(默认 100)注入提示词,防 token 超限;过滤仍用全量。
|
||||
used_llm = used[-max_used_in_prompt:] if max_used_in_prompt > 0 else []
|
||||
# 2) 生成搜索词:种子词不再由 LLM 给出,直接由内置国家种子词库随机抽取(优先未用过),
|
||||
# 追加 " t-shirt design"(保证 Pinterest 返回真正的 T 恤印花图);llm 模式保留兼容
|
||||
terms: List[str] = []
|
||||
llm = None
|
||||
if provider != "static":
|
||||
try:
|
||||
llm = get_backend(provider)
|
||||
if hasattr(llm, "bind_config"):
|
||||
llm.bind_config(config.get("llm_screen") or {})
|
||||
if provider not in ("mock",) and not getattr(llm, "has_key", False):
|
||||
print(f"[pinterest_search] {provider} 未配置 API key,降级 mock")
|
||||
llm = get_backend("mock")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_search] LLM 初始化失败: {e}")
|
||||
llm = None
|
||||
if search_mode == "direct":
|
||||
from graph.pinterest import load_pinterest_seeds
|
||||
pool = load_pinterest_seeds(country)
|
||||
used_set = {str(u).strip().lower() for u in merge_used(used, attempted)}
|
||||
fresh = [s for s in pool if s.lower() not in used_set]
|
||||
if not fresh:
|
||||
fresh = pool # 库内词全部用过 → 允许复用(词库有限)
|
||||
terms = [f"{s} t-shirt design" if "t-shirt design" not in s.lower() else s
|
||||
for s in random.sample(fresh, min(want, len(fresh)))]
|
||||
print(f"[pinterest_search] direct 模式:国家种子词库随机抽 {len(terms)} 个 + t-shirt design({country})")
|
||||
else:
|
||||
used_llm = merge_used(used, attempted)
|
||||
if max_used_in_prompt > 0:
|
||||
used_llm = used_llm[-max_used_in_prompt:]
|
||||
llm = None
|
||||
if provider != "static":
|
||||
try:
|
||||
llm = get_backend(provider)
|
||||
if hasattr(llm, "bind_config"):
|
||||
llm.bind_config(config.get("llm_screen") or {})
|
||||
if provider not in ("mock",) and not getattr(llm, "has_key", False):
|
||||
print(f"[pinterest_search] {provider} 未配置 API key,降级 mock")
|
||||
llm = get_backend("mock")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_search] LLM 初始化失败: {e}")
|
||||
llm = None
|
||||
|
||||
if llm is not None and hasattr(llm, "generate_pinterest_terms"):
|
||||
try:
|
||||
ctx = {"country": country, "seeds": seeds, "used_terms": used_llm, "count": want}
|
||||
res = llm.generate_pinterest_terms(ctx)
|
||||
terms = [str(t).strip() for t in (res.get("search_terms") or []) if str(t).strip()]
|
||||
print(f"[pinterest_search] LLM 生成搜索词 {len(terms)} 个({country},已用词注入 {len(used_llm)}/{len(used)})")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_search] LLM 生成失败,回退种子词池: {e}")
|
||||
terms = []
|
||||
if llm is not None and hasattr(llm, "generate_pinterest_terms"):
|
||||
try:
|
||||
ctx = {"country": country, "seeds": seeds, "used_terms": used_llm, "count": want}
|
||||
res = llm.generate_pinterest_terms(ctx)
|
||||
terms = [str(t).strip() for t in (res.get("search_terms") or []) if str(t).strip()]
|
||||
print(f"[pinterest_search] LLM 生成搜索词 {len(terms)} 个({country},已用词注入 {len(used_llm)})")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_search] LLM 生成失败,回退种子词池: {e}")
|
||||
terms = []
|
||||
|
||||
# 3) 兜底:LLM 无结果 → 种子词池随机抽样
|
||||
if not terms:
|
||||
terms = random.sample(seeds, min(want, len(seeds))) if seeds else []
|
||||
print(f"[pinterest_search] 兜底:从种子词池取 {len(terms)} 个")
|
||||
# 3) 兜底:LLM 无结果 → 种子词池随机抽样
|
||||
if not terms:
|
||||
terms = [f"{s} t-shirt design" if "t-shirt design" not in s.lower() else s
|
||||
for s in random.sample(seeds, min(want, len(seeds)))]
|
||||
print(f"[pinterest_search] 兜底:从种子词池取 {len(terms)} 个")
|
||||
|
||||
# 4) 全局过滤(已用/黑名单/不适合T恤/去重)
|
||||
filtered = filter_search_terms(terms, used, blacklist)
|
||||
if len(filtered) < want and seeds:
|
||||
# 不足时用种子词池补充(同样过滤),保证数量
|
||||
extra = filter_search_terms(seeds, merge_used(used, filtered), blacklist)
|
||||
for t in extra:
|
||||
if len(filtered) >= want:
|
||||
break
|
||||
filtered.append(t)
|
||||
|
||||
# 5) 持久化已用词
|
||||
new_used = merge_used(used, filtered)
|
||||
save_used_terms(output_dir, country, new_used)
|
||||
# 4) 全局过滤(已用/本轮已尝试/黑名单/不适合T恤/去重)——注意:不在此处持久化已用词
|
||||
# direct 模式:抽样时已避开已用词(库内词有限,全部用过后允许复用),不再额外过滤
|
||||
if search_mode == "direct":
|
||||
filtered = terms
|
||||
else:
|
||||
filtered = filter_search_terms(terms, merge_used(used, attempted), blacklist)
|
||||
if not filtered and seeds:
|
||||
# 生成词全被过滤 → 从种子词池补充(同样过滤)
|
||||
extra = filter_search_terms(seeds, merge_used(used, attempted), blacklist)
|
||||
filtered = extra[:want]
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_search"] = {
|
||||
"provider": provider,
|
||||
"round": rounds,
|
||||
"generated": len(terms),
|
||||
"filtered": len(filtered),
|
||||
"used_total": len(new_used),
|
||||
"used_total": len(used),
|
||||
}
|
||||
print(f"[pinterest_search] 搜索词 {len(filtered)} 个(已用累计 {len(new_used)}): "
|
||||
f"{', '.join(filtered[:6])}{'...' if len(filtered) > 6 else ''}")
|
||||
print(f"[pinterest_search] 第 {rounds} 轮搜索词 {len(filtered)} 个(已用累计 {len(used)}): "
|
||||
f"{', '.join(filtered[:3])}{'...' if len(filtered) > 3 else ''}")
|
||||
|
||||
return {"pinterest_search_terms": filtered, "stats": stats, "errors": errors}
|
||||
return {"pinterest_search_terms": filtered, "pinterest_rounds": rounds,
|
||||
"stats": stats, "errors": errors}
|
||||
|
||||
+19
-10
@@ -134,7 +134,7 @@ def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
|
||||
def _process_spu(
|
||||
db_path, basemap_root, material_root, category, prod_dir, brief, ib,
|
||||
spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
|
||||
img_code="", model_img=None,
|
||||
img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
|
||||
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
|
||||
@@ -210,7 +210,12 @@ def _process_spu(
|
||||
prompt = ensure_rebrand_hint(brief, sanitize_image_prompt(brief.get("image_prompt", "")))
|
||||
ib.generate(prompt, design_path,
|
||||
brief.get("composite_negative", ""),
|
||||
size="1024x1024") # 印花设计统一 1024x1024
|
||||
size=design_size) # 设计稿尺寸按 config compose.design_size
|
||||
# 全局 MD5 去重:生成了设计后,把 MD5 加入全局过滤(对所有国家生效);重复 → 跳过该产品
|
||||
from graph.pinterest import design_md5_ok
|
||||
if not design_md5_ok(design_path):
|
||||
print(f"{tag} 设计稿 MD5 全局重复,跳过该产品: {design_path}")
|
||||
return None
|
||||
result["design_path"] = design_path
|
||||
result["design_from"] = "product"
|
||||
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
|
||||
@@ -252,7 +257,7 @@ def _process_spu(
|
||||
ib.print(wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], # 图2印花, 图3底图
|
||||
size="1504x2000") # 三合一统一 1504x2000
|
||||
size=compose_size) # 合成图尺寸按 config compose.size
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -260,7 +265,7 @@ def _process_spu(
|
||||
print(f"{tag} 三图合成失败,退避重试…: {e}")
|
||||
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], size="1504x2000")
|
||||
extra_images=[design_path, str(basemap_img)], size=compose_size)
|
||||
if retried is not None:
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
@@ -280,14 +285,14 @@ def _process_spu(
|
||||
ib.print(flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], # 图2印花
|
||||
size="1504x2000") # 合成统一 1504x2000
|
||||
size=compose_size) # 合成图尺寸按 config compose.size
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图已生成(无模特,底图+印花): {printed_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
|
||||
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], size="1504x2000")
|
||||
extra_images=[design_path], size=compose_size)
|
||||
if retried is not None:
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图重试成功: {printed_path}")
|
||||
@@ -312,7 +317,7 @@ def _process_spu(
|
||||
ib.print(MODEL_WEAR_PROMPT, str(model_img), cp,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
|
||||
size="1504x2000") # 三合一统一 1504x2000
|
||||
size=compose_size) # 合成图尺寸按 config compose.size
|
||||
col = next((c["color"] for c in colors if c["sku_code"] == sc), sc)
|
||||
color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
|
||||
print(f"{tag} 颜色 {sc}({col})三合一已生成: {cp}")
|
||||
@@ -327,12 +332,14 @@ def _process_spu(
|
||||
or result.get("design_path"))
|
||||
if title_img:
|
||||
t = title_backend.generate_title(title_img, country=country)
|
||||
if t.get("en_title") or t.get("cn_title") or t.get("ja_title"):
|
||||
if t.get("en_title") or t.get("cn_title") or t.get("ja_title") or t.get("es_title"):
|
||||
result["en_title"] = t.get("en_title", "")
|
||||
result["cn_title"] = t.get("cn_title", "")
|
||||
result["ja_title"] = t.get("ja_title", "")
|
||||
result["es_title"] = t.get("es_title", "")
|
||||
print(f"{tag} 标题已生成: EN={t.get('en_title','')[:50]}... "
|
||||
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}...")
|
||||
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}... "
|
||||
f"ES={t.get('es_title','')[:30]}...")
|
||||
|
||||
return result
|
||||
|
||||
@@ -514,7 +521,9 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
|
||||
tb, ib, spu, skus, pcfg, errors, design_path, title_backend,
|
||||
country, img_code=img_code,
|
||||
model_img=model_assign.get(spu.get("code", "")))
|
||||
model_img=model_assign.get(spu.get("code", "")),
|
||||
design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"),
|
||||
compose_size=str((config.get("compose") or {}).get("size") or "1536x2048"))
|
||||
if r:
|
||||
r["img_code"] = img_code
|
||||
return r, img_code
|
||||
|
||||
@@ -11,6 +11,17 @@ from graph.style_rules import derive_style_palette, derive_composition
|
||||
from graph.templates import assemble_prompts
|
||||
from graph.validate import validate_brief, with_fallback
|
||||
|
||||
# —— Pinterest 图生图生最终设计稿时统一追加的「小印花 + 纯白底」约束段 ——
|
||||
# (从热点搜集的文字生图模板里提炼:尺寸缩小、禁止自带背景/满幅)
|
||||
PINTEREST_PRINT_SUFFIX = (
|
||||
" standalone pure print design on a pure white background, "
|
||||
"the print artwork is SMALL and CENTERED with clearly larger white margins around it, "
|
||||
"print area between about 15x18 cm and 26x32 cm, "
|
||||
"do NOT fill the entire canvas, do NOT force full-bleed, "
|
||||
"do NOT add any gradient, texture or background color behind the artwork, "
|
||||
"no garment, no shirt, no model, no mannequin, no watermark"
|
||||
)
|
||||
|
||||
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
|
||||
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
|
||||
_IMG_RISKY_SWAP = {
|
||||
@@ -62,6 +73,12 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
composition = (r.get("composition") or derive_composition(r["topic"], r.get("design_category"))).strip()
|
||||
|
||||
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
|
||||
# Pinterest 简报:直接用 LLM 多模态对图片的描述拼接的 image_prompt(跳过四要素模板),
|
||||
# 仅追加统一的「小印花 + 纯白底」约束段;wearable/composite 仍用模板装配。
|
||||
llm_ip = (r.get("image_prompt") or "").strip()
|
||||
if r.get("source") == "pinterest" and llm_ip:
|
||||
prompts["image_prompt"] = llm_ip + PINTEREST_PRINT_SUFFIX
|
||||
print(f"[prompt] Pinterest 简报用 LLM 多模态描述作为 image_prompt(跳过四要素模板): 「{r['topic']}」")
|
||||
# 文字印花(约 30% 概率):简报有 slogan 时,随机注入文字段到设计稿提示词
|
||||
slogan = (r.get("slogan") or "").strip()
|
||||
if slogan and random.random() < float(config.get("prompt_templates", {}).get("text_ratio", 0.3)):
|
||||
|
||||
@@ -40,16 +40,6 @@ def _plan_seed_shots(comps: List[Dict[str, Any]], count: int) -> List[tuple]:
|
||||
return plan
|
||||
|
||||
|
||||
def _color_tag(cc: Dict[str, Any], idx: int) -> str:
|
||||
"""种草图文件名里的颜色标识:优先 sku_code 的颜色段,回退颜色名/序号。"""
|
||||
sku = str(cc.get("sku_code") or "")
|
||||
if "-" in sku:
|
||||
tag = sku.split("-", 1)[1]
|
||||
else:
|
||||
tag = str(cc.get("color") or "") or f"c{idx}"
|
||||
return "".join(ch for ch in tag if ch.isalnum() or ch in "-_") or f"c{idx}"
|
||||
|
||||
|
||||
@with_fallback("seed_shot")
|
||||
def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
products: List[Dict[str, Any]] = state.get("product") or []
|
||||
@@ -95,7 +85,7 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 材质读取失败(用空): {e}")
|
||||
|
||||
from graph.seed_shot import generate_seed_shots
|
||||
from graph.seed_shot import generate_seed_shots, read_template_category, gender_from_category
|
||||
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
|
||||
from graph.nodes.oss_upload_node import _gen_rand4, MAX_CODE
|
||||
|
||||
@@ -104,7 +94,18 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
seq = int(state.get("oss_seq") or 0)
|
||||
oss_cfg = config.get("oss") or {}
|
||||
oss_enabled = bool(oss_cfg.get("enabled", True)) and bool(oss_cfg.get("oss_bucket"))
|
||||
size = str(ss_cfg.get("size") or "1504x2000")
|
||||
size = str(ss_cfg.get("size") or "1536x2048")
|
||||
|
||||
# 类目 → 性别:模版「类目」表头值含「男」→ 男模;含「女」→ 女模;都不含 → 全部随机
|
||||
gender = None
|
||||
tp = str(((config.get("product") or {}).get("template_path")) or "").strip()
|
||||
if tp:
|
||||
category = read_template_category(tp)
|
||||
gender = gender_from_category(category)
|
||||
if gender:
|
||||
print(f"[seed_shot] 类目「{category[:30]}…」含{'男' if gender == 'male' else '女'} → 固定 {gender} 模特")
|
||||
elif category:
|
||||
print(f"[seed_shot] 类目「{category[:30]}…」无男/女 → 男女模特随机")
|
||||
|
||||
all_shots: List[Dict[str, Any]] = []
|
||||
shot_dir = output_dir / "seed_shots"
|
||||
@@ -128,7 +129,9 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
plan = _plan_seed_shots(comps, count)
|
||||
cn = (r.get("cn_title") or "").strip() or r.get("topic", "")
|
||||
material = material_map.get(r.get("spu_code", ""), "")
|
||||
# 按对应货号命名(img_code=货号,如 DG000);无货号时回退 seed
|
||||
base_prefix = r.get("img_code") or r.get("oss_code") or ""
|
||||
pfx = base_prefix or "seed"
|
||||
|
||||
paths: List[str] = []
|
||||
for ci, (cc, n) in enumerate(plan, start=1):
|
||||
@@ -136,23 +139,25 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not base or not Path(base).exists():
|
||||
print(f"[seed_shot] {r.get('spu_code', '')} 参考图缺失({base}),跳过该颜色种草图")
|
||||
continue
|
||||
tag = _color_tag(cc, ci)
|
||||
pfx = f"{base_prefix}_{tag}" if base_prefix else f"seed_{tag}"
|
||||
generated = generate_seed_shots(ib, base, cn, material, n, str(shot_dir),
|
||||
r.get("composite_negative", ""),
|
||||
size=size, prefix=pfx)
|
||||
size=size, prefix=pfx, gender=gender)
|
||||
paths.extend(generated)
|
||||
if not paths:
|
||||
return None
|
||||
r["seed_shot_paths"] = paths
|
||||
urls: List[str] = []
|
||||
for pth in paths:
|
||||
# OSS key 用对应货号(img_code),不再自增;无货号时回退自增计数
|
||||
with seq_lock:
|
||||
if seq >= MAX_CODE:
|
||||
print(f"[seed_shot] 货号计数达上限 999,停止上传种草图")
|
||||
break
|
||||
code = f"{prefix}{seq:03d}"
|
||||
seq += 1
|
||||
if not base_prefix:
|
||||
if seq >= MAX_CODE:
|
||||
print(f"[seed_shot] 货号计数达上限 999,停止上传种草图")
|
||||
break
|
||||
code = f"{prefix}{seq:03d}"
|
||||
seq += 1
|
||||
else:
|
||||
code = base_prefix
|
||||
if oss_enabled:
|
||||
try:
|
||||
compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg")))
|
||||
|
||||
@@ -73,19 +73,18 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
db_path = root / db_path
|
||||
break
|
||||
|
||||
from graph.template_export import export_product
|
||||
from graph.template_export import export_products
|
||||
tdir = (pcfg.get("template_dir") or "").strip() or str(Path(tp).parent)
|
||||
prod_dir = output_dir / "product"
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
exported: List[str] = []
|
||||
# 批量合并导出:所有产品一次性写入同一模板,只打开/保存一次(避免逐产品频繁读写)
|
||||
batch: List[Dict[str, Any]] = []
|
||||
skipped = 0
|
||||
merged_out: Optional[str] = None # 合并模式:一次任务所有产品填同一个模板
|
||||
is_first = True
|
||||
for r in products:
|
||||
# 失败跳过:合成图(composite/printed)与标题都失败的产品不写进模板
|
||||
has_img = bool(r.get("composite_path") or r.get("printed_path"))
|
||||
has_title = bool((r.get("cn_title") or "").strip())
|
||||
has_title = bool((r.get("en_title") or "").strip()) # 商品名称统一用 en_title
|
||||
if not (has_img and has_title):
|
||||
skipped += 1
|
||||
print(f"[template] 跳过失败产品 {r.get('spu_code')}/{r.get('img_code','')}: "
|
||||
@@ -94,31 +93,37 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
sku_codes = [cc.get("sku_code") for cc in (r.get("color_composites") or [])]
|
||||
if not sku_codes:
|
||||
sku_codes = [r.get("sku_code") or ""]
|
||||
batch.append({
|
||||
"spu_code": r.get("spu_code", ""),
|
||||
"sku_codes": sku_codes,
|
||||
"images": [],
|
||||
"spu_per_color": True, # 每颜色一个独立 SPU 块(单色多 SPU)
|
||||
"oss_code": r.get("oss_code") or (r.get("color_composites") or [{}])[0].get("code", ""),
|
||||
"cn_title": r.get("cn_title", ""),
|
||||
"en_title": r.get("en_title", ""),
|
||||
"ja_title": r.get("ja_title", ""),
|
||||
"es_title": r.get("es_title", ""),
|
||||
"composite_urls": r.get("color_composites") or [],
|
||||
"seed_shot_urls": r.get("seed_shot_urls") or [],
|
||||
})
|
||||
|
||||
exported: List[str] = []
|
||||
if batch:
|
||||
out = _template_out_path(prod_dir, "商品上传")
|
||||
try:
|
||||
if is_first:
|
||||
merged_out = str(_template_out_path(prod_dir, "商品上传"))
|
||||
out = export_product(
|
||||
db_path, r.get("spu_code", ""), sku_codes, tdir, tp,
|
||||
merged_out,
|
||||
images=[],
|
||||
spu_per_color=True, # 每颜色一个独立 SPU 块(单色多 SPU)
|
||||
oss_code=r.get("oss_code") or (r.get("color_composites") or [{}])[0].get("code", ""),
|
||||
cn_title=r.get("cn_title", ""),
|
||||
en_title=r.get("en_title", ""),
|
||||
ja_title=r.get("ja_title", ""),
|
||||
composite_urls=r.get("color_composites") or [],
|
||||
seed_shot_urls=r.get("seed_shot_urls") or [],
|
||||
append_to="" if is_first else merged_out, # 首个产品从模板创建,后续追加合并
|
||||
out = export_products(
|
||||
db_path, batch, tdir, tp, str(out),
|
||||
markup_percent=float(pcfg.get("markup_percent") or 0),
|
||||
)
|
||||
r["template_path"] = str(out)
|
||||
for r in products:
|
||||
if (r.get("composite_path") or r.get("printed_path")) and (r.get("en_title") or "").strip():
|
||||
r["template_path"] = str(out)
|
||||
exported.append(str(out))
|
||||
print(f"[template] 商品上传模板已生成({len(exported)}/{len(products)} 合并): {out}")
|
||||
print(f"[template] 商品上传模板已生成({len(batch)} 个产品一次合并): {out}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "template_export", "type": type(e).__name__,
|
||||
"message": f"模板导出失败 {r.get('spu_code')}: {e}", "trace": ""})
|
||||
print(f"[template] 模板导出失败 {r.get('spu_code')}: {e}")
|
||||
is_first = False
|
||||
"message": f"模板批量导出失败: {e}", "trace": ""})
|
||||
print(f"[template] 模板批量导出失败: {e}")
|
||||
|
||||
stats["template_export"] = {"exported": len(exported)}
|
||||
return {"product": products, "errors": errors, "stats": stats}
|
||||
|
||||
+224
-1
@@ -1,10 +1,13 @@
|
||||
"""Pinterest 参考模式共享辅助:种子词加载、已用搜索词持久化、搜索词全局过滤。
|
||||
"""Pinterest 参考模式共享辅助:种子词加载、已用搜索词持久化、搜索词全局过滤、设计图全局 MD5 去重。
|
||||
|
||||
独立于 Google Trends 采集链路,供 pinterest_search / scrape / analyze 节点复用。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
@@ -114,3 +117,223 @@ def merge_used(existing: List[str], new_terms: List[str]) -> List[str]:
|
||||
seen.add(k)
|
||||
out.append(str(t).strip())
|
||||
return out
|
||||
|
||||
|
||||
# —— 设计图全局 MD5 去重(跨国家、跨运行;对所有国家生效)——
|
||||
_MD5_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def global_md5_path() -> Path:
|
||||
"""全局设计图 MD5 过滤文件(.cache 随打包同步,跨版本保留)。"""
|
||||
return runtime_root() / ".cache" / "global_design_md5.json"
|
||||
|
||||
|
||||
def load_global_md5() -> set:
|
||||
"""读全局设计图 MD5 集合。"""
|
||||
try:
|
||||
p = global_md5_path()
|
||||
if p.exists():
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
return {str(m).strip().lower() for m in (data.get("md5s") or []) if str(m).strip()}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 全局 MD5 过滤读取失败: {e}")
|
||||
return set()
|
||||
|
||||
|
||||
def add_global_md5(md5: str) -> bool:
|
||||
"""把设计图 MD5 加入全局过滤;返回 True=新增可用,False=已存在(全局重复,应跳过)。"""
|
||||
md5 = str(md5 or "").strip().lower()
|
||||
if not md5:
|
||||
return False
|
||||
with _MD5_LOCK:
|
||||
try:
|
||||
p = global_md5_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
md5s = load_global_md5()
|
||||
if md5 in md5s:
|
||||
return False
|
||||
md5s.add(md5)
|
||||
p.write_text(json.dumps(
|
||||
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "md5s": sorted(md5s)},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 全局 MD5 过滤写入失败: {e}")
|
||||
return True # 写入失败不阻塞:按新增处理,避免误跳过
|
||||
|
||||
|
||||
def design_md5_ok(image_path: str) -> bool:
|
||||
"""计算设计图 MD5 并加入全局过滤;返回 True=新增可用,False=全局重复(应跳过)。"""
|
||||
try:
|
||||
md5 = hashlib.md5(Path(image_path).read_bytes()).hexdigest()
|
||||
return add_global_md5(md5)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 设计图 MD5 计算失败: {e}")
|
||||
return True
|
||||
|
||||
|
||||
# —— 图池(Image Pool):跨轮次/跨运行持久化的爬取图片池 + 已消费图片 MD5 拉黑 ——
|
||||
# 图池 = 所有已爬取图片的注册表(path + md5 + term);已消费(分析过)的图片 MD5 记入
|
||||
# used_images,不再复用。分析从图池取未消费图片,池不足时由路由触发新一轮搜索。
|
||||
|
||||
_IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp")
|
||||
|
||||
|
||||
def image_pool_path(output_dir: str, country: str) -> Path:
|
||||
return Path(output_dir) / "pinterest_ref" / country / "image_pool.json"
|
||||
|
||||
|
||||
def used_images_path(output_dir: str, country: str) -> Path:
|
||||
return Path(output_dir) / "pinterest_ref" / country / "used_images.json"
|
||||
|
||||
|
||||
def load_image_pool(output_dir: str, country: str) -> Dict[str, Any]:
|
||||
"""读图池注册表;无文件时回退扫描文件系统重建。"""
|
||||
try:
|
||||
p = image_pool_path(output_dir, country)
|
||||
if p.exists():
|
||||
data = json.loads(p.read_text(encoding="utf-8")) or {}
|
||||
imgs = data.get("images") or []
|
||||
if imgs:
|
||||
return {"updated_at": data.get("updated_at", ""), "images": imgs}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 图池读取失败: {e}")
|
||||
return {"updated_at": "", "images": scan_scraped_images(output_dir, country)}
|
||||
|
||||
|
||||
def save_image_pool(output_dir: str, country: str, pool: Dict[str, Any]) -> None:
|
||||
"""持久化图池注册表。"""
|
||||
try:
|
||||
p = image_pool_path(output_dir, country)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(
|
||||
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "images": pool.get("images") or []},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 图池保存失败: {e}")
|
||||
|
||||
|
||||
def scan_scraped_images(output_dir: str, country: str) -> List[Dict[str, Any]]:
|
||||
"""扫描 pinterest_ref/<country>/*/ 下所有图片,重建图池注册表(含 md5)。"""
|
||||
base = Path(output_dir) / "pinterest_ref" / country
|
||||
out: List[Dict[str, Any]] = []
|
||||
if not base.exists():
|
||||
return out
|
||||
for term_dir in sorted(base.iterdir()):
|
||||
if not term_dir.is_dir():
|
||||
continue
|
||||
term = term_dir.name
|
||||
for f in sorted(term_dir.iterdir()):
|
||||
if not f.is_file() or f.suffix.lower() not in _IMG_EXTS:
|
||||
continue
|
||||
out.append({"path": str(f), "md5": image_md5(str(f)), "term": term})
|
||||
return out
|
||||
|
||||
|
||||
def image_md5(path: str) -> str:
|
||||
"""计算图片文件 MD5(失败返回空串)。"""
|
||||
try:
|
||||
return hashlib.md5(Path(path).read_bytes()).hexdigest()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 图片 MD5 计算失败 {path}: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def load_used_images(output_dir: str, country: str) -> set:
|
||||
"""读已消费图片 MD5 集合(拉黑,不再复用)。"""
|
||||
try:
|
||||
p = used_images_path(output_dir, country)
|
||||
if p.exists():
|
||||
data = json.loads(p.read_text(encoding="utf-8")) or {}
|
||||
return {str(m).strip().lower() for m in (data.get("md5s") or []) if str(m).strip()}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 已消费图片读取失败: {e}")
|
||||
return set()
|
||||
|
||||
|
||||
def save_used_images(output_dir: str, country: str, md5s: set) -> None:
|
||||
"""持久化已消费图片 MD5 集合。"""
|
||||
try:
|
||||
p = used_images_path(output_dir, country)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(
|
||||
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "md5s": sorted(md5s)},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 已消费图片保存失败: {e}")
|
||||
|
||||
|
||||
def pool_unused_images(pool: Dict[str, Any], used: set) -> List[Dict[str, Any]]:
|
||||
"""从图池取未消费图片(md5 不在 used 集合),保序。"""
|
||||
out = []
|
||||
for img in pool.get("images") or []:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
m = str(img.get("md5") or "").strip().lower()
|
||||
if not m or m in used:
|
||||
continue
|
||||
if not Path(str(img.get("path") or "")).exists():
|
||||
continue
|
||||
out.append(img)
|
||||
return out
|
||||
|
||||
|
||||
def compress_image(path: str, max_dim: int = 1024, max_bytes: int = 1_500_000,
|
||||
out_dir: str = "") -> str:
|
||||
"""压缩大图:超过 max_dim 边长或 max_bytes 体积时缩放/重编码,返回压缩后路径。
|
||||
|
||||
- 内存占用过大(分辨率过高)→ 等比缩放到 max_dim 内;
|
||||
- 文件过大 → 转 JPEG 重编码(质量自适应);
|
||||
- 无需压缩 → 返回原路径。压缩产物存 out_dir(默认图片同目录 .compressed/)。
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return path
|
||||
size = p.stat().st_size
|
||||
try:
|
||||
with Image.open(p) as im:
|
||||
w, h = im.size
|
||||
except Exception: # noqa: BLE001 无法解析的图片(损坏/非标准)直接返回原路径
|
||||
return path
|
||||
if w <= max_dim and h <= max_dim and size <= max_bytes:
|
||||
return path
|
||||
out_root = Path(out_dir) if out_dir else (p.parent / ".compressed")
|
||||
out_root.mkdir(parents=True, exist_ok=True)
|
||||
out = out_root / f"{p.stem}_c.jpg"
|
||||
with Image.open(p) as im:
|
||||
im = im.convert("RGB")
|
||||
im.thumbnail((max_dim, max_dim), Image.LANCZOS)
|
||||
quality = 85
|
||||
while quality >= 40:
|
||||
im.save(out, "JPEG", quality=quality, optimize=True)
|
||||
if out.stat().st_size <= max_bytes:
|
||||
break
|
||||
quality -= 15
|
||||
print(f"[pinterest] 图片压缩 {p.name} ({w}x{h}, {size // 1024}KB) → "
|
||||
f"{out.name} ({out.stat().st_size // 1024}KB)")
|
||||
return str(out)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 图片压缩失败 {path}: {e}")
|
||||
return path
|
||||
|
||||
|
||||
def is_400_content_image(exc) -> bool:
|
||||
"""HTTP 400 且错误信息模糊匹配到「内容」或「图片」才算(用户要求)。
|
||||
|
||||
生图后端抛 RuntimeError("图像 API 400: {body}"),body 在消息里;
|
||||
多模态后端抛 requests.HTTPError,响应体在 exc.response.text。
|
||||
"""
|
||||
msg = str(exc or "")
|
||||
resp = getattr(exc, "response", None)
|
||||
if resp is not None:
|
||||
try:
|
||||
body = resp.text or ""
|
||||
except Exception: # noqa: BLE001
|
||||
body = ""
|
||||
if body:
|
||||
msg = f"{msg} {body}"
|
||||
if "400" not in msg:
|
||||
return False
|
||||
return ("内容" in msg or "图片" in msg)
|
||||
|
||||
@@ -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}")
|
||||
+23
-3
@@ -47,11 +47,31 @@ def list_colors(db_path, spu_code: str) -> List[Dict[str, Any]]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _norm_name(s: str) -> str:
|
||||
"""去空格(半角+全角)+ 小写,用于 SKU code 与文件夹名比较。"""
|
||||
return str(s or "").replace(" ", "").replace(" ", "").strip().lower()
|
||||
|
||||
|
||||
def find_basemap(basemap_root, spu_code: str, sku_code: str) -> Optional[Path]:
|
||||
"""basemap/<款号>/<SKU.code>/ 下第一张图片;无返回 None。"""
|
||||
d = Path(basemap_root) / spu_code / sku_code
|
||||
if not d.exists():
|
||||
"""basemap/<款号>/<SKU.code>/ 下第一张图片;无返回 None。
|
||||
|
||||
SKU code 与文件夹名比较时两边都去空格(兼容 db code 或文件夹名带空格)。
|
||||
"""
|
||||
root = Path(basemap_root) / spu_code
|
||||
if not root.exists():
|
||||
return None
|
||||
target = _norm_name(sku_code)
|
||||
if not target:
|
||||
return None
|
||||
d = root / sku_code
|
||||
if not (d.exists() and d.is_dir()):
|
||||
d = None
|
||||
for cand in sorted(root.iterdir()):
|
||||
if cand.is_dir() and _norm_name(cand.name) == target:
|
||||
d = cand
|
||||
break
|
||||
if d is None:
|
||||
return None
|
||||
for f in sorted(d.iterdir()):
|
||||
if f.is_file() and f.suffix.lower() in IMG_EXTS:
|
||||
return f
|
||||
|
||||
+56
-9
@@ -44,15 +44,55 @@ def load_templates() -> List[Dict[str, str]]:
|
||||
for t in tpls if t.get("prompt")]
|
||||
|
||||
|
||||
def load_model_features() -> List[str]:
|
||||
"""模特特征列表(无配置时给内置兜底)。"""
|
||||
def load_model_features(gender: Optional[str] = None) -> List[str]:
|
||||
"""模特特征列表(无配置时给内置兜底)。
|
||||
gender: "male"/"female" 时只返回对应性别;None/其他 返回全部(指定性别组为空时回退全部)。"""
|
||||
data = _load_yaml("configs/model_features.yaml")
|
||||
feats = [str(f) for f in (data.get("model_features") or []) if str(f).strip()]
|
||||
mf = data.get("model_features") or []
|
||||
feats: List[str] = []
|
||||
if isinstance(mf, dict):
|
||||
if gender and gender in mf:
|
||||
feats = [str(f) for f in mf[gender] if str(f).strip()]
|
||||
if not feats:
|
||||
feats = [str(f) for g in mf.values() for f in g if str(f).strip()]
|
||||
else:
|
||||
feats = [str(f) for f in mf if str(f).strip()]
|
||||
if not feats:
|
||||
feats = ["20岁清新少女,素颜通透感", "25岁都市职场女性,干练气质"]
|
||||
return feats
|
||||
|
||||
|
||||
def read_template_category(template_path: str) -> str:
|
||||
"""读取模版「类目」表头对应的值(如 服装、鞋靴和珠宝饰品>男士时尚>男装>男装上衣、T恤、衬衫>男装T恤)。
|
||||
遍历所有 sheet(类目表头可能在「模版」等 sheet),找到即返回其下一行同列值。"""
|
||||
try:
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(template_path, data_only=True, read_only=True)
|
||||
try:
|
||||
for ws in wb.worksheets:
|
||||
rows = [r for r in ws.iter_rows(min_row=1, max_row=5, values_only=True)]
|
||||
for ri, row in enumerate(rows):
|
||||
for ci, v in enumerate(row):
|
||||
if v is not None and str(v).strip() == "类目":
|
||||
if ri + 1 < len(rows):
|
||||
val = rows[ri + 1][ci]
|
||||
return str(val or "").strip()
|
||||
finally:
|
||||
wb.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 读取模版类目失败: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def gender_from_category(category: str) -> Optional[str]:
|
||||
"""类目含「男」→ male;含「女」→ female;都不含 → None(全部随机)。"""
|
||||
if "男" in category:
|
||||
return "male"
|
||||
if "女" in category:
|
||||
return "female"
|
||||
return None
|
||||
|
||||
|
||||
def load_style_features() -> List[str]:
|
||||
"""服装风格列表(style_features.yaml,随机取一条替换 [服装风格];无配置时内置兜底)。"""
|
||||
data = _load_yaml("configs/style_features.yaml")
|
||||
@@ -75,13 +115,15 @@ def render_prompt(template_prompt: str, cn_title: str, material: str, model_feat
|
||||
|
||||
def generate_seed_shots(image_backend, base_image: str, cn_title: str, material: str,
|
||||
count: int, out_dir: str, negative: str = "",
|
||||
size: str = "1504x2000", prefix: str = "") -> List[str]:
|
||||
size: str = "1536x2048", prefix: str = "",
|
||||
gender: Optional[str] = None) -> List[str]:
|
||||
"""生成 count 张种草图(img2img,图1=合成图)。返回产物路径列表。
|
||||
size: 种草图统一 1504x2000。
|
||||
prefix: 货号前缀(对应产品货号,命名 {prefix}_seedshot_{n}.png,不覆盖旧文件)。
|
||||
size: 种草图统一 1536x2048(与合成图一致)。
|
||||
prefix: 货号前缀(对应产品货号,命名 {prefix}_{随机4位}.png,不覆盖旧文件)。
|
||||
gender: "male"/"female" 时只从对应性别模特特征随机;None 全部随机。
|
||||
占位符 [商品名称]/[材质]/[模特特征]/[服装风格] 均随机组合(模板/模特/服装风格各随机取一条)。"""
|
||||
templates = load_templates()
|
||||
features = load_model_features()
|
||||
features = load_model_features(gender=gender)
|
||||
style_features = load_style_features()
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
@@ -91,8 +133,13 @@ def generate_seed_shots(image_backend, base_image: str, cn_title: str, material:
|
||||
feat = random.choice(features)
|
||||
style_feat = random.choice(style_features)
|
||||
prompt = render_prompt(tpl["prompt"], cn_title, material, feat, style_feat)
|
||||
out_path = str(out / f"{prefix}_seedshot_{i + 1:02d}.png" if prefix
|
||||
else out / f"seed_shot_{i + 1:02d}.png")
|
||||
# 命名:{货号}_{随机4位}.png(按货号命名,随机4位避免自增序号/覆盖)
|
||||
while True:
|
||||
rand4 = f"{random.randint(0, 9999):04d}"
|
||||
out_path = str(out / f"{prefix}_{rand4}.png" if prefix
|
||||
else out / f"seed_{rand4}.png")
|
||||
if not Path(out_path).exists():
|
||||
break
|
||||
try:
|
||||
image_backend.print(prompt, base_image, out_path, negative, size=size)
|
||||
paths.append(out_path)
|
||||
|
||||
@@ -34,6 +34,12 @@ class AgentState(TypedDict, total=False):
|
||||
pinterest_images: Dict[str, List[str]] # pinterest_scrape 产出:搜索词 → 爬取图片路径列表
|
||||
pinterest_briefs: List[Dict[str, Any]] # pinterest_analyze 产出:LLM 分析图片的原始设计简报
|
||||
|
||||
# —— Pinterest 按需搜索循环状态 ——
|
||||
pinterest_target: int # 目标简报数(= spu_tasks 数量,每款一个设计)
|
||||
pinterest_rounds: int # 已搜索轮次
|
||||
pinterest_attempted: List[str] # 本轮已尝试(未持久化)的搜索词,防同轮重复
|
||||
pinterest_pipeline: Any # PinterestPipeline 实例(简报池 + 并发生成线程)
|
||||
|
||||
# —— 可观测性 ——
|
||||
errors: List[Dict[str, Any]] # 各节点兜底捕获的错误:{node, type, message, trace}
|
||||
stats: Dict[str, Any] # 各阶段统计:{fetch, filter, score, screen, prompt, compose}
|
||||
|
||||
+258
-125
@@ -20,6 +20,11 @@ from graph.product import _connect
|
||||
# 商品轮播图列名关键词(模板存在中/英/日变体,如 商品轮播图1 / Product Carousel Image 1 / 商品カルーセル画像1)
|
||||
_CAROUSEL_KW = ("轮播", "carousel", "カルーセル")
|
||||
|
||||
# 商品产地:国家简称 → 正式名称(模版要求,如「沙特站」提取为「沙特」但需填「沙特阿拉伯」)
|
||||
_COUNTRY_NAME_MAP = {
|
||||
"沙特": "沙特阿拉伯",
|
||||
}
|
||||
|
||||
|
||||
def _size_rank(size) -> tuple:
|
||||
"""把尺码字符串转成可排序 rank(从小到大)。
|
||||
@@ -99,15 +104,29 @@ def _ja_col(router) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _es_col(router) -> Optional[int]:
|
||||
"""定位「西语名称」列(基础信息组,如 西语名称/西班牙语名称)。"""
|
||||
try:
|
||||
return router.resolve_col("西语名称")
|
||||
except KeyError:
|
||||
pass
|
||||
for name, col in router.column_map.items():
|
||||
low = str(name)
|
||||
if ("西语" in low or "西班牙" in low) and "详情图文" not in low and "轮播图" not in low and "名称" in low:
|
||||
return col
|
||||
return None
|
||||
|
||||
|
||||
def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_title: str,
|
||||
ja_title: str, composite_by_sku: Dict[str, Any],
|
||||
ja_title: str, es_title: str, composite_by_sku: Dict[str, Any],
|
||||
all_composite_urls: List[str], seed_shot_urls: List[str],
|
||||
only_rows: Optional[List[int]] = None) -> None:
|
||||
"""按用户要求填充设计联动字段:
|
||||
- SPU 行:SPU货号=设计货号、SKU货号=设计货号、商品名称=cn_title、英文名称=en_title、
|
||||
日语名称=ja_title、商品轮播图1=随机一张三合一主图、详情图文=全部主图+种草图链接 | 分割
|
||||
- SPU 行:SPU货号=设计货号、SKU货号=设计货号、商品名称=en_title、英文名称=en_title、
|
||||
日语名称=ja_title、西语名称=es_title、商品轮播图1=随机一张三合一主图、
|
||||
详情图文=全部主图+种草图 链接 | 分割(不含 img_url_2)
|
||||
- SKU 行:SPU货号=设计货号、SKU货号=该颜色货号、商品轮播图1=该颜色三合一链接、
|
||||
商品名称/英文名称/日语名称 与 SPU 一致
|
||||
商品名称/英文名称/日语名称/西语名称 与 SPU 一致
|
||||
only_rows:合并模式下只填充本产品块的行(None=该款全部行)
|
||||
"""
|
||||
import random
|
||||
@@ -131,6 +150,10 @@ def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_
|
||||
ja_col = _ja_col(router)
|
||||
except KeyError:
|
||||
ja_col = None
|
||||
try:
|
||||
es_col = _es_col(router)
|
||||
except KeyError:
|
||||
es_col = None
|
||||
try:
|
||||
sku_code_col = router.resolve_col("SKU货号")
|
||||
except KeyError:
|
||||
@@ -148,12 +171,14 @@ def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_
|
||||
router.ws.cell(row, spu_col, oss_code)
|
||||
if sku_code_col and oss_code:
|
||||
router.ws.cell(row, sku_code_col, oss_code)
|
||||
if name_col and cn_title:
|
||||
router.ws.cell(row, name_col, cn_title)
|
||||
if name_col and en_title:
|
||||
router.ws.cell(row, name_col, en_title) # 商品名称统一用 en_title
|
||||
if en_col and en_title:
|
||||
router.ws.cell(row, en_col, en_title)
|
||||
if ja_col and ja_title:
|
||||
router.ws.cell(row, ja_col, ja_title)
|
||||
if es_col and es_title:
|
||||
router.ws.cell(row, es_col, es_title)
|
||||
if car1 is not None and all_composite_urls:
|
||||
router.ws.cell(row, car1, random.choice(all_composite_urls)) # SPU 轮播图1 随机
|
||||
if detail_col is not None:
|
||||
@@ -163,13 +188,15 @@ def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_
|
||||
else:
|
||||
if spu_col and oss_code:
|
||||
router.ws.cell(row, spu_col, oss_code)
|
||||
# SKU 行与 SPU 一致:商品名称/英文名称/日语名称
|
||||
if name_col and cn_title:
|
||||
router.ws.cell(row, name_col, cn_title)
|
||||
# SKU 行与 SPU 一致:商品名称/英文名称/日语名称/西语名称
|
||||
if name_col and en_title:
|
||||
router.ws.cell(row, name_col, en_title) # 商品名称统一用 en_title
|
||||
if en_col and en_title:
|
||||
router.ws.cell(row, en_col, en_title)
|
||||
if ja_col and ja_title:
|
||||
router.ws.cell(row, ja_col, ja_title)
|
||||
if es_col and es_title:
|
||||
router.ws.cell(row, es_col, es_title)
|
||||
cc = composite_by_sku.get(color) or composite_by_sku.get("") # 按色值匹配该颜色主图
|
||||
if sku_code_col and oss_code:
|
||||
router.ws.cell(row, sku_code_col, oss_code) # SKU货号=SPU货号(同一货号)
|
||||
@@ -185,6 +212,7 @@ def _build_spu_row(spu: Dict[str, Any], spu_code: str, origin_province: str,
|
||||
"SKC货号": spu_code, # code 路由为 SKC货号(用户要求)
|
||||
"风格": "休闲", # style 路由为"休闲"(用户要求)
|
||||
"商品产地": origin_province, # 产地省份不用填,经营站点填到「商品产地」
|
||||
"款式来源": "现货款", # SPU商品属性-款式来源 统一填「现货款」(用户要求)
|
||||
}
|
||||
if color:
|
||||
row["色值(主规格)"] = color
|
||||
@@ -195,19 +223,26 @@ def _build_spu_row(spu: Dict[str, Any], spu_code: str, origin_province: str,
|
||||
return row
|
||||
|
||||
|
||||
def _find_price_header(router) -> str:
|
||||
"""定位价格列表头:任意含「申报价格」的列(美站/日站/英站…模糊匹配);找不到回退默认。"""
|
||||
for k in router.column_map:
|
||||
if "申报价格" in str(k):
|
||||
return str(k)
|
||||
return "申报价格-日本站"
|
||||
def _find_price_headers(router) -> List[str]:
|
||||
"""定位所有「申报价格」列(美站/日站/英站…模糊匹配);匹配到多个时全部返回,统一填加价后价格。"""
|
||||
hits = [str(k) for k in router.column_map if "申报价格" in str(k)]
|
||||
return hits or ["申报价格-日本站"]
|
||||
|
||||
|
||||
def _find_bust_headers(router) -> List[str]:
|
||||
"""定位所有「胸围」列(基码表-胸围(cm)/胸围全围(cm)…模糊匹配);匹配到多个时全部填 sku.bust。"""
|
||||
hits = [str(k) for k in router.column_map if "胸围" in str(k)]
|
||||
return hits or ["胸围全围(cm)"]
|
||||
|
||||
|
||||
def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: str,
|
||||
warehouses: List[str], markup_percent: float = 0.0,
|
||||
multi: bool = True, price_header: str = "申报价格-日本站") -> Dict[str, Any]:
|
||||
multi: bool = True, price_header: str = "申报价格-日本站",
|
||||
bust_headers: Optional[List[str]] = None,
|
||||
price_headers: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。
|
||||
价格(price_header 列,如 申报价格-美国站/日本站,模糊匹配)= SKU.price × (1+markup/100),预先填好。
|
||||
价格(price_headers 列,如 申报价格-美国站/日本站,模糊匹配到多个时全部填)= SKU.price × (1+markup/100),
|
||||
预先填好。bust 填所有「胸围」列(bust_headers,如 基码表-胸围(cm)/胸围全围(cm),检测到才填)。
|
||||
规格类型2 统一填「尺码」两个字(不是 size 参数值)。"""
|
||||
row: Dict[str, Any] = {
|
||||
"基础信息-商品层级": "sku",
|
||||
@@ -217,17 +252,30 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color:
|
||||
"规格类型2": "尺码", # 规格类型2 统一填「尺码」(不填 size 值)
|
||||
"币种": "CNY",
|
||||
}
|
||||
if price_headers is None:
|
||||
price_headers = [price_header]
|
||||
if bust_headers is None:
|
||||
bust_headers = ["胸围全围(cm)"]
|
||||
for j, w in enumerate(warehouses, start=1):
|
||||
row[f"发货仓{j}"] = w
|
||||
row[f"发货仓{j}库存"] = 200
|
||||
for dbk, header in SKU_MAP.items():
|
||||
if dbk == "color":
|
||||
continue
|
||||
if dbk == "price":
|
||||
header = price_header # 模糊匹配的实际价格列(申报价格-美站/日站/英站…)
|
||||
v = sk.get(dbk)
|
||||
if dbk == "price" and v not in (None, ""):
|
||||
if dbk == "bust":
|
||||
if v in (None, ""):
|
||||
continue
|
||||
for h in bust_headers:
|
||||
row[h] = v
|
||||
continue
|
||||
if dbk == "price":
|
||||
if v in (None, ""):
|
||||
continue
|
||||
v = round(float(v) * (1 + markup_percent / 100), 2) # 申报价格 = price × (1+加价%)
|
||||
for h in price_headers:
|
||||
row[h] = v
|
||||
continue
|
||||
if v not in (None, ""):
|
||||
row[header] = v
|
||||
return row
|
||||
@@ -325,6 +373,7 @@ def _read_meta(router) -> tuple:
|
||||
site = str(ws.cell(2, 1).value or "").strip()
|
||||
raw = str(ws.cell(2, 2).value or "").strip()
|
||||
origin_province = site[:-1] if site.endswith("站") else site
|
||||
origin_province = _COUNTRY_NAME_MAP.get(origin_province, origin_province) # 简称→正式名称(如 沙特→沙特阿拉伯)
|
||||
warehouses = [w.strip() for w in raw.split("、") if w.strip()]
|
||||
return origin_province, warehouses
|
||||
|
||||
@@ -342,42 +391,36 @@ def _read_skus(db_path, spu_code: str, sku_code: str) -> List[Dict[str, Any]]:
|
||||
return skus
|
||||
|
||||
|
||||
def export_product(
|
||||
db_path,
|
||||
spu_code: str,
|
||||
sku_code, # str | List[str]:单颜色或多个颜色
|
||||
template_dir: str,
|
||||
template_path: str,
|
||||
out_path: str,
|
||||
images: Optional[List[str]] = None,
|
||||
def _import_router(template_dir: str) -> None:
|
||||
"""import template_router(优先 config 的 template_dir;打包后回退 _MEIPASS/model;再兜底项目自带 templates/)。"""
|
||||
tdir = Path(template_dir)
|
||||
candidates = [tdir]
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass:
|
||||
candidates.append(Path(meipass) / "model")
|
||||
# 用户上传的模板可能在任意目录(无 template_router.py),兜底项目自带 templates/
|
||||
from graph.paths import project_root as _proj_root
|
||||
candidates.append(_proj_root() / "templates")
|
||||
for d in candidates:
|
||||
if d.exists() and str(d) not in sys.path:
|
||||
sys.path.insert(0, str(d))
|
||||
|
||||
|
||||
def _insert_product_block(
|
||||
router, db_path, spu_code, sku_code,
|
||||
origin_province, warehouses, price_headers,
|
||||
markup_percent: float = 0.0, images: Optional[List[str]] = None,
|
||||
spu_per_color: bool = True,
|
||||
oss_code: str = "",
|
||||
cn_title: str = "",
|
||||
en_title: str = "",
|
||||
ja_title: str = "",
|
||||
oss_code: str = "", cn_title: str = "", en_title: str = "", ja_title: str = "",
|
||||
es_title: str = "",
|
||||
composite_urls: Optional[List[Dict[str, Any]]] = None,
|
||||
seed_shot_urls: Optional[List[str]] = None,
|
||||
append_to: str = "",
|
||||
markup_percent: float = 0.0,
|
||||
) -> Path:
|
||||
"""生成商品上传"已填写"模板(支持多产品合并到同一文件)。
|
||||
bust_headers: Optional[List[str]] = None,
|
||||
) -> List[int]:
|
||||
"""在已打开的 router 中插入一个产品的 SPU+SKU 块并填充设计字段,返回本块行号。
|
||||
|
||||
sku_code :SKU 颜色编码,支持单个 str 或多个(list/tuple/逗号分隔字符串)。
|
||||
spu_per_color :True(默认)= 每个颜色导出一个 SPU 块;False = 单 SPU 下挂所有颜色 SKU 变体。
|
||||
template_dir :template_router.py 所在目录(用于 import)
|
||||
template_path :商品上传模版 xlsx 路径
|
||||
images :生成的产品图路径列表(仅作用于第一个颜色块:SPU 行轮播图1 + SKU 行回退)
|
||||
oss_code :设计货号(前缀+3位计数),SPU货号/SKU货号 列均填它
|
||||
cn_title :商品名称(中文标题)
|
||||
en_title :英文名称(英文标题)
|
||||
ja_title :日语名称(日语标题,JP 模板生成)
|
||||
composite_urls:[{"sku_code","color","url","code"}] 每色三合一主图(含图床链接与货号)
|
||||
seed_shot_urls :种草图图床链接列表(详情图文 | 拼接用)
|
||||
append_to :已有输出文件路径;提供则在其基础上追加本产品块(一次任务多产品合并一个模板)
|
||||
markup_percent :加价百分比,申报价格 = SKU.price × (1+markup/100) 预填
|
||||
返回输出文件路径。
|
||||
供单产品 export_product 与批量 export_products 复用(批量时只打开/保存一次)。
|
||||
"""
|
||||
# 1) 读 db(支持单/多颜色)
|
||||
spu = _read_spu(db_path, spu_code)
|
||||
if spu is None:
|
||||
raise ValueError(f"SPU {spu_code} 不存在于 db")
|
||||
@@ -395,90 +438,180 @@ def export_product(
|
||||
skus_by_color.append((sc, skus))
|
||||
images = [str(i) for i in (images or []) if i]
|
||||
|
||||
# 2) import template_router(优先 config 的 template_dir;打包后回退 _MEIPASS/model)
|
||||
tdir = Path(template_dir)
|
||||
candidates = [tdir]
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass:
|
||||
candidates.append(Path(meipass) / "model")
|
||||
# 用户上传的模板可能在任意目录(无 template_router.py),兜底项目自带 templates/
|
||||
from graph.paths import project_root as _proj_root
|
||||
candidates.append(_proj_root() / "templates")
|
||||
for d in candidates:
|
||||
if d.exists() and str(d) not in sys.path:
|
||||
sys.path.insert(0, str(d))
|
||||
color_col = router.resolve_col("色值(主规格)")
|
||||
block_rows: List[int] = []
|
||||
|
||||
if spu_per_color:
|
||||
# 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充)
|
||||
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province),
|
||||
match="exact",
|
||||
))
|
||||
for ci, (sc, skus) in enumerate(skus_by_color):
|
||||
first = skus[0]
|
||||
color = first.get("color") or sc
|
||||
|
||||
# 该颜色全部尺码 SKU(SKU 行 SPU货号/SKU货号=spu_code,色值区分)
|
||||
for i, sk in enumerate(skus):
|
||||
size = sk.get("size") or f"{i+1}"
|
||||
block_rows.append(router.insert(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=True,
|
||||
bust_headers=bust_headers, price_headers=price_headers),
|
||||
spu_code=spu_code, match="exact",
|
||||
))
|
||||
|
||||
# 轮播图:首色 SKU 行轮播图1 = 生成首图;SKU 行按色值填 db url/生成图
|
||||
if ci == 0 and images:
|
||||
col1 = _carousel_col(router, 1)
|
||||
if col1 is not None:
|
||||
sku_rows = router.find_sku_rows(spu_code)
|
||||
if sku_rows:
|
||||
router.ws.cell(min(sku_rows), col1, str(images[0]))
|
||||
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
|
||||
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
|
||||
else:
|
||||
# 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province), match="exact"))
|
||||
multi_variant = len(skus_by_color) > 1
|
||||
for ci, (sc, skus) in enumerate(skus_by_color):
|
||||
first = skus[0]
|
||||
color = first.get("color") or sc
|
||||
for i, sk in enumerate(skus):
|
||||
size = sk.get("size") or f"{i+1}"
|
||||
block_rows.append(router.insert(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=multi_variant,
|
||||
bust_headers=bust_headers, price_headers=price_headers),
|
||||
))
|
||||
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
|
||||
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
|
||||
|
||||
# 设计联动字段:货号/标题/轮播图路由/详情图文(图床链接,| 分割)
|
||||
if oss_code or cn_title or en_title or ja_title or es_title or composite_urls:
|
||||
by_sku: Dict[str, Any] = {}
|
||||
all_urls: List[str] = []
|
||||
for cc in (composite_urls or []):
|
||||
if cc.get("color"):
|
||||
by_sku[str(cc["color"]).strip()] = cc
|
||||
if cc.get("url"):
|
||||
all_urls.append(str(cc["url"]))
|
||||
_fill_design_fields(router, spu_code, oss_code, cn_title, en_title, ja_title,
|
||||
es_title, by_sku, all_urls, seed_shot_urls or [],
|
||||
only_rows=block_rows)
|
||||
|
||||
return block_rows
|
||||
|
||||
|
||||
def export_product(
|
||||
db_path,
|
||||
spu_code: str,
|
||||
sku_code, # str | List[str]:单颜色或多个颜色
|
||||
template_dir: str,
|
||||
template_path: str,
|
||||
out_path: str,
|
||||
images: Optional[List[str]] = None,
|
||||
spu_per_color: bool = True,
|
||||
oss_code: str = "",
|
||||
cn_title: str = "",
|
||||
en_title: str = "",
|
||||
ja_title: str = "",
|
||||
es_title: str = "",
|
||||
composite_urls: Optional[List[Dict[str, Any]]] = None,
|
||||
seed_shot_urls: Optional[List[str]] = None,
|
||||
append_to: str = "",
|
||||
markup_percent: float = 0.0,
|
||||
) -> Path:
|
||||
"""生成商品上传"已填写"模板(支持多产品合并到同一文件)。
|
||||
|
||||
sku_code :SKU 颜色编码,支持单个 str 或多个(list/tuple/逗号分隔字符串)。
|
||||
spu_per_color :True(默认)= 每个颜色导出一个 SPU 块;False = 单 SPU 下挂所有颜色 SKU 变体。
|
||||
template_dir :template_router.py 所在目录(用于 import)
|
||||
template_path :商品上传模版 xlsx 路径
|
||||
images :生成的产品图路径列表(仅作用于第一个颜色块:SPU 行轮播图1 + SKU 行回退)
|
||||
oss_code :设计货号(前缀+3位计数),SPU货号/SKU货号 列均填它
|
||||
cn_title :商品名称(中文标题)
|
||||
en_title :英文名称(英文标题)
|
||||
ja_title :日语名称(日语标题,JP 模板生成)
|
||||
es_title :西语名称(西班牙语标题,ES 模板生成)
|
||||
composite_urls:[{"sku_code","color","url","code"}] 每色三合一主图(含图床链接与货号)
|
||||
seed_shot_urls :种草图图床链接列表(详情图文 | 拼接用)
|
||||
append_to :已有输出文件路径;提供则在其基础上追加本产品块(一次任务多产品合并一个模板)
|
||||
markup_percent :加价百分比,申报价格 = SKU.price × (1+markup/100) 预填
|
||||
返回输出文件路径。
|
||||
"""
|
||||
_import_router(template_dir)
|
||||
from template_router import TemplateRouter # noqa: E402
|
||||
|
||||
# append_to:合并模式从已有输出文件继续追加(一次任务多产品填一个模板)
|
||||
router = TemplateRouter(append_to if append_to else template_path)
|
||||
try:
|
||||
origin_province, warehouses = _read_meta(router)
|
||||
price_header = _find_price_header(router) # 申报价格列(美站/日站/英站…模糊匹配)
|
||||
multi = len(skus_by_color) > 1
|
||||
color_col = router.resolve_col("色值(主规格)")
|
||||
block_rows: List[int] = [] # 本产品块插入的所有行号(_fill_design_fields 只填这些行)
|
||||
price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填)
|
||||
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
|
||||
_insert_product_block(router, db_path, spu_code, sku_code,
|
||||
origin_province, warehouses, price_headers,
|
||||
markup_percent=markup_percent, images=images,
|
||||
spu_per_color=spu_per_color,
|
||||
oss_code=oss_code, cn_title=cn_title, en_title=en_title,
|
||||
ja_title=ja_title, es_title=es_title,
|
||||
composite_urls=composite_urls,
|
||||
seed_shot_urls=seed_shot_urls,
|
||||
bust_headers=bust_headers)
|
||||
out = router.save(out_path)
|
||||
return Path(out)
|
||||
finally:
|
||||
try:
|
||||
router.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if spu_per_color:
|
||||
# 3) 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充)
|
||||
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province),
|
||||
match="exact",
|
||||
))
|
||||
for ci, (sc, skus) in enumerate(skus_by_color):
|
||||
first = skus[0]
|
||||
color = first.get("color") or sc
|
||||
|
||||
# 该颜色全部尺码 SKU(SKU 行 SPU货号/SKU货号=spu_code,色值区分)
|
||||
for i, sk in enumerate(skus):
|
||||
size = sk.get("size") or f"{i+1}"
|
||||
block_rows.append(router.insert(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=True,
|
||||
price_header=price_header),
|
||||
spu_code=spu_code, match="exact",
|
||||
))
|
||||
def export_products(
|
||||
db_path,
|
||||
products: List[Dict[str, Any]],
|
||||
template_dir: str,
|
||||
template_path: str,
|
||||
out_path: str,
|
||||
markup_percent: float = 0.0,
|
||||
) -> Path:
|
||||
"""批量合并导出:所有产品一次性写入同一模板,只打开/保存一次。
|
||||
|
||||
# 3.3) 轮播图:首色 SKU 行轮播图1 = 生成首图;SKU 行按色值填 db url/生成图
|
||||
if ci == 0 and images:
|
||||
col1 = _carousel_col(router, 1)
|
||||
if col1 is not None:
|
||||
sku_rows = router.find_sku_rows(spu_code)
|
||||
if sku_rows:
|
||||
router.ws.cell(min(sku_rows), col1, str(images[0]))
|
||||
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
|
||||
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
|
||||
else:
|
||||
# 4) 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province), match="exact"))
|
||||
multi_variant = len(skus_by_color) > 1
|
||||
for ci, (sc, skus) in enumerate(skus_by_color):
|
||||
first = skus[0]
|
||||
color = first.get("color") or sc
|
||||
for i, sk in enumerate(skus):
|
||||
size = sk.get("size") or f"{i+1}"
|
||||
block_rows.append(router.insert(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=multi_variant,
|
||||
price_header=price_header),
|
||||
))
|
||||
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
|
||||
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
|
||||
# 无 SPU 行:首图(轮播图1)由 _fill_design_fields 按 SKU 行填充
|
||||
products 每项字段:spu_code / sku_codes(str 或 list)/ oss_code / cn_title / en_title /
|
||||
ja_title / es_title / composite_urls / seed_shot_urls / images / spu_per_color。
|
||||
相比逐产品调用 export_product(每次全量读写工作簿),批量模式显著提速。
|
||||
"""
|
||||
_import_router(template_dir)
|
||||
from template_router import TemplateRouter # noqa: E402
|
||||
|
||||
# 5) 设计联动字段:货号/标题/轮播图路由/详情图文(图床链接,| 分割)
|
||||
if oss_code or cn_title or en_title or ja_title or composite_urls:
|
||||
by_sku: Dict[str, Any] = {}
|
||||
all_urls: List[str] = []
|
||||
for cc in (composite_urls or []):
|
||||
if cc.get("color"):
|
||||
by_sku[str(cc["color"]).strip()] = cc
|
||||
if cc.get("url"):
|
||||
all_urls.append(str(cc["url"]))
|
||||
_fill_design_fields(router, spu_code, oss_code, cn_title, en_title, ja_title,
|
||||
by_sku, all_urls, seed_shot_urls or [], only_rows=block_rows)
|
||||
|
||||
router = TemplateRouter(template_path)
|
||||
try:
|
||||
origin_province, warehouses = _read_meta(router)
|
||||
price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填)
|
||||
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
|
||||
for r in products:
|
||||
try:
|
||||
_insert_product_block(
|
||||
router, db_path, r.get("spu_code", ""),
|
||||
r.get("sku_codes") or r.get("sku_code") or "",
|
||||
origin_province, warehouses, price_headers,
|
||||
markup_percent=markup_percent,
|
||||
images=r.get("images"),
|
||||
spu_per_color=bool(r.get("spu_per_color", True)),
|
||||
oss_code=r.get("oss_code", ""),
|
||||
cn_title=r.get("cn_title", ""),
|
||||
en_title=r.get("en_title", ""),
|
||||
ja_title=r.get("ja_title", ""),
|
||||
es_title=r.get("es_title", ""),
|
||||
composite_urls=r.get("composite_urls"),
|
||||
seed_shot_urls=r.get("seed_shot_urls"),
|
||||
bust_headers=bust_headers,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[template_export] 产品 {r.get('spu_code')} 写入失败,跳过: {e}")
|
||||
continue
|
||||
out = router.save(out_path)
|
||||
return Path(out)
|
||||
finally:
|
||||
|
||||
+10
-11
@@ -10,18 +10,17 @@ v3:模板按国家区分(COUNTRY_TEMPLATES),每国有自己的设计风
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# —— 通用固定段(所有国家共用,保证结构一致)——
|
||||
# 尺寸规则:最小约 15×18cm ~ 最大 26×32cm 之间自由选择(防模型默认出满幅大图)
|
||||
# 尺寸规则:印花必须「小且居中、四周大量留白」,禁止满幅/自带背景(防模型默认出满幅大图)
|
||||
SIZE_RULE = (
|
||||
"size: choose freely between a MINIMUM print area of about 15x18 cm "
|
||||
"and a MAXIMUM of 26x32 cm, any size in this range fits, "
|
||||
"pick the one that best suits the design, keep proportions, "
|
||||
"scale naturally to the content, do NOT stretch, "
|
||||
"do NOT fill the entire canvas, do NOT force full-bleed, "
|
||||
"leave balanced margins around the artwork"
|
||||
"size: the print artwork must be SMALL and CENTERED with clearly larger white margins "
|
||||
"around it, print area between about 15x18 cm and 26x32 cm, keep proportions, "
|
||||
"scale naturally to the content, do NOT stretch, do NOT fill the entire canvas, "
|
||||
"do NOT force full-bleed, leave wide balanced white margins around the artwork"
|
||||
)
|
||||
# 排除段:无衣服/模特/场景/水印
|
||||
# 排除段:纯白底/无背景/无衣服/模特/场景/水印
|
||||
NEG_FIXED = (
|
||||
"no garment, no shirt, no model, no mannequin, no background scene, no watermark"
|
||||
"isolated on pure white background, no background, no scene, no texture, no frame, no border, "
|
||||
"no garment, no shirt, no model, no mannequin, no watermark"
|
||||
)
|
||||
# 文字规则(v3):可加可不加、适配印花即可;任何文字严禁敏感内容
|
||||
# 注意:正向提示词不写敏感词(no politics/no hate/no violence/no sexual…会被图像审核误判),
|
||||
@@ -71,6 +70,7 @@ DEFAULT_TEMPLATES: Dict[str, str] = {
|
||||
"studio lighting, e-commerce product photo, no human model"
|
||||
),
|
||||
# 复合提示词(三图模特合成):图1=模特 / 图2=纯印花设计稿 / 图3=平铺底图 → 模特穿着成品
|
||||
# 设计已由图2提供,不再附加 DESIGN CONTENT 四要素描述
|
||||
"composite_prompt": (
|
||||
"【图片角色,按提交顺序】图1=模特实拍图(基底);图2=纯印花设计稿;"
|
||||
"图3=平铺衣服底图(颜色/面料来源)。\n"
|
||||
@@ -85,8 +85,7 @@ DEFAULT_TEMPLATES: Dict[str, str] = {
|
||||
"杜绝“贴纸感”与“平面涂色感”。\n"
|
||||
"5.光影融合:按图1环境光方向调整亮度/对比度,印花受光影响产生明暗变化但色号不偏移。\n"
|
||||
"6.纯净输出:仅输出一张最终合成图;图1背景/人物/构图/光影100%不变,"
|
||||
"仅替换衣服印花与底色。\n"
|
||||
"DESIGN CONTENT: {motif}, {art_style}, {color_palette}, {composition}."
|
||||
"仅替换衣服印花与底色。"
|
||||
),
|
||||
# 复合负向(印图专用)
|
||||
"composite_negative": (
|
||||
|
||||
Reference in New Issue
Block a user