新增 Pinterest 参考模式:独立于 Google Trends 的完整链路(12国种子词池 / LLM搜索词json_schema+防重复+已用词限100 / 并发爬图 / 多模态分析→原创简报 / 生图带爬取图参考图生图 / UI流程选择)
This commit is contained in:
@@ -58,6 +58,39 @@ def build_graph():
|
||||
return builder.compile()
|
||||
|
||||
|
||||
def build_pinterest_graph():
|
||||
"""Pinterest 参考模式图(独立于 Google Trends 采集链路):
|
||||
pinterest_search → pinterest_scrape → pinterest_analyze → compose → product
|
||||
→ oss_upload → seed_shot → template_export
|
||||
"""
|
||||
from graph.nodes import (
|
||||
pinterest_analyze_node,
|
||||
pinterest_scrape_node,
|
||||
pinterest_search_node,
|
||||
)
|
||||
|
||||
builder = StateGraph(AgentState)
|
||||
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("template_export", template_export_node)
|
||||
|
||||
builder.add_edge("__start__", "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_edge("template_export", END)
|
||||
return builder.compile()
|
||||
|
||||
|
||||
def run_country(
|
||||
country: str,
|
||||
global_config: Dict[str, Any],
|
||||
@@ -109,3 +142,46 @@ def run_country(
|
||||
|
||||
result = compiled.invoke(state)
|
||||
return result
|
||||
|
||||
|
||||
def run_pinterest_ref(
|
||||
country: str,
|
||||
global_config: Dict[str, Any],
|
||||
project_root: Path,
|
||||
output_root: Optional[Path] = None,
|
||||
task_timestamp: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Pinterest 参考模式入口:独立于 Google Trends 的完整流程。
|
||||
|
||||
种子词 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图 → LLM 分析图片
|
||||
→ 设计简报 → 设计稿 → 产品图 → 上传 → 种草图 → 模板导出。
|
||||
参数语义与 run_country 一致(project_root=数据根,output_root=产物根)。
|
||||
"""
|
||||
compiled = build_pinterest_graph()
|
||||
cc = build_country_config(global_config, country, project_root)
|
||||
prompts_dir = project_root / "prompts" / country
|
||||
cache_dir = (output_root or project_root) / "output" / country
|
||||
ts = task_timestamp or time.strftime("%Y%m%d_%H%M%S")
|
||||
_base = ts
|
||||
_i = 1
|
||||
while (cache_dir / ts).exists(): # 时间戳文件夹唯一(同秒多任务防冲突/覆盖)
|
||||
ts = f"{_base}_{_i}"
|
||||
_i += 1
|
||||
output_dir = cache_dir / ts
|
||||
|
||||
state: Dict[str, Any] = {
|
||||
"country": country,
|
||||
"config": global_config,
|
||||
"country_config": cc,
|
||||
"prompts_dir": str(prompts_dir),
|
||||
"cache_dir": str(cache_dir),
|
||||
"output_dir": str(output_dir),
|
||||
"briefs": [],
|
||||
"composite": [],
|
||||
"designs": [],
|
||||
"errors": [],
|
||||
"stats": {},
|
||||
"task_timestamp": ts,
|
||||
"oss_seq": 0,
|
||||
}
|
||||
return compiled.invoke(state)
|
||||
|
||||
@@ -145,3 +145,46 @@ class MockBackend:
|
||||
"style_seeds": _dedup_limit(style, max_style),
|
||||
"related_seeds": _dedup_limit(related, max_related),
|
||||
}
|
||||
|
||||
def generate_pinterest_terms(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""规则生成 Pinterest 搜索词(零 API 成本):从种子词池随机取 + 两两组合增加多样性。"""
|
||||
import random
|
||||
seeds = [str(s).strip() for s in (context.get("seeds") or []) if str(s).strip()]
|
||||
used = {str(u).strip().lower() for u in (context.get("used_terms") or [])}
|
||||
count = int(context.get("count", 10))
|
||||
pool = [s for s in seeds if s.lower() not in used]
|
||||
random.shuffle(pool)
|
||||
terms = pool[:count]
|
||||
# 不足时用「种子词 + 风格词」组合补足(视觉导向,避免与已用重复)
|
||||
style_tail = ["aesthetic", "style", "inspiration", "design", "vibe", "art"]
|
||||
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
|
||||
return {"search_terms": terms}
|
||||
|
||||
def analyze_pinterest_images(self, image_paths, term="", country=""):
|
||||
"""规则生成设计简报(零 API 成本):按搜索词启发式推导风格/配色/构图。"""
|
||||
from ..classify import classify, prompt_suggestion
|
||||
cat = classify(term)
|
||||
art_style, palette = derive_style_palette(term, country, category=cat)
|
||||
motif = prompt_suggestion(term, cat).split(" --no ")[0].split(",")[0].strip()
|
||||
composition = derive_composition(term, cat)
|
||||
negative = ("no real people, no likeness of any person, no copyrighted characters, "
|
||||
"no brand logos, no trademarks, no celebrity, no readable text unless safe")
|
||||
n = max(1, len(image_paths or []))
|
||||
paths = list(image_paths or [])
|
||||
return [{
|
||||
"topic": term,
|
||||
"concept": f"(启发式兜底)围绕「{term}」做原创{art_style}风格印花",
|
||||
"motif": motif,
|
||||
"art_style": art_style,
|
||||
"color_palette": palette,
|
||||
"composition": composition,
|
||||
"negative_prompt": negative,
|
||||
# 生图参考:每条简报对应其来源爬取图(mock 按图逐张产出简报,顺序一一对应)
|
||||
"ref_images": [str(paths[i])] if i < len(paths) else [],
|
||||
"source": "pinterest",
|
||||
} for i in range(n)]
|
||||
|
||||
@@ -233,6 +233,118 @@ 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.
|
||||
|
||||
RULES:
|
||||
- Generate EXACTLY the requested number of search terms.
|
||||
- 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.
|
||||
- Use the country's local language where natural (e.g. Japanese for JP, Spanish for ES/MX), else English.
|
||||
- Each term is 2-4 words, concise, no punctuation.
|
||||
- COPYRIGHT-SAFE: no brands, no logos, no characters, no celebrities, no real persons, no franchises.
|
||||
- AVOID: politics, religion, hate, violence, sexual content, alcohol, national flags.
|
||||
|
||||
Return JSON with the field "search_terms" (array of strings)."""
|
||||
|
||||
PINTEREST_TERM_SCHEMA = {
|
||||
"name": "pinterest_search_terms",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search_terms": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Diverse, non-overlapping Pinterest search terms for T-shirt design inspiration",
|
||||
}
|
||||
},
|
||||
"required": ["search_terms"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_pinterest_term_user_prompt(context: Dict[str, Any]) -> str:
|
||||
"""动态注入:种子词(灵感)+ 已用搜索词(禁止重复)+ 数量要求。"""
|
||||
seeds = context.get("seeds", []) or []
|
||||
used = context.get("used_terms", []) or []
|
||||
count = int(context.get("count", 10))
|
||||
lines = [
|
||||
f"Country: {context.get('country', '')}",
|
||||
f"Seed words (inspiration, may combine or extend): {', '.join(seeds)}",
|
||||
"",
|
||||
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.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# —— Pinterest 参考模式:图片分析 → 原创设计简报(多模态)——
|
||||
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
|
||||
T-shirt print design brief that captures that VIBE WITHOUT copying the image.
|
||||
|
||||
RULES:
|
||||
- NEVER copy the image, never reproduce the exact artwork, characters, logos, or any text from it.
|
||||
- Extract only the abstract style/mood/motif concept as inspiration.
|
||||
- Produce an original, flat, print-ready design brief (no garment, no model, no background scene).
|
||||
- COPYRIGHT-SAFE: no brands, no logos, no characters, no celebrities, no real persons, no franchises.
|
||||
- AVOID: politics, religion, hate, violence, sexual content, alcohol, national flags.
|
||||
- motif: English, concrete central subject of the print (e.g. "a smiling cat with a fish", "geometric mountain layers").
|
||||
- art_style: English visual technique (e.g. "clean flat vector", "retro screen print").
|
||||
- color_palette: English colors (e.g. "sunset orange, cream, dusty blue").
|
||||
- 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).
|
||||
|
||||
Return JSON with the field "designs" (array of objects with keys:
|
||||
motif, art_style, color_palette, composition, concept, negative_prompt)."""
|
||||
|
||||
PINTEREST_ANALYZE_SCHEMA = {
|
||||
"name": "pinterest_design_briefs",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"designs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"motif": {"type": "string"},
|
||||
"art_style": {"type": "string"},
|
||||
"color_palette": {"type": "string"},
|
||||
"composition": {"type": "string"},
|
||||
"concept": {"type": "string"},
|
||||
"negative_prompt": {"type": "string"},
|
||||
},
|
||||
"required": ["motif", "art_style", "color_palette", "composition",
|
||||
"concept", "negative_prompt"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["designs"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_pinterest_analyze_user_prompt(term: str, country: str, image_count: int) -> str:
|
||||
return (
|
||||
f"Country: {country}\n"
|
||||
f"Pinterest search term: {term}\n"
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
def call_openai_compatible(cfg, messages, timeout=90):
|
||||
base_url = str(cfg.get("base_url", "https://api.openai.com/v1")).rstrip("/")
|
||||
api_key = cfg.get("api_key", "")
|
||||
@@ -251,6 +363,41 @@ def call_openai_compatible(cfg, messages, timeout=90):
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
def call_openai_compatible_structured(cfg, messages, json_schema, timeout=120):
|
||||
"""调用 LLM 并返回结构化 JSON 文本。
|
||||
|
||||
优先 json_schema(strict 结构化输出);部分兼容厂商不支持 json_schema 时
|
||||
自动回退 json_object(仍要求 JSON)。最终解析交给 _extract_json 兜底。
|
||||
"""
|
||||
base_url = str(cfg.get("base_url", "https://api.openai.com/v1")).rstrip("/")
|
||||
api_key = cfg.get("api_key", "")
|
||||
model = cfg.get("model", "gpt-4o-mini")
|
||||
url = f"{base_url}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": float(cfg.get("temperature", 0.6)),
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": json_schema.get("name", "structured_output"),
|
||||
"strict": True,
|
||||
"schema": json_schema.get("schema", json_schema),
|
||||
},
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=timeout, proxies=NO_PROXY)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
except Exception: # noqa: BLE001 兼容厂商不支持 json_schema → 回退 json_object
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=timeout, proxies=NO_PROXY)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
def _retry(func, max_attempts=4, base_delay=4):
|
||||
last = None
|
||||
for attempt in range(max_attempts):
|
||||
@@ -345,6 +492,124 @@ class OpenAICompatBackend(LLMBackend):
|
||||
_cache_set(cache_key, out)
|
||||
return out
|
||||
|
||||
def generate_pinterest_terms(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""生成 Pinterest 搜索词(json_schema 结构化 + 动态注入已用词防重复)。
|
||||
|
||||
context 字段:country, seeds, used_terms, count。
|
||||
返回 {"search_terms": [str]};失败抛异常由节点兜底(回退种子词)。
|
||||
"""
|
||||
cfg = self._cfg
|
||||
# 防御性上限:已用词最多注入 100 个,防 token 超限(节点层已截断,这里双保险)
|
||||
ctx = dict(context or {})
|
||||
used = [str(u) for u in (ctx.get("used_terms") or []) if str(u)]
|
||||
max_used = int((cfg or {}).get("max_used_terms_in_prompt", 100) or 100)
|
||||
if max_used > 0:
|
||||
ctx["used_terms"] = used[-max_used:]
|
||||
messages = [
|
||||
{"role": "system", "content": PINTEREST_TERM_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_pinterest_term_user_prompt(ctx)},
|
||||
]
|
||||
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()]
|
||||
return {"search_terms": terms}
|
||||
|
||||
def analyze_pinterest_images(self, image_paths: List[str], term: str, country: str = "") -> List[Dict[str, Any]]:
|
||||
"""多模态分析 Pinterest 图片 → 原创设计简报列表。
|
||||
|
||||
图片输入不被模型支持(纯文本模型 400)时自动降级为纯文本分析(仅用搜索词)。
|
||||
失败返回 [],由节点兜底(回退 mock 规则简报)。
|
||||
"""
|
||||
cfg = self._cfg
|
||||
api_key = cfg.get("api_key", "")
|
||||
if not api_key:
|
||||
print("[pinterest_analyze] 未配置 LLM api_key,跳过图片分析")
|
||||
return []
|
||||
base_url = str(cfg.get("base_url") or "https://api.openai.com/v1").rstrip("/")
|
||||
model = cfg.get("model", "gpt-4o-mini")
|
||||
url = f"{base_url}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
|
||||
# 图片 → base64 data URI(多模态输入)
|
||||
data_uris: List[str] = []
|
||||
for p in image_paths:
|
||||
try:
|
||||
import base64 as b64
|
||||
mime = "image/png"
|
||||
if Path(p).suffix.lower() in (".jpg", ".jpeg"):
|
||||
mime = "image/jpeg"
|
||||
data_uris.append(f"data:{mime};base64,{b64.b64encode(Path(p).read_bytes()).decode()}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] 图片读取失败 {p}: {e}")
|
||||
|
||||
def _call(use_images: bool) -> str:
|
||||
user_content: List[Any] = [
|
||||
{"type": "text", "text": build_pinterest_analyze_user_prompt(term, country, len(data_uris))},
|
||||
]
|
||||
if use_images:
|
||||
user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris]
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": PINTEREST_ANALYZE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.5,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": PINTEREST_ANALYZE_SCHEMA["name"],
|
||||
"strict": True,
|
||||
"schema": PINTEREST_ANALYZE_SCHEMA["schema"],
|
||||
},
|
||||
},
|
||||
}
|
||||
try:
|
||||
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
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
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 "")
|
||||
|
||||
raw = ""
|
||||
if data_uris:
|
||||
try:
|
||||
raw = _call(use_images=True)
|
||||
except Exception as e: # noqa: BLE001 纯文本模型不支持图片 → 降级纯文本
|
||||
print(f"[pinterest_analyze] 图片输入失败,降级纯文本分析: {e}")
|
||||
raw = ""
|
||||
if not raw:
|
||||
try:
|
||||
raw = _call(use_images=False)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] 分析失败: {e}")
|
||||
return []
|
||||
try:
|
||||
parsed = _extract_json(raw)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] 解析失败: {e}")
|
||||
return []
|
||||
designs = []
|
||||
for i, d in enumerate(parsed.get("designs") or []):
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
designs.append({
|
||||
"topic": term,
|
||||
"concept": str(d.get("concept", "")).strip(),
|
||||
"motif": str(d.get("motif", "")).strip(),
|
||||
"art_style": str(d.get("art_style", "")).strip(),
|
||||
"color_palette": str(d.get("color_palette", "")).strip(),
|
||||
"composition": str(d.get("composition", "")).strip(),
|
||||
"negative_prompt": str(d.get("negative_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)时,
|
||||
|
||||
@@ -3,6 +3,9 @@ from .compose_node import compose_node
|
||||
from .fetch_node import fetch_node
|
||||
from .filter_node import filter_node
|
||||
from .oss_upload_node import oss_upload_node
|
||||
from .pinterest_analyze_node import pinterest_analyze_node
|
||||
from .pinterest_scrape_node import pinterest_scrape_node
|
||||
from .pinterest_search_node import pinterest_search_node
|
||||
from .product_node import product_node
|
||||
from .prompt_node import prompt_node
|
||||
from .score_node import score_node
|
||||
@@ -23,4 +26,7 @@ __all__ = [
|
||||
"oss_upload_node",
|
||||
"seed_shot_node",
|
||||
"template_export_node",
|
||||
"pinterest_search_node",
|
||||
"pinterest_scrape_node",
|
||||
"pinterest_analyze_node",
|
||||
]
|
||||
|
||||
@@ -157,15 +157,38 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _gen_one(i: int, b: Dict[str, Any]):
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。
|
||||
|
||||
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 = ib.generate(
|
||||
img_prompt,
|
||||
str(design_dir / f"{country}_{i:02d}_design.png"),
|
||||
b.get("composite_negative", ""),
|
||||
size="1024x1024") # 印花设计统一 1024x1024
|
||||
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
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""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 用。
|
||||
|
||||
兜底链:LLM 多模态 → 纯文本降级(后端内部)→ mock 规则简报 → 空列表(下游跳过)。
|
||||
带 with_fallback:任何异常都不中断。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.llms import get_backend
|
||||
from graph.nodes.prompt_node import prompt_node
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[str, Any]]:
|
||||
"""富化原始简报 → screened 格式(唯一 topic / safe / 分类 / 分数),供 prompt_node 装配。
|
||||
|
||||
同一搜索词的多张图会产出多条简报,topic 相同 → 追加序号保证唯一
|
||||
(product_node 按 topic 绑定简报,重复 topic 会互相覆盖)。
|
||||
"""
|
||||
from graph.classify import classify
|
||||
seen_topics: Dict[str, int] = {}
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(raw_briefs):
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
term = str(b.get("topic") or "").strip() or f"pinterest {i + 1}"
|
||||
base = term
|
||||
n = seen_topics.get(base.lower(), 0)
|
||||
seen_topics[base.lower()] = n + 1
|
||||
topic = base if n == 0 else f"{base} #{n + 1}"
|
||||
motif = str(b.get("motif") or "").strip() or term
|
||||
if not motif:
|
||||
continue
|
||||
out.append({
|
||||
"country": country,
|
||||
"topic": topic,
|
||||
"risk_level": "safe",
|
||||
"safe_for_print": True,
|
||||
"suitable_for_print": True,
|
||||
"design_category": classify(term),
|
||||
"concept": str(b.get("concept") or "").strip() or f"围绕「{term}」的原创印花设计",
|
||||
"motif": motif,
|
||||
"art_style": str(b.get("art_style") or "").strip(),
|
||||
"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(),
|
||||
"ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)],
|
||||
"slogan": "",
|
||||
"score": 1.0,
|
||||
"confidence": 1.0,
|
||||
"source": "pinterest",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@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"]
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
pcfg = config.get("pinterest") or {}
|
||||
analyze_per_term = int(pcfg.get("analyze_per_term", 6))
|
||||
max_designs = int(pcfg.get("max_designs", 10))
|
||||
provider = str(pcfg.get("provider") or "openai").strip().lower()
|
||||
|
||||
# 1) LLM 后端(openai → 真多模态;mock → 规则兜底)
|
||||
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_analyze] {provider} 未配置 API key,降级 mock")
|
||||
llm = get_backend("mock")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] LLM 初始化失败: {e}")
|
||||
llm = None
|
||||
|
||||
# 2) 逐搜索词分析图片 → 原始设计简报
|
||||
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
|
||||
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)} 条简报")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "pinterest_analyze", "type": type(e).__name__,
|
||||
"message": f"term[{term}]: {e}", "trace": ""})
|
||||
print(f"[pinterest_analyze] 「{term}」分析失败: {e}")
|
||||
|
||||
# 3) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成)
|
||||
if not raw_briefs and llm is not None:
|
||||
try:
|
||||
for term, paths in images.items():
|
||||
sample = list(paths)[:analyze_per_term]
|
||||
if sample:
|
||||
raw_briefs.extend(llm.analyze_pinterest_images(sample, term, country) or [])
|
||||
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]
|
||||
seen: set = set()
|
||||
uniq: List[Dict[str, Any]] = []
|
||||
for b in raw_briefs:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
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
|
||||
|
||||
# 5) 富化 → screened → prompt_node 装配提示词 → 标准 briefs
|
||||
screened = _enrich_briefs(raw_briefs, country)
|
||||
if not screened:
|
||||
print("[pinterest_analyze] 无有效设计简报,跳过")
|
||||
return {"pinterest_briefs": [], "briefs": [], "errors": errors}
|
||||
|
||||
r = prompt_node({**state, "screened": screened})
|
||||
briefs = r.get("briefs") or []
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_analyze"] = {
|
||||
"provider": provider,
|
||||
"images_analyzed": sum(len(v) for v in images.values()),
|
||||
"briefs": len(briefs),
|
||||
}
|
||||
print(f"[pinterest_analyze] 设计简报 {len(briefs)} 条({country})")
|
||||
return {"pinterest_briefs": raw_briefs, "briefs": briefs, "stats": stats, "errors": errors}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pinterest 参考模式节点 2/3:爬取图片(pinterest_scrape)。
|
||||
|
||||
对 pinterest_search 生成的每个搜索词,调 pinterest_scraper.scraper.scrape_pinterest
|
||||
(Playwright 启动本地 Chrome)搜索 Pinterest 并下载图片到
|
||||
output/pinterest_ref/<国家>/<搜索词>/。
|
||||
|
||||
- 单个搜索词失败(未登录/网络/无结果)跳过,不中断整批。
|
||||
- 并发数由 config.pinterest.scrape_concurrency 控制(每个并发开一个 Chrome 窗口)。
|
||||
- 已爬取过且图片数达标的搜索词跳过(断点续爬,避免重复开 Chrome)。
|
||||
"""
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
def _term_dir(output_dir: str, country: str, term: str) -> Path:
|
||||
safe = "".join(ch for ch in term if ch.isalnum() or ch in "-_ ").strip() or "term"
|
||||
return Path(output_dir) / "pinterest_ref" / country / safe
|
||||
|
||||
|
||||
def _already_scraped(term_dir: Path) -> bool:
|
||||
"""该搜索词已爬取过(目录里已有 ≥1 张图)→ 跳过,避免重复开 Chrome。"""
|
||||
if not term_dir.exists():
|
||||
return False
|
||||
return any(p.is_file() and p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
|
||||
for p in term_dir.iterdir())
|
||||
|
||||
|
||||
@with_fallback("pinterest_scrape")
|
||||
def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
terms: List[str] = state.get("pinterest_search_terms") or []
|
||||
if not terms:
|
||||
print("[pinterest_scrape] 无搜索词,跳过爬取")
|
||||
return {"pinterest_images": {}, "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 {}
|
||||
images_per_term = int(pcfg.get("images_per_term", 40))
|
||||
concurrency = int(pcfg.get("scrape_concurrency", 2))
|
||||
headless = bool(pcfg.get("headless", False))
|
||||
proxy = pcfg.get("proxy") or None
|
||||
|
||||
results: Dict[str, List[str]] = {}
|
||||
skipped: List[str] = []
|
||||
|
||||
def _one(term: str) -> None:
|
||||
term_dir = _term_dir(output_dir, country, term)
|
||||
if _already_scraped(term_dir):
|
||||
skipped.append(term)
|
||||
print(f"[pinterest_scrape] 已爬取过(跳过): {term}")
|
||||
return
|
||||
try:
|
||||
from pinterest_scraper.scraper import scrape_pinterest
|
||||
files = scrape_pinterest(term, count=images_per_term,
|
||||
save_dir=str(term_dir), proxy=proxy, headless=headless)
|
||||
results[term] = files
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "pinterest_scrape", "type": type(e).__name__,
|
||||
"message": f"term[{term}]: {e}", "trace": ""})
|
||||
print(f"[pinterest_scrape] 爬取失败(跳过): {term}: {e}")
|
||||
|
||||
print(f"[pinterest_scrape] 开始爬取 {len(terms)} 个搜索词(并发 {concurrency})…")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, concurrency)) as ex:
|
||||
list(ex.map(_one, terms))
|
||||
|
||||
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,
|
||||
}
|
||||
print(f"[pinterest_scrape] 完成:{len(results)} 个搜索词,共 {total} 张图(跳过 {len(skipped)})")
|
||||
|
||||
return {"pinterest_images": results, "stats": stats, "errors": errors}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Pinterest 参考模式节点 1/3:LLM 生成搜索词(pinterest_search)。
|
||||
|
||||
流程:国家 Pinterest 种子词池 → LLM 生成搜索词(json_schema 结构化 + 动态注入已用词防重复)
|
||||
→ 全局过滤(已用/黑名单/不适合T恤/去重)→ 持久化已用词。
|
||||
|
||||
兜底链:LLM json_schema → json_object → 解析失败/调用失败 → 回退种子词池随机抽样。
|
||||
带 with_fallback:任何异常都不中断,返回空列表由下游跳过。
|
||||
"""
|
||||
import random
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.llms import get_backend
|
||||
from graph.pinterest import (
|
||||
filter_search_terms,
|
||||
load_used_terms,
|
||||
merge_used,
|
||||
sample_seeds,
|
||||
save_used_terms,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("pinterest_search")
|
||||
def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
country = state["country"]
|
||||
config = state["config"]
|
||||
output_dir = state["output_dir"]
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
pcfg = config.get("pinterest") or {}
|
||||
if not pcfg.get("enabled", True):
|
||||
return {"pinterest_search_terms": [], "errors": errors}
|
||||
|
||||
provider = str(pcfg.get("provider") or "openai").strip().lower()
|
||||
want = int(pcfg.get("search_terms_per_run", 10))
|
||||
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) 种子词池(随机抽样)+ 已用搜索词
|
||||
seeds = sample_seeds(country, seed_sample)
|
||||
used = load_used_terms(output_dir, country)
|
||||
if not seeds:
|
||||
print(f"[pinterest_search] {country} 无种子词,跳过搜索词生成")
|
||||
return {"pinterest_search_terms": [], "errors": errors}
|
||||
|
||||
# 2) LLM 生成(json_schema + 动态注入已用词)
|
||||
# 已用词只取最近 N 个(默认 100)注入提示词,防 token 超限;过滤仍用全量。
|
||||
used_llm = used[-max_used_in_prompt:] if max_used_in_prompt > 0 else []
|
||||
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 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 = []
|
||||
|
||||
# 3) 兜底:LLM 无结果 → 种子词池随机抽样
|
||||
if not terms:
|
||||
terms = random.sample(seeds, min(want, len(seeds))) if seeds else []
|
||||
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)
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_search"] = {
|
||||
"provider": provider,
|
||||
"generated": len(terms),
|
||||
"filtered": len(filtered),
|
||||
"used_total": len(new_used),
|
||||
}
|
||||
print(f"[pinterest_search] 搜索词 {len(filtered)} 个(已用累计 {len(new_used)}): "
|
||||
f"{', '.join(filtered[:6])}{'...' if len(filtered) > 6 else ''}")
|
||||
|
||||
return {"pinterest_search_terms": filtered, "stats": stats, "errors": errors}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Pinterest 参考模式共享辅助:种子词加载、已用搜索词持久化、搜索词全局过滤。
|
||||
|
||||
独立于 Google Trends 采集链路,供 pinterest_search / scrape / analyze 节点复用。
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.paths import project_root, runtime_root
|
||||
|
||||
# 不适合 T 恤印花的类目关键词(复用 product_batch 的兜底清单)
|
||||
_UNSUITABLE = re.compile(
|
||||
r"\b(nails?|manicure|pedicure|recipes?|cooking|lottery|jackpot|results?|score|scores?|"
|
||||
r"fixtures?|forecast|weather|temperature|map|directions?|parking|opening hours?|"
|
||||
r"prices?|price|reviews?|jobs?|salary|mortgage|council tax|election|referendum|"
|
||||
r"stock market|exchange rate|gas prices?)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def pinterest_seed_path(country: str) -> Path:
|
||||
for root in (runtime_root(), project_root()):
|
||||
p = root / "configs" / "pinterest" / f"{country}.yaml"
|
||||
if p.exists():
|
||||
return p
|
||||
return Path("configs") / "pinterest" / f"{country}.yaml"
|
||||
|
||||
|
||||
def load_pinterest_seeds(country: str) -> List[str]:
|
||||
"""读国家 Pinterest 种子词池(configs/pinterest/<CC>.yaml 的 seeds)。"""
|
||||
try:
|
||||
import yaml
|
||||
p = pinterest_seed_path(country)
|
||||
if not p.exists():
|
||||
print(f"[pinterest] 未找到种子词配置: {p}")
|
||||
return []
|
||||
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
seeds = [str(s).strip() for s in (data.get("seeds") or []) if str(s).strip()]
|
||||
return seeds
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 种子词加载失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def sample_seeds(country: str, n: int) -> List[str]:
|
||||
"""从国家种子池随机抽取 n 个种子词(不足则全取)。"""
|
||||
seeds = load_pinterest_seeds(country)
|
||||
if not seeds:
|
||||
return []
|
||||
if len(seeds) <= n:
|
||||
return list(seeds)
|
||||
return random.sample(seeds, n)
|
||||
|
||||
|
||||
def used_terms_path(output_dir: str, country: str) -> Path:
|
||||
return Path(output_dir) / "pinterest_ref" / country / "used_search_terms.json"
|
||||
|
||||
|
||||
def load_used_terms(output_dir: str, country: str) -> List[str]:
|
||||
"""读已用搜索词(跨多次运行持久化,供动态注入防重复)。"""
|
||||
try:
|
||||
p = used_terms_path(output_dir, country)
|
||||
if p.exists():
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
return [str(t).strip() for t in (data.get("terms") or []) if str(t).strip()]
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 已用搜索词读取失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def save_used_terms(output_dir: str, country: str, terms: List[str]) -> None:
|
||||
"""持久化已用搜索词(去重保序)。"""
|
||||
try:
|
||||
p = used_terms_path(output_dir, country)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
seen, out = set(), []
|
||||
for t in terms:
|
||||
k = t.strip().lower()
|
||||
if k and k not in seen:
|
||||
seen.add(k)
|
||||
out.append(t.strip())
|
||||
p.write_text(json.dumps({"terms": out}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest] 已用搜索词保存失败: {e}")
|
||||
|
||||
|
||||
def filter_search_terms(terms: List[str], used: List[str], blacklist: List[str]) -> List[str]:
|
||||
"""全局搜索词过滤:剔除已用、黑名单、不适合 T 恤类目、去重(大小写不敏感)。"""
|
||||
used_set = {str(u).strip().lower() for u in used if str(u).strip()}
|
||||
black = [str(b).strip().lower() for b in (blacklist or []) if str(b).strip()]
|
||||
seen, out = set(), []
|
||||
for t in terms:
|
||||
s = str(t).strip()
|
||||
low = s.lower()
|
||||
if not s or low in seen or low in used_set:
|
||||
continue
|
||||
if any(b and b in low for b in black):
|
||||
continue
|
||||
if _UNSUITABLE.search(low):
|
||||
continue
|
||||
seen.add(low)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def merge_used(existing: List[str], new_terms: List[str]) -> List[str]:
|
||||
"""合并已用搜索词(新词追加到末尾,去重保序)。"""
|
||||
seen, out = set(), []
|
||||
for t in list(existing) + list(new_terms):
|
||||
k = str(t).strip().lower()
|
||||
if k and k not in seen:
|
||||
seen.add(k)
|
||||
out.append(str(t).strip())
|
||||
return out
|
||||
@@ -29,6 +29,11 @@ class AgentState(TypedDict, total=False):
|
||||
product: List[Dict[str, Any]] # product 产出:产品图生成(SPU/SKU/底图/印花/模特合成)
|
||||
seed_words: Dict[str, Any] # seed 产出:动态种子词(含 llm_style_seeds / llm_related_seeds)
|
||||
|
||||
# —— Pinterest 参考模式(独立于 Google Trends 采集链路)——
|
||||
pinterest_search_terms: List[str] # pinterest_search 产出:LLM 生成的搜索词
|
||||
pinterest_images: Dict[str, List[str]] # pinterest_scrape 产出:搜索词 → 爬取图片路径列表
|
||||
pinterest_briefs: List[Dict[str, Any]] # pinterest_analyze 产出:LLM 分析图片的原始设计简报
|
||||
|
||||
# —— 可观测性 ——
|
||||
errors: List[Dict[str, Any]] # 各节点兜底捕获的错误:{node, type, message, trace}
|
||||
stats: Dict[str, Any] # 各阶段统计:{fetch, filter, score, screen, prompt, compose}
|
||||
|
||||
Reference in New Issue
Block a user