From 05a1dca074b063f789929fd5d601d72383eb28dd Mon Sep 17 00:00:00 2001 From: "mingsheng.li" Date: Mon, 27 Apr 2026 12:09:34 +0800 Subject: [PATCH] feat: add Hong Kong public holiday support - Add scripts/fetch_hk.py to fetch HK holidays from 1823.gov.hk - Make generate_ics() cal_name/cal_desc configurable - Refactor update.py with REGIONS config; support --region cn|hk flag - Generate hk/{year}.json and hk/{year}.ics under hk/ subdirectory Co-Authored-By: Claude Sonnet 4.6 --- hk/2026.ics | 100 ++++++++++++++++++++++++++++++ hk/2026.json | 95 ++++++++++++++++++++++++++++ hk/2027.ics | 16 +++++ hk/2027.json | 9 +++ holiday-hk.ics | 100 ++++++++++++++++++++++++++++++ scripts/fetch_hk.py | 45 ++++++++++++++ scripts/generate_ics.py | 11 +++- scripts/update.py | 134 ++++++++++++++++++++++++++++++---------- 8 files changed, 473 insertions(+), 37 deletions(-) create mode 100644 hk/2026.ics create mode 100644 hk/2026.json create mode 100644 hk/2027.ics create mode 100644 hk/2027.json create mode 100644 holiday-hk.ics create mode 100644 scripts/fetch_hk.py diff --git a/hk/2026.ics b/hk/2026.ics new file mode 100644 index 0000000..06dd2a7 --- /dev/null +++ b/hk/2026.ics @@ -0,0 +1,100 @@ +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +CLASS:PUBLIC +X-WR-CALDESC:香港公众假期数据,来源:香港特别行政区政 + 府 1823.gov.hk。 +X-WR-CALNAME:香港公众假期 +BEGIN:VTIMEZONE +TZID:Asia/Shanghai +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +SUMMARY:一月一日假期 +DTSTART;VALUE=DATE:20260101 +DTEND;VALUE=DATE:20260102 +DTSTAMP;VALUE=DATE:20260101 +UID:2026-01-01/2026-01-02/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:農曆年初一假期 +DTSTART;VALUE=DATE:20260217 +DTEND;VALUE=DATE:20260220 +DTSTAMP;VALUE=DATE:20260217 +UID:2026-02-17/2026-02-20/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:耶穌受難節假期 +DTSTART;VALUE=DATE:20260403 +DTEND;VALUE=DATE:20260405 +DTSTAMP;VALUE=DATE:20260403 +UID:2026-04-03/2026-04-05/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:清明節翌日假期 +DTSTART;VALUE=DATE:20260406 +DTEND;VALUE=DATE:20260408 +DTSTAMP;VALUE=DATE:20260406 +UID:2026-04-06/2026-04-08/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:勞動節假期 +DTSTART;VALUE=DATE:20260501 +DTEND;VALUE=DATE:20260502 +DTSTAMP;VALUE=DATE:20260501 +UID:2026-05-01/2026-05-02/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:佛誕翌日假期 +DTSTART;VALUE=DATE:20260525 +DTEND;VALUE=DATE:20260526 +DTSTAMP;VALUE=DATE:20260525 +UID:2026-05-25/2026-05-26/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:端午節假期 +DTSTART;VALUE=DATE:20260619 +DTEND;VALUE=DATE:20260620 +DTSTAMP;VALUE=DATE:20260619 +UID:2026-06-19/2026-06-20/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:香港特別行政區成立紀念日假期 +DTSTART;VALUE=DATE:20260701 +DTEND;VALUE=DATE:20260702 +DTSTAMP;VALUE=DATE:20260701 +UID:2026-07-01/2026-07-02/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:中秋節翌日假期 +DTSTART;VALUE=DATE:20260926 +DTEND;VALUE=DATE:20260927 +DTSTAMP;VALUE=DATE:20260926 +UID:2026-09-26/2026-09-27/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:國慶日假期 +DTSTART;VALUE=DATE:20261001 +DTEND;VALUE=DATE:20261002 +DTSTAMP;VALUE=DATE:20261001 +UID:2026-10-01/2026-10-02/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:重陽節翌日假期 +DTSTART;VALUE=DATE:20261019 +DTEND;VALUE=DATE:20261020 +DTSTAMP;VALUE=DATE:20261019 +UID:2026-10-19/2026-10-20/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:聖誕節假期 +DTSTART;VALUE=DATE:20261225 +DTEND;VALUE=DATE:20261227 +DTSTAMP;VALUE=DATE:20261225 +UID:2026-12-25/2026-12-27/NateScarlet/holiday-cn +END:VEVENT +END:VCALENDAR diff --git a/hk/2026.json b/hk/2026.json new file mode 100644 index 0000000..248d73c --- /dev/null +++ b/hk/2026.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/schema.json", + "$id": "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/hk/2026.json", + "year": 2026, + "papers": [ + "https://www.1823.gov.hk/common/ical/tc.json" + ], + "days": [ + { + "name": "一月一日", + "date": "2026-01-01", + "isOffDay": true + }, + { + "name": "農曆年初一", + "date": "2026-02-17", + "isOffDay": true + }, + { + "name": "農曆年初二", + "date": "2026-02-18", + "isOffDay": true + }, + { + "name": "農曆年初三", + "date": "2026-02-19", + "isOffDay": true + }, + { + "name": "耶穌受難節", + "date": "2026-04-03", + "isOffDay": true + }, + { + "name": "耶穌受難節翌日", + "date": "2026-04-04", + "isOffDay": true + }, + { + "name": "清明節翌日", + "date": "2026-04-06", + "isOffDay": true + }, + { + "name": "復活節星期一翌日", + "date": "2026-04-07", + "isOffDay": true + }, + { + "name": "勞動節", + "date": "2026-05-01", + "isOffDay": true + }, + { + "name": "佛誕翌日", + "date": "2026-05-25", + "isOffDay": true + }, + { + "name": "端午節", + "date": "2026-06-19", + "isOffDay": true + }, + { + "name": "香港特別行政區成立紀念日", + "date": "2026-07-01", + "isOffDay": true + }, + { + "name": "中秋節翌日", + "date": "2026-09-26", + "isOffDay": true + }, + { + "name": "國慶日", + "date": "2026-10-01", + "isOffDay": true + }, + { + "name": "重陽節翌日", + "date": "2026-10-19", + "isOffDay": true + }, + { + "name": "聖誕節", + "date": "2026-12-25", + "isOffDay": true + }, + { + "name": "聖誕節後第一個周日", + "date": "2026-12-26", + "isOffDay": true + } + ] +} \ No newline at end of file diff --git a/hk/2027.ics b/hk/2027.ics new file mode 100644 index 0000000..b2170b8 --- /dev/null +++ b/hk/2027.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +CLASS:PUBLIC +X-WR-CALDESC:香港公众假期数据,来源:香港特别行政区政 + 府 1823.gov.hk。 +X-WR-CALNAME:香港公众假期 +BEGIN:VTIMEZONE +TZID:Asia/Shanghai +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +END:STANDARD +END:VTIMEZONE +END:VCALENDAR diff --git a/hk/2027.json b/hk/2027.json new file mode 100644 index 0000000..4b1dd9d --- /dev/null +++ b/hk/2027.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/schema.json", + "$id": "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/hk/2027.json", + "year": 2027, + "papers": [ + "https://www.1823.gov.hk/common/ical/tc.json" + ], + "days": [] +} \ No newline at end of file diff --git a/holiday-hk.ics b/holiday-hk.ics new file mode 100644 index 0000000..06dd2a7 --- /dev/null +++ b/holiday-hk.ics @@ -0,0 +1,100 @@ +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +CLASS:PUBLIC +X-WR-CALDESC:香港公众假期数据,来源:香港特别行政区政 + 府 1823.gov.hk。 +X-WR-CALNAME:香港公众假期 +BEGIN:VTIMEZONE +TZID:Asia/Shanghai +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +SUMMARY:一月一日假期 +DTSTART;VALUE=DATE:20260101 +DTEND;VALUE=DATE:20260102 +DTSTAMP;VALUE=DATE:20260101 +UID:2026-01-01/2026-01-02/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:農曆年初一假期 +DTSTART;VALUE=DATE:20260217 +DTEND;VALUE=DATE:20260220 +DTSTAMP;VALUE=DATE:20260217 +UID:2026-02-17/2026-02-20/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:耶穌受難節假期 +DTSTART;VALUE=DATE:20260403 +DTEND;VALUE=DATE:20260405 +DTSTAMP;VALUE=DATE:20260403 +UID:2026-04-03/2026-04-05/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:清明節翌日假期 +DTSTART;VALUE=DATE:20260406 +DTEND;VALUE=DATE:20260408 +DTSTAMP;VALUE=DATE:20260406 +UID:2026-04-06/2026-04-08/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:勞動節假期 +DTSTART;VALUE=DATE:20260501 +DTEND;VALUE=DATE:20260502 +DTSTAMP;VALUE=DATE:20260501 +UID:2026-05-01/2026-05-02/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:佛誕翌日假期 +DTSTART;VALUE=DATE:20260525 +DTEND;VALUE=DATE:20260526 +DTSTAMP;VALUE=DATE:20260525 +UID:2026-05-25/2026-05-26/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:端午節假期 +DTSTART;VALUE=DATE:20260619 +DTEND;VALUE=DATE:20260620 +DTSTAMP;VALUE=DATE:20260619 +UID:2026-06-19/2026-06-20/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:香港特別行政區成立紀念日假期 +DTSTART;VALUE=DATE:20260701 +DTEND;VALUE=DATE:20260702 +DTSTAMP;VALUE=DATE:20260701 +UID:2026-07-01/2026-07-02/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:中秋節翌日假期 +DTSTART;VALUE=DATE:20260926 +DTEND;VALUE=DATE:20260927 +DTSTAMP;VALUE=DATE:20260926 +UID:2026-09-26/2026-09-27/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:國慶日假期 +DTSTART;VALUE=DATE:20261001 +DTEND;VALUE=DATE:20261002 +DTSTAMP;VALUE=DATE:20261001 +UID:2026-10-01/2026-10-02/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:重陽節翌日假期 +DTSTART;VALUE=DATE:20261019 +DTEND;VALUE=DATE:20261020 +DTSTAMP;VALUE=DATE:20261019 +UID:2026-10-19/2026-10-20/NateScarlet/holiday-cn +END:VEVENT +BEGIN:VEVENT +SUMMARY:聖誕節假期 +DTSTART;VALUE=DATE:20261225 +DTEND;VALUE=DATE:20261227 +DTSTAMP;VALUE=DATE:20261225 +UID:2026-12-25/2026-12-27/NateScarlet/holiday-cn +END:VEVENT +END:VCALENDAR diff --git a/scripts/fetch_hk.py b/scripts/fetch_hk.py new file mode 100644 index 0000000..527bee2 --- /dev/null +++ b/scripts/fetch_hk.py @@ -0,0 +1,45 @@ +"""Fetch Hong Kong public holiday data from 1823.gov.hk.""" + +import datetime +import requests + +# Traditional Chinese names, consistent with CN data format +HK_ICAL_URL = "https://www.1823.gov.hk/common/ical/tc.json" + +# Earliest year available from the 1823.gov.hk API +HK_START_YEAR = 2024 + + +def fetch_hk_holiday(year: int) -> dict: + """Fetch HK public holidays for a given year. + + HK has no makeup work day (调休) concept, so all entries are isOffDay=True. + Data coverage starts from HK_START_YEAR. + """ + response = requests.get(HK_ICAL_URL) + response.raise_for_status() + data = response.json() + + events = data["vcalendar"][0]["vevent"] + days = [] + + for event in events: + dtstart = event["dtstart"][0] # "YYYYMMDD" + date = datetime.date(int(dtstart[:4]), int(dtstart[4:6]), int(dtstart[6:8])) + if date.year != year: + continue + days.append( + { + "name": event["summary"], + "date": date.isoformat(), + "isOffDay": True, + } + ) + + days.sort(key=lambda d: d["date"]) + + return { + "year": year, + "papers": [HK_ICAL_URL], + "days": days, + } diff --git a/scripts/generate_ics.py b/scripts/generate_ics.py index 0d98db8..b2a33df 100644 --- a/scripts/generate_ics.py +++ b/scripts/generate_ics.py @@ -60,11 +60,16 @@ def _iter_date_ranges(days: Sequence[dict]) -> Iterator[Tuple[dict, dict]]: yield fr, to -def generate_ics(days: Sequence[dict], filename: Text) -> None: +def generate_ics( + days: Sequence[dict], + filename: Text, + cal_name: str = "中国法定节假日", + cal_desc: str = "中国法定节假日数据,自动每日抓取国务院公告。", +) -> None: """Generate ics from days.""" cal = Calendar() - cal.add("X-WR-CALNAME", "中国法定节假日") - cal.add("X-WR-CALDESC", "中国法定节假日数据,自动每日抓取国务院公告。") + cal.add("X-WR-CALNAME", cal_name) + cal.add("X-WR-CALDESC", cal_desc) cal.add("VERSION", "2.0") cal.add("METHOD", "PUBLISH") cal.add("CLASS", "PUBLIC") diff --git a/scripts/update.py b/scripts/update.py index 2e85e31..fbd6034 100644 --- a/scripts/update.py +++ b/scripts/update.py @@ -14,9 +14,32 @@ from zipfile import ZipFile from tqdm import tqdm from fetch import CustomJSONEncoder, fetch_holiday +from fetch_hk import HK_START_YEAR, fetch_hk_holiday from generate_ics import generate_ics from filetools import workspace_path +SCHEMA_URL = "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/schema.json" +GITHUB_RAW_BASE = "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master" + +REGIONS = { + "cn": { + "fetch": fetch_holiday, + "start_year": 2007, + "subdir": None, + "main_ics_name": "holiday-cn.ics", + "cal_name": "中国法定节假日", + "cal_desc": "中国法定节假日数据,自动每日抓取国务院公告。", + }, + "hk": { + "fetch": fetch_hk_holiday, + "start_year": HK_START_YEAR, + "subdir": "hk", + "main_ics_name": "holiday-hk.ics", + "cal_name": "香港公众假期", + "cal_desc": "香港公众假期数据,来源:香港特别行政区政府 1823.gov.hk。", + }, +} + class ChinaTimezone(tzinfo): """Timezone of china.""" @@ -31,25 +54,34 @@ class ChinaTimezone(tzinfo): return timedelta() -def update_data(year: int) -> Iterator[str]: - """Update and store data for a year.""" +def _region_paths(region: str, year: int): + """Return (json_path, ics_path, id_url) for a region and year.""" + subdir = REGIONS[region]["subdir"] + if subdir: + os.makedirs(workspace_path(subdir), exist_ok=True) + json_path = workspace_path(subdir, f"{year}.json") + ics_path = workspace_path(subdir, f"{year}.ics") + id_url = f"{GITHUB_RAW_BASE}/{subdir}/{year}.json" + else: + json_path = workspace_path(f"{year}.json") + ics_path = workspace_path(f"{year}.ics") + id_url = f"{GITHUB_RAW_BASE}/{year}.json" + return json_path, ics_path, id_url - json_filename = workspace_path(f"{year}.json") - ics_filename = workspace_path(f"{year}.ics") - with open(json_filename, "w", encoding="utf-8", newline="\n") as f: - data = fetch_holiday(year) +def update_data(year: int, region: str = "cn") -> Iterator[str]: + """Update and store data for a year and region.""" + cfg = REGIONS[region] + json_path, ics_path, id_url = _region_paths(region, year) + + data = cfg["fetch"](year) + + with open(json_path, "w", encoding="utf-8", newline="\n") as f: json.dump( dict( ( - ( - "$schema", - "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/schema.json", - ), - ( - "$id", - f"https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/{year}.json", - ), + ("$schema", SCHEMA_URL), + ("$id", id_url), *data.items(), ) ), @@ -59,25 +91,38 @@ def update_data(year: int) -> Iterator[str]: cls=CustomJSONEncoder, ) - yield json_filename - generate_ics(data["days"], ics_filename) - yield ics_filename + yield json_path + generate_ics( + data["days"], + ics_path, + cal_name=cfg["cal_name"], + cal_desc=cfg["cal_desc"], + ) + yield ics_path -def update_main_ics(fr_year, to_year): +def update_main_ics(fr_year: int, to_year: int, region: str = "cn"): + cfg = REGIONS[region] + subdir = cfg["subdir"] all_days = [] + for year in range(fr_year, to_year + 1): - filename = workspace_path(f"{year}.json") + if subdir: + filename = workspace_path(subdir, f"{year}.json") + else: + filename = workspace_path(f"{year}.json") if not os.path.isfile(filename): continue with open(filename, "r", encoding="utf8") as inf: data = json.loads(inf.read()) - all_days.extend(data.get("days")) + all_days.extend(data.get("days", [])) - filename = workspace_path("holiday-cn.ics") + filename = workspace_path(cfg["main_ics_name"]) generate_ics( all_days, filename, + cal_name=cfg["cal_name"], + cal_desc=cfg["cal_desc"], ) return filename @@ -87,30 +132,41 @@ def main(): parser.add_argument( "--all", action="store_true", - help="Update all years since 2007, default is this year and next year", + help="Update all years since each region's start year, default is this year and next year", ) parser.add_argument( "--release", action="store_true", help="create new release if repository data is not up to date", ) + parser.add_argument( + "--region", + choices=list(REGIONS.keys()), + default=None, + help="Region to update (default: all regions)", + ) args = parser.parse_args() now = datetime.now(ChinaTimezone()) is_release = args.release + regions_to_update = list(REGIONS.keys()) if args.region is None else [args.region] filenames = [] - progress = tqdm(range(2007 if args.all else now.year, now.year + 2)) - for i in progress: - progress.set_description(f"Updating {i} data") - filenames += list(update_data(i)) - progress.set_description("Updating holiday-cn.ics") - filenames.append(update_main_ics(now.year - 4, now.year + 1)) + for region in regions_to_update: + cfg = REGIONS[region] + year_start = cfg["start_year"] if args.all else max(cfg["start_year"], now.year) + progress = tqdm(range(year_start, now.year + 2)) + for year in progress: + progress.set_description(f"Updating {region} {year}") + filenames += list(update_data(year, region)) + progress.set_description(f"Updating {cfg['main_ics_name']}") + filenames.append(update_main_ics(now.year - 4, now.year + 1, region)) + print("") subprocess.run(["git", "add", *filenames], check=True) diff = subprocess.run( - ["git", "diff", "--stat", "--cached", "*.json", "*.ics"], + ["git", "diff", "--stat", "--cached"], check=True, stdout=subprocess.PIPE, encoding="utf-8", @@ -160,13 +216,23 @@ def main(): def pack_data(file): - """Pack data json in zip file.""" - + """Pack all region JSON data into a zip file.""" zip_file = ZipFile(file, "w") - for i in os.listdir(workspace_path()): - if not re.match(r"\d+\.json", i): + # Root-level {year}.json files (CN) + for name in os.listdir(workspace_path()): + if re.match(r"\d+\.json", name): + zip_file.write(workspace_path(name), name) + # Subdirectory region files (e.g. hk/{year}.json) + for region, cfg in REGIONS.items(): + subdir = cfg["subdir"] + if not subdir: continue - zip_file.write(workspace_path(i), i) + subdir_path = workspace_path(subdir) + if not os.path.isdir(subdir_path): + continue + for name in os.listdir(subdir_path): + if re.match(r"\d+\.json", name): + zip_file.write(workspace_path(subdir, name), f"{subdir}/{name}") if __name__ == "__main__":