修复种草图生成:以三合一主图为参考 img2img 生成,按颜色分配(每色优先、超出随机补足);新增 BR/CA/DE/ES/IT/PL/SA 七国配置与提示词;删除验证用测试脚本

This commit is contained in:
2026-08-24 14:02:19 +08:00
parent f493bde8a9
commit 3c341d2e78
51 changed files with 2571 additions and 514 deletions
+4
View File
@@ -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),
+25
View File
@@ -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
View File
@@ -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]] = []
+4 -1
View File
@@ -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,
}
+61 -12
View File
@@ -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
+15 -6
View File
@@ -8,7 +8,8 @@
4. 用完全用:池中种子数 ≤ 需要数时全部使用(不再随机限量/截断);
5. 每个国家独立配置:configs/countries/<country>.yaml 的 style.seeds / related.seed_keywords。
limit 由 seed_node 从 context 注入max_style_seeds / max_related_seeds0 或缺失=不限)。
limit 由 seed_node 从 context 注入max_seeds(单一总量,不再按类型分,从统一池随机抽后均分
style/related);兼容旧参数 max_style_seeds / max_related_seeds0 或缺失=不限)。
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],
+71
View File
@@ -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
+4
View File
@@ -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 产出:各源原始行(统一格式)
+48
View File
@@ -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",
},
}
+55 -9
View File
@@ -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(