v92-v105 多国适配 + 模板导出增强 + 生图可靠性优化

- 新增韩国(KR)适配:countries/pinterest 种子词、K-pop 提示词、UI 国家列表
- 爬虫跨运行持久化已采集 URL(.collected_urls.json),同关键词重复搜索采新图
- 模板导出增强:SPU 商品属性列名前缀剥离匹配、尺码下拉框 INDIRECT 动态引用、
  SPU 字段映射(袖长/门襟/胸垫等)、季节/印花图案女装映射、建议售价统一必填、
  SKU 分类/数量/单位直接填默认值
- 自定义模式:多模态提示词独立(custom_analyze_*)、生图提示词模板可配置
- Pinterest:空选品守卫、连续空分析保护(max_empty_analyze)、flat_prompt 配置覆盖
- 生图可靠性:开始/完成进度日志、异步任务轮询超时 60s→300s
This commit is contained in:
2026-08-31 18:35:51 +08:00
parent 71a48e4ed5
commit 3dc594cf57
29 changed files with 1369 additions and 321 deletions
+18 -6
View File
@@ -81,6 +81,12 @@ 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()
@@ -88,7 +94,7 @@ def _pinterest_route(state: Dict[str, Any]) -> str:
# 日志由 pinterest_wait 节点进入时统一打印(阻塞等待消化,避免此处高频刷屏)
return "wait"
# 简报池空闲 → 检查图池还有未消费图片 → 补分析(不管简报离目标差多少,先把这批用完
# 简报池空闲 + 简报未达标 + 图池还有未消费图片 → 补分析(缺口批大小由 analyze_node 按 target 收敛
try:
from graph.pinterest import load_image_pool, load_used_images, pool_unused_images
pool = load_image_pool(str(state.get("output_dir") or ""), state.get("country") or "")
@@ -96,16 +102,22 @@ def _pinterest_route(state: Dict[str, Any]) -> str:
unused = pool_unused_images(pool, used)
except Exception: # noqa: BLE001
unused = []
# 连续多轮图片分析无新增简报(分析失败 / 图片均不适合印花)→ 视为无法生成,
# 直接收尾,避免"取下一张参考图"式无限空转
max_empty = int(pcfg.get("max_empty_analyze") or 0)
if max_empty <= 0:
max_empty = max(target + 2, 3)
empty = int(state.get("pinterest_empty_rounds") or 0)
if empty >= max_empty:
print(f"[pinterest_route] 连续 {empty} 轮图片分析无新增简报(目标 {len(briefs)}/{target} 条简报未达标),"
f"无法生成 → 放弃补分析,直接收尾(不再逐一取下一张参考图)")
return "done"
if unused:
print(f"[pinterest_route] 简报池空闲,图池还有 {len(unused)} 张未消费图片,补分析"
f"(简报 {len(briefs)}/{target}")
return "analyze"
# 简报池空闲 + 图池空 → 检查简报是否已达标
if 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},按现有结果继续")
+26 -16
View File
@@ -78,29 +78,32 @@ def _shrink_blob_to_2mb(img_path: str, blob: bytes, max_bytes: int = 2 * 1024 *
return blob
def _save_from_response(j: dict, out_path: str) -> str:
def _save_from_response(j: dict, out_path: str, started: float = None) -> str:
"""从同步/异步最终响应提取图片(data[].b64_json 或 url)并保存。"""
data_item = (j.get("data") or [{}])[0]
b64 = data_item.get("b64_json")
if b64:
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
Path(out_path).write_bytes(base64.b64decode(b64))
return out_path
url = data_item.get("url")
if not url:
raise RuntimeError(f"图像 API 返回无 b64_json/url: {str(j)[:200]}")
img_resp = requests.get(url, proxies=NO_PROXY, timeout=120)
img_resp.raise_for_status()
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
Path(out_path).write_bytes(img_resp.content)
else:
url = data_item.get("url")
if not url:
raise RuntimeError(f"图像 API 返回无 b64_json/url: {str(j)[:200]}")
img_resp = requests.get(url, proxies=NO_PROXY, timeout=120)
img_resp.raise_for_status()
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
Path(out_path).write_bytes(img_resp.content)
if started is not None:
print(f"[img] 已生成(耗时 {time.time() - started:.0f}s: {out_path}")
return out_path
def _wait_task(base_url: str, headers: dict, task_id: str, poll_after_ms: int,
timeout: int = 60) -> dict:
timeout: int = 300) -> dict:
"""轮询异步图像任务直到完成;uncertain(结果暂时不确定)继续等,不重复提交。
timeout 默认 60s:异步路径不稳定(ai-media 网关常 uncertain/task not found),
超时即抛异常由调用方重试同步提交"""
timeout 默认 300s(与提交请求 timeout 一致):网关异步任务实际需 30s~6 分钟
(设计稿/三图合成),60s 轮询超时会把仍在处理中的任务误判为失败而重复提交
白白消耗配额和时间。超时即抛异常由调用方重试同步提交。"""
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
@@ -126,14 +129,15 @@ def _wait_task(base_url: str, headers: dict, task_id: str, poll_after_ms: int,
raise RuntimeError(f"图像异步任务超时({timeout}s")
def _resolve_task_or_sync(j: dict, base_url: str, headers: dict, out_path: str) -> str:
def _resolve_task_or_sync(j: dict, base_url: str, headers: dict, out_path: str,
started: float = None) -> str:
"""提交后的统一处理:异步任务则轮询,然后提取图片保存。"""
if j.get("object") == "image.task" or j.get("task_id") or j.get("id", "").startswith("imgtask"):
tid = j.get("task_id") or j.get("id") or ""
if not tid:
raise RuntimeError(f"异步任务无 task_id: {str(j)[:200]}")
j = _wait_task(base_url, headers, tid, int(j.get("poll_after_ms") or 2000))
return _save_from_response(j, out_path)
return _save_from_response(j, out_path, started=started)
class OpenAIImageBackend(ImageBackend):
@@ -191,6 +195,8 @@ class OpenAIImageBackend(ImageBackend):
data["execution_mode"] = em
if seed is not None:
data["seed"] = seed
started = time.time()
print(f"[img] 开始图生图({model}{data['size']},参考图 {len(file_payloads)} 张)→ {Path(out_path).name}")
# 提交重试:异步路径不稳定 → 失败重试同步提交(最多 3 次);
# 内容政策拦截(content_policy_violation)多为网关误判 → 等待后重试
last_err: Optional[str] = None
@@ -222,7 +228,8 @@ class OpenAIImageBackend(ImageBackend):
continue
raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}")
try:
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path)
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path,
started=started)
except json.JSONDecodeError as e:
# 空/非 JSON 响应:多为网关过载返回空 body → 退避后重试,
# 避免即时重压触发网关 429(用户实测空响应风暴 → 429)
@@ -264,6 +271,8 @@ class OpenAIImageBackend(ImageBackend):
data["execution_mode"] = em
if seed is not None:
data["seed"] = seed
started = time.time()
print(f"[img] 开始文生图({model}{data['size']})→ {Path(out_path).name}")
last_err: Optional[str] = None
for attempt in range(3):
resp = requests.post(f"{base_url}/images/generations", headers=headers, json=data,
@@ -293,7 +302,8 @@ class OpenAIImageBackend(ImageBackend):
continue
raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}")
try:
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path)
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path,
started=started)
except json.JSONDecodeError as e:
# 空/非 JSON 响应:多为网关过载返回空 body → 退避后重试,
# 避免即时重压触发网关 429(用户实测空响应风暴 → 429)
+5 -5
View File
@@ -170,21 +170,21 @@ class MockBackend:
terms = [f"{t} {suffix}" if suffix not in t.lower() else t for t in terms]
return {"search_terms": terms}
def analyze_pinterest_images(self, image_paths, term="", country="", on_400=None):
"""规则生成设计简报(零 API 成本):按搜索词启发式推导风格/配色/构图。"""
def analyze_pinterest_images(self, image_paths, term="", country="", on_400=None, custom_mode=False):
"""规则生成设计简报(零 API 成本):按搜索词启发式推导风格/配色/构图。
custom_mode: 仅作签名兼容(mock 为规则生成,不区分模式)。
"""
from ..classify import classify, prompt_suggestion
cat = classify(term)
art_style, palette = derive_style_palette(term, country, category=cat)
motif = prompt_suggestion(term, cat).split(" --no ")[0].split(",")[0].strip()
composition = derive_composition(term, cat)
negative = ("no real people, no likeness of any person, no copyrighted characters, "
"no brand logos, no trademarks, no celebrity, no readable text unless safe")
n = max(1, len(image_paths or []))
paths = list(image_paths or [])
return [{
"topic": term,
"suitable_for_print": True,
"negative_prompt": negative,
"image_prompt": (f"{motif}, {art_style}, {palette}, {composition}, "
f"original {art_style} t-shirt print design, "
f"no brand logo, no trademark, no character, no watermark"),
+69 -12
View File
@@ -362,12 +362,8 @@ image_prompt = two parts:
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>"}]}"""
{"designs":[{"suitable_for_print":<bool>,"image_prompt":"<str>"}]}"""
PINTEREST_ANALYZE_SCHEMA = {
"name": "pinterest_design_briefs",
@@ -380,10 +376,9 @@ PINTEREST_ANALYZE_SCHEMA = {
"type": "object",
"properties": {
"suitable_for_print": {"type": "boolean"},
"negative_prompt": {"type": "string"},
"image_prompt": {"type": "string"},
},
"required": ["suitable_for_print", "negative_prompt", "image_prompt"],
"required": ["suitable_for_print", "image_prompt"],
"additionalProperties": False,
},
}
@@ -411,6 +406,61 @@ def _pinterest_analyze_prompt_file(country: str, filename: str) -> str:
return ""
# 自定义模式(pinterest.mode=custom)多模态分析系统提示词内置默认:
# 输入是「现有爆款产品图」(模特实拍/平铺图),定位衣服上的印花并产出同风格、不同执行的非侵权原创设计。
# 该文本为「内置默认」,可被 prompts/custom_analyze_system.md 覆盖(缺失/留空回退这里)。
CUSTOM_ANALYZE_SYSTEM_PROMPT = """You are a POD T-shirt design analyst working in CUSTOM-REFERENCE mode. You are given ONE existing bestseller product photo — an on-model shot or a flat-lay shot of a printed garment. Your job:
1. Locate the printed artwork on the garment and judge whether that PRINT can inspire a new T-shirt design (ignore the shirt itself, the model, the background, watermarks and photo quality — judge only the printed artwork).
2. Write an ORIGINAL design brief that keeps the bestseller's general vibe (theme, style genre, era, mood) but is a clearly different, non-infringing work.
RULES:
0. REFERENCE HANDLING — the attached photo is a product mockup of an existing bestseller, not an artwork file. Use it ONLY to identify the printed artwork and distill its appeal into generic style keywords (retro, y2k, minimal, grunge, boho, kawaii, western, cottagecore, vintage cartoon...). Never reproduce, trace, rearrange, recolor or closely imitate the print, its layout, characters or text.
1. SAME SUBJECT FAMILY, NEW EXECUTION — the new design must belong to the same visual family as the bestseller print so a buyer would instantly see they are "same style, different shirt". LOCK these elements to the bestseller's: (a) general subject category (e.g., human/animal figure silhouette, celestial object, floral, vehicle, typography-led...), (b) art technique (e.g., grunge ink silhouette, clean line art, watercolor, distressed collage...), (c) palette (monochrome / muted / neon...). VARY these: the exact subject, the pose, the composition and layout, and any decorative framing. The result must be clearly original — no traced or rearranged reuse of the reference artwork — while staying recognizably in the same genre.
2. IP SCREENING — if the bestseller print contains brand logos, trademarks, slogans, mascots, copyrighted characters (including stylized or silhouette versions), real people/celebrities, movie/game/anime/band IP, or lyrics, do NOT imitate them; swap in fully generic equivalents (e.g., an unnamed cartoon animal instead of a recognizable mascot). Also 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. Because the reference is a product photo, explicitly exclude the garment and the photo itself: never describe shirts, models, hangers, scenes or backgrounds. Photographic elements in the print may be re-rendered as detailed full-color illustrations, retro badges or vintage stickers.
4. 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 or near-duplicate the bestseller print's wording; no brand/band/movie names or famous slogans. When unsure, omit.
suitable_for_print: judge ONLY the printed artwork extracted from the bestseller photo. DEFAULT TRUE for graphics, illustrations, badges, vector art, typography prints, or prints visible on mockups. FALSE only for: blank garments with no artwork, prints that are indiscernible due to watermark, blur or very low quality, pure subjectless photo scenery, memes/screenshots/collages with no usable motif. Even when FALSE, still fill all fields so downstream never breaks.
image_prompt = two parts:
1) mandatory opener: "First, carefully examine the printed artwork on the garment in the attached photo: zoom in mentally on the print area, identify its subject, technique, palette and layout, and base the new design on THOSE observed traits. Ignore the model, background and photo quality. Then: use the attached bestseller product photo only as loose inspiration for overall mood, theme, era and style genre — do NOT reproduce, trace, rearrange, recolor or closely imitate its printed artwork, characters, layout or text, and do NOT render a shirt, garment, model, hanger, photo scene, product mockup or background of any kind."
2) the new design: [same subject family as observed] + [same technique and palette family] + [changed exact subject, pose, composition and framing] + [mood], plus quoted original text if used. NEVER mention shirts, apparel, models, scenes, sizes, backgrounds or watermarks — placement is handled externally. Before output, verify the new design shares the locked elements (subject category, technique, palette) with the observed print; if not, revise it.
OUTPUT — ONLY valid JSON, no fences:
{"designs":[{"suitable_for_print":<bool>,"image_prompt":"<str>"}]}"""
def _custom_analyze_prompt_file(filename: str) -> str:
"""定位自定义模式多模态分析提示词文件:优先运行根 prompts(exe 旁,可编辑),回退数据根 prompts。
只读全局 prompts/<filename>(自定义模式不按国家区分)。找不到返回空串,由调用方回退内置默认。
"""
for base in (runtime_root(), project_root()):
p = base / "prompts" / filename
if p.exists():
return p.read_text(encoding="utf-8").strip()
return ""
def resolve_custom_analyze_system_prompt() -> str:
"""自定义模式多模态分析系统提示词(可配置):命中 prompts/custom_analyze_system.md
否则回退内置 CUSTOM_ANALYZE_SYSTEM_PROMPT。"""
text = _custom_analyze_prompt_file("custom_analyze_system.md")
return text if text else CUSTOM_ANALYZE_SYSTEM_PROMPT
def build_custom_analyze_user_prompt() -> str:
"""自定义模式多模态分析用户提示词(可配置):命中 prompts/custom_analyze_user.md 否则内置默认。"""
text = _custom_analyze_prompt_file("custom_analyze_user.md")
if text:
return text
return (
"Analyze the attached bestseller product photo, locate its printed artwork, "
"and produce one ORIGINAL T-shirt print design brief that keeps its general vibe "
"without copying its printed artwork."
)
def resolve_pinterest_analyze_system_prompt(country: str = "") -> str:
"""多模态分析系统提示词(可配置):命中 prompts/pinterest_analyze_system.md(国家覆盖优先),
否则回退内置 PINTEREST_ANALYZE_SYSTEM_PROMPT。"""
@@ -628,12 +678,14 @@ class OpenAICompatBackend(LLMBackend):
terms = [f"{t} {suffix}" if suffix not in t.lower() else t for t in terms]
return {"search_terms": terms}
def analyze_pinterest_images(self, image_paths: List[str], term: str, country: str = "",
on_400=None) -> List[Dict[str, Any]]:
def analyze_pinterest_images(self, image_paths: List[str], term: str = "", country: str = "",
on_400=None, custom_mode: bool = False) -> List[Dict[str, Any]]:
"""多模态分析 Pinterest 图片 → 原创设计简报列表。
图片输入失败/无有效图片时直接放弃(返回 [],不降级纯文本),由节点跳过该产品。
on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。
custom_mode: True=自定义模式,用独立的多模态提示词(CUSTOM-REFERENCE 版,
见 prompts/custom_analyze_system.md / custom_analyze_user.md),输入是现有爆款产品图。
"""
cfg = self._cfg
api_key = cfg.get("api_key", "")
@@ -683,14 +735,20 @@ class OpenAICompatBackend(LLMBackend):
pass
def _call() -> str:
if custom_mode:
sys_prompt = resolve_custom_analyze_system_prompt()
user_prompt = build_custom_analyze_user_prompt()
else:
sys_prompt = resolve_pinterest_analyze_system_prompt(country)
user_prompt = build_pinterest_analyze_user_prompt(country)
user_content: List[Any] = [
{"type": "text", "text": build_pinterest_analyze_user_prompt(country)},
{"type": "text", "text": user_prompt},
]
user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris]
payload = {
"model": model,
"messages": [
{"role": "system", "content": resolve_pinterest_analyze_system_prompt(country)},
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_content},
],
"temperature": 0.5,
@@ -742,7 +800,6 @@ class OpenAICompatBackend(LLMBackend):
designs.append({
"topic": term,
"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 按图逐张产出简报,顺序一一对应)
"ref_images": [str(image_paths[i])] if i < len(image_paths) else [],
+19 -8
View File
@@ -74,7 +74,6 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str,
"suitable_for_print": True,
"design_category": classify(term),
"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(),
@@ -114,11 +113,11 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
if batch_size <= 0:
batch_size = max_designs # 自动:一次最多分析 max_designs 张(每张图→1条简报)
need = batch_size
# 自定义模式:本地图池是唯一且有限的图源,最多分析选品清单总数」张即可
# 避免多余分析(超出的图源在简报达标后由自定义路由结束,不浪费配额)。
if bool(state.get("custom_mode")):
_t = int(state.get("pinterest_target") or 1) or 1
need = max(0, min(need, _t))
# 按目标简报数收敛:最多分析到满足选品清单所需的简报数即可(target=spu_tasks,每款一个设计)
# 普通与自定义模式一致——避免单一产品任务也把整池未消费图片全部分析成冗余简报、浪费配额
# 分析出的简报若不适合可丢弃,缺口不足时再由路由补分析/search。
_t = int(state.get("pinterest_target") or 1) or 1
need = max(1, min(need, _t))
# 1) 图池取未消费图片(md5 不在 used_images);无 → 返回空,路由触发搜索
pool = load_image_pool(output_dir, country)
@@ -204,7 +203,8 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
if pipe is not None and hasattr(pipe, "record_400"):
if pipe.record_400():
pipe._abort_current_term()
res = llm.analyze_pinterest_images(paths, term, country, on_400=_on_400) or []
res = llm.analyze_pinterest_images(paths, term, country, on_400=_on_400,
custom_mode=bool(state.get("custom_mode"))) or []
# 把每条简报的来源图路径回填为原始图(压缩图仅用于分析,参考图用原图)
return _assign_refs(res, chunk)
except Exception as e: # noqa: BLE001
@@ -269,6 +269,16 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
accumulated = list(state.get("briefs") or [])
accumulated.extend(new_briefs)
# 连续无产出计数器:本次无新增简报 → 递增(防"逐一取下一张参考图"空转);
# 有新增简报 → 重置为 0
empty_rounds = int(state.get("pinterest_empty_rounds") or 0)
if new_briefs:
empty_rounds = 0
else:
empty_rounds += 1
print(f"[pinterest_analyze] 本轮无新增简报(连续 {empty_rounds} 轮无产出),"
f"超过上限后将自动结束")
# 推送实际新增且保留的简报到并发生成流水线(简报池):边分析边生成设计/三合一/种草图
pushed = accumulated[old_count:]
pipe = state.get("pinterest_pipeline")
@@ -293,4 +303,5 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
}
print(f"[pinterest_analyze] 本轮分析 {len(batch)} 张图 → 简报 {len(new_briefs)} 条,"
f"累计 {len(accumulated)} 条({country}")
return {"pinterest_briefs": kept, "briefs": accumulated, "stats": stats, "errors": errors}
return {"pinterest_briefs": kept, "briefs": accumulated, "stats": stats, "errors": errors,
"pinterest_empty_rounds": empty_rounds}
+50 -19
View File
@@ -22,11 +22,7 @@ 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 段(仅当设计含文字时追加):
# Pinterest 生图提示词 3 段结构中第 2(仅当设计含文字时追加):
# 要求模型把引号内的文字按原文逐字正确拼写,避免乱码/拼错
SPELLING_RULE = (
"Render every phrase shown in quotes exactly as written, "
@@ -43,6 +39,26 @@ REVIEW_REBRAND_HINT = (
"a generic, non-infringing homage in the same mood, clearly distinct from the original."
)
# —— 自定义模式(custom)生图模板:固定前缀 + 分析模型 image_prompt + 固定负向 ——
# 自定义模式分析模型产出的是「新设计描述」,生图时套用这套固定模板(含防复制/防服装约束),
# 负向用固定文本(写入 composite_negativecompose 生图时作为负向参数传给图像后端)。
CUSTOM_IMAGE_PROMPT_TEMPLATE = (
"Use the attached bestseller product photo only as loose inspiration for "
"overall mood, theme, era and style genre — do NOT reproduce, trace, "
"rearrange, recolor or closely imitate its printed artwork, characters, "
"layout or text, and do NOT render a shirt, garment, model, hanger, photo "
"scene, product mockup or background of any kind. First, carefully examine "
"the printed artwork on the garment in the attached photo: zoom in mentally "
"on the print area, identify its subject, technique, palette and layout, and "
"base the new design on THOSE observed traits. Ignore the model, background "
"and photo quality. Then: {image_prompt}"
)
CUSTOM_NEGATIVE_PROMPT = (
"copy of reference artwork, lookalike of the bestseller print, characters, "
"mascots, likenesses, logos, trademarks, watermark, photorealistic shirt, "
"apparel, product mockup, model, garment, hanger, busy background"
)
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
_IMG_RISKY_SWAP = {
@@ -76,6 +92,17 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
cc = state["country_config"]
extra_rules = cc.get("extra_style_rules") or []
tpls = config.get("prompt_templates") or {}
# Pinterest 生图提示词固定段:可配置(config.pinterest.prompt_pieces),留空/缺失回退内置常量。
# 自定义模式(custom_mode)用独立的一套固定段(config.custom.prompt_pieces),默认与 Pinterest 相同、可单独编辑。
if bool(state.get("custom_mode")):
pp = (config.get("custom") or {}).get("prompt_pieces") or {}
else:
pp = (config.get("pinterest") or {}).get("prompt_pieces") or {}
print_suffix = (pp.get("print_suffix") or "").strip() or PINTEREST_PRINT_SUFFIX
spelling_rule = (pp.get("spelling_rule") or "").strip() or SPELLING_RULE
review_rebrand_hint = (pp.get("review_rebrand_hint") or "").strip() or REVIEW_REBRAND_HINT
custom_ip_tpl = (pp.get("image_prompt_template") or "").strip() or CUSTOM_IMAGE_PROMPT_TEMPLATE
custom_neg = (pp.get("negative_prompt") or "").strip() or CUSTOM_NEGATIVE_PROMPT
briefs: List[Dict[str, Any]] = []
for r in screened:
@@ -95,20 +122,24 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
llm_ip = (r.get("image_prompt") or "").strip()
llm_neg = (r.get("negative_prompt") or "").strip()
if r.get("source") == "pinterest" and llm_ip:
# —— 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']}")
if bool(state.get("custom_mode")):
# —— 自定义模式:固定模板(前缀 + 分析 image_prompt),负向用固定文本 ——
# 模板含防复制/防服装约束,不再追加 print_suffix;负向写入 composite_negative
# compose 生图时作为负向参数传给图像后端。
prompts["image_prompt"] = custom_ip_tpl.replace("{image_prompt}", llm_ip)
prompts["composite_negative"] = custom_neg
print(f"[prompt] 自定义模式按固定模板拼 image_prompt(含固定负向): 「{r['topic']}")
else:
# —— Pinterest 参考模式:跳过四要素模板,按 3 段结构拼 image_prompt ——
# ① image_prompt(分析模型产出) + 固定输出形态后缀 PINTEREST_PRINT_SUFFIX
# ② 拼写锁定句 SPELLING_RULE(仅当 LLM image_prompt 已含引号文字段时)
# 是否含文字、拼写与否均由分析模型产出决定,本模式不注入 slogan。
seg: List[str] = [llm_ip, print_suffix]
if '"' in llm_ip:
seg.append(spelling_rule)
prompts["image_prompt"] = ", ".join(seg)
print(f"[prompt] Pinterest 简报按 3 段结构拼 image_prompt(跳过四要素模板): 「{r['topic']}")
else:
# —— 热点采集模式:四要素模板装配 + 文字印花(约 30% 概率注入 slogan)——
slogan = (r.get("slogan") or "").strip()
@@ -119,7 +150,7 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
r["used_slogan"] = slogan
# review(疑似商标/受保护主题)→ 追加「原创化魔改」引导(两个模式通用)
if str(r.get("risk_level", "")).strip().lower() == "review":
prompts["image_prompt"] = prompts["image_prompt"] + " " + REVIEW_REBRAND_HINT
prompts["image_prompt"] = prompts["image_prompt"] + " " + review_rebrand_hint
print(f"[prompt] review 简报注入原创化魔改引导: 「{r['topic']}")
r.update(prompts)
r["motif"] = motif
+5 -1
View File
@@ -67,7 +67,8 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
db_path = root / db_path
break
from graph.template_export import export_products
from graph.template_export import (export_products, _resolve_component_map,
_resolve_season_map, _resolve_pattern_map)
tdir = (pcfg.get("template_dir") or "").strip() or str(Path(tp).parent)
prod_dir = output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
@@ -120,6 +121,9 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
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),
component_map=_resolve_component_map(config),
season_map=_resolve_season_map(config),
pattern_map=_resolve_pattern_map(config),
)
for r in products:
if (r.get("composite_path") or r.get("printed_path")) and (r.get("en_title") or "").strip():
+1
View File
@@ -38,6 +38,7 @@ class AgentState(TypedDict, total=False):
# —— Pinterest 按需搜索循环状态 ——
pinterest_target: int # 目标简报数(= spu_tasks 数量,每款一个设计)
pinterest_rounds: int # 已搜索轮次
pinterest_empty_rounds: int # 连续图片分析无新增简报的轮数(防"逐一取下一张参考图"空转)
pinterest_attempted: List[str] # 本轮已尝试(未持久化)的搜索词,防同轮重复
pinterest_pipeline: Any # PinterestPipeline 实例(简报池 + 并发生成线程)
+257 -83
View File
@@ -22,6 +22,7 @@ _CAROUSEL_KW = ("轮播", "carousel", "カルーセル")
# 成分值字典映射:db 成分值 → 女装模板下拉框选项(男装模板选项与 db 值一致,直接保留)。
# 女装模板(如 SatVoy 沙特)成分下拉框是「中文+英文」格式(棉Cotton),db 存中文(棉),需映射。
# 该字典为「内置默认」,运行时可被 config.yaml 顶层 component_map.female 覆盖(缺失/留空回退这里)。
_COMPONENT_FEMALE_MAP = {
"": "棉Cotton",
"聚酯纤维": "聚酯纤维(涤纶)Polyester",
@@ -32,11 +33,108 @@ _COMPONENT_FEMALE_MAP = {
}
def _map_component(value: Any, gender: Optional[str]) -> Any:
"""成分值按性别映射:male/None 保留原值;female 查女装字典(找不到保留原值)。"""
def _resolve_component_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置成分映射(config.component_map.female),缺失/留空回退内置默认。
仅作用于 component_1/2/3material 字段不经过任何映射,原样透传。
"""
if not config:
return dict(_COMPONENT_FEMALE_MAP)
female = None
try:
cm = config.get("component_map") or {}
female = (cm.get("female") or {}) if isinstance(cm, dict) else {}
except Exception: # noqa: BLE001
female = {}
if not isinstance(female, dict) or not female:
return dict(_COMPONENT_FEMALE_MAP)
merged = dict(_COMPONENT_FEMALE_MAP)
merged.update({str(k): v for k, v in female.items() if v not in (None, "")})
return merged
def _map_component(value: Any, gender: Optional[str], cmp_map: Optional[Dict[str, Any]] = None) -> Any:
"""成分值按性别映射(仅 component 字段,不作用于 material):male/None 保留原值;
female 查成分映射表(默认内置、可由 config 覆盖,找不到保留原值)。"""
if gender != "female" or value in (None, ""):
return value
return _COMPONENT_FEMALE_MAP.get(value, value)
mapping = cmp_map if cmp_map is not None else _COMPONENT_FEMALE_MAP
return mapping.get(value, value)
# 季节值字典映射:db 季节值 → 女装模板下拉框选项(男装模板与 db 值一致,直接保留)。
# 女装模板(如 SatVoy 沙特)季节下拉框用「ALL/全球/所有」等英文+中文格式,db 存「四季」,需映射。
# 该字典为「内置默认」,运行时可被 config.yaml 顶层 season_map.female 覆盖(缺失/留空回退这里)。
_SEASON_FEMALE_MAP = {
"四季": "ALL/全球/所有",
}
# 印花图案值字典映射:db pattern 值 → 女装模板下拉框选项(男装模板与 db 值一致,直接保留)。
# 女装模板遇到 pattern="印花" 时映射为「卡通」;该字典为「内置默认」,
# 运行时可被 config.yaml 顶层 pattern_map.female 覆盖(缺失/留空回退这里)。
_PATTERN_FEMALE_MAP = {
"印花": "卡通",
}
def _resolve_pattern_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置图案映射(config.pattern_map.female),缺失/留空回退内置默认。
仅作用于 spu.pattern 字段(女装模板时映射,男装/None 保留原值)。
"""
if not config:
return dict(_PATTERN_FEMALE_MAP)
female = None
try:
pm = config.get("pattern_map") or {}
female = (pm.get("female") or {}) if isinstance(pm, dict) else {}
except Exception: # noqa: BLE001
female = {}
if not isinstance(female, dict) or not female:
return dict(_PATTERN_FEMALE_MAP)
merged = dict(_PATTERN_FEMALE_MAP)
merged.update({str(k): v for k, v in female.items() if v not in (None, "")})
return merged
def _map_pattern(value: Any, gender: Optional[str], pattern_map: Optional[Dict[str, Any]] = None) -> Any:
"""图案值按性别映射(仅 spu.pattern 字段):male/None 保留原值;
female 查图案映射表(默认内置、可由 config 覆盖,找不到保留原值),如「印花」→「卡通」。"""
if gender != "female" or value is None:
return value
v = str(value).strip()
mapping = pattern_map if pattern_map is not None else _PATTERN_FEMALE_MAP
return mapping.get(v, value)
def _resolve_season_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置季节映射(config.season_map.female),缺失/留空回退内置默认。
仅作用于 spu.season 字段(女装模板时映射,男装/None 保留原值)。
"""
if not config:
return dict(_SEASON_FEMALE_MAP)
female = None
try:
sm = config.get("season_map") or {}
female = (sm.get("female") or {}) if isinstance(sm, dict) else {}
except Exception: # noqa: BLE001
female = {}
if not isinstance(female, dict) or not female:
return dict(_SEASON_FEMALE_MAP)
merged = dict(_SEASON_FEMALE_MAP)
merged.update({str(k): v for k, v in female.items() if v not in (None, "")})
return merged
def _map_season(value: Any, gender: Optional[str], season_map: Optional[Dict[str, Any]] = None) -> Any:
"""季节值按性别映射(仅 spu.season 字段):male/None 保留原值;
female 查季节映射表(默认内置、可由 config 覆盖,找不到保留原值)。"""
if gender != "female" or value in (None, ""):
return value
mapping = season_map if season_map is not None else _SEASON_FEMALE_MAP
return mapping.get(str(value), value)
def _template_gender(template_path: str) -> Optional[str]:
@@ -230,13 +328,20 @@ 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,
color: Optional[str] = None,
fabric_headers: Optional[List[str]] = None,
gender: Optional[str] = None) -> Dict[str, Any]:
"""构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=中国大陆、产地省份=广东省;多颜色时用色值列区分)。
gender: Optional[str] = None,
component_map: Optional[Dict[str, Any]] = None,
season_map: Optional[Dict[str, Any]] = None,
pattern_map: Optional[Dict[str, Any]] = None,
oss_code: str = "") -> Dict[str, Any]:
"""构造一行 SPU(固定字段:SKC货号=当前生成货号、风格=休闲、商品产地=中国大陆、产地省份=广东省;多颜色时用色值区分)。
fabric 填「面料弹性」列(fabric_headers,如 SPU商品属性-面料弹性,检测到才填 spu.fabric)。
component_1/2/3 按性别映射(gender=female 时查 _COMPONENT_FEMALE_MAP,男装/None 保留原值)。"""
component_1/2/3 按性别映射(gender=female 时查成分映射表 component_map,男装/None 保留原值
material 字段不经过任何映射,原样透传)。
season 按性别映射(gender=female 时查季节映射表 season_map,如「四季」→「ALL/全球/所有」)。
pattern 按性别映射(gender=female 时空值查图案映射表 pattern_map,如空值→「卡通」)。"""
row: Dict[str, Any] = {
"基础信息-商品层级": "spu",
"SKC货号": spu_code, # code 路由为 SKC货号(用户要求
"SKC货号": oss_code or spu_code, # SKC货号 = 当前生成货号 oss_code(无则回退 spu_code
"风格": "休闲", # style 路由为"休闲"(用户要求)
"商品产地": "中国大陆", # 所有国家统一「中国大陆」,不读经营站点/不做字典匹配(用户要求)
"产地省份": "广东省", # 新增:精确匹配「产地省份」列,统一填「广东省」(用户要求)
@@ -247,7 +352,11 @@ def _build_spu_row(spu: Dict[str, Any], spu_code: str,
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)
v = _map_component(v, gender, component_map)
elif dbk == "season":
v = _map_season(v, gender, season_map)
elif dbk == "pattern":
v = _map_pattern(v, gender, pattern_map)
if v not in (None, ""):
row[header] = v
fabric = spu.get("fabric")
@@ -289,13 +398,62 @@ 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 _read_range_values(ws, ref: str) -> Optional[set]:
"""读取单元格/区域内的非空值集合;解析失败返回 None。"""
try:
cells = ws[ref]
except Exception: # noqa: BLE001
return None
opts = []
if isinstance(cells, (list, tuple)):
for row in cells:
for cell in row:
if cell.value not in (None, ""):
opts.append(str(cell.value).strip())
else:
if cells.value not in (None, ""):
opts.append(str(cells.value).strip())
return set(opts) if opts else None
def _read_size_options(router) -> Optional[set]:
"""读取「尺码」列的 dataValidation 下拉选项集合;无下拉/非 list/无值 返回 None。
仅作「尺码下拉受限时过滤 SKU 行」用:sku.size 不在下拉选项内则该 SKU 行跳过。
支持下拉直接内联("S,M,L")、引用区域("=Sheet!$A$1:$A$5")、动态引用(INDIRECT(...))。
动态引用无法解析(INDIRECT 指向空单元格/公式/跨表)→ 返回 None(不启用过滤,避免误杀全部 SKU 行)。"""
import re
from openpyxl.utils import get_column_letter
col = router.column_map.get("尺码") or router.column_map.get("商品规格-尺码")
if col is None:
return None
lf = get_column_letter(col)
dvs = getattr(router.ws, "data_validations", None)
if dvs is None:
return None
for dv in dvs.dataValidation:
if getattr(dv, "type", "") != "list":
continue
if lf not in str(getattr(dv, "sqref", "")):
continue
f = (getattr(dv, "formula1", "") or "").strip()
if not f:
continue
# 动态引用 INDIRECT(...):尝试解析引用的单元格/区域内容;无法解析 → 不过滤
m = re.match(r"^=?INDIRECT\((.+)\)$", f, re.IGNORECASE)
if m:
opts = _read_range_values(router.ws, m.group(1).strip())
return opts if opts else None
# 引用区域(=Sheet!$A$1:$A$5 或 Sheet!$A$1:$A$5
ref = f[1:] if f.startswith("=") else f
if ":" in ref:
opts = _read_range_values(router.ws, ref)
return opts if opts else None
# 内联列表("S,M,L" / "S;M;L" / "SML"
sep = "" if "" in f else (";" if ";" in f else ",")
opts = [x.strip() for x in f.split(sep) if x.strip()]
return set(opts) if opts else None
return None
def _find_sku_category_headers(router) -> List[str]:
@@ -313,22 +471,6 @@ def _find_sku_qty_unit_headers(router) -> List[str]:
return [str(k) for k in router.column_map if "SKU数量单位" in str(k)]
def _required_headers(router, headers) -> List[str]:
"""过滤「非必填」列:读列头下一行备注(与建议售价同套路),备注显式写「非必填」才跳过。
SKU分类/SKU数量/SKU数量单位在本模板族属必填项(SKU分类备注「必填」,数量/单位为按规则填写),
因此默认视为需填写,仅在列备注写明「非必填」时跳过。"""
keep: List[str] = []
for h in headers:
col = router.column_map.get(h)
if col is not None:
note = str(router.ws.cell(router.header_row + 1, col).value or "")
if "非必填" in note:
continue
keep.append(h)
return keep
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 = "申报价格-日本站",
@@ -337,12 +479,13 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color:
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,
sku_category_headers: Optional[List[str]] = None,
sku_qty_headers: Optional[List[str]] = None,
sku_qty_unit_headers: Optional[List[str]] = None) -> Dict[str, Any]:
"""构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。
sku_qty_unit_headers: Optional[List[str]] = None,
oss_code: str = "",
size_options: Optional[set] = None) -> Dict[str, Any]:
"""构造一行 SKU(固定字段:SPU货号、SKC货号=当前生成货号 oss_code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。
价格(price_headers 列,如 申报价格-美国站/日本站,模糊匹配到多个时全部填)= SKU.price × (1+markup/100)
预先填好。建议售价(suggested_price_headers 列,模板「建议售价」必填时才填)= 申报价格 × (1+suggested_price_ratio/100)
填了建议售价同时填「建议售价单位」=CNY。bust 填所有「胸围」列(bust_headers,如 基码表-胸围(cm)/胸围全围(cm),检测到才填)。
@@ -351,7 +494,7 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color:
row: Dict[str, Any] = {
"基础信息-商品层级": "sku",
"SPU货号": spu_code,
"SKC货号": sk.get("code") or sc, # SKC货号 = SKU 的 code(款号-颜色编码
"SKC货号": oss_code or spu_code, # SKC货号 = 当前生成货号 oss_code(无则回退 spu_code
"色值(主规格)": color,
"规格类型2": "尺码", # 规格类型2 统一填「尺码」(不填 size 值)
"币种": "CNY",
@@ -381,18 +524,19 @@ 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
# 建议售价 = 申报价格 × (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"
row[h] = suggested
for uh in suggested_unit_headers:
row[uh] = "CNY"
continue
if dbk == "size":
if v in (None, ""):
continue
if size_options is not None and v not in size_options:
return None # 尺码不在模板下拉框选项内,跳过该 SKU 行
row[header] = v
for h in sa_size_headers:
row[h] = v
@@ -443,13 +587,23 @@ SPU_MAP: Dict[str, str] = {
"target_audience": "适用人群",
"season": "季节",
"is_transparent": "是否透明",
"layout": "版型",
"weaving_method": "织造方式",
"printing_type": "印花类型",
"fabric_texture_1": "面料纹理1",
"fabric_weight_1": "面料克重1g/m²)",
"fabric_weight_unit_1": "面料克重1g/m²)单位",
"lining_texture": "里料纹理",
"placket_type": "SPU商品属性-门襟类型",
"sleeve_type": "SPU商品属性-袖型",
"sleeve_length_type": "SPU商品属性-袖长",
"breast_pad": "SPU商品属性-胸垫",
"layout": "SPU商品属性-版型",
"silhouette": "SPU商品属性-廓形",
"length_type": "SPU商品属性-长度",
"belt": "SPU商品属性-腰带",
"occasion": "SPU商品属性-场合",
"scene": "SPU商品属性-场景",
"hemline_shape": "SPU商品属性-下摆形状",
}
# db SKU 字段 -> 上传模板列名
@@ -541,9 +695,11 @@ def _insert_product_block(
fabric_headers: Optional[List[str]] = None,
sa_size_headers: Optional[List[str]] = None,
gender: Optional[str] = None,
component_map: Optional[Dict[str, Any]] = None,
season_map: Optional[Dict[str, Any]] = None,
pattern_map: Optional[Dict[str, Any]] = 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,
sku_category_headers: Optional[List[str]] = None,
sku_qty_headers: Optional[List[str]] = None,
@@ -572,13 +728,16 @@ def _insert_product_block(
color_col = router.resolve_col("色值(主规格)")
block_rows: List[int] = []
size_options = _read_size_options(router) # 尺码下拉框选项(sku.size 不在其中则跳过该 SKU 行)
if spu_per_color:
# 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充)
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
block_rows.append(router.insert(
_build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
gender=gender),
gender=gender, component_map=component_map,
season_map=season_map, pattern_map=pattern_map,
oss_code=oss_code),
match="exact",
))
for ci, (sc, skus) in enumerate(skus_by_color):
@@ -588,20 +747,21 @@ def _insert_product_block(
# 该颜色全部尺码 SKUSKU 行 SPU货号/SKU货号=spu_code,色值区分)
for i, sk in enumerate(skus):
size = sk.get("size") or f"{i+1}"
block_rows.append(router.insert(
_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,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers),
spu_code=spu_code, match="exact",
))
row = _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,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers,
oss_code=oss_code, size_options=size_options)
if row is None:
continue
block_rows.append(router.insert(row, spu_code=spu_code, match="exact"))
# 轮播图:首色 SKU 行轮播图1 = 生成首图;SKU 行按色值填 db url/生成图
if ci == 0 and images:
@@ -616,26 +776,30 @@ def _insert_product_block(
# 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
block_rows.append(router.insert(
_build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
gender=gender), match="exact"))
gender=gender, component_map=component_map,
season_map=season_map, pattern_map=pattern_map,
oss_code=oss_code), match="exact"))
multi_variant = len(skus_by_color) > 1
for ci, (sc, skus) in enumerate(skus_by_color):
first = skus[0]
color = first.get("color") or sc
for i, sk in enumerate(skus):
size = sk.get("size") or f"{i+1}"
block_rows.append(router.insert(
_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,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers),
))
row = _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,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers,
oss_code=oss_code, size_options=size_options)
if row is None:
continue
block_rows.append(router.insert(row))
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)
@@ -674,6 +838,9 @@ def export_product(
append_to: str = "",
markup_percent: float = 0.0,
suggested_price_ratio: float = 0.0,
component_map: Optional[Dict[str, Any]] = None,
season_map: Optional[Dict[str, Any]] = None,
pattern_map: Optional[Dict[str, Any]] = None,
) -> Path:
"""生成商品上传"已填写"模板(支持多产品合并到同一文件)。
@@ -707,11 +874,10 @@ def export_product(
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} # 各建议售价列是否必填(结合非必填/必填判断
sku_category_headers = _required_headers(router, _find_sku_category_headers(router)) # SKU分类列(非必填才跳过
sku_qty_headers = _required_headers(router, _find_sku_qty_headers(router)) # SKU数量列
sku_qty_unit_headers = _required_headers(router, _find_sku_qty_unit_headers(router)) # SKU数量单位列
sku_category_headers = _find_sku_category_headers(router) # SKU分类列(存在即填默认值「单品」
sku_qty_headers = _find_sku_qty_headers(router) # SKU数量列(存在即填 1
sku_qty_unit_headers = _find_sku_qty_unit_headers(router) # SKU数量单位列(存在即填「件」)
_insert_product_block(router, db_path, spu_code, sku_code,
warehouses, price_headers,
markup_percent=markup_percent, images=images,
@@ -724,9 +890,12 @@ def export_product(
fabric_headers=fabric_headers,
sa_size_headers=sa_size_headers,
gender=gender,
component_map=component_map,
season_map=season_map,
pattern_map=pattern_map,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
@@ -748,6 +917,9 @@ def export_products(
out_path: str,
markup_percent: float = 0.0,
suggested_price_ratio: float = 0.0,
component_map: Optional[Dict[str, Any]] = None,
season_map: Optional[Dict[str, Any]] = None,
pattern_map: Optional[Dict[str, Any]] = None,
) -> Path:
"""批量合并导出:所有产品一次性写入同一模板,只打开/保存一次。
@@ -768,11 +940,10 @@ def export_products(
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} # 各建议售价列是否必填(结合非必填/必填判断
sku_category_headers = _required_headers(router, _find_sku_category_headers(router)) # SKU分类列(非必填才跳过
sku_qty_headers = _required_headers(router, _find_sku_qty_headers(router)) # SKU数量列
sku_qty_unit_headers = _required_headers(router, _find_sku_qty_unit_headers(router)) # SKU数量单位列
sku_category_headers = _find_sku_category_headers(router) # SKU分类列(存在即填默认值「单品」
sku_qty_headers = _find_sku_qty_headers(router) # SKU数量列(存在即填 1
sku_qty_unit_headers = _find_sku_qty_unit_headers(router) # SKU数量单位列(存在即填「件」)
for r in products:
try:
_insert_product_block(
@@ -793,9 +964,12 @@ def export_products(
fabric_headers=fabric_headers,
sa_size_headers=sa_size_headers,
gender=gender,
component_map=component_map,
season_map=season_map,
pattern_map=pattern_map,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
-1
View File
@@ -138,7 +138,6 @@ def validate_brief(b: Dict[str, Any]) -> Dict[str, Any]:
b.setdefault("color_palette", "balanced modern palette")
b.setdefault("composition", "centered emblem with balanced negative space")
b.setdefault("concept", b.get("topic", ""))
b.setdefault("negative_prompt", "")
b.setdefault("image_prompt", "")
b.setdefault("wearable_prompt", "")
b.setdefault("composite_prompt", "")