247 lines
14 KiB
Markdown
247 lines
14 KiB
Markdown
# 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`
|