Files
daily-robots/daily/scheduler.py
yumao 6ea2a4e4c6 feat: 早报系统重构与功能增强
- 新增常驻调度器 daily/scheduler.py + run-scheduler.ps1(定时生成/推送)
- 新增 daily/bridge_manager.py:Windows 兼容的 Cursor SDK 桥接
- 新增 skills/daily-featured-pick 首推 Skill 与叙事轴/去重逻辑
- 新闻抓取窗口、GitHub 搜索、企微 delta 模式等多项改进
- 补充设计文档与 superpowers 计划/规范
- 新增对应测试(scheduler、featured_pick、github_search、news_fetch_window 等)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:12:00 +08:00

285 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""常驻调度:按配置时刻生成早报并推送企微。"""
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())