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 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:09:34 +08:00
parent 29869ddebf
commit 05a1dca074
8 changed files with 473 additions and 37 deletions

100
hk/2026.ics Normal file
View File

@@ -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

95
hk/2026.json Normal file
View File

@@ -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
}
]
}

16
hk/2027.ics Normal file
View File

@@ -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

9
hk/2027.json Normal file
View File

@@ -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": []
}

100
holiday-hk.ics Normal file
View File

@@ -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

45
scripts/fetch_hk.py Normal file
View File

@@ -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,
}

View File

@@ -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")

View File

@@ -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):
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__":