"""M7 定时任务调度入口。 支持两种模式: --once 立刻执行一次全链路 (默认) 启动 APScheduler,按 .env 中 SCHEDULE_TIMES 定时执行 用法: uv run python -m scripts.run_scheduler # 启动定时服务 uv run python -m scripts.run_scheduler --once # 立即执行一次 uv run python -m scripts.run_scheduler --once --date 20260616 uv run python -m scripts.run_scheduler --once --steps crawler,extractor """ from __future__ import annotations import argparse import signal import sys from datetime import date, datetime from pathlib import Path from typing import Any from dotenv import load_dotenv from loguru import logger from scheduler import STEP_COMMANDS, run_pipeline from scheduler.stock_reporter import generate_all_stock_reports def _setup_logger(level: str) -> None: logger.remove() logger.add( sys.stderr, level=level, format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}", ) log_path = Path("logs") / "scheduler.log" log_path.parent.mkdir(parents=True, exist_ok=True) logger.add(log_path, level="DEBUG", rotation="10 MB", retention=5, encoding="utf-8") def _parse_schedule_times(raw: str) -> list[tuple[int, int]]: """解析 SCHEDULE_TIMES 环境变量。 格式: "07:00,12:00,18:00,22:00" 返回: [(7,0), (12,0), (18,0), (22,0)] """ out: list[tuple[int, int]] = [] for part in raw.split(","): part = part.strip() if not part: continue try: h, m = part.split(":") out.append((int(h), int(m))) except ValueError: logger.warning("SCHEDULE_TIMES 格式错误: {!r},跳过", part) return out def _once(args: argparse.Namespace) -> int: """单次执行模式。""" steps = None if args.steps: steps = [s.strip() for s in args.steps.split(",")] run_pipeline(args.date, steps=steps) return 0 def _daemon(args: argparse.Namespace) -> int: """守护进程模式(APScheduler)。""" import os from apscheduler.schedulers.background import BackgroundScheduler # noqa: E402 from apscheduler.triggers.cron import CronTrigger # noqa: E402 times_raw = os.environ.get("SCHEDULE_TIMES", "07:00,12:00,18:00,22:00") times = _parse_schedule_times(times_raw) if not times: logger.error("SCHEDULE_TIMES 为空或全部非法,无法启动定时任务") return 2 # 找出最早的时间(当天首次运行),仅该次追加日报步骤 sorted_times = sorted(times) first_hour, first_minute = sorted_times[0] if sorted_times else (0, 0) scheduler = BackgroundScheduler() # 包装函数:每次触发时重新计算日期,避免 date.today() 在注册时冻结。 def _scheduled_pipeline(steps: list[str] | None = None) -> None: run_pipeline(date.today().strftime("%Y%m%d"), steps=steps) for hour, minute in times: trigger = CronTrigger(hour=hour, minute=minute, timezone="Asia/Shanghai") is_first = (hour == first_hour and minute == first_minute) job_kwargs: dict | None = None if is_first: job_kwargs = { "steps": [k for k in STEP_COMMANDS if k not in ("report", "cninfo_crawl", "cninfo_extract", "cninfo_pdf")] + ["report"] } scheduler.add_job( _scheduled_pipeline, trigger=trigger, kwargs=job_kwargs, id=f"pipeline_{hour:02d}{minute:02d}", name=f"全链路 {'+日报' if is_first else ''} {hour:02d}:{minute:02d}", ) logger.info("已注册定时任务: {}每天 {:02d}:{:02d}{}", trigger, hour, minute, " (含日报)" if is_first else "") # cninfo 公告管道(可配置,默认 06:30) cninfo_raw = os.environ.get("CNINFO_SCHEDULE_TIME", "06:30") cninfo_parts = cninfo_raw.split(":") cninfo_h, cninfo_m = int(cninfo_parts[0]), int(cninfo_parts[1]) if len(cninfo_parts) > 1 else 0 cninfo_trigger = CronTrigger(hour=cninfo_h, minute=cninfo_m, timezone="Asia/Shanghai") cninfo_steps = ["cninfo_crawl", "cninfo_extract", "cninfo_pdf", "dedup", "llm", "embedding", "qdrant"] scheduler.add_job( _scheduled_pipeline, trigger=cninfo_trigger, kwargs={"steps": cninfo_steps}, id="pipeline_cninfo", name=f"cninfo 公告管道 {cninfo_h:02d}:{cninfo_m:02d}", ) logger.info("已注册定时任务: cninfo 公告管道 每天 {:02d}:{:02d}", cninfo_h, cninfo_m) # 个股日报(可配置,默认 07:30, 设为空可禁用) stock_raw = os.environ.get("STOCK_REPORT_TIME", "07:30") if stock_raw: stock_parts = stock_raw.split(":") stock_h, stock_m = int(stock_parts[0]), int(stock_parts[1]) if len(stock_parts) > 1 else 0 stock_trigger = CronTrigger(hour=stock_h, minute=stock_m, timezone="Asia/Shanghai") scheduler.add_job( generate_all_stock_reports, trigger=stock_trigger, id="stock_report", name=f"个股日报 {stock_h:02d}:{stock_m:02d}", ) logger.info("已注册定时任务: 个股日报 每天 {:02d}:{:02d}", stock_h, stock_m) else: logger.info("STOCK_REPORT_TIME 为空, 已禁用个股日报") # 优雅退出 def _shutdown(signum: int, frame: Any) -> None: logger.info("收到信号 {}, 关闭调度器...", signum) scheduler.shutdown(wait=False) raise SystemExit(0) signal.signal(signal.SIGINT, _shutdown) signal.signal(signal.SIGTERM, _shutdown) scheduler.start() logger.info("调度器已启动,等待触发... (按 Ctrl+C 退出)") # 启动时检查是否有因重启/宕机错过的定时任务,30 分钟内补跑 now = datetime.now() for hour, minute in times: scheduled = now.replace(hour=hour, minute=minute, second=0, microsecond=0) missed_minutes = (now - scheduled).total_seconds() / 60 if 0 < missed_minutes < 30: logger.warning( "检测到错过的定时任务 {:02d}:{:02d} ({} 分钟前),立即补跑一次", hour, minute, int(missed_minutes), ) steps = [k for k in STEP_COMMANDS if k != "report"] if (hour, minute) == sorted_times[0]: steps.append("report") run_pipeline(date.today().strftime("%Y%m%d"), steps=steps) import contextlib with contextlib.suppress(SystemExit, KeyboardInterrupt): # 保持主线程存活,直到收到退出信号 signal.pause() return 0 def main() -> int: parser = argparse.ArgumentParser(description="A 股新闻定时任务 (M7)") parser.add_argument("--once", action="store_true", help="立即执行一次全链路") parser.add_argument( "--date", default=date.today().strftime("%Y%m%d"), help="日期 YYYYMMDD (仅 --once 模式)", ) parser.add_argument("--steps", default=None, help="仅执行指定步骤,逗号分隔 (如 crawler,extractor)") parser.add_argument("--log-level", default="INFO") args = parser.parse_args() _setup_logger(args.log_level) load_dotenv() if args.once: return _once(args) return _daemon(args) if __name__ == "__main__": raise SystemExit(main())