#!/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"]))