Files
3218485270 3dc594cf57 v92-v105 多国适配 + 模板导出增强 + 生图可靠性优化
- 新增韩国(KR)适配:countries/pinterest 种子词、K-pop 提示词、UI 国家列表
- 爬虫跨运行持久化已采集 URL(.collected_urls.json),同关键词重复搜索采新图
- 模板导出增强:SPU 商品属性列名前缀剥离匹配、尺码下拉框 INDIRECT 动态引用、
  SPU 字段映射(袖长/门襟/胸垫等)、季节/印花图案女装映射、建议售价统一必填、
  SKU 分类/数量/单位直接填默认值
- 自定义模式:多模态提示词独立(custom_analyze_*)、生图提示词模板可配置
- Pinterest:空选品守卫、连续空分析保护(max_empty_analyze)、flat_prompt 配置覆盖
- 生图可靠性:开始/完成进度日志、异步任务轮询超时 60s→300s
2026-08-31 18:35:51 +08:00

365 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""构建并编译 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)