修复种草图生成:以三合一主图为参考 img2img 生成,按颜色分配(每色优先、超出随机补足);新增 BR/CA/DE/ES/IT/PL/SA 七国配置与提示词;删除验证用测试脚本
This commit is contained in:
@@ -135,8 +135,12 @@ class MockBackend:
|
||||
related += history[:6]
|
||||
related += trending[4:8]
|
||||
|
||||
max_seeds = int(context.get("max_seeds") or 0)
|
||||
max_style = int(context.get("max_style_seeds", 10) or 10)
|
||||
max_related = int(context.get("max_related_seeds", 10) or 10)
|
||||
if max_seeds > 0:
|
||||
# 不再按类型分:总量均分到 style/related
|
||||
max_style = max_related = (max_seeds + 1) // 2
|
||||
return {
|
||||
"style_seeds": _dedup_limit(style, max_style),
|
||||
"related_seeds": _dedup_limit(related, max_related),
|
||||
|
||||
@@ -64,4 +64,29 @@ def filter_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
kept = validate_rows(kept, "filter")
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["filter"] = {"kept": len(kept), "dropped": dropped_total}
|
||||
|
||||
# 把过滤后的热点写入 collected_keywords.json(前台显示完整热点池用):
|
||||
# 完整流水线(run_country)也会写,保证运行后前台能看到所有未用热点,
|
||||
# 而不是只显示简报(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.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),
|
||||
encoding="utf-8")
|
||||
print(f"[filter] 已写入采集缓存 {len(kws)} 条(collected_keywords.json)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[filter] 写入采集缓存失败: {e}")
|
||||
|
||||
return {"filtered_rows": kept, "stats": stats}
|
||||
|
||||
|
||||
def _datetime_now() -> str:
|
||||
import time
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
+29
-23
@@ -221,6 +221,7 @@ def _process_spu(
|
||||
# 6) 模板选择按 SPU.mark 决定:
|
||||
# mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图)
|
||||
# mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计)
|
||||
# mark==1 统一只做三合一:无模特图时跳过合成,不再回退两图合成(印花+底图)
|
||||
if int(spu.get("mark") or 0) == 1:
|
||||
print(f"{tag} SPU {spu['code']} mark=1 → 使用三图合成模板(图1模特+图2印花+图3底图)")
|
||||
if model_img is not None:
|
||||
@@ -231,13 +232,14 @@ def _process_spu(
|
||||
result["model_folder"] = model_img.parent.name
|
||||
print(f"{tag} 模特图(任务级分配,{model_img.parent.name}/): {model_copy}")
|
||||
else:
|
||||
print(f"{tag} material_library 无模特图,回退两图合成(composite_prompt)")
|
||||
print(f"{tag} material_library 无模特图,mark=1 统一只做三合一,跳过合成")
|
||||
else:
|
||||
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
|
||||
|
||||
# 7) 合成:
|
||||
# 有模特图 → 三图合成(图1=模特 / 图2=印花设计 / 图3=底图)
|
||||
# 无模特图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计)
|
||||
# mark=1 无模特图 → 跳过合成(统一只做三合一,不做印花+底图两图合成)
|
||||
# mark!=1 无模特图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计)
|
||||
if "design_path" not in result:
|
||||
print(f"{tag} 无设计稿,跳过合成")
|
||||
elif model_img is not None:
|
||||
@@ -267,28 +269,32 @@ def _process_spu(
|
||||
print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
return None
|
||||
else:
|
||||
printed_path = str(prod_dir / f"{img_code}_printed.png")
|
||||
try:
|
||||
# 两图合成(无模特):用平铺印图文案(wearable_prompt),回退旧 composite_prompt
|
||||
flat_prompt = (brief.get("wearable_prompt") or "").strip() or brief.get("composite_prompt", "")
|
||||
ib.print(flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], # 图2印花
|
||||
size="1504x2000") # 合成统一 1504x2000
|
||||
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="1504x2000")
|
||||
if retried is not None:
|
||||
# mark=1 无模特图 → 统一只做三合一,不做印花+底图两图合成
|
||||
if int(spu.get("mark") or 0) == 1:
|
||||
print(f"{tag} mark=1 无模特图,跳过合成(统一只做三合一)")
|
||||
else:
|
||||
printed_path = str(prod_dir / f"{img_code}_printed.png")
|
||||
try:
|
||||
# 两图合成(无模特,mark!=1):用平铺印图文案(wearable_prompt),回退旧 composite_prompt
|
||||
flat_prompt = (brief.get("wearable_prompt") or "").strip() or brief.get("composite_prompt", "")
|
||||
ib.print(flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], # 图2印花
|
||||
size="1504x2000") # 合成统一 1504x2000
|
||||
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
|
||||
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="1504x2000")
|
||||
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
|
||||
|
||||
# 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由
|
||||
color_composites: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -34,7 +34,7 @@ def _cache_key(country: str, provider: str, cfg: Dict[str, Any]) -> str:
|
||||
避免命中旧参数生成的种子;旧文件保留(不删缓存,取最新)。"""
|
||||
fp = hashlib.md5(
|
||||
json.dumps(
|
||||
{k: cfg.get(k) for k in ("max_style_seeds", "max_related_seeds",
|
||||
{k: cfg.get(k) for k in ("max_seeds", "max_style_seeds", "max_related_seeds",
|
||||
"trending_context_limit", "history_limit")},
|
||||
sort_keys=True, ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
@@ -72,6 +72,7 @@ def seed_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cfg = config.get("seed_provider_cfg") or {}
|
||||
trending_limit = int(cfg.get("trending_context_limit", 15))
|
||||
history_limit = int(cfg.get("history_limit", 20))
|
||||
max_seeds = int(cfg.get("max_seeds", 0))
|
||||
max_style = int(cfg.get("max_style_seeds", 12))
|
||||
max_related = int(cfg.get("max_related_seeds", 12))
|
||||
guard = COMMON_RISK_WORDS + [b.lower() for b in (config.get("blacklist") or [])]
|
||||
@@ -84,6 +85,7 @@ def seed_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
res = cached
|
||||
context: Dict[str, Any] = {
|
||||
"country": country,
|
||||
"max_seeds": max_seeds,
|
||||
"max_style_seeds": max_style,
|
||||
"max_related_seeds": max_related,
|
||||
}
|
||||
@@ -91,6 +93,7 @@ def seed_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 1) 收集上下文
|
||||
context: Dict[str, Any] = {
|
||||
"country": country,
|
||||
"max_seeds": max_seeds,
|
||||
"max_style_seeds": max_style,
|
||||
"max_related_seeds": max_related,
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
"""节点 8/8:种草图生成(seed_shot)——在 oss_upload 之后。
|
||||
|
||||
对每个 product 的合成图(图1),按 seed_shot_templates.yaml 模板 + model_features.yaml 随机模特特征
|
||||
生成 N 张种草图(config.seed_shot.count,默认 1):
|
||||
种草图用 img2img 真正生成(不是复用主图):从 product 已生成的三合一主图
|
||||
(color_composites,每颜色一张)中按分配规则选参考图,以该图提取衣服颜色并作为
|
||||
参考图,按 seed_shot_templates.yaml 模板 + model_features.yaml 随机模特特征生成新图:
|
||||
- [商品名称] ← product 的 cn_title(上一节点多模态生成)
|
||||
- [材质] ← 数据库 SPU.material 字段
|
||||
- [模特特征] ← model_features.yaml 随机一条
|
||||
种草图同样压缩上传到 OSS(货号计数与 oss_upload 共用 state["oss_seq"] 续接)。
|
||||
分配规则(config.seed_shot.count):
|
||||
- count <= 颜色数:随机取 count 个不同颜色,各生成 1 张
|
||||
- count > 颜色数:每个颜色至少 1 张,剩余随机补足(可重复)
|
||||
种草图同样压缩上传到 OSS(货号计数与 oss_upload 共用 state["oss_seq"] 续接),
|
||||
URL 写入 r["seed_shot_urls"],供 template_export 插入模板详情图文列。
|
||||
|
||||
未配置图像后端 / 无合成图 / count=0 时跳过,不中断。
|
||||
未配置图像后端 / 无三合一主图 / count=0 时跳过,不中断。
|
||||
"""
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
@@ -16,6 +22,34 @@ from typing import Any, Dict, List
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
def _plan_seed_shots(comps: List[Dict[str, Any]], count: int) -> List[tuple]:
|
||||
"""按颜色分配种草图数量:count <= 颜色数 → 随机取 count 个不同颜色各 1 张;
|
||||
count > 颜色数 → 每色 1 张 + 随机补足(可重复)。返回 [(composite, n)]。"""
|
||||
if count <= 0 or not comps:
|
||||
return []
|
||||
if len(comps) >= count:
|
||||
picked = random.sample(comps, count)
|
||||
return [(cc, 1) for cc in picked]
|
||||
plan: List[tuple] = [(cc, 1) for cc in comps] # 每色至少 1 张
|
||||
for _ in range(count - len(comps)):
|
||||
cc = random.choice(comps) # 随机补足(可重复)
|
||||
for i, (c, n) in enumerate(plan):
|
||||
if c is cc:
|
||||
plan[i] = (c, n + 1)
|
||||
break
|
||||
return plan
|
||||
|
||||
|
||||
def _color_tag(cc: Dict[str, Any], idx: int) -> str:
|
||||
"""种草图文件名里的颜色标识:优先 sku_code 的颜色段,回退颜色名/序号。"""
|
||||
sku = str(cc.get("sku_code") or "")
|
||||
if "-" in sku:
|
||||
tag = sku.split("-", 1)[1]
|
||||
else:
|
||||
tag = str(cc.get("color") or "") or f"c{idx}"
|
||||
return "".join(ch for ch in tag if ch.isalnum() or ch in "-_") or f"c{idx}"
|
||||
|
||||
|
||||
@with_fallback("seed_shot")
|
||||
def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
products: List[Dict[str, Any]] = state.get("product") or []
|
||||
@@ -47,7 +81,6 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
material_map: Dict[str, str] = {}
|
||||
try:
|
||||
from graph.product import list_spus
|
||||
import yaml
|
||||
dbp = (config.get("product") or {}).get("db_path", "db/spu_sku.db")
|
||||
p = Path(dbp)
|
||||
if not p.is_absolute():
|
||||
@@ -71,6 +104,7 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
seq = int(state.get("oss_seq") or 0)
|
||||
oss_cfg = config.get("oss") or {}
|
||||
oss_enabled = bool(oss_cfg.get("enabled", True)) and bool(oss_cfg.get("oss_bucket"))
|
||||
size = str(ss_cfg.get("size") or "1504x2000")
|
||||
|
||||
all_shots: List[Dict[str, Any]] = []
|
||||
shot_dir = output_dir / "seed_shots"
|
||||
@@ -83,16 +117,31 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _shot_one(r: Dict[str, Any]):
|
||||
"""单个产品的种草图生成+上传(每产品独立线程)。"""
|
||||
nonlocal seq
|
||||
base = r.get("composite_path") or r.get("printed_path")
|
||||
if not base or not Path(base).exists():
|
||||
print(f"[seed_shot] {r.get('spu_code', '')} 无合成图,跳过种草图")
|
||||
# 三合一主图:多色用 color_composites;单色回退 composite_path
|
||||
comps = r.get("color_composites") or []
|
||||
if not comps and r.get("composite_path") and Path(r["composite_path"]).exists():
|
||||
comps = [{"sku_code": r.get("sku_code"), "color": r.get("color", ""),
|
||||
"composite_path": r["composite_path"]}]
|
||||
if not comps:
|
||||
print(f"[seed_shot] {r.get('spu_code', '')} 无三合一主图,跳过种草图")
|
||||
return None
|
||||
plan = _plan_seed_shots(comps, count)
|
||||
cn = (r.get("cn_title") or "").strip() or r.get("topic", "")
|
||||
material = material_map.get(r.get("spu_code", ""), "")
|
||||
paths = generate_seed_shots(ib, base, cn, material, count, str(shot_dir),
|
||||
r.get("composite_negative", ""),
|
||||
size=str((config.get("seed_shot") or {}).get("size") or "1504x2000"),
|
||||
prefix=r.get("img_code") or r.get("oss_code") or "")
|
||||
base_prefix = r.get("img_code") or r.get("oss_code") or ""
|
||||
|
||||
paths: List[str] = []
|
||||
for ci, (cc, n) in enumerate(plan, start=1):
|
||||
base = cc.get("composite_path")
|
||||
if not base or not Path(base).exists():
|
||||
print(f"[seed_shot] {r.get('spu_code', '')} 参考图缺失({base}),跳过该颜色种草图")
|
||||
continue
|
||||
tag = _color_tag(cc, ci)
|
||||
pfx = f"{base_prefix}_{tag}" if base_prefix else f"seed_{tag}"
|
||||
generated = generate_seed_shots(ib, base, cn, material, n, str(shot_dir),
|
||||
r.get("composite_negative", ""),
|
||||
size=size, prefix=pfx)
|
||||
paths.extend(generated)
|
||||
if not paths:
|
||||
return None
|
||||
r["seed_shot_paths"] = paths
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
4. 用完全用:池中种子数 ≤ 需要数时全部使用(不再随机限量/截断);
|
||||
5. 每个国家独立配置:configs/countries/<country>.yaml 的 style.seeds / related.seed_keywords。
|
||||
|
||||
limit 由 seed_node 从 context 注入(max_style_seeds / max_related_seeds,0 或缺失=不限)。
|
||||
limit 由 seed_node 从 context 注入:max_seeds(单一总量,不再按类型分,从统一池随机抽后均分
|
||||
style/related);兼容旧参数 max_style_seeds / max_related_seeds(0 或缺失=不限)。
|
||||
LLM 后端生成失败时自动回退到静态+节日主题,保证不中断。
|
||||
"""
|
||||
import random
|
||||
@@ -87,14 +88,22 @@ class DynamicStrategy(SeedStrategy):
|
||||
add(dyn_related, 1.0, "dynamic")
|
||||
|
||||
items = list(pool.values())
|
||||
limit_total = int(context.get("max_seeds") or 0)
|
||||
limit_style = int(context.get("max_style_seeds") or 0)
|
||||
limit_related = int(context.get("max_related_seeds") or 0)
|
||||
|
||||
# 2) 每次随机取(加权,不重复);池不足 → 全部用
|
||||
style_pick = _weighted_sample(items, limit_style)
|
||||
style_keys = {id(it) for it in style_pick}
|
||||
remaining = [it for it in items if id(it) not in style_keys]
|
||||
related_pick = _weighted_sample(remaining, limit_related)
|
||||
if limit_total > 0:
|
||||
# 不再按类型分:从统一池随机抽 max_seeds 个,均分到 style/related(各约一半)
|
||||
pick = _weighted_sample(items, limit_total)
|
||||
half = (len(pick) + 1) // 2
|
||||
style_pick = pick[:half]
|
||||
related_pick = pick[half:]
|
||||
else:
|
||||
# 兼容旧参数(max_style_seeds / max_related_seeds 分别限量)
|
||||
style_pick = _weighted_sample(items, limit_style)
|
||||
style_keys = {id(it) for it in style_pick}
|
||||
remaining = [it for it in items if id(it) not in style_keys]
|
||||
related_pick = _weighted_sample(remaining, limit_related)
|
||||
|
||||
return {
|
||||
"style_seeds": [it["word"] for it in style_pick],
|
||||
|
||||
@@ -101,6 +101,74 @@ _HOLIDAYS_BY_COUNTRY: Dict[str, List[tuple]] = {
|
||||
("Boxing Day", 12, 26, None, 21),
|
||||
("Halloween", 10, 31, None, 21),
|
||||
],
|
||||
"DE": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Valentine's Day", 2, 14, None, 21),
|
||||
("Easter", 0, 0, "easter", 21),
|
||||
("Oktoberfest", 9, 21, "third_saturday", 30), # 啤酒节(9 月第三个周六)
|
||||
("German Unity Day", 10, 3, None, 21),
|
||||
("Halloween", 10, 31, None, 30),
|
||||
("Advent Season", 11, 30, None, 30),
|
||||
("Christmas", 12, 25, None, 30),
|
||||
("Boxing Day", 12, 26, None, 14),
|
||||
],
|
||||
"BR": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Carnival", 2, 15, None, 30), # 狂欢节(浮动,2 月近似)
|
||||
("Easter", 0, 0, "easter", 21),
|
||||
("Tiradentes Day", 4, 21, None, 14),
|
||||
("Independence Day (BR)", 9, 7, None, 21),
|
||||
("Children's Day (BR)", 10, 12, None, 21),
|
||||
("Halloween", 10, 31, None, 30),
|
||||
("Christmas", 12, 25, None, 30),
|
||||
("New Year Eve", 12, 31, None, 14),
|
||||
],
|
||||
"SA": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Saudi Founding Day", 2, 22, None, 21),
|
||||
("Saudi National Day", 9, 23, None, 30),
|
||||
("Winter Season", 12, 1, None, 30),
|
||||
],
|
||||
"PL": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Valentine's Day", 2, 14, None, 21),
|
||||
("Easter", 0, 0, "easter", 21),
|
||||
("Constitution Day (PL)", 5, 3, None, 21),
|
||||
("Halloween", 10, 31, None, 30),
|
||||
("Independence Day (PL)", 11, 11, None, 21),
|
||||
("Christmas", 12, 25, None, 30),
|
||||
("Boxing Day", 12, 26, None, 14),
|
||||
],
|
||||
"ES": [
|
||||
("Three Kings Day", 1, 6, None, 14),
|
||||
("Valentine's Day", 2, 14, None, 21),
|
||||
("Easter (Semana Santa)", 0, 0, "easter", 21),
|
||||
("Feria de Abril", 4, 15, None, 21),
|
||||
("La Tomatina", 8, 27, None, 21),
|
||||
("Halloween", 10, 31, None, 30),
|
||||
("Christmas", 12, 25, None, 30),
|
||||
("New Year Eve", 12, 31, None, 14),
|
||||
],
|
||||
"IT": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Epiphany", 1, 6, None, 14),
|
||||
("Valentine's Day", 2, 14, None, 21),
|
||||
("Carnevale", 2, 15, None, 21),
|
||||
("Easter", 0, 0, "easter", 21),
|
||||
("Ferragosto", 8, 15, None, 21),
|
||||
("Halloween", 10, 31, None, 30),
|
||||
("Christmas", 12, 25, None, 30),
|
||||
],
|
||||
"CA": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Valentine's Day", 2, 14, None, 21),
|
||||
("Easter", 0, 0, "easter", 21),
|
||||
("Canada Day", 7, 1, None, 21),
|
||||
("Thanksgiving (CA)", 10, 12, None, 21), # 10 月第 2 周一(近似固定日)
|
||||
("Halloween", 10, 31, None, 30),
|
||||
("Christmas", 12, 25, None, 30),
|
||||
("Boxing Day", 12, 26, None, 14),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +215,9 @@ def _resolve(name: str, month: int, day: int, rule, year: int) -> Optional[datet
|
||||
if rule == "labor":
|
||||
first = datetime.date(year, month, 1)
|
||||
return first + datetime.timedelta(days=(0 - first.weekday()) % 7)
|
||||
if rule == "third_saturday":
|
||||
first = datetime.date(year, month, 1)
|
||||
return first + datetime.timedelta(days=(5 - first.weekday()) % 7 + 2 * 7)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ class AgentState(TypedDict, total=False):
|
||||
country_config: Dict[str, Any] # 该国合并后的配置(全局 + configs/countries/*.yaml + prompts/<country>/aesthetics.yaml)
|
||||
prompts_dir: str # prompts/<country> 绝对路径
|
||||
output_dir: str # output/<country> 绝对路径
|
||||
cache_dir: str # output/<country> 根目录(采集缓存/去重/简报缓存)
|
||||
task_timestamp: str # 任务开始时间戳(OSS 路径段 / 产物文件夹名)
|
||||
oss_seq: int # 货号计数(000 起,最多 999)
|
||||
base_image: str # 平铺衣服底图路径(可选,印图用)
|
||||
|
||||
# —— 流水线数据(逐节点累积)——
|
||||
raw_rows: List[Dict[str, Any]] # fetch 产出:各源原始行(统一格式)
|
||||
|
||||
@@ -199,6 +199,54 @@ COUNTRY_AESTHETICS: Dict[str, Dict[str, str]] = {
|
||||
"art_style": "sunny laid-back coastal illustration, relaxed",
|
||||
"palette": "sunny coastal palette: sky blue, sand beige, coral, sun-bleached white, turquoise",
|
||||
},
|
||||
"MX": {
|
||||
"label": "墨西哥",
|
||||
"style_hint": "Vibrant Mexican folk art; Day of the Dead / calavera / Loteria / Aztec / Talavera motifs; fiesta colors.",
|
||||
"art_style": "vibrant mexican folk art illustration, festive",
|
||||
"palette": "fiesta palette: marigold orange, magenta, deep purple, black, gold",
|
||||
},
|
||||
"DE": {
|
||||
"label": "德国",
|
||||
"style_hint": "Bavarian-Alpine folk charm, Berlin street-art edge, Bauhaus minimalism, cozy beer-garden and Christmas-market mood.",
|
||||
"art_style": "bavarian folk or bauhaus minimal illustration, clean",
|
||||
"palette": "bavarian palette: bavarian blue, white, golden yellow, alpine green, red",
|
||||
},
|
||||
"BR": {
|
||||
"label": "巴西",
|
||||
"style_hint": "Vibrant tropical carnival energy, samba rhythm, football passion, bold beach and street-art colors.",
|
||||
"art_style": "vibrant tropical carnival illustration, bold",
|
||||
"palette": "tropical carnival palette: hot pink, turquoise, gold, lime green, purple",
|
||||
},
|
||||
"SA": {
|
||||
"label": "沙特阿拉伯",
|
||||
"style_hint": "Elegant desert minimalism, arabian geometric patterns, falconry and starry-night calm, refined and conservative.",
|
||||
"art_style": "elegant desert geometric illustration, refined",
|
||||
"palette": "desert palette: warm sand, terracotta, deep amber, teal, cream",
|
||||
},
|
||||
"PL": {
|
||||
"label": "波兰",
|
||||
"style_hint": "Highland folk embroidery charm, wycinanki paper-cut patterns, slavic forest mystique, retro polish poster mood.",
|
||||
"art_style": "highland folk or retro polish poster illustration",
|
||||
"palette": "folk palette: rust red, forest green, cream, amber gold, black",
|
||||
},
|
||||
"ES": {
|
||||
"label": "西班牙",
|
||||
"style_hint": "Flamenco passion, andalusian tile patterns, mediterranean sunshine, festive fiesta energy.",
|
||||
"art_style": "flamenco or andalusian tile illustration, vibrant",
|
||||
"palette": "flamenco palette: flamenco red, black, gold, cobalt blue, cream",
|
||||
},
|
||||
"IT": {
|
||||
"label": "意大利",
|
||||
"style_hint": "Renaissance elegance, roman heritage, tuscan countryside warmth, la dolce vita retro charm.",
|
||||
"art_style": "renaissance or tuscan countryside illustration, elegant",
|
||||
"palette": "tuscan palette: terracotta, olive green, golden wheat, cream, gold",
|
||||
},
|
||||
"CA": {
|
||||
"label": "加拿大",
|
||||
"style_hint": "Maple-leaf pride, northern-lights wonder, rocky-mountain outdoors, cozy cottage and hockey spirit.",
|
||||
"art_style": "maple or rocky-mountain outdoor illustration, clean",
|
||||
"palette": "canadian palette: maple red, aurora green, pine green, snow white, lake blue",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,38 @@ from graph.product import _connect
|
||||
_CAROUSEL_KW = ("轮播", "carousel", "カルーセル")
|
||||
|
||||
|
||||
def _size_rank(size) -> tuple:
|
||||
"""把尺码字符串转成可排序 rank(从小到大)。
|
||||
|
||||
优先级:数字码(90/100...) < 字母码(XS/S/M/L/XL/XXL/3XL...) < 童装年龄码(0/3M、1-2Y...)
|
||||
< 尺寸规格(30*40...) < 未知(字典序兜底) < 均码/Onesize(最后)。
|
||||
"""
|
||||
s = str(size or "").strip().upper()
|
||||
if not s:
|
||||
return (9, 0, "")
|
||||
if s in ("ONESIZE", "ONE SIZE", "FREESIZE", "FREE SIZE", "均码"):
|
||||
return (8, 0, s)
|
||||
if s == "XS":
|
||||
return (2, 0, s)
|
||||
if s in ("S", "M", "L"):
|
||||
return (2, {"S": 1, "M": 2, "L": 3}[s], s)
|
||||
m = re.fullmatch(r"(X+)(L)", s) # XL/XXL/XXXL/XXXXL/XXXXXL
|
||||
if m:
|
||||
return (2, 3 + len(m.group(1)), s)
|
||||
m = re.fullmatch(r"(\d+)XL", s) # 3XL/4XL/5XL
|
||||
if m:
|
||||
return (2, 3 + int(m.group(1)), s)
|
||||
if s.isdigit(): # 数字码:90/100/110...
|
||||
return (1, int(s), s)
|
||||
m = re.fullmatch(r"(\d+)\s*[-/]\s*(\d+)\s*(?:M|Y|YRS|YR)?", s) # 童装年龄码
|
||||
if m:
|
||||
return (3, (int(m.group(1)) + int(m.group(2))) / 2.0, s)
|
||||
m = re.search(r"(\d+(?:\.\d+)?)\s*(?:CM)?\s*[X*]", s) # 尺寸规格:30*40/12CM X 12CM
|
||||
if m:
|
||||
return (4, float(m.group(1)), s)
|
||||
return (5, 0, s) # 未知格式:字典序兜底
|
||||
|
||||
|
||||
def _carousel_col(router, idx: int) -> Optional[int]:
|
||||
"""定位「商品轮播图{idx}」列号:先精确匹配(中文列名),失败则按中/英/日关键词模糊匹配序号。"""
|
||||
try:
|
||||
@@ -109,7 +141,7 @@ def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_
|
||||
for row in router.find_spu_rows(spu_code):
|
||||
if only_rows is not None and row not in only_rows:
|
||||
continue # 合并模式:只填本产品块的行
|
||||
lvl = str(router.ws.cell(row, 1).value or "").strip().lower()
|
||||
lvl = str(router.ws.cell(row, router.level_col).value or "").strip().lower()
|
||||
color = str(router.ws.cell(row, color_col).value or "").strip() if color_col else ""
|
||||
if lvl == "spu":
|
||||
if spu_col and oss_code:
|
||||
@@ -213,7 +245,7 @@ def _fill_sku_carousel(router, spu_code: str, color: str, color_col: int,
|
||||
if col is None:
|
||||
continue
|
||||
for row in router.find_spu_rows(spu_code):
|
||||
if (str(router.ws.cell(row, 1).value or "").strip().lower() == "sku"
|
||||
if (str(router.ws.cell(row, router.level_col).value or "").strip().lower() == "sku"
|
||||
and str(router.ws.cell(row, color_col).value or "").strip() == color):
|
||||
router.ws.cell(row, col, str(img))
|
||||
|
||||
@@ -270,30 +302,44 @@ def _read_spu(db_path, spu_code: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
def _read_meta(router) -> tuple:
|
||||
"""读模板顶头元信息:经营站点(第2行第1列)、发货仓(第2行第2列)。
|
||||
"""读模板顶头元信息:经营站点、发货仓(按标签名定位,不依赖固定行列)。
|
||||
|
||||
返回 (origin_province, warehouses):
|
||||
- origin_province:经营站点去掉末尾「站」(如「日本站」→「日本」)
|
||||
- warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个)
|
||||
"""
|
||||
ws = router.ws
|
||||
site = str(ws.cell(2, 1).value or "").strip()
|
||||
top = max(1, router.group_row - 1) # 元信息区位于分组行之前
|
||||
|
||||
def _val(label: str) -> str:
|
||||
for row in range(1, top + 1):
|
||||
for col in range(1, min(ws.max_column, 30) + 1):
|
||||
if str(ws.cell(row, col).value or "").strip() == label:
|
||||
return str(ws.cell(row + 1, col).value or "").strip()
|
||||
return ""
|
||||
|
||||
site = _val("经营站点")
|
||||
raw = _val("发货仓")
|
||||
if not site and not raw:
|
||||
# 回退:旧版固定位置(第2行第1/2列)
|
||||
site = str(ws.cell(2, 1).value or "").strip()
|
||||
raw = str(ws.cell(2, 2).value or "").strip()
|
||||
origin_province = site[:-1] if site.endswith("站") else site
|
||||
raw = str(ws.cell(2, 2).value or "").strip()
|
||||
warehouses = [w.strip() for w in raw.split("、") if w.strip()]
|
||||
return origin_province, warehouses
|
||||
|
||||
|
||||
def _read_skus(db_path, spu_code: str, sku_code: str) -> List[Dict[str, Any]]:
|
||||
"""该款该颜色的全部尺码 SKU。"""
|
||||
"""该款该颜色的全部尺码 SKU,按尺码从小到大排序(字母码/数字码/童装码/规格码)。"""
|
||||
conn = _connect(db_path)
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, p.code AS spu_code FROM SKU s
|
||||
JOIN SPU p ON s.spu_id = p.id
|
||||
WHERE p.code = ? AND s.code = ?
|
||||
ORDER BY s.size""", (spu_code, sku_code)).fetchall()
|
||||
WHERE p.code = ? AND s.code = ?""", (spu_code, sku_code)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
skus = [dict(r) for r in rows]
|
||||
skus.sort(key=lambda sk: _size_rank(sk.get("size")))
|
||||
return skus
|
||||
|
||||
|
||||
def export_product(
|
||||
|
||||
Reference in New Issue
Block a user