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:
2026-08-28 10:28:35 +08:00
parent 685b7b0862
commit 2a96ec0870
28 changed files with 1187 additions and 729 deletions
+55 -14
View File
@@ -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)
+69 -10
View File
@@ -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}")
+3 -6
View File
@@ -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",
+83 -67
View File
@@ -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 按图逐张产出简报,顺序一一对应)
+22 -2
View File
@@ -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)}
+4 -4
View File
@@ -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] # 已过滤去重的关键词
+7 -7
View File
@@ -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")
+46 -77
View File
@@ -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_indexmock 兜底)→ 回退按顺序;无效/越界/重复 → 丢弃该简报(避免错位)。
这样 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:]
+31 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+15 -9
View File
@@ -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():
-7
View File
@@ -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
View File
@@ -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] ⛔ 检测到图像服务 503No 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.jsonlJSONL 每行一个产品)。
落盘失败不阻塞主流程(仅告警);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
+100
View File
@@ -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):
+1
View File
@@ -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
View File
@@ -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}")
+24
View File
@@ -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'] 中的一条记录,返回空更新。