Files
Sansheng-Intelligence/scripts/update_textile_trends.py
T

925 lines
36 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""家纺流行趋势数据采集脚本 — 聚合多个行业数据源,生成 textile-trends.json。
数据源清单:
1. TrendHunter — hipfashion / furniture
2. 免费趋势博客 RSS — Dezeen / Design Milk / Fibre2Fashion 等
3. 时装趋势 RSS — Hypebeast / GQ Hong Kong / Citta Bella
4. The Met / V&A 开放博物馆图像
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
if __package__:
from .textile_sources import CURATED_TREND_PAGES, FREE_TREND_FEEDS, is_fashion_reference
from .intelligence_web_translation import translation_fields
else:
from textile_sources import CURATED_TREND_PAGES, FREE_TREND_FEEDS, is_fashion_reference
from intelligence_web_translation import translation_fields
try:
import feedparser # type: ignore[import-not-found]
except ModuleNotFoundError:
feedparser = None # type: ignore[assignment]
try:
from dateutil import parser as dtparser # type: ignore[import-not-found]
except ModuleNotFoundError:
dtparser = None # type: ignore[assignment]
# ────────────── 常量 ──────────────
UTC = timezone.utc
BROWSER_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
# 指定的 Dezeen Showroom HTML 爬取源
DEZEEN_SHOWROOM_URLS: list[dict[str, str]] = [
{"name": "Dezeen Showroom - Fabrics", "url": "https://www.dezeen.com/showroom/textiles-soft-furnishings/fabrics/"},
{"name": "Dezeen Showroom - Textiles & Furnishings", "url": "https://www.dezeen.com/showroom/textiles-soft-furnishings/"},
]
BLOCKED_PAID_TREND_MARKERS = ("91jiafang", "91家纺", "pop-fashion", "pop趋势网", "pop家居")
# ────────────── 数据结构 ──────────────
@dataclass
class TrendItem:
"""一条趋势数据。"""
source: str # 数据源名称
category: str # 分类名
category_key: str # 分类键
title: str
url: str
thumbnail: str = ""
image_original: str = ""
summary: str = ""
published_at: str | None = None
meta: dict[str, Any] = field(default_factory=dict)
# ────────────── 翻译逻辑 ──────────────
def translate_to_zh_cn(session: requests.Session, text: str) -> str | None:
s = (text or "").strip()
if not s:
return None
try:
import os
proxies = {}
proxy_url = os.environ.get("HTTP_PROXY") or os.environ.get("HTTPS_PROXY") or os.environ.get("http_proxy") or os.environ.get("https_proxy")
if proxy_url:
proxies = {"http": proxy_url, "https": proxy_url}
r = session.get(
"https://translate.googleapis.com/translate_a/single",
params={
"client": "gtx",
"sl": "auto",
"tl": "zh-CN",
"dt": "t",
"q": s,
},
proxies=proxies if proxies else None,
timeout=12,
)
r.raise_for_status()
payload = r.json()
if not isinstance(payload, list) or not payload:
return None
segs = payload[0]
if not isinstance(segs, list):
return None
translated = "".join(str(seg[0]) for seg in segs if isinstance(seg, list) and seg and seg[0])
translated = translated.strip()
if translated and translated != s:
return translated
except Exception as exc:
print(f" [翻译警告] '{s[:25]}...' 汉化失败: {exc}")
return None
return None
def load_zh_cache(output_dir: Path) -> dict[str, str]:
path = output_dir / "textile-zh-cache.json"
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {str(k): str(v) for k, v in data.items() if str(k).strip() and str(v).strip()}
except Exception:
pass
return {}
def save_zh_cache(output_dir: Path, cache: dict[str, str]) -> None:
path = output_dir / "textile-zh-cache.json"
try:
path.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
# ────────────── 工具函数 ──────────────
def utc_now() -> datetime:
return datetime.now(tz=UTC)
def iso(dt: datetime | None) -> str | None:
if not dt:
return None
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
def make_trend_id(source: str, title: str, url: str) -> str:
"""生成趋势条目唯一 ID。"""
key = "||".join([source.strip().lower(), title.strip().lower(), url.strip().lower()])
return hashlib.sha1(key.encode("utf-8")).hexdigest()
VAM_GENERIC_TITLE_ZH = {
"hanging": "壁挂织物",
"pattern": "纹样图稿",
"embroidery sample": "刺绣样本",
"textile": "纺织藏品",
"embroidery": "刺绣作品",
"machine embroidered textile": "机绣织物",
}
def curate_vam_title(title: str) -> str:
match = re.fullmatch(r"\s*(.+?)\s+\[(O\d+)\]\s*", title, re.IGNORECASE)
if not match:
return ""
localized = VAM_GENERIC_TITLE_ZH.get(match.group(1).strip().casefold(), "")
return f"{localized}(V&A {match.group(2).upper()})" if localized else ""
def parse_date_str(value: Any) -> str | None:
"""尝试解析日期字符串为 ISO 格式。"""
if value is None:
return None
s = str(value).strip()
if not s:
return None
# 尝试 dateutil 解析
if dtparser is not None:
try:
dt = dtparser.parse(s, tzinfos={"UT": 0, "UTC": 0, "GMT": 0})
if not dt.tzinfo:
dt = dt.replace(tzinfo=UTC)
return iso(dt)
except Exception:
pass
# 尝试常见中文日期格式: 2026-06-01 / 2026.06.01 / 2026年06月01日
m = re.search(r"(\d{4})[\-\.年/](\d{1,2})[\-\.月/](\d{1,2})", s)
if m:
try:
dt = datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)), tzinfo=UTC)
return iso(dt)
except Exception:
pass
return None
def create_session() -> requests.Session:
"""创建带重试策略的 HTTP 会话。"""
session = requests.Session()
retry = Retry(
total=3,
connect=2,
read=2,
backoff_factor=2.0,
backoff_max=30,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=frozenset(["GET", "POST"]),
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("http://", adapter)
session.mount("https://", adapter)
session.headers.update({
"User-Agent": BROWSER_UA,
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
})
return session
# ────────────── 免费流行趋势 RSS 聚合 ──────────────
def fetch_free_trend_rss(session: requests.Session, now: datetime) -> list[TrendItem]:
"""通过 RSS 聚合各种国际免费的 Textiles / Fashion / Home Design 精选趋势渠道。"""
items: list[TrendItem] = []
if feedparser is None:
print(" [免费趋势RSS] feedparser 未安装,跳过 RSS 抓取")
return items
for feed_info in FREE_TREND_FEEDS:
feed_name = feed_info["name"]
feed_url = feed_info["url"]
category_name = str(feed_info.get("category") or "趋势博客")
category_key = str(feed_info.get("category_key") or "trend_blogs")
try:
r = session.get(feed_url, timeout=20)
r.raise_for_status()
parsed = feedparser.parse(r.content)
count = 0
for entry in parsed.entries[:15]: # 每个源最多取 15 条
title = str(entry.get("title", "")).strip()
link = str(entry.get("link", "")).strip()
if not title or not link:
continue
# 解析发布日期
published = None
for date_field in ("published", "updated", "pubDate"):
if entry.get(date_field):
published = parse_date_str(entry.get(date_field))
if published:
break
content_html = "".join(str(c.get("value") or "") for c in entry.get("content", []))
# 提取摘要
summary = ""
if entry.get("summary"):
raw = str(entry.get("summary", ""))
summary_text = BeautifulSoup(raw, "html.parser").get_text(" ", strip=True)
summary = re.split(r"\bThe post\b", summary_text, maxsplit=1, flags=re.I)[0].strip()[:200]
reference_text = BeautifulSoup(
str(entry.get("summary") or ""),
"html.parser",
).get_text(" ", strip=True)
if category_key == "fashion_trends" and not is_fashion_reference(title, reference_text):
continue
# 提取缩略图
thumbnail = ""
if entry.get("media_content"):
for mc in entry.media_content:
if isinstance(mc, dict) and mc.get("url"):
thumbnail = mc["url"]
break
if not thumbnail and entry.get("links"):
for lk in entry.links:
if isinstance(lk, dict) and lk.get("type", "").startswith("image"):
thumbnail = lk.get("href", "")
break
# 正则容错提取:从正文 HTML 中提取第一张 <img> 作为缩略图
if not thumbnail:
html_content = content_html or str(entry.get("summary") or "")
if html_content:
m_img = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html_content, re.I)
if m_img:
thumbnail = m_img.group(1)
items.append(TrendItem(
source=feed_name,
category=category_name,
category_key=category_key,
title=title,
url=link,
thumbnail=thumbnail,
summary=summary,
published_at=published,
))
count += 1
print(f" [免费趋势RSS] {feed_name}: {count} 条")
except Exception as exc:
print(f" [免费趋势RSS] {feed_name} 抓取失败: {exc}")
continue
return items
def fetch_curated_trend_pages(session: requests.Session, now: datetime) -> list[TrendItem]:
"""抓取无稳定 RSS、但可公开验证的免费趋势页面。"""
items: list[TrendItem] = []
for page in CURATED_TREND_PAGES:
try:
response = session.get(page["url"], timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
title = (soup.select_one('meta[property="og:title"]') or {}).get("content", "")
summary = (soup.select_one('meta[property="og:description"]') or {}).get("content", "")
if not title and soup.title:
title = soup.title.get_text(" ", strip=True)
image = (soup.select_one('meta[property="og:image"]') or {}).get("content", "")
published = ""
for selector in ('meta[property="article:published_time"]', 'meta[name="date"]'):
node = soup.select_one(selector)
if node and node.get("content"):
published = str(node.get("content"))
break
product_image = soup.select_one('img[src*="m.media-amazon.com"]')
if product_image:
image = str(product_image.get("src") or "")
elif not image or image.endswith("/fb.png"):
image_node = soup.select_one("img[src]")
image = image_node.get("src", "") if image_node else ""
if not title or not summary:
raise ValueError("缺少可验证的标题或摘要")
items.append(TrendItem(
source=page["name"],
category=page["category"],
category_key=page["category_key"],
title=title.strip(),
url=page["url"],
thumbnail=urljoin(page["url"], image),
summary=summary.strip()[:300],
published_at=parse_date_str(published),
))
print(f" [精选趋势页] {page['name']}: 1 条")
except Exception as exc:
print(f" [精选趋势页] {page['name']} 抓取失败: {exc}")
return items
# ────────────── Dezeen Showroom 网页抓取 ──────────────
def fetch_dezeen_showroom(session: requests.Session, now: datetime) -> list[TrendItem]:
"""爬取 Dezeen Showroom 中的纺织面料与软装产品页面。"""
import random
items: list[TrendItem] = []
category_name = "趋势博客"
category_key = "trend_blogs"
for site in DEZEEN_SHOWROOM_URLS:
name = site["name"]
url = site["url"]
# 引入随机等待机制避开 WAF 的并发频率检测
time.sleep(random.uniform(1.2, 3.0))
# 注入高仿真 Referer 和 Accept 头,拟合正常浏览器点击跳转行为
headers = {
"Referer": "https://www.dezeen.com/showroom/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
}
html_text = ""
max_retries = 3
for attempt in range(max_retries):
try:
r = session.get(url, headers=headers, timeout=30)
r.raise_for_status()
html_text = r.text
break
except Exception as exc:
if attempt < max_retries - 1:
wait_time = (attempt + 1) * 4
print(f" [Dezeen Showroom] {name} 请求异常 (尝试 {attempt+1}/{max_retries}): {exc},等待 {wait_time} 秒后重试...")
time.sleep(wait_time)
else:
print(f" [Dezeen Showroom] {name} 抓取彻底失败: {exc}")
if not html_text:
continue
try:
soup = BeautifulSoup(html_text, "html.parser")
articles = soup.find_all("article")
count = 0
for art in articles:
title_el = art.find("h3") or art.find("h2") or art.find("a")
title = title_el.get_text(" ", strip=True) if title_el else ""
link_el = art.find("a")
link = link_el.get("href") if link_el else ""
if link and not link.startswith("http"):
link = urljoin("https://www.dezeen.com", link)
if not title or not link:
continue
# 从链接解析时间:形如 https://www.dezeen.com/2026/04/27/...
published = None
date_match = re.search(r"/(\d{4})/(\d{2})/(\d{2})/", link)
if date_match:
try:
dt = datetime(int(date_match.group(1)), int(date_match.group(2)), int(date_match.group(3)), tzinfo=timezone.utc)
published = iso(dt)
except Exception:
pass
img_el = art.find("img")
thumbnail = ""
if img_el:
thumbnail = img_el.get("src") or img_el.get("data-src") or ""
summary_el = art.find("p") or art.find(class_="summary")
summary = summary_el.get_text(" ", strip=True) if summary_el else ""
# 清理尾部
if "More about" in summary:
summary = summary.split("More about")[0].strip()
items.append(TrendItem(
source=name,
category=category_name,
category_key=category_key,
title=title,
url=link,
thumbnail=thumbnail,
summary=summary,
published_at=published,
))
count += 1
print(f" [Dezeen Showroom] {name}: {count} 条")
except Exception as exc:
print(f" [Dezeen Showroom] {name} 抓取失败: {exc}")
continue
return items
# ────────────── TrendHunter 容错采集 ──────────────
def fetch_trendhunter(session: requests.Session, now: datetime) -> list[TrendItem]:
"""容错尝试抓取 TrendHunter 流行趋势。若因 Akamai WAF 403 被阻断,将默默跳过不抛出异常。"""
items: list[TrendItem] = []
category_name = "趋势博客"
category_key = "trend_blogs"
source_name = "TrendHunter"
urls = [
"https://www.trendhunter.com/hipfashion",
"https://www.trendhunter.com/trends/textile-design",
"https://www.trendhunter.com/trends/premium-home-textiles",
"https://www.trendhunter.com/trends/furnishing-fabrics"
]
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "https://www.google.com/"
}
for url in urls:
slug = url.split("/")[-1]
try:
r = session.get(url, headers=headers, timeout=15)
if r.status_code == 403:
print(f" [TrendHunter] {slug} 抓取受限 (HTTP 403),已自动跳过")
continue
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
# 查找卡片元素,采用多重 fallback
cards = soup.select("div[class*='trendUnit'], div[class*='post-item'], .trend-card")
if not cards:
cards = [a for a in soup.find_all("a", href=True) if "/trends/" in a["href"] and len(a["href"]) > 20]
count = 0
for card in cards:
title = ""
href = ""
thumbnail = ""
summary = ""
if card.name == "a":
href = card["href"]
title = card.get("title", "").strip() or card.get_text(" ", strip=True)
img = card.find("img")
if img:
thumbnail = img.get("src") or img.get("data-src") or img.get("data-original") or ""
if not title:
title = img.get("alt", "").strip()
else:
a_link = card.find("a", href=True)
if a_link:
href = a_link["href"]
title = a_link.get("title", "").strip() or a_link.get_text(" ", strip=True)
img = card.find("img")
if img:
thumbnail = img.get("src") or img.get("data-src") or img.get("data-original") or ""
if not title:
title = img.get("alt", "").strip()
p = card.find("p")
if p:
summary = p.get_text(" ", strip=True)
if not href:
continue
if not href.startswith("http"):
href = urljoin("https://www.trendhunter.com", href)
# 标题兜底
if not title:
m = re.search(r"/trends/([^/?#]+)", href)
if m:
title = m.group(1).replace("-", " ").title()
items.append(TrendItem(
source=source_name,
category=category_name,
category_key=category_key,
title=title,
url=href,
thumbnail=thumbnail,
summary=summary,
published_at=iso(now),
))
count += 1
print(f" [TrendHunter] {slug} 成功抓取: {count} 条")
except Exception as exc:
print(f" [TrendHunter] {slug} 抓取异常 (已自动跳过): {exc}")
continue
return items
# ────────────── 博物馆灵感图库采集 ──────────────
def fetch_museum_images(session: requests.Session) -> list[TrendItem]:
"""通过大都会艺术博物馆 (The Met) 和维多利亚与阿尔伯特博物馆 (V&A) 的公共 API 获取高清家纺设计大图。"""
items: list[TrendItem] = []
category_name = "灵感图库"
category_key = "textile_gallery"
# 1. 抓取大都会艺术博物馆 (The Met) — 四大垂直主题并发抓取 (textile, tapestry, embroidery, brocade)
met_queries = ["textile", "tapestry", "embroidery", "brocade"]
def fetch_single_met_query(q):
target_ids = []
try:
search_url = f"https://collectionapi.metmuseum.org/public/collection/v1/search?q={q}&hasImages=true"
res = session.get(search_url, timeout=20)
if res.status_code == 200:
object_ids = res.json().get("objectIDs", [])
if object_ids:
target_ids = object_ids[:10] # 每个查询取前 10 个
except Exception as e:
print(f" [博物馆图库] The Met 子查询 '{q}' 失败: {e}")
return target_ids
# 并发获取所有 object ID 并去重
met_object_ids = []
with ThreadPoolExecutor(max_workers=4) as met_search_executor:
met_search_futures = [met_search_executor.submit(fetch_single_met_query, q) for q in met_queries]
for fut in as_completed(met_search_futures):
o_ids = fut.result()
for oid in o_ids:
if oid not in met_object_ids:
met_object_ids.append(oid)
# 并发请求 object 详情
def fetch_met_detail(obj_id):
detail_url = f"https://collectionapi.metmuseum.org/public/collection/v1/objects/{obj_id}"
try:
res = session.get(detail_url, timeout=12)
if res.status_code == 200:
return res.json()
except Exception:
pass
return None
textile_terms = (
"textile", "tapestry", "embroidery", "brocade", "fabric", "woven", "weaving",
"cloth", "velvet", "silk", "cotton", "linen", "wool", "lace", "carpet", "rug",
"quilt", "blanket", "coverlet", "bedcover", "curtain", "sampler",
)
met_count = 0
# 取前 30 个 object 详情,控制并发数量,防止 Met 的限流
with ThreadPoolExecutor(max_workers=5) as met_detail_executor:
met_detail_futures = {met_detail_executor.submit(fetch_met_detail, oid): oid for oid in met_object_ids[:30]}
for fut in as_completed(met_detail_futures):
detail = fut.result()
if detail and (detail.get("primaryImageSmall") or detail.get("primaryImage")):
title = str(detail.get("title") or "精美印花织物").strip()
material = " ".join(str(detail.get(key) or "") for key in ("objectName", "medium", "classification", "title")).lower()
if not any(term in material for term in textile_terms):
continue
culture = str(detail.get("culture") or "").strip()
period = str(detail.get("period") or "").strip()
date = str(detail.get("objectDate") or "").strip()
parts = ["大都会博物馆藏"]
if culture:
parts.append(f"工艺文化: {culture}")
if detail.get("medium"):
parts.append(f"材质: {str(detail['medium']).strip()}")
if period or date:
parts.append(f"年代: {period or date}")
summary = " | ".join(parts)
items.append(TrendItem(
source="The Met",
category=category_name,
category_key=category_key,
title=title,
url=f"https://www.metmuseum.org/art/collection/search/{detail.get('objectID')}",
thumbnail=detail.get("primaryImageSmall") or detail.get("primaryImage"),
image_original=detail.get("primaryImage") or detail.get("primaryImageSmall"),
summary=summary,
))
met_count += 1
print(f" [博物馆图库] The Met 成功提取并合并: {met_count} 张高清图")
# 2. 抓取维多利亚与阿尔伯特博物馆 (V&A) — 细分多渠道并发抓取 (技术、壁挂、纹样、刺绣)
va_queries = [
{"name": "V&A 工艺技术", "url": "https://api.vam.ac.uk/v2/objects/search?id_technique=x40351&q=textile&images_exist=1&page_size=15"},
{"name": "V&A 壁挂艺术", "url": "https://api.vam.ac.uk/v2/objects/search?q=Hanging&images_exist=1&page_size=15"},
{"name": "V&A 经典纹样", "url": "https://api.vam.ac.uk/v2/objects/search?q=Pattern&images_exist=1&page_size=15"},
{"name": "V&A 刺绣艺术", "url": "https://api.vam.ac.uk/v2/objects/search?q=embroidery&images_exist=1&page_size=15"},
]
def fetch_single_va_query(q_info):
va_items = []
try:
res = session.get(q_info["url"], timeout=20)
if res.status_code == 200:
records = res.json().get("records", [])
for rec in records:
img_info = rec.get("_images", {})
image_url = img_info.get("_primary_thumbnail")
if image_url:
thumb = image_url.replace("!100,100", "!600,")
original = image_url.replace("!100,100", "full")
else:
continue
primary_title = str(rec.get("_primaryTitle") or "").strip()
object_type = str(rec.get("objectType") or "").strip()
system_number = str(rec.get("systemNumber") or "").strip()
title = primary_title or object_type or "经典纺织藏品"
if not primary_title and system_number and object_type.casefold() in VAM_GENERIC_TITLE_ZH:
title = f"{object_type} [{system_number}]"
maker = rec.get("_primaryMaker", {}).get("name") if rec.get("_primaryMaker") else None
place = rec.get("_primaryPlace")
date = rec.get("_primaryDate")
parts = ["V&A 博物馆藏"]
if maker:
parts.append(f"制造/设计: {maker}")
if place or date:
parts.append(f"产地年代: {place or '未知'} ({date or '未知'})")
summary = " | ".join(parts)
va_items.append(TrendItem(
source="V&A Museum",
category=category_name,
category_key=category_key,
title=title,
url=f"https://collections.vam.ac.uk/item/{rec.get('systemNumber')}",
thumbnail=thumb,
image_original=original,
summary=summary,
))
else:
print(f" [博物馆图库] {q_info['name']} 返回状态受限 (HTTP {res.status_code})")
except Exception as e:
print(f" [博物馆图库] {q_info['name']} 请求异常: {e}")
return va_items
va_count = 0
with ThreadPoolExecutor(max_workers=4) as va_executor:
va_futures = [va_executor.submit(fetch_single_va_query, q) for q in va_queries]
for fut in as_completed(va_futures):
res_items = fut.result()
# 合并并去重图片/系统编号链接
for item in res_items:
if not any(x.url == item.url for x in items):
items.append(item)
va_count += 1
print(f" [博物馆图库] V&A Museum 成功提取并合并: {va_count} 张高清图")
return items
# ────────────── 数据汇总 & 输出 ──────────────
def collect_all_trends(session: requests.Session, now: datetime, cache: dict[str, str]) -> dict[str, Any]:
"""并发采集所有数据源,汇总为输出 payload。"""
print("\n[开始] 家纺流行趋势数据采集")
print("=" * 60)
all_items: list[TrendItem] = []
errors: list[str] = []
# POP 趋势网需要付费账号,本项目不再采集;图片搜索改为零抓取成本的前端快捷入口。
tasks = {
"免费趋势RSS": lambda: fetch_free_trend_rss(session, now),
"精选趋势页": lambda: fetch_curated_trend_pages(session, now),
"Dezeen展厅": lambda: fetch_dezeen_showroom(session, now),
"博物馆图库": lambda: fetch_museum_images(session),
"TrendHunter": lambda: fetch_trendhunter(session, now),
}
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {executor.submit(fn): name for name, fn in tasks.items()}
for future in as_completed(futures):
name = futures[future]
try:
result = future.result(timeout=90)
all_items.extend(result)
except Exception as exc:
error_msg = f"{name}: {exc}"
errors.append(error_msg)
print(f" [ERROR] {error_msg}")
print(f"\n[汇总] 共采集 {len(all_items)} 条趋势数据")
# 按分类分组
categories: dict[str, dict[str, Any]] = {}
seen_urls: set[str] = set()
# 限制单次运行新翻译上限,防止高频调用被谷歌限制IP
max_new_translations = 250
translated_count = 0
# 优先将最新发布的文章排在前面,确保首屏最新文章必定获得翻译
all_items.sort(
key=lambda x: x.published_at or "1970-01-01",
reverse=True
)
for item in all_items:
blocked_text = f"{item.category_key} {item.source} {item.url}".lower()
if any(marker in blocked_text for marker in BLOCKED_PAID_TREND_MARKERS):
continue
# 针对灵感图库,如果没有缩略图,则直接跳过不录入,保障不出现空白卡片
if item.category_key == "textile_gallery" and not item.thumbnail:
continue
# 去重
if item.url in seen_urls:
continue
seen_urls.add(item.url)
title = item.title
summary = item.summary
title_original = ""
summary_original = ""
# 针对外部英文源以及灵感图库进行免费汉化翻译
if item.category_key in ("trend_blogs", "fashion_trends", "textile_gallery"):
if title:
title_original = title
curated_title = curate_vam_title(title) if item.source == "V&A Museum" else ""
if curated_title:
title = curated_title
elif title in cache:
title = cache[title]
elif translated_count < max_new_translations:
translated = translate_to_zh_cn(session, title)
if translated:
cache[title] = translated
title = translated
translated_count += 1
if summary:
summary_original = summary
if summary in cache:
summary = cache[summary]
elif translated_count < max_new_translations:
translated = translate_to_zh_cn(session, summary)
if translated:
cache[summary] = translated
summary = translated
translated_count += 1
if item.category_key not in categories:
categories[item.category_key] = {
"name": item.category,
"source": item.source,
"items": [],
}
id_title = re.sub(r"\s+\[O\d+\]\s*$", "", item.title, flags=re.IGNORECASE) if item.source == "V&A Museum" else item.title
item_dict = {
"id": make_trend_id(item.source, id_title, item.url),
"title": title,
"title_original": title_original,
"url": item.url,
"source": item.source,
"category": item.category,
"category_key": item.category_key,
"thumbnail": item.thumbnail,
"image_original": item.image_original,
"summary": summary,
"summary_original": summary_original,
"published_at": item.published_at,
}
item_dict.update(translation_fields(
item.url,
source_text=f"{title_original or item.title} {summary_original or item.summary}",
))
categories[item.category_key]["items"].append(item_dict)
# 馆藏没有新闻发布时间,按来源和永久链接稳定排序;其他分类仍按发布时间倒序。
for category_key, cat_data in categories.items():
if category_key == "textile_gallery":
cat_data["items"].sort(key=lambda x: (x.get("source", ""), x.get("url", "")))
else:
cat_data["items"].sort(
key=lambda x: (
bool(x.get("thumbnail")) if category_key == "fashion_trends" else False,
x.get("published_at") or "1970-01-01",
),
reverse=True,
)
cat_data["count"] = len(cat_data["items"])
total_items = sum(len(c["items"]) for c in categories.values())
payload = {
"generated_at": iso(now),
"total_items": total_items,
"category_count": len(categories),
"source_count": len({i.source for i in all_items}),
"categories": categories,
"errors": errors if errors else None,
}
return payload
def validate_payload(payload: dict[str, Any]) -> None:
def walk(value: Any, path: str = "$") -> None:
if isinstance(value, dict):
for key, item in value.items():
walk(item, f"{path}.{key}")
elif isinstance(value, list):
for index, item in enumerate(value):
walk(item, f"{path}[{index}]")
elif isinstance(value, str) and "????" in value:
raise ValueError(f"前台数据文本损坏: {path}")
walk(payload)
# ────────────── 主函数 ──────────────
def main() -> int:
parser = argparse.ArgumentParser(description="家纺流行趋势数据采集")
parser.add_argument("--output-dir", default="data", help="输出目录")
args = parser.parse_args()
now = utc_now()
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "textile-trends.json"
session = create_session()
cache = load_zh_cache(output_dir)
payload = collect_all_trends(session, now, cache)
validate_payload(payload)
output_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
save_zh_cache(output_dir, cache)
print(f"\n[完成] 数据已写入: {output_path}")
print(f" 总条目: {payload['total_items']}")
print(f" 分类数: {payload['category_count']}")
print(f" 数据源: {payload['source_count']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())