fix: 修复独立站日历节日去重与中文资料链接

This commit is contained in:
Jack
2026-08-17 12:58:19 +08:00
parent b1b7c13c97
commit 7af253f081
7 changed files with 15419 additions and 20828 deletions
+15
View File
@@ -1,5 +1,20 @@
# 更新日志
## 2026-08-17
### 日历数据规则与旧快照修复
- 按当前 UTC 日期重建公开日历快照,过滤已结束事件并保留未来事件的可用排序。
- 将元旦/新年假期、开斋节、宰牲节和农历新年按年度合并,卡内保留不同国家的日期差异。
- 修正日本 `Coming of Age Day`、`Greenery Day`、印度尼西亚独立日和菲律宾国家英雄日的中文标题;阻止“国家纪念与公共假日”等泛称跨国误合并。
- 中国市场节日不再生成无意义的机器翻译入口;百度百科元旦链接固定指向“元旦”;Bing 图片查询收敛为标准节日名、国家和 `holiday`。
- 同步更新 Nager 日历翻译字段、公开 JSON 快照和回归测试,代码与数据应作为同一提交回滚。
### 验证与恢复
- Python 合同测试 11 项、Node 前端测试 14 项通过。
- `docs/CODEX_TASK_STATE.md` 固化任务目标、验收标准和回滚方式,避免服务中断后丢失上下文。
## 2026-08-15
### 日历实用性与筛选交互
+2 -2
View File
@@ -6,12 +6,12 @@
## 当前基线
当前提交数据以 `data/*.json` 为准,业务基准日为 **2026-08-14**:
当前提交数据以 `data/*.json` 为准,业务基准日为 **2026-08-17**:
| 指标 | 当前值 |
|---|---:|
| 严格权威源 | 59/59 |
| 日历原始事件 / 聚合机会 | 1270 / 673 |
| 日历原始事件 / 聚合机会 | 1261 / 640 |
| 今日必看 | 24 |
| 展会 | 79 |
| 政策与召回 | 48 |
+15273 -20809
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# 任务状态:日历数据规则与旧快照修复
更新时间:2026-08-17(Asia/Shanghai)
## 目标
- 日历按当前 UTC 日期过滤和排序,已过日期降权,不让过期节日占据默认首屏。
- 合并同一节日的跨天、跨国家记录;保留各国日期差异,不把连续假期拆成重复卡片。
- 清理国家纪念类泛称、错误中文译名和失效百度百科链接。
- 中国市场记录不生成无意义的机器翻译入口;图片查询只使用节日标准名、国家和 `holiday` 短查询词。
- 独立站与主站分别维护、分别测试、分别提交和推送。
## 验收标准
- 独立站 Python 11 项、Node 前端 14 项测试通过。
- 2027 年元旦/新年假期合并为一张卡,至少覆盖 1 月 1 日至 1 月 8 日,并包含中国与俄罗斯语境。
- 2027 年宰牲节合并为一张连续假期卡;日本 `Coming of Age Day`、`Greenery Day` 显示中文规范名。
- 不输出带英文副标题的“国家纪念与公共假日”等泛称卡片。
## 回滚
本次代码、测试和公开 JSON 必须作为同一提交回滚:`git revert <commit>`,随后重新构建静态目录。不要把父目录工作树的改动提交到本仓库。
+74 -17
View File
@@ -109,6 +109,7 @@ LOCAL_HOLIDAY_NAMES: dict[str, dict[str, tuple[str, str]]] = {
"AU": ("Boxing Day (observed)", "英语"), "GB": ("Boxing Day (observed)", "英语"),
},
"lunar-new-year": {"CN": ("春节", "简体中文"), "KR": ("설날", "韩语"), "VN": ("Tết Nguyên Đán", "越南语"), "SG": ("农历新年 / Chinese New Year", "简体中文 / 英语")},
"new-year-eve": {"DK": ("Nytårsaften", "丹麦语"), "SE": ("Nyårsafton", "瑞典语"), "PH": ("一年中的最后一天", "英语 / 菲律宾语")},
"eid-fitr": {"AE": ("عيد الفطر", "阿拉伯语"), "TR": ("Ramazan Bayramı", "土耳其语"), "ID": ("Idul Fitri", "印度尼西亚语")},
"eid-adha": {"AE": ("عيد الأضحى", "阿拉伯语"), "TR": ("Kurban Bayramı", "土耳其语"), "ID": ("Idul Adha", "印度尼西亚语")},
"assumption-day": {
@@ -118,6 +119,11 @@ LOCAL_HOLIDAY_NAMES: dict[str, dict[str, tuple[str, str]]] = {
"PT": ("Assunção de Nossa Senhora", "葡萄牙语"), "ES": ("Asunción", "西班牙语"),
},
"indonesia-independence": {"ID": ("Hari Kemerdekaan Republik Indonesia", "印度尼西亚语")},
"new-year": {
"CN": ("元旦", "简体中文"), "JP": ("元日", "日语"), "RU": ("Новогодние каникулы", "俄语"),
},
"public-holiday:JP:coming of age day": {"JP": ("成人の日", "日语")},
"public-holiday:JP:greenery day": {"JP": ("みどりの日", "日语")},
}
FESTIVAL_TARGET_COUNTRIES = {
@@ -139,6 +145,10 @@ HOLIDAY_TRANSLATIONS = {
"whit monday": "圣灵降临节星期一", "pentecost monday": "圣灵降临节星期一",
"king's birthday": "国王诞辰", "mountain day": "山之日", "marine day": "海之日",
"respect for the aged day": "敬老日", "culture day": "文化日", "children's day": "儿童节",
"new year s day": "元旦", "new year holiday": "新年假期",
"new year s eve": "跨年夜", "last day of the year": "跨年夜",
"coming of age day": "成人节", "greenery day": "绿之日",
"labour thanksgiving day": "勤劳感谢之日", "labor thanksgiving day": "勤劳感谢之日",
}
SUBDIVISION_NAMES = {
@@ -282,6 +292,12 @@ FESTIVAL_PROFILES = {
"design": ["使用新年焕新而非单一宗教表达", "按南北半球季节调整材质"],
"avoid": ["南半球正值夏季,不要统一推保暖产品"],
},
"new-year-eve": {
"title": "跨年夜", "relevance": "low", "decision": "仅用于运营排班",
"what": "公历新年前夜或年末公共休假节点,主要影响跨年营业、物流和客服排班。",
"scenes": ["跨年营业", "物流排班"], "products": [], "design": [],
"avoid": ["不要把跨年夜与元旦或连续新年假期拆成多个重复消费节"],
},
"super-bowl": {
"title": "超级碗观赛消费节点", "relevance": "high", "decision": "值得场景备货",
"what": "美国职业橄榄球年度决赛,带动家庭观赛、聚会、酒店和城市活动消费。",
@@ -371,9 +387,11 @@ FESTIVAL_PROFILES = {
GLOBAL_AGGREGATE_KEYS = {
"black-friday", "cyber-monday", "winter-holiday", "christmas", "boxing-day",
"lunar-new-year", "eid-fitr", "eid-adha", "easter", "assumption-day", "new-year", "st-stephens-day",
"lunar-new-year", "eid-fitr", "eid-adha", "easter", "assumption-day", "new-year", "new-year-eve", "st-stephens-day",
}
RANGE_AGGREGATE_KEYS = {"new-year", "eid-fitr", "eid-adha", "lunar-new-year"}
FESTIVAL_ENGLISH_TITLES = {
"back-to-school": "Back to School",
"black-friday": "Black Friday",
@@ -385,6 +403,7 @@ FESTIVAL_ENGLISH_TITLES = {
"eid-fitr": "Eid al-Fitr",
"lunar-new-year": "Lunar New Year",
"new-year": "New Year's Day",
"new-year-eve": "New Year's Eve",
"singles-day": "Singles' Day",
"st-stephens-day": "St. Stephen's Day",
"super-bowl": "Super Bowl",
@@ -393,6 +412,7 @@ FESTIVAL_ENGLISH_TITLES = {
}
GENERIC_HOLIDAY_TITLES = {"国家纪念与公共假日", "地区公共假日"}
UNSAFE_CROSS_COUNTRY_TITLES = {"独立日", "国庆日", "共和国日", "宪法日", "解放日", "胜利日", "纪念日"}
HOLIDAY_TITLE_ALIASES = {"五一劳动节": "劳动节"}
@@ -446,7 +466,9 @@ def festival_key(name: str, source_id: str = "", country_code: str = "") -> str:
return "eid-adha"
if any(token in lower for token in ("easter", "good friday", "pentecost", "whit monday", "复活节", "耶稣受难")):
return "easter"
if any(token in lower for token in ("new year's day", "new year holiday", "元旦", "新年假期")):
if any(token in lower for token in ("new year s eve", "last day of the year", "跨年夜")):
return "new-year-eve"
if any(token in lower for token in ("new year s day", "new year holiday", "new year s holiday", "元旦", "新年假期")):
return "new-year"
if any(token in lower for token in ("memorial", "remembrance", "all souls", "追悼", "纪念")):
return "memorial"
@@ -477,7 +499,12 @@ def calendar_event_type(name: str, source_id: str = "") -> str:
def holiday_title(local_name: str, english_name: str, country_code: str) -> str:
translated = HOLIDAY_TRANSLATIONS.get(english_name.lower())
normalized = _normalized_name(english_name)
if country_code == "ID" and normalized == "independence day":
return "印度尼西亚独立日"
if country_code == "PH" and normalized == "national heroes day":
return "菲律宾国家英雄日"
translated = HOLIDAY_TRANSLATIONS.get(english_name.lower()) or HOLIDAY_TRANSLATIONS.get(_normalized_name(english_name))
if translated:
return translated
if country_code in ("CN", "JP", "KR") and local_name:
@@ -650,17 +677,20 @@ def culture_reference_url(title: str) -> str:
return f"https://zh.wikipedia.org/wiki/{quote(title.replace(' ', '_'), safe='')}"
def culture_reference_cn_url(title: str) -> str:
def culture_reference_cn_url(title: str, festival_key: str = "") -> str:
"""中国大陆访客使用国内网络更易访问的百度百科。"""
return f"https://baike.baidu.com/item/{quote(title, safe='')}"
aliases = {"new-year": "元旦"}
return f"https://baike.baidu.com/item/{quote(aliases.get(festival_key, title), safe='')}"
def visual_reference_url(title: str, country_details: list[dict[str, Any]]) -> str:
def visual_reference_url(title: str, country_details: list[dict[str, Any]], title_english: str = "") -> str:
"""图片入口只搜索节日本身,不复用来源页标题。"""
terms = [title]
for detail in country_details[:2]:
terms.extend((str(detail.get("local_name") or ""), str(detail.get("country") or "")))
query = " ".join(_dedupe(terms))
detail = next((row for row in country_details if row.get("country")), {})
query_title = title_english.strip() or str(detail.get("local_name") or title).strip()
if query_title in GENERIC_HOLIDAY_TITLES or not query_title:
query_title = str(detail.get("local_name") or title).strip()
country = str(detail.get("country_english") or detail.get("country") or "").strip()
query = " ".join(_dedupe([query_title, country, "holiday"]))[:120]
return f"https://www.bing.com/images/search?q={quote(query, safe='')}&setlang=zh-hans"
@@ -668,7 +698,11 @@ def calendar_group_identity(item: dict[str, Any]) -> tuple[str, str]:
"""同日同名的明确节日跨国家聚合;国家纪念类泛称仍按国家拆分。"""
key = str(item.get("festival_key") or "public-holiday")
title = HOLIDAY_TITLE_ALIASES.get(str(item.get("title") or "").strip(), str(item.get("title") or "").strip())
if title and title not in GENERIC_HOLIDAY_TITLES and (key.startswith("public-holiday:") or key == "memorial"):
if title and title not in GENERIC_HOLIDAY_TITLES and (
key.startswith("public-holiday:")
or key == "memorial"
or (key.startswith("national:") and title not in UNSAFE_CROSS_COUNTRY_TITLES)
):
return f"named-holiday:{_normalized_name(title)}", ""
country_partition = "" if key in GLOBAL_AGGREGATE_KEYS else str(item.get("country_code") or item.get("market_area") or item.get("region") or "")
return key, country_partition
@@ -677,6 +711,8 @@ def calendar_group_identity(item: dict[str, Any]) -> tuple[str, str]:
def calendar_group_date(item: dict[str, Any]) -> str:
"""圣诞季共用一张决策卡,其他节日仍按实际日期聚合。"""
start_date = str(item["start_date"])
if item.get("festival_key") in RANGE_AGGREGATE_KEYS:
return f"@{start_date[:4]}"
if item.get("festival_key") != "christmas":
return start_date
value = date.fromisoformat(start_date)
@@ -704,8 +740,15 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
grouped.setdefault((calendar_group_date(item), group_key, country_partition), []).append(item)
insights: list[dict[str, Any]] = []
for (start_date, group_key, _), rows in grouped.items():
rows = sorted(rows, key=lambda row: (str(row.get("start_date")) != start_date, str(row.get("start_date"))))
for (group_date, group_key, _), rows in grouped.items():
range_group = group_date.startswith("@")
start_date = min(str(row.get("start_date") or group_date) for row in rows) if range_group else group_date
rows = sorted(rows, key=lambda row: (
str(row.get("start_date")) != start_date,
0 if str(row.get("country_code") or "") == "CN" else 1,
str(row.get("country_code") or ""),
str(row.get("start_date") or ""),
))
row_keys = _dedupe([str(row.get("festival_key") or "public-holiday") for row in rows])
key = row_keys[0] if len(row_keys) == 1 else f"public-holiday:{group_key.removeprefix('named-holiday:')}"
profile = profile_for_market(key, str(rows[0].get("country_code") or ""))
@@ -715,7 +758,7 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
platforms = _dedupe([value for row in rows for value in row.get("platforms") or []])
titles = [str(row.get("title") or "") for row in rows if row.get("title")]
title = str(profile.get("title") or Counter(titles).most_common(1)[0][0])
if key.startswith("public-holiday:") or key in ("national", "memorial"):
if key.startswith(("public-holiday:", "national:")) or key in ("national", "memorial"):
title = Counter(titles).most_common(1)[0][0]
english_titles = [str(row.get("title_english") or "").strip() for row in rows if row.get("title_english")]
title_english = FESTIVAL_ENGLISH_TITLES.get(key, "")
@@ -822,6 +865,9 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
"label": christmas_date_label(value, start_date) if key == "christmas" else "地区日期",
"countries": variant_countries,
})
end_date = start_date if key == "christmas" else max(str(row.get("end_date") or start_date) for row in rows)
if key == "new-year" and "RU" in country_codes:
end_date = max(end_date, f"{start_date[:4]}-01-08")
insight = {
"id": f"calendar-insight-{digest}",
"section": "calendar",
@@ -842,7 +888,7 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
"change_status": "官方日期已核验",
"confidence": "高" if official_sources else "中高",
"start_date": start_date,
"end_date": start_date if key == "christmas" else max(str(row.get("end_date") or start_date) for row in rows),
"end_date": end_date,
"related_dates": related_dates,
"date_variants": date_variants,
"date_variation_note": (
@@ -876,9 +922,9 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
"days_until": (date.fromisoformat(start_date) - now).days,
"priority_score": max(int(row.get("priority_score") or 0) for row in rows) + min(12, len(countries)),
"analysis_basis": "事实层:官方日期与适用国家;分析层:家纺经营场景推断",
"visual_reference_url": visual_reference_url(title, country_details),
"visual_reference_url": visual_reference_url(title, country_details, title_english),
"culture_reference_url": culture_reference_url(title),
"culture_reference_cn_url": culture_reference_cn_url(title),
"culture_reference_cn_url": culture_reference_cn_url(title, key),
}
translation = {
key: first[key]
@@ -889,6 +935,17 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
)
if key in first
}
if "CN" in country_codes:
translation.update({
"source_language": "zh-CN",
"needs_translation": False,
"google_translation_supported": False,
"translation_url": "",
"translation_provider": "",
"translation_fallback_url": "",
"translation_fallback_provider": "",
"translation_notice": "该节日包含中国市场,中文访客直接查看中文节日资料。",
})
insight.update(translation)
insight["poster"] = poster_for(insight["id"], "calendar", insight["culture_key"])
insights.append(insight)
+7
View File
@@ -157,6 +157,8 @@ def build_nager_events(now: date, horizon: date, source: SourceSpec, result: Fet
continue
local_title = normalize_text(str(row.get("localName") or ""))
english_title = normalize_text(str(row.get("name") or local_title or "公共假日"))
if english_title.lower().replace("’", "'") == "new year's day" and day.month == 12 and day.day == 31:
continue
title = market_holiday_title(local_title, english_title, code)
subdivision_codes = [normalize_text(str(value)) for value in row.get("counties") or [] if value]
applicable_areas, country_scope = calendar_scope(bool(row.get("global", True)), subdivision_codes)
@@ -183,6 +185,11 @@ def build_nager_events(now: date, horizon: date, source: SourceSpec, result: Fet
"source_url": event_source_url,
"authority": "结构化公共假日数据",
})
item.update(translation_fields(
event_source_url,
"zh-CN" if code == "CN" else "en",
local_title or english_title,
))
items.append(enrich_market_calendar_item(item, now, code, poster_for))
return items
def build_govuk_events(now: date, horizon: date, source: SourceSpec, result: FetchResult) -> list[dict[str, Any]]:
+26
View File
@@ -146,6 +146,32 @@ class StandaloneProjectContractTests(unittest.TestCase):
if item.get("title") == title and item.get("start_date", "").startswith("2026-")
]
self.assertEqual(expected, len(matches), f"{title} 应跨国家聚合为单一日历卡片")
new_year = [
item for item in intelligence["calendar_insights"]
if item.get("festival_key") == "new-year" and item.get("start_date") == "2027-01-01"
]
self.assertEqual(len(new_year), 1)
self.assertGreaterEqual(new_year[0].get("end_date", ""), "2027-01-08")
self.assertLessEqual(new_year[0].get("end_date", ""), "2027-01-08")
self.assertIn("中国", new_year[0].get("affected_countries", []))
self.assertIn("俄罗斯", new_year[0].get("affected_countries", []))
self.assertFalse(new_year[0].get("needs_translation"))
new_year_eve = [
item for item in intelligence["calendar_insights"]
if item.get("festival_key") == "new-year-eve" and item.get("start_date") == "2026-12-31"
]
self.assertEqual(len(new_year_eve), 1)
self.assertEqual(new_year_eve[0].get("title"), "跨年夜")
self.assertFalse(any(item.get("title_english") == "New Year's Day" and item.get("start_date", "")[5:] == "12-31" for item in intelligence["calendar"]))
eid_adha = [
item for item in intelligence["calendar_insights"]
if item.get("festival_key") == "eid-adha" and item.get("start_date") == "2027-05-16"
]
self.assertEqual(len(eid_adha), 1)
self.assertGreaterEqual(eid_adha[0].get("end_date", ""), "2027-05-19")
self.assertEqual(next(item["title"] for item in intelligence["calendar_insights"] if item["id"] == "calendar-insight-4f8974ea2f346b"), "成人节")
self.assertEqual(next(item["title"] for item in intelligence["calendar_insights"] if item["id"] == "calendar-insight-87f06bf4075a17"), "绿之日")
self.assertFalse(any(item.get("title") == "国家纪念与公共假日" and item.get("title_english") for item in intelligence["calendar_insights"]))
self.assertTrue(all(item.get("title_english") for item in intelligence["calendar_insights"] if item.get("country_details")))
boxing_day = next(item for item in intelligence["calendar_insights"] if item.get("festival_key") == "boxing-day")
self.assertEqual(boxing_day["title_english"], "Boxing Day")