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>
This commit is contained in:
2026-07-17 18:12:00 +08:00
parent 6192dd4e2a
commit 6ea2a4e4c6
35 changed files with 4389 additions and 359 deletions

284
daily/scheduler.py Normal file
View File

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