- 图源映射统一:热点采集与 Pinterest 模式均走 config.product.mark_dirs 配置,按任务序号随机抽模特图/平铺图 - 商品产地固定:统一为「中国大陆」+「产地省份=广东省」(不再读站点/字典映射) - 模板 SKU 字段检测:按建议售价同一套路检测 SKU分类/SKU数量/SKU数量单位,必填时填入单品/1/件 - 多模态分析提示词可配置:prompts/pinterest_analyze_system.md + user.md,支持国家覆盖,不丢文件回退内置 - 自定义图片模式:新增 pinterest_custom_load_node,图片数量硬校验,选品清单 ≤ 有效图片数 - 模板导出优化:写入前按货号末 3 位升序排序,不再产生空白 xlsx - 修复 v90 project review 10 项(503 致命终止、线程安全、原子写入等)
123 lines
6.2 KiB
Python
123 lines
6.2 KiB
Python
"""Pinterest 参考模式节点 1/3:按需生成单个搜索词(pinterest_search)。
|
||
|
||
按需搜索:每次只生成 1 个搜索词(LLM json_schema + 动态注入已用词防重复),
|
||
带短袖/印花设计引导,保证搜索词适合短袖 T 恤印花。
|
||
不在此处持久化已用词 —— 只有爬取成功(真正用掉)才标记已用(见 pinterest_scrape)。
|
||
|
||
兜底链: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,
|
||
)
|
||
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()
|
||
search_mode = str(pcfg.get("search_mode") or "direct").strip().lower()
|
||
_raw_suffix = str(pcfg.get("search_term_suffix") if pcfg.get("search_term_suffix") is not None else " t-shirt design").strip()
|
||
suffix = _raw_suffix if _raw_suffix else "" # 配置留空则后缀为空(不追加),不再回退默认
|
||
want = int(pcfg.get("search_terms_per_run", 1)) # 每次搜索词数量
|
||
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)
|
||
attempted = [str(t).strip() for t in (state.get("pinterest_attempted") or []) if str(t).strip()]
|
||
rounds = int(state.get("pinterest_rounds") or 0) + 1
|
||
if not seeds:
|
||
print(f"[pinterest_search] {country} 无种子词,跳过搜索词生成")
|
||
return {"pinterest_search_terms": [], "pinterest_rounds": rounds, "errors": errors}
|
||
|
||
# 2) 生成搜索词:种子词不再由 LLM 给出,直接由内置国家种子词库随机抽取(优先未用过),
|
||
# 追加 " t-shirt design"(保证 Pinterest 返回真正的 T 恤印花图);llm 模式保留兼容
|
||
terms: List[str] = []
|
||
if search_mode == "direct":
|
||
from graph.pinterest import load_pinterest_seeds
|
||
pool = load_pinterest_seeds(country)
|
||
used_set = {str(u).strip().lower() for u in merge_used(used, attempted)}
|
||
fresh = [s for s in pool if s.lower() not in used_set]
|
||
if not fresh:
|
||
fresh = pool # 库内词全部用过 → 允许复用(词库有限)
|
||
terms = [f"{s} {suffix}".strip() if suffix and suffix not in s.lower() else s
|
||
for s in random.sample(fresh, min(want, len(fresh)))]
|
||
print(f"[pinterest_search] direct 模式:国家种子词库随机抽 {len(terms)} 个 + 后缀「{suffix}」({country})")
|
||
else:
|
||
used_llm = merge_used(used, attempted)
|
||
if max_used_in_prompt > 0:
|
||
used_llm = used_llm[-max_used_in_prompt:]
|
||
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,
|
||
"search_term_suffix": suffix}
|
||
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)})")
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[pinterest_search] LLM 生成失败,回退种子词池: {e}")
|
||
terms = []
|
||
|
||
# 3) 兜底:LLM 无结果 → 种子词池随机抽样
|
||
if not terms:
|
||
terms = [f"{s} {suffix}".strip() if suffix and suffix not in s.lower() else s
|
||
for s in random.sample(seeds, min(want, len(seeds)))]
|
||
print(f"[pinterest_search] 兜底:从种子词池取 {len(terms)} 个")
|
||
|
||
# 4) 全局过滤(已用/本轮已尝试/黑名单/不适合T恤/去重)——注意:不在此处持久化已用词
|
||
# direct 模式:抽样时已避开已用词(库内词有限,全部用过后允许复用),不再额外过滤
|
||
if search_mode == "direct":
|
||
filtered = terms
|
||
else:
|
||
filtered = filter_search_terms(terms, merge_used(used, attempted), blacklist)
|
||
if not filtered and seeds:
|
||
# 生成词全被过滤 → 从种子词池补充(同样过滤)
|
||
extra = filter_search_terms(seeds, merge_used(used, attempted), blacklist)
|
||
filtered = extra[:want]
|
||
|
||
stats = dict(state.get("stats") or {})
|
||
stats["pinterest_search"] = {
|
||
"provider": provider,
|
||
"round": rounds,
|
||
"generated": len(terms),
|
||
"filtered": len(filtered),
|
||
"used_total": len(used),
|
||
}
|
||
print(f"[pinterest_search] 第 {rounds} 轮搜索词 {len(filtered)} 个(已用累计 {len(used)}): "
|
||
f"{', '.join(filtered[:3])}{'...' if len(filtered) > 3 else ''}")
|
||
|
||
return {"pinterest_search_terms": filtered, "pinterest_rounds": rounds,
|
||
"stats": stats, "errors": errors}
|