feat: 军事科技每日资讯推送系统 - Docker部署 + 日志系统 + 数据目录重组

This commit is contained in:
poiuy
2026-07-12 20:01:02 +08:00
commit 54ca4b1b6a
267 changed files with 47047 additions and 0 deletions
+246
View File
@@ -0,0 +1,246 @@
# tech-spec.md - 技术架构总览
> 本文档面向未来的 AI Agent 与核心开发者,目的是在阅读源码之前快速建立对项目的整体认识:项目定位、运行模式、核心数据流、模块边界、部署形态、关键约束。
>
> **本文写什么**:架构层面的"是什么、为什么、边界在哪里";跨模块的数据流和契约;部署/运行环境约束;不读源码就无法获知的设计决策。
>
> **本文不写什么**:源码摘录、函数签名、字段清单、命令行帮助、变更日志、UI/文案。这些信息以源码、`README.md`、`docs/plan.md`、`config.json` 为准。
>
> **维护原则**:当架构边界、数据流、运行环境、部署形态或核心设计决策发生变化时同步更新本文;普通实现调整、字段增删、文案修改不在维护范围内。
>
> update: 2026-05-17
## 项目定位
AI 驱动的 RSS 新闻聚合与推送系统:周期性抓取 400+ AI 领域信息源,调用 LLM 评分筛选,按调度规则将高分内容汇总推送到 Discord / 飞书;高分热点条目在 fetch 阶段即时推送。
面向单机部署、单租户使用,所有状态以本地文件(JSON / Markdown)持久化,不依赖外部数据库或队列。
## 运行模式
| 模式 | 触发方 | 适用场景 |
|------|--------|----------|
| **生产**(推荐) | systemd timer 分别触发 `fetch``push` 单次任务 | 服务器长期运行,依赖 systemd 提供调度、重启、开机自启 |
| **开发** | `loop` 子命令在单进程内并发跑 fetch/push 双循环 | 本地调试,无需 systemd |
CLI 子命令分工(详见 `python -m src.main --help`):
- `check`:**唯一**的 LLM 健康检查入口,仅在部署期由 `install.sh` 调用
- `fetch` / `push`:单次执行后退出,由 systemd timer 触发;运行期不再做 LLM 健康检查,异常由统一的告警通道兜底
- `loop`:开发模式,启动时做一次健康检查,然后并发跑 fetch/push 循环
- `github` / `hackernews`:单板块手动调试入口;只跑对应板块(含 LLM 总结),打印 markdown 到终端,**不**推送、**不**写入 push 文件。仅供 prompt 调优期使用
关键约束:`fetch` / `push` 失败时进程退出码非 0,systemd 据此判定 service 失败,下个 timer 周期自动重试。
## 核心架构
### 调度模型(生产)
```
┌─────────────────────────────────────────────────────────────┐
│ config.json │
│ schedule.fetch_interval_minutes → dnews-fetch.timer │
│ schedule.push_cron → dnews-push.timer │
│ log.retention_days → journald@dnews retention│
└────────────────────────┬────────────────────────────────────┘
│ scripts/install.sh 渲染并安装
┌────────────┴────────────┐
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ dnews-fetch.timer │ │ dnews-push.timer │
└─────────┬─────────┘ └─────────┬─────────┘
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ fetch.service │ │ push.service │
│ 抓取+评分+热点推送│ │ 收集+汇总+推送 │
└─────────┬─────────┘ └─────────┬─────────┘
└────────────┬────────────┘
┌─────────────────┐
│ news-data/ │
│ fetch-*.json │
│ push-*.md │
│ notify-*.md │
└─────────────────┘
```
开发模式(`loop`)以 `asyncio.gather(fetch_loop, push_loop)` 并发运行两条循环,`push_loop` 通过 croniter 计算下次触发时间,行为等价于生产模式但共享单进程。
### 数据流
```mermaid
flowchart LR
subgraph Sources ["📡 RSS Sources"]
RS1[Twitter/X]
RS2[博客 / 媒体]
RS3[微信公众号]
end
subgraph Fetch ["🔄 fetch job (周期触发)"]
F1[抓取 RSS] --> F2[HTML→Markdown] --> F3[LLM 批量评分]
F3 --> HOT{score ≥ hot_threshold?}
HOT -->|是| IP[即时推送 + notify-*.md]
end
subgraph Push ["📅 push job (cron 触发)"]
P1[读取近 N 天 fetch + 历史 push 上下文] --> P2[LLM 汇总去重] --> P3[生成 push-*.md] --> P4[推送 Discord / 飞书]
end
subgraph Storage ["💾 news-data/"]
DB[(JSON / MD)]
end
Sources --> F1
F3 --> DB
IP --> DB
DB --> P1
P3 --> DB
```
**关键数据契约**
- `fetch-YYYY-MM-DD.json`:当日抓取与评分结果(含 score / summary / tags / content
- `push-YYYY-MM-DD.md`:汇总推送内容(YAML frontmatter + Markdown 正文),同时作为下一次 push 的去重上下文
- `notify-YYYY-MM-DD.md`:即时推送记录,作为 LLM 即时推送时的去重上下文
具体字段以源码 `src/storage.py` 与样例文件为准,README "数据示例" 章节给出了一份示例。
## 关键模块边界
```
src/ 运行时代码
├── main.py CLI 入口;定义 fetch_job / push_job / loop 的编排顺序
├── config.py 加载 config.json,合并 OPML + add/block,做配置校验
├── fetcher.py RSS 抓取;并发控制、UA 伪装、域名通配符屏蔽;nitter/xcancel 走独立的 requests+Inoreader UA 低并发池
├── processor.py HTML → Markdown 转换
├── llm.py LLM 客户端;批量评分、即时推送生成、汇总生成、错误聚合
├── storage.py news-data 文件读写;按日期分片;过期清理
└── push/ 推送平台抽象
├── base.py PushPlatform 基类(validate_config / send)
├── discord.py
└── feishu.py
scripts/ 部署脚本(仅生产 systemd 部署使用)
├── install.sh 一键安装:uv sync → LLM check → 渲染单元 → 装入系统 → 启用
├── uninstall.sh 卸载 systemd 单元、daily-news 包装脚本和日志 drop-in;不删数据
├── status.sh 查看 timer/service 状态(daily-news status 包装它)
├── _gen_units.py 从 config.json 渲染 systemd 单元和 daily-news 包装脚本
└── daily-news.tmpl /usr/local/bin/daily-news 的脚本模板,封装 systemctl/journalctl
systemd/ systemd 单元模板(由 _gen_units.py 渲染并装入 /etc/systemd/system/)
├── dnews-fetch.service.tmpl fetch service 单元模板
├── dnews-fetch.timer.tmpl fetch 定时器(OnUnitActiveSec 间隔触发)
├── dnews-push.service.tmpl push service 单元模板
├── dnews-push.timer.tmpl push 定时器(OnCalendar 日历触发)
└── journald-dnews.conf.tmpl journald 命名空间 dnews 的日志保留 drop-in
config.json 主配置;运行参数 + 调度 + LLM + 推送渠道;唯一可热改的运行配置
prompts/ LLM 提示词文本;score / immediate_push / digest 各自独立文件
resources/rss.opml 基础 RSS 订阅源(约 420 个),通过 sources.add/block 增量调整
.env 敏感凭证(API Key / Webhook URL),通过环境变量注入,不入库
```
### 板块化扩展 (morning push)
早报推送在 RSS 之上扩展三个板块:GitHub 趋势 / Hacker News 热议 / 跨板块洞察。模块结构、数据流与失败降级详见 `docs/extra-sections-design.md`。架构层关键约束:
- 仅在当天 `schedule.push_cron` 列表里最早那次触发时启用(单条 cron 时每次都启用),其余时段维持纯 RSS 行为
- 各板块封装为 `src/sections/<board>/section.py::run_xxx_section(config, now) -> (markdown, error)`
- `push_job``asyncio.gather` 并发跑 RSS / GH / HN,串行接 insights;最后用 `<!-- SECTION:xxx BEGIN/END -->` sentinel 包入 push 文件
- 仅 RSS 失败会让 push_job 整体退出非 0;其余板块失败 → 板块整段省略 + 告警
新增持久化文件:`news-data/trending-history.json`(GH 已查阅 repo 索引,按 `filter.keep_days` 过期)
模块协作的关键约定:
- **fetch 与 push 之间通过文件系统解耦**:双方不直接通信,push 只读 fetch 已写入的 JSON
- **LLM 调用的错误处理由调用方决定**:`llm.py` 不做 fallback,失败时返回 `(空内容, 错误列表)`;调用方决定是否告警或跳过推送,避免一个批次失败污染整次任务
- **批量评分按 `link` 字段对齐**:LLM 返回的条目数可能少于输入,按 link 匹配并丢弃无法对齐的结果,错误聚合后由调用方统一上报
- **推送平台通过基类多态**:新增平台只需实现 `validate_config()``send()`,并在工厂函数注册,main.py 无需改动
## 数据边界与持久化
- 所有持久化数据落在项目根目录的 `news-data/`:按日期分片的 `fetch-*.json` / `push-*.md` / `notify-*.md`
- 过期文件由 fetch job 在每次执行后清理,保留窗口由 `filter.keep_days` 控制
- 没有数据库、没有外部缓存、没有跨机器同步;状态完全可由文件系统重建
- 敏感信息(API Key、Webhook URL)只通过环境变量注入,禁止写入 `config.json` 或代码
## 配置与约束
完整配置字段说明见 `README.md` 的"配置详解"章节,本文只列出对架构有影响的约束。
### schedule
| 字段 | 约束 |
|------|------|
| `fetch_interval_minutes` | systemd 部署下用 `OnUnitActiveSec` 实现,从上次任务**完成**开始计时(非日历对齐) |
| `fetch_lookback_minutes` | 必须大于 `fetch_interval_minutes`,用作 RSS 延迟的冗余窗口,依赖 link 去重防止重复入库 |
| `push_cron` | systemd 部署下**只支持 minute/hour 字段**,其他位必须为 `*`;不支持范围、列表、`*/N``loop` 模式下走 croniter,支持完整语法 |
| `timezone_hours` | 整数小时偏移;用于显示和 cron 计算 |
### log
`log.retention_days` 仅对 systemd 部署生效,由 `install.sh` 渲染到 journald 命名空间 `dnews` 的 drop-in 配置;修改后必须重跑 `install.sh`
### LLM
`llm.max_prompt_chars` 决定批次切分粒度,`llm.max_concurrent_batches` 决定批次并发数。这两个值同时影响吞吐和单次推送的成本上限。
### 环境变量
敏感凭证名通过 config.json 的 `*.apiKeyName` 字段指定环境变量名,由 `os.environ` 读取;约定通过 `.env` 提供,systemd 部署时 install.sh 会注入到 service 单元的 `EnvironmentFile`
## systemd 部署形态
### 文件落点
| 文件 | 位置 | 来源 |
|------|------|------|
| `dnews-{fetch,push}.{service,timer}` | `/etc/systemd/system/` | `systemd/*.tmpl``_gen_units.py` 渲染 |
| `journald@dnews` retention drop-in | `/etc/systemd/journald@dnews.conf.d/` | `systemd/journald-dnews.conf.tmpl` |
| `daily-news` 包装脚本 | `/usr/local/bin/` | `scripts/daily-news.tmpl` |
| 持久化数据 | 项目目录下的 `news-data/` | 运行时生成 |
### cron → OnCalendar 转换
`scripts/_gen_units.py` 完成:
- `fetch_interval_minutes``OnActiveSec` + `OnUnitActiveSec`(间隔触发,跟随上次完成时间)
- `push_cron``OnCalendar`(日历触发,按指定时刻)
- 不支持的 cron 语法(范围、列表、`*/N` 在 minute/hour、非 `*` 的 day/month/dow)在 install 阶段直接报错
### 日志
- 所有 stdout/stderr 进入 journald 命名空间 `dnews`,与系统其他服务隔离
- 查询:`journalctl --namespace=dnews -u dnews-fetch -f`
- 卸载不会清理历史日志,需要时手动 `journalctl --namespace=dnews --vacuum-time=1s`
## 设计决策(重要的"为什么")
| 决策 | 原因 |
|------|------|
| 调度交给 systemd timer 而非 asyncio 循环 | 进程崩溃和服务器重启可自愈;调度配置即声明式单元,热更新只需重跑 install.sh |
| LLM 健康检查只在 `check` 子命令做 | 每次 timer 触发都校验会产生无意义的 LLM API 调用;运行期错误由 `notify_llm_errors` 兜底 |
| LLM 失败时不生成 fallback 内容 | 避免低质量内容污染推送;由调用方决定告警或跳过 |
| 用 journald 命名空间而非文件日志 | 自动轮转、与系统日志隔离、无需写文件 IO 代码 |
| 数据全用本地文件而非数据库 | 单机单租户场景下足够;可读、可备份、可手动审阅 |
| `fetch_lookback_minutes` 冗余窗口 | RSS 源时间戳常有延迟,仅按时间过滤会漏读;冗余抓取后按 link 去重 |
| Push 上下文带入近 N 天历史 push 文件 | 避免汇总推送在多个时段重复推同一条目 |
## 扩展指南
- **新推送平台**:在 `src/push/` 新建文件,继承 `PushPlatform`,在工厂注册
- **新评分维度**:编辑 `prompts/score.txt`,调整评分标准
- **新 RSS 源**:编辑 `config.json``sources.add` / `sources.block` / `sources.block_domains`,无需修改 OPML
## 测试
测试入口分两类,详细命令参考 `README.md``tests/` 目录:
- `tests/pytest/`:单元/集成测试,CI 友好,`uv run pytest tests/pytest/` 一键跑
- `tests/*.py`:交互式实操脚本(`fetch_news.py` / `push_news.py` / `run_llm_test.py` 等),针对真实 RSS 与 LLM 做端到端验证,用于调参和手测
## 相关文档
- 用户文档与配置详解:`README.md`
- 任务进度与产品决策:`docs/plan.md`