Files
news/continuation.md
T
simon ff911cf6f7 feat: Token Plan 迁移与 .env 热加载,并修复日报 AI 摘要为空
Token Plan 迁移 / 配置热加载:
- configs/llm_models.yaml: 各场景切到 Token Plan(deepseek-v4.1-flash / qwen3.6-flash)
- 新增 configs/runtime_env.py: .env 按 (mtime_ns, size) 热加载并同步 os.environ,
  统一 env_get 取值;llm / embedding / vectorstore / mcp / pipeline 改用 env_get
- configs/loader.py / scripts/run_scheduler.py 等配套调整
- 新增 tests/test_hot_reload.py

日报 AI 摘要为空修复(2026-09-25):
- 根因: 推理模型的 reasoning token 与正文共用 max_tokens, 预算 1500 被"思考"
  占满 -> text_tokens=0 / finish_reason=length, 摘要静默为空且不重试
- daily_report 场景新增 max_tokens(默认 4000, YAML 保存即热生效);
  LLMConfig 支持可选 max_tokens; 分块预算 800 -> 2000
- _llm_call 拆出 _call_once, 正文为空时自动加倍预算重试(上限 16000),
  用尽才降级返回空串; 网络异常重试语义不变
- docs/user-guide.md 新增 FAQ; continuation.md 记录本次排查
- 已重跑 2026-09-25 日报(report_id=357)补回 466 字摘要

测试: 相关用例 56 passed(test_hot_reload 12 passed);
      ruff 无新增问题; 3 个 crawler 既有失败与本改动无关
2026-09-25 11:13:37 +08:00

35 KiB
Raw Blame History

continuation.md

checkpoint @ 2026-08-22


本次完成 (2026-09-25) — 日报 AI 摘要为空修复(推理模型 reasoning 占满 max_tokens)

现象:用户反馈 2026-09-25 日报没有 AI 摘要。news_report 中 id=357(2026-09-25, finance) ai_summary 为 NULL;当天 07:13:55 日志:

WARNING | scheduler.reporter:_llm_call - AI 摘要可能被截断: max_tokens=1500 finish_reason=length 实际输出 0 字符

根因(证据链闭合):

  • 日报场景(daily_report)调用 deepseek-v4.1-flash(Token Plan),这是推理模型:reasoning_content 的 token 与正文共用 max_tokens 预算
  • 用当天真实素材原样复现:reasoning_tokens=1500 / text_tokens=0 / content=0 字符 / finish_reason=length —— 预算被"思考"全部吃掉,正文为空
  • 同素材把预算提到 4000:finish_reason=stop、reasoning 938 + text 337、摘要 523 字 ✓
  • 代码缺陷:scheduler/reporter.py:_llm_call 只取 message.content,空内容不抛异常 → _generate_ai_summary 返回 "" → ai_summary=None 入库,pipeline 仍标 report ✅(静默失败、无重试)
  • 非本次 Token Plan 迁移引入:历史同为空的还有 9-12 / 9-13 / 9-15 / 9-18,当时用的是 deepseek-v4-flash(provider=deepseek),同样是推理类模型 → 长期间歇性缺陷

修复(方案 B:配置化 + 代码兜底):

  1. configs/llm_models.yaml:daily_report 新增 max_tokens: 4000(注释说明 reasoning 共用预算);YAML 保存即热生效
  2. llm/client.py:LLMConfig 新增可选字段 max_tokens;load_llm_config 用新增的 _pick_optional_int 读取场景配置(未配置 = None,调用方回退内置默认)
  3. scheduler/reporter.py:
    • 新增常量 DEFAULT_SUMMARY_MAX_TOKENS=4000 / DEFAULT_SUMMARY_CHUNK_MAX_TOKENS=2000 / MAX_SUMMARY_MAX_TOKENS=16000 / _MAX_BUDGET_ESCALATIONS=2
    • 新增 _summary_max_tokens()(场景配置 > 内置默认)、_chunk_max_tokens()(不超过合并预算)
    • _llm_call 拆出 _call_once;保持网络异常指数退避重试语义不变;新增空正文 + finish_reason=length 时自动加倍预算重试(上限 16000),用尽后返回空串降级(不抛异常)
    • 分块预算 800 → 2000;合并预算 1500 → 配置值(4000)

验证:

  • 线上复现 → 修复后回归:uv run a-share report --date 20260925 重跑,无截断告警,report_id=357 原地更新(幂等 upsert),ai_summary 466 字 ✓
  • 测试:新增 TestReasoningBudgetEscalation(升级恢复 / 场景值优先 / 用尽降级返回空 / 上限)+ test_llm.py 的 max_tokens 场景配置、_pick_optional_int、真实 YAML 预算 ≥4000 回归保护 → tests/test_report_builder.py tests/test_llm.py 56 passed
  • ruff 改动文件无新增问题(5 条 N806/SIM115 为 reporter.py 既有);mypy 仅剩 llm/client.py:86 既有告警

运维动作:report 步骤由调度器进程内执行,已 sudo systemctl restart a-share-research(10:55,重启后任务同步正常),明早 07:00 起生效。

遗留与后续:

  • scheduler/stock_reporter.py:285 个股日报 max_tokens=500,配置模型 qwen3.6-flash 亦属推理类,同类隐患(当前个股日报禁用);启用前建议一并按本方案处理
  • 空摘要目前只降级为"无摘要",未做告警;可考虑连续 N 天为空时推送通知
  • 本次改动尚未 git commit(工作区还混有 9-22 Token Plan 迁移的未提交改动,避免混提)

本次完成 (2026-09-10) — cninfo 抓取压穿内存导致整机冻结的修复

现象:2026-09-06 / 09-08 / 09-10 连续三次早上 06:0x 整机冻结,看门狗(硬件 2min)硬复位。

根因(证据链闭合):

  • 三次冻结时刻 = cninfo 公告管道 06:00 定时任务:logs/scheduler_error.log 显示 09-10 06:00:03.180~.888 0.7 秒内打印 15 条「抓取…公告」(15 只股票协程同时进入渲染),随后日志全无直到 06:14:08 重启
  • data/raw/cninfo/ 缺 20260906/08/10 三个目录(存续目录 mtime 均为 06:02,而落盘在 _save_items() 中、抓取全部完成后才执行 → 崩在写数据之前)
  • pcp-pmie 09-10 06:01:20 报 load 65(4 核);DNS 全面超时(frpc/dockerd resolver)
  • journalctl --list-boots 与三次重启时刻吻合

代码缺陷(crawler/cninfo.py):

  1. _render_page 每次调用都 async with AsyncWebCrawler(...) 新建完整 Chromium(最贵的错误)
  2. crawl_watchlist 用 asyncio.as_completed 对 watchlist 全量并发(15 只)
  3. 无任何并发限制;service 亦无资源限制(CPUQuota=infinity、TasksMax=9626)
  4. 本机 cgroup_disable=memory → MemoryMax 不可用(只会 cpuset cpu io pids)

实测(本机 8GB):基线 chrome=0 → 2 并发峰值 1713MB / 20 进程 = 857MB/实例;外推 15 并发 ≈12.5GB ≫ 7.9GB RAM → 必然压穿(时好时坏是 zram 与时序侥幸)

修复(第一步:限并发):

  • crawler/cninfo.py:新增 MAX_RENDER_CONCURRENCY(默认 2,env CNINFO_RENDER_CONCURRENCY 可覆盖)+ 模块级 _render_sem 信号量,_render_page 全程持槽(含浏览器启停)
  • 顺带清理 3 个既有死导入(time/datetime/Any)

实测验证(2026-09-10 20:43 实跑 a-share cninfo):

指标 修复前(15 并发) 修复后(2 并发)
Chromium 内存峰值 ≈12.5GB(外推) 1796 MB
进程峰值 ≈150 22
load 峰值 65 2.75
available 最低 压穿冻结 3561 MB
耗时 崩(无 END) 441 s(上限 900s)
结果 无数据 抓 34 条/存 23 条,补上 09-10 缺失目录 ✅

测试:新增 tests/test_cninfo.py 8 个(并发上限/串行/异常释放槽位/env 解析);全量 291 passed(3 个 crawler 基线失败无关);ruff 干净

遗留与后续优化:

  • 耗时由 127~145s 增至 441s(用时间换内存安全),cninfo_crawl 超时 900s 余量由 6 倍降至 2 倍;如嫌慢可 CNINFO_RENDER_CONCURRENCY=3(峰值约 2.6GB,仍安全)
  • 第二步(未做):复用单个 AsyncWebCrawler + arun_many() 批量渲染(浏览器组 15→1,可同时提速降内存)
  • 第三步(未做):_fetch_irm_requests() 是同步 requests.get 却在 async 中直接调用,阻塞事件循环;可改 asyncio.to_thread
  • 长期:改用 cninfo 公告 POST JSON API(hisAnnouncement/query)彻底去掉浏览器依赖
  • 未执行 systemd 资源限制:CPUQuota 对内存型崩溃基本无效(反而延长驻留),TasksMax 过小会让抓取永久失败

本次完成 (2026-08-23) — MCP 新闻查询服务确认与修复

用户需求:实现新闻查询 MCP 服务(阅读文档步骤,确认是否已实现)。

确认结论:核心实现完整且可用(此前已实现,本次验证 + 修复一处配置错误):

文档要求 现状 验证
scripts/run_mcp_server.py(stdio/SSE 入口) ✅ 存在 stdio 端到端调用通过
mcp_server/tools.py 5 个工具 ✅ 齐全 tools/list 返回 5 工具,tools/call 真实检索成功
pyproject 依赖 mcp>=1.0 ✅ 已声明 已安装
tests/test_mcp.py ✅ 10 个测试 全过
.mcp.json(Claude Code 配置) ❌ 配置错误 见下

发现并修复的差距:

  • .mcp.json 被跟踪的内容是 Mac 上另一项目(serena)的残留配置(路径指向 /Users/summer/...),与本项目无关 → 按 docs/user-guide.md 8.2 重写为 a-share-research 配置
  • 补充 1 个测试(test_fmt_results_multi_source_tag,验证 A2 多源展示)

端到端验证(真实调用,非 mock):

  1. 5 个工具函数直接调用:全部返回检索结果 ✓
  2. SSE 模式 --sse 8765:GET /sse 返回 event: endpoint 握手 ✓
  3. MCP 协议层(stdio 客户端 → scripts.run_mcp_server):tools/list 返回 5 个工具与文档一致;tools/call search_news("国务院常务会议") 命中《李强主持召开国务院常务会议》等 ✓

全量测试:283 passed(新增 1 个多源展示),3 个 crawler 基线失败与本次无关;ruff 干净

遗留:无(仅既有 reporter 5 个 ruff 问题、Qdrant 本地模式性能警告)


本次完成 (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)

用户需求:一条新闻有多个来源时,全部来源都要记录并可见;此前"找不到多源"。

诊断结论:

  • M3→M4 多源记录逻辑本身正常:指纹库 227 条多源、当日 sources.json 11 条、events 313 条全含 sources、MySQL news_event.sources 填充率 100% 且有 7 条真实多源(如许家印案 ["cls","sina"])
  • 用户"看不到"的原因:① 展示层(日报 HTML/CLI/MCP)只渲染单源 source_id;② 知识库链路 M5→M6 真断点——EmbeddingResult 无 sources 字段、Qdrant payload 只写 source_id、SearchResult 无 sources

修复内容:

  • B1 模型层: embedding/models.py EmbeddingResult.sources + validator(主源居首/去重/旧产物兜底);vectorstore/models.py SearchResult.sources
  • B2 scripts/run_embedding.py: _build_text_from_event/_build_text_from_article 透传 sources 进 EmbeddingResult
  • B3 scripts/run_qdrant_ingest.py: payload 写 sources(M5 产物 → 回查 M4 → 兜底 [主源])
  • B4 vectorstore/client.py: 检索读取 payload.sources
  • A1 scheduler/reporter.py: 日报 HTML 多源时显示「财联社 / 新浪 📰」
  • A2 a_share_cli/main.py + mcp_server/tools.py: 检索展示「cecn / cscn [多源]」,MCP 返回 sources 数组
  • 存量回填 scripts/backfill_qdrant_sources.py(新):以指纹库 source_ids 为权威源回填 Qdrant payload
    • ⚠️ 坑:本地文件模式 Qdrant 写入逐点 ≈0.40.65s,**全量 3.3 万条回填需 46 小时**且占用单进程锁,不可行
    • 改为定向回填:只有多源 point 需要 sources(单源点展示层兜底 source_id),仅回填指纹库 227 条多源 → 1m57s 完成 226 条(1 条不在集合)
    • 执行时机:避开定时调度窗口(22:00 pipeline 结束后 22:34 执行)

验证:

  • 新增 tests/test_multisource.py 10 个(validator/_result_to_point 三优先级/文本构造透传/回填加载)
  • 全量 276 passed(3 个 crawler 基线失败与本次无关);ruff 干净(reporter 5 个 N806/SIM115 为既有问题未动)
  • 今日 M5/M6 重跑:313 条全含 sources(11 条多源);CLI 检索显示「来源: cecn / cscn [多源]」✓,MCP 返回 sources: ['cecn','cscn'] ✓
  • 存量回填:226 条多源 point payload 已补 sources;CLI 检索「碧根果反倾销」显示「来源: cnstock / cscn [多源]」✓;日报 HTML 冒烟测试多源渲染「财联社 / 新浪财经 / 东方财富 📰」✓

待办/遗留:

  • 单源存量 point 无 sources 字段(展示层兜底 source_id,行为不变,如需统一可后续补)
  • P1-2(补跑 steps 含 cninfo)、P1-3(时区一致性),待用户决策

本次完成 (2026-08-22) — P1-1 修复 dedup 高重复率返回码语义

问题:run_dedup 重复率 > 5% 时返回 1;scheduler/pipeline.py 曾把 dedup 的 rc=1 无条件视为成功并打日志"无新数据场景"。结果:高重复率(可能是正常无新数据,也可能是抓取源/指纹库异常)被一刀切掩盖,真实异常无法上报。

修复(分离"执行成功"与"统计告警"):

  • scripts/run_dedup.py:
    • 生产模式(默认):重复率仅作 WARNING 告警,不影响退出码(执行成功即 0);阈值常量 _DUP_RATE_THRESHOLD = 0.05
    • 新增 --strict 验收模式:保留 M3 验收门槛,重复率 > 5% 时返回 1(供人工验收)
    • 新增统计快照 data/deduped/{date}/stats.json(原子写):unique/duplicates/total/dup_rate/layers/fingerprint_total/generated_at,供运维排查与监控
    • 顺带修复:空日场景 sources.json 写入前补 mkdir(原会 FileNotFoundError)
  • scheduler/pipeline.py:移除 dedup rc=1 特判,恢复"非 0 即失败"的统一语义

测试:

  • 新增 tests/test_run_dedup.py 7 个(生产模式高重复返回 0 / --strict 高重复返回 1 / --strict 低重复返回 0 / 空日返回 0 / stats.json 结构与数值 / pipeline 不再掩蔽失败)
  • 全量 266 passed,3 个 crawler 基线失败与本次无关

生产实测:同日重跑重复率 100% → 生产模式 rc=0 + WARNING + stats.json 完整;--strict 同场景 rc=1(用非管道方式核实退出码)

待办/遗留:

  • P1-2(守护进程补跑 steps 含 cninfo 三步)、P1-3(调度时区与 date.today() 一致性),待用户决策

本次完成 (2026-08-22) — P0-3/P0-4 补跑日期语义修复

P0-3(crawler 补跑历史日期静默空转):

  • 原因:crawler/storage.py 落盘硬编码 date.today()(engine 不传 day),pipeline --once --date {历史} 时下游读历史目录为空,且 run_extractor 对空目录返回成功 → 全链路静默空转
  • 修复:run_step() 开头加补跑保护——date_str != 今天 时跳过 crawler(首页只含当天内容,历史文章已滚走,补抓不可能),WARNING 日志 + tail_msg 说明,返回 success(补跑复用已有 raw 数据是正确行为);xwlb 不跳过(API 支持任意历史日期)

P0-4(report 硬编码 date.today() 忽略传入日期):

  • 原因:run_step("report") 未使用入参 date_str,取当前时间;补跑历史日期+--report 会生成今天的空日报并覆盖当天已有日报(唯一键 file_name='')
  • 修复:一行改动 report_date = date_str;reporter.generate_report(day_str) 本身已支持任意日期,无需其它改动

测试:

  • 新增 4 个(补跑跳过不执行子进程/当天正常执行/report 收到传入日期/全链路仅 crawler 跳过),并入 tests/test_incremental.py
  • 修正 3 个既有测试(tests/test_scheduler.py)适配新行为(用当天日期测正常执行路径)
  • 全量 259 passed,3 个 crawler 基线失败与本次无关

待办/遗留:

  • P1-1(dedup rc=1 静默转成功) 已修复(见上方小节);余:守护进程补跑 steps 含 cninfo_*、调度时区与 date.today() 一致性,待用户决策
  • 既有小问题:tests/test_scheduler.py 部分 run_pipeline 测试未传 state_path,会写真实 data/pipeline/state.json(既有行为,未改)

本次完成 (2026-08-22) — xwlb 日期错位修复(方案 A)

背景问题:

  • P0-1: run_xwlb 硬编码抓前一天并落盘到「数据日」目录,而下游 extractor 按「处理日」扫目录 → 60 天联播数据从未进入知识库(实测: data/processed/xwlb/ 仅 20260622 一天)
  • P0-2: run_xwlb 用 open("a") 裸追加,同一天被多次调度触发导致 index.jsonl 重复行(实测 20260821: 70 行仅 24 唯一)

业务约束(保持不变): 当天日报需要前一天晚上(19:00 播出)的新闻联播内容。

方案 A 实施:

  1. scripts/run_xwlb.py 重构:
    • 新增 --date 参数(处理日,默认今天),与管道其它步骤日期语义一致
    • 内部 数据日 = 处理日 - 1(业务约束:抓前一晚已播出的联播)
    • 落盘目录改为处理日 data/raw/xwlb/{处理日}/ → extractor/dedup/llm/embedding/qdrant 零改动即可处理
    • 幂等落盘: 落盘前读已有 index.jsonl 构建 url_hash 集合,已存在条目跳过;index.jsonl 重写为去重完整集(原子写),不再裸追加
  2. scheduler/pipeline.py: xwlb 步骤命令加 --date {date}
  3. scripts/run_extractor.py: xwlb 假 HTML 无时间节点 → 新增 _fill_xwlb_publish_time,从 url(xwlb://YYYY-MM-DD/sid)兜底解析真实播出日填充 publish_time(否则检索时间过滤/排序失效)
  4. 历史死数据 A1 清理: 删除 data/raw/xwlb/ 62 个旧目录(20260622~20260822)+ data/processed/xwlb/(仅 20260622)

验证(生产端到端,20260822):

  • 新增 tests/test_run_xwlb.py 9 个测试(处理日目录、幂等、空数据、坏日期),全部通过
  • 全量测试 255 passed(3 个 crawler 基线失败与本次无关)
  • 实跑: 处理日=20260822 抓数据日 20260821 联播 23 条 → 二次运行新增 0/跳过 23(幂等)→ M2 提取 23/23 → M3 唯一 23 → M4 LLM 23/23 → M5 23/23 → M6 Qdrant 净增 23
  • 检索验证: a-share search "国务院常务会议" top2 命中 xwlb《李强主持召开国务院常务会议》,时间 2026-08-21(真实播出日)✓
  • 日报路径不受影响(仍走 reporter._collect_xwlb API 直读)

运维规则新增: 每次代码升级完成后必须执行 sudo systemctl restart a-share-research.service

待办/遗留:

  • P0-3(crawler 无 --date,补跑历史日期静默空转)、P0-4(report 硬编码 date.today()) 已修复(见上方小节)
  • P1(dedup rc=1 静默/补跑含 cninfo/时区)未修,待用户决策
  • run_extractor --force 重提取会重复追加 data/processed/{src}/{date}/index.jsonl(仅影响该辅助索引,下游读 *.json 不受影响),暂不修
  • 历史 60 天联播未入库(按用户决策 A1 清理,不回补)

本次完成 (2026-08-22) — 文档清理与重构

目标: 清理历史上多个 AI agent 文档残留,重构项目文档为 4 个核心文件。

删除的过时/残留文件 (4 个):

  1. project_plan.md (700 行) — 9 个 Milestone 全部完成,计划文档已无价值
  2. docs/agent_prompt.md (80 行) — Cherry Studio Agent 使用说明,非本项目核心文档
  3. docs/optimization_plan.md (144 行) — 8 项优化中 5 项已完成,剩余不再跟踪
  4. docs/report_db_design.md (330 行) — M10 中间设计稿,逻辑已下沉到代码

新建/重写的文档 (5 个):

文件 行数 说明
docs/architecture.md ~500 项目架构:11 包职责与导出 API、核心数据模型、配置体系、产物目录
docs/user-guide.md ~500 用户手册:15 个 CLI 子命令全量 + 增量/断点续跑 + MCP + systemd + FAQ
README.md ~120 精简为项目入口:简介 + 文档索引 + 命令速查
docs/db-schema.md 保留 日报表结构(API/前端对接,无需改动)
deploy/README.md 删除 已在服务器上直接修改,无需部署文档

docs/ 最终目录:

docs/
├── architecture.md    # 项目架构(新增)
├── user-guide.md      # 用户手册(重写)
└── db-schema.md       # 日报表结构(保留)

验证:

  • 所有文档中文撰写,Markdown 格式,层次清晰
  • architecture.md 准确反映 11 个包的实际公开 API(基于源码 __init__.py 导出)
  • user-guide.md 覆盖全部 15 个 CLI 子命令(crawl/extract/dedup/events/embed/ingest/pipeline/search/status/report/report-import/cninfo/stock-report/watchlist/discover/add-entry)
  • 无过时信息残留(不再提 M9 Agent/HTML 日报/旧版 Mac 部署等)

待办:

  • git 提交本次改动(包括 2026-08-12 增量处理改动,仍未提交)
  • 同步 pi5(代码 + 文档),重启 a-share-research


当前状态

项目 值
新闻源 14 个(13 Web + 1 API: xwlb 新闻联播)
Qdrant 本地文件模式 data/qdrant_storage/
日报 M10 完成并已部署 pi5: 结构化入库 MySQL;日报按当天日期生成(新闻 30h 回溯 / xwlb 取前一日 / 公告调研近 15 日)
DB 连接 pi 上 systemd 服务 a-share-db-tunnel 常驻(0.0.0.0:13306 → doorcome.cn:3306);pi5 直连 192.168.1.10:13306
调度器 APScheduler,systemd a-share-research.service(pi5);每天 07:00 首次任务生成日报(12/18/22 点不生成)
LLM 场景化配置 configs/llm_models.yaml(4 场景: event_extraction/daily_report/stock_report/embedding);YAML 优先、.env 兜底;模型必须显式配置,无内置兜底
去重 多源记录:指纹库 source_ids 列 + uniques JSON sources 字段 + data/deduped/{day}/sources.json
增量/断点 M2/M4/M5 产物存在即跳过(--force 全量);pipeline --once --resume 断点续跑(状态 data/pipeline/state.json)
服务器 pi@192.168.1.160(生产)/ pi@192.168.1.10(DB 隧道宿主)
抓取方式 js_render=false → httpx 直连;js_render=true → Playwright

本次完成 (2026-08-12) — 增量处理与 pipeline 断点续跑

目标: ① 全链路中断后可从断点恢复;② 各子任务排除已处理文件,避免全量重跑与重复 API 计费。

1. 各步骤增量处理(产物存在即跳过,--force 全量):

  • M2 run_extractor.py:输出目录已有 {url_hash}.json 即跳过提取,仅回补 index 行;--force 重建;成功率统计含跳过项(修复全跳过时误报 rc=1)
  • M4 run_event_extraction.py:data/events/{day}/{url_hash}.json 已存在即跳过(不重复调用 LLM API);--force 全量;failed.jsonl 只保留本次失败、index 累积追加
  • M5 run_embedding.py:data/embeddings/{day}/{url_hash}.json 已存在即跳过(不重复调用 embed API);--force 全量
  • M1(seen_urls 增量)/ M3(指纹库判重)/ M6(upsert 幂等)为既有能力,README 汇总成表

2. pipeline 断点续跑(scheduler/pipeline.py + run_scheduler.py):

  • 新增 data/pipeline/state.json(按日期隔离,记录每步骤 ok/failed + 退出码 + 耗时),原子写
  • run_pipeline(resume=True) 跳过连续成功前缀,从首个失败/未执行步骤继续执行到结尾
  • pipeline --once --resume(默认全量不变;--resume 与 --steps 互斥报错);定时守护模式不受影响

验证(Mac 本地,20260616 数据 100 篇):

  • pytest 225 passed(新增 tests/test_incremental.py 10 个:M2/M4/M5 跳过、状态记录、resume 续跑、resume 全完成 noop、--resume+--steps 互斥);crawler 3 个基线失败仍与本次无关
  • ruff 零新增(9 个基线错误不变)
  • 端到端:M2 增量重跑 140 条跳过 126 条,2.5s 完成、rc=0(修复前误报失败);state.json 正确记录 extractor ok

效果评估(100 篇规模中断重跑场景):

  • M4 减少约 100 次 LLM API 调用、M5 减少约 100 次 embedding API 调用 → 中断恢复不再重复计费,耗时从分钟级降至秒级
  • M2 重跑从全量 GNE 提取(分钟级)降至约 2.5s
  • 断点恢复操作:中断后直接重跑同一条 pipeline --once --resume 命令即可

待办:

  • 同步 pi5(代码 + 文档),重启 a-share-research;首次同步后 pi5 的 data/pipeline/state.json 不存在 → resume 按全量处理,行为安全
  • git 提交(本次改动尚未提交)

本次完成 (2026-08-05) — 日报可靠性修复与取数逻辑优化

目标: 解决日报 AI 摘要偶发失败;修正日报日期与 xwlb/新闻取数语义。

1. AI 摘要可靠性(llm/client.py + scheduler/reporter.py):

  • 去掉内置默认模型兜底(deepseek-chat/qwen-plus),模型必须显式配置(DEEPSEEK_MODEL/QWEN_MODEL → LLM_MODEL),缺失即报错,避免静默用错模型
  • _llm_call 增加指数退避重试:_LLM_RETRY_TIMES(默认 3 次)、_LLM_RETRY_BACKOFF_SEC(默认 2.0s,可 .env 覆盖),全部失败才抛异常
  • 确认 AI 摘要模型:deepseek + deepseek-v4-flash(生产实测)

2. 日报取数逻辑(scheduler/pipeline.py + reporter.py):

  • pipeline report 步骤:report_date = date.today()(原为昨天+回溯 3 天)
  • _collect_news_events:读当天+前一天事件目录,按 publish_time 过滤最近 30 小时(_NEWS_LOOKBACK_HOURS=30);时区统一(naive 假定本地时区);无时间戳事件保留
  • _collect_xwlb:固定查 day_str 前一日(《新闻联播》19:00 播出,早间日报只能取昨晚已播出的一期);source_date 同步为实际来源日
  • 公告/调研/互动保持原设置:近 15 日(STOCK_REPORT_DAYS=15),irm 互动仍跳过——未受 30h 改动影响

验证(pi5):

  • 单测 9 个(30h 回溯/带时区时间戳/重试/模型缺失报错/xwlb 前一日)全部通过;全量 212 passed + 3 crawler 预存在失败
  • 生产端到端 report_id=191(2026-08-05):news 20 + cninfo 20 + xwlb 34(08-04 联播),AI 摘要 2076 字
  • 生产服务已重启生效

本次代码尚未 git 提交(见待办)。


本次完成 (2026-08-03 22:00) — pi5 部署与生产测试

操作: M10 代码全量同步 pi5 + 生产环境验收(所有测试在 pi5 执行,Mac 不再作为测试环境)。

文件同步: rsync 本地 → pi5:/home/pi/news/(排除 .venv/data/logs/.git/.env/configs/*.yaml);清除 pi5 根目录 6 月 17 日旧版散文件(已 tar 备份 /tmp/news_backup_20260803.tar.gz);pi5 uv sync 装 pymysql。

DB 隧道修复: pi 原 autossh 参数 -L 13306:0.0.0.0:3306 未生效(0.0.0.0 被当远端目标),改为 -L 0.0.0.0:13306:127.0.0.1:3306 并持久化为 systemd 服务 a-share-db-tunnel(enabled + active)。

测试结果(pi5):

  • 全量 pytest:208 passed, 3 failed(3 个失败均为 crawler retry mock 预先存在问题,与 M10 无关)
  • report-import 幂等:已存在文件正确 skipped
  • 生产端到端:a-share report --date 20260802/20260803 → report_id=181/182 入库成功(AI 摘要正常,57/58 事件)
  • DB 总量:finance 52 + intl 128 = 180 行(历史 177 + 端到端测试 2 + 本机 1)
  • 生产服务 a-share-research 已重启 active,22:00 定时任务起用新代码

已知问题:

  • tests/test_crawler.py 3 个 retry 测试失败(预先存在,crawl4ai mock 行为)
  • Mac 本机 anaconda/uv python 出站到 192.168.1.10 被拦截(EHOSTUNREACH;nc/bash/系统 python 正常)——仅影响本机,pi5 不受影响;Mac 本地用 127.0.0.1 + ssh 隧道绕过
  • 生产 pi5 的 .env 里 NEWS_DB_HOST=192.168.1.10(直连);Mac 本地 .env 为 127.0.0.1(隧道)——两处 .env 不同,勿互相覆盖

本次完成 (2026-08-03) — M10 日报结构化入库

目标: 日报前后端分离的数据层——日报内容结构化存入 MySQL(news_ 前缀表),历史 178 份日报 HTML 解析入库;本项目不做 API/前端(用户另行实现)。

表结构(myquant 库,见 docs/db_schema.md):

  • news_report:主表,唯一键 (report_date, report_type, file_name);file_name='' 表示新生成日报(每天每类型一行,重复生成覆盖)
  • news_event:事件明细,section ∈ xwlb/news/cninfo/intl

新增/修改文件:

  • report_db/(models/schema/db):连接、建表、幂等 save_report
  • report_import/(parser/importer):历史 HTML 解析(表头驱动列映射)+ 批量导入
  • scheduler/reporter.py:generate_report() 完全切换为结构化入库;_render_html/_upload 标记废弃保留
  • a_share_cli/main.py:新增 report-import 子命令;report 命令改为入库提示
  • pyproject.toml:uv add pymysql(唯一新增依赖)
  • .env:新增 NEWS_DB_*(连接 127.0.0.1:13306,需 ssh 隧道)、REPORT_HISTORY_DIR
  • docs/report_db_design.md(实现逻辑)、docs/db_schema.md(表结构,供 API/前端)

验证结果:

  • 历史 178 份全部入库:scanned=178 imported=7(首轮)+170 skipped=171 failed=0;DB 177 行(finance 49 + intl 128,1 个跨目录同名文件被幂等合并)+ 事件 4222 条
  • 端到端:a-share report --date 20260616 → report_id=180 入库成功,无 HTML 产出
  • 单测 24 个通过(parser 19 + builder 4 + models/import 补充)

命令:

ssh -L 13306:127.0.0.1:13306 pi        # Mac 开发连库隧道
uv run a-share report --date YYYYMMDD # 生成日报入库
uv run a-share report-import          # 历史导入(幂等)

注意: 本机 .env 无 LLM API key,AI 摘要会降级 WARNING(不影响入库);生产 pi5 需配置 NEWS_DB_PASSWORD 且解决 13306 隧道可达性(见待确认项)。


历史 (2026-07-17) — 禁用个股日报

操作: STOCK_REPORT_TIME= 设为空,run_scheduler.py 加空值守卫。

文件:

  • scripts/run_scheduler.py L127-141:STOCK_REPORT_TIME 为空时跳过注册并输出 已禁用个股日报
  • Pi .env:STOCK_REPORT_TIME=08:00 → STOCK_REPORT_TIME=

恢复方法: 改回 STOCK_REPORT_TIME=08:00,重启 a-share-research。


本次完成 (2026-07-13) — eeo 反爬修复 + 静态直连 + 超时保护

问题

eeo(经济观察网)在 Pi 上用 Playwright 连续抓取后触发反爬,服务器故意不响应导致 page.goto() 超时(30s → 60s 均超时)。而在本机 Mac 上一切正常——反爬是 IP 级别的,Pi 的 IP 已被限流。

根因

eeo 是纯静态页面(js_render: false),但 Crawl4AI 始终走 Playwright。eeo 检测到 headless Chrome 的连续请求模式后,对后续请求挂起 TCP 连接直至超时。

修复

crawler/engine.py — 三个改动:

  1. 新增 _fetch_static():js_render=False 且无 wait_for 的源用 httpx 直连,绕过 Playwright 反爬。HTML → Markdown 用 markdownify + BeautifulSoup 清洗。

  2. 新增 _TimeoutGuard:同源连续超时 2 次后自动跳过剩余请求,防止整个 pipeline 被单一故障源拖死。

  3. _crawl_once 分流逻辑:

    js_render=False 且无 wait_for → _fetch_static() (httpx)
    其他 → Playwright
    

configs/sources.yaml — eeo 源两处调整:

  • 删除 wait_for: "css:body"(静态页面无需等待 CSS 选择器)
  • page_timeout_ms: 30000 → 60000(恢复 Crawl4AI 默认值)

测试结果

环境 Playwright (修复前) httpx (修复后)
Mac 本地 全部通过 36 篇 / 7 秒
Pi 服务器 前 3 个请求全部 15s 超时 36 篇 / 9 秒,0 失败

验证方法

Pi 上本地复现了反爬(Playwright 前 3 个 URL 全部超时),同批 URL 用 httpx 全部瞬间返回——证明方案有效。


历史 (2026-06-24)

1. 环境变量全面审计与修复

触发: 服务器 dedup 步骤超时 120s。

修复 6 个文件:

# 文件 修改
1 scheduler/pipeline.py 超时解析重写:TIMEOUT_{NAME} > PIPELINE_STEP_TIMEOUT > 硬编码 > 1800s
2 a_share_cli/main.py 新增 _resolve_timeout(),CLI 命令超时改为读环境变量
3 crawler/cninfo.py CNINFO_PDF_BASE 从硬编码改为 os.environ.get()
4 scheduler/reporter.py STOCK_REPORT_DAYS 默认值 7→15;max_tokens 600→1500
5 .env 清除 10 个死配置;EMBEDDING_MODEL→LOCAL_EMBEDDING_MODEL;补全遗漏参数
6 .env.example 重写为仅包含实际生效配置

审计结果:

类别 数量 处理
✅ 正常工作 23 —
❌ 死配置 10 已清除
⚠️ 默认值不一致 1 已统一
🔧 硬编码 1 已修复

2. AI 摘要截断修复

触发: 日报 AI 摘要内容极少(137 字),末尾被截断。

根因: _llm_call() max_tokens=600 太低,deepseek-v4-flash 输出被硬截断。

修复: max_tokens 三处提升 + 截断检测日志。

位置 修复前 修复后
_llm_call() 默认 600 1500
分块摘要 400 800
合并摘要 600 1500

验证: 手动生成日报,摘要从 137 字 → 486 字,结构完整无截断。


超时优先级(最终设计)

TIMEOUT_{NAME} 环境变量          ← 单步精确控制
    ↓ 未设
PIPELINE_STEP_TIMEOUT 环境变量   ← 全局兜底 (覆盖所有硬编码)
    ↓ 未设
STEP_TIMEOUTS 硬编码字典         ← 代码内默认值
    ↓ 步骤不在字典中
1800s                            ← 最终兜底

服务器 .env 当前配置

# ---- 调度 ----
SCHEDULE_TIMES=07:00,12:00,18:00,22:00
CNINFO_SCHEDULE_TIME=06:00
STOCK_REPORT_TIME=08:00
STOCK_REPORT_DAYS=15

# ---- Pipeline 超时 ----
PIPELINE_STEP_TIMEOUT=3600
TIMEOUT_CRAWLER=900
TIMEOUT_XWLB=60
TIMEOUT_EXTRACTOR=300
TIMEOUT_DEDUP=900
TIMEOUT_LLM=900
TIMEOUT_EMBEDDING=900
TIMEOUT_QDRANT=1800
TIMEOUT_CNINFO_CRAWL=900
TIMEOUT_CNINFO_EXTRACT=900
TIMEOUT_CNINFO_PDF=900

调度器

时间 步骤
06:00 cninfo 公告管道
07:00 crawler→xwlb→extractor→dedup→llm→embedding→qdrant→日报
08:00 个股日报
12:00 crawler→xwlb→extractor→dedup→llm→embedding→qdrant
18:00 crawler→xwlb→extractor→dedup→llm→embedding→qdrant
22:00 crawler→xwlb→extractor→dedup→llm→embedding→qdrant

常用命令

# 全链路 + 日报
uv run a-share pipeline --once --report

# 仅日报 (指定日期)
/home/pi/.local/bin/uv run python -m scripts.run_scheduler --once --date 20260623 --steps report

# 检索
uv run a-share search "关键词" --top 5

# 同步到服务器
scp <file> pi@192.168.1.160:/home/pi/news/<path>/

# 重启服务
ssh pi@192.168.1.160 "sudo systemctl restart a-share-research"

已知技术债务

问题 文件 状态
QdrantClient HTTP 超时硬编码 10s vectorstore/client.py:93 无可配环境变量
DashScope HTTP 超时硬编码 60s embedding/remote.py:81 无可配环境变量
超时解析逻辑在 pipeline.py 和 main.py 各一份 两处 可抽取公共函数
deepseek-v4-flash 概率性返回空 — 分块处理 + 失败跳过
wallstreetcn JS 渲染拦截 — 正文提取率 0%
eeo 列表页仍走 Playwright(仅文章页走 httpx) crawler/engine.py 列表页目前正常,暂不改动

架构备忘:静态/动态抓取分流

SourceConfig.js_render == False 且 wait_for == None
  → _fetch_static() → httpx 直连 (无浏览器, 快, 不被反爬)
  → markdownify + BeautifulSoup (清洗 script/style 后转 Markdown)

SourceConfig.js_render == True 或 wait_for 有值
  → _crawl_once() → Playwright (现有逻辑)

_TimeoutGuard:

  • 同源连续超时 2 次 → skip=True
  • 后续请求在 semaphore 内检查 guard.skip,立即返回 Skipped
  • 一次成功即重置计数器

记忆

  • never-change-llm-model.md — 绝不允许修改大模型配置
  • deploy-html.md — user_guide.html 同步到 Pi 和 Web 服务器
  • sync-sources-yaml.md — 修改 sources.yaml 前从 Pi 拉取,改完推回
  • update-user-guide.md — 更新 md+html+部署