新增 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
+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}