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

1) 模板导出:识别「基码表-胸围」填 sku.bust(多个胸围列都填);申报价格模糊匹配多列统一按加价后价格填写;详情图文不再拼接 img_url_2;SPU 款式来源统一填「现货款」;商品产地国家简称映射(沙特→沙特阿拉伯)
2) 模特性别分组:model_features 按男女分组,按模板类目含男/女固定取对应性别模特(含 Pinterest 模式 pipeline)
3) 三合一提示词:去掉 DESIGN CONTENT 四要素描述(设计已由设计稿提供)
4) 生图尺寸:全部改为读 config 不再硬编码(设计图 compose.design_size / 合成图 compose.size / 种草图 seed_shot.size)
This commit is contained in:
2026-08-26 18:05:11 +08:00
parent e317547b8b
commit b7f429db89
93 changed files with 2708 additions and 583 deletions
+62 -13
View File
@@ -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)
+1 -1
View File
@@ -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:
+3 -2
View File
@@ -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:
+10 -3
View File
@@ -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
+6 -2
View File
@@ -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",
+107 -25
View File
@@ -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 路由到对应模板。
模板 1US/GB/AU/MX)返回 {"en_title","cn_title"}
模板 2(JP)额外返回 {"ja_title"}
模板 2(JP)额外返回 {"ja_title"}
模板 3ES)返回 {"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
View File
@@ -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})
+222 -50
View File
@@ -1,28 +1,71 @@
"""Pinterest 参考模式节点 3/3LLM 多模态分析图片 → 原创设计简报(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_indexmock 兜底)→ 回退按顺序;无效/越界/重复 → 丢弃该简报(避免错位)。
这样 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}
+47
View File
@@ -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.jsonproduct 节点原职责)
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}
+15
View File
@@ -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}
+69 -9
View File
@@ -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,请先开启代理/VPNClash/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}
+68 -52
View File
@@ -1,7 +1,8 @@
"""Pinterest 参考模式节点 1/3LLM 生成搜索词(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
View File
@@ -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
+17
View File
@@ -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)):
+25 -20
View File
@@ -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")))
+29 -24
View File
@@ -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
View File
@@ -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)
+589
View File
@@ -0,0 +1,589 @@
"""Pinterest 简报池 + 并发生成流水线。
分析节点产出简报后立即推入简报池,后台 worker 逐条并发生成:
生成设计(compose) → 三合一(product) → OSS上传 → 生成种草图(seed_shot)
不等全部分析完,边分析边生成,显著缩短总耗时。
集成:
pinterest_init 创建 PinterestPipeline(存 state["pinterest_pipeline"]
pinterest_analyze 每批产出简报 → pipe.add_briefs(new_briefs)
pinterest_finalize → pipe.finish()(排空 + 合并产品 + 补写报告)→ template_export
并发上限与 product_node 一致(默认 5),避免压垮图像网关。
"""
import concurrent.futures
import re
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from graph.paths import project_root, runtime_root
class PinterestPipeline:
def __init__(self, state: Dict[str, Any]):
self.config = state["config"]
self.country = state.get("country", "")
self.output_dir = Path(state["output_dir"])
self.cache_dir = Path(state.get("cache_dir") or self.output_dir)
self.task_timestamp = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S"))
# 简报池 + 信号
self._lock = threading.Lock()
self._cond = threading.Condition(self._lock)
self._briefs: List[Dict[str, Any]] = []
self._done = False
self._cursor = 0
# 结果
self._products: List[Dict[str, Any]] = []
self._products_lock = threading.Lock()
self._errors: List[Dict[str, Any]] = []
self._errors_lock = threading.Lock()
# 路径解析(复用 product_node 的 _abs 逻辑:运行根优先,其次数据根)
pcfg = self.config.get("product") or {}
def _abs(key: str, default: str) -> Path:
p = Path(pcfg.get(key, default))
if p.is_absolute():
return p
for root in (runtime_root(), project_root()):
cand = root / p
if cand.exists():
return cand
return project_root() / p
self._db_path = _abs("db_path", "db/spu_sku.db")
self._basemap_root = _abs("basemap_dir", "basemap")
self._material_root = _abs("material_library_dir", "material_library")
self._category = pcfg.get("model_category", "T-shirt")
self._prefix = str(pcfg.get("code_prefix") or "DG").strip()
# 任务清单(spu_tasks → [(spu, skus)],简报按序号绑定)
self._worklist = self._build_worklist()
# 图像后端(compose + product 共用)/ 标题后端
self._ib = self._init_image_backend()
self._title_backend = self._init_title_backend()
# 分析后端(失败/侵权时从图池补充图片重新分析用)
self._analyze_backend = self._init_analyze_backend()
self._country_config = state.get("country_config") or {}
# 补充重试次数:设计生成失败/侵权时,从图池取新图重新分析的最多尝试次数
self._supply_attempts = int((self.config.get("pinterest") or {}).get("supply_attempts", 3))
# 400 计数(per 种子词):多模态 + 生图模型合计,超限放弃当前种子词
self._err400_lock = threading.Lock()
self._err400_count = 0
self._err400_limit = int((self.config.get("pinterest") or {}).get("err400_limit", 15))
self._err400_aborted = False
self._err400_term = ""
# 模特分配(一个 SPU 一个模特,SPU>模特数循环兜底)
self._model_assign = self._assign_models()
# 材质映射(seed_shot 用)
self._material_map = self._load_materials()
# 类目 → 性别(seed_shot 用):模版「类目」表头值含「男」→ 男模;含「女」→ 女模;都不含 → 全部随机
self._gender = None
tp = str((pcfg.get("template_path") or "") or "").strip()
if tp:
from graph.seed_shot import read_template_category, gender_from_category
category = read_template_category(tp)
self._gender = gender_from_category(category)
if self._gender:
print(f"[pinterest_pipeline] 类目「{category[:30]}…」含{'' if self._gender == 'male' else ''} → 固定 {self._gender} 模特")
elif category:
print(f"[pinterest_pipeline] 类目「{category[:30]}…」无男/女 → 男女模特随机")
# OSS
self._oss_cfg = self.config.get("oss") or {}
self._oss_enabled = bool(self._oss_cfg.get("enabled", True)) and bool(
self._oss_cfg.get("oss_bucket"))
self.oss_seq = int(state.get("oss_seq") or 0)
self._oss_lock = threading.Lock()
# 并发线程池(默认 5,与 product_node 上限一致)
concurrency = int(pcfg.get("concurrency") or 0) or 5
self._pool = concurrent.futures.ThreadPoolExecutor(max_workers=concurrency)
# 分发线程:拉简报 → 逐条提交线程池
self._dispatcher = threading.Thread(target=self._dispatch, daemon=True)
self._dispatcher.start()
print(f"[pinterest_pipeline] 简报池启动:{len(self._worklist)} 个产品任务,"
f"并发 {concurrency}{self.country}")
# ------------------------------------------------------------------ #
# 初始化辅助
# ------------------------------------------------------------------ #
def _build_worklist(self) -> List[tuple]:
pcfg = self.config.get("product") or {}
spu_tasks = pcfg.get("spu_tasks") or []
worklist: List[tuple] = []
if not spu_tasks:
return worklist
try:
from graph.product import list_spus
spus = list_spus(str(self._db_path))
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] SPU 读取失败: {e}")
spus = []
for t in spu_tasks:
code = (t.get("spu") or t.get("spu_code") or "").strip()
spu = next((s for s in spus if s["code"] == code), None)
if spu is None:
print(f"[pinterest_pipeline] 任务款号 {code} 不在 db,跳过")
continue
worklist.append((spu, (t.get("skus") or "").strip()))
return worklist
def _init_image_backend(self):
compose_cfg = self.config.get("compose") or {}
backend_name = (compose_cfg.get("backend") or "").strip()
if not backend_name:
return None
try:
from graph.backends import get_image_backend
ib = get_image_backend(backend_name)
if ib is not None:
ib.bind_config(compose_cfg)
return ib
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 图像后端不可用: {e}")
return None
def _init_title_backend(self):
ls_cfg = self.config.get("llm_screen") or {}
if (ls_cfg.get("provider") or "") in ("", "mock"):
return None
try:
from graph.llms import get_backend as _glb
tb = _glb(ls_cfg.get("provider"))
tb.bind_config(ls_cfg)
if getattr(tb, "has_key", False):
return tb
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 标题后端初始化失败: {e}")
return None
def _init_analyze_backend(self):
"""图片分析后端(失败/侵权时从图池补充图片重新分析用)。"""
pcfg = self.config.get("pinterest") or {}
provider = str(pcfg.get("provider") or "openai").strip().lower()
if provider == "static":
return None
try:
from graph.llms import get_backend
llm = get_backend(provider)
if hasattr(llm, "bind_config"):
llm.bind_config(self.config.get("llm_screen") or {})
if provider not in ("mock",) and not getattr(llm, "has_key", False):
print(f"[pinterest_pipeline] {provider} 未配置 API key,降级 mock")
llm = get_backend("mock")
return llm
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 分析后端初始化失败: {e}")
return None
def _assign_models(self) -> Dict[str, Any]:
model_assign: Dict[str, Any] = {}
try:
from graph.product import find_first_model_folder
_folder, _all_models = find_first_model_folder(self._material_root, self._category)
except Exception: # noqa: BLE001
_all_models = []
if _all_models:
seen: Dict[str, str] = {}
for _i, (spu, _skus) in enumerate(self._worklist):
code = spu.get("code", "")
if code not in seen:
seen[code] = _all_models[_i % len(_all_models)]
model_assign[code] = seen[code]
return model_assign
def _load_materials(self) -> Dict[str, str]:
material_map: Dict[str, str] = {}
try:
from graph.product import list_spus
for s in list_spus(str(self._db_path)):
m = " ".join(str(s.get("material", "")).replace("\r", " ").replace("\n", " ").split())
material_map[s["code"]] = m
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 材质读取失败(用空): {e}")
return material_map
# ------------------------------------------------------------------ #
# 对外接口
# ------------------------------------------------------------------ #
def reset_400(self, term: str = "") -> None:
"""新一批图爬取完成后调用:重置 400 计数并记录当前种子词。"""
with self._err400_lock:
self._err400_count = 0
self._err400_aborted = False
self._err400_term = term or ""
def record_400(self) -> bool:
"""记录一次 400(含内容/图片)。返回 True 表示本次触发放弃当前种子词。"""
with self._err400_lock:
self._err400_count += 1
if self._err400_count > self._err400_limit and not self._err400_aborted:
self._err400_aborted = True
return True
return False
def is_400_aborted(self) -> bool:
with self._err400_lock:
return self._err400_aborted
def _abort_current_term(self) -> None:
"""放弃当前种子词:清空其未完成简报 + 图池未消费图片(已完成的保留)。"""
term = self._err400_term
with self._cond:
kept = [b for b in self._briefs if not self._brief_of_term(b, term)]
dropped = len(self._briefs) - len(kept)
self._briefs = kept
if dropped:
print(f"[pinterest_pipeline] 放弃「{term}」未完成简报 {dropped}")
self._drop_term_pool(term)
@staticmethod
def _brief_of_term(b: Dict[str, Any], term: str) -> bool:
"""简报是否属于某种子词(topic 去掉 #N 后缀后 == term)。"""
if not term:
return False
topic = str(b.get("topic") or "").strip()
base = re.sub(r"\s+#\d+$", "", topic).strip().lower()
return bool(base) and base == term.strip().lower()
def _drop_term_pool(self, term: str) -> None:
"""清除图池中属于当前种子词的图片,并把它们的 md5 全部拉黑(used_images.json)。
400 超限说明这批图反复触发内容/图片 400,整批拉黑防止下次重新爬取到相同图再次触发。
"""
if not term:
return
try:
from graph.pinterest import (
load_image_pool, save_image_pool,
load_used_images, save_used_images,
)
pool = load_image_pool(str(self.output_dir), self.country)
imgs = pool.get("images") or []
term_imgs = [img for img in imgs
if str(img.get("term") or "").strip().lower() == term.strip().lower()]
kept = [img for img in imgs if img not in term_imgs]
if len(kept) < len(imgs):
pool["images"] = kept
save_image_pool(str(self.output_dir), self.country, pool)
print(f"[pinterest_pipeline] 清除图池「{term}」图片 {len(imgs) - len(kept)}")
md5s = [str(img.get("md5") or "").strip().lower() for img in term_imgs]
md5s = [m for m in md5s if m]
if md5s:
used = load_used_images(str(self.output_dir), self.country)
before = len(used)
used.update(md5s)
if len(used) > before:
save_used_images(str(self.output_dir), self.country, used)
print(f"[pinterest_pipeline] 400 超限:拉黑「{term}」图片 md5 {len(md5s)}")
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 清除图池失败: {e}")
def add_briefs(self, briefs: List[Dict[str, Any]]) -> None:
if not briefs:
return
with self._cond:
self._briefs.extend(briefs)
self._cond.notify_all()
print(f"[pinterest_pipeline] 简报池 +{len(briefs)} 条(待处理 {len(self._briefs)}")
def finish(self) -> tuple:
"""排空简报池、等待全部产品完成,返回 (products, errors)。"""
with self._cond:
self._done = True
self._cond.notify_all()
self._dispatcher.join()
self._pool.shutdown(wait=True)
with self._products_lock:
products = list(self._products)
with self._errors_lock:
errors = list(self._errors)
print(f"[pinterest_pipeline] 收尾:完成 {len(products)} 个产品,错误 {len(errors)}")
return products, errors
# ------------------------------------------------------------------ #
# 后台线程
# ------------------------------------------------------------------ #
def _dispatch(self) -> None:
while True:
with self._cond:
while not self._briefs and not self._done:
self._cond.wait()
if self._done and not self._briefs:
break
batch = self._briefs
self._briefs = []
for b in batch:
with self._cond:
idx = self._cursor
self._cursor += 1
self._pool.submit(self._process_one, b, idx)
# ------------------------------------------------------------------ #
# 单条简报完整链路:设计 → 三合一 → OSS → 种草图
# ------------------------------------------------------------------ #
def _process_one(self, brief: Dict[str, Any], idx: int) -> None:
try:
# 0) 先定货号:整条链路(设计/三合一/种草图)都用它命名与匹配,避免序号错位
if idx >= len(self._worklist):
print(f"[pinterest_pipeline] 简报 {idx} 无对应产品任务,跳过")
return
spu, skus = self._worklist[idx]
img_code = f"{self._prefix}{idx:03d}"
# 1) 生成设计(compose)——直接按货号命名 designs/{img_code}_design.png
design_path = self._gen_design(brief, img_code)
if self.is_400_aborted():
# 当前种子词 400 超限已放弃:正在生成的当个也放弃,不进入后续链路
print(f"[pinterest_pipeline] 当前种子词 400 超限已放弃,跳过简报 {idx}")
return
if not design_path:
# 生成失败/侵权(MD5 全局重复/API 错误)→ 从图池补充图片重新分析,最多尝试 N 次;
# 图池不足 → 返回 None,由路由在下一轮触发搜索
for _ in range(self._supply_attempts):
new_brief = self._supply_from_pool(
reason=f"简报「{brief.get('topic','')}」设计生成失败")
if new_brief is None:
return
brief = new_brief
design_path = self._gen_design(brief, img_code)
if design_path:
break
if not design_path:
return
brief["design_path"] = design_path
# 2) 三合一(product)——同一货号
prod = self._process_spu(brief, spu, skus, img_code, design_path)
if not prod:
return
# 3) OSS 上传
self._upload_product(prod)
# 4) 种草图——同一货号
self._seed_shot(prod)
# 去重记录
try:
from graph.nodes.product_node import _record_used
_record_used(self.cache_dir, prod)
except Exception: # noqa: BLE001
pass
with self._products_lock:
self._products.append(prod)
print(f"[pinterest_pipeline] 产品完成: {prod.get('img_code', '')}"
f"(累计 {len(self._products)}")
except Exception as e: # noqa: BLE001
with self._errors_lock:
self._errors.append({"node": "pinterest_pipeline", "type": type(e).__name__,
"message": f"简报 {idx} 处理失败: {e}", "trace": ""})
print(f"[pinterest_pipeline] 简报 {idx} 处理失败: {e}")
def _gen_design(self, brief: Dict[str, Any], img_code: str) -> Optional[str]:
if self._ib is None:
return None
try:
from graph.nodes.compose_node import generate_design
design_dir = self.output_dir / "designs"
design_dir.mkdir(parents=True, exist_ok=True)
def _on_400():
if self.record_400():
self._abort_current_term()
return generate_design(self._ib, brief, design_dir, img_code, self._errors,
on_400=_on_400,
size=str((self.config.get("compose") or {}).get("design_size") or "1024x1024"))
except Exception as e: # noqa: BLE001
with self._errors_lock:
self._errors.append({"node": "compose", "type": type(e).__name__,
"message": f"设计稿生成失败 {brief.get('topic', '')}: {e}",
"trace": ""})
return None
def _supply_from_pool(self, reason: str) -> Optional[Dict[str, Any]]:
"""生成失败/侵权时,从图池取一张未消费图片重新分析,产出新简报。
图池不足 → 返回 None(由路由在下一轮触发搜索)。该图片分析后 md5 一律拉黑
(合适/不合适都拉黑),避免重复分析。
"""
from graph.pinterest import (
compress_image,
load_image_pool,
load_used_images,
pool_unused_images,
save_used_images,
)
pool = load_image_pool(str(self.output_dir), self.country)
used = load_used_images(str(self.output_dir), self.country)
unused = pool_unused_images(pool, used)
if not unused:
print(f"[pinterest_pipeline] 图池无未消费图片,无法补充({reason}),等待路由搜索")
return None
img = unused[0]
compressed = compress_image(img["path"])
llm = self._analyze_backend
if llm is None or not hasattr(llm, "analyze_pinterest_images"):
return None
try:
def _on_400():
if self.record_400():
self._abort_current_term()
res = llm.analyze_pinterest_images([compressed], img.get("term", ""), self.country,
on_400=_on_400) or []
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 补充分析失败: {e}")
res = []
# 该图片已消费 → 拉黑(合适/不合适都拉黑)
if img.get("md5"):
used.add(str(img["md5"]).lower())
save_used_images(str(self.output_dir), self.country, used)
if not res or not isinstance(res[0], dict):
return None
b = res[0]
from graph.nodes.pinterest_analyze_node import _brief_suitable
if not _brief_suitable(b):
print(f"[pinterest_pipeline] 补充简报侵权/不适合印花,丢弃: {b.get('topic','')}")
return None
b["ref_images"] = [img["path"]]
b["source_md5"] = str(img.get("md5") or "").strip().lower()
try:
from graph.nodes.pinterest_analyze_node import _enrich_briefs
from graph.nodes.prompt_node import prompt_node
screened = _enrich_briefs([b], self.country)
if not screened:
return None
r = prompt_node({
"config": self.config, "country": self.country,
"country_config": self._country_config, "screened": screened,
})
new_briefs = r.get("briefs") or []
if new_briefs:
print(f"[pinterest_pipeline] 图池补充成功({reason}: {new_briefs[0].get('topic','')}")
return new_briefs[0]
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 补充简报装配失败: {e}")
return None
def _process_spu(self, brief: Dict[str, Any], spu, skus: str, img_code: str,
design_path: str) -> Optional[Dict[str, Any]]:
from graph.nodes.product_node import _process_spu as _ps
prod_dir = self.output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
# 设计稿已按货号命名(designs/{img_code}_design.png),直接复用,无需再拷贝
brief = dict(brief)
brief["design_path"] = design_path
r = _ps(self._db_path, self._basemap_root, self._material_root, self._category,
prod_dir, brief, self._ib, spu, skus, self.config.get("product") or {},
self._errors, design_path, self._title_backend, self.country,
img_code=img_code, model_img=self._model_assign.get(spu.get("code", "")))
if r:
r["img_code"] = img_code
return r
def _upload_product(self, r: Dict[str, Any]) -> None:
if not self._oss_enabled:
return
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
from graph.nodes.oss_upload_node import KIND_ORDER, _gen_rand4
# 货号直接取产品 img_code:同一货号的所有图片(合成/平铺/底图)共用同一货号,
# 避免独立计数器在有产品被跳过时与 img_code 错位
base_code = r.get("img_code") or r.get("oss_code") or ""
if not base_code:
return
for kind in KIND_ORDER:
src = r.get(f"{kind}_path")
if not src or not Path(src).exists():
continue
try:
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(self.country, self.task_timestamp, base_code, _gen_rand4())
url = upload_to_oss(self._oss_cfg, compressed, key)
if url:
r[f"{kind}_url"] = url
r["oss_code"] = base_code
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] OSS 上传失败 {src}: {e}")
# 多色:首色用主图 url/code,额外色单独上传(同一货号)
comps = r.get("color_composites") or []
if comps and r.get("composite_url"):
comps[0]["url"] = r["composite_url"]
comps[0]["code"] = r.get("oss_code", "")
for cc in comps[1:]:
src = cc.get("composite_path")
if not src or not Path(src).exists():
continue
try:
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(self.country, self.task_timestamp, base_code, _gen_rand4())
url = upload_to_oss(self._oss_cfg, compressed, key)
if url:
cc["url"] = url
cc["code"] = base_code
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] OSS 颜色图上传失败 {src}: {e}")
def _seed_shot(self, r: Dict[str, Any]) -> None:
ss_cfg = self.config.get("seed_shot") or {}
count = int(ss_cfg.get("count", 1))
if count <= 0 or not bool(ss_cfg.get("enabled", True)) or self._ib is None:
return
from graph.nodes.seed_shot_node import _plan_seed_shots
from graph.seed_shot import generate_seed_shots
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
from graph.nodes.oss_upload_node import MAX_CODE, _gen_rand4
comps = r.get("color_composites") or []
if not comps and r.get("composite_path") and Path(r["composite_path"]).exists():
comps = [{"sku_code": r.get("sku_code"), "color": r.get("color", ""),
"composite_path": r["composite_path"]}]
if not comps:
return
plan = _plan_seed_shots(comps, count)
cn = (r.get("cn_title") or "").strip() or r.get("topic", "")
material = self._material_map.get(r.get("spu_code", ""), "")
base_prefix = r.get("img_code") or r.get("oss_code") or ""
pfx = base_prefix or "seed"
shot_dir = self.output_dir / "seed_shots"
shot_dir.mkdir(parents=True, exist_ok=True)
size = str(ss_cfg.get("size") or "1536x2048")
paths: List[str] = []
for cc, n in plan:
base = cc.get("composite_path")
if not base or not Path(base).exists():
print(f"[pinterest_pipeline] {r.get('spu_code', '')} 参考图缺失,跳过该色种草图")
continue
generated = generate_seed_shots(self._ib, base, cn, material, n, str(shot_dir),
r.get("composite_negative", ""),
size=size, prefix=pfx, gender=self._gender)
paths.extend(generated)
if not paths:
return
r["seed_shot_paths"] = paths
urls: List[str] = []
for pth in paths:
with self._oss_lock:
if not base_prefix:
if self.oss_seq >= MAX_CODE:
break
code = f"{self._prefix}{self.oss_seq:03d}"
self.oss_seq += 1
else:
code = base_prefix
if self._oss_enabled:
try:
compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg")))
url = upload_to_oss(self._oss_cfg, compressed,
build_oss_key(self.country, self.task_timestamp,
code, _gen_rand4()))
if url:
urls.append(url)
r["seed_shot_urls"] = urls
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 种草图上传失败 {pth}: {e}")
+23 -3
View File
@@ -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
View File
@@ -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)
+6
View File
@@ -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
View File
@@ -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
# 该颜色全部尺码 SKUSKU 行 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
# 该颜色全部尺码 SKUSKU 行 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_codesstr 或 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
View File
@@ -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": (