Files
Sansheng-Intelligence/scripts/intelligence_activities.py
Jack f1ce583fd1 feat: 发布三笙AI情报局独立版公司研发基线
同步家纺情报 v2 数据、59 个严格权威源、趋势图库、移动端交互、Google/Yandex 翻译门控及静态 SEO/GEO 构建。

补充面向 C# 研发的 README,明确当前无 LLM、多模态与生图模型,记录翻译请求参数、缓存与失败策略、数据处理边界、前端职责和无 GitHub Pages 部署流程。

新增按日期倒序维护的 CHANGELOG,记录影响、验证与代码/数据/dist 回滚方法。

验证:11 个 Python 契约测试、13 个 Node 前端测试通过;静态构建生成 1096 个 Sitemap URL。
2026-08-14 19:26:39 +08:00

629 lines
27 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""抓取与家纺出海直接相关或跨境经营必须关注的活动。"""
from __future__ import annotations
import hashlib
import json
import re
from datetime import date, datetime
from typing import Any
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
try:
from .intelligence_web_translation import translation_fields
except ImportError:
from intelligence_web_translation import translation_fields
ACTIVITY_SOURCES: tuple[dict[str, str], ...] = (
{
"id": "activity_cifnews",
"name": "雨果跨境活动",
"url": "https://www.cifnews.com/activity",
"authority": "行业媒体发现",
"level": "行业媒体",
},
{
"id": "activity_amazon",
"name": "Amazon 全球开店官方直播",
"url": "https://gs.amazon.cn/resources/live-broadcast",
"authority": "平台官方",
"level": "官方",
},
{
"id": "activity_wipo",
"name": "WIPO 官方 Webinar",
"url": "https://www.wipo.int/meetings/en/topic.jsp?group_id=7",
"authority": "国际组织官方",
"level": "官方",
},
{
"id": "activity_cbp",
"name": "美国海关 CBP Trade Webinar",
"url": "https://www.cbp.gov/trade/stakeholder-engagement/webinars",
"authority": "美国政府官方",
"level": "官方",
},
{
"id": "activity_ccpit",
"name": "中国贸促会经贸活动云",
"url": "https://bizevent.ccpit.org/",
"authority": "中国贸促会官方",
"level": "政府/贸促机构",
},
{
"id": "activity_amazon_global",
"name": "Amazon 全球卖家活动",
"url": "https://sell.amazon.com/events",
"authority": "Amazon 平台官方",
"level": "官方",
},
{
"id": "activity_ebay",
"name": "eBay Seller Events",
"url": "https://community.ebay.com/ebay-seller-events/",
"authority": "eBay 平台官方",
"level": "官方",
},
{
"id": "activity_eu_ip",
"name": "欧盟知识产权帮助中心活动",
"url": "https://intellectual-property-helpdesk.ec.europa.eu/news-events/upcoming-trainings-events_en",
"authority": "欧盟委员会官方",
"level": "国际/区域官方",
},
{
"id": "activity_uspto",
"name": "USPTO 商标与品牌保护活动",
"url": "https://www.uspto.gov/about-us/events",
"authority": "美国专利商标局官方",
"level": "政府官方",
},
{
"id": "activity_eu_trade",
"name": "欧盟贸易政策活动",
"url": "https://policy.trade.ec.europa.eu/events_en",
"authority": "欧盟委员会贸易总司官方",
"level": "国际/区域官方",
},
)
TEXTILE_TERMS = re.compile(
r"家纺|家居|床垫|床品|床单|寝具|被子|被套|棉被|枕|靠垫|窗帘|毛巾|纺织|面料|Wayfair|"
r"textile|bedding|mattress|cushion",
re.I,
)
HOME_TEXTILE_TERMS = re.compile(
r"家纺|家居纺织|床垫|床品|床单|寝具|被子|被套|棉被|枕头|枕芯|靠垫|窗帘|毛巾|浴巾|"
r"home textile|bedding|mattress|pillow|cushion|duvet|bed linen|curtain|towel",
re.I,
)
APPAREL_ONLY_TERMS = re.compile(r"女装|男装|西装|礼服|鞋服|美妆|women'?s suit|menswear|womenswear|apparel only", re.I)
COMPLIANCE_TERMS = re.compile(
r"合规|法规|税则|海关|关税|商标|知识产权|品牌保护|侵权|认证|EPR|VAT|GPSR|EAPA|INFORM|"
r"trademark|Madrid System|classification|heading\s*\d|trade violations?",
re.I,
)
LOGISTICS_TERMS = re.compile(r"物流|仓储|FBA|配送|退货|海外仓|供应链|履约", re.I)
PLATFORM_ACTION_TERMS = re.compile(
r"入驻|招商|平台规则|账户|账号|认证|INFORM|合规|费用|佣金|物流|仓储|FBA|退货|履约|关税|税务|召回|"
r"onboarding|seller verification|account|compliance|fee|fulfillment|warehouse|returns?|tariff|recall",
re.I,
)
PLATFORM_TERMS: tuple[tuple[str, str], ...] = (
("Amazon", r"Amazon|亚马逊"),
("Walmart", r"Walmart|沃尔玛"),
("TikTok Shop", r"TikTok|TK\b"),
("Temu", r"Temu"),
("Shopee", r"Shopee"),
("Lazada", r"Lazada"),
("Mercado Libre", r"Mercado Libre|美客多"),
("Ozon", r"Ozon"),
("eBay", r"eBay"),
("Noon", r"Noon"),
("Wayfair", r"Wayfair"),
("Rakuten", r"Rakuten|乐天"),
("Coupang", r"Coupang"),
("Jumia", r"Jumia"),
)
MONTHS = {name: index for index, name in enumerate(
("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"),
start=1,
)}
MONTHS.update({name[:3]: index for name, index in MONTHS.copy().items()})
def _text(value: str) -> str:
return " ".join((value or "").split())
def _id(source_id: str, start_date: str, url: str) -> str:
digest = hashlib.sha256(f"{source_id}|{start_date}|{url}".encode("utf-8")).hexdigest()[:18]
return f"activity-{digest}"
def _platforms(title: str) -> list[str]:
return [name for name, pattern in PLATFORM_TERMS if re.search(pattern, title, re.I)]
def _region(title: str, location: str, platforms: list[str]) -> str:
value = f"{title} {location}"
if re.search(r"欧洲|英国|德国|法国|EU\b|Europe", value, re.I):
return "欧洲"
if re.search(r"美国|加拿大|北美|US\b|USA|Canada", value, re.I):
return "北美"
if re.search(r"中东|沙特|阿联酋|Noon", value, re.I):
return "中东"
if re.search(r"日本|韩国|Rakuten|Coupang", value, re.I):
return "东亚"
if re.search(r"东南亚|Shopee|Lazada|TikTok", value, re.I):
return "东南亚"
if re.search(r"拉美|巴西|墨西哥|美客多|Mercado", value, re.I):
return "拉丁美洲"
if location and location not in ("线上", "Virtual"):
return "中国"
return "全球"
def _profile(title: str, source_id: str) -> tuple[str, str, str]:
if HOME_TEXTILE_TERMS.search(title) and not APPAREL_ONLY_TERMS.search(title):
return (
"家纺/纺织直接相关",
"direct",
"与家纺、纺织品或家居品类直接相关,优先核对产品规格、税则归类、平台机会与报名截止时间。",
)
if COMPLIANCE_TERMS.search(title):
return (
"跨境合规必看",
"must",
"涉及关税、海关、知识产权或平台法规,可能直接影响商品上架、清关、账户与品牌安全。",
)
if LOGISTICS_TERMS.search(title):
return (
"物流履约必看",
"must",
"涉及入仓、配送、退货或供应链成本,适合运营、跟单和物流负责人参加。",
)
if _platforms(title) and PLATFORM_ACTION_TERMS.search(title):
return (
"平台招商与运营",
"must",
"由平台或行业机构发布,适合核对最新招商条件、站点机会、旺季节奏和运营规则。",
)
if source_id in {"activity_ccpit", "activity_eu_trade"} and re.search(r"出海|跨境|贸易|关税|海关|Trade Policy|Export Control|digital trade|supply chain", title, re.I):
return (
"跨境经营必看",
"must",
"由政府或区域官方发布,适合核对贸易政策、出海服务、关税、供应链与市场准入变化。",
)
return ("经营增长", "observe", "属于一般跨境经营活动,家纺团队可按目标市场和当前问题决定是否参加。")
def _localized_title(title: str) -> str:
exact = {
"Mattresses and Cushions: Hybrids and More of Heading 9404": "美国海关:床垫与靠垫混合产品(税则号 9404)归类讲座",
"Wadding, Gauze, Bandages, and Similar Articles": "美国海关:絮胎、纱布、绷带及类似制品归类讲座",
"Enforce and Protect Act (EAPA) Q3": "美国海关:EAPA 反逃避执法与举报机制讲座",
"Madrid System Webinar: Managing International Trademark Registration – Subsequent Designation and Renewal (in Chinese)": "WIPO 中文讲座:国际商标注册的后期指定与续展管理",
}
return exact.get(title, title)
def _item(
*, source: dict[str, str], title: str, start_date: date, url: str, checked_at: str,
location: str = "线上", image: str = "", source_language: str = "zh-CN", profile_text: str = "",
) -> dict[str, Any] | None:
title = _text(title)
category, relevance, summary = _profile(_text(profile_text) or title, source["id"])
if relevance == "observe":
return None
platforms = _platforms(title)
region = _region(title, location, platforms)
display_title = _localized_title(title)
item = {
"id": _id(source["id"], start_date.isoformat(), url),
"section": "activity",
"title": display_title,
"title_original": title if display_title != title else "",
"summary": summary,
"start_date": start_date.isoformat(),
"end_date": start_date.isoformat(),
"days_until": 0,
"location": location or "线上",
"venue": location or "线上",
"format": "线上" if location in ("线上", "Virtual") else "线下",
"region": region,
"platforms": platforms,
"activity_type": category,
"relevance": relevance,
"source": source["name"],
"source_id": source["id"],
"source_url": url,
"url": url,
"registration_url": url,
"authority": source["authority"],
"source_level": source["level"],
"confidence": "中" if source["level"] == "行业媒体" else "高",
"official_activity": source["level"] != "行业媒体",
"image": image,
"verified_at": checked_at,
"checked_at": checked_at,
"next_action": "先核对议程、适用站点和报名截止时间,再决定由老板、运营、合规或跟单人员参加。",
"poster": {"eyebrow": "跨境活动", "accent": "#2f7d6d", "ink": "#10251f", "paper": "#eef8f4"},
}
translation = translation_fields(url, source_language, title)
item.update(translation)
item.update({
"source_needs_translation": translation["needs_translation"],
"source_translation_url": translation["translation_url"],
"source_translation_fallback_url": translation["translation_fallback_url"],
})
return item
def _parse_cifnews(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
soup = BeautifulSoup(html, "html.parser")
rows: list[dict[str, Any]] = []
for card in soup.select(".cif-flexbox__wrap"):
title_node = card.select_one(".title-text")
image_node = card.select_one(".cif-flexbox__imgbox img")
title = _text(title_node.get_text(" ", strip=True) if title_node else "")
image = str(image_node.get("src") or "") if image_node else ""
date_node = card.select_one(".desc.time")
if date_node:
match = re.search(r"(20\d{2})[.-](\d{1,2})[.-](\d{1,2})", date_node.get_text(" ", strip=True))
link = card.select_one(".signup-active-btn[href]") or card.select_one("a.title[href]")
if not match or not link:
continue
start = date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
if start < now:
continue
location_node = card.select_one(".bottom .desc")
item = _item(
source=source, title=title, start_date=start,
url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at,
location=_text(location_node.get_text(" ", strip=True) if location_node else "线下"),
image=image,
)
if item:
rows.append(item)
continue
for event in card.select(".series-item.is-active[href]"):
match = re.search(r"(\d{1,2})[.-](\d{1,2})", _text(event.get_text(" ", strip=True)))
if not match:
continue
start = date(now.year, int(match.group(1)), int(match.group(2)))
if start < now:
continue
city_node = event.select_one(".series-item-city")
city = _text(city_node.get_text(" ", strip=True) if city_node else "线下")
item = _item(
source=source, title=f"{title} · {city}", start_date=start,
url=urljoin(source["url"], str(event.get("href") or "")), checked_at=checked_at,
location=city, image=image,
)
if item:
rows.append(item)
return rows
def _parse_amazon(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
soup = BeautifulSoup(html, "html.parser")
rows: list[dict[str, Any]] = []
for card in soup.select(".tile-wrap"):
value = _text(card.get_text(" ", strip=True))
match = re.search(r"(20\d{2}-\d{2}-\d{2})", value)
link = card.select_one("a[href]")
if not match or not link:
continue
start = date.fromisoformat(match.group(1))
if start < now:
continue
title = value[:match.start()].strip(" -|:")
item = _item(
source=source, title=title, start_date=start,
url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at,
location="线上", source_language="zh-CN",
)
if item:
item["platforms"] = ["Amazon"]
rows.append(item)
return rows
def _english_date(value: str) -> date | None:
match = re.search(
r"(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),\s+(20\d{2})",
value,
)
if not match:
return None
return date(int(match.group(3)), MONTHS[match.group(1)], int(match.group(2)))
def _parse_wipo(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
soup = BeautifulSoup(html, "html.parser")
rows: list[dict[str, Any]] = []
for card in soup.select("article.listing--event"):
link = card.select_one("h3 a[href]")
if not link:
continue
title = _text(link.get_text(" ", strip=True))
if not re.search(r"Madrid System|Trademark|Industrial Design|Hague System|Goods and Services", title, re.I):
continue
start = _english_date(_text(card.get_text(" ", strip=True)))
if not start or start < now:
continue
item = _item(
source=source, title=title, start_date=start,
url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at,
location="线上", source_language="en",
)
if item:
rows.append(item)
return rows
def _parse_cbp(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
soup = BeautifulSoup(html, "html.parser")
rows: list[dict[str, Any]] = []
for card in soup.select("li.usa-icon-list__item"):
link = card.select_one("a[href]")
if not link:
continue
title = _text(link.get_text(" ", strip=True))
if not (TEXTILE_TERMS.search(title) or re.search(r"EAPA|Trade Violations?", title, re.I)):
continue
start = _english_date(_text(card.get_text(" ", strip=True)))
if not start or start < now:
continue
item = _item(
source=source, title=title, start_date=start,
url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at,
location="线上", source_language="en",
)
if item:
rows.append(item)
return rows
def _parse_ccpit(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
match = re.search(r"var\s+meetingList\s*=\s*(\{[\s\S]*?\});", html)
if not match:
return []
payload = json.loads(match.group(1))
rows: list[dict[str, Any]] = []
for meeting in payload.get("content") or []:
raw_title = _text(str(meeting.get("title") or ""))
profile_text = _text(" ".join(str(meeting.get(key) or "") for key in ("title", "briefIntroduction", "agenda", "targetWebsite")))
if not re.search(r"出海|跨境|外贸|数字贸易|知识产权|商标|海关|关税|纺织|家纺|服装|平台", profile_text, re.I):
continue
start_value = str(meeting.get("startDate") or "")[:10]
if not re.match(r"20\d{2}-\d{2}-\d{2}", start_value):
continue
start = date.fromisoformat(start_value)
if start < now or int(meeting.get("status") or 0) not in (2, 3):
continue
location = "线上" if meeting.get("onLineMeeting") else _text(" ".join(str(meeting.get(key) or "") for key in ("addressProvince", "addressCity", "addressDetail"))).strip() or "中国"
item = _item(
source=source, title=raw_title, start_date=start,
url=urljoin(source["url"], f"/meetings/{meeting.get('id')}"), checked_at=checked_at,
location=location, source_language="zh-CN", profile_text=profile_text,
)
if item:
item["end_date"] = str(meeting.get("endDate") or start_value)[:10]
item["registration_deadline"] = str((meeting.get("signSetting") or {}).get("signEndDate") or "")[:10]
item["free"] = bool(meeting.get("free"))
rows.append(item)
return rows
def _parse_amazon_global(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
soup = BeautifulSoup(html, "html.parser")
rows: list[dict[str, Any]] = []
for card in soup.select(".background-color-white.has-box-shadow"):
value = _text(card.get_text(" ", strip=True))
date_match = re.search(r"(?:When:\s*)?([A-Z][a-z]+\s+\d{1,2}(?:-\d{1,2})?,\s+20\d{2})", value)
if not date_match:
continue
parts = date_match.group(1).replace("–", "-").split(",")
day_match = re.search(r"([A-Z][a-z]+)\s+(\d{1,2})(?:-\d{1,2})?", parts[0])
if not day_match:
continue
start = date(int(parts[1].strip()), MONTHS[day_match.group(1)], int(day_match.group(2)))
if start < now:
continue
title_node = card.select_one("h2.heading.font-size-xlarge")
title = _text(title_node.get_text(" ", strip=True) if title_node else value[:date_match.start()])
link = next((node for node in card.select("a[href]") if "register" in _text(node.get_text(" ", strip=True)).lower() or "注册" in node.get_text()), card.select_one("a[href]"))
if not link:
continue
item = _item(source=source, title=title, start_date=start, url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at, location="线上", source_language="zh-CN", profile_text=value)
if item:
item["platforms"] = ["Amazon"]
rows.append(item)
return rows
def _parse_ebay(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for script in BeautifulSoup(html, "html.parser").select("script"):
text = script.string or script.get_text()
if "start_date" not in text or "events/details" not in text:
continue
try:
payload = json.loads(text)
except json.JSONDecodeError:
continue
stack: list[Any] = [payload]
while stack:
value = stack.pop()
if isinstance(value, dict):
if value.get("start_date") and value.get("title") and value.get("url") and "/events/details/" in str(value["url"]):
start = date.fromisoformat(str(value["start_date"])[:10])
if start >= now and str(value["url"]) not in seen:
seen.add(str(value["url"]))
item = _item(source=source, title=_text(str(value["title"])), start_date=start, url=str(value["url"]), checked_at=checked_at, location="线上", source_language="en", profile_text=str(value["title"]))
if item:
item["platforms"] = ["eBay"]
rows.append(item)
stack.extend(value.values())
elif isinstance(value, list):
stack.extend(value)
return rows
def _ecl_date(card: Any) -> date | None:
text = _text(card.get_text(" ", strip=True))
match = re.search(r"\b(\d{1,2})(?:-\d{1,2})?\s+([A-Za-z]+)\s+(20\d{2})\b", text)
if not match:
return None
month = MONTHS.get(match.group(2)) or MONTHS.get(match.group(2)[:3])
return date(int(match.group(3)), month, int(match.group(1))) if month else None
def _parse_eu_cards(html: str, source: dict[str, str], now: date, checked_at: str, *, trade: bool = False) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
soup = BeautifulSoup(html, "html.parser")
for card in soup.select("article.ecl-content-item.ecl-content-item--inline"):
link = card.select_one("a[data-ecl-title-link][href]") or card.select_one("a[href]")
start = _ecl_date(card)
if not link or not start or start < now:
continue
title = _text(link.get_text(" ", strip=True))
profile_text = _text(card.get_text(" ", strip=True))
if trade and not re.search(r"custom|tariff|trade|export|digital|supply|textile|policy|关税|贸易|海关", profile_text, re.I):
continue
if not trade and not re.search(r"trademark|design|copyright|brand|IP|知识产权|商标|版权", profile_text, re.I):
continue
location = "线上" if re.search(r"Live streaming available|Webinar", profile_text, re.I) else "欧洲"
item = _item(source=source, title=title, start_date=start, url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at, location=location, source_language="en", profile_text=profile_text)
if item:
rows.append(item)
return rows
def _parse_eu_ip(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
return _parse_eu_cards(html, source, now, checked_at, trade=False)
def _parse_eu_trade(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
return _parse_eu_cards(html, source, now, checked_at, trade=True)
def _parse_uspto(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
soup = BeautifulSoup(html, "html.parser")
for row in soup.select("table.views-table.views-view-table.cols-4 tbody tr[date]"):
raw_date = str(row.get("date") or "")
if not re.fullmatch(r"20\d{6}", raw_date):
continue
start = date(int(raw_date[:4]), int(raw_date[4:6]), int(raw_date[6:]))
if start < now:
continue
title_node = row.select_one("td.name a[href]")
if not title_node:
continue
title = _text(title_node.get_text(" ", strip=True))
profile_text = _text(row.get_text(" ", strip=True))
if re.search(r"CANCELED|POSTPONED", profile_text, re.I) or not re.search(r"trademark|brand|counterfeit|design|intellectual property|商标|品牌|假冒|外观", profile_text, re.I):
continue
location = _text((row.select_one("td.location") or row).get_text(" ", strip=True))
item = _item(source=source, title=title, start_date=start, url=urljoin(source["url"], str(title_node.get("href") or "")), checked_at=checked_at, location=location or "线上", source_language="en", profile_text=profile_text)
if item:
rows.append(item)
return rows
PARSERS = {
"activity_cifnews": _parse_cifnews,
"activity_amazon": _parse_amazon,
"activity_wipo": _parse_wipo,
"activity_cbp": _parse_cbp,
"activity_ccpit": _parse_ccpit,
"activity_amazon_global": _parse_amazon_global,
"activity_ebay": _parse_ebay,
"activity_eu_ip": _parse_eu_ip,
"activity_uspto": _parse_uspto,
"activity_eu_trade": _parse_eu_trade,
}
def build_activities(now: date, checked_at: str, session: requests.Session) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""返回未来活动与逐源抓取状态;活动源不冒充原 52 个权威资讯源。"""
activities: list[dict[str, Any]] = []
statuses: list[dict[str, Any]] = []
for source in ACTIVITY_SOURCES:
try:
response = session.get(source["url"], timeout=45, allow_redirects=True)
response.raise_for_status()
parsed = PARSERS[source["id"]](response.text, source, now, checked_at)
activities.extend(parsed)
statuses.append({
**source,
"ok": True,
"status_code": response.status_code,
"final_url": response.url,
"items": len(parsed),
"checked_at": checked_at,
"error": "",
})
except (requests.RequestException, ValueError) as error:
statuses.append({
**source,
"ok": False,
"status_code": 0,
"final_url": source["url"],
"items": 0,
"checked_at": checked_at,
"error": str(error),
})
unique: dict[tuple[str, str], dict[str, Any]] = {}
for item in activities:
item["days_until"] = (date.fromisoformat(item["start_date"]) - now).days
key = (str(item["start_date"]), re.sub(r"\W+", "", str(item["title"]).lower()))
existing = unique.get(key)
if not existing or (item["official_activity"] and not existing["official_activity"]):
unique[key] = item
ordered = sorted(
unique.values(),
key=lambda item: (item["start_date"], 0 if item["official_activity"] else 1, item["title"]),
)
return ordered, statuses
def merge_previous_activities(current: list[dict[str, Any]], previous: list[dict[str, Any]], now: date) -> list[dict[str, Any]]:
"""当前抓取优先,补回上一快照中仍未过期的活动,避免源站窗口滚动造成数据闪断。"""
merged: dict[tuple[str, str], dict[str, Any]] = {}
for item in current:
key = (str(item.get("start_date") or ""), re.sub(r"\W+", "", str(item.get("title") or "").lower()))
if key[0] and key[1]:
item["days_until"] = (date.fromisoformat(key[0]) - now).days
merged[key] = item
# ponytail: 仅按结束日期保留上一快照,活动撤回或改期需下一次抓取覆盖。
for item in previous:
try:
end_date = date.fromisoformat(str(item.get("end_date") or item.get("start_date") or "")[:10])
except ValueError:
continue
if end_date < now:
continue
key = (str(item.get("start_date") or ""), re.sub(r"\W+", "", str(item.get("title") or "").lower()))
if key[0] and key[1] and key not in merged:
item = dict(item)
item["days_until"] = (date.fromisoformat(key[0]) - now).days
merged[key] = item
return sorted(merged.values(), key=lambda item: (item["start_date"], 0 if item.get("official_activity") else 1, item.get("title") or ""))
if __name__ == "__main__":
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0"
rows, source_statuses = build_activities(date.today(), datetime.now().isoformat(), session)
print(f"活动 {len(rows)} 条,来源 {sum(item['ok'] for item in source_statuses)}/{len(source_statuses)}")