Files
pod_trend_agent/graph/pinterest.py
T
3218485270 b7f429db89 模板导出增强 + 模特性别分组 + 三合一提示词精简
1) 模板导出:识别「基码表-胸围」填 sku.bust(多个胸围列都填);申报价格模糊匹配多列统一按加价后价格填写;详情图文不再拼接 img_url_2;SPU 款式来源统一填「现货款」;商品产地国家简称映射(沙特→沙特阿拉伯)
2) 模特性别分组:model_features 按男女分组,按模板类目含男/女固定取对应性别模特(含 Pinterest 模式 pipeline)
3) 三合一提示词:去掉 DESIGN CONTENT 四要素描述(设计已由设计稿提供)
4) 生图尺寸:全部改为读 config 不再硬编码(设计图 compose.design_size / 合成图 compose.size / 种草图 seed_shot.size)
2026-08-26 18:05:11 +08:00

340 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
"""Pinterest 参考模式共享辅助:种子词加载、已用搜索词持久化、搜索词全局过滤、设计图全局 MD5 去重。
独立于 Google Trends 采集链路,供 pinterest_search / scrape / analyze 节点复用。
"""
import hashlib
import json
import random
import re
import threading
import time
from pathlib import Path
from typing import Any, Dict, List
from graph.paths import project_root, runtime_root
# 不适合 T 恤印花的类目关键词(复用 product_batch 的兜底清单)
_UNSUITABLE = re.compile(
r"\b(nails?|manicure|pedicure|recipes?|cooking|lottery|jackpot|results?|score|scores?|"
r"fixtures?|forecast|weather|temperature|map|directions?|parking|opening hours?|"
r"prices?|price|reviews?|jobs?|salary|mortgage|council tax|election|referendum|"
r"stock market|exchange rate|gas prices?)\b",
re.IGNORECASE,
)
def pinterest_seed_path(country: str) -> Path:
for root in (runtime_root(), project_root()):
p = root / "configs" / "pinterest" / f"{country}.yaml"
if p.exists():
return p
return Path("configs") / "pinterest" / f"{country}.yaml"
def load_pinterest_seeds(country: str) -> List[str]:
"""读国家 Pinterest 种子词池(configs/pinterest/<CC>.yaml 的 seeds)。"""
try:
import yaml
p = pinterest_seed_path(country)
if not p.exists():
print(f"[pinterest] 未找到种子词配置: {p}")
return []
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
seeds = [str(s).strip() for s in (data.get("seeds") or []) if str(s).strip()]
return seeds
except Exception as e: # noqa: BLE001
print(f"[pinterest] 种子词加载失败: {e}")
return []
def sample_seeds(country: str, n: int) -> List[str]:
"""从国家种子池随机抽取 n 个种子词(不足则全取)。"""
seeds = load_pinterest_seeds(country)
if not seeds:
return []
if len(seeds) <= n:
return list(seeds)
return random.sample(seeds, n)
def used_terms_path(output_dir: str, country: str) -> Path:
return Path(output_dir) / "pinterest_ref" / country / "used_search_terms.json"
def load_used_terms(output_dir: str, country: str) -> List[str]:
"""读已用搜索词(跨多次运行持久化,供动态注入防重复)。"""
try:
p = used_terms_path(output_dir, country)
if p.exists():
data = json.loads(p.read_text(encoding="utf-8"))
return [str(t).strip() for t in (data.get("terms") or []) if str(t).strip()]
except Exception as e: # noqa: BLE001
print(f"[pinterest] 已用搜索词读取失败: {e}")
return []
def save_used_terms(output_dir: str, country: str, terms: List[str]) -> None:
"""持久化已用搜索词(去重保序)。"""
try:
p = used_terms_path(output_dir, country)
p.parent.mkdir(parents=True, exist_ok=True)
seen, out = set(), []
for t in terms:
k = t.strip().lower()
if k and k not in seen:
seen.add(k)
out.append(t.strip())
p.write_text(json.dumps({"terms": out}, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e: # noqa: BLE001
print(f"[pinterest] 已用搜索词保存失败: {e}")
def filter_search_terms(terms: List[str], used: List[str], blacklist: List[str]) -> List[str]:
"""全局搜索词过滤:剔除已用、黑名单、不适合 T 恤类目、去重(大小写不敏感)。"""
used_set = {str(u).strip().lower() for u in used if str(u).strip()}
black = [str(b).strip().lower() for b in (blacklist or []) if str(b).strip()]
seen, out = set(), []
for t in terms:
s = str(t).strip()
low = s.lower()
if not s or low in seen or low in used_set:
continue
if any(b and b in low for b in black):
continue
if _UNSUITABLE.search(low):
continue
seen.add(low)
out.append(s)
return out
def merge_used(existing: List[str], new_terms: List[str]) -> List[str]:
"""合并已用搜索词(新词追加到末尾,去重保序)。"""
seen, out = set(), []
for t in list(existing) + list(new_terms):
k = str(t).strip().lower()
if k and k not in seen:
seen.add(k)
out.append(str(t).strip())
return out
# —— 设计图全局 MD5 去重(跨国家、跨运行;对所有国家生效)——
_MD5_LOCK = threading.Lock()
def global_md5_path() -> Path:
"""全局设计图 MD5 过滤文件(.cache 随打包同步,跨版本保留)。"""
return runtime_root() / ".cache" / "global_design_md5.json"
def load_global_md5() -> set:
"""读全局设计图 MD5 集合。"""
try:
p = global_md5_path()
if p.exists():
data = json.loads(p.read_text(encoding="utf-8"))
return {str(m).strip().lower() for m in (data.get("md5s") or []) if str(m).strip()}
except Exception as e: # noqa: BLE001
print(f"[pinterest] 全局 MD5 过滤读取失败: {e}")
return set()
def add_global_md5(md5: str) -> bool:
"""把设计图 MD5 加入全局过滤;返回 True=新增可用,False=已存在(全局重复,应跳过)。"""
md5 = str(md5 or "").strip().lower()
if not md5:
return False
with _MD5_LOCK:
try:
p = global_md5_path()
p.parent.mkdir(parents=True, exist_ok=True)
md5s = load_global_md5()
if md5 in md5s:
return False
md5s.add(md5)
p.write_text(json.dumps(
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "md5s": sorted(md5s)},
ensure_ascii=False, indent=2), encoding="utf-8")
return True
except Exception as e: # noqa: BLE001
print(f"[pinterest] 全局 MD5 过滤写入失败: {e}")
return True # 写入失败不阻塞:按新增处理,避免误跳过
def design_md5_ok(image_path: str) -> bool:
"""计算设计图 MD5 并加入全局过滤;返回 True=新增可用,False=全局重复(应跳过)。"""
try:
md5 = hashlib.md5(Path(image_path).read_bytes()).hexdigest()
return add_global_md5(md5)
except Exception as e: # noqa: BLE001
print(f"[pinterest] 设计图 MD5 计算失败: {e}")
return True
# —— 图池(Image Pool):跨轮次/跨运行持久化的爬取图片池 + 已消费图片 MD5 拉黑 ——
# 图池 = 所有已爬取图片的注册表(path + md5 + term);已消费(分析过)的图片 MD5 记入
# used_images,不再复用。分析从图池取未消费图片,池不足时由路由触发新一轮搜索。
_IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp")
def image_pool_path(output_dir: str, country: str) -> Path:
return Path(output_dir) / "pinterest_ref" / country / "image_pool.json"
def used_images_path(output_dir: str, country: str) -> Path:
return Path(output_dir) / "pinterest_ref" / country / "used_images.json"
def load_image_pool(output_dir: str, country: str) -> Dict[str, Any]:
"""读图池注册表;无文件时回退扫描文件系统重建。"""
try:
p = image_pool_path(output_dir, country)
if p.exists():
data = json.loads(p.read_text(encoding="utf-8")) or {}
imgs = data.get("images") or []
if imgs:
return {"updated_at": data.get("updated_at", ""), "images": imgs}
except Exception as e: # noqa: BLE001
print(f"[pinterest] 图池读取失败: {e}")
return {"updated_at": "", "images": scan_scraped_images(output_dir, country)}
def save_image_pool(output_dir: str, country: str, pool: Dict[str, Any]) -> None:
"""持久化图池注册表。"""
try:
p = image_pool_path(output_dir, country)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "images": pool.get("images") or []},
ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e: # noqa: BLE001
print(f"[pinterest] 图池保存失败: {e}")
def scan_scraped_images(output_dir: str, country: str) -> List[Dict[str, Any]]:
"""扫描 pinterest_ref/<country>/*/ 下所有图片,重建图池注册表(含 md5)。"""
base = Path(output_dir) / "pinterest_ref" / country
out: List[Dict[str, Any]] = []
if not base.exists():
return out
for term_dir in sorted(base.iterdir()):
if not term_dir.is_dir():
continue
term = term_dir.name
for f in sorted(term_dir.iterdir()):
if not f.is_file() or f.suffix.lower() not in _IMG_EXTS:
continue
out.append({"path": str(f), "md5": image_md5(str(f)), "term": term})
return out
def image_md5(path: str) -> str:
"""计算图片文件 MD5(失败返回空串)。"""
try:
return hashlib.md5(Path(path).read_bytes()).hexdigest()
except Exception as e: # noqa: BLE001
print(f"[pinterest] 图片 MD5 计算失败 {path}: {e}")
return ""
def load_used_images(output_dir: str, country: str) -> set:
"""读已消费图片 MD5 集合(拉黑,不再复用)。"""
try:
p = used_images_path(output_dir, country)
if p.exists():
data = json.loads(p.read_text(encoding="utf-8")) or {}
return {str(m).strip().lower() for m in (data.get("md5s") or []) if str(m).strip()}
except Exception as e: # noqa: BLE001
print(f"[pinterest] 已消费图片读取失败: {e}")
return set()
def save_used_images(output_dir: str, country: str, md5s: set) -> None:
"""持久化已消费图片 MD5 集合。"""
try:
p = used_images_path(output_dir, country)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "md5s": sorted(md5s)},
ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e: # noqa: BLE001
print(f"[pinterest] 已消费图片保存失败: {e}")
def pool_unused_images(pool: Dict[str, Any], used: set) -> List[Dict[str, Any]]:
"""从图池取未消费图片(md5 不在 used 集合),保序。"""
out = []
for img in pool.get("images") or []:
if not isinstance(img, dict):
continue
m = str(img.get("md5") or "").strip().lower()
if not m or m in used:
continue
if not Path(str(img.get("path") or "")).exists():
continue
out.append(img)
return out
def compress_image(path: str, max_dim: int = 1024, max_bytes: int = 1_500_000,
out_dir: str = "") -> str:
"""压缩大图:超过 max_dim 边长或 max_bytes 体积时缩放/重编码,返回压缩后路径。
- 内存占用过大(分辨率过高)→ 等比缩放到 max_dim 内;
- 文件过大 → 转 JPEG 重编码(质量自适应);
- 无需压缩 → 返回原路径。压缩产物存 out_dir(默认图片同目录 .compressed/)。
"""
try:
from PIL import Image
p = Path(path)
if not p.exists():
return path
size = p.stat().st_size
try:
with Image.open(p) as im:
w, h = im.size
except Exception: # noqa: BLE001 无法解析的图片(损坏/非标准)直接返回原路径
return path
if w <= max_dim and h <= max_dim and size <= max_bytes:
return path
out_root = Path(out_dir) if out_dir else (p.parent / ".compressed")
out_root.mkdir(parents=True, exist_ok=True)
out = out_root / f"{p.stem}_c.jpg"
with Image.open(p) as im:
im = im.convert("RGB")
im.thumbnail((max_dim, max_dim), Image.LANCZOS)
quality = 85
while quality >= 40:
im.save(out, "JPEG", quality=quality, optimize=True)
if out.stat().st_size <= max_bytes:
break
quality -= 15
print(f"[pinterest] 图片压缩 {p.name} ({w}x{h}, {size // 1024}KB) → "
f"{out.name} ({out.stat().st_size // 1024}KB)")
return str(out)
except Exception as e: # noqa: BLE001
print(f"[pinterest] 图片压缩失败 {path}: {e}")
return path
def is_400_content_image(exc) -> bool:
"""HTTP 400 且错误信息模糊匹配到「内容」或「图片」才算(用户要求)。
生图后端抛 RuntimeError("图像 API 400: {body}")body 在消息里;
多模态后端抛 requests.HTTPError,响应体在 exc.response.text。
"""
msg = str(exc or "")
resp = getattr(exc, "response", None)
if resp is not None:
try:
body = resp.text or ""
except Exception: # noqa: BLE001
body = ""
if body:
msg = f"{msg} {body}"
if "400" not in msg:
return False
return ("内容" in msg or "图片" in msg)