"""常驻调度:按配置时刻生成早报并推送企微。""" from __future__ import annotations import json import logging import subprocess import sys import time from dataclasses import dataclass from datetime import date, datetime, time as dt_time, timedelta from pathlib import Path from zoneinfo import ZoneInfo from daily.config import ( CACHE_DIR, LOG_DIR, OUTPUT_DIR, ROOT, schedule_generate_at, schedule_push_at, schedule_timezone_name, ) logger = logging.getLogger(__name__) _STATE_FILE = CACHE_DIR / "scheduler-state.json" _POLL_SECONDS = 15 @dataclass(frozen=True) class ClockTime: hour: int minute: int @dataclass class SchedulerState: last_generate_date: str | None = None last_push_date: str | None = None @classmethod def load(cls) -> SchedulerState: if not _STATE_FILE.exists(): return cls() try: raw = json.loads(_STATE_FILE.read_text(encoding="utf-8")) except (OSError, ValueError): return cls() if not isinstance(raw, dict): return cls() return cls( last_generate_date=raw.get("last_generate_date"), last_push_date=raw.get("last_push_date"), ) def save(self) -> None: _STATE_FILE.parent.mkdir(parents=True, exist_ok=True) payload = { "last_generate_date": self.last_generate_date, "last_push_date": self.last_push_date, } _STATE_FILE.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") def parse_hhmm(value: str) -> ClockTime: raw = (value or "").strip() parts = raw.split(":", 1) if len(parts) != 2: raise ValueError(f"无效时间格式: {value!r},应为 HH:MM") hour = int(parts[0]) minute = int(parts[1]) if not (0 <= hour <= 23 and 0 <= minute <= 59): raise ValueError(f"无效时间: {value!r}") return ClockTime(hour=hour, minute=minute) def load_timezone() -> ZoneInfo: name = schedule_timezone_name() try: return ZoneInfo(name) except Exception as exc: raise RuntimeError(f"无效时区 DAILY_SCHEDULE_TZ={name!r}") from exc def _localize(day: date, clock: ClockTime, tz: ZoneInfo) -> datetime: return datetime.combine(day, dt_time(clock.hour, clock.minute), tz) def next_occurrence_after(clock: ClockTime, tz: ZoneInfo, after: datetime) -> datetime: local = after.astimezone(tz) candidate = local.replace(hour=clock.hour, minute=clock.minute, second=0, microsecond=0) if candidate <= local: candidate += timedelta(days=1) return candidate def _today_slot(day: date, clock: ClockTime, tz: ZoneInfo) -> datetime: return _localize(day, clock, tz) def _run_daily_subcommand(subcmd: str, *extra: str) -> int: cmd = [sys.executable, "-m", "daily", subcmd, *extra] logger.info("执行: %s", " ".join(cmd)) proc = subprocess.run(cmd, cwd=str(ROOT), check=False) return int(proc.returncode) def run_generate() -> int: return _run_daily_subcommand("generate") def run_push_for_date(date_str: str) -> int: report = OUTPUT_DIR / f"{date_str}.wecom.md" if not report.exists(): logger.error("推送失败:报告不存在 %s", report) return 1 return _run_daily_subcommand("push", str(report)) def _setup_logging() -> Path: LOG_DIR.mkdir(parents=True, exist_ok=True) log_path = LOG_DIR / "scheduler.log" logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=[ logging.FileHandler(log_path, encoding="utf-8"), logging.StreamHandler(sys.stdout), ], ) return log_path def _sleep_until(target: datetime, tz: ZoneInfo) -> None: while True: now = datetime.now(tz) seconds = (target - now).total_seconds() if seconds <= 0: return time.sleep(min(seconds, _POLL_SECONDS)) def plan_next_action( *, now: datetime, tz: ZoneInfo, state: SchedulerState, generate_at: ClockTime, push_at: ClockTime, ) -> tuple[datetime, str] | None: """返回下一次应执行的动作;若今日已全部完成则返回明日 generate。""" today = now.astimezone(tz).date() today_str = today.isoformat() gen_done = state.last_generate_date == today_str push_done = state.last_push_date == today_str gen_slot = _today_slot(today, generate_at, tz) push_slot = _today_slot(today, push_at, tz) # 推送窗口内:generate 未做则立即补跑(须先于 push) if not gen_done and gen_slot <= now <= push_slot: return now, "generate" # 已过推送时刻:仅当 generate 已完成时补跑 push if not push_done and gen_done and now >= push_slot: return now, "push" candidates: list[tuple[datetime, str]] = [] if not gen_done and gen_slot > now: candidates.append((gen_slot, "generate")) if not push_done and push_slot > now: candidates.append((push_slot, "push")) if candidates: return min(candidates, key=lambda item: item[0]) tomorrow_gen = next_occurrence_after(generate_at, tz, now) return tomorrow_gen, "generate" def run_scheduled_action(action: str, *, today_str: str) -> int: if action == "generate": return run_generate() if action == "push": return run_push_for_date(today_str) raise ValueError(f"未知动作: {action}") def tick_once( *, now: datetime | None = None, tz: ZoneInfo | None = None, state: SchedulerState | None = None, generate_at: ClockTime | None = None, push_at: ClockTime | None = None, dry_run: bool = False, ) -> SchedulerState: tz = tz or load_timezone() now = now or datetime.now(tz) state = state or SchedulerState.load() generate_at = generate_at or parse_hhmm(schedule_generate_at()) push_at = push_at or parse_hhmm(schedule_push_at()) today_str = now.astimezone(tz).date().isoformat() planned = plan_next_action( now=now, tz=tz, state=state, generate_at=generate_at, push_at=push_at, ) if not planned: return state run_at, action = planned if run_at > now: if not dry_run: logger.info("下次 %s @ %s (%s)", action, run_at.isoformat(), tz.key) _sleep_until(run_at, tz) elif not dry_run: slot = _today_slot(now.astimezone(tz).date(), generate_at if action == "generate" else push_at, tz) logger.info( "补跑 %s(计划 %02d:%02d,当前 %s)", action, slot.hour, slot.minute, now.astimezone(tz).strftime("%H:%M"), ) if dry_run: logger.info("[dry-run] 将执行 %s @ %s", action, run_at.isoformat()) return state logger.info("开始 %s(%s)", action, today_str) code = run_scheduled_action(action, today_str=today_str) if code != 0: logger.error("%s 失败,exit=%s", action, code) else: if action == "generate": state.last_generate_date = today_str elif action == "push": state.last_push_date = today_str state.save() logger.info("%s 完成", action) return state def main() -> int: dry_run = "--dry-run" in sys.argv[1:] once = "--once" in sys.argv[1:] log_path = _setup_logging() tz = load_timezone() generate_at = parse_hhmm(schedule_generate_at()) push_at = parse_hhmm(schedule_push_at()) logger.info( "调度器启动 tz=%s generate=%02d:%02d push=%02d:%02d log=%s", tz.key, generate_at.hour, generate_at.minute, push_at.hour, push_at.minute, log_path, ) state = SchedulerState.load() try: while True: state = tick_once( tz=tz, state=state, generate_at=generate_at, push_at=push_at, dry_run=dry_run, ) if once or dry_run: break except KeyboardInterrupt: logger.info("调度器已停止(KeyboardInterrupt)") return 0 return 0 if __name__ == "__main__": raise SystemExit(main())