"""构建并编译 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)