Files

954 lines
60 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 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"]))