14 KiB
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 计算下次触发时间,行为等价于生产模式但共享单进程。
数据流
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