fix: 修复补跑误含 cninfo 三步与时区不一致 (P1-2/P1-3)

- P1-2: 提取 DEFAULT_NEWS_STEPS(全链路去 report+cninfo),定时/补跑/默认三处统一;
  启动补跑不再误执行 cninfo 公告管道
- P1-3: 新增 scheduler/timeutil.py(调度时区统一入口,SCHEDULE_TZ 可覆盖,
  默认 Asia/Shanghai);run_scheduler 定时/补跑/--once、crawler 补跑保护、
  reporter 兜底日期全部改用调度时区,避免系统时区非上海时日期错位
- 新增 6 个测试;全量 282 passed
This commit is contained in:
2026-08-23 05:40:52 +08:00
parent 639d857ffb
commit 7b33182f67
7 changed files with 197 additions and 20 deletions
+18
View File
@@ -4,6 +4,24 @@
---
## 本次完成 (2026-08-23) — P1-2/P1-3 补跑步骤与时区一致性修复
**P1-2(守护进程补跑误含 cninfo 三步)**:
- 原因:`scripts/run_scheduler.py` 启动补跑逻辑 `steps = [k for k in STEP_COMMANDS if k != "report"]` 只排除 report,**漏掉 cninfo_crawl/cninfo_extract/cninfo_pdf**;错过定时任务重启补跑时会额外执行整套公告管道(且 cninfo_crawl 无 --date,补跑历史日期静默空转)
- 修复:提取共享常量 `scheduler.pipeline.DEFAULT_NEWS_STEPS`(= 全链路去除 report 与 cninfo 三步),定时任务、补跑、run_pipeline 默认三处统一引用
**P1-3(调度时区与 date.today() 不一致)**:
- 原因:cron 触发器显式用 `Asia/Shanghai`,但 `date.today()`/`datetime.now()` 取**系统时区**;若系统时区非上海(如容器 UTC),07:00 上海(=前一日 23:00 UTC)触发时日期会错一天,整条 pipeline 落错日目录
- 修复:新增 `scheduler/timeutil.py`(schedule_tz/today_str/now,env `SCHEDULE_TZ` 可覆盖,默认 Asia/Shanghai);`run_scheduler` 定时/补跑/`--once`、`pipeline.run_step` crawler 补跑保护、`reporter.generate_report` 兜底日期全部改用它
**测试**:
- 新增 6 个(DEFAULT_NEWS_STEPS 不含 cninfo/run_pipeline 默认步骤行为/补跑源码引用/时区两例/--once 缺省日期),并入 `tests/test_incremental.py`
- 全量 **282 passed**,3 个 crawler 基线失败与本次无关;ruff 干净(5 个 reporter 既有问题未动)
**验证**:DEFAULT_NEWS_STEPS = [crawler, xwlb, extractor, dedup, llm, embedding, qdrant] ✓;today_str = 20260823(Asia/Shanghai)✓
---
## 本次完成 (2026-08-22) — 多源新闻记录链路修复(方案 A + B)
**用户需求**:一条新闻有多个来源时,全部来源都要记录并可见;此前"找不到多源"。
+2
View File
@@ -7,6 +7,7 @@
"""
from .pipeline import (
DEFAULT_NEWS_STEPS,
STEP_COMMANDS,
STEP_TIMEOUTS,
PipelineResult,
@@ -16,6 +17,7 @@ from .pipeline import (
)
__all__ = [
"DEFAULT_NEWS_STEPS",
"STEP_COMMANDS",
"STEP_TIMEOUTS",
"PipelineResult",
+14 -3
View File
@@ -16,11 +16,13 @@ import os
import subprocess
import time
from dataclasses import dataclass, field
from datetime import date, datetime
from datetime import datetime
from pathlib import Path
from loguru import logger
from .timeutil import today_str
# 断点状态文件(按日期隔离,记录每步骤结果)
DEFAULT_STATE_PATH = Path("data/pipeline/state.json")
@@ -74,6 +76,14 @@ STEP_COMMANDS: dict[str, list[str]] = {
"cninfo_pdf": ["uv", "run", "a-share", "cninfo", "--enrich-pdf", "--pdf-limit", "100"],
}
# 新闻链路默认步骤:全链路除去 report(单独追加)与 cninfo 独立管道(P1-2:
# 补跑/定时任务若误含 cninfo 三步,会重复执行整套公告管道,且 cninfo_crawl
# 无 --date 参数,补跑历史日期时会静默空转)。
DEFAULT_NEWS_STEPS: list[str] = [
k for k in STEP_COMMANDS
if k not in ("report", "cninfo_crawl", "cninfo_extract", "cninfo_pdf")
]
@dataclass
class StepResult:
@@ -223,7 +233,8 @@ def run_step(name: str, date_str: str) -> StepResult:
# 补跑保护:抓取类步骤只能产生当天数据(网站首页只含当前内容,历史文章已滚走),
# 补跑历史日期时跳过抓取并告警,复用已有 data/raw/*/{date_str} 数据。
# 注意:仅保护 crawler; xwlb 走 API 支持任意历史日期,无需跳过。
if name == "crawler" and date_str != date.today().strftime("%Y%m%d"):
# "今天"按调度时区判定(P1-3),避免系统时区与 cron 时区不一致时错判。
if name == "crawler" and date_str != today_str():
started = datetime.now()
logger.warning(
"补跑模式:首页只含当天内容,无法补抓 {};跳过抓取,复用已有 data/raw/*/{}",
@@ -330,7 +341,7 @@ def run_pipeline(
记录,跳过连续成功的步骤,从第一个失败/未执行步骤继续。
state_path: 断点状态文件路径(测试可注入)。
"""
names = steps or [k for k in STEP_COMMANDS if k not in ("report", "cninfo_crawl", "cninfo_extract", "cninfo_pdf")]
names = steps or list(DEFAULT_NEWS_STEPS)
result = PipelineResult(started_at=datetime.now())
state = _load_pipeline_state(state_path)
+4 -1
View File
@@ -1056,7 +1056,10 @@ def generate_report(day_str: str | None = None, *, upload: bool = True) -> int |
`upload` 参数保留以兼容 scheduler/pipeline.py 调用,已无实际作用。
返回 report_id(成功)或 None(无数据/失败)。
"""
day_str = day_str or date.today().strftime("%Y%m%d")
# 兜底日期按调度时区取(P1-3),与 pipeline 传入的 date_str 语义一致
if not day_str:
from .timeutil import today_str
day_str = today_str()
logger.info("生成日报: {}", day_str)
# 收集数据
+36
View File
@@ -0,0 +1,36 @@
"""调度时区工具。
背景(P1-3):
定时任务 cron 触发时区与"今天"的判定必须一致。此前 cron 用
Asia/Shanghai,而 `date.today()` / `datetime.now()` 取系统时区——
若系统时区不是 Asia/Shanghai(如 UTC),07:00 上海(=前一日 23:00 UTC)
触发时 `date.today()` 会返回错误日期,整条 pipeline 落在错日目录。
统一入口:
schedule_tz(): 调度时区(env SCHEDULE_TZ 可覆盖,默认 Asia/Shanghai)
today_str(): 按调度时区返回 YYYYMMDD
"""
from __future__ import annotations
import os
from datetime import datetime
from zoneinfo import ZoneInfo
# 默认调度时区(与 .env 的 SCHEDULE_TIMES 语义一致)
DEFAULT_SCHEDULE_TZ = "Asia/Shanghai"
def schedule_tz() -> ZoneInfo:
"""调度时区(env SCHEDULE_TZ 可覆盖,默认 Asia/Shanghai)。"""
return ZoneInfo(os.environ.get("SCHEDULE_TZ") or DEFAULT_SCHEDULE_TZ)
def now() -> datetime:
"""当前时刻(调度时区,aware)。"""
return datetime.now(schedule_tz())
def today_str() -> str:
"""按调度时区返回今天的 YYYYMMDD。"""
return now().strftime("%Y%m%d")
+18 -14
View File
@@ -16,15 +16,16 @@ 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 import DEFAULT_NEWS_STEPS, run_pipeline
from scheduler.stock_reporter import generate_all_stock_reports
from scheduler.timeutil import now as tz_now
from scheduler.timeutil import schedule_tz, today_str
def _setup_logger(level: str) -> None:
@@ -69,7 +70,7 @@ def _once(args: argparse.Namespace) -> int:
if args.resume and args.steps:
logger.error("--resume 与 --steps 不能同时使用(断点续跑针对全链路)")
return 2
run_pipeline(args.date, steps=steps, resume=args.resume)
run_pipeline(args.date or today_str(), steps=steps, resume=args.resume)
return 0
@@ -92,17 +93,18 @@ def _daemon(args: argparse.Namespace) -> int:
scheduler = BackgroundScheduler()
# 包装函数:每次触发时重新计算日期,避免 date.today() 在注册时冻结。
# 包装函数:每次触发时按调度时区重新计算日期(P1-3),
# 避免 date.today() 在注册时冻结或与 cron 时区不一致。
def _scheduled_pipeline(steps: list[str] | None = None) -> None:
run_pipeline(date.today().strftime("%Y%m%d"), steps=steps)
run_pipeline(today_str(), steps=steps)
for hour, minute in times:
trigger = CronTrigger(hour=hour, minute=minute, timezone="Asia/Shanghai")
trigger = CronTrigger(hour=hour, minute=minute, timezone=str(schedule_tz()))
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"]
"steps": list(DEFAULT_NEWS_STEPS) + ["report"]
}
scheduler.add_job(
_scheduled_pipeline,
@@ -118,7 +120,7 @@ def _daemon(args: argparse.Namespace) -> int:
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_trigger = CronTrigger(hour=cninfo_h, minute=cninfo_m, timezone=str(schedule_tz()))
cninfo_steps = ["cninfo_crawl", "cninfo_extract", "cninfo_pdf",
"dedup", "llm", "embedding", "qdrant"]
scheduler.add_job(
@@ -135,7 +137,7 @@ def _daemon(args: argparse.Namespace) -> int:
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")
stock_trigger = CronTrigger(hour=stock_h, minute=stock_m, timezone=str(schedule_tz()))
scheduler.add_job(
generate_all_stock_reports,
trigger=stock_trigger,
@@ -159,7 +161,7 @@ def _daemon(args: argparse.Namespace) -> int:
logger.info("调度器已启动,等待触发... (按 Ctrl+C 退出)")
# 启动时检查是否有因重启/宕机错过的定时任务,30 分钟内补跑
now = datetime.now()
now = tz_now()
for hour, minute in times:
scheduled = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
missed_minutes = (now - scheduled).total_seconds() / 60
@@ -168,10 +170,12 @@ def _daemon(args: argparse.Namespace) -> int:
"检测到错过的定时任务 {:02d}:{:02d} ({} 分钟前),立即补跑一次",
hour, minute, int(missed_minutes),
)
steps = [k for k in STEP_COMMANDS if k != "report"]
# P1-2:补跑只跑新闻链路(DEFAULT_NEWS_STEPS 不含 report 与 cninfo 三步),
# cninfo 公告管道由其自身定时任务负责,避免重复执行整套公告管道。
steps = list(DEFAULT_NEWS_STEPS)
if (hour, minute) == sorted_times[0]:
steps.append("report")
run_pipeline(date.today().strftime("%Y%m%d"), steps=steps)
run_pipeline(today_str(), steps=steps)
import contextlib
@@ -186,8 +190,8 @@ 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 模式)",
"--date", default=None,
help="日期 YYYYMMDD (仅 --once 模式,默认按调度时区取今天)",
)
parser.add_argument("--steps", default=None,
help="仅执行指定步骤,逗号分隔 (如 crawler,extractor)")
+105 -2
View File
@@ -8,6 +8,7 @@
from __future__ import annotations
import json
from datetime import UTC
from pathlib import Path
from types import SimpleNamespace
@@ -306,8 +307,7 @@ def test_crawler_today_runs_normally(monkeypatch: pytest.MonkeyPatch) -> None:
def test_report_step_uses_date_str(monkeypatch: pytest.MonkeyPatch) -> None:
"""report 步骤必须使用传入的 date_str,而非 date.today()(P0-4)。"""
from scheduler import pipeline
from scheduler import reporter
from scheduler import pipeline, reporter
received: list[str] = []
@@ -346,3 +346,106 @@ def test_pipeline_backfill_skips_crawler_keeps_rest(
assert all(s.success for s in result.steps)
state = pipeline._load_pipeline_state(state_path)
assert state["20260101"]["crawler"]["status"] == "ok"
# --------------------------------------------------------------------------- #
# P1-2: 默认步骤不含 cninfo 独立管道 / P1-3: 调度时区统一
# --------------------------------------------------------------------------- #
def test_default_news_steps_excludes_cninfo_and_report() -> None:
"""新闻链路默认步骤不含 report 与 cninfo 三步(P1-2)。"""
from scheduler import DEFAULT_NEWS_STEPS
assert "report" not in DEFAULT_NEWS_STEPS
assert "cninfo_crawl" not in DEFAULT_NEWS_STEPS
assert "cninfo_extract" not in DEFAULT_NEWS_STEPS
assert "cninfo_pdf" not in DEFAULT_NEWS_STEPS
# 新闻链路核心步骤齐全
assert {"crawler", "xwlb", "extractor", "dedup", "llm",
"embedding", "qdrant"} <= set(DEFAULT_NEWS_STEPS)
def test_run_pipeline_default_steps_excludes_cninfo(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""run_pipeline 默认 steps 不含 cninfo(只跑新闻链路)。"""
from scheduler import pipeline
from scheduler.timeutil import today_str
calls: list[str] = []
def _fake_run(cmd, timeout=None): # noqa: ARG001
name = next(c.split(".")[-1] for c in cmd if "scripts.run_" in c)
calls.append(name)
return SimpleNamespace(returncode=0)
monkeypatch.setattr(pipeline.subprocess, "run", _fake_run)
state_path = tmp_path / "state.json"
today = today_str() # 今天:避免 crawler 补跑保护跳过
result = pipeline.run_pipeline(
today, state_path=state_path,
)
assert result.all_success is True
assert calls == ["run_crawler", "run_xwlb", "run_extractor", "run_dedup",
"run_event_extraction", "run_embedding", "run_qdrant_ingest"]
assert "cninfo" not in " ".join(calls)
def test_scheduler_daemon_backfill_uses_default_news_steps(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""守护进程补跑 steps 使用 DEFAULT_NEWS_STEPS(不含 cninfo 三步)。"""
import inspect
import scripts.run_scheduler as rs
from scheduler import DEFAULT_NEWS_STEPS
# 验证补跑逻辑引用的常量:通过源码断言 + 常量内容双重保证
src = inspect.getsource(rs._daemon)
assert "list(DEFAULT_NEWS_STEPS)" in src
assert "cninfo_crawl" not in DEFAULT_NEWS_STEPS
def test_today_str_matches_schedule_timezone(monkeypatch: pytest.MonkeyPatch) -> None:
"""today_str 按调度时区返回 YYYYMMDD(P1-3)。"""
from datetime import datetime
from zoneinfo import ZoneInfo
from scheduler.timeutil import today_str
s = today_str()
assert len(s) == 8 and s.isdigit()
# 与调度时区(默认 Asia/Shanghai)当前日期一致
expect = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
assert s == expect
def test_today_str_respects_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""SCHEDULE_TZ 环境变量可覆盖调度时区(P1-3)。"""
from datetime import datetime
from scheduler.timeutil import today_str
# 覆盖为 UTC 后,today_str 应返回 UTC 日期(而非默认 Asia/Shanghai)
monkeypatch.setenv("SCHEDULE_TZ", "UTC")
s = today_str()
expect = datetime.now(UTC).strftime("%Y%m%d")
assert s == expect
def test_once_uses_today_str_when_date_missing(monkeypatch: pytest.MonkeyPatch) -> None:
"""--once 不带 --date 时按调度时区取今天(P1-3)。"""
import scripts.run_scheduler as rs
captured: dict[str, str] = {}
def _fake_pipeline(date_str, steps=None, resume=False): # noqa: ARG001
captured["date"] = date_str
monkeypatch.setattr(rs, "run_pipeline", _fake_pipeline)
from types import SimpleNamespace
args = SimpleNamespace(steps=None, resume=False, date=None)
rc = rs._once(args)
assert rc == 0
assert captured["date"] == rs.today_str()