925 lines
36 KiB
Python
925 lines
36 KiB
Python
#!/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())
|