Initial commit
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"""定时任务主流程(M7)。
|
||||
|
||||
编排 M1→M6 全链路,每一步调用已有脚本。
|
||||
单步失败记录日志但不阻断后续(后续步骤可能使用旧缓存数据,降级继续)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# 步骤超时(秒)
|
||||
STEP_TIMEOUTS: dict[str, int] = {
|
||||
"crawler": 900, # M1 抓取(含 Playwright 浏览器,13 源约 8-12 min)
|
||||
"xwlb": 60, # M1 新闻联播 API(纯 HTTP,秒级)
|
||||
"extractor": 300, # M2 正文提取
|
||||
"dedup": 120, # M3 去重
|
||||
"llm": 900, # M4 LLM 事件抽取(API 调用,100 篇约 30s 但加限流余量)
|
||||
"embedding": 300, # M5 向量化
|
||||
"qdrant": 300, # M6 入库(数据量大时需较长时间)
|
||||
"report": 30, # 日报生成+上传
|
||||
"cninfo_crawl": 900, # cninfo watchlist URL 驱动(SPA 渲染,每只约 25s)
|
||||
}
|
||||
|
||||
# 步骤对应的 uv run 命令(参数中 {date} 会被替换为实际日期)
|
||||
# crawler 不支持 --date,固定写当天目录; dedup 不加 --reset 以保持增量
|
||||
STEP_COMMANDS: dict[str, list[str]] = {
|
||||
"crawler": ["uv", "run", "python", "-m", "scripts.run_crawler"], # 无 --date
|
||||
"xwlb": ["uv", "run", "python", "-m", "scripts.run_xwlb"],
|
||||
"extractor": ["uv", "run", "python", "-m", "scripts.run_extractor", "--date", "{date}"],
|
||||
"dedup": ["uv", "run", "python", "-m", "scripts.run_dedup", "--date", "{date}"],
|
||||
"llm": ["uv", "run", "python", "-m", "scripts.run_event_extraction", "--date", "{date}"],
|
||||
"embedding": ["uv", "run", "python", "-m", "scripts.run_embedding", "--date", "{date}"],
|
||||
"qdrant": ["uv", "run", "python", "-m", "scripts.run_qdrant_ingest", "--date", "{date}"],
|
||||
"report": [], # 特殊步骤:仅在每天首次定时任务时追加
|
||||
"cninfo_crawl": ["uv", "run", "a-share", "cninfo"],
|
||||
"cninfo_extract": ["uv", "run", "a-share", "extract", "--source", "cninfo", "--date", "{date}"],
|
||||
"cninfo_pdf": ["uv", "run", "a-share", "cninfo", "--enrich-pdf", "--pdf-limit", "100"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
name: str
|
||||
success: bool
|
||||
elapsed_sec: float
|
||||
exit_code: int | None = None
|
||||
tail_msg: str = ""
|
||||
started_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineResult:
|
||||
steps: list[StepResult] = field(default_factory=list)
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def all_success(self) -> bool:
|
||||
return all(s.success for s in self.steps)
|
||||
|
||||
|
||||
def run_step(name: str, date_str: str) -> StepResult:
|
||||
"""执行单个 pipeline 步骤。
|
||||
|
||||
参数:
|
||||
name: 步骤名(crawler/extractor/.../report)
|
||||
date_str: YYYYMMDD 日期字符串
|
||||
|
||||
返回: StepResult。
|
||||
"""
|
||||
# report 步骤:内部函数,不走子进程
|
||||
# 日报默认统计"昨天"的数据(因为今天的数据由当天的定时任务处理)。
|
||||
# 如果昨天没有事件数据,向前回溯最多 3 天,取最近有数据的日期。
|
||||
if name == "report":
|
||||
started = datetime.now()
|
||||
try:
|
||||
from datetime import timedelta # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
|
||||
from .reporter import generate_report # noqa: E402
|
||||
|
||||
# 向前回溯找最近有事件数据的日期(最多回溯 3 天)
|
||||
report_date: str | None = None
|
||||
for offset in range(1, 4):
|
||||
candidate = (date.today() - timedelta(days=offset)).strftime("%Y%m%d")
|
||||
ev_dir = Path(f"data/events/{candidate}")
|
||||
if ev_dir.is_dir() and list(ev_dir.glob("*.json")):
|
||||
report_date = candidate
|
||||
break
|
||||
|
||||
if report_date is None:
|
||||
# 没有任何事件数据,仍然尝试生成昨天日报(至少展示管道统计)
|
||||
report_date = (date.today() - timedelta(days=1)).strftime("%Y%m%d")
|
||||
logger.warning(
|
||||
"日报: 近 3 日均无事件数据 ({} ~ {}), 日报将只含管道统计",
|
||||
(date.today() - timedelta(days=3)).strftime("%Y%m%d"),
|
||||
(date.today() - timedelta(days=1)).strftime("%Y%m%d"),
|
||||
)
|
||||
elif report_date != (date.today() - timedelta(days=1)).strftime("%Y%m%d"):
|
||||
logger.warning(
|
||||
"日报: 昨天 ({}) 无事件数据, 回退使用 {}",
|
||||
(date.today() - timedelta(days=1)).strftime("%Y%m%d"),
|
||||
report_date,
|
||||
)
|
||||
|
||||
path = generate_report(report_date, upload=True)
|
||||
elapsed = (datetime.now() - started).total_seconds()
|
||||
ok = path is not None
|
||||
return StepResult(
|
||||
name=name, success=ok, elapsed_sec=elapsed,
|
||||
tail_msg=str(path) if path else f"无数据 (report_date={report_date})",
|
||||
started_at=started,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
elapsed = (datetime.now() - started).total_seconds()
|
||||
logger.exception("日报生成异常: {}", e)
|
||||
return StepResult(name=name, success=False, elapsed_sec=elapsed,
|
||||
tail_msg=str(e)[:200], started_at=started)
|
||||
|
||||
cmd = STEP_COMMANDS.get(name)
|
||||
if cmd is None:
|
||||
return StepResult(name=name, success=False, elapsed_sec=0,
|
||||
tail_msg=f"未知步骤: {name}")
|
||||
|
||||
full_cmd = [arg.replace("{date}", date_str) for arg in cmd]
|
||||
# 超时优先级:
|
||||
# 1. TIMEOUT_{NAME} 环境变量 (单步精确控制)
|
||||
# 2. PIPELINE_STEP_TIMEOUT 环境变量 (全局兜底, 覆盖硬编码)
|
||||
# 3. STEP_TIMEOUTS 硬编码字典 (代码内默认值)
|
||||
# 4. 1800s (最终兜底)
|
||||
import os
|
||||
specific_key = f"TIMEOUT_{name.upper()}"
|
||||
if specific_key in os.environ:
|
||||
timeout = int(os.environ[specific_key])
|
||||
elif "PIPELINE_STEP_TIMEOUT" in os.environ:
|
||||
timeout = int(os.environ["PIPELINE_STEP_TIMEOUT"])
|
||||
else:
|
||||
timeout = STEP_TIMEOUTS.get(name, 1800)
|
||||
started = datetime.now()
|
||||
logger.info("步骤 {} 开始: {}", name, " ".join(full_cmd))
|
||||
|
||||
try:
|
||||
# 不捕获输出,子进程日志直接流到终端(用户能看到每个源的抓取进度)
|
||||
proc = subprocess.run(
|
||||
full_cmd,
|
||||
timeout=timeout,
|
||||
)
|
||||
elapsed = (datetime.now() - started).total_seconds()
|
||||
ok = proc.returncode == 0
|
||||
|
||||
# dedup 返回 1 是"重复率过高"(无新文章的正常场景)
|
||||
if name == "dedup" and proc.returncode == 1:
|
||||
ok = True
|
||||
logger.info("dedup 重复率超过阈值(无新数据场景,视为成功)")
|
||||
|
||||
tail_msg = f"rc={proc.returncode}" if not ok else ""
|
||||
|
||||
if ok:
|
||||
logger.info("步骤 {} 完成 ({}s) ✅", name, elapsed)
|
||||
else:
|
||||
logger.error("步骤 {} 失败 rc={} ({}s)", name, proc.returncode, elapsed)
|
||||
|
||||
return StepResult(
|
||||
name=name, success=ok, elapsed_sec=elapsed,
|
||||
exit_code=proc.returncode, tail_msg=tail_msg,
|
||||
started_at=started,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
elapsed = (datetime.now() - started).total_seconds()
|
||||
logger.error("步骤 {} 超时 (>{:.0f}s)", name, elapsed)
|
||||
return StepResult(name=name, success=False, elapsed_sec=elapsed,
|
||||
tail_msg="超时", started_at=started)
|
||||
except Exception as e: # noqa: BLE001
|
||||
elapsed = (datetime.now() - started).total_seconds()
|
||||
logger.exception("步骤 {} 异常: {}", name, e)
|
||||
return StepResult(name=name, success=False, elapsed_sec=elapsed,
|
||||
tail_msg=str(e)[:200], started_at=started)
|
||||
|
||||
|
||||
def run_pipeline(date_str: str, *, steps: list[str] | None = None) -> PipelineResult:
|
||||
"""串联执行全链路(M1→M6)。
|
||||
|
||||
参数:
|
||||
date_str: YYYYMMDD。
|
||||
steps: 可选步骤列表,默认全部 6 步。
|
||||
"""
|
||||
names = steps or [k for k in STEP_COMMANDS if k not in ("report", "cninfo_crawl", "cninfo_extract", "cninfo_pdf")]
|
||||
result = PipelineResult(started_at=datetime.now())
|
||||
|
||||
for name in names:
|
||||
sr = run_step(name, date_str)
|
||||
result.steps.append(sr)
|
||||
if not sr.success:
|
||||
logger.warning("步骤 {} 失败,后续步骤继续(可能降级)", name)
|
||||
# 步间留一点缓冲
|
||||
time.sleep(0.5)
|
||||
|
||||
result.finished_at = datetime.now()
|
||||
total = (result.finished_at - result.started_at).total_seconds() if result.started_at else 0
|
||||
succ = sum(1 for s in result.steps if s.success)
|
||||
rate = succ / max(len(result.steps), 1)
|
||||
logger.info(
|
||||
"Pipeline 完成: {}/{} 步骤成功 ({:.0%}) 总耗时 {:.0f}s",
|
||||
succ, len(result.steps), rate, total,
|
||||
)
|
||||
|
||||
# 输出摘要
|
||||
for s in result.steps:
|
||||
flag = "✅" if s.success else "❌"
|
||||
logger.info(" {} {} {}s", flag, s.name, s.elapsed_sec)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user