新增 Pinterest 参考模式:独立于 Google Trends 的完整链路(12国种子词池 / LLM搜索词json_schema+防重复+已用词限100 / 并发爬图 / 多模态分析→原创简报 / 生图带爬取图参考图生图 / UI流程选择)

This commit is contained in:
2026-08-24 15:02:01 +08:00
parent 2144c36e60
commit 309d4a520c
30 changed files with 2076 additions and 23 deletions
+76
View File
@@ -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)
+43
View File
@@ -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)]
+265
View File
@@ -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_schemastrict 结构化输出);部分兼容厂商不支持 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)时,
+6
View File
@@ -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",
]
+29 -6
View File
@@ -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
+148
View File
@@ -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}
+80
View File
@@ -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}
+103
View File
@@ -0,0 +1,103 @@
"""Pinterest 参考模式节点 1/3LLM 生成搜索词(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}
+116
View File
@@ -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
+5
View File
@@ -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}