feat: 新增节假日缓存,仅工作日生成与推送

- 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>
This commit is contained in:
2026-07-23 17:51:09 +08:00
parent d66f2c716c
commit 36085f107d
6 changed files with 232 additions and 0 deletions

View File

@@ -69,6 +69,9 @@ DAILY_NEWS_DEDUP_DAYS=7
DAILY_SCHEDULE_TZ=Asia/Shanghai DAILY_SCHEDULE_TZ=Asia/Shanghai
DAILY_SCHEDULE_GENERATE_AT=08:50 DAILY_SCHEDULE_GENERATE_AT=08:50
DAILY_SCHEDULE_PUSH_AT=09:00 DAILY_SCHEDULE_PUSH_AT=09:00
# 仅工作日生成/推送(法定节假日、周末跳过;调休补班日照常)
# 节假日数据取自 xiaoai.me缓存于 .cache/holidays-<year>.json每年首次自动获取一次
DAILY_WORKDAY_ONLY=1
# 编辑指定今日首推(可选):关键词,或 关键词|URL # 编辑指定今日首推(可选):关键词,或 关键词|URL
# Python Step 0 检索 → featured.jsonAgent / classic 企微「今日首推」优先使用 # Python Step 0 检索 → featured.jsonAgent / classic 企微「今日首推」优先使用

View File

@@ -167,3 +167,8 @@ def narrative_axis_days() -> int:
def news_backfill_enabled() -> bool: def news_backfill_enabled() -> bool:
return env_bool("DAILY_NEWS_BACKFILL", False) return env_bool("DAILY_NEWS_BACKFILL", False)
def workday_only() -> bool:
"""仅工作日生成/推送;法定节假日与周末跳过(调休补班日照常)。"""
return env_bool("DAILY_WORKDAY_ONLY", True)

106
daily/holiday.py Normal file
View File

@@ -0,0 +1,106 @@
"""法定节假日获取、缓存与工作日判定。
数据来自 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

View File

@@ -20,7 +20,9 @@ from daily.config import (
schedule_generate_at, schedule_generate_at,
schedule_push_at, schedule_push_at,
schedule_timezone_name, schedule_timezone_name,
workday_only,
) )
from daily.holiday import is_workday, load_holidays, workday_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -200,6 +202,27 @@ def tick_once(
push_at = push_at or parse_hhmm(schedule_push_at()) push_at = push_at or parse_hhmm(schedule_push_at())
today_str = now.astimezone(tz).date().isoformat() today_str = now.astimezone(tz).date().isoformat()
# 仅工作日运行:非工作日(法定节假日/周末,调休补班日除外)跳过当日 generate/push。
# 将两者标记为已完成,避免 plan_next_action 在当天反复补跑。
if workday_only() and state.last_generate_date != today_str:
today = now.astimezone(tz).date()
try:
holidays = load_holidays(today.year)
if not is_workday(today, holidays):
name = workday_name(today, holidays) or "周末"
logger.info("今日 %s 非工作日(%s),跳过生成与推送", today_str, name)
state.last_generate_date = today_str
state.last_push_date = today_str
if not dry_run:
state.save()
if dry_run:
logger.info("[dry-run] 非工作日,将跳过")
return state
except Exception as exc:
# 节假日数据不可用时按常规工作日处理,避免因接口故障漏跑
logger.warning("工作日判定失败(%s),按正常工作日处理", exc)
planned = plan_next_action( planned = plan_next_action(
now=now, now=now,
tz=tz, tz=tz,

57
tests/test_holiday.py Normal file
View File

@@ -0,0 +1,57 @@
"""Tests for daily.holiday."""
from __future__ import annotations
import unittest
from datetime import date
from daily.holiday import is_workday, workday_name
# 模拟 2026 节假日表1/1 元旦(休)1/4 元旦调休(补班)
HOLIDAYS = {
"2026-01-01": {"rest": True, "name": "元旦节"},
"2026-01-02": {"rest": True, "name": "元旦节"},
"2026-01-03": {"rest": True, "name": "元旦节"},
"2026-01-04": {"rest": False, "name": "元旦节调休"},
"2026-02-16": {"rest": True, "name": "春节"},
"2026-02-14": {"rest": False, "name": "春节调休"},
}
class IsWorkdayTests(unittest.TestCase):
def test_legal_holiday_is_rest(self):
# 2026-01-01 周四,元旦 → 休息
self.assertFalse(is_workday(date(2026, 1, 1), HOLIDAYS))
def test_tiaoxiu_makeup_day_is_workday(self):
# 2026-01-04 周日,调休补班 → 上班
self.assertEqual(date(2026, 1, 4).weekday(), 6)
self.assertTrue(is_workday(date(2026, 1, 4), HOLIDAYS))
def test_spring_festival_holiday(self):
self.assertFalse(is_workday(date(2026, 2, 16), HOLIDAYS))
def test_spring_festival_makeup_saturday(self):
# 2026-02-14 周六,调休 → 上班
self.assertEqual(date(2026, 2, 14).weekday(), 5)
self.assertTrue(is_workday(date(2026, 2, 14), HOLIDAYS))
def test_normal_weekday(self):
# 2026-07-23 周四,非节假日 → 上班
self.assertTrue(is_workday(date(2026, 7, 23), HOLIDAYS))
def test_normal_weekend(self):
# 2026-07-25 周六,非节假日 → 休息
self.assertFalse(is_workday(date(2026, 7, 25), HOLIDAYS))
class WorkdayNameTests(unittest.TestCase):
def test_holiday_name(self):
self.assertEqual(workday_name(date(2026, 1, 1), HOLIDAYS), "元旦节")
def test_normal_day_has_no_name(self):
self.assertIsNone(workday_name(date(2026, 7, 23), HOLIDAYS))
if __name__ == "__main__":
unittest.main()

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import unittest import unittest
from datetime import datetime from datetime import datetime
from unittest import mock
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from daily.scheduler import ( from daily.scheduler import (
@@ -12,6 +13,7 @@ from daily.scheduler import (
next_occurrence_after, next_occurrence_after,
parse_hhmm, parse_hhmm,
plan_next_action, plan_next_action,
tick_once,
) )
@@ -116,3 +118,39 @@ class NextOccurrenceTests(unittest.TestCase):
nxt = next_occurrence_after(ClockTime(8, 50), tz, now) nxt = next_occurrence_after(ClockTime(8, 50), tz, now)
self.assertEqual(nxt.date().isoformat(), "2026-07-10") self.assertEqual(nxt.date().isoformat(), "2026-07-10")
self.assertEqual((nxt.hour, nxt.minute), (8, 50)) self.assertEqual((nxt.hour, nxt.minute), (8, 50))
class WorkdayGateTests(unittest.TestCase):
"""非工作日 tick_once 直接标记完成并跳过,不触发 generate。"""
def setUp(self) -> None:
self.tz = ZoneInfo("Asia/Shanghai")
self.gen = ClockTime(8, 50)
self.push = ClockTime(9, 0)
def _tick(self, day, workday):
now = datetime(day.year, day.month, day.day, 8, 30, tzinfo=self.tz)
holidays = {} if workday else {day.isoformat(): {"rest": True, "name": "测试假"}}
with mock.patch("daily.scheduler.load_holidays", return_value=holidays), \
mock.patch("daily.scheduler.run_scheduled_action") as run:
state = tick_once(
now=now, tz=self.tz, state=SchedulerState(),
generate_at=self.gen, push_at=self.push, dry_run=True,
)
return state, run
def test_holiday_skips_and_marks_done(self):
# 2026-07-25 是周六;强制为非工作日
from datetime import date
day = date(2026, 7, 25)
state, run = self._tick(day, workday=False)
self.assertFalse(run.called)
self.assertEqual(state.last_generate_date, "2026-07-25")
self.assertEqual(state.last_push_date, "2026-07-25")
def test_workday_proceeds(self):
from datetime import date
day = date(2026, 7, 23) # 周四,工作日
state, run = self._tick(day, workday=True)
# 工作日不提前标记完成dry-run 不执行动作,故 last_generate_date 仍为 None
self.assertIsNone(state.last_generate_date)