v88 功能增强:产品落盘持久化 + 生图网关适配 + 模板导出优化
- 产品持久化:每完成一个产品立即追加写入 products_pending.jsonl,崩溃不丢已完成产品,finish 读盘合并后统一写模板 - 503 致命错误提前终止:compose/product/seed_shot 端到端识别,提前终止搜索分析,丢弃未完成简报,保留已完成落盘产品直接合成模板 - 模特分配:material_library 合格模特图按任务序号独立随机,同 SPU 多款不再共用同一模特 - 图像网关适配:execution_mode/background 默认不再传入 yunfei 等标准网关,base_url 需带 /v1;429/5xx/空响应退避重试 - Pinterest 分析:删除 term 注入与纯文本降级,失败直接放弃;图片上传前 PIL 完整性校验;suitable_for_print=False 过滤丢弃 - 模板导出:不再产生空白 xlsx,文件名=模板原文件名_已填写;写入前按货号末 3 位升序排序 - 删除对接文档.md,更新 README,gitignore 排除测试产物
This commit is contained in:
+55
-14
@@ -26,6 +26,7 @@ from graph.nodes import (
|
||||
template_export_node,
|
||||
)
|
||||
from graph.state import AgentState
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
def build_graph():
|
||||
@@ -59,8 +60,16 @@ def build_graph():
|
||||
|
||||
|
||||
def _pinterest_route(state: Dict[str, Any]) -> str:
|
||||
"""图池路由:简报达标 → done;图池还有未消费图片 → analyze(继续分析,不搜索);
|
||||
图池不足 → search(新一轮搜索);轮次耗尽 → done。"""
|
||||
"""图池路由:按简报池实时需求补——简报池还有待处理/在途简报 → 不分析不采集;
|
||||
简报池空闲 + 图池有未消费图片 → 补分析;简报池空闲 + 图池空 + 简报不足 → 搜索采集;
|
||||
简报达标 → 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
|
||||
@@ -72,11 +81,14 @@ def _pinterest_route(state: Dict[str, Any]) -> str:
|
||||
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"
|
||||
|
||||
# 图池还有未消费图片 → 继续分析(不搜索)
|
||||
# 简报池空闲 → 检查图池:还有未消费图片 → 补分析(不管简报离目标差多少,先把这批用完)
|
||||
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 "")
|
||||
@@ -85,24 +97,31 @@ def _pinterest_route(state: Dict[str, Any]) -> str:
|
||||
except Exception: # noqa: BLE001
|
||||
unused = []
|
||||
if unused:
|
||||
print(f"[pinterest_route] 图池还有 {len(unused)} 张未消费图片,继续分析(简报 {len(briefs)}/{target})")
|
||||
print(f"[pinterest_route] 简报池空闲,图池还有 {len(unused)} 张未消费图片,补分析"
|
||||
f"(简报 {len(briefs)}/{target})")
|
||||
return "analyze"
|
||||
|
||||
# 图池不足 → 搜索
|
||||
# 简报池空闲 + 图池空 → 检查简报是否已达标
|
||||
if len(briefs) >= target:
|
||||
print(f"[pinterest_route] 图池已空,简报已达目标 {len(briefs)}/{target},结束")
|
||||
return "done"
|
||||
|
||||
# 简报池空闲 + 图池空 + 简报不达标 → 新一轮搜索采集
|
||||
if rounds >= max_rounds:
|
||||
print(f"[pinterest_route] 已达最大轮次 {max_rounds},简报 {len(briefs)}/{target},按现有结果继续")
|
||||
print(f"[pinterest_route] 已达最大轮次 {max_rounds},图池已空,简报 {len(briefs)}/{target},按现有结果继续")
|
||||
return "done"
|
||||
if not terms and rounds > 0:
|
||||
print(f"[pinterest_route] 无可用搜索词,停止搜索(简报 {len(briefs)}/{target})")
|
||||
print(f"[pinterest_route] 无可用搜索词,图池已空,停止搜索(简报 {len(briefs)}/{target})")
|
||||
return "done"
|
||||
print(f"[pinterest_route] 图池不足,新一轮搜索(第 {rounds} 轮,简报 {len(briefs)}/{target})")
|
||||
print(f"[pinterest_route] 简报池空闲,图池不足,简报未达标,新一轮搜索(第 {rounds} 轮,简报 {len(briefs)}/{target})")
|
||||
return "search"
|
||||
|
||||
|
||||
def build_pinterest_graph():
|
||||
"""Pinterest 参考模式图(按需搜索循环 + 简报池并发生成):
|
||||
pinterest_init(建简报池)→ pinterest_search → pinterest_scrape → pinterest_analyze
|
||||
→ [pinterest_route] 简报不足 → 回到 pinterest_search;达标 → pinterest_finalize
|
||||
→ [pinterest_route] 简报池还有在途 → wait(等待后台消化)→ 回到路由;
|
||||
简报池空闲 + 图池有图 → analyze;图池空 + 简报不足 → search;达标 → pinterest_finalize
|
||||
(排空简报池、后台并发生成 设计→三合一→OSS→种草图)→ template_export
|
||||
"""
|
||||
from graph.nodes import (
|
||||
@@ -113,11 +132,27 @@ def build_pinterest_graph():
|
||||
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_search", pinterest_search_node)
|
||||
builder.add_node("pinterest_scrape", pinterest_scrape_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)
|
||||
|
||||
@@ -126,10 +161,12 @@ def build_pinterest_graph():
|
||||
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", # 图池不足 → 新一轮搜索
|
||||
"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()
|
||||
@@ -232,5 +269,9 @@ def run_pinterest_ref(
|
||||
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)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
所有请求跳过环境代理(NO_PROXY),适配用户挂 VPN 时直连国内/自建网关。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -31,6 +32,17 @@ _FINAL_STATUS = ("succeeded", "completed", "done")
|
||||
_FAIL_STATUS = ("failed", "error")
|
||||
|
||||
|
||||
def _retry_after(resp, attempt: int) -> int:
|
||||
"""429 限流等待秒数:优先取网关 Retry-After 头,否则指数退避(3/6/9s)。"""
|
||||
ra = resp.headers.get("Retry-After")
|
||||
try:
|
||||
if ra is not None and ra.isdigit():
|
||||
return min(int(ra), 30)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 3 * (attempt + 1)
|
||||
|
||||
|
||||
def _shrink_blob_to_2mb(img_path: str, blob: bytes, max_bytes: int = 2 * 1024 * 1024) -> bytes:
|
||||
"""把大图压缩到 <max_bytes(默认 2MB)再上传:
|
||||
- 尺寸过大先缩放(合成输入 1600x2200 内足够);
|
||||
@@ -171,8 +183,12 @@ class OpenAIImageBackend(ImageBackend):
|
||||
"n": 1,
|
||||
"size": size or cfg.get("size", "1024x1024"),
|
||||
"model": model,
|
||||
"execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定)
|
||||
}
|
||||
# execution_mode 仅对明确支持的网关(如 ai-media)传;yunfei 等标准 OpenAI 网关
|
||||
# 不认识该参数,硬传会导致空响应,故默认不传,仅 config 显式配置时才带上
|
||||
em = str(cfg.get("execution_mode") or "").strip()
|
||||
if em:
|
||||
data["execution_mode"] = em
|
||||
if seed is not None:
|
||||
data["seed"] = seed
|
||||
# 提交重试:异步路径不稳定 → 失败重试同步提交(最多 3 次);
|
||||
@@ -181,6 +197,20 @@ class OpenAIImageBackend(ImageBackend):
|
||||
for attempt in range(3):
|
||||
resp = requests.post(f"{base_url}/images/edits", headers=headers, files=files,
|
||||
data=data, timeout=300, proxies=NO_PROXY)
|
||||
if resp.status_code == 429:
|
||||
# 限流:尊重网关负载退避等待再重试,避免硬撞雪崩
|
||||
wait = _retry_after(resp, attempt)
|
||||
print(f"[img] 图像 API 429 限流,等待 {wait}s 重试 {attempt + 1}/3")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
if resp.status_code >= 500:
|
||||
# 5xx 服务端错误(500/502/503/504 超时等):网关临时故障/过载,退避后重试
|
||||
body = resp.text or ""
|
||||
last_err = f"图像 API {resp.status_code}: {body[:300]}"
|
||||
wait = 3 * (attempt + 1)
|
||||
print(f"[img] 图像 API {resp.status_code}(网关临时故障/超时),等待 {wait}s 重试 {attempt + 1}/3")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
if resp.status_code >= 400:
|
||||
body = resp.text or ""
|
||||
if "content_policy" in body and attempt < 2:
|
||||
@@ -193,18 +223,24 @@ class OpenAIImageBackend(ImageBackend):
|
||||
raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}")
|
||||
try:
|
||||
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path)
|
||||
except json.JSONDecodeError as e:
|
||||
# 空/非 JSON 响应:多为网关过载返回空 body → 退避后重试,
|
||||
# 避免即时重压触发网关 429(用户实测空响应风暴 → 429)
|
||||
last_err = str(e)
|
||||
wait = 3 * (attempt + 1)
|
||||
print(f"[img] 网关空响应(JSON 解析失败),等待 {wait}s 重试提交 {attempt + 1}/3: {e}")
|
||||
time.sleep(wait)
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = str(e)
|
||||
print(f"[img] 第 {attempt + 1} 次提交异步失败,重试同步提交: {e}")
|
||||
print(f"[img] 第 {attempt + 1} 次提交异步失败,等待后重试同步提交: {e}")
|
||||
time.sleep(2 * (attempt + 1))
|
||||
raise RuntimeError(f"图像合成多次提交均失败: {last_err}")
|
||||
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "",
|
||||
seed: Optional[int] = None) -> str:
|
||||
"""纯文生图:生成白底纯印花设计稿(standalone pure print design)。
|
||||
size: 显式尺寸覆盖(印花设计统一 1024x1024);留空用配置 size。
|
||||
seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。
|
||||
background: 配置 compose.background="transparent" 时传 background 参数 → 透明背景 PNG
|
||||
(gpt-image-1/2 等模型支持;网关不支持该参数时会被忽略或由网关兜底)。"""
|
||||
seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。"""
|
||||
cfg = self._cfg
|
||||
api_key = cfg.get("api_key", "")
|
||||
model = cfg.get("model", "gpt-image-1")
|
||||
@@ -220,17 +256,32 @@ class OpenAIImageBackend(ImageBackend):
|
||||
"size": size or cfg.get("size", "1024x1024"),
|
||||
"model": model,
|
||||
"response_format": "b64_json",
|
||||
"execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定)
|
||||
}
|
||||
# execution_mode 仅对明确支持的网关(如 ai-media)传;yunfei 等标准 OpenAI 网关
|
||||
# 不认识该参数,硬传会导致空响应,故默认不传,仅 config 显式配置时才带上
|
||||
em = str(cfg.get("execution_mode") or "").strip()
|
||||
if em:
|
||||
data["execution_mode"] = em
|
||||
if seed is not None:
|
||||
data["seed"] = seed
|
||||
bg = str(cfg.get("background") or "").strip()
|
||||
if bg:
|
||||
data["background"] = bg # 如 "transparent"(透明背景 PNG)
|
||||
last_err: Optional[str] = None
|
||||
for attempt in range(3):
|
||||
resp = requests.post(f"{base_url}/images/generations", headers=headers, json=data,
|
||||
timeout=300, proxies=NO_PROXY)
|
||||
if resp.status_code == 429:
|
||||
# 限流:尊重网关负载退避等待再重试,避免硬撞雪崩
|
||||
wait = _retry_after(resp, attempt)
|
||||
print(f"[img] 图像 API 429 限流,等待 {wait}s 重试 {attempt + 1}/3")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
if resp.status_code >= 500:
|
||||
# 5xx 服务端错误(500/502/503/504 超时等):网关临时故障/过载,退避后重试
|
||||
body = resp.text or ""
|
||||
last_err = f"图像 API {resp.status_code}: {body[:300]}"
|
||||
wait = 3 * (attempt + 1)
|
||||
print(f"[img] 图像 API {resp.status_code}(网关临时故障/超时),等待 {wait}s 重试 {attempt + 1}/3")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
if resp.status_code >= 400:
|
||||
body = resp.text or ""
|
||||
if "content_policy" in body and attempt < 2:
|
||||
@@ -243,7 +294,15 @@ class OpenAIImageBackend(ImageBackend):
|
||||
raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}")
|
||||
try:
|
||||
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path)
|
||||
except json.JSONDecodeError as e:
|
||||
# 空/非 JSON 响应:多为网关过载返回空 body → 退避后重试,
|
||||
# 避免即时重压触发网关 429(用户实测空响应风暴 → 429)
|
||||
last_err = str(e)
|
||||
wait = 3 * (attempt + 1)
|
||||
print(f"[img] 网关空响应(JSON 解析失败),等待 {wait}s 重试生成 {attempt + 1}/3: {e}")
|
||||
time.sleep(wait)
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = str(e)
|
||||
print(f"[img] 第 {attempt + 1} 次生成异步失败,重试同步提交: {e}")
|
||||
print(f"[img] 第 {attempt + 1} 次生成异步失败,等待后重试同步提交: {e}")
|
||||
time.sleep(2 * (attempt + 1))
|
||||
raise RuntimeError(f"图像生成多次提交均失败: {last_err}")
|
||||
|
||||
@@ -180,14 +180,11 @@ class MockBackend:
|
||||
paths = list(image_paths or [])
|
||||
return [{
|
||||
"topic": term,
|
||||
"concept": f"(启发式兜底)围绕「{term}」做原创{art_style}风格印花",
|
||||
"motif": motif,
|
||||
"art_style": art_style,
|
||||
"color_palette": palette,
|
||||
"composition": composition,
|
||||
"suitable_for_print": True,
|
||||
"negative_prompt": negative,
|
||||
"image_prompt": (f"{motif}, {art_style}, {palette}, {composition}, "
|
||||
f"original {art_style} t-shirt print design"),
|
||||
f"original {art_style} t-shirt print design, "
|
||||
f"no brand logo, no trademark, no character, no watermark"),
|
||||
# 生图参考:每条简报对应其来源爬取图(mock 按图逐张产出简报,顺序一一对应)
|
||||
"ref_images": [str(paths[i])] if i < len(paths) else [],
|
||||
"source": "pinterest",
|
||||
|
||||
@@ -318,31 +318,56 @@ def build_pinterest_term_user_prompt(context: Dict[str, Any]) -> str:
|
||||
# —— Pinterest 参考模式:图片分析 → 原创设计简报(多模态)——
|
||||
# image_prompt 由 LLM 直接输出完整的英文生图提示词(多模态对图片的描述拼接),
|
||||
# 不再走「四要素 + 固定模板」装配;尺寸/白底等统一约束段由 prompt_node 自动追加。
|
||||
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.
|
||||
PINTEREST_ANALYZE_SYSTEM_PROMPT = """You are a POD T-shirt design analyst. Given ONE Pinterest reference image,
|
||||
judge whether it can inspire a T-shirt print, then write an ORIGINAL design brief
|
||||
capturing its vibe WITHOUT copying.
|
||||
Your image_prompt will be sent TOGETHER WITH this reference image to an image
|
||||
generator, so it must actively override visual imitation.
|
||||
|
||||
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).
|
||||
- image_prompt: a COMPLETE, fluent English text-to-image prompt for generating the ORIGINAL flat print
|
||||
design artwork (the print itself, NOT a garment photo). Describe the motif, art style, colors, layout
|
||||
and mood in natural English, as a standalone print. Do NOT include garment / shirt / model / mannequin /
|
||||
background-scene / watermark words. Do NOT mention any size or white-background suffix — a fixed
|
||||
"small centered print on pure white" suffix will be appended automatically by the system.
|
||||
1. NO COPYING — never reproduce or closely imitate the reference's artwork,
|
||||
characters, layout or text. Deliberately change motif, arrangement and/or
|
||||
palette so the two read as clearly different works sharing only a general
|
||||
style. Distill inspiration into generic style words (retro, y2k, minimal,
|
||||
grunge, boho, kawaii...); never imitate an identifiable artist/studio/IP
|
||||
style.
|
||||
2. FORBIDDEN — brand logos, trademarks, slogans, mascots, copyrighted
|
||||
characters, real people/celebrities, movie/game/anime/band IP, lyrics,
|
||||
even stylized or silhouette versions. Avoid politics, religion, violence,
|
||||
sexual content, alcohol, drugs, gambling, flags, death/occult themes.
|
||||
3. FORM — ONE clear central subject with strong graphic composition;
|
||||
print-ready standalone artwork. ANY colors are fine — rich palettes,
|
||||
gradients and detailed shading are all acceptable. Photographic references
|
||||
may be rendered as detailed full-color illustrations, retro badges or
|
||||
vintage stickers.
|
||||
|
||||
Return JSON with the field "designs" (array of objects with keys:
|
||||
motif, art_style, color_palette, composition, concept, negative_prompt, image_prompt)."""
|
||||
TEXT: short ORIGINAL English wording (1-6 words) allowed; wrap exact words in
|
||||
double quotes and demand exact spelling; integrate into composition. Never
|
||||
reuse/translate reference text; no brand/band/movie names or famous slogans.
|
||||
When unsure, omit.
|
||||
|
||||
suitable_for_print: DEFAULT TRUE for graphics, illustrations, badges, vector
|
||||
art, typography posters, or prints on mockups (judge only the printed artwork).
|
||||
FALSE only for: subjectless photo scenery, memes/screenshots/collages,
|
||||
watermarked or very low-quality images, decor/food/candid photos with no
|
||||
usable motif. Even when FALSE, still fill all fields so downstream never breaks.
|
||||
|
||||
image_prompt = two parts:
|
||||
1) mandatory opener, e.g.: "Use the attached reference image only as loose
|
||||
inspiration for overall mood, theme and era — do NOT reproduce, trace,
|
||||
rearrange, recolor or closely imitate any element, character, layout or
|
||||
text shown in it."
|
||||
2) the new design: [central motif] + [style] + [color treatment] +
|
||||
[composition] + [mood], plus quoted original text if used.
|
||||
NEVER mention shirts, apparel, models, scenes, sizes, backgrounds or
|
||||
watermarks — placement is handled externally.
|
||||
|
||||
negative_prompt: copy of reference artwork, likenesses, characters, logos,
|
||||
trademarks, watermark, photorealistic shirt/product mockups, busy background;
|
||||
add garbled-lettering terms only if your design includes text.
|
||||
|
||||
OUTPUT — ONLY valid JSON, no fences:
|
||||
{"designs":[{"suitable_for_print":<bool>,"negative_prompt":"<str>","image_prompt":"<str>"}]}"""
|
||||
|
||||
PINTEREST_ANALYZE_SCHEMA = {
|
||||
"name": "pinterest_design_briefs",
|
||||
@@ -354,17 +379,11 @@ PINTEREST_ANALYZE_SCHEMA = {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_index": {"type": "integer"},
|
||||
"motif": {"type": "string"},
|
||||
"art_style": {"type": "string"},
|
||||
"color_palette": {"type": "string"},
|
||||
"composition": {"type": "string"},
|
||||
"concept": {"type": "string"},
|
||||
"suitable_for_print": {"type": "boolean"},
|
||||
"negative_prompt": {"type": "string"},
|
||||
"image_prompt": {"type": "string"},
|
||||
},
|
||||
"required": ["image_index", "motif", "art_style", "color_palette",
|
||||
"composition", "concept", "negative_prompt", "image_prompt"],
|
||||
"required": ["suitable_for_print", "negative_prompt", "image_prompt"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
@@ -375,18 +394,10 @@ PINTEREST_ANALYZE_SCHEMA = {
|
||||
}
|
||||
|
||||
|
||||
def build_pinterest_analyze_user_prompt(term: str, country: str, image_count: int) -> str:
|
||||
def build_pinterest_analyze_user_prompt() -> 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.\n"
|
||||
f"For EACH brief you MUST set image_index to the 0-based position of the input image "
|
||||
f"it was derived from (first image = 0, second = 1, ...). Every image_index from 0 to "
|
||||
f"{max(image_count - 1, 0)} must appear exactly once — this links each brief to its "
|
||||
f"source image so the design is generated from the SAME image that was analyzed."
|
||||
"Analyze the attached image and produce one ORIGINAL T-shirt print design brief "
|
||||
"that captures its visual vibe without copying it."
|
||||
)
|
||||
|
||||
|
||||
@@ -587,8 +598,7 @@ class OpenAICompatBackend(LLMBackend):
|
||||
on_400=None) -> List[Dict[str, Any]]:
|
||||
"""多模态分析 Pinterest 图片 → 原创设计简报列表。
|
||||
|
||||
图片输入不被模型支持(纯文本模型 400)时自动降级为纯文本分析(仅用搜索词)。
|
||||
失败返回 [],由节点兜底(回退 mock 规则简报)。
|
||||
图片输入失败/无有效图片时直接放弃(返回 [],不降级纯文本),由节点跳过该产品。
|
||||
on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。
|
||||
"""
|
||||
cfg = self._cfg
|
||||
@@ -605,11 +615,26 @@ class OpenAICompatBackend(LLMBackend):
|
||||
data_uris: List[str] = []
|
||||
for p in image_paths:
|
||||
try:
|
||||
pk = Path(p)
|
||||
raw = pk.read_bytes()
|
||||
# 校验图片完整性:损坏/截断的图片会被火山方舟等多模态接口直接 400 拒绝,
|
||||
# 必须滤掉后才能编码 base64(PIL 打开失败即视为损坏)。
|
||||
if not raw or len(raw) < 100:
|
||||
print(f"[pinterest_analyze] 图片文件过小/为空,跳过: {p} ({len(raw)}B)")
|
||||
continue
|
||||
try:
|
||||
from PIL import Image
|
||||
_im = Image.open(pk)
|
||||
_im.verify() # 校验文件头/结构,不完整则抛异常
|
||||
_im.close()
|
||||
except Exception as _ve: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] 图片损坏/不完整,跳过: {p} ({_ve})")
|
||||
continue
|
||||
import base64 as b64
|
||||
mime = "image/png"
|
||||
if Path(p).suffix.lower() in (".jpg", ".jpeg"):
|
||||
if pk.suffix.lower() in (".jpg", ".jpeg"):
|
||||
mime = "image/jpeg"
|
||||
data_uris.append(f"data:{mime};base64,{b64.b64encode(Path(p).read_bytes()).decode()}")
|
||||
data_uris.append(f"data:{mime};base64,{b64.b64encode(raw).decode()}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] 图片读取失败 {p}: {e}")
|
||||
|
||||
@@ -623,12 +648,11 @@ class OpenAICompatBackend(LLMBackend):
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _call(use_images: bool) -> str:
|
||||
def _call() -> str:
|
||||
user_content: List[Any] = [
|
||||
{"type": "text", "text": build_pinterest_analyze_user_prompt(term, country, len(data_uris))},
|
||||
{"type": "text", "text": build_pinterest_analyze_user_prompt()},
|
||||
]
|
||||
if use_images:
|
||||
user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris]
|
||||
user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris]
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
@@ -656,19 +680,15 @@ class OpenAICompatBackend(LLMBackend):
|
||||
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 []
|
||||
# 图片输入失败/无有效图片 → 直接放弃该产品(不降级纯文本),由节点跳过后续流程
|
||||
if not data_uris:
|
||||
print("[pinterest_analyze] 无有效图片输入,放弃该产品(不降级纯文本)")
|
||||
return []
|
||||
try:
|
||||
raw = _call()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] 图片分析失败,放弃该产品(不降级纯文本): {e}")
|
||||
return []
|
||||
try:
|
||||
parsed = _extract_json(raw)
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -687,11 +707,7 @@ class OpenAICompatBackend(LLMBackend):
|
||||
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(),
|
||||
"suitable_for_print": bool(d.get("suitable_for_print", True)),
|
||||
"negative_prompt": str(d.get("negative_prompt", "")).strip(),
|
||||
"image_prompt": str(d.get("image_prompt", "")).strip(),
|
||||
# 生图参考:每条简报对应其来源爬取图(LLM 按图逐张产出简报,顺序一一对应)
|
||||
|
||||
@@ -12,7 +12,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from graph.validate import with_fallback
|
||||
from graph.validate import ThreadSafeErrors, with_fallback
|
||||
|
||||
RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"}
|
||||
|
||||
@@ -29,6 +29,18 @@ def _notify_400(on_400, exc) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _notify_503(on_503, exc) -> None:
|
||||
"""致命图像服务错误(503 / 账户不可用)时触发 on_503 回调(供调用方提前终止任务)。"""
|
||||
if on_503 is None:
|
||||
return
|
||||
try:
|
||||
from graph.pinterest_pipeline import PinterestPipeline
|
||||
if PinterestPipeline.is_fatal_503(exc):
|
||||
on_503()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _build_briefs_md(briefs: List[Dict[str, Any]], generated_at: str) -> str:
|
||||
lines = [
|
||||
"# POD 印花设计简报(LLM 合规筛选 + 生图提示词)",
|
||||
@@ -112,6 +124,7 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
|
||||
errors: List[Dict[str, Any]] = None,
|
||||
seed: Optional[int] = None,
|
||||
on_400=None,
|
||||
on_503=None,
|
||||
size: str = "1024x1024") -> Optional[str]:
|
||||
"""生成单张纯印花设计稿(图2)。返回设计稿路径;失败 / 全局 MD5 重复返回 None。
|
||||
|
||||
@@ -123,6 +136,7 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
|
||||
无参考图或图生图失败 → 回退 ib.generate() 纯文生图。
|
||||
seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。
|
||||
on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。
|
||||
on_503: 致命图像服务错误(503/账户不可用)时回调(供调用方提前终止任务)。
|
||||
"""
|
||||
try:
|
||||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||||
@@ -145,6 +159,7 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[compose] 图生图(参考图)失败,回退文生图 {brief.get('topic','')}: {e}")
|
||||
_notify_400(on_400, e)
|
||||
_notify_503(on_503, e)
|
||||
out_path = ib.generate(
|
||||
img_prompt, str(design_dir / f"{out_stem}_design.png"),
|
||||
brief.get("composite_negative", ""), size=size, seed=seed)
|
||||
@@ -161,6 +176,7 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
|
||||
return out_path
|
||||
except Exception as e: # noqa: BLE001
|
||||
_notify_400(on_400, e)
|
||||
_notify_503(on_503, e)
|
||||
if errors is not None:
|
||||
errors.append({"node": "compose", "type": type(e).__name__,
|
||||
"message": f"设计稿生成失败 {brief.get('topic','')}: {e}", "trace": ""})
|
||||
@@ -238,12 +254,13 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _gen_one(i: int, b: Dict[str, Any]):
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
|
||||
out_path = generate_design(ib, b, design_dir, f"{country}_{i:02d}",
|
||||
state.get("errors"), seed=_seed,
|
||||
_safe_errors, seed=_seed,
|
||||
size=compose_cfg.get("design_size", "1024x1024"))
|
||||
if out_path is None:
|
||||
return i, b, None, None
|
||||
return i, b, out_path, None
|
||||
|
||||
_safe_errors = ThreadSafeErrors()
|
||||
targets = [(i, b) for i, b in enumerate(safe_briefs[:design_count], 1)]
|
||||
# 并发生成:每张设计一个线程(并行调图像网关),数量多时不串行等待
|
||||
workers = max(1, min(len(targets), int((config.get("compose") or {}).get("design_workers", 5))))
|
||||
@@ -263,6 +280,9 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
b["design_path"] = out_path
|
||||
designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path})
|
||||
print(f"[compose] 印花设计稿已生成: {out_path}")
|
||||
# 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加)
|
||||
if len(_safe_errors):
|
||||
state.setdefault("errors", []).extend(list(_safe_errors))
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["compose"] = {"written": len(briefs), "designs": len(designs), "output_dir": str(output_dir)}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
1. 先用种子词走数据源抓取(Google Trends 等),成功即用新数据;
|
||||
2. 抓取失败/无结果才回退 output/<国>/collected_keywords.json 旧缓存,保证流水线不中断。
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.sources import get_source
|
||||
@@ -44,11 +46,9 @@ def fetch_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
use_collected = (config.get("fetch") or {}).get("use_collected", True)
|
||||
if use_collected:
|
||||
try:
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
p = _Path(state.get("cache_dir") or state.get("output_dir", "")) / "collected_keywords.json"
|
||||
p = Path(state.get("cache_dir") or state.get("output_dir", "")) / "collected_keywords.json"
|
||||
if p.exists():
|
||||
data = _json.loads(p.read_text(encoding="utf-8"))
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
cached_rows = data.get("keywords") or []
|
||||
if cached_rows:
|
||||
rows = [dict(r) for r in cached_rows] # 已过滤去重的关键词
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
2) 真实人物(名单 + Firstname Lastname 模式,仅对 gt_trending 源,避免误删风格词)
|
||||
3) 设计相关性(剔除泛新闻/科技/赛事词)
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.scoring import apply_blacklist, filter_design_relevance, filter_person_names, filter_query_noise
|
||||
@@ -70,15 +73,13 @@ def filter_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 而不是只显示简报(design_briefs 仅含本次限量生成的热点)。
|
||||
if kept:
|
||||
try:
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
p = _Path(state.get("cache_dir") or state.get("output_dir", "")) / "collected_keywords.json"
|
||||
p = Path(state.get("cache_dir") or state.get("output_dir", "")) / "collected_keywords.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
kws = [{"topic": r.get("topic", ""), "source": r.get("source", ""),
|
||||
"kind": r.get("kind", ""), "raw_score": r.get("raw_score")} for r in kept]
|
||||
p.write_text(_json.dumps({"country": country,
|
||||
"collected_at": _datetime_now(),
|
||||
"keywords": kws}, ensure_ascii=False, indent=2),
|
||||
p.write_text(json.dumps({"country": country,
|
||||
"collected_at": _datetime_now(),
|
||||
"keywords": kws}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
print(f"[filter] 已写入采集缓存 {len(kws)} 条(collected_keywords.json)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -88,5 +89,4 @@ def filter_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _datetime_now() -> str:
|
||||
import time
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
图池机制:
|
||||
- 从持久化图池(image_pool.json)取「未消费」图片(md5 不在 used_images.json)。
|
||||
- 大图先压缩(内存占用过大 → 缩放/重编码)再送 LLM。
|
||||
- 多并发分析(每批 analyze_per_term 张,并发 analyze_concurrency 线程)。
|
||||
- 多并发分析(每批 1 张,并发 analyze_concurrency 线程;一次 API 请求一张图,利于 LLM 注意力)。
|
||||
- 每张被分析的图片 md5 一律拉黑(used_images.json)——合适→产出简报→生成设计(设计 md5 全局拉黑见 compose);
|
||||
不合适→图片 md5 已拉黑→下一轮自动取下一张,不重复分析。
|
||||
- 图池无未消费图片时返回空,由路由触发新一轮搜索。
|
||||
|
||||
兜底链:LLM 多模态 → 纯文本降级(后端内部)→ mock 规则简报 → 空列表(下游跳过)。
|
||||
兜底链:LLM 多模态分析 → 失败/无图直接放弃本轮(不降级纯文本、不做 mock 兜底)。
|
||||
带 with_fallback:任何异常都不中断。
|
||||
"""
|
||||
import concurrent.futures
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
@@ -25,25 +25,14 @@ from graph.pinterest import (
|
||||
pool_unused_images,
|
||||
save_used_images,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
# 明显不适合 T 恤印花的简报主体(启发式过滤;真实判定交给 LLM 搜索词引导)
|
||||
_BRIEF_UNSUITABLE = re.compile(
|
||||
r"\b(landscape|panorama|scenery|cityscape|street scene|interior|room decor|"
|
||||
r"food photography|meal|dinner plate|recipe|makeup|nails|manicure|"
|
||||
r"weather forecast|map|directions|photorealistic scene|realistic portrait)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
from graph.validate import ThreadSafeErrors, with_fallback
|
||||
|
||||
|
||||
def _brief_suitable(b: Dict[str, Any]) -> bool:
|
||||
"""简报是否适合做 T 恤印花:非侵权(blocked 拦截)+ 有主体 + 非明显非印花概念。"""
|
||||
"""简报是否适合做 T 恤印花:非侵权(blocked 拦截)+ LLM 判定适合印花(suitable_for_print)。"""
|
||||
if str(b.get("risk_level") or "").strip().lower() == "blocked":
|
||||
return False
|
||||
motif = str(b.get("motif") or "").strip()
|
||||
if not motif:
|
||||
return False
|
||||
if _BRIEF_UNSUITABLE.search(motif):
|
||||
if not bool(b.get("suitable_for_print", True)):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -75,25 +64,21 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str,
|
||||
n = seen_topics.get(base.lower(), 0)
|
||||
seen_topics[base.lower()] = n + 1
|
||||
topic = base if n == 0 else f"{base} #{n + 1}"
|
||||
motif = str(b.get("motif") or "").strip() or term
|
||||
if not motif:
|
||||
continue
|
||||
if not bool(b.get("suitable_for_print", True)):
|
||||
continue # LLM 判定不适合做印花 → 丢弃(md5 已拉黑,下轮取新图)
|
||||
out.append({
|
||||
"country": country,
|
||||
"topic": topic,
|
||||
"risk_level": str(b.get("risk_level") or "safe").strip().lower() or "safe",
|
||||
"safe_for_print": bool(b.get("safe_for_print", True)),
|
||||
"suitable_for_print": bool(b.get("suitable_for_print", True)),
|
||||
"suitable_for_print": True,
|
||||
"design_category": classify(term),
|
||||
"concept": str(b.get("concept") or "").strip() or f"围绕「{term}」的原创印花设计",
|
||||
"motif": motif,
|
||||
"art_style": str(b.get("art_style") or "").strip(),
|
||||
"color_palette": str(b.get("color_palette") or "").strip(),
|
||||
"composition": str(b.get("composition") or "").strip(),
|
||||
"concept": f"围绕「{term}」的原创印花设计",
|
||||
"negative_prompt": str(b.get("negative_prompt") or "").strip(),
|
||||
"image_prompt": str(b.get("image_prompt") or "").strip(),
|
||||
"ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)],
|
||||
"source_md5": str(b.get("source_md5") or "").strip().lower(),
|
||||
"brief_id": str(b.get("brief_id") or "").strip(),
|
||||
"slogan": "",
|
||||
"score": 1.0,
|
||||
"confidence": 1.0,
|
||||
@@ -110,23 +95,25 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
pcfg = config.get("pinterest") or {}
|
||||
analyze_per_term = int(pcfg.get("analyze_per_term", 1))
|
||||
analyze_per_term = 1 # 固定一次 API 请求分析 1 张图(利于 LLM 注意力;不再用 analyze_per_term 配置)
|
||||
concurrency = int(pcfg.get("analyze_concurrency", 3))
|
||||
max_designs = int(pcfg.get("max_designs", 10))
|
||||
n_ref = max(1, int(pcfg.get("ref_images_per_design", 1)))
|
||||
provider = str(pcfg.get("provider") or "openai").strip().lower()
|
||||
|
||||
# 按需分析:只取补齐到目标所需的图片数(batch_size 为上限,不超额分析),并按 md5 去重,
|
||||
# 保证同一图片内容(md5)不会同时被多条简报使用
|
||||
target = int(state.get("pinterest_target") or 0)
|
||||
existing = state.get("briefs") or []
|
||||
remaining = max(0, target - len(existing))
|
||||
# 简报池还有待处理/在途简报 → 不分析新图(等后台消化完再由路由决定下一步)
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
if pipe is not None and hasattr(pipe, "pending_count") and pipe.pending_count() > 0:
|
||||
print(f"[pinterest_analyze] 简报池还有 {pipe.pending_count()} 条在途,本轮不分析")
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
|
||||
# 按需分析:一次把图池当前未消费图片全部分析成简报(搜集完一批→生成一批简报),
|
||||
# 不再按目标任务数截断(不够用了才由路由触发新采集)。analyze_batch 可设上限保护配额。
|
||||
batch_size = int(pcfg.get("analyze_batch", 0))
|
||||
if batch_size <= 0:
|
||||
# 自动:一次分析补齐到「目标所需」或「每词简报上限」的较小值(每张图→1条简报),
|
||||
# 让 pipeline 队列一次有足够任务,时刻保持并发生成(避免每轮只推 6 条导致线程空转)
|
||||
batch_size = min(remaining, max_designs)
|
||||
need = min(batch_size, remaining) if remaining > 0 else 0
|
||||
batch_size = max_designs # 自动:一次最多分析 max_designs 张(每张图→1条简报)
|
||||
need = batch_size
|
||||
|
||||
# 1) 图池取未消费图片(md5 不在 used_images);无 → 返回空,路由触发搜索
|
||||
pool = load_image_pool(output_dir, country)
|
||||
@@ -137,10 +124,6 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
|
||||
if need <= 0:
|
||||
print("[pinterest_analyze] 简报已达标,无需分析")
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
seen_md5: set = set()
|
||||
batch: List[Dict[str, Any]] = []
|
||||
for img in unused:
|
||||
@@ -148,34 +131,27 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if m and m in seen_md5:
|
||||
continue # 同一图片内容(md5)不重复分析
|
||||
seen_md5.add(m)
|
||||
img["brief_id"] = str(uuid.uuid4()) # md5 校验后分配全局唯一 id,用于图-简报对应校验
|
||||
batch.append(img)
|
||||
if len(batch) >= need:
|
||||
break
|
||||
print(f"[pinterest_analyze] 图池取 {len(batch)} 张未消费图片分析(按需 {need},"
|
||||
print(f"[pinterest_analyze] 图池取 {len(batch)} 张未消费图片分析(本批上限 {need},"
|
||||
f"池剩余未消费 {len(unused) - len(batch)} 张,已消费 {len(used)} 张)")
|
||||
|
||||
def _assign_refs(res: List[Dict[str, Any]], chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""按简报的 image_index(LLM 返回)匹配它实际分析的图,写入 source_md5 + ref_images。
|
||||
"""每批只分析 1 张图:简报按顺序对应 chunk 里唯一一张图,写入 source_md5 + ref_images + brief_id。
|
||||
|
||||
全局 id 校验:简报必须带 image_index(对应输入第几张图,0-based);
|
||||
无 image_index(mock 兜底)→ 回退按顺序;无效/越界/重复 → 丢弃该简报(避免错位)。
|
||||
这样 analyze_per_term 可 >1 一次分析多张图提速,简报仍严格对应各自的图。
|
||||
全局 id 校验:分析前已给每张图分配 uuid4(brief_id),简报回填同一 brief_id,
|
||||
保证图-简报严格对应(一次 API 请求一张图,无 image_index 错位问题)。
|
||||
"""
|
||||
chunk_paths = [img["path"] for img in chunk]
|
||||
chunk_md5s = [str(img.get("md5") or "").strip().lower() for img in chunk]
|
||||
used_idx: set = set()
|
||||
chunk_ids = [str(img.get("brief_id") or "") for img in chunk]
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(res):
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
try:
|
||||
idx = int(b.get("image_index"))
|
||||
except (TypeError, ValueError):
|
||||
idx = i # 无 image_index → 回退按顺序
|
||||
if idx < 0 or idx >= len(chunk_paths) or idx in used_idx:
|
||||
print(f"[pinterest_analyze] 简报 image_index={idx} 无效/重复,丢弃(避免图-简报错位)")
|
||||
continue
|
||||
used_idx.add(idx)
|
||||
idx = i if i < len(chunk_paths) else 0 # 一次一张:简报按顺序对应唯一图
|
||||
refs: List[str] = []
|
||||
for k in range(n_ref):
|
||||
src = chunk_paths[(idx + k) % len(chunk_paths)]
|
||||
@@ -183,6 +159,7 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
refs.append(src)
|
||||
b["ref_images"] = refs
|
||||
b["source_md5"] = chunk_md5s[idx] if idx < len(chunk_md5s) else ""
|
||||
b["brief_id"] = chunk_ids[idx] if idx < len(chunk_ids) else ""
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
@@ -210,6 +187,7 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
# 4) 多并发分析(每线程分析一个 chunk;LLM 后端只读 self._cfg,线程安全)
|
||||
raw_briefs: List[Dict[str, Any]] = []
|
||||
_safe_errors = ThreadSafeErrors()
|
||||
|
||||
def _analyze_chunk(chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
term = str(chunk[0].get("term") or "")
|
||||
@@ -225,33 +203,27 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 把每条简报的来源图路径回填为原始图(压缩图仅用于分析,参考图用原图)
|
||||
return _assign_refs(res, chunk)
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "pinterest_analyze", "type": type(e).__name__,
|
||||
"message": f"term[{term}] batch@{len(chunk)}: {e}", "trace": ""})
|
||||
_safe_errors.append({"node": "pinterest_analyze", "type": type(e).__name__,
|
||||
"message": f"term[{term}] batch@{len(chunk)}: {e}", "trace": ""})
|
||||
print(f"[pinterest_analyze] 「{term}」分析失败: {e}")
|
||||
return []
|
||||
return []
|
||||
|
||||
workers = max(1, min(concurrency, len(chunks)))
|
||||
if len(chunks) > 1:
|
||||
print(f"[pinterest_analyze] 并发分析 {len(chunks)} 批({workers} 线程,每批 {analyze_per_term} 张)…")
|
||||
print(f"[pinterest_analyze] 并发分析 {len(chunks)} 批({workers} 线程,每批 1 张)…")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as _ex:
|
||||
_futs = [_ex.submit(_analyze_chunk, c) for c in chunks]
|
||||
for _f in concurrent.futures.as_completed(_futs):
|
||||
raw_briefs.extend(_f.result())
|
||||
# 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加)
|
||||
if len(_safe_errors):
|
||||
errors.extend(list(_safe_errors))
|
||||
|
||||
# 5) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成)
|
||||
# 5) 真实 LLM 图片分析无结果(失败/无有效图片)→ 直接放弃本轮,跳过后续流程
|
||||
# 不再 mock 兜底生成设计(用户要求:分析失败即放弃该产品)
|
||||
if not raw_briefs:
|
||||
try:
|
||||
mock = get_backend("mock")
|
||||
for chunk in chunks:
|
||||
term = str(chunk[0].get("term") or "")
|
||||
paths = [compressed_map.get(img["path"], img["path"]) for img in chunk]
|
||||
res = mock.analyze_pinterest_images(paths, term, country) or []
|
||||
_assign_refs(res, chunk)
|
||||
raw_briefs.extend(res)
|
||||
print(f"[pinterest_analyze] 兜底:mock 规则简报 {len(raw_briefs)} 条")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] mock 兜底失败: {e}")
|
||||
print("[pinterest_analyze] 图片分析无结果,放弃本轮简报(跳过后续流程)")
|
||||
|
||||
# 6) 本批所有图片 md5 一律拉黑(已消费,不再复用)——合适/不合适都拉黑
|
||||
for img in batch:
|
||||
@@ -267,19 +239,20 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if len(kept) < len(raw_briefs):
|
||||
print(f"[pinterest_analyze] 简报过滤:{len(raw_briefs)} → {len(kept)} 条适合印花")
|
||||
|
||||
# 8) 上限 + 去重(同 motif+style 指纹只留一条)
|
||||
# 8) 上限 + 去重(同 brief_id 只留一条;brief_id 为每张图 uuid4,天然唯一)
|
||||
kept = kept[:max_designs]
|
||||
seen: set = set()
|
||||
uniq: List[Dict[str, Any]] = []
|
||||
for b in kept:
|
||||
fp = f"{str(b.get('motif', '')).strip().lower()}|{str(b.get('art_style', '')).strip().lower()}"
|
||||
if fp in seen:
|
||||
fp = str(b.get("brief_id") or b.get("image_prompt") or "").strip().lower()
|
||||
if not fp or fp in seen:
|
||||
continue
|
||||
seen.add(fp)
|
||||
uniq.append(b)
|
||||
kept = uniq
|
||||
|
||||
# 9) 富化 → screened → prompt_node 装配提示词 → 标准 briefs(追加到累计,按需截断到目标数)
|
||||
# 9) 富化 → screened → prompt_node 装配提示词 → 标准 briefs(追加到累计)
|
||||
# 不再按目标任务数截断:搜集完一批→生成一批简报,不够用了才由路由触发新采集
|
||||
existing_topics = [str(b.get("topic", "")).strip() for b in (state.get("briefs") or [])]
|
||||
screened = _enrich_briefs(kept, country, existing_topics)
|
||||
new_briefs: List[Dict[str, Any]] = []
|
||||
@@ -290,10 +263,6 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
old_count = len(state.get("briefs") or [])
|
||||
accumulated = list(state.get("briefs") or [])
|
||||
accumulated.extend(new_briefs)
|
||||
target = int(state.get("pinterest_target") or 0)
|
||||
if target > 0 and len(accumulated) > target:
|
||||
accumulated = accumulated[:target]
|
||||
print(f"[pinterest_analyze] 简报已达目标 {target} 条,截断多余部分")
|
||||
|
||||
# 推送实际新增且保留的简报到并发生成流水线(简报池):边分析边生成设计/三合一/种草图
|
||||
pushed = accumulated[old_count:]
|
||||
|
||||
@@ -55,6 +55,31 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
headless = bool(pcfg.get("headless", False))
|
||||
proxy = pcfg.get("proxy") or None
|
||||
search_mode = str(pcfg.get("search_mode") or "direct").strip().lower()
|
||||
login_check = bool(pcfg.get("login_check", True))
|
||||
login_wait = bool(pcfg.get("login_wait", False))
|
||||
|
||||
# 爬取前静态检测 Pinterest 登录态(不启动 Chrome,只读 .chrome_session cookies):
|
||||
# 未登录/无会话 → 跳过本轮爬取并告警,避免每个搜索词都启动 Chrome 后才发现未登录。
|
||||
login_state: Dict[str, Any] = {"status": "unknown"}
|
||||
if login_check:
|
||||
try:
|
||||
from pinterest_scraper.pinterest_image_capture import check_login_state
|
||||
login_state = check_login_state()
|
||||
except Exception as e: # noqa: BLE001
|
||||
login_state = {"status": "unknown", "detail": f"登录态检测失败: {e}"}
|
||||
status = login_state.get("status")
|
||||
if status in ("logged_out", "no_session"):
|
||||
print(f"[pinterest_scrape] ⚠️ 未检测到 Pinterest 登录态({login_state.get('detail')})。"
|
||||
f"跳过本轮 {len(terms)} 个搜索词爬取。")
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_scrape"] = {
|
||||
"terms": len(terms), "scraped": 0, "skipped": 0, "failed": 0,
|
||||
"images": 0, "pool": len((load_image_pool(output_dir, country).get("images")) or []),
|
||||
"login_status": status,
|
||||
}
|
||||
return {"pinterest_images": {}, "pinterest_attempted": state.get("pinterest_attempted") or [],
|
||||
"pinterest_login": login_state, "stats": stats, "errors": errors}
|
||||
print(f"[pinterest_scrape] 登录态检测:{status}({login_state.get('detail')})")
|
||||
|
||||
# 所有搜索词共享同一个 .chrome_session 登录态目录,Chrome 对同一 user-data-dir 是单例,
|
||||
# 并发启动会互相抢占导致 "browser has been closed",必须串行爬取。
|
||||
@@ -62,7 +87,8 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
print(f"[pinterest_scrape] 共享登录态目录不支持并发,scrape_concurrency 强制为 1(原 {concurrency})")
|
||||
concurrency = 1
|
||||
|
||||
# 一次性探测并校验代理(Pinterest 需代理才能访问;代理失效时给出明确警告,避免逐词静默失败)
|
||||
# 默认走代理:config.pinterest.proxy 未配置时自动探测(环境变量/系统代理/本地常见端口),
|
||||
# 本地 VPN 已开启时探测到的代理即可访问 Pinterest;代理失效时给出明确警告,避免逐词静默失败。
|
||||
if proxy is None:
|
||||
try:
|
||||
from pinterest_scraper.pinterest_image_capture import detect_proxy, get_system_proxy, _validate_proxy
|
||||
@@ -91,7 +117,8 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
from pinterest_scraper.scraper import scrape_pinterest
|
||||
files = scrape_pinterest(term, count=images_per_term,
|
||||
save_dir=str(term_dir), proxy=proxy, headless=headless)
|
||||
save_dir=str(term_dir), proxy=proxy, headless=headless,
|
||||
login_wait=login_wait)
|
||||
results[term] = files
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed.append(term)
|
||||
@@ -150,8 +177,9 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
stats["pinterest_scrape"] = {
|
||||
"terms": len(terms), "scraped": len(results), "skipped": len(skipped),
|
||||
"failed": len(failed), "images": total, "pool": len(pool.get("images") or []),
|
||||
"login_status": login_state.get("status", "unknown"),
|
||||
}
|
||||
print(f"[pinterest_scrape] 完成:{len(results)} 个搜索词,共 {total} 张图(跳过 {len(skipped)},失败 {len(failed)})")
|
||||
|
||||
return {"pinterest_images": results, "pinterest_attempted": attempted,
|
||||
"stats": stats, "errors": errors}
|
||||
"pinterest_login": login_state, "stats": stats, "errors": errors}
|
||||
|
||||
+103
-47
@@ -34,22 +34,18 @@ from typing import Any, Dict, List, Optional
|
||||
from graph.paths import project_root, runtime_root
|
||||
from graph.product import (
|
||||
find_basemap,
|
||||
find_first_model_folder,
|
||||
first_available_sku,
|
||||
list_colors,
|
||||
list_spus,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
from graph.validate import ThreadSafeErrors, with_fallback
|
||||
|
||||
|
||||
_USED_LOCK = threading.Lock() # used_designs.json 并发写锁
|
||||
_MODEL_LOCK = threading.Lock() # 同款共用模特缓存并发锁
|
||||
_MODEL_CACHE: Dict[str, Any] = {} # 同款共用模特:spu_code → model 路径
|
||||
|
||||
|
||||
def _next_img_idx(prod_dir: Path, prefix: str) -> int:
|
||||
"""货号续号:扫 prod_dir 已有 {prefix}{数字}* 文件,返回下一个起始序号(不覆盖旧产物)。"""
|
||||
import re
|
||||
max_n = -1
|
||||
try:
|
||||
if prod_dir.exists():
|
||||
@@ -116,17 +112,30 @@ def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path:
|
||||
return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx"
|
||||
|
||||
|
||||
def _is_fatal_50x(e) -> bool:
|
||||
"""致命图像服务错误(503 / No available compatible accounts)→ 不重试,提前终止。"""
|
||||
try:
|
||||
from graph.pinterest_pipeline import PinterestPipeline
|
||||
return PinterestPipeline.is_fatal_503(e)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
|
||||
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。"""
|
||||
import time as _t
|
||||
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。
|
||||
|
||||
致命 503(账户不可用)重试无效 → 直接抛出,交由调用方终止任务。
|
||||
"""
|
||||
last = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except Exception as e: # noqa: BLE001
|
||||
if _is_fatal_50x(e):
|
||||
raise
|
||||
last = e
|
||||
if i < attempts - 1:
|
||||
_t.sleep(backoff[i])
|
||||
time.sleep(backoff[i])
|
||||
print(f"[product] 图像合成重试 {attempts} 次均失败: {last}")
|
||||
return None
|
||||
|
||||
@@ -135,16 +144,31 @@ def _process_spu(
|
||||
db_path, basemap_root, material_root, category, prod_dir, brief, ib,
|
||||
spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
|
||||
img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048",
|
||||
on_503=None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
|
||||
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
|
||||
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
|
||||
(按货号命名,包含该货号对应的所有图片)。
|
||||
on_503: 致命图像服务错误(503/账户不可用)回调(供调用方提前终止任务)。
|
||||
返回 result dict;内部异常已兜底,不中断。
|
||||
"""
|
||||
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
|
||||
prod_dir = prod_dir / img_code
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _fatal(e) -> bool:
|
||||
"""致命图像服务错误(503/账户不可用)→ 通知 on_503 并返回 True(调用方应立即终止)。"""
|
||||
try:
|
||||
from graph.pinterest_pipeline import PinterestPipeline
|
||||
if PinterestPipeline.is_fatal_503(e):
|
||||
if on_503 is not None:
|
||||
on_503()
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
colors = list_colors(db_path, spu["code"])
|
||||
valid_codes = {c["sku_code"] for c in colors}
|
||||
if sku_code:
|
||||
@@ -220,6 +244,9 @@ def _process_spu(
|
||||
result["design_from"] = "product"
|
||||
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
if _fatal(e):
|
||||
print(f"{tag} 图像服务 503,终止: {e}")
|
||||
return None
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""})
|
||||
print(f"{tag} 设计稿生成失败: {e}")
|
||||
|
||||
@@ -262,17 +289,26 @@ def _process_spu(
|
||||
print(f"{tag} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次)
|
||||
print(f"{tag} 三图合成失败,退避重试…: {e}")
|
||||
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], size=compose_size)
|
||||
if retried is not None:
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
if _fatal(e):
|
||||
print(f"{tag} 图像服务 503,终止: {e}")
|
||||
return None
|
||||
print(f"{tag} 三图合成失败,退避重试…: {e}")
|
||||
try:
|
||||
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], size=compose_size)
|
||||
if retried is not None:
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
return None
|
||||
except Exception as e2: # noqa: BLE001
|
||||
if _fatal(e2):
|
||||
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
|
||||
return None
|
||||
raise
|
||||
else:
|
||||
# mark=1 无模特图 → 统一只做三合一,不做印花+底图两图合成
|
||||
if int(spu.get("mark") or 0) == 1:
|
||||
@@ -289,17 +325,26 @@ def _process_spu(
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图已生成(无模特,底图+印花): {printed_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
|
||||
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], size=compose_size)
|
||||
if retried is not None:
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图重试成功: {printed_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 平铺服装图重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
if _fatal(e):
|
||||
print(f"{tag} 图像服务 503,终止: {e}")
|
||||
return None
|
||||
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
|
||||
try:
|
||||
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], size=compose_size)
|
||||
if retried is not None:
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图重试成功: {printed_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 平铺服装图重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
return None
|
||||
except Exception as e2: # noqa: BLE001
|
||||
if _fatal(e2):
|
||||
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
|
||||
return None
|
||||
raise
|
||||
|
||||
# 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由
|
||||
color_composites: List[Dict[str, Any]] = []
|
||||
@@ -322,6 +367,9 @@ def _process_spu(
|
||||
color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
|
||||
print(f"{tag} 颜色 {sc}({col})三合一已生成: {cp}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
if _fatal(e):
|
||||
print(f"{tag} 颜色 {sc} 三合一遇 503,终止: {e}")
|
||||
return None
|
||||
errors.append({"node": "product", "type": type(e).__name__,
|
||||
"message": f"颜色 {sc} 三合一失败: {e}", "trace": ""})
|
||||
result["color_composites"] = color_composites
|
||||
@@ -445,23 +493,25 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
prod_dir = output_dir / "product"
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 4.1) 任务级模特分配(material_library-<category>):
|
||||
# 一个 SPU 对应一个模特;SPU(不同款)数 > 模特数 → 从全部模特循环兜底(允许重复)
|
||||
model_assign: Dict[str, Any] = {}
|
||||
_all_models: List[str] = []
|
||||
# 4.1) 任务级模特分配(按 spu.mark 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽取):
|
||||
# 每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复
|
||||
model_assign: Dict[int, Any] = {}
|
||||
try:
|
||||
_folder, _all_models = find_first_model_folder(material_root, category)
|
||||
from graph.product import build_mark_model_map, find_model_images_for_mark
|
||||
mark_map = build_mark_model_map(db_path, material_root)
|
||||
except Exception: # noqa: BLE001
|
||||
_all_models = []
|
||||
if _all_models:
|
||||
seen_spu: Dict[str, str] = {}
|
||||
for _i, (_spu, _skus, _tb) in enumerate(worklist):
|
||||
code = _spu.get("code", "")
|
||||
if code not in seen_spu:
|
||||
seen_spu[code] = _all_models[_i % len(_all_models)] # SPU>模特数 → 循环兜底
|
||||
model_assign[code] = seen_spu[code]
|
||||
print(f"[product] 任务级模特分配:{len(seen_spu)} 个 SPU,模特池 {len(_all_models)} 张"
|
||||
f"{'(SPU>模特,循环兜底)' if len(seen_spu) > len(_all_models) else ''}")
|
||||
mark_map = {}
|
||||
for _i, (_spu, _skus, _tb) in enumerate(worklist):
|
||||
mark = str(_spu.get("mark") or "").strip() or "1"
|
||||
folder = mark_map.get(mark, category)
|
||||
try:
|
||||
pool_imgs = find_model_images_for_mark(db_path, material_root, mark, folder)
|
||||
except Exception: # noqa: BLE001
|
||||
pool_imgs = []
|
||||
if pool_imgs:
|
||||
model_assign[_i] = random.choice(pool_imgs) # 过滤后随机抽(按任务序号)
|
||||
if model_assign:
|
||||
print(f"[product] 任务级模特分配:{len(model_assign)} 个产品任务(按 mark 过滤 3:4 后随机抽取)")
|
||||
|
||||
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
|
||||
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
|
||||
@@ -503,8 +553,11 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
concurrency = int(pcfg.get("concurrency") or 0) or min(len(worklist), 5)
|
||||
print(f"[product] 并发 {concurrency}(每 SPU 一线程,上限 {concurrency})处理 {len(worklist)} 个产品任务")
|
||||
|
||||
def _run_one(idx: int, spu, skus, tb):
|
||||
_safe_errors = ThreadSafeErrors()
|
||||
|
||||
def _run_one(wi: int, spu, skus, tb):
|
||||
"""并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
|
||||
idx = start_idx + wi # 实际货号序号(start_idx 起自动续号)
|
||||
img_code = f"{prefix}{idx:03d}" # 货号:图片按此命名(DG000_design.png…)
|
||||
try:
|
||||
# 每个任务用自己的热点设计(designs_map),并拷贝为货号命名(designs/DG000_design.png)
|
||||
@@ -519,9 +572,9 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
tb = dict(tb)
|
||||
tb["design_path"] = design_path
|
||||
r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
|
||||
tb, ib, spu, skus, pcfg, errors, design_path, title_backend,
|
||||
tb, ib, spu, skus, pcfg, _safe_errors, design_path, title_backend,
|
||||
country, img_code=img_code,
|
||||
model_img=model_assign.get(spu.get("code", "")),
|
||||
model_img=model_assign.get(wi), # 按任务序号取独立随机模特
|
||||
design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"),
|
||||
compose_size=str((config.get("compose") or {}).get("size") or "1536x2048"))
|
||||
if r:
|
||||
@@ -535,7 +588,7 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 货号自动续号:任务一开始全部按序分配(start_idx 起),不覆盖已生成的产物
|
||||
start_idx = _next_img_idx(prod_dir, prefix)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
|
||||
futures = [ex.submit(_run_one, start_idx + i, spu, skus, tb)
|
||||
futures = [ex.submit(_run_one, i, spu, skus, tb)
|
||||
for i, (spu, skus, tb) in enumerate(worklist)]
|
||||
for f in concurrent.futures.as_completed(futures):
|
||||
r, img_code = f.result()
|
||||
@@ -545,6 +598,9 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
_record_used(cache_dir, r) # (热点-风格) 去重记录 → 缓存根目录
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加)
|
||||
if len(_safe_errors):
|
||||
errors.extend(list(_safe_errors))
|
||||
|
||||
results.sort(key=lambda x: x.get("img_code", "")) # 按货号排序,模板/清单顺序稳定
|
||||
_write_products(prod_dir, results)
|
||||
|
||||
+44
-18
@@ -22,6 +22,27 @@ PINTEREST_PRINT_SUFFIX = (
|
||||
"no garment, no shirt, no model, no mannequin, no watermark"
|
||||
)
|
||||
|
||||
# Pinterest 生图提示词 4 段结构中第 3 段的引导前缀:把 LLM 产出的 negative_prompt
|
||||
# 转成一条正向「Strictly avoid: ...」条款拼进 image_prompt,让防复制/防商标约束落到生成指令
|
||||
NEG_LEAD = "Strictly avoid: "
|
||||
|
||||
# Pinterest 生图提示词 4 段结构中第 4 段(仅当设计含文字时追加):
|
||||
# 要求模型把引号内的文字按原文逐字正确拼写,避免乱码/拼错
|
||||
SPELLING_RULE = (
|
||||
"Render every phrase shown in quotes exactly as written, "
|
||||
"correctly spelled."
|
||||
)
|
||||
|
||||
# review(疑似商标/受保护主题)简报统一追加的「原创化魔改」引导段:
|
||||
# 只做风格参考,禁复刻品牌/商标/角色,换名换细节,生成通用非侵权致敬式设计。
|
||||
# 两个模式(热点采集 / Pinterest 参考)共用同一文本,避免不一致。
|
||||
REVIEW_REBRAND_HINT = (
|
||||
"IMPORTANT: this theme is ONLY a loose stylistic reference. "
|
||||
"Do NOT reproduce any brand logo, trademark, character, mascot, copyrighted artwork or real person. "
|
||||
"Create a fully ORIGINAL design with a different name and distinct visual details and colors — "
|
||||
"a generic, non-infringing homage in the same mood, clearly distinct from the original."
|
||||
)
|
||||
|
||||
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
|
||||
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
|
||||
_IMG_RISKY_SWAP = {
|
||||
@@ -73,27 +94,32 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
composition = (r.get("composition") or derive_composition(r["topic"], r.get("design_category"))).strip()
|
||||
|
||||
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
|
||||
# Pinterest 简报:直接用 LLM 多模态对图片的描述拼接的 image_prompt(跳过四要素模板),
|
||||
# 仅追加统一的「小印花 + 纯白底」约束段;wearable/composite 仍用模板装配。
|
||||
llm_ip = (r.get("image_prompt") or "").strip()
|
||||
llm_neg = (r.get("negative_prompt") or "").strip()
|
||||
if r.get("source") == "pinterest" and llm_ip:
|
||||
prompts["image_prompt"] = llm_ip + PINTEREST_PRINT_SUFFIX
|
||||
print(f"[prompt] Pinterest 简报用 LLM 多模态描述作为 image_prompt(跳过四要素模板): 「{r['topic']}」")
|
||||
# 文字印花(约 30% 概率):简报有 slogan 时,随机注入文字段到设计稿提示词
|
||||
slogan = (r.get("slogan") or "").strip()
|
||||
if slogan and random.random() < float(config.get("prompt_templates", {}).get("text_ratio", 0.3)):
|
||||
text_seg = (f', with the text "{slogan}" rendered as bold retro typography, '
|
||||
f'lettering clean and correctly spelled, high contrast, as the focal text of the print')
|
||||
prompts["image_prompt"] = prompts["image_prompt"] + text_seg
|
||||
r["used_slogan"] = slogan
|
||||
# review(疑似商标/受保护主题)→ 动态注入「原创化魔改」引导:只做风格参考,禁止复刻品牌/商标/角色,
|
||||
# 换名换细节,生成通用非侵权的致敬式设计
|
||||
# —— Pinterest 参考模式:跳过四要素模板,按 4 段结构拼 image_prompt ——
|
||||
# ① image_prompt(分析模型产出) + 固定输出形态后缀 PINTEREST_PRINT_SUFFIX
|
||||
# ② 负向条款(由 LLM negative_prompt 经 NEG_LEAD 引导,转化进正向指令)
|
||||
# ③ 拼写锁定句 SPELLING_RULE(仅当 LLM image_prompt 已含引号文字段时)
|
||||
# 是否含文字、拼写与否均由分析模型产出决定,本模式不注入 slogan。
|
||||
seg: List[str] = [llm_ip, PINTEREST_PRINT_SUFFIX.strip()]
|
||||
if llm_neg:
|
||||
seg.append(NEG_LEAD + llm_neg)
|
||||
if '"' in llm_ip:
|
||||
seg.append(SPELLING_RULE)
|
||||
prompts["image_prompt"] = ", ".join(seg)
|
||||
print(f"[prompt] Pinterest 简报按 4 段结构拼 image_prompt(跳过四要素模板): 「{r['topic']}」")
|
||||
else:
|
||||
# —— 热点采集模式:四要素模板装配 + 文字印花(约 30% 概率注入 slogan)——
|
||||
slogan = (r.get("slogan") or "").strip()
|
||||
if slogan and random.random() < float(config.get("prompt_templates", {}).get("text_ratio", 0.3)):
|
||||
text_seg = (f', with the text "{slogan}" rendered as bold retro typography, '
|
||||
f'lettering clean and correctly spelled, high contrast, as the focal text of the print')
|
||||
prompts["image_prompt"] = prompts["image_prompt"] + text_seg
|
||||
r["used_slogan"] = slogan
|
||||
# review(疑似商标/受保护主题)→ 追加「原创化魔改」引导(两个模式通用)
|
||||
if str(r.get("risk_level", "")).strip().lower() == "review":
|
||||
prompts["image_prompt"] = (prompts["image_prompt"]
|
||||
+ " IMPORTANT: this theme is ONLY a loose stylistic reference. "
|
||||
"Do NOT reproduce any brand logo, trademark, character, mascot, copyrighted artwork or real person. "
|
||||
"Create a fully ORIGINAL design with a different name and distinct visual details and colors — "
|
||||
"a generic, non-infringing homage in the same mood, clearly distinct from the original.")
|
||||
prompts["image_prompt"] = prompts["image_prompt"] + " " + REVIEW_REBRAND_HINT
|
||||
print(f"[prompt] review 简报注入原创化魔改引导: 「{r['topic']}」")
|
||||
r.update(prompts)
|
||||
r["motif"] = motif
|
||||
|
||||
@@ -20,14 +20,8 @@ from graph.validate import with_fallback
|
||||
def _template_out_path(prod_dir: Path, tpl_name: str) -> Path:
|
||||
"""模板输出路径:默认 {tpl_name}_已填写.xlsx;已存在/被占用则自动换名加序号(同款号多产品不互相覆盖)。"""
|
||||
base = prod_dir / f"{tpl_name}_已填写.xlsx"
|
||||
try:
|
||||
with open(base, "ab"):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
if not base.exists():
|
||||
return base
|
||||
if not base.exists():
|
||||
return base
|
||||
for i in range(2, 100):
|
||||
cand = prod_dir / f"{tpl_name}_已填写_{i}.xlsx"
|
||||
if not cand.exists():
|
||||
@@ -95,6 +89,7 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
sku_codes = [r.get("sku_code") or ""]
|
||||
batch.append({
|
||||
"spu_code": r.get("spu_code", ""),
|
||||
"img_code": r.get("img_code", ""),
|
||||
"sku_codes": sku_codes,
|
||||
"images": [],
|
||||
"spu_per_color": True, # 每颜色一个独立 SPU 块(单色多 SPU)
|
||||
@@ -107,13 +102,24 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"seed_shot_urls": r.get("seed_shot_urls") or [],
|
||||
})
|
||||
|
||||
# 写入模板前按货号(前端自定义前缀+3位计数,如 DG001)最后3位从小到大排序,按顺序插入
|
||||
def _tail_num(rec: Dict[str, Any]) -> tuple:
|
||||
code = str(rec.get("img_code") or rec.get("oss_code") or "")
|
||||
try:
|
||||
return (0, int(code[-3:]))
|
||||
except ValueError:
|
||||
return (1, 0)
|
||||
batch.sort(key=_tail_num)
|
||||
|
||||
exported: List[str] = []
|
||||
if batch:
|
||||
out = _template_out_path(prod_dir, "商品上传")
|
||||
# 输出文件名 = 模板原文件名 + _已填写(如 NEW-波兰男黑T恤_已填写.xlsx)
|
||||
out = _template_out_path(prod_dir, Path(tp).stem)
|
||||
try:
|
||||
out = export_products(
|
||||
db_path, batch, tdir, tp, str(out),
|
||||
markup_percent=float(pcfg.get("markup_percent") or 0),
|
||||
suggested_price_ratio=float(pcfg.get("suggested_price_ratio") or 0),
|
||||
)
|
||||
for r in products:
|
||||
if (r.get("composite_path") or r.get("printed_path")) and (r.get("en_title") or "").strip():
|
||||
|
||||
@@ -88,10 +88,3 @@ def build_oss_key(country: str, timestamp: str, code: str, rand4: str, ext: str
|
||||
"""对象 key:{国家}/{时间戳}/{货号}_{4位随机}.jpg(如 GB/20260820150945/DG000_Ab3x.jpg)。"""
|
||||
safe = lambda s: "".join(c for c in (s or "") if c.isalnum() or c in "-_").strip()
|
||||
return f"{safe(country)}/{safe(timestamp)}/{safe(code)}_{safe(rand4)}{ext}"
|
||||
|
||||
|
||||
def random_code4() -> str:
|
||||
"""4 位随机:大小写英文 + 数字。"""
|
||||
import random
|
||||
import string
|
||||
return "".join(random.choices(string.ascii_letters + string.digits, k=4))
|
||||
|
||||
+190
-22
@@ -12,6 +12,7 @@
|
||||
并发上限与 product_node 一致(默认 5),避免压垮图像网关。
|
||||
"""
|
||||
import concurrent.futures
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
@@ -35,13 +36,23 @@ class PinterestPipeline:
|
||||
self._briefs: List[Dict[str, Any]] = []
|
||||
self._done = False
|
||||
self._cursor = 0
|
||||
# 正在处理(已提交线程池、尚未完成)的简报数,供路由判断简报池是否空闲
|
||||
self._in_flight = 0
|
||||
|
||||
# 结果
|
||||
# 结果:每完成一个产品立即落盘追加写入 products_pending.jsonl,
|
||||
# 内存列表仅作缓存(finish 时再读盘合并),中途崩溃也不丢已完成产品。
|
||||
self._products: List[Dict[str, Any]] = []
|
||||
self._products_lock = threading.Lock()
|
||||
self._errors: List[Dict[str, Any]] = []
|
||||
self._errors_lock = threading.Lock()
|
||||
|
||||
# 致命图像服务错误(53/账户不可用):置位后终止分发、丢弃未完成简报,仅保留已完成产品
|
||||
self._fatal_lock = threading.Lock()
|
||||
self._fatal_503 = False
|
||||
|
||||
# 已完成产品落盘文件(JSONL 追加写):output/<country>/<ts>/products_pending.jsonl
|
||||
self._pending_file = self.output_dir / "products_pending.jsonl"
|
||||
|
||||
# 路径解析(复用 product_node 的 _abs 逻辑:运行根优先,其次数据根)
|
||||
pcfg = self.config.get("product") or {}
|
||||
|
||||
@@ -188,19 +199,28 @@ class PinterestPipeline:
|
||||
return None
|
||||
|
||||
def _assign_models(self) -> Dict[str, Any]:
|
||||
"""任务级模特分配:按 spu.mark 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽取。
|
||||
|
||||
每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复。
|
||||
"""
|
||||
model_assign: Dict[str, Any] = {}
|
||||
try:
|
||||
from graph.product import find_first_model_folder
|
||||
_folder, _all_models = find_first_model_folder(self._material_root, self._category)
|
||||
from graph.product import build_mark_model_map, find_model_images_for_mark
|
||||
mark_map = build_mark_model_map(self._db_path, self._material_root)
|
||||
except Exception: # noqa: BLE001
|
||||
_all_models = []
|
||||
if _all_models:
|
||||
seen: Dict[str, str] = {}
|
||||
for _i, (spu, _skus) in enumerate(self._worklist):
|
||||
code = spu.get("code", "")
|
||||
if code not in seen:
|
||||
seen[code] = _all_models[_i % len(_all_models)]
|
||||
model_assign[code] = seen[code]
|
||||
mark_map = {}
|
||||
# 每个任务按序号绑定独立随机模特(同 SPU 多款也各自随机,不共用)
|
||||
for _i, (spu, _skus) in enumerate(self._worklist):
|
||||
key = f"task_{_i}"
|
||||
mark = str(spu.get("mark") or "").strip() or "1"
|
||||
folder = mark_map.get(mark, self._category)
|
||||
try:
|
||||
pool_imgs = find_model_images_for_mark(self._db_path, self._material_root,
|
||||
mark, folder)
|
||||
except Exception: # noqa: BLE001
|
||||
pool_imgs = []
|
||||
if pool_imgs:
|
||||
model_assign[key] = random.choice(pool_imgs) # 过滤后随机抽
|
||||
return model_assign
|
||||
|
||||
def _load_materials(self) -> Dict[str, str]:
|
||||
@@ -237,6 +257,48 @@ class PinterestPipeline:
|
||||
with self._err400_lock:
|
||||
return self._err400_aborted
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 致命图像服务错误(503 / 账户不可用):重试无效,提前终止整个任务
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def is_fatal_503(exc) -> bool:
|
||||
"""判断异常是否为「图像服务不可用」类致命错误(503 / No available compatible accounts)。
|
||||
|
||||
这类错误说明账户配额耗尽或网关故障,重试必然失败,应提前终止任务而非无意义重试。
|
||||
"""
|
||||
msg = str(exc)
|
||||
if "503" in msg:
|
||||
return True
|
||||
low = msg.lower()
|
||||
return "no available compatible accounts" in low or "account" in low and "not available" in low
|
||||
|
||||
def record_503(self) -> bool:
|
||||
"""记录一次致命 503:首次触发即置位终止标志(后续请求直接短路不再提交)。
|
||||
|
||||
返回 True 表示本次触发终止(调用方应立即停止当前链路)。
|
||||
"""
|
||||
with self._fatal_lock:
|
||||
first = not self._fatal_503
|
||||
self._fatal_503 = True
|
||||
if first:
|
||||
print("[pinterest_pipeline] ⛔ 检测到图像服务 503(No available compatible accounts),"
|
||||
"重试无效 → 提前终止任务,未完成产品将废弃,仅保留已完成产品")
|
||||
return first
|
||||
|
||||
def is_fatal_503_aborted(self) -> bool:
|
||||
with self._fatal_lock:
|
||||
return self._fatal_503
|
||||
|
||||
def abort_unfinished(self) -> None:
|
||||
"""终止分发:丢弃简报池中所有未完成简报(已完成的落盘产品保留)。"""
|
||||
with self._cond:
|
||||
dropped = len(self._briefs)
|
||||
self._briefs = []
|
||||
self._done = True
|
||||
self._cond.notify_all()
|
||||
if dropped:
|
||||
print(f"[pinterest_pipeline] 503 终止:丢弃未完成简报 {dropped} 条(未完成产品废弃)")
|
||||
|
||||
def _abort_current_term(self) -> None:
|
||||
"""放弃当前种子词:清空其未完成简报 + 图池未消费图片(已完成的保留)。"""
|
||||
term = self._err400_term
|
||||
@@ -298,18 +360,52 @@ class PinterestPipeline:
|
||||
self._cond.notify_all()
|
||||
print(f"[pinterest_pipeline] 简报池 +{len(briefs)} 条(待处理 {len(self._briefs)})")
|
||||
|
||||
def pending_count(self) -> int:
|
||||
"""简报池中待处理 + 正在处理的简报数(供路由判断是否需要补图/补分析)。"""
|
||||
with self._cond:
|
||||
queued = len(self._briefs)
|
||||
return queued + self._in_flight
|
||||
|
||||
def wait_idle(self, timeout: Optional[float] = None) -> bool:
|
||||
"""阻塞等待简报池消化完(无待处理且无在途),返回是否已空闲。
|
||||
|
||||
用 Condition 等待(_process_one 完成时 notify_all 唤醒),而非轮询 sleep,
|
||||
避免 wait 循环疯狂刷屏。timeout 为 None 时无限等待(受 _done 保护)。
|
||||
"""
|
||||
with self._cond:
|
||||
while (self._briefs or self._in_flight > 0) and not self._done:
|
||||
if timeout is not None:
|
||||
deadline = time.time() + timeout
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
self._cond.wait(min(remaining, 1.0))
|
||||
else:
|
||||
self._cond.wait()
|
||||
return not self._briefs and self._in_flight <= 0
|
||||
|
||||
def finish(self) -> tuple:
|
||||
"""排空简报池、等待全部产品完成,返回 (products, errors)。"""
|
||||
"""排空简报池、等待全部产品完成,返回 (products, errors)。
|
||||
|
||||
产品来源:手动已完成(内存缓存)+ 落盘文件(products_pending.jsonl)
|
||||
按货号去重合并——即使中途 503 终止/崩溃,已完成产品也不丢。
|
||||
"""
|
||||
with self._cond:
|
||||
self._done = True
|
||||
self._cond.notify_all()
|
||||
self._dispatcher.join()
|
||||
self._pool.shutdown(wait=True)
|
||||
# 读盘 + 内存合并去重(内存为准,但以落盘为最终权威——崩溃恢复后走落盘)
|
||||
pending = self.load_pending()
|
||||
merged = {str(p.get("img_code", "")): p for p in pending}
|
||||
with self._products_lock:
|
||||
products = list(self._products)
|
||||
for p in self._products:
|
||||
merged[str(p.get("img_code", ""))] = p
|
||||
products = [merged[k] for k in merged if k]
|
||||
with self._errors_lock:
|
||||
errors = list(self._errors)
|
||||
print(f"[pinterest_pipeline] 收尾:完成 {len(products)} 个产品,错误 {len(errors)}")
|
||||
print(f"[pinterest_pipeline] 收尾:完成 {len(products)} 个产品,错误 {len(errors)}"
|
||||
f"{'(含落盘恢复 ' + str(len(pending)) + ')' if pending else ''}")
|
||||
return products, errors
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -322,8 +418,16 @@ class PinterestPipeline:
|
||||
self._cond.wait()
|
||||
if self._done and not self._briefs:
|
||||
break
|
||||
if self._fatal_503:
|
||||
# 致命 503:不再分发新简报(未完成的废弃,仅保留已完成落盘产品)
|
||||
self._briefs = []
|
||||
self._done = True
|
||||
self._cond.notify_all()
|
||||
break
|
||||
batch = self._briefs
|
||||
self._briefs = []
|
||||
# 与 _briefs 清空同一临界区递增在途数,避免 wait_idle 误判空闲
|
||||
self._in_flight += len(batch)
|
||||
for b in batch:
|
||||
with self._cond:
|
||||
idx = self._cursor
|
||||
@@ -335,6 +439,8 @@ class PinterestPipeline:
|
||||
# ------------------------------------------------------------------ #
|
||||
def _process_one(self, brief: Dict[str, Any], idx: int) -> None:
|
||||
try:
|
||||
if self.is_fatal_503_aborted():
|
||||
return
|
||||
# 0) 先定货号:整条链路(设计/三合一/种草图)都用它命名与匹配,避免序号错位
|
||||
if idx >= len(self._worklist):
|
||||
print(f"[pinterest_pipeline] 简报 {idx} 无对应产品任务,跳过")
|
||||
@@ -343,6 +449,8 @@ class PinterestPipeline:
|
||||
img_code = f"{self._prefix}{idx:03d}"
|
||||
# 1) 生成设计(compose)——直接按货号命名 designs/{img_code}_design.png
|
||||
design_path = self._gen_design(brief, img_code)
|
||||
if self.is_fatal_503_aborted():
|
||||
return
|
||||
if self.is_400_aborted():
|
||||
# 当前种子词 400 超限已放弃:正在生成的当个也放弃,不进入后续链路
|
||||
print(f"[pinterest_pipeline] 当前种子词 400 超限已放弃,跳过简报 {idx}")
|
||||
@@ -357,13 +465,17 @@ class PinterestPipeline:
|
||||
return
|
||||
brief = new_brief
|
||||
design_path = self._gen_design(brief, img_code)
|
||||
if self.is_fatal_503_aborted():
|
||||
return
|
||||
if design_path:
|
||||
break
|
||||
if not design_path:
|
||||
return
|
||||
brief["design_path"] = design_path
|
||||
# 2) 三合一(product)——同一货号
|
||||
prod = self._process_spu(brief, spu, skus, img_code, design_path)
|
||||
# 2) 三合一(product)——同一货号;task_idx=本次简报序号,模特按任务独立随机
|
||||
prod = self._process_spu(brief, spu, skus, img_code, design_path, task_idx=idx)
|
||||
if self.is_fatal_503_aborted():
|
||||
return
|
||||
if not prod:
|
||||
return
|
||||
# 3) OSS 上传
|
||||
@@ -376,6 +488,8 @@ class PinterestPipeline:
|
||||
_record_used(self.cache_dir, prod)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# 5) 落盘:每完成一个产品立即追加写入 products_pending.jsonl(不依赖内存,崩溃不丢)
|
||||
self._persist_product(prod)
|
||||
with self._products_lock:
|
||||
self._products.append(prod)
|
||||
print(f"[pinterest_pipeline] 产品完成: {prod.get('img_code', '')}"
|
||||
@@ -385,6 +499,42 @@ class PinterestPipeline:
|
||||
self._errors.append({"node": "pinterest_pipeline", "type": type(e).__name__,
|
||||
"message": f"简报 {idx} 处理失败: {e}", "trace": ""})
|
||||
print(f"[pinterest_pipeline] 简报 {idx} 处理失败: {e}")
|
||||
finally:
|
||||
with self._cond:
|
||||
self._in_flight = max(0, self._in_flight - 1)
|
||||
self._cond.notify_all()
|
||||
|
||||
def _persist_product(self, prod: Dict[str, Any]) -> None:
|
||||
"""把已完成产品追加写入 products_pending.jsonl(JSONL 每行一个产品)。
|
||||
|
||||
落盘失败不阻塞主流程(仅告警);finish() 时读盘合并,保证已完成产品不丢。
|
||||
"""
|
||||
try:
|
||||
import json as _json
|
||||
self._pending_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._pending_file, "a", encoding="utf-8") as f:
|
||||
f.write(_json.dumps(prod, ensure_ascii=False) + "\n")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_pipeline] 产品落盘失败(不影响流程): {e}")
|
||||
|
||||
def load_pending(self) -> List[Dict[str, Any]]:
|
||||
"""读回 products_pending.jsonl 中已落盘的产品(进程重启/崩溃恢复用)。"""
|
||||
import json as _json
|
||||
out: List[Dict[str, Any]] = []
|
||||
if not self._pending_file.exists():
|
||||
return out
|
||||
try:
|
||||
for line in self._pending_file.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
out.append(_json.loads(line))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_pipeline] 读回落盘产品失败: {e}")
|
||||
return out
|
||||
|
||||
def _gen_design(self, brief: Dict[str, Any], img_code: str) -> Optional[str]:
|
||||
if self._ib is None:
|
||||
@@ -398,10 +548,18 @@ class PinterestPipeline:
|
||||
if self.record_400():
|
||||
self._abort_current_term()
|
||||
|
||||
def _on_503():
|
||||
self.record_503()
|
||||
self.abort_unfinished()
|
||||
|
||||
return generate_design(self._ib, brief, design_dir, img_code, self._errors,
|
||||
on_400=_on_400,
|
||||
on_400=_on_400, on_503=_on_503,
|
||||
size=str((self.config.get("compose") or {}).get("design_size") or "1024x1024"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
if self.is_fatal_503(e):
|
||||
self.record_503()
|
||||
self.abort_unfinished()
|
||||
return None
|
||||
with self._errors_lock:
|
||||
self._errors.append({"node": "compose", "type": type(e).__name__,
|
||||
"message": f"设计稿生成失败 {brief.get('topic', '')}: {e}",
|
||||
@@ -473,7 +631,7 @@ class PinterestPipeline:
|
||||
return None
|
||||
|
||||
def _process_spu(self, brief: Dict[str, Any], spu, skus: str, img_code: str,
|
||||
design_path: str) -> Optional[Dict[str, Any]]:
|
||||
design_path: str, task_idx: int = 0) -> Optional[Dict[str, Any]]:
|
||||
from graph.nodes.product_node import _process_spu as _ps
|
||||
prod_dir = self.output_dir / "product"
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -483,7 +641,9 @@ class PinterestPipeline:
|
||||
r = _ps(self._db_path, self._basemap_root, self._material_root, self._category,
|
||||
prod_dir, brief, self._ib, spu, skus, self.config.get("product") or {},
|
||||
self._errors, design_path, self._title_backend, self.country,
|
||||
img_code=img_code, model_img=self._model_assign.get(spu.get("code", "")))
|
||||
img_code=img_code,
|
||||
model_img=self._model_assign.get(f"task_{task_idx}"),
|
||||
on_503=lambda: (self.record_503(), self.abort_unfinished()))
|
||||
if r:
|
||||
r["img_code"] = img_code
|
||||
return r
|
||||
@@ -559,9 +719,17 @@ class PinterestPipeline:
|
||||
if not base or not Path(base).exists():
|
||||
print(f"[pinterest_pipeline] {r.get('spu_code', '')} 参考图缺失,跳过该色种草图")
|
||||
continue
|
||||
generated = generate_seed_shots(self._ib, base, cn, material, n, str(shot_dir),
|
||||
r.get("composite_negative", ""),
|
||||
size=size, prefix=pfx, gender=self._gender)
|
||||
try:
|
||||
generated = generate_seed_shots(self._ib, base, cn, material, n, str(shot_dir),
|
||||
r.get("composite_negative", ""),
|
||||
size=size, prefix=pfx, gender=self._gender)
|
||||
except Exception as e: # noqa: BLE001
|
||||
if self.is_fatal_503(e):
|
||||
self.record_503()
|
||||
self.abort_unfinished()
|
||||
return
|
||||
print(f"[pinterest_pipeline] 种草图生成失败(跳过该色): {e}")
|
||||
continue
|
||||
paths.extend(generated)
|
||||
if not paths:
|
||||
return
|
||||
|
||||
@@ -11,6 +11,8 @@ import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# 支持的图片格式:模特图/底图均按此识别(png/jpg 等常见格式全覆盖;AVIF/GIF/TIFF 亦支持)
|
||||
IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".avif", ".gif", ".tiff", ".tif"}
|
||||
|
||||
@@ -114,6 +116,104 @@ def find_first_model_folder(material_root, preferred: Optional[str] = None):
|
||||
return None, []
|
||||
|
||||
|
||||
def image_ratio_ok(path, target_ratio: float = 3 / 4, tolerance: float = 0.06) -> bool:
|
||||
"""图片宽高比是否接近目标比例(默认 3:4,相对容差 6%)。
|
||||
|
||||
相对容差:接受 [target*(1-tol), target*(1+tol)],对 3:4 即 0.705~0.795。
|
||||
无法解析的图片(损坏/非标准)按不通过处理,避免坏图被当模特。
|
||||
"""
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
w, h = im.size
|
||||
if w <= 0 or h <= 0:
|
||||
return False
|
||||
ratio = w / h
|
||||
lo = target_ratio * (1 - tolerance)
|
||||
hi = target_ratio * (1 + tolerance)
|
||||
return lo <= ratio <= hi
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def build_mark_model_map(db_path, material_root) -> Dict[str, str]:
|
||||
"""启动任务前检测 spu.mark 字段,建立 {mark: 模特文件夹名} 字典。
|
||||
|
||||
规则:
|
||||
- 读取 spu 表全部 mark 值(去重);
|
||||
- 每个 mark 映射到 material_library/<mark> 目录(目录名与 mark 一致);
|
||||
- 目录不存在时回退到 category 默认目录(T-shirt);
|
||||
- 目前库中 mark=1 → 映射到 material_library/T-shirt。
|
||||
"""
|
||||
mark_map: Dict[str, str] = {}
|
||||
try:
|
||||
root = Path(material_root)
|
||||
if not root.exists():
|
||||
return mark_map
|
||||
folders = [d.name for d in sorted(root.iterdir()) if d.is_dir()]
|
||||
if not folders:
|
||||
return mark_map
|
||||
# 读 spu.mark 实际值(去重)
|
||||
marks: List[str] = []
|
||||
try:
|
||||
conn = _connect(db_path)
|
||||
rows = conn.execute("SELECT DISTINCT mark FROM SPU WHERE mark IS NOT NULL AND mark != ''").fetchall()
|
||||
conn.close()
|
||||
marks = [str(r["mark"]).strip() for r in rows if str(r["mark"]).strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
marks = []
|
||||
if not marks:
|
||||
marks = ["1"] # 库无 mark 数据时按默认 1 处理
|
||||
for m in marks:
|
||||
if m in folders:
|
||||
mark_map[m] = m
|
||||
else:
|
||||
# mark 无同名目录 → 回退默认 T-shirt(当前 mark=1 → T-shirt)
|
||||
mark_map[m] = "T-shirt" if "T-shirt" in folders else folders[0]
|
||||
print(f"[product] mark→模特目录映射: {mark_map}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[product] mark→模特目录映射构建失败: {e}")
|
||||
return mark_map
|
||||
|
||||
|
||||
def find_model_images_for_mark(db_path, material_root, mark, category: str = "T-shirt",
|
||||
ratio: float = 3 / 4, tolerance: float = 0.06) -> List[Path]:
|
||||
"""按 spu.mark 定位模特目录,过滤非目标比例图片,返回合格图片列表。
|
||||
|
||||
- mark 有对应目录(material_library/<mark>)→ 用该目录;
|
||||
- 否则回退 category(如 T-shirt);
|
||||
- 过滤掉非 3:4 比例(默认容差 6%)的图片;
|
||||
- 返回过滤后的图片列表(供调用方随机抽取)。
|
||||
"""
|
||||
root = Path(material_root)
|
||||
if not root.exists():
|
||||
return []
|
||||
d = None
|
||||
if mark is not None:
|
||||
cand = root / str(mark)
|
||||
if cand.is_dir():
|
||||
d = cand
|
||||
if d is None:
|
||||
cand = root / category
|
||||
if cand.is_dir():
|
||||
d = cand
|
||||
if d is None:
|
||||
# 兜底:第一个有图的目录(跳过无图目录)
|
||||
for sub in sorted(root.iterdir()):
|
||||
if not sub.is_dir():
|
||||
continue
|
||||
if any(f.is_file() and f.suffix.lower() in IMG_EXTS for f in sub.iterdir()):
|
||||
d = sub
|
||||
break
|
||||
if d is None:
|
||||
return []
|
||||
imgs = [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS]
|
||||
ok = [f for f in imgs if image_ratio_ok(f, ratio, tolerance)]
|
||||
if len(ok) < len(imgs):
|
||||
print(f"[product] 模特目录 {d.name}/ 过滤非 {int(ratio * 100)}:{int(ratio * 100) + 1} 比例:"
|
||||
f"{len(imgs)} → {len(ok)} 张")
|
||||
return ok
|
||||
|
||||
|
||||
def first_available_sku(db_path, basemap_root, spu_code: str) -> Optional[str]:
|
||||
"""返回该款号下第一个「本地有底图」的 SKU.code;无则 None。"""
|
||||
for c in list_colors(db_path, spu_code):
|
||||
|
||||
@@ -33,6 +33,7 @@ class AgentState(TypedDict, total=False):
|
||||
pinterest_search_terms: List[str] # pinterest_search 产出:LLM 生成的搜索词
|
||||
pinterest_images: Dict[str, List[str]] # pinterest_scrape 产出:搜索词 → 爬取图片路径列表
|
||||
pinterest_briefs: List[Dict[str, Any]] # pinterest_analyze 产出:LLM 分析图片的原始设计简报
|
||||
pinterest_login: Dict[str, Any] # pinterest_scrape 产出:登录态检测结果 {status, detail, ...}
|
||||
|
||||
# —— Pinterest 按需搜索循环状态 ——
|
||||
pinterest_target: int # 目标简报数(= spu_tasks 数量,每款一个设计)
|
||||
|
||||
+110
-9
@@ -25,6 +25,34 @@ _COUNTRY_NAME_MAP = {
|
||||
"沙特": "沙特阿拉伯",
|
||||
}
|
||||
|
||||
# 成分值字典映射:db 成分值 → 女装模板下拉框选项(男装模板选项与 db 值一致,直接保留)。
|
||||
# 女装模板(如 SatVoy 沙特)成分下拉框是「中文+英文」格式(棉Cotton),db 存中文(棉),需映射。
|
||||
_COMPONENT_FEMALE_MAP = {
|
||||
"棉": "棉Cotton",
|
||||
"聚酯纤维": "聚酯纤维(涤纶)Polyester",
|
||||
"氨纶": "氨纶Elastane",
|
||||
"锦纶": "锦纶(尼龙)Polyamide",
|
||||
"再生聚酯纤维": "聚酯纤维(涤纶)Polyester", # 女装无再生聚酯纤维,归入聚酯纤维
|
||||
"棉Cotton": "棉Cotton", # 已是女装格式,保持
|
||||
}
|
||||
|
||||
|
||||
def _map_component(value: Any, gender: Optional[str]) -> Any:
|
||||
"""成分值按性别映射:male/None 保留原值;female 查女装字典(找不到保留原值)。"""
|
||||
if gender != "female" or value in (None, ""):
|
||||
return value
|
||||
return _COMPONENT_FEMALE_MAP.get(value, value)
|
||||
|
||||
|
||||
def _template_gender(template_path: str) -> Optional[str]:
|
||||
"""通过表头解析模版「类目」判断男装/女装:类目含「男」→male;含「女」→female;都不含→None。
|
||||
用于成分值映射(女装模板下拉框是「中文+英文」格式,需字典映射;男装保留 db 值)。"""
|
||||
try:
|
||||
from graph.seed_shot import gender_from_category, read_template_category
|
||||
return gender_from_category(read_template_category(template_path))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _size_rank(size) -> tuple:
|
||||
"""把尺码字符串转成可排序 rank(从小到大)。
|
||||
@@ -206,9 +234,11 @@ def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_
|
||||
|
||||
def _build_spu_row(spu: Dict[str, Any], spu_code: str, origin_province: str,
|
||||
color: Optional[str] = None,
|
||||
fabric_headers: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
fabric_headers: Optional[List[str]] = None,
|
||||
gender: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=经营站点;多颜色时用色值列区分)。
|
||||
fabric 填「面料弹性」列(fabric_headers,如 SPU商品属性-面料弹性,检测到才填 spu.fabric)。"""
|
||||
fabric 填「面料弹性」列(fabric_headers,如 SPU商品属性-面料弹性,检测到才填 spu.fabric)。
|
||||
component_1/2/3 按性别映射(gender=female 时查 _COMPONENT_FEMALE_MAP,男装/None 保留原值)。"""
|
||||
row: Dict[str, Any] = {
|
||||
"基础信息-商品层级": "spu",
|
||||
"SKC货号": spu_code, # code 路由为 SKC货号(用户要求)
|
||||
@@ -220,6 +250,8 @@ def _build_spu_row(spu: Dict[str, Any], spu_code: str, origin_province: str,
|
||||
row["色值(主规格)"] = color
|
||||
for dbk, header in SPU_MAP.items():
|
||||
v = spu.get(dbk)
|
||||
if dbk in ("component_1", "component_2", "component_3"):
|
||||
v = _map_component(v, gender)
|
||||
if v not in (None, ""):
|
||||
row[header] = v
|
||||
fabric = spu.get("fabric")
|
||||
@@ -251,15 +283,39 @@ def _find_sa_size_headers(router) -> List[str]:
|
||||
return [str(k) for k in router.column_map if "沙特阿拉伯码" in str(k)]
|
||||
|
||||
|
||||
def _find_suggested_price_headers(router) -> List[str]:
|
||||
"""定位「建议售价」列(不含「单位」);匹配到多个时全部返回。"""
|
||||
return [str(k) for k in router.column_map if "建议售价" in str(k) and "单位" not in str(k)]
|
||||
|
||||
|
||||
def _find_suggested_unit_headers(router) -> List[str]:
|
||||
"""定位「建议售价单位」列。"""
|
||||
return [str(k) for k in router.column_map if "建议售价单位" in str(k)]
|
||||
|
||||
|
||||
def _read_suggested_required(router, col: int) -> bool:
|
||||
"""读取「建议售价」列下方注意事项,判断是否必填。
|
||||
结合「非必填/必填」判断(不能只看「必填」两字):含「非必填」→非必填;否则含「必填」→必填。"""
|
||||
note = str(router.ws.cell(router.header_row + 1, col).value or "")
|
||||
if "非必填" in note:
|
||||
return False
|
||||
return "必填" in note
|
||||
|
||||
|
||||
def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: str,
|
||||
warehouses: List[str], markup_percent: float = 0.0,
|
||||
multi: bool = True, price_header: str = "申报价格-日本站",
|
||||
bust_headers: Optional[List[str]] = None,
|
||||
price_headers: Optional[List[str]] = None,
|
||||
sa_size_headers: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
sa_size_headers: Optional[List[str]] = None,
|
||||
suggested_price_ratio: float = 0.0,
|
||||
suggested_price_headers: Optional[List[str]] = None,
|
||||
suggested_required: Optional[Dict[str, bool]] = None,
|
||||
suggested_unit_headers: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。
|
||||
价格(price_headers 列,如 申报价格-美国站/日本站,模糊匹配到多个时全部填)= SKU.price × (1+markup/100),
|
||||
预先填好。bust 填所有「胸围」列(bust_headers,如 基码表-胸围(cm)/胸围全围(cm),检测到才填)。
|
||||
预先填好。建议售价(suggested_price_headers 列,模板「建议售价」必填时才填)= 申报价格 × (1+suggested_price_ratio/100),
|
||||
填了建议售价同时填「建议售价单位」=CNY。bust 填所有「胸围」列(bust_headers,如 基码表-胸围(cm)/胸围全围(cm),检测到才填)。
|
||||
size 填「尺码」列 + 所有「沙特阿拉伯码」列(sa_size_headers,如 尺码表-沙特阿拉伯码,检测到才填)。
|
||||
规格类型2 统一填「尺码」两个字(不是 size 参数值)。"""
|
||||
row: Dict[str, Any] = {
|
||||
@@ -295,6 +351,14 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color:
|
||||
v = round(float(v) * (1 + markup_percent / 100), 2) # 申报价格 = price × (1+加价%)
|
||||
for h in price_headers:
|
||||
row[h] = v
|
||||
# 建议售价 = 申报价格 × (1+建议售价比例%);模板「建议售价」必填时才填,填了同时填单位 CNY
|
||||
if suggested_price_headers and suggested_price_ratio > 0:
|
||||
suggested = round(v * (1 + suggested_price_ratio / 100), 2)
|
||||
for h in suggested_price_headers:
|
||||
if (suggested_required or {}).get(h, True):
|
||||
row[h] = suggested
|
||||
for uh in suggested_unit_headers:
|
||||
row[uh] = "CNY"
|
||||
continue
|
||||
if dbk == "size":
|
||||
if v in (None, ""):
|
||||
@@ -445,6 +509,11 @@ def _insert_product_block(
|
||||
bust_headers: Optional[List[str]] = None,
|
||||
fabric_headers: Optional[List[str]] = None,
|
||||
sa_size_headers: Optional[List[str]] = None,
|
||||
gender: Optional[str] = None,
|
||||
suggested_price_ratio: float = 0.0,
|
||||
suggested_price_headers: Optional[List[str]] = None,
|
||||
suggested_required: Optional[Dict[str, bool]] = None,
|
||||
suggested_unit_headers: Optional[List[str]] = None,
|
||||
) -> List[int]:
|
||||
"""在已打开的 router 中插入一个产品的 SPU+SKU 块并填充设计字段,返回本块行号。
|
||||
|
||||
@@ -474,7 +543,8 @@ def _insert_product_block(
|
||||
# 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充)
|
||||
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers),
|
||||
_build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers,
|
||||
gender=gender),
|
||||
match="exact",
|
||||
))
|
||||
for ci, (sc, skus) in enumerate(skus_by_color):
|
||||
@@ -488,7 +558,11 @@ def _insert_product_block(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=True,
|
||||
bust_headers=bust_headers, price_headers=price_headers,
|
||||
sa_size_headers=sa_size_headers),
|
||||
sa_size_headers=sa_size_headers,
|
||||
suggested_price_ratio=suggested_price_ratio,
|
||||
suggested_price_headers=suggested_price_headers,
|
||||
suggested_required=suggested_required,
|
||||
suggested_unit_headers=suggested_unit_headers),
|
||||
spu_code=spu_code, match="exact",
|
||||
))
|
||||
|
||||
@@ -504,7 +578,8 @@ def _insert_product_block(
|
||||
else:
|
||||
# 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers), match="exact"))
|
||||
_build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers,
|
||||
gender=gender), match="exact"))
|
||||
multi_variant = len(skus_by_color) > 1
|
||||
for ci, (sc, skus) in enumerate(skus_by_color):
|
||||
first = skus[0]
|
||||
@@ -515,7 +590,11 @@ def _insert_product_block(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=multi_variant,
|
||||
bust_headers=bust_headers, price_headers=price_headers,
|
||||
sa_size_headers=sa_size_headers),
|
||||
sa_size_headers=sa_size_headers,
|
||||
suggested_price_ratio=suggested_price_ratio,
|
||||
suggested_price_headers=suggested_price_headers,
|
||||
suggested_required=suggested_required,
|
||||
suggested_unit_headers=suggested_unit_headers),
|
||||
))
|
||||
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
|
||||
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
|
||||
@@ -554,6 +633,7 @@ def export_product(
|
||||
seed_shot_urls: Optional[List[str]] = None,
|
||||
append_to: str = "",
|
||||
markup_percent: float = 0.0,
|
||||
suggested_price_ratio: float = 0.0,
|
||||
) -> Path:
|
||||
"""生成商品上传"已填写"模板(支持多产品合并到同一文件)。
|
||||
|
||||
@@ -584,6 +664,11 @@ def export_product(
|
||||
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
|
||||
fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填)
|
||||
sa_size_headers = _find_sa_size_headers(router) # 沙特阿拉伯码列(尺码表-沙特阿拉伯码…检测到才填)
|
||||
gender = _template_gender(template_path) # 类目含「男」→male /「女」→female(成分值映射用)
|
||||
suggested_price_headers = _find_suggested_price_headers(router) # 建议售价列(不含单位)
|
||||
suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列
|
||||
suggested_required = {h: _read_suggested_required(router, router.column_map[h])
|
||||
for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断)
|
||||
_insert_product_block(router, db_path, spu_code, sku_code,
|
||||
origin_province, warehouses, price_headers,
|
||||
markup_percent=markup_percent, images=images,
|
||||
@@ -594,7 +679,12 @@ def export_product(
|
||||
seed_shot_urls=seed_shot_urls,
|
||||
bust_headers=bust_headers,
|
||||
fabric_headers=fabric_headers,
|
||||
sa_size_headers=sa_size_headers)
|
||||
sa_size_headers=sa_size_headers,
|
||||
gender=gender,
|
||||
suggested_price_ratio=suggested_price_ratio,
|
||||
suggested_price_headers=suggested_price_headers,
|
||||
suggested_required=suggested_required,
|
||||
suggested_unit_headers=suggested_unit_headers)
|
||||
out = router.save(out_path)
|
||||
return Path(out)
|
||||
finally:
|
||||
@@ -611,6 +701,7 @@ def export_products(
|
||||
template_path: str,
|
||||
out_path: str,
|
||||
markup_percent: float = 0.0,
|
||||
suggested_price_ratio: float = 0.0,
|
||||
) -> Path:
|
||||
"""批量合并导出:所有产品一次性写入同一模板,只打开/保存一次。
|
||||
|
||||
@@ -628,6 +719,11 @@ def export_products(
|
||||
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
|
||||
fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填)
|
||||
sa_size_headers = _find_sa_size_headers(router) # 沙特阿拉伯码列(尺码表-沙特阿拉伯码…检测到才填)
|
||||
gender = _template_gender(template_path) # 类目含「男」→male /「女」→female(成分值映射用)
|
||||
suggested_price_headers = _find_suggested_price_headers(router) # 建议售价列(不含单位)
|
||||
suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列
|
||||
suggested_required = {h: _read_suggested_required(router, router.column_map[h])
|
||||
for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断)
|
||||
for r in products:
|
||||
try:
|
||||
_insert_product_block(
|
||||
@@ -647,6 +743,11 @@ def export_products(
|
||||
bust_headers=bust_headers,
|
||||
fabric_headers=fabric_headers,
|
||||
sa_size_headers=sa_size_headers,
|
||||
gender=gender,
|
||||
suggested_price_ratio=suggested_price_ratio,
|
||||
suggested_price_headers=suggested_price_headers,
|
||||
suggested_required=suggested_required,
|
||||
suggested_unit_headers=suggested_unit_headers,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[template_export] 产品 {r.get('spu_code')} 写入失败,跳过: {e}")
|
||||
|
||||
@@ -8,10 +8,34 @@
|
||||
剔除非法记录并记录原因,保证下游拿到的数据"形状正确"。
|
||||
"""
|
||||
import functools
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class ThreadSafeErrors:
|
||||
"""线程安全的错误收集器:并发节点(compose/product 等)内 append 错误用。
|
||||
|
||||
避免多个 worker 线程直接写共享 list 造成竞态;主线程统一合并到 state['errors']。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._items: List[Dict[str, Any]] = []
|
||||
|
||||
def append(self, item: Dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self._items.append(item)
|
||||
|
||||
def __iter__(self):
|
||||
with self._lock:
|
||||
return iter(list(self._items))
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._items)
|
||||
|
||||
|
||||
def with_fallback(node_name: str):
|
||||
"""装饰器:捕获节点异常,转为 state['errors'] 中的一条记录,返回空更新。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user