- daily/holiday.py: 抓取 xiaoai.me 全年节假日并缓存到 .cache/holidays-<year>.json, is_workday() 判定(法定节假日/周末休息,调休补班日上班),联网失败回退本地缓存 - scheduler: tick_once 前置工作日闸门,非工作日标记完成并跳过,判定失败降级为正常工作日避免漏跑 - config/.env.example: 新增 DAILY_WORKDAY_ONLY 开关(默认开) - tests: test_holiday 8 例 + scheduler 非工作日闸门 2 例 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
3.6 KiB
Python
107 lines
3.6 KiB
Python
"""法定节假日获取、缓存与工作日判定。
|
||
|
||
数据来自 xiaoai.me 公共接口,一次性抓取全年并缓存到 .cache/,
|
||
后续判定只读本地缓存;缓存缺失或过期时才重新请求。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import ssl
|
||
import urllib.request
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
from daily.config import CACHE_DIR
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_API_URL = "https://publicapi.xiaoai.me/holiday/year?date={year}"
|
||
_TIMEOUT = 15
|
||
|
||
|
||
def _cache_file(year: int) -> Path:
|
||
return CACHE_DIR / f"holidays-{year}.json"
|
||
|
||
|
||
def _ssl_context() -> ssl.SSLContext:
|
||
ctx = ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE
|
||
return ctx
|
||
|
||
|
||
def _fetch_year(year: int) -> dict[str, dict]:
|
||
"""请求全年节假日,返回 {date_str: {"rest": bool, "name": str}}。
|
||
|
||
rest=1 表示休息(法定节假日/调休放假),rest=0 表示调休补班(需上班)。
|
||
"""
|
||
url = _API_URL.format(year=year)
|
||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||
with urllib.request.urlopen(req, timeout=_TIMEOUT, context=_ssl_context()) as resp:
|
||
payload = json.loads(resp.read().decode("utf-8"))
|
||
items = payload.get("data") or []
|
||
result: dict[str, dict] = {}
|
||
for item in items:
|
||
day = item.get("date")
|
||
if not day:
|
||
continue
|
||
result[day] = {
|
||
"rest": bool(item.get("rest", 0)),
|
||
"name": item.get("holiday", ""),
|
||
}
|
||
if not result:
|
||
raise RuntimeError(f"节假日接口返回为空 year={year}")
|
||
return result
|
||
|
||
|
||
def load_holidays(year: int, *, refresh: bool = False) -> dict[str, dict]:
|
||
"""加载全年节假日缓存;缺失或 refresh 时联网抓取并写入缓存。
|
||
|
||
联网失败时若已有缓存则回退用缓存,保证离线可用。
|
||
"""
|
||
cache = _cache_file(year)
|
||
if cache.exists() and not refresh:
|
||
try:
|
||
raw = json.loads(cache.read_text(encoding="utf-8"))
|
||
if isinstance(raw, dict) and raw:
|
||
return raw
|
||
except (OSError, ValueError):
|
||
logger.warning("节假日缓存损坏,重新获取 %s", cache)
|
||
|
||
try:
|
||
data = _fetch_year(year)
|
||
except Exception as exc:
|
||
if cache.exists():
|
||
logger.warning("节假日获取失败,回退到本地缓存:%s", exc)
|
||
return json.loads(cache.read_text(encoding="utf-8"))
|
||
raise
|
||
|
||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||
cache.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
logger.info("已缓存 %d 年节假日 %d 条 -> %s", year, len(data), cache)
|
||
return data
|
||
|
||
|
||
def is_workday(day: date, holidays: dict[str, dict] | None = None) -> bool:
|
||
"""判断是否为工作日。
|
||
|
||
规则:调休补班日(rest=0 且在表中)→ 上班;
|
||
法定节假日/调休放假(rest=1)→ 休息;
|
||
周六日 → 休息;其余 → 上班。
|
||
"""
|
||
holidays = holidays if holidays is not None else load_holidays(day.year)
|
||
key = day.isoformat()
|
||
entry = holidays.get(key)
|
||
if entry is not None:
|
||
return not entry["rest"]
|
||
return day.weekday() < 5 # 周一~周五
|
||
|
||
|
||
def workday_name(day: date, holidays: dict[str, dict] | None = None) -> str | None:
|
||
"""返回该天的节假日名(休息或调休补班),无则 None。用于日志。"""
|
||
holidays = holidays if holidays is not None else load_holidays(day.year)
|
||
entry = holidays.get(day.isoformat())
|
||
return entry["name"] if entry else None
|