新增 Pinterest 参考模式:独立于 Google Trends 的完整链路(12国种子词池 / LLM搜索词json_schema+防重复+已用词限100 / 并发爬图 / 多模态分析→原创简报 / 生图带爬取图参考图生图 / UI流程选择)
This commit is contained in:
@@ -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)时,
|
||||
|
||||
Reference in New Issue
Block a user