Files
pod_trend_agent/graph/product.py
T
3218485270 71a48e4ed5 v89-v91 模板增强 + 图源映射 + 多模态提示词可配置化
- 图源映射统一:热点采集与 Pinterest 模式均走 config.product.mark_dirs 配置,按任务序号随机抽模特图/平铺图
- 商品产地固定:统一为「中国大陆」+「产地省份=广东省」(不再读站点/字典映射)
- 模板 SKU 字段检测:按建议售价同一套路检测 SKU分类/SKU数量/SKU数量单位,必填时填入单品/1/件
- 多模态分析提示词可配置:prompts/pinterest_analyze_system.md + user.md,支持国家覆盖,不丢文件回退内置
- 自定义图片模式:新增 pinterest_custom_load_node,图片数量硬校验,选品清单 ≤ 有效图片数
- 模板导出优化:写入前按货号末 3 位升序排序,不再产生空白 xlsx
- 修复 v90 project review 10 项(503 致命终止、线程安全、原子写入等)
2026-08-28 16:24:50 +08:00

197 lines
7.7 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SPU/SKU 数据库查询 + 底图/模特图资源查找(产品图生成流水线的数据层)。
数据关系(已核实 spu_sku.db):
- SPU.code = 款号(如 DG004
- SKU.code = "款号-颜色编码"(如 DG004-BL01),SKU.color = 中文色名(黑/灰/...
- basemap 目录 = basemap/<款号>/<SKU.code>/xxx.jpg
- material_library/<品类>/ 存放模特图
- SKU.img_url_2~5 = CDN 图 URL(底图/细节/模特图,仅作参考字段)
"""
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"}
def _connect(db_path) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
return conn
def list_spus(db_path, country: Optional[str] = None) -> List[Dict[str, Any]]:
"""全部 SPU(可选按国家过滤)。"""
conn = _connect(db_path)
sql = "SELECT id, code, style, material, printing_type, target_audience, pattern, country, mark FROM SPU"
params: list = []
if country:
sql += " WHERE country = ?"
params.append(country)
sql += " ORDER BY code"
rows = conn.execute(sql, params).fetchall()
conn.close()
return [dict(r) for r in rows]
def list_colors(db_path, spu_code: str) -> List[Dict[str, Any]]:
"""款号 → 颜色列表(SKU.code 去重,附中文色名、CDN 底图 URL、最低价)。"""
conn = _connect(db_path)
rows = conn.execute(
"""SELECT s.code AS sku_code, s.color, s.img_url_2 AS img_url, MIN(s.price) AS price
FROM SKU s JOIN SPU p ON s.spu_id = p.id
WHERE p.code = ?
GROUP BY s.code, s.color ORDER BY s.code""", (spu_code,)).fetchall()
conn.close()
return [dict(r) for r in rows]
def _norm_name(s: str) -> str:
"""去空格(半角+全角)+ 小写,用于 SKU code 与文件夹名比较。"""
return str(s or "").replace(" ", "").replace(" ", "").strip().lower()
def find_basemap(basemap_root, spu_code: str, sku_code: str) -> Optional[Path]:
"""basemap/<款号>/<SKU.code>/ 下第一张图片;无返回 None。
SKU code 与文件夹名比较时两边都去空格(兼容 db code 或文件夹名带空格)。
"""
root = Path(basemap_root) / spu_code
if not root.exists():
return None
target = _norm_name(sku_code)
if not target:
return None
d = root / sku_code
if not (d.exists() and d.is_dir()):
d = None
for cand in sorted(root.iterdir()):
if cand.is_dir() and _norm_name(cand.name) == target:
d = cand
break
if d is None:
return None
for f in sorted(d.iterdir()):
if f.is_file() and f.suffix.lower() in IMG_EXTS:
return f
return None
def list_model_images(material_root, category: str = "T-shirt") -> List[Path]:
"""material_library/<品类>/ 下所有图片;无返回空列表。"""
d = Path(material_root) / category
if not d.exists():
return []
return [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS]
def find_first_model_folder(material_root, preferred: Optional[str] = None):
"""material_library 下「第一个有图片的子目录」及其图片列表。
- preferred(如 config 的 model_category)优先:该目录有图就直接用;
- 否则按子目录名排序,取第一个有图的目录;
- 全空返回 (None, [])。
返回 (dir_name or None, images: List[Path])。
"""
root = Path(material_root)
if not root.exists():
return None, []
candidates = []
if preferred:
d = root / preferred
if d.is_dir():
candidates.append(d)
candidates += [d for d in sorted(root.iterdir()) if d.is_dir()]
seen = set()
for d in candidates:
if d in seen:
continue
seen.add(d)
imgs = [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS]
if imgs:
return d.name, imgs
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 _dir_images(d: Path, ratio: float, tolerance: float) -> List[Path]:
"""返回目录内满足比例过滤(默认3:4)的图片列表;目录不存在/无图片返回空。"""
if d is None or not d.is_dir():
return []
imgs = [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS]
if not imgs:
return []
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 build_mark_sources(material_root, mark_dirs: Optional[Dict] = None,
category: str = "T-shirt", mark: str = "1",
ratio: float = 3 / 4, tolerance: float = 0.06) -> Dict[str, Any]:
"""按可配置的 mark→图源映射,返回指定 mark 的图源选择信息。
图源 = {"model": [Path...], "flat": [Path...]}(模特图 / 平铺图,各带英文键)。
从 config.product.mark_dirs 读 mark 对应的两个子目录名(model_dir/flat_dir),
在 material_library 下解析 → 过滤 3:4 → 返回「有图的文件夹」图片列表。
mark 未配置时回退 mark_dirs["1"];完全无配置则回退 category 默认目录。
返回结构:{"model": [imgs], "flat": [imgs]}。
"""
root = Path(material_root)
result: Dict[str, Any] = {"model": [], "flat": []}
if not root.exists():
return result
mcfg = mark_dirs or {}
cfg = mcfg.get(str(mark)) or mcfg.get("1") or {} # 优先按 mark,其次回退默认 "1"
model_name = str(cfg.get("model_dir") or "").strip()
flat_name = str(cfg.get("flat_dir") or "").strip()
if model_name:
result["model"] = _dir_images(root / model_name, ratio, tolerance)
if flat_name:
result["flat"] = _dir_images(root / flat_name, ratio, tolerance)
# 回退:某类目录未配置时,使用 category 默认目录补充模特图
if not result["model"] and not result["flat"]:
d = root / category
if d.is_dir():
result["model"] = _dir_images(d, ratio, tolerance)
if not result["model"] and not result["flat"]:
for sub in sorted(root.iterdir()):
if not sub.is_dir():
continue
imgs = _dir_images(sub, ratio, tolerance)
if imgs:
result["model"] = imgs
break
return result
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):
if find_basemap(basemap_root, spu_code, c["sku_code"]) is not None:
return c["sku_code"]
return None