Files

247 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`