Compare commits
2 Commits
d66f2c716c
...
f166d1e504
| Author | SHA1 | Date | |
|---|---|---|---|
| f166d1e504 | |||
| 36085f107d |
@@ -69,6 +69,9 @@ DAILY_NEWS_DEDUP_DAYS=7
|
||||
DAILY_SCHEDULE_TZ=Asia/Shanghai
|
||||
DAILY_SCHEDULE_GENERATE_AT=08:50
|
||||
DAILY_SCHEDULE_PUSH_AT=09:00
|
||||
# 仅工作日生成/推送(法定节假日、周末跳过;调休补班日照常)
|
||||
# 节假日数据取自 xiaoai.me,缓存于 .cache/holidays-<year>.json,每年首次自动获取一次
|
||||
DAILY_WORKDAY_ONLY=1
|
||||
|
||||
# 编辑指定今日首推(可选):关键词,或 关键词|URL
|
||||
# Python Step 0 检索 → featured.json;Agent / classic 企微「今日首推」优先使用
|
||||
|
||||
@@ -167,3 +167,8 @@ def narrative_axis_days() -> int:
|
||||
|
||||
def news_backfill_enabled() -> bool:
|
||||
return env_bool("DAILY_NEWS_BACKFILL", False)
|
||||
|
||||
|
||||
def workday_only() -> bool:
|
||||
"""仅工作日生成/推送;法定节假日与周末跳过(调休补班日照常)。"""
|
||||
return env_bool("DAILY_WORKDAY_ONLY", True)
|
||||
|
||||
106
daily/holiday.py
Normal file
106
daily/holiday.py
Normal 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
|
||||
@@ -74,7 +74,7 @@ def _cursor_chat(system: str, user: str) -> str:
|
||||
api_key = (env("CURSOR_API_KEY") or "").strip()
|
||||
if not api_key:
|
||||
return ""
|
||||
from cursor_sdk import Agent, AgentOptions, CursorAgentError, LocalAgentOptions
|
||||
from cursor_sdk import Agent, AgentOptions, Client, CursorAgentError, LocalAgentOptions
|
||||
|
||||
from daily.bridge_manager import warm_cursor_bridge
|
||||
|
||||
@@ -83,6 +83,17 @@ def _cursor_chat(system: str, user: str) -> str:
|
||||
warm_cursor_bridge()
|
||||
model = env("CURSOR_MODEL") or "composer-2.5"
|
||||
prompt = f"{system}\n\n{user}"
|
||||
|
||||
# SDK 默认 unary_timeout 只有 60s,CURSOR_MODEL=auto 时后端首 token 常超时;
|
||||
# 自建带大超时的 Client 绕开 _default_client(),unary/stream 都放大。
|
||||
unary_timeout = env_int("DAILY_CURSOR_UNARY_TIMEOUT", 300)
|
||||
stream_timeout = env_int("DAILY_CURSOR_STREAM_TIMEOUT", 900)
|
||||
client = Client(
|
||||
base_url=os.environ["CURSOR_SDK_BRIDGE_URL"],
|
||||
auth_token=os.environ["CURSOR_SDK_BRIDGE_TOKEN"],
|
||||
unary_timeout=unary_timeout,
|
||||
stream_timeout=stream_timeout,
|
||||
)
|
||||
try:
|
||||
result = Agent.prompt(
|
||||
prompt,
|
||||
@@ -91,9 +102,12 @@ def _cursor_chat(system: str, user: str) -> str:
|
||||
model=model,
|
||||
local=LocalAgentOptions(cwd=cwd),
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
except CursorAgentError as exc:
|
||||
raise RuntimeError(f"LLM 调用失败:{exc.message}") from exc
|
||||
finally:
|
||||
client.close()
|
||||
if result.status == "error":
|
||||
raise RuntimeError(f"LLM 调用失败:{result.result or '未知错误'}")
|
||||
return (result.result or "").strip()
|
||||
|
||||
@@ -20,7 +20,9 @@ from daily.config import (
|
||||
schedule_generate_at,
|
||||
schedule_push_at,
|
||||
schedule_timezone_name,
|
||||
workday_only,
|
||||
)
|
||||
from daily.holiday import is_workday, load_holidays, workday_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -200,6 +202,27 @@ def tick_once(
|
||||
push_at = push_at or parse_hhmm(schedule_push_at())
|
||||
|
||||
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(
|
||||
now=now,
|
||||
tz=tz,
|
||||
|
||||
57
tests/test_holiday.py
Normal file
57
tests/test_holiday.py
Normal 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()
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from daily.scheduler import (
|
||||
@@ -12,6 +13,7 @@ from daily.scheduler import (
|
||||
next_occurrence_after,
|
||||
parse_hhmm,
|
||||
plan_next_action,
|
||||
tick_once,
|
||||
)
|
||||
|
||||
|
||||
@@ -116,3 +118,39 @@ class NextOccurrenceTests(unittest.TestCase):
|
||||
nxt = next_occurrence_after(ClockTime(8, 50), tz, now)
|
||||
self.assertEqual(nxt.date().isoformat(), "2026-07-10")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user