- 新增常驻调度器 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>
285 lines
8.1 KiB
Python
285 lines
8.1 KiB
Python
"""常驻调度:按配置时刻生成早报并推送企微。"""
|
||
|
||
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())
|