新增 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
+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)时,