- 新增韩国(KR)适配:countries/pinterest 种子词、K-pop 提示词、UI 国家列表 - 爬虫跨运行持久化已采集 URL(.collected_urls.json),同关键词重复搜索采新图 - 模板导出增强:SPU 商品属性列名前缀剥离匹配、尺码下拉框 INDIRECT 动态引用、 SPU 字段映射(袖长/门襟/胸垫等)、季节/印花图案女装映射、建议售价统一必填、 SKU 分类/数量/单位直接填默认值 - 自定义模式:多模态提示词独立(custom_analyze_*)、生图提示词模板可配置 - Pinterest:空选品守卫、连续空分析保护(max_empty_analyze)、flat_prompt 配置覆盖 - 生图可靠性:开始/完成进度日志、异步任务轮询超时 60s→300s
365 lines
18 KiB
Python
365 lines
18 KiB
Python
"""构建并编译 LangGraph,提供 run_country() 入口。
|
||
|
||
图结构(线性流水线,节点全部带兜底):
|
||
START -> seed -> fetch -> filter -> score -> screen -> prompt_build
|
||
-> compose(生成纯印花设计稿 + 导出简报)-> product(底图/模特/三图合成/模板)
|
||
-> oss_upload(压缩 3:4 / ≥1340×1785 / <2MB + 上传阿里云 OSS)-> END
|
||
"""
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Optional
|
||
|
||
from langgraph.graph import END, StateGraph
|
||
|
||
from graph.loader import build_country_config
|
||
from graph.nodes import (
|
||
compose_node,
|
||
fetch_node,
|
||
filter_node,
|
||
oss_upload_node,
|
||
product_node,
|
||
prompt_node,
|
||
score_node,
|
||
screen_node,
|
||
seed_node,
|
||
seed_shot_node,
|
||
template_export_node,
|
||
)
|
||
from graph.state import AgentState
|
||
from graph.validate import with_fallback
|
||
|
||
|
||
def build_graph():
|
||
"""构建 StateGraph 并编译。"""
|
||
builder = StateGraph(AgentState)
|
||
builder.add_node("seed", seed_node)
|
||
builder.add_node("fetch", fetch_node)
|
||
builder.add_node("filter", filter_node)
|
||
builder.add_node("score", score_node)
|
||
builder.add_node("screen", screen_node)
|
||
builder.add_node("prompt_build", prompt_node)
|
||
builder.add_node("product", product_node)
|
||
builder.add_node("compose", compose_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__", "seed")
|
||
builder.add_edge("seed", "fetch")
|
||
builder.add_edge("fetch", "filter")
|
||
builder.add_edge("filter", "score")
|
||
builder.add_edge("score", "screen")
|
||
builder.add_edge("screen", "prompt_build")
|
||
builder.add_edge("prompt_build", "compose") # compose:生成纯印花设计稿(放前面)
|
||
builder.add_edge("compose", "product") # product:底图/模特/三图合成/模板
|
||
builder.add_edge("product", "oss_upload") # oss_upload:压缩 + 上传图床
|
||
builder.add_edge("oss_upload", "seed_shot") # seed_shot:种草图生成(模板+模特特征 yaml)→ 上传
|
||
builder.add_edge("seed_shot", "template_export") # template_export:最终结果导入商品上传模板
|
||
builder.add_edge("template_export", END)
|
||
return builder.compile()
|
||
|
||
|
||
def _pinterest_route(state: Dict[str, Any]) -> str:
|
||
"""图池路由:按简报池实时需求补——简报池还有待处理/在途简报 → 不分析不采集;
|
||
简报池空闲 + 图池有未消费图片 → 补分析;简报池空闲 + 图池空 + 简报不足 → 搜索采集;
|
||
简报达标 → done。不按任务数量提前结束,先消耗图池存量,不够用了才主动采集。
|
||
致命 503(图像服务不可用)→ 立即终止,不再搜索/分析,直接收尾合成模板。"""
|
||
# 致命 503:图像服务不可用,重试无效 → 提前终止,未完成产品废弃,直接收尾
|
||
pipe = state.get("pinterest_pipeline")
|
||
if pipe is not None and hasattr(pipe, "is_fatal_503_aborted") and pipe.is_fatal_503_aborted():
|
||
print("[pinterest_route] ⛔ 图像服务 503 已终止任务,停止搜索/分析,直接收尾合成模板")
|
||
return "done"
|
||
|
||
target = int(state.get("pinterest_target") or 0)
|
||
if target <= 0:
|
||
target = 1
|
||
briefs = state.get("briefs") or []
|
||
rounds = int(state.get("pinterest_rounds") or 0)
|
||
terms = state.get("pinterest_search_terms") or []
|
||
pcfg = (state.get("config") or {}).get("pinterest") or {}
|
||
max_rounds = int(pcfg.get("max_search_rounds") or 0)
|
||
if max_rounds <= 0:
|
||
max_rounds = max(target * 2, 5)
|
||
|
||
# 简报已达目标 → 停止补分析,直接收尾出模板(即使用户图池还有未消费图片也不再分析,
|
||
# 避免目标简报数的小任务把整池图分析成冗余简报、浪费配额)
|
||
if len(briefs) >= target:
|
||
print(f"[pinterest_route] 简报已达目标 {len(briefs)}/{target},停止补分析,结束(将收尾出模板)")
|
||
return "done"
|
||
|
||
# 简报池还有待处理/在途简报 → 先让后台消化,不分析新图也不采集
|
||
if pipe is not None and hasattr(pipe, "pending_count"):
|
||
pending = pipe.pending_count()
|
||
if pending > 0:
|
||
# 日志由 pinterest_wait 节点进入时统一打印(阻塞等待消化,避免此处高频刷屏)
|
||
return "wait"
|
||
|
||
# 简报池空闲 + 简报未达标 + 图池还有未消费图片 → 补分析(缺口批大小由 analyze_node 按 target 收敛)
|
||
try:
|
||
from graph.pinterest import load_image_pool, load_used_images, pool_unused_images
|
||
pool = load_image_pool(str(state.get("output_dir") or ""), state.get("country") or "")
|
||
used = load_used_images(str(state.get("output_dir") or ""), state.get("country") or "")
|
||
unused = pool_unused_images(pool, used)
|
||
except Exception: # noqa: BLE001
|
||
unused = []
|
||
# 连续多轮图片分析无新增简报(分析失败 / 图片均不适合印花)→ 视为无法生成,
|
||
# 直接收尾,避免"取下一张参考图"式无限空转
|
||
max_empty = int(pcfg.get("max_empty_analyze") or 0)
|
||
if max_empty <= 0:
|
||
max_empty = max(target + 2, 3)
|
||
empty = int(state.get("pinterest_empty_rounds") or 0)
|
||
if empty >= max_empty:
|
||
print(f"[pinterest_route] 连续 {empty} 轮图片分析无新增简报(目标 {len(briefs)}/{target} 条简报未达标),"
|
||
f"无法生成 → 放弃补分析,直接收尾(不再逐一取下一张参考图)")
|
||
return "done"
|
||
|
||
if unused:
|
||
print(f"[pinterest_route] 简报池空闲,图池还有 {len(unused)} 张未消费图片,补分析"
|
||
f"(简报 {len(briefs)}/{target})")
|
||
return "analyze"
|
||
|
||
# 简报池空闲 + 图池空 + 简报不达标 → 新一轮搜索采集
|
||
if rounds >= max_rounds:
|
||
print(f"[pinterest_route] 已达最大轮次 {max_rounds},图池已空,简报 {len(briefs)}/{target},按现有结果继续")
|
||
return "done"
|
||
if not terms and rounds > 0:
|
||
print(f"[pinterest_route] 无可用搜索词,图池已空,停止搜索(简报 {len(briefs)}/{target})")
|
||
return "done"
|
||
print(f"[pinterest_route] 简报池空闲,图池不足,简报未达标,新一轮搜索(第 {rounds} 轮,简报 {len(briefs)}/{target})")
|
||
return "search"
|
||
|
||
|
||
def _pinterest_custom_route(state: Dict[str, Any]) -> str:
|
||
"""自定义模式图池路由:只消耗本地图片池,从不搜索/采集。
|
||
|
||
简报池还有在途 → wait(等待后台消化);简报达标 → done;
|
||
简报池空闲 + 图池有未消费图片 → analyze;图池空 → done(自定义模式不采集)。
|
||
致命 503(图像服务不可用)→ 立即终止,直接收尾合成模板。
|
||
"""
|
||
pipe = state.get("pinterest_pipeline")
|
||
if pipe is not None and hasattr(pipe, "is_fatal_503_aborted") and pipe.is_fatal_503_aborted():
|
||
print("[pinterest_custom_route] ⛔ 图像服务 503 已终止任务,停止分析,直接收尾合成模板")
|
||
return "done"
|
||
|
||
target = int(state.get("pinterest_target") or 1) or 1
|
||
briefs = state.get("briefs") or []
|
||
if len(briefs) >= target:
|
||
print(f"[pinterest_custom_route] 简报已达目标 {len(briefs)}/{target},结束")
|
||
return "done"
|
||
if pipe is not None and hasattr(pipe, "pending_count") and pipe.pending_count() > 0:
|
||
return "wait"
|
||
try:
|
||
from graph.pinterest import load_image_pool, load_used_images, pool_unused_images
|
||
pool = load_image_pool(str(state.get("output_dir") or ""), state.get("country") or "")
|
||
used = load_used_images(str(state.get("output_dir") or ""), state.get("country") or "")
|
||
unused = pool_unused_images(pool, used)
|
||
except Exception: # noqa: BLE001
|
||
unused = []
|
||
if unused:
|
||
print(f"[pinterest_custom_route] 图池还有 {len(unused)} 张未消费图片,继续分析(简报 {len(briefs)}/{target})")
|
||
return "analyze"
|
||
print(f"[pinterest_custom_route] 图池已空,简报 {len(briefs)}/{target},结束(自定义模式不采集)")
|
||
return "done"
|
||
|
||
|
||
def build_pinterest_graph(custom_mode: bool = False):
|
||
"""Pinterest 参考模式图(按需搜索循环 + 简报池并发生成):
|
||
pinterest_init(建简报池)→ pinterest_search → pinterest_scrape → pinterest_analyze
|
||
→ [pinterest_route] 简报池还有在途 → wait(等待后台消化)→ 回到路由;
|
||
简报池空闲 + 图池有图 → analyze;图池空 + 简报不足 → search;达标 → pinterest_finalize
|
||
(排空简报池、后台并发生成 设计→三合一→OSS→种草图)→ template_export
|
||
|
||
custom_mode=True:自定义模式,不搜索不采集 ——
|
||
pinterest_init → pinterest_custom_load(本地文件夹校验+入库)→ pinterest_analyze
|
||
→ [pinterest_custom_route] wait / analyze / done(图池空或简报达标即结束,从不 search)。
|
||
其余下游流程(analyze→设计→三合一→OSS→种草图→模板)与普通 Pinterest 模式完全一致。
|
||
"""
|
||
from graph.nodes import (
|
||
pinterest_analyze_node,
|
||
pinterest_scrape_node,
|
||
pinterest_search_node,
|
||
)
|
||
from graph.nodes import pinterest_custom_load_node
|
||
from graph.nodes.pinterest_finalize_node import pinterest_finalize_node
|
||
from graph.nodes.pinterest_init_node import pinterest_init_node
|
||
|
||
@with_fallback("pinterest_wait")
|
||
def _pinterest_wait(state: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""简报池还有在途简报时阻塞等待后台消化(最多 60s),避免轮询刷屏。
|
||
|
||
用 pipeline.wait_idle 的 Condition 阻塞等待(_process_one 完成即唤醒),
|
||
消化完才返回,路由重新判断;超时兜底返回,避免死等。
|
||
"""
|
||
pipe = state.get("pinterest_pipeline")
|
||
if pipe is not None and hasattr(pipe, "wait_idle"):
|
||
pipe.wait_idle(60)
|
||
else:
|
||
import time as _t
|
||
_t.sleep(5)
|
||
return {}
|
||
|
||
builder = StateGraph(AgentState)
|
||
builder.add_node("pinterest_init", pinterest_init_node)
|
||
builder.add_node("pinterest_analyze", pinterest_analyze_node)
|
||
builder.add_node("pinterest_wait", _pinterest_wait)
|
||
builder.add_node("pinterest_finalize", pinterest_finalize_node)
|
||
builder.add_node("template_export", template_export_node)
|
||
|
||
if custom_mode:
|
||
builder.add_node("pinterest_custom_load", pinterest_custom_load_node)
|
||
builder.add_edge("__start__", "pinterest_init")
|
||
builder.add_edge("pinterest_init", "pinterest_custom_load")
|
||
builder.add_edge("pinterest_custom_load", "pinterest_analyze")
|
||
builder.add_conditional_edges("pinterest_analyze", _pinterest_custom_route, {
|
||
"analyze": "pinterest_analyze",
|
||
"wait": "pinterest_wait",
|
||
"done": "pinterest_finalize",
|
||
})
|
||
else:
|
||
builder.add_node("pinterest_search", pinterest_search_node)
|
||
builder.add_node("pinterest_scrape", pinterest_scrape_node)
|
||
builder.add_edge("__start__", "pinterest_init")
|
||
builder.add_edge("pinterest_init", "pinterest_search")
|
||
builder.add_edge("pinterest_search", "pinterest_scrape")
|
||
builder.add_edge("pinterest_scrape", "pinterest_analyze")
|
||
builder.add_conditional_edges("pinterest_analyze", _pinterest_route, {
|
||
"analyze": "pinterest_analyze", # 简报池空闲 + 图池有未消费图片 → 补分析(不搜索)
|
||
"search": "pinterest_search", # 简报池空闲 + 图池空 + 简报不足 → 新一轮搜索
|
||
"wait": "pinterest_wait", # 简报池还有在途 → 等待后台消化
|
||
"done": "pinterest_finalize", # 简报达标/轮次耗尽 → 收尾(排空简报池)
|
||
})
|
||
builder.add_edge("pinterest_wait", "pinterest_analyze")
|
||
builder.add_edge("pinterest_finalize", "template_export")
|
||
builder.add_edge("template_export", END)
|
||
return builder.compile()
|
||
|
||
|
||
def run_country(
|
||
country: str,
|
||
global_config: Dict[str, Any],
|
||
project_root: Path,
|
||
output_root: Optional[Path] = None,
|
||
base_image: Optional[str] = None,
|
||
task_timestamp: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""运行单个国家的完整流水线,返回最终 state(含 errors / stats / briefs)。
|
||
|
||
project_root:数据文件根(configs / prompts,打包后为 _MEIPASS 只读目录)。
|
||
output_root :产物输出根(默认=project_root;打包后传 exe 旁运行目录,
|
||
避免把 output/ 写进临时解压目录导致重启丢失)。
|
||
task_timestamp:任务时间戳(每次点击运行 = 一个任务);None 时自动生成。
|
||
"""
|
||
compiled = build_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),
|
||
"raw_rows": [],
|
||
"filtered_rows": [],
|
||
"scored_rows": [],
|
||
"screened": [],
|
||
"briefs": [],
|
||
"composite": [],
|
||
"designs": [],
|
||
"errors": [],
|
||
"stats": {},
|
||
"task_timestamp": ts, # 任务开始时间戳(OSS 路径段 / 产物文件夹名)
|
||
"oss_seq": 0, # 货号计数(000 起,最多 999)
|
||
}
|
||
if base_image:
|
||
state["base_image"] = base_image
|
||
|
||
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,
|
||
custom_image_dir: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Pinterest 参考模式入口:独立于 Google Trends 的完整流程。
|
||
|
||
种子词 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图 → LLM 分析图片
|
||
→ 设计简报 → 设计稿 → 产品图 → 上传 → 种草图 → 模板导出。
|
||
参数语义与 run_country 一致(project_root=数据根,output_root=产物根)。
|
||
|
||
custom_image_dir 非空 或 config.pinterest.mode=="custom" → 进入自定义模式:
|
||
不搜索不采集,直接把指定文件夹的有效图片送多模态分析,沿用 Pinterest 后续所有步骤。
|
||
数量硬校验(有效图片数 ≥ 选品清单总数)在启动前执行,不满足直接抛错。
|
||
"""
|
||
target = len((global_config.get("product") or {}).get("spu_tasks") or []) \
|
||
or int((global_config.get("product") or {}).get("spu_count") or 0) or 1
|
||
pinterest_cfg = global_config.get("pinterest") or {}
|
||
mode = str(pinterest_cfg.get("mode") or "scrape").strip().lower()
|
||
custom_dir = str(custom_image_dir or "").strip() \
|
||
or str(pinterest_cfg.get("custom_image_dir") or "").strip()
|
||
custom_mode = bool(custom_dir) or (mode == "custom")
|
||
|
||
if custom_mode:
|
||
if not custom_dir:
|
||
raise ValueError(
|
||
"自定义模式需要填写 pinterest.custom_image_dir(选择上传的图片文件夹),"
|
||
"当前为空,无法启动")
|
||
from graph.pinterest import validate_custom_images
|
||
ok, valid_n, msg = validate_custom_images(custom_dir, target)
|
||
if not ok:
|
||
raise ValueError(f"自定义模式数量校验未通过:{msg}(选品清单总数 {target})")
|
||
global_config.setdefault("pinterest", {})["custom_image_dir"] = custom_dir
|
||
print(f"[run_pinterest_ref] 自定义模式启用:{custom_dir} 有效图片 {valid_n} 张 ≥ 选品清单 {target}")
|
||
|
||
compiled = build_pinterest_graph(custom_mode=custom_mode)
|
||
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,
|
||
# 按需搜索目标:简报数 = spu_tasks 数量(每款一个设计);无任务时回退 spu_count/1
|
||
"pinterest_target": len((global_config.get("product") or {}).get("spu_tasks") or [])
|
||
or int((global_config.get("product") or {}).get("spu_count") or 0) or 1,
|
||
"pinterest_rounds": 0,
|
||
"pinterest_attempted": [],
|
||
"pinterest_images": {},
|
||
"pinterest_briefs": [],
|
||
"pinterest_login": {},
|
||
"pinterest_pipeline": None,
|
||
}
|
||
return compiled.invoke(state)
|