Files
Sansheng-Intelligence/scripts/intelligence_calendar.py
T

954 lines
60 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""备货日历的市场语境、经营分析与跨国节日聚合。"""
from __future__ import annotations
import hashlib
import re
from collections import Counter
from datetime import date, timedelta
from typing import Any, Callable
from urllib.parse import quote
COUNTRY_DETAILS = {
"CN": ("中国", "东亚", "china", ["简体中文"], ["天猫", "京东", "抖音电商"]),
"US": ("美国", "北美", "north-america", ["英语", "西班牙语"], ["Amazon US", "Walmart", "eBay", "Etsy", "TikTok Shop", "Temu"]),
"GB": ("英国", "英国及爱尔兰", "british-isles", ["英语"], ["Amazon UK", "eBay UK", "Etsy"]),
"DE": ("德国", "中西欧", "central-europe", ["德语"], ["Amazon DE", "OTTO", "eBay DE", "Temu"]),
"FR": ("法国", "中西欧", "central-europe", ["法语"], ["Amazon FR", "Cdiscount", "Etsy", "Temu"]),
"IT": ("意大利", "南欧", "southern-europe", ["意大利语"], ["Amazon IT", "eBay IT", "Etsy"]),
"ES": ("西班牙", "南欧", "southern-europe", ["西班牙语"], ["Amazon ES", "eBay ES", "Etsy"]),
"NL": ("荷兰", "中西欧", "central-europe", ["荷兰语"], ["bol.com", "Amazon NL"]),
"CA": ("加拿大", "北美", "north-america", ["英语", "法语"], ["Amazon CA", "Walmart Canada", "eBay"]),
"AU": ("澳大利亚", "大洋洲", "oceania", ["英语"], ["Amazon AU", "eBay AU"]),
"JP": ("日本", "东亚", "japan", ["日语"], ["Amazon Japan", "乐天市场", "Yahoo!购物", "Temu"]),
"KR": ("韩国", "东亚", "korea", ["韩语"], ["Coupang", "Naver Shopping", "Gmarket", "Temu"]),
"BR": ("巴西", "拉丁美洲", "latin-america", ["葡萄牙语"], ["Mercado Libre", "Amazon Brazil", "Shopee"]),
"MX": ("墨西哥", "拉丁美洲", "latin-america", ["西班牙语"], ["Mercado Libre", "Amazon Mexico", "Walmart Mexico"]),
"NZ": ("新西兰", "大洋洲", "oceania", ["英语", "毛利语"], ["Trade Me", "Amazon AU"]),
"SG": ("新加坡", "东南亚", "southeast-asia", ["英语", "简体中文", "马来语", "泰米尔语"], ["Shopee", "Lazada", "Amazon SG", "TikTok Shop"]),
"ID": ("印度尼西亚", "东南亚", "southeast-asia", ["印度尼西亚语"], ["Shopee", "Tokopedia", "Lazada", "TikTok Shop"]),
"VN": ("越南", "东南亚", "southeast-asia", ["越南语"], ["Shopee", "Lazada", "TikTok Shop"]),
"PH": ("菲律宾", "东南亚", "southeast-asia", ["菲律宾语", "英语"], ["Shopee", "Lazada", "TikTok Shop"]),
"ZA": ("南非", "南部非洲", "southern-africa", ["英语", "祖鲁语", "南非荷兰语"], ["Takealot", "Amazon South Africa"]),
"PL": ("波兰", "中东欧", "central-europe", ["波兰语"], ["Allegro", "Amazon DE", "Empik"]),
"SE": ("瑞典", "北欧", "nordic", ["瑞典语"], ["Amazon SE", "CDON"]),
"DK": ("丹麦", "北欧", "nordic", ["丹麦语"], ["Amazon DE", "CDON"]),
"NO": ("挪威", "北欧", "nordic", ["挪威语"], ["Amazon DE", "CDON"]),
"FI": ("芬兰", "北欧", "nordic", ["芬兰语", "瑞典语"], ["Amazon DE", "CDON"]),
"BE": ("比利时", "中西欧", "central-europe", ["荷兰语", "法语", "德语"], ["bol.com", "Amazon FR", "Amazon DE"]),
"AT": ("奥地利", "中西欧", "central-europe", ["德语"], ["Amazon DE", "eBay DE"]),
"CH": ("瑞士", "中西欧", "central-europe", ["德语", "法语", "意大利语"], ["Galaxus", "Amazon DE", "Amazon FR"]),
"PT": ("葡萄牙", "南欧", "southern-europe", ["葡萄牙语"], ["Amazon ES", "Worten", "Etsy"]),
"IE": ("爱尔兰", "英国及爱尔兰", "british-isles", ["英语", "爱尔兰语"], ["Amazon UK", "eBay", "Etsy"]),
"CZ": ("捷克", "中东欧", "central-europe", ["捷克语"], ["Alza", "Amazon DE"]),
"TR": ("土耳其", "欧亚市场", "turkiye", ["土耳其语"], ["Trendyol", "Hepsiburada", "Amazon TR"]),
"AE": ("阿联酋", "中东", "islamic", ["阿拉伯语", "英语"], ["Amazon.ae", "Noon", "Temu"]),
"RU": ("俄罗斯", "俄罗斯及欧亚", "central-europe", ["俄语"], ["Ozon", "Wildberries", "Yandex Market"]),
}
PLATFORM_PLAYBOOKS = {
"Amazon": "核对活动提报与最晚入仓日;主图和标题使用当地语言,尺寸、材质、克重及洗护信息完整。",
"Walmart": "核对促销资格、配送承诺和库存可售天数;优先准备高性价比套装与稳定补货。",
"TikTok Shop": "提前准备当地语言短视频、达人样品和直播库存;避免未经授权使用节日、赛事或人物元素。",
"Shopee": "核对站点大促报名、跨境仓截单和包邮门槛;轻小件、组合装和本地语言素材优先。",
"Lazada": "核对站点活动日历、LGS 物流时效与优惠券成本;按东南亚气候准备轻薄、快干产品。",
"Mercado Libre": "核对 Mercado Envios 入仓与配送承诺;西班牙语/葡萄牙语页面、尺寸单位和税费表达要本地化。",
"Etsy": "强调原创图案、材质故事与个性化能力;上架前先完成商标、版权和节庆符号检索。",
"eBay": "核对目的国配送承诺、退货地址和商品属性;标题关键词、尺寸单位和税费说明按站点本地化。",
"Temu": "核对站点与托管模式、申报价和仓配时效;用高性价比组合装测试,不要用低价掩盖材质与尺寸信息。",
"Ozon": "准备俄语标题、尺寸和认证信息;核对跨境物流、退货与卢布定价,保暖床品按当地季节提前排期。",
"Wildberries": "核对跨境招商、仓配和罚则;俄语商品属性、条码与包装必须在发货前完成。",
"Coupang": "准备韩语商品页与快速履约方案;床品尺寸、洗护和材质说明按韩国消费者习惯表达。",
"乐天市场": "通过日本主体或代理核对店铺资格;用日语细化尺寸、材质、收纳与独居场景。",
"OTTO": "先确认欧盟主体、税务与德语客服条件;适合已有欧洲合规和稳定补货能力的品牌。",
"Cdiscount": "核对法国税务、EPR 与法语客服;床品和桌布需使用当地尺寸与材质术语。",
}
COUNTRY_NAMES = {code: values[0] for code, values in COUNTRY_DETAILS.items()}
COUNTRY_MARKETS = {code: (values[1], values[2]) for code, values in COUNTRY_DETAILS.items()}
COUNTRY_ENGLISH_NAMES = {
"CN": "China", "US": "United States", "GB": "United Kingdom", "DE": "Germany", "FR": "France",
"IT": "Italy", "ES": "Spain", "NL": "Netherlands", "CA": "Canada", "AU": "Australia",
"JP": "Japan", "KR": "South Korea", "BR": "Brazil", "MX": "Mexico", "NZ": "New Zealand",
"SG": "Singapore", "ID": "Indonesia", "VN": "Vietnam", "PH": "Philippines", "ZA": "South Africa",
"PL": "Poland", "SE": "Sweden", "DK": "Denmark", "NO": "Norway", "FI": "Finland", "BE": "Belgium",
"AT": "Austria", "CH": "Switzerland", "PT": "Portugal", "IE": "Ireland", "CZ": "Czechia",
"TR": "Türkiye", "AE": "United Arab Emirates", "RU": "Russia",
}
LOCAL_HOLIDAY_NAMES: dict[str, dict[str, tuple[str, str]]] = {
"back-to-school": {
"US": ("Back to School / Back to College", "英语"),
"GB": ("Back to School / Autumn Term", "英语"),
"DE": ("Schulanfang / Schulbeginn", "德语"),
"FR": ("Rentrée scolaire", "法语"),
"JP": ("新学期(Shingakki)", "日语"),
"CA": ("Back to School / Rentrée scolaire", "英语 / 法语"),
"AU": ("Back to School / Term 1", "英语"),
},
"black-friday": {"US": ("Black Friday", "英语"), "GB": ("Black Friday", "英语"), "DE": ("Black Friday", "德语市场通用英文名"), "FR": ("Black Friday", "法语市场通用英文名")},
"cyber-monday": {"US": ("Cyber Monday", "英语")},
"singles-day": {"CN": ("双十一", "简体中文")},
"winter-holiday": {"US": ("Holiday Season", "英语"), "CA": ("Holiday Season / Saison des Fêtes", "英语 / 法语")},
"christmas": {
"US": ("Christmas Day", "英语"), "GB": ("Christmas Day", "英语"), "DE": ("Erster Weihnachtstag", "德语"),
"FR": ("Noël", "法语"), "IT": ("Natale", "意大利语"), "ES": ("Navidad", "西班牙语"),
"PT": ("Natal", "葡萄牙语"), "PL": ("Boże Narodzenie", "波兰语"), "BR": ("Natal", "葡萄牙语"),
},
"boxing-day": {"GB": ("Boxing Day", "英语"), "CA": ("Boxing Day / Lendemain de Noël", "英语 / 法语"), "AU": ("Boxing Day", "英语"), "NZ": ("Boxing Day", "英语")},
"st-stephens-day": {
"CA": ("St. Stephen's Day / Boxing Day", "英语 / 法语"), "BE": ("Saint-Étienne / Sint-Stefanus", "法语 / 荷兰语"),
"DK": ("2. juledag", "丹麦语"), "AT": ("Stefanitag", "德语"), "DE": ("Zweiter Weihnachtstag", "德语"),
"IT": ("Santo Stefano", "意大利语"), "NO": ("Andre juledag", "挪威语"), "CZ": ("2. svátek vánoční", "捷克语"),
"PL": ("Drugi dzień Bożego Narodzenia", "波兰语"), "IE": ("Lá Fhéile Stiofáin", "爱尔兰语 / 英语"),
"SE": ("Annandag jul", "瑞典语"), "CH": ("Stephanstag", "德语 / 法语 / 意大利语"),
"FI": ("Tapaninpäivä", "芬兰语 / 瑞典语"), "NL": ("Tweede kerstdag", "荷兰语"),
"PT": ("Primeira Oitava", "葡萄牙语"), "ES": ("Fiesta de San Esteban", "西班牙语"),
"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": {
"AT": ("Mariä Himmelfahrt", "德语"), "DE": ("Mariä Himmelfahrt", "德语"), "IT": ("Ferragosto / Assunzione", "意大利语"),
"BE": ("Onze-Lieve-Vrouw-Hemelvaart / Assomption", "荷兰语 / 法语"), "FR": ("Assomption", "法语"),
"PL": ("Wniebowzięcie Najświętszej Maryi Panny", "波兰语"), "CH": ("Mariä Himmelfahrt / Assomption / Assunzione", "德语 / 法语 / 意大利语"),
"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 = {
"eid-fitr": ("AE", "TR", "ID"),
"eid-adha": ("AE", "TR", "ID"),
}
HOLIDAY_TRANSLATIONS = {
"new year's day": "元旦", "new year holiday": "新年假期", "chinese new year": "春节",
"lunar new year": "农历新年", "spring festival": "春节", "good friday": "耶稣受难日",
"easter sunday": "复活节", "easter monday": "复活节星期一", "christmas day": "圣诞节",
"boxing day": "节礼日", "st. stephen's day": "圣斯蒂芬节", "labour day": "劳动节", "labor day": "劳动节",
"independence day": "独立日", "national day": "国庆日", "republic day": "共和国日",
"constitution day": "宪法日", "liberation day": "解放日", "victory day": "胜利日",
"memorial day": "阵亡将士纪念日", "thanksgiving day": "感恩节", "assumption": "圣母升天节",
"assumption day": "圣母升天节", "assumption of the virgin mary": "圣母升天节",
"ascension day": "耶稣升天节", "all saints' day": "诸圣节", "epiphany": "主显节",
"corpus christi": "基督圣体节", "reformation day": "宗教改革纪念日",
"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 = {
"DE-BW": "巴登-符腾堡州", "DE-BY": "巴伐利亚州", "DE-BE": "柏林州", "DE-BB": "勃兰登堡州",
"DE-HB": "不来梅州", "DE-HH": "汉堡州", "DE-HE": "黑森州", "DE-MV": "梅克伦堡-前波美拉尼亚州",
"DE-NI": "下萨克森州", "DE-NW": "北莱茵-威斯特法伦州", "DE-RP": "莱茵兰-普法尔茨州", "DE-SL": "萨尔州",
"DE-SN": "萨克森州", "DE-ST": "萨克森-安哈尔特州", "DE-SH": "石勒苏益格-荷尔斯泰因州", "DE-TH": "图林根州",
"CH-AG": "阿尔高州", "CH-AI": "内阿彭策尔州", "CH-AR": "外阿彭策尔州", "CH-BE": "伯尔尼州",
"CH-BL": "巴塞尔乡村州", "CH-BS": "巴塞尔城市州", "CH-FR": "弗里堡州", "CH-GE": "日内瓦州",
"CH-GL": "格拉鲁斯州", "CH-GR": "格劳宾登州", "CH-JU": "汝拉州", "CH-LU": "卢塞恩州",
"CH-NE": "纳沙泰尔州", "CH-NW": "下瓦尔登州", "CH-OW": "上瓦尔登州", "CH-SG": "圣加仑州",
"CH-SH": "沙夫豪森州", "CH-SO": "索洛图恩州", "CH-SZ": "施维茨州", "CH-TG": "图尔高州",
"CH-TI": "提契诺州", "CH-UR": "乌里州", "CH-VD": "沃州", "CH-VS": "瓦莱州",
"CH-ZG": "楚格州", "CH-ZH": "苏黎世州",
"AU-ACT": "首都领地", "AU-NSW": "新南威尔士州", "AU-NT": "北领地", "AU-QLD": "昆士兰州",
"AU-SA": "南澳大利亚州", "AU-TAS": "塔斯马尼亚州", "AU-VIC": "维多利亚州", "AU-WA": "西澳大利亚州",
"CA-AB": "艾伯塔省", "CA-BC": "不列颠哥伦比亚省", "CA-MB": "马尼托巴省", "CA-NB": "新不伦瑞克省",
"CA-NL": "纽芬兰与拉布拉多省", "CA-NS": "新斯科舍省", "CA-NT": "西北地区", "CA-NU": "努纳武特地区",
"CA-ON": "安大略省", "CA-PE": "爱德华王子岛省", "CA-QC": "魁北克省", "CA-SK": "萨斯喀彻温省", "CA-YT": "育空地区",
}
def calendar_scope(is_global: bool, subdivision_codes: list[str]) -> tuple[list[str], str]:
if is_global or not subdivision_codes:
return ["全国"], "全国适用"
areas = [SUBDIVISION_NAMES.get(code, code) for code in subdivision_codes]
preview = "、".join(areas[:4])
suffix = f"等 {len(areas)} 个州/省级地区" if len(areas) > 4 else ""
return areas, f"仅适用于{preview}{suffix}"
FESTIVAL_PROFILES = {
"back-to-school": {
"title": "美国开学季与大学返校季", "relevance": "high", "decision": "值得专项备货",
"what": "美国中小学返校与大学宿舍入住消费季,核心机会集中在 Twin XL 宿舍床品、浴巾、收纳和一站式套装。",
"scenes": ["大学宿舍入住", "学生公寓布置", "返校采购"],
"products": ["Twin XL 床品套装", "枕芯与床垫保护套", "浴巾套装", "宿舍收纳软品"],
"design": ["商品页明确 Twin XL 尺寸和套装清单", "使用宿舍小空间与耐洗场景"],
"avoid": ["不要把普通单人床尺寸直接当作 Twin XL;避免漏写套装件数"],
},
"black-friday": {
"title": "黑色星期五", "relevance": "high", "decision": "值得专项备货",
"what": "北美感恩节后的大型零售促销节点,并已扩展到欧洲等跨境市场。",
"scenes": ["大促折扣", "礼赠采购", "冬季换新"],
"products": ["冬季四件套", "盖毯组合装", "毛巾礼盒", "轻小件软装"],
"design": ["突出折扣前后价与套装价值", "准备黑金之外的品牌主色素材"],
"avoid": ["不要只降价不核算平台费用、退货和海外仓成本"],
},
"cyber-monday": {
"title": "网络星期一", "relevance": "high", "decision": "值得专项备货",
"what": "紧接黑色星期五的线上零售促销节点,重点考验移动端转化、库存同步和快速履约。",
"scenes": ["线上闪促", "移动端下单", "大促补货"],
"products": ["轻量盖毯", "枕套组合", "毛巾套装", "快递友好包装"],
"design": ["主图先讲尺寸、材质和折扣", "准备短视频与移动端竖版素材"],
"avoid": ["不要使用体积过大、运费吞噬毛利的低客单产品做主推"],
},
"singles-day": {
"title": "双十一购物节", "relevance": "high", "decision": "值得专项备货",
"what": "中国电商平台年度集中促销节点,也会影响跨境华人市场和部分海外平台营销节奏。",
"scenes": ["家居焕新", "冬季换被", "礼盒囤货"],
"products": ["保暖床品", "高客单套装", "智能温控产品", "礼盒装毛巾"],
"design": ["分预售、付尾款和现货三套素材", "把克重、支数和保暖指标写清楚"],
"avoid": ["不要照搬国内促销语言到非中文市场"],
},
"winter-holiday": {
"title": "北美冬季假日消费季", "relevance": "high", "decision": "值得专项备货",
"what": "从感恩节延续到圣诞节前的礼赠、家庭聚会和冬季家居消费周期。",
"scenes": ["家庭聚会", "节日礼赠", "冬季家居焕新"],
"products": ["保暖床品", "沙发盖毯", "餐桌布艺", "礼赠套装"],
"design": ["建立传统红绿、自然木色和中性冬季三套方案", "礼盒需适配快递与退换货"],
"avoid": ["不要把宗教符号用于不明确的通用礼赠款"],
},
"christmas": {
"title": "圣诞节", "relevance": "high", "decision": "值得专项备货",
"what": "欧美、大洋洲及部分亚洲市场的重要家庭团聚与礼赠节日。",
"scenes": ["家庭团聚", "节日装饰", "礼赠与旅行"],
"products": ["保暖四件套", "盖毯", "抱枕套", "餐桌布艺", "节庆礼盒"],
"design": ["准备经典、自然和现代简约三种节庆风格", "兼顾公寓小空间与礼赠包装"],
"avoid": ["面向多宗教市场时减少强宗教图案,改用冬季与团聚表达"],
},
"boxing-day": {
"title": "节礼日", "relevance": "high", "decision": "值得专项备货",
"what": "英国、加拿大、澳大利亚和新西兰等市场圣诞节后的公共假日与清仓促销节点。",
"scenes": ["节后清仓", "礼品卡消费", "居家换新"],
"products": ["清仓床品套装", "基础毛巾", "盖毯", "次年常青款"],
"design": ["突出库存有限与套装优惠", "把退换货规则写清楚"],
"avoid": ["不要把节礼日误译为单纯送礼日"],
},
"lunar-new-year": {
"title": "农历新年", "relevance": "high", "decision": "值得专项备货",
"what": "中国及多个东亚、东南亚市场的重要团聚、焕新与礼赠节庆。",
"scenes": ["家庭团聚", "新居焕新", "节庆礼赠"],
"products": ["红金床品", "新年礼盒", "沙发软装", "餐桌布艺"],
"design": ["按中国、越南、韩国等市场分别处理生肖、文字和色彩", "准备华人市场双语素材"],
"avoid": ["不要把单一中国春节视觉直接套用到所有亚洲市场"],
},
"eid-fitr": {
"title": "开斋节", "relevance": "high", "decision": "值得专项备货",
"what": "斋月结束后的重要穆斯林节日,家庭团聚、礼赠与居家接待需求集中。",
"scenes": ["家庭团聚", "居家宴请", "节庆礼赠"],
"products": ["礼赠床品", "祈祷毯", "餐桌布艺", "宾客毛巾"],
"design": ["使用几何、植物和建筑纹样", "为海湾与东南亚穆斯林市场分别配色"],
"avoid": ["避免人物或宗教符号的随意商业化使用"],
},
"eid-adha": {
"title": "宰牲节", "relevance": "high", "decision": "值得专项备货",
"what": "穆斯林重要节日,通常伴随家庭团聚、礼赠、旅行和居家接待。",
"scenes": ["家庭接待", "礼赠", "旅行与酒店"],
"products": ["礼赠床品", "客用毛巾", "祈祷毯", "酒店布草"],
"design": ["采用克制的几何和金属色点缀", "关注耐洗和礼盒陈列"],
"avoid": ["不要使用与宗教礼仪不符的图案和促销文案"],
},
"easter": {
"title": "复活节假期", "relevance": "medium", "decision": "按区域观察",
"what": "欧美等市场的春季宗教与公共假期,常与家庭活动、短途旅行和春季换新叠加。",
"scenes": ["春季换新", "家庭活动", "短途旅行"],
"products": ["轻薄床品", "野餐毯", "淡彩抱枕", "儿童房软装"],
"design": ["以春季植物、柔和色彩和户外场景为主", "宗教图案只用于明确受众"],
"avoid": ["不同国家的放假范围不同,不要统一承诺配送时效"],
},
"assumption-day": {
"title": "圣母升天节", "relevance": "medium", "decision": "按区域观察",
"what": "8 月 15 日的基督教传统节日,在法国、意大利、西班牙、奥地利等市场为全国公共假日;德国和瑞士只覆盖部分州,且常与欧洲夏季休假叠加。",
"scenes": ["夏季长周末", "家庭出行", "酒店与民宿补货"],
"products": ["轻薄床品", "浴巾", "旅行毯", "酒店布草"],
"design": ["以夏季、海岸和天然材质表达为主", "各国素材分别使用当地语言"],
"avoid": ["它不是所有国家都适合强促销的消费节,不要统一压主题库存"],
},
"thanksgiving": {
"title": "感恩节", "relevance": "high", "decision": "值得专项备货",
"what": "美国、加拿大等市场的家庭团聚节日,但日期和消费节奏并不相同。",
"scenes": ["家庭聚餐", "客房整理", "节日礼赠"],
"products": ["餐桌布艺", "客房床品", "厨房毛巾", "沙发盖毯"],
"design": ["使用秋季自然色与家庭场景", "美国和加拿大分开排期"],
"avoid": ["不要把美国感恩节日期直接套到加拿大"],
},
"new-year": {
"title": "元旦与新年假期", "relevance": "medium", "decision": "按区域观察",
"what": "公历新年公共假期,重点影响跨年旅行、酒店、物流和年初家居焕新。",
"scenes": ["跨年旅行", "酒店住宿", "年初焕新"],
"products": ["酒店布草", "基础床品", "浴巾", "收纳软品"],
"design": ["使用新年焕新而非单一宗教表达", "按南北半球季节调整材质"],
"avoid": ["南半球正值夏季,不要统一推保暖产品"],
},
"new-year-eve": {
"title": "跨年夜", "relevance": "low", "decision": "仅用于运营排班",
"what": "公历新年前夜或年末公共休假节点,主要影响跨年营业、物流和客服排班。",
"scenes": ["跨年营业", "物流排班"], "products": [], "design": [],
"avoid": ["不要把跨年夜与元旦或连续新年假期拆成多个重复消费节"],
},
"super-bowl": {
"title": "超级碗观赛消费节点", "relevance": "high", "decision": "值得场景备货",
"what": "美国职业橄榄球年度决赛,带动家庭观赛、聚会、酒店和城市活动消费。",
"scenes": ["家庭观赛", "朋友聚会", "酒店与城市活动"],
"products": ["沙发盖毯", "靠垫", "坐垫", "酒吧与酒店布草"],
"design": ["使用通用运动与城市氛围", "优先开发可快速换色的软装组合"],
"avoid": ["球队名称、赛事标志和球员肖像涉及知识产权,未经授权不要使用"],
},
"la28": {
"title": "洛杉矶 2028 奥运消费周期", "relevance": "high", "decision": "提前规划",
"what": "大型国际体育赛事周期,机会集中在酒店、城市旅行、团体接待和跨境礼赠。",
"scenes": ["酒店升级", "城市旅行", "团体接待"],
"products": ["酒店床品", "功能毛巾", "旅行毯", "团体礼赠"],
"design": ["使用通用运动、城市和多文化表达", "优先开发耐洗与快干材料"],
"avoid": ["奥运标志、会徽和官方称谓受保护,营销前先做授权核验"],
},
"mountain-day": {
"title": "日本山之日", "relevance": "low", "decision": "以运营排班为主",
"what": "日本的国民祝日,鼓励亲近山林;对家纺更接近夏季出行与客服排班节点。",
"scenes": ["户外出行", "短途旅行"],
"products": ["便携毯", "快干毛巾"],
"design": ["采用自然、山林和清爽材质表达"],
"avoid": ["缺少专项消费证据时不要单独大规模压货"],
},
"national:ID": {
"title": "印度尼西亚独立日", "relevance": "medium", "decision": "按平台活动判断",
"what": "8 月 17 日是印度尼西亚独立日,全国会举行社区庆祝和红白主题活动,也是 Shopee、Tokopedia、Lazada 与 TikTok Shop 常见的 8.17 营销节点。",
"scenes": ["社区庆祝", "家庭布置", "平台 8.17 活动"],
"products": ["红白软装", "轻薄床品", "快干毛巾", "户外坐垫"],
"design": ["优先使用当地语言与红白生活方式场景", "热带市场突出轻薄、快干和易清洗"],
"avoid": ["国旗与国家象征的使用需尊重当地规范,不要把政治符号随意商品化"],
},
"national:PH": {
"title": "菲律宾国家英雄日", "relevance": "low", "decision": "以排班与克制表达为主",
"what": "菲律宾每年 8 月最后一个星期一纪念国家英雄,属于公共假日,但不是应默认建立家纺主题库存的强消费节。",
"scenes": ["公共休假", "家庭出行", "物流与客服排班"],
"products": [],
"design": ["如参与平台活动,使用尊重历史与本地文化的克制表达"],
"avoid": ["不要为了促销把英雄纪念语境包装成娱乐化主题"],
},
"national:US": {
"title": "美国独立日", "relevance": "high", "decision": "值得场景备货",
"what": "7 月 4 日是美国全国性公共假日,家庭聚会、户外活动、旅行和季中促销会共同带动家居与户外纺织需求。",
"scenes": ["户外聚会", "家庭烧烤", "夏季旅行", "季中促销"],
"products": ["户外毯", "野餐布", "沙发盖毯", "厨房毛巾"],
"design": ["使用夏季户外与家庭聚会场景", "平台素材需提前核查广告与配送截止日"],
"avoid": ["国旗图形、军政元素和品牌联名不得未经核验直接使用"],
},
"national:CN": {
"title": "中国国庆假期", "relevance": "medium", "decision": "按内销与出行场景判断",
"what": "中国国庆假期会影响国内生产、物流、客服和旅行消费节奏,跨境卖家还需提前安排工厂放假前的交付与补货。",
"scenes": ["工厂与物流放假", "旅行住宿", "家庭换新"],
"products": ["酒店布草", "旅行用品", "秋冬基础床品"],
"design": ["重点表达秋季换新与旅行住宿", "跨境订单先锁定工厂排期"],
"avoid": ["不要把国内假期误当作海外平台的统一消费节"],
},
"national": {
"title": "国家纪念与公共假日", "relevance": "low", "decision": "先核对当地消费证据",
"what": "国家纪念日、独立日或共和国日首先影响公共休假、物流和本地广告语境;没有平台活动或消费数据时不应自动转成备货机会。",
"scenes": ["公共休假", "物流排班", "本地广告语境"],
"products": [],
"design": ["只在确认当地平台活动后制作本地化素材"],
"avoid": ["国旗、国徽和政治符号需核验使用规范;不要批量套模板或制造主题库存"],
},
"memorial": {
"title": "纪念日与悼念节点", "relevance": "low", "decision": "仅做排班与克制陈列",
"what": "以纪念、悼念或宗教追思为主的公共节点,通常不适合强促销。",
"scenes": ["公共休假", "克制陈列"],
"products": [], "design": ["使用低饱和与克制表达"],
"avoid": ["不要把悼念节点包装成热闹促销节"],
},
"public-holiday": {
"title": "地区公共假日", "relevance": "low", "decision": "仅用于运营排班",
"what": "该市场的法定或地方公共假日,首先影响物流、仓储、客服和门店营业。",
"scenes": ["物流排班", "客服安排"],
"products": [], "design": [],
"avoid": ["没有明确消费证据时,不应为了日历数量制造专项备货建议"],
},
"st-stephens-day": {
"title": "圣斯蒂芬节", "relevance": "low", "decision": "仅用于运营排班",
"what": "12 月 26 日在欧洲、加拿大等市场使用不同当地名称庆祝或放假,部分市场也称圣诞次日或节礼日;主要影响物流、客服和门店营业,不等同于统一消费节。",
"scenes": ["公共休假", "物流排班", "节后门店营业"],
"products": [], "design": [],
"avoid": ["不要把各国同日名称差异拆成多个节日,也不要在没有平台活动证据时专项备货"],
},
}
GLOBAL_AGGREGATE_KEYS = {
"black-friday", "cyber-monday", "winter-holiday", "christmas", "boxing-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",
"boxing-day": "Boxing Day",
"christmas": "Christmas Day",
"cyber-monday": "Cyber Monday",
"easter": "Easter",
"eid-adha": "Eid al-Adha",
"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",
"thanksgiving": "Thanksgiving Day",
"winter-holiday": "Holiday Season",
}
GENERIC_HOLIDAY_TITLES = {"国家纪念与公共假日", "地区公共假日"}
UNSAFE_CROSS_COUNTRY_TITLES = {"独立日", "国庆日", "共和国日", "宪法日", "解放日", "胜利日", "纪念日"}
HOLIDAY_TITLE_ALIASES = {"五一劳动节": "劳动节"}
def _normalized_name(value: str) -> str:
text = value.lower().replace("’", "'")
text = re.sub(r"\([^)]*\)", " ", text)
text = re.sub(r"\b(observed|substitute day|tentative date)\b", " ", text)
return " ".join(re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", text).split())
def festival_key(name: str, source_id: str = "", country_code: str = "") -> str:
lower = _normalized_name(name)
if source_id in ("calendar_uk_school_terms", "calendar_de_school_holidays"):
return "back-to-school"
if country_code == "JP" and "labour thanksgiving" in lower:
return "public-holiday:JP:labour-thanksgiving"
if source_id == "calendar_tmall" or "双十一" in lower:
return "singles-day"
if any(token in lower for token in (
"back to school", "back to college", "开学季", "返校季", "新学期", "新学年",
"学生住房", "大学宿舍", "独居搬家", "学生住宿",
)):
return "back-to-school"
if "black friday" in lower or "黑色星期五" in lower:
return "black-friday"
if "cyber monday" in lower or "网络星期一" in lower:
return "cyber-monday"
if "winter holiday" in lower or "冬季假日消费季" in lower:
return "winter-holiday"
if source_id == "calendar_nfl" or "super bowl" in lower or "超级碗" in lower:
return "super-bowl"
if source_id == "calendar_la28" or "la28" in lower or "olympic" in lower:
return "la28"
if any(token in lower for token in ("chinese new year", "lunar new year", "spring festival", "春节", "农历新年")):
return "lunar-new-year"
if "st stephen s day" in lower or "saint stephen s day" in lower or "圣斯蒂芬" in lower:
return "st-stephens-day"
if "boxing day" in lower or "节礼日" in lower:
return "boxing-day"
if "christmas" in lower or "圣诞" in lower:
return "christmas"
if country_code in ("US", "CA") and ("thanksgiving" in lower or "感恩节" in lower):
return "thanksgiving"
if "assumption" in lower or "圣母升天" in lower or lower == "假设":
return "assumption-day"
if "mountain day" in lower or "山之日" in lower:
return "mountain-day"
if any(token in lower for token in ("eid al fitr", "eid ul fitr", "hari raya puasa", "开斋")):
return "eid-fitr"
if any(token in lower for token in ("eid al adha", "eid ul adha", "宰牲")):
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 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"
if any(token in lower for token in ("national", "independence", "republic", "constitution", "liberation", "victory", "国庆", "独立")):
return f"national:{country_code}" if country_code else "national"
return f"public-holiday:{country_code}:{lower or 'unknown'}"
def calendar_event_type(name: str, source_id: str = "") -> str:
key = festival_key(name, source_id)
if key in ("back-to-school", "black-friday", "cyber-monday", "singles-day", "winter-holiday"):
return "retail"
if key in ("super-bowl", "la28"):
return "sport"
if key in ("eid-fitr", "eid-adha") or source_id == "calendar_aladhan":
return "islamic"
if key == "lunar-new-year":
return "lunar-new-year"
if key in ("christmas", "boxing-day", "thanksgiving"):
return "christmas"
if key == "easter":
return "easter"
if key == "national" or key.startswith("national:"):
return "national"
if key == "memorial":
return "memorial"
return "public-holiday"
def holiday_title(local_name: str, english_name: str, country_code: str) -> str:
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:
return local_name
return local_name or english_name or "公共假日"
def platform_family(platform: str) -> str:
for family in PLATFORM_PLAYBOOKS:
if platform == family or platform.startswith(f"{family} "):
return family
return platform
def platform_actions(platforms: list[str]) -> list[dict[str, str]]:
actions: list[dict[str, str]] = []
for platform in platforms:
family = platform_family(platform)
action = PLATFORM_PLAYBOOKS.get(family)
if action and not any(row["platform"] == family for row in actions):
actions.append({"platform": family, "action": action})
return actions
def calendar_culture(country_code: str, event_type: str) -> tuple[str, str, str]:
if event_type == "islamic":
return "中东及穆斯林市场", "islamic", "家庭团聚、礼赠与夜间消费"
if event_type == "sport":
return "全球体育消费", "sport", "观赛、旅行与酒店场景"
if event_type == "retail":
return COUNTRY_MARKETS.get(country_code, ("全球电商", "retail"))[0], "retail", "价格、流量与履约效率"
market_group, culture_key = COUNTRY_MARKETS.get(country_code, ("全球", "central-europe"))
return market_group, culture_key, "按当地文化、季节和公共假期安排运营"
def profile_for(key: str) -> dict[str, Any]:
if key.startswith("public-holiday:"):
return FESTIVAL_PROFILES["public-holiday"]
if key.startswith("national:") and key not in FESTIVAL_PROFILES:
return FESTIVAL_PROFILES["national"]
return FESTIVAL_PROFILES.get(key, FESTIVAL_PROFILES["public-holiday"])
def profile_for_market(key: str, country_code: str) -> dict[str, Any]:
profile = dict(profile_for(key))
if key != "back-to-school":
return profile
overrides = {
"GB": {
"title": "英国秋季返校与学生住宿采购季",
"what": "英国各地区秋季学期开学时间不完全一致,采购机会集中在学生住宿、耐洗床品、浴巾和小空间软装;具体日期按政府地区校历核对。",
"products": ["单人床品套装", "床垫保护套", "耐洗浴巾", "宿舍收纳软品"],
"design": ["突出耐洗、易护理和小空间收纳", "按英国单人床尺寸与目标学校住宿要求核对规格"],
},
"DE": {
"title": "德国夏末返校与学生住宿采购季",
"what": "德国各州开学时间不同,机会集中在学生宿舍、租住公寓和耐用基础床品;具体州别日期以德国教育部长联席会议校历为准。",
"products": ["单人被套与枕套", "床垫保护套", "耐洗毛巾", "中性色靠垫与收纳软品"],
"design": ["优先低饱和中性色与耐久材质", "商品页标清厘米尺寸、洗护温度和材料认证"],
},
"FR": {
"title": "法国新学年返校与学生住房采购季",
"what": "法国返校日按学年与大区安排,学生住房采购集中在八月下旬至九月;具体日期以法国教育部大区校历为准。",
"products": ["单人被套与床单", "浴巾", "小户型窗帘", "靠垫与收纳软品"],
"design": ["使用克制的低饱和色与现代图案", "法语商品页标清厘米尺寸、材质和洗护方式"],
},
"JP": {
"title": "日本新学期与独居搬家采购季",
"what": "日本多数学校四月进入新学年,同时叠加大学入学、就职和独居搬家;具体日期由学校与地方教育委员会确定。",
"products": ["日式单人寝具", "薄被与床垫保护套", "毛巾套装", "遮光窗帘与收纳软品"],
"design": ["控制体积、突出易收纳与易护理", "按日本单人床和本地厘米规格核对尺寸"],
},
"CA": {
"title": "加拿大返校与大学宿舍采购季",
"what": "加拿大返校日期按省、学区和学校安排,主要采购窗口集中在八月至九月;节点以安大略省政府校历作示例并要求复核目标省。",
"products": ["保暖单人床品", "床垫保护套", "厚浴巾", "宿舍收纳与盖毯"],
"design": ["兼顾早秋温差和冬季保暖", "按目标学校宿舍床型核对 Twin 与 Twin XL"],
},
"AU": {
"title": "澳大利亚新学年与学生住宿采购季",
"what": "澳大利亚各州第一学期通常在一月至二月开始,日期由州教育部门确定;采购窗口与北半球返校季不同。",
"products": ["透气单人床品", "轻薄被", "浴巾", "遮光防晒窗帘与学生公寓软装"],
"design": ["按南半球夏季强调透气、凉感和防晒", "按目标州校历与当地厘米规格核对商品信息"],
},
}
profile.update(overrides.get(country_code, {}))
return profile
def calendar_opportunity(name: str, source_id: str = "") -> str:
profile = profile_for(festival_key(name, source_id))
products = profile.get("products") or []
if products:
return f"{profile['decision']}:优先评估{'、'.join(products[:3])},并按平台入仓时点倒排生产。"
return "以物流、客服和仓储排班为主;没有明确消费证据时不建议建立专项库存。"
def _planning_stage(now: date, start: date, end: date, stocking_start: date, stocking_end: date) -> tuple[str, str]:
if now > end:
return "今年节点已结束", "复盘销量、退货和库存,把有效款加入下一年度计划。"
if start <= now <= end:
return "今年备货已结束", "当前只做履约、客服和活动复盘,不再临时追加大批库存。"
if now > stocking_end:
return "营销执行期", "库存不再大幅追加,重点完成素材、定价、客服和履约检查。"
if now >= stocking_start:
return "备货窗口", "立即确认订单、生产交期、国际物流和平台最晚入仓日期。"
return "规划期", "先确定目标国家、平台和产品,再安排打样、报价与生产计划。"
def enrich_calendar_item(item: dict[str, Any], now: date, country_code: str, poster_for: Callable[..., dict[str, str]]) -> dict[str, Any]:
english_name = str(item.get("title_english") or "").strip()
local_name = str(item.get("title_original") or "").strip()
combined_name = local_name if country_code in ("AU", "GB", "NZ", "CA") and _normalized_name(local_name) == "boxing day" else english_name
combined_name = combined_name or " ".join(filter(None, (str(item.get("title") or ""), local_name)))
source_id = str(item.get("source_id") or "")
key = festival_key(combined_name, source_id, country_code)
profile = profile_for_market(key, country_code)
event_type = calendar_event_type(combined_name, source_id)
market_group, culture_key, cultural_note = calendar_culture(country_code, event_type)
country_detail = COUNTRY_DETAILS.get(country_code, (item.get("region") or "全球", market_group, culture_key, [], []))
start = date.fromisoformat(str(item["start_date"])[:10])
end = date.fromisoformat(str(item.get("end_date") or item["start_date"])[:10])
lead_days = {
"retail": 165, "sport": 180, "christmas": 150, "lunar-new-year": 165,
"islamic": 120, "national": 100, "easter": 90, "memorial": 60,
"public-holiday": 75,
}[event_type]
stocking_start = start - timedelta(days=lead_days)
stocking_end = start - timedelta(days=max(21, min(45, lead_days // 3)))
planning_stage, next_action = _planning_stage(now, start, end, stocking_start, stocking_end)
relevance = str(profile["relevance"])
impact = "高" if relevance == "high" else ("中" if relevance == "medium" else "低")
priority_score = {"high": 120, "medium": 80, "low": 35}[relevance] + max(0, 180 - min((start - now).days, 180)) // 6
item.update({
"country_code": country_code,
"market_group": market_group,
"culture_key": culture_key,
"cultural_note": cultural_note,
"festival_key": key,
"event_type": event_type,
"impact": impact,
"business_relevance": relevance,
"decision_label": profile["decision"],
"what_it_is": profile["what"],
"consumption_scenes": list(profile.get("scenes") or []),
"design_opportunities": list(profile.get("design") or []),
"cultural_avoidances": list(profile.get("avoid") or []),
"languages": list(country_detail[3]),
"platforms": list(country_detail[4]),
"lead_days": lead_days,
"stocking_start": stocking_start.isoformat(),
"stocking_end": stocking_end.isoformat(),
"planning_stage": planning_stage,
"next_action": next_action,
"days_until": (start - now).days,
"priority_score": priority_score,
"product_suggestions": list(profile.get("products") or []),
"analysis_basis": "官方日期 + 系统经营分析",
})
item["poster"] = poster_for(str(item["id"]), "calendar", culture_key)
return item
def _dedupe(values: list[str]) -> list[str]:
return list(dict.fromkeys(value for value in values if value))
def culture_reference_url(title: str) -> str:
"""国际访客默认使用条目质量更完整的中文 Wikipedia。"""
return f"https://zh.wikipedia.org/wiki/{quote(title.replace(' ', '_'), safe='')}"
def culture_reference_cn_url(title: str, festival_key: str = "") -> str:
"""中国大陆访客使用国内网络更易访问的百度百科。"""
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]], title_english: str = "") -> str:
"""图片入口只搜索节日本身,不复用来源页标题。"""
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"
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"
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
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)
season_year = value.year - 1 if value.month == 1 and value.day <= 10 else value.year
return f"{season_year}-12-25"
def christmas_date_label(value: str, primary_date: str) -> str:
if value == primary_date:
return "主要日期"
month_day = value[5:]
if month_day == "12-24":
return "平安夜"
if month_day in ("01-06", "01-07"):
return "东正教传统日期"
if month_day in ("12-26", "12-27", "12-28"):
return "法定假日顺延"
return "地区日期"
def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_for: Callable[..., dict[str, str]]) -> list[dict[str, Any]]:
grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
for item in items:
group_key, country_partition = calendar_group_identity(item)
grouped.setdefault((calendar_group_date(item), group_key, country_partition), []).append(item)
insights: list[dict[str, Any]] = []
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 ""))
country_codes = _dedupe([str(row.get("country_code") or "") for row in rows])
market_groups = _dedupe([str(row.get("market_group") or "") for row in rows])
languages = _dedupe([value for row in rows for value in row.get("languages") or []])
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:", "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, "")
if not title_english and english_titles:
title_english = Counter(english_titles).most_common(1)[0][0]
if not title_english:
original_titles = [str(row.get("title_original") or "").strip() for row in rows]
title_english = next((value for value in original_titles if re.search(r"[A-Za-z]", value)), title)
digest = hashlib.sha256(f"{start_date}|{key}|{'|'.join(country_codes)}".encode("utf-8")).hexdigest()[:14]
official_sources = []
seen_sources: set[tuple[str, str]] = set()
for row in rows:
source_pair = (str(row.get("source") or ""), str(row.get("source_url") or row.get("url") or ""))
if source_pair in seen_sources:
continue
seen_sources.add(source_pair)
official_sources.append({"name": source_pair[0], "url": source_pair[1]})
relevance = str(profile["relevance"])
if not profile.get("products"):
platforms = []
country_details_by_name: dict[str, dict[str, Any]] = {}
for row in rows:
code = str(row.get("country_code") or "")
raw_region = str(row.get("region") or "")
country = COUNTRY_DETAILS.get(code, (raw_region,))[0] if code else raw_region
if not code and ("市场" in country or "全球" in country):
continue
fallback_scope = "以来源覆盖范围为准" if "市场" in raw_region or "全球" in raw_region else "全国适用"
local_name = str(row.get("title_original") or row.get("title") or "")
local_language = " / ".join(row.get("languages") or [])
if code == "ZA" and row.get("title_english") == "Day of Goodwill":
local_name, local_language = "Day of Goodwill", "英语"
mapped_local = LOCAL_HOLIDAY_NAMES.get(key, {}).get(code)
if mapped_local:
local_name, local_language = mapped_local
detail = {
"country": country,
"country_code": code,
"country_english": COUNTRY_ENGLISH_NAMES.get(code, code),
"local_name": local_name,
"local_language": local_language,
"languages": list(row.get("languages") or []),
"scope": str(row.get("country_scope") or fallback_scope),
"areas": list(row.get("applicable_areas") or (["全国"] if fallback_scope == "全国适用" else [])),
"dates": [str(row.get("start_date") or start_date)],
}
existing = country_details_by_name.get(country)
if not existing:
country_details_by_name[country] = detail
continue
existing["languages"] = _dedupe([*existing["languages"], *detail["languages"]])
existing["areas"] = _dedupe([*existing["areas"], *detail["areas"]])
existing["dates"] = _dedupe([*existing["dates"], *detail["dates"]])
if "全国适用" in (existing["scope"], detail["scope"]):
existing["scope"] = "全国适用"
existing["areas"] = ["全国"]
elif existing["areas"]:
if country == "英国" and {"英格兰和威尔士", "苏格兰", "北爱尔兰"}.issubset(existing["areas"]):
existing["scope"] = "英国全境(英格兰和威尔士、苏格兰、北爱尔兰)"
else:
existing["scope"] = f"仅适用于{'、'.join(existing['areas'])}"
if not country_details_by_name:
for code in FESTIVAL_TARGET_COUNTRIES.get(key, ()):
country, target_market, _, target_languages, target_platforms = COUNTRY_DETAILS[code]
local_name, local_language = LOCAL_HOLIDAY_NAMES[key][code]
country_details_by_name[country] = {
"country": country,
"country_code": code,
"country_english": COUNTRY_ENGLISH_NAMES[code],
"local_name": local_name,
"local_language": local_language,
"languages": list(target_languages),
"scope": "重点经营市场;日期与放假范围以当地官方公告为准",
"areas": [],
"dates": [start_date],
}
country_codes.append(code)
market_groups.append(target_market)
languages.extend(target_languages)
platforms.extend(target_platforms)
country_codes = _dedupe(country_codes)
market_groups = _dedupe(market_groups)
languages = _dedupe(languages)
platforms = _dedupe(platforms)
country_details = list(country_details_by_name.values())
countries = list(country_details_by_name)
if len(countries) == 1:
region = countries[0]
elif len(countries) > 1:
region = f"{market_groups[0]}多国" if len(market_groups) == 1 else f"{len(countries)} 个国家/地区"
else:
region = market_groups[0] if market_groups else str(rows[0].get("region") or "全球")
first = rows[0]
related_dates = sorted(_dedupe([str(row.get("start_date") or start_date) for row in rows]))
date_variants = []
for value in related_dates:
variant_rows = [row for row in rows if str(row.get("start_date") or "") == value]
variant_countries = _dedupe([
COUNTRY_DETAILS.get(str(row.get("country_code") or ""), (str(row.get("region") or ""),))[0]
for row in variant_rows
])
date_variants.append({
"date": value,
"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",
"source_id": str(first.get("source_id") or ""),
"source": official_sources[0]["name"] if official_sources else "权威日期来源",
"authority": str(first.get("authority") or "权威日期已核验"),
"region": region,
"title": title,
"title_english": title_english,
"title_original": country_details[0]["local_name"] if country_details else "",
"summary": str(profile["what"]),
"what_it_is": str(profile["what"]),
"url": str(first.get("url") or first.get("source_url") or ""),
"source_url": str(first.get("source_url") or first.get("url") or ""),
"official_sources": official_sources,
"image": "",
"verified_at": max(str(row.get("verified_at") or "") for row in rows),
"change_status": "官方日期已核验",
"confidence": "高" if official_sources else "中高",
"start_date": start_date,
"end_date": end_date,
"related_dates": related_dates,
"date_variants": date_variants,
"date_variation_note": (
"12月24日是平安夜;多数市场在12月25日庆祝圣诞节。部分东正教传统在次年1月6日或7日庆祝;周末产生的法定补休不代表庆祝日期改变。"
if key == "christmas" else ""
),
"impact": "高" if relevance == "high" else ("中" if relevance == "medium" else "低"),
"business_relevance": relevance,
"decision_label": str(profile["decision"]),
"opportunity": str(first.get("next_action") or ""),
"next_action": str(first.get("next_action") or ""),
"market_group": "、".join(market_groups),
"affected_regions": market_groups,
"affected_countries": countries,
"affected_country_codes": country_codes,
"country_details": country_details,
"languages": languages,
"platforms": platforms,
"platform_actions": platform_actions(platforms),
"country_count": len(countries),
"festival_key": key,
"event_type": str(first.get("event_type") or "public-holiday"),
"culture_key": str(first.get("culture_key") or "central-europe"),
"consumption_scenes": list(profile.get("scenes") or []),
"product_suggestions": list(profile.get("products") or []),
"design_opportunities": list(profile.get("design") or []),
"cultural_avoidances": list(profile.get("avoid") or []),
"stocking_start": min(str(row.get("stocking_start") or start_date) for row in rows),
"stocking_end": max(str(row.get("stocking_end") or start_date) for row in rows),
"planning_stage": str(first.get("planning_stage") or "规划期"),
"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, title_english),
"culture_reference_url": culture_reference_url(title),
"culture_reference_cn_url": culture_reference_cn_url(title, key),
}
translation = {
key: first[key]
for key in (
"source_language", "needs_translation", "google_translation_supported",
"translation_url", "translation_provider", "translation_fallback_url",
"translation_fallback_provider", "translation_notice",
)
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)
return sorted(insights, key=lambda row: (row["start_date"], -int(row["priority_score"]), row["title"]))