feat: 军事科技每日资讯推送系统 - Docker部署 + 日志系统 + 数据目录重组
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
# extra-sections-design.md — GitHub Trending / Hacker News / 行业洞察 板块设计
|
||||
|
||||
> 本文是对现有 push job 的能力扩展设计。在保持 RSS 主流程不变的前提下,新增三个板块仅在「早报」时段输出:GitHub 趋势、Hacker News 热议、跨板块行业洞察。
|
||||
>
|
||||
> 配套修改的源码模块、配置字段、数据契约见各章节。落地后需要把架构层结论合并回 `docs/tech-spec.md`,运行参数变更同步 `README.md`,进度记录写入 `docs/plan.md`。
|
||||
>
|
||||
> created: 2026-05-16
|
||||
> updated: 2026-05-16(按用户决策重写:四模块独立、单页 GH trending、HN 单选 K=1、insights 结构由 prompt 决定)
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
- 在每天的早报推送中,除了现有的 RSS 精选 digest,再加入:
|
||||
- **GitHub Trending**:当日热门开源项目中筛选 1-3 个 AI 相关项目
|
||||
- **Hacker News**:HN 首页中筛选 1 个最有讨论价值的 AI 相关热议(带评论与外链正文摘要)
|
||||
- **行业洞察**:基于上述三个板块 + 历史 insights 段做一段跨板块趋势小结
|
||||
- 这三段内容**只在早报时段生成**(当天 `schedule.push_cron` 列表里最早那次触发),其余时段维持现有纯 RSS digest 行为
|
||||
- 单板块失败 → 降级推送其他板块,整体任务仍算成功;RSS 失败 → 同现有行为,任务非 0 退出
|
||||
- 不引入数据库;状态全部落到 `news-data/` 的本地文件
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
```
|
||||
push_job (cron 触发)
|
||||
│
|
||||
├─ Step 1: 早报判定
|
||||
│ └─ 否 → 走原有纯 RSS digest 流程,结束
|
||||
│
|
||||
├─ Step 2: 四模块编排(前三路 asyncio.gather 并发,insights 串行后置)
|
||||
│ ├─ run_rss_section(config, now)
|
||||
│ ├─ run_github_section(config, now)
|
||||
│ └─ run_hackernews_section(config, now)
|
||||
│ ↓
|
||||
│ └─ run_insights_section(rss_md, gh_md, hn_md, config, now)
|
||||
│
|
||||
└─ Step 3: push_job 上游
|
||||
└─ sentinel 包裹四段 → 拼装 markdown → 推送 → 写 push-*.md
|
||||
```
|
||||
|
||||
**关键设计选择**:
|
||||
|
||||
1. **模块自治**。每个板块封装为 `run_xxx_section(...) -> (markdown, error)`,板块内部从抓取、enrich、LLM 总结全包;板块互不感知。
|
||||
2. **sentinel 由 push_job 统一包**。模块返回**裸 markdown**(不含 sentinel),由上游 `_assemble_with_sentinels()` 包入 `<!-- SECTION:xxx BEGIN/END -->`。这样模块不需要知道自己的板块标识,便于后续替换或加新板块。
|
||||
3. **失败自吞**。模块内部捕获异常,返回 `("", error)`;上游根据返回值决定是否在最终 push 中省略该段、是否走告警通道。
|
||||
4. **RSS 是核心**。其他三个模块失败都是降级;RSS 模块失败仍按现有行为整体退出非 0。
|
||||
|
||||
**并发模型**:`asyncio.gather`,与项目通体异步风格一致。
|
||||
|
||||
## 3. 模块结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── sections/ ← 新增
|
||||
│ ├── __init__.py # 暴露 run_rss_section / run_github_section / ...
|
||||
│ ├── rss/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── section.py # 把现有 collect_entries_for_push + compose_digest 流程搬入
|
||||
│ ├── github/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── trending_scraper.py # HTML 抓取 + 解析(单页 https://github.com/trending)
|
||||
│ │ ├── repo_enricher.py # GitHub REST API:metadata + README
|
||||
│ │ ├── history.py # trending-history.json 读写 + 过期清理
|
||||
│ │ └── section.py # run_github_section 入口
|
||||
│ ├── hackernews/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── frontpage_scraper.py # HTML 抓首页 30 条
|
||||
│ │ ├── item_enricher.py # Algolia /items/{id} + 外链正文(html_to_markdown)
|
||||
│ │ └── section.py # run_hackernews_section 入口
|
||||
│ └── insights/
|
||||
│ ├── __init__.py
|
||||
│ └── section.py # run_insights_section 入口
|
||||
├── llm.py # 新增 4 个函数(GH/HN select/HN summarize/insights)
|
||||
├── storage.py # 新增:sentinel 切片、trending-history、profile 字段
|
||||
├── main.py # push_job 升级:早报判定 + 四模块编排
|
||||
└── push/ # 不变,平台层无感知
|
||||
|
||||
prompts/ ← 新增 4 个
|
||||
├── section_github.md # 输入 enriched repo 数组 → 选 1-3 + 写 markdown
|
||||
├── section_hackernews_select.md # 30 条 frontpage 元数据 → 选 K 个 id(默认 K=1)
|
||||
├── section_hackernews.md # enriched story → 写 markdown(K=1 时不挑选,只行文)
|
||||
└── insights.md # 输入三段成品 + 近 N 天 insights 段历史 → 写洞察段
|
||||
|
||||
news-data/
|
||||
├── fetch-*.json # 不变
|
||||
├── notify-*.md # 不变
|
||||
├── push-*.md # 内容升级:含 sentinel 与 profile frontmatter
|
||||
└── trending-history.json # 新增:GH 已查阅 repo 索引
|
||||
```
|
||||
|
||||
## 4. 数据契约
|
||||
|
||||
### 4.1 push 文件分段 sentinel
|
||||
|
||||
push 文件保留**完整拼接**写入磁盘和发送各平台,但用 HTML 注释 sentinel 划分板块边界。HTML 注释在 markdown 渲染中不显示,机器可解析,下游做"按段查重"时能精确切片。
|
||||
|
||||
```markdown
|
||||
---
|
||||
pushDate: "2026-05-16T08:00:03+08:00"
|
||||
profile: "morning"
|
||||
sourceCount: 12
|
||||
totalEntries: 12
|
||||
---
|
||||
|
||||
<!-- SECTION:rss BEGIN -->
|
||||
# 📰 AI Daily 每日精选 | 2026-05-16
|
||||
|
||||
*开头一句定调...*
|
||||
|
||||
### 1️⃣ ...
|
||||
<!-- SECTION:rss END -->
|
||||
|
||||
<!-- SECTION:github BEGIN -->
|
||||
## ⭐ GitHub 趋势
|
||||
|
||||
- **owner/repo** ⭐234 — 一句话价值定位
|
||||
<!-- SECTION:github END -->
|
||||
|
||||
<!-- SECTION:hackernews BEGIN -->
|
||||
## 🟧 Hacker News 热议
|
||||
|
||||
### 标题 (120 pts · 45 comments)
|
||||
- 链接: url
|
||||
- 要点:...
|
||||
- HN 讨论: comments_url
|
||||
<!-- SECTION:hackernews END -->
|
||||
|
||||
<!-- SECTION:insights BEGIN -->
|
||||
## 💡 今日洞察
|
||||
|
||||
(行文结构由 prompts/insights.md 决定,代码不强加格式)
|
||||
<!-- SECTION:insights END -->
|
||||
```
|
||||
|
||||
某板块 markdown 为空 → 对应 sentinel 段**整段省略**(不留空标记,不留空 SECTION)。
|
||||
|
||||
### 4.2 分段提取函数(storage.py 新增)
|
||||
|
||||
```python
|
||||
def extract_section(push_md: str, section: str) -> str:
|
||||
"""从 push 文件内容中切出 <!-- SECTION:{section} BEGIN/END --> 之间的 markdown。
|
||||
|
||||
向后兼容:
|
||||
- 新 push 文件(带 sentinel): 按 sentinel 边界切片
|
||||
- 老 push 文件(无 sentinel) 且 section=='rss': 返回整个 body(老文件视为全 RSS)
|
||||
- 老 push 文件且 section in {github, hackernews, insights}: 返回空字符串
|
||||
"""
|
||||
|
||||
def load_recent_section_titles(section: str, days: int, data_dir="news-data") -> str:
|
||||
"""汇总近 days 天 push-*.md 的指定板块,提取标题级别清单(沿用 _extract_push_titles 思路)。
|
||||
|
||||
仅 insights 板块在新增模块中使用本函数加载历史。
|
||||
GH/HN 板块按用户决策不传历史上下文给 LLM,不调用本函数。
|
||||
"""
|
||||
```
|
||||
|
||||
四个 LLM 与历史上下文的关系:
|
||||
|
||||
| LLM 调用 | recent context 数据源 |
|
||||
|---|---|
|
||||
| `compose_digest` (RSS) | 维持现有 `load_recent_push_titles(filter.push_context_days)`(老接口在 sentinel 升级后等价于 `load_recent_section_titles("rss", ...)`) |
|
||||
| `summarize_github_trending` | 不传 |
|
||||
| `select_ai_related_hn` | 不传 |
|
||||
| `summarize_hackernews` | 不传 |
|
||||
| `generate_trend_insights` | `load_recent_section_titles("insights", filter.push_context_days)` |
|
||||
|
||||
### 4.3 trending-history.json
|
||||
|
||||
```json
|
||||
{
|
||||
"repos": {
|
||||
"https://github.com/owner/repo-a": "2026-05-15",
|
||||
"https://github.com/owner/repo-b": "2026-05-12"
|
||||
},
|
||||
"updated_at": "2026-05-16T08:00:01+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
**写入语义**(用户决策的精确语义):
|
||||
|
||||
每次早报触发,按下列顺序处理:
|
||||
|
||||
1. 加载 history,剔除 `last_seen_date < today - filter.keep_days` 的条目
|
||||
2. 抓 trending 页 → 得到 `all_repos`
|
||||
3. 对 `all_repos` 中每个 url:
|
||||
- 若已在 history → `history.touch(url, today)`(更新日期),**从候选移除**
|
||||
- 不在 history → 进入 `candidates`
|
||||
4. 把 `candidates` 中每个 url 也 `history.touch(url, today)` 写入 history
|
||||
5. 持久化 history(覆盖写)
|
||||
6. 对 `candidates`(即今日新出现的 repo)做后续 deep-dive 与 LLM 总结
|
||||
|
||||
效果:repo 在 trending 上挂多久就被屏蔽多久;过 `keep_days` 天没再出现则可重新推荐。
|
||||
|
||||
## 5. 模块详设
|
||||
|
||||
### 5.1 RSS 模块(迁移既有逻辑)
|
||||
|
||||
`src/sections/rss/section.py::run_rss_section(config, now) -> (str, Optional[str])`:
|
||||
|
||||
把现有 `run_push_job` 中"收集 + compose_digest"的部分原样迁过来,返回裸 markdown(不含 sentinel)+ 错误信息。无新增功能。
|
||||
|
||||
### 5.2 GitHub 模块
|
||||
|
||||
`src/sections/github/section.py::run_github_section(config, now) -> (str, Optional[str])`:
|
||||
|
||||
```
|
||||
1. 抓取 trending 单页(HTML)
|
||||
GET https://github.com/trending
|
||||
解析 → all_repos: [{url, full_name, description, language, stars_today, stars_total}]
|
||||
|
||||
2. 加载 history 并清理
|
||||
history = load_trending_history(sections.github_trending.history_file)
|
||||
history.cleanup(keep_days=filter.keep_days)
|
||||
|
||||
3. 候选筛选(按 §4.3 语义)
|
||||
candidates = []
|
||||
for repo in all_repos:
|
||||
if repo.url in history:
|
||||
history.touch(repo.url, today)
|
||||
else:
|
||||
candidates.append(repo)
|
||||
|
||||
4. 候选写回 history + 持久化
|
||||
for repo in candidates:
|
||||
history.touch(repo.url, today)
|
||||
history.save()
|
||||
|
||||
5. 数量护栏
|
||||
if not candidates: return ("", None) # 静默
|
||||
if len(candidates) > max_deep_dive:
|
||||
candidates = candidates[:max_deep_dive] # 截断,默认 10
|
||||
|
||||
6. 并发 deep-dive(REST API)
|
||||
async for repo in candidates:
|
||||
meta = await fetch_repo_metadata(owner, repo) # GET /repos/{o}/{r}
|
||||
readme = await fetch_readme(owner, repo) # GET /repos/{o}/{r}/readme
|
||||
enriched = [{...repo, topics, license, pushed_at, readme_excerpt}]
|
||||
- 单 repo 任一请求失败 → 该 repo 跳过 + 错误聚合,不阻塞其他
|
||||
|
||||
7. LLM:summarize_github_trending(enriched, config)
|
||||
prompt: 候选数组(含 readme_excerpt) → 选 1-max_items + 写 markdown
|
||||
不传 recent_section_titles
|
||||
|
||||
8. 返回 (markdown, error)
|
||||
```
|
||||
|
||||
**REST API 调用细节**:
|
||||
|
||||
| 调用 | 路径 | 取什么 |
|
||||
|---|---|---|
|
||||
| metadata | `GET /repos/{owner}/{repo}` | `description, topics, language, license.spdx_id, pushed_at, stargazers_count, archived` |
|
||||
| readme | `GET /repos/{owner}/{repo}/readme` | `content` (base64) → decode → 截断到 `readme_max_chars` |
|
||||
|
||||
- `archived=true` 的 repo 从候选剔除(trending 偶尔出现僵尸归档项目)
|
||||
- README 截断策略:前 `readme_max_chars` 字符(默认 10000,基于 trending 页 README 长度分布 p50≈25k 选定,详见 §14 决策记录)
|
||||
- 鉴权:`config.sections.github_trending.tokenName`(默认 `"GITHUB_TOKEN"`)对应的环境变量存在时走 `Authorization: Bearer {token}`,否则匿名调用并接受 60 req/hr 上限(日 10 个 repo × 2 calls = 20 calls,匿名安全)
|
||||
|
||||
**enriched repo 字段(喂给最终 LLM)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://github.com/owner/repo",
|
||||
"full_name": "owner/repo",
|
||||
"description": "(来自 trending 页)",
|
||||
"language": "Python",
|
||||
"stars_today": 234,
|
||||
"stars_total": 12340,
|
||||
"topics": ["llm", "rag", "agent"],
|
||||
"license": "MIT",
|
||||
"pushed_at": "2026-05-15",
|
||||
"readme_excerpt": "(前 3000 chars)"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Hacker News 模块
|
||||
|
||||
`src/sections/hackernews/section.py::run_hackernews_section(config, now) -> (str, Optional[str])`:
|
||||
|
||||
```
|
||||
1. 抓首页(HTML)
|
||||
GET https://news.ycombinator.com/news
|
||||
解析 30 条 → front: [{id, title, url, site, points, comments, comments_url}]
|
||||
|
||||
2. 轻 LLM 初筛:select_ai_related_hn
|
||||
输入: 30 条 frontpage 元数据(无正文)
|
||||
输出: K = sections.hackernews.select_k 个 story id(默认 K=1)
|
||||
if K 个为空 → return ("", None) # 静默
|
||||
|
||||
3. 并发 enrich 选中的 K 个 story
|
||||
async for story in selected:
|
||||
- 评论树:Algolia GET /api/v1/items/{id}
|
||||
→ 取前 top_comments 条 L1(默认 30,按 HN ranking)
|
||||
→ 每条 L1 下挂前 top_l2_per_l1 条 L2 回复(默认 3)
|
||||
→ 每条 text 过 html_to_markdown,单条截断到 comment_max_chars(默认 2000)
|
||||
→ 累计达 comments_total_chars(默认 60000) 立即停止,防离群 story 撑爆 prompt
|
||||
→ 输出 tree JSON: [{"l1": "...", "replies": ["...", "..."]}, ...]
|
||||
- 外链正文:
|
||||
if story.url 指向 https://news.ycombinator.com/item?id=... (Show HN/Ask HN):
|
||||
从 Algolia 同次返回的 root.text 字段取(无外部请求)
|
||||
else:
|
||||
GET story.url → html_to_markdown → 截断到 link_content_max_chars(默认 50000,p50≈10k)
|
||||
- 单 story 任一失败 → 字段留空,metadata 仍传给最终 LLM
|
||||
|
||||
4. LLM:summarize_hackernews(enriched_stories, config)
|
||||
prompt: 对输入的 K 个 story 全部行文(K 通常 = 1)
|
||||
不传 recent_section_titles
|
||||
|
||||
5. 返回 (markdown, error)
|
||||
```
|
||||
|
||||
**Algolia API 接口**:
|
||||
|
||||
```
|
||||
GET https://hn.algolia.com/api/v1/items/{id}
|
||||
```
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 12345678,
|
||||
"title": "...",
|
||||
"url": "...",
|
||||
"points": 120,
|
||||
"author": "...",
|
||||
"text": null, // Show HN/Ask HN 的正文在这里
|
||||
"children": [ // 顶层评论数组(按 HN ranking 排序)
|
||||
{"id": ..., "text": "<HTML>", "author": "...", "children": [...]},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**优势 vs HTML 解析**:免去 HN 的 `td.ind[indent="0"]` indent-tree 解析;评论文本是干净 HTML 字符串,直接 `html_to_markdown`。
|
||||
|
||||
**Show HN / Ask HN 特例**:
|
||||
- 首页解析时 `url` 字段就是 `https://news.ycombinator.com/item?id=X`,作为"非外链"标记
|
||||
- enrich 时只调一次 Algolia(覆盖评论 + post 正文 `text` 字段),不再发外部请求
|
||||
|
||||
**enriched story 字段(喂给最终 LLM)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "12345678",
|
||||
"title": "...",
|
||||
"url": "...",
|
||||
"site": "example.com",
|
||||
"points": 120,
|
||||
"comments": 45,
|
||||
"comments_url": "https://news.ycombinator.com/item?id=12345678",
|
||||
"link_content": "(markdown, ≤3000 chars; Show HN 时是 post 正文)",
|
||||
"top_comments": [
|
||||
{"l1": "(markdown, ≤comment_max_chars)", "replies": ["(markdown, ≤comment_max_chars)", "..."]},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Insights 模块
|
||||
|
||||
`src/sections/insights/section.py::run_insights_section(rss_md, gh_md, hn_md, config, now) -> (str, Optional[str])`:
|
||||
|
||||
```
|
||||
1. 加载历史
|
||||
recent = load_recent_section_titles("insights", filter.push_context_days)
|
||||
|
||||
2. LLM:generate_trend_insights
|
||||
输入: {"rss": rss_md, "github": gh_md, "hackernews": hn_md} + recent
|
||||
输出: insights 段 markdown
|
||||
注:行文结构、bullet 数量、风格约束等全部交给 prompts/insights.md,
|
||||
代码层不强加固定格式
|
||||
|
||||
3. 返回 (markdown, error)
|
||||
```
|
||||
|
||||
如果某板块返回空(失败或本日无内容),prompt 里对应键标记 `"(本次无内容)"`,LLM 自行适配。
|
||||
|
||||
## 6. LLM 调用与 Prompt 策略
|
||||
|
||||
### 6.1 新增 LLM 函数(src/llm.py)
|
||||
|
||||
```python
|
||||
async def select_ai_related_hn(
|
||||
candidates: list[dict], # 首页 30 条元数据(无正文)
|
||||
k: int, # 期望返回数量,默认 1
|
||||
config: dict,
|
||||
) -> tuple[list[str], Optional[str]]:
|
||||
"""轻量 LLM:从 HN 首页候选中挑出 k 个 AI 相关的 story id,只读 title/site/points/comments。
|
||||
返回 ([id1, ...], error)。"""
|
||||
|
||||
async def summarize_github_trending(
|
||||
enriched_repos: list[dict], # 已 deep-dive 的 repo 候选(含 readme_excerpt + topics)
|
||||
config: dict,
|
||||
) -> tuple[str, Optional[str]]:
|
||||
"""选 1-max_items + 写 markdown 段。不传历史上下文。"""
|
||||
|
||||
async def summarize_hackernews(
|
||||
enriched_stories: list[dict], # 已 enrich(含 link_content 与 top_comments)
|
||||
config: dict,
|
||||
) -> tuple[str, Optional[str]]:
|
||||
"""对输入的 K 个 enriched stories 全部行文。K 由配置 select_k 决定,默认 1。
|
||||
不传历史上下文。"""
|
||||
|
||||
async def generate_trend_insights(
|
||||
sections: dict[str, str], # {"rss": md, "github": md, "hackernews": md}
|
||||
recent_insights: str, # load_recent_section_titles("insights", days)
|
||||
config: dict,
|
||||
) -> tuple[str, Optional[str]]:
|
||||
"""输入三段成品 + 近期 insights 标题,返回洞察段 markdown。"""
|
||||
```
|
||||
|
||||
返回风格沿用 `generate_immediate_push`:成功返回 `(content, None)`,失败返回 `("", error_msg)`。
|
||||
|
||||
**单次早报推送的 LLM 调用预算**:
|
||||
|
||||
| 调用 | 输入规模 | 用途 |
|
||||
|---|---|---|
|
||||
| `compose_digest` | 当日符合条件的 RSS 条目 | 现有,RSS digest 主体 |
|
||||
| `select_ai_related_hn` | 30 条 HN 首页元数据 | 轻量;只读 title/site/points/comments |
|
||||
| `summarize_github_trending` | ≤ `max_deep_dive`=10 个 enriched repos | 选 1-3 + 行文 |
|
||||
| `summarize_hackernews` | `K = select_k` 个 enriched stories(默认 1) | 行文 K 条 |
|
||||
| `generate_trend_insights` | 三段已生成 markdown + 近期 insights 标题 | 一段洞察 |
|
||||
|
||||
合计 5 次 / 早报。晚报维持现有 1 次。
|
||||
|
||||
### 6.2 关注领域(GitHub / HN 共用)
|
||||
|
||||
为避免领域定义在 3 个 prompt 里漂移,统一在此沉淀;各 prompt 在自身骨架里直接引用本节,不做重新发明。
|
||||
|
||||
**正面关注**:
|
||||
|
||||
- **AI Agent**:智能体架构、工具链、多智能体、自主规划、Agent 框架
|
||||
- **AI 模型**:训练、推理、微调、量化部署、模型服务、语音 / 多模态 / 视觉模型
|
||||
- **AI 基础设施**:GPU 调度、芯片硬件、数据中心、推理优化、分布式训练、向量数据库、RAG 框架
|
||||
- **大厂 / 前沿动态**:Apple、Google、Meta、OpenAI、Anthropic、Microsoft、xAI 等公司的官方动作与战略
|
||||
- **AI 集成的开发者工具**:API 网关、自动化脚本、低代码平台等明确与 AI 协同的工具
|
||||
- **有创新性的开源产品**:日增长显著且有清晰用户价值(GH 板块专属)
|
||||
|
||||
**负面排除(一律剔除)**:
|
||||
|
||||
- 嵌入式开发(Arduino、ESP32、树莓派、单片机)
|
||||
- 底层系统编程(内存分配器、编译器、链接器,与 AI 工作负载无明显关联时)
|
||||
- 通用开发工具(命名规范、代码风格、纯前端模板、UI 组件库、管理后台模板、静态网站主题)
|
||||
- 学习资源(纯教程仓库、面试题合集、Roadmap,除非是含实用代码的深度技术指南)
|
||||
- 配置文件集合(Dotfiles、配置模板)
|
||||
- 与 AI / 科技无关的内容(电子书、资源搬运、刷榜项目、明星项目搬运)
|
||||
- 纯娱乐 / 高风险误用(deepfake 等无明确基础设施价值的项目)
|
||||
|
||||
### 6.3 Prompt 文件
|
||||
|
||||
#### prompts/section_github.md(骨架)
|
||||
|
||||
- 角色定位("开源情报分析师")
|
||||
- 输入 schema 说明(JSON 数组:url / full_name / description / language / stars_today / stars_total / topics / license / pushed_at / readme_excerpt)
|
||||
- 关注领域:引用 §6.2(正面列表与负面排除完整复制进 prompt)
|
||||
- 选项规则:
|
||||
- 从候选中挑 1-`max_items` 个最值得关注的项目
|
||||
- 优先信号:stars_today 高 + topics 含 AI 标签(agent/llm/rag/inference/training 等)+ readme 描述明确 + 非纯模板/教程仓库
|
||||
- 必跳过:`archived=true`(理论上已在 enricher 剔除,prompt 层兜底)、纯 awesome-list、个人配置 dotfiles
|
||||
- 输出格式(markdown 列表):
|
||||
- `- **owner/repo** ⭐{stars_today} — 一句话价值定位 [link]`
|
||||
- 一句话需点明"解决什么问题",避免营销语
|
||||
- 风格约束:与 `prompts/digest.md` 同源;负面句式("震撼""炸裂""革命性")禁用;避免套话
|
||||
|
||||
#### prompts/section_hackernews_select.md(骨架)
|
||||
|
||||
- 角色定位("HN 早间选题人")
|
||||
- 输入:JSON 数组(30 条 frontpage 元数据:id / title / site / points / comments)
|
||||
- 关注领域:引用 §6.2
|
||||
- 任务:挑 `k` 个最符合关注领域的 story id(K 默认 1)
|
||||
- 决策原则:title + site 不足以判定 AI 相关时,**宁可漏选不可错选**(错选会让最终 LLM 写出与 AI Daily 调性无关的内容)
|
||||
- 输出:纯 JSON id 数组,如 `["12345"]` 或 `[]`(无任何匹配时返回空数组)
|
||||
- 严禁输出任何解释性文字
|
||||
|
||||
#### prompts/section_hackernews.md(骨架)
|
||||
|
||||
- 角色定位("HN 早间编辑")
|
||||
- 输入 schema 说明(K 个 enriched story,含 `link_content` 与 `top_comments`)
|
||||
- 关注领域:引用 §6.2(K=1 时通常无需筛选,仅作为行文背景参考)
|
||||
- 任务:对输入的 `K` 个 enriched stories 全部行文(不再二次挑选)
|
||||
- 内容要求(每条 story):
|
||||
- 提炼原文核心(背景 / 要点 / 结论)
|
||||
- 汇总 HN 评论区的有价值观点(支持 / 反对 / 补充),不是简单复述
|
||||
- 若评论中出现明显反驳原文的观点,必须保留并标注
|
||||
- 输出格式建议(最终以 prompt 实测为准):
|
||||
```
|
||||
### 标题 (N pts · M comments)
|
||||
- 链接: url
|
||||
- 内容总结: 2-3 条核心要点
|
||||
- 💬 HN 讨论: 1-2 条最有价值的观点(含反对意见)
|
||||
- 🔗 HN 讨论页: comments_url
|
||||
```
|
||||
- 风格约束:客观、犀利、克制;避免与 RSS digest 句式雷同;不做宏大叙事
|
||||
|
||||
#### prompts/insights.md(骨架)
|
||||
|
||||
- 角色定位("AI 行业观察员")
|
||||
- 输入:三段成品 markdown + 近 N 天 insights 板块清单
|
||||
- 任务:基于三段产出做跨板块小结
|
||||
- 风格约束:避免与 RSS digest 句式雷同;避免简单复述已经在其他板块出现过的具体新闻
|
||||
- 结构与 bullet 数量交由 prompt 内部约定,code 层不限制
|
||||
|
||||
### 6.4 调用顺序与并发
|
||||
|
||||
```python
|
||||
async def _run_morning_push(config):
|
||||
rss_md, gh_md, hn_md = await asyncio.gather(
|
||||
run_rss_section(config, now),
|
||||
run_github_section(config, now),
|
||||
run_hackernews_section(config, now),
|
||||
return_exceptions=False, # 各 section 自吞异常,不抛
|
||||
)
|
||||
|
||||
insights_md, _ = await run_insights_section(rss_md, gh_md, hn_md, config, now)
|
||||
|
||||
final = _assemble_with_sentinels({
|
||||
"rss": rss_md,
|
||||
"github": gh_md,
|
||||
"hackernews": hn_md,
|
||||
"insights": insights_md,
|
||||
})
|
||||
|
||||
await send_to_platforms(final, config["push"])
|
||||
save_push_file(get_push_file(), final, profile="morning", ...)
|
||||
```
|
||||
|
||||
`_assemble_with_sentinels(sections: dict[str, str]) -> str` 的契约:
|
||||
|
||||
- 按固定顺序拼装 `rss → github → hackernews → insights`
|
||||
- 空 markdown 段整段省略(连同 sentinel)
|
||||
- 段间留一个空行
|
||||
|
||||
## 7. 行业洞察板块设计
|
||||
|
||||
按用户决策,本节**不在 code 层规定 insights 的格式**:
|
||||
|
||||
- bullet 数量、子标题、字数限制、固定栏目等都属于 prompt 工程范畴
|
||||
- 调整方法:编辑 `prompts/insights.md` 而非改代码
|
||||
- 输入合同(code 层保证):
|
||||
- `sections["rss" | "github" | "hackernews"]` 三个键的 markdown
|
||||
- 任一板块为空时该键值为 `"(本次无内容)"`
|
||||
- `recent_insights`:近 `filter.push_context_days` 天 insights 段标题清单(防风格趋同)
|
||||
- 输出合同(code 层不校验):直接作为 markdown 段插入
|
||||
|
||||
## 8. 配置 schema 增量
|
||||
|
||||
```json
|
||||
{
|
||||
"filter": {
|
||||
"min_score": 60,
|
||||
"hot_threshold": 90,
|
||||
"context_days": 2,
|
||||
"keep_days": 7,
|
||||
"push_context_days": 5,
|
||||
"no_content_marker": "[NO_NEW_CONTENT]"
|
||||
},
|
||||
"schedule": {
|
||||
"fetch_interval_minutes": 30,
|
||||
"fetch_lookback_minutes": 120,
|
||||
"push_cron": ["0 8 * * *", "0 17 * * *"],
|
||||
"timezone_hours": 8
|
||||
},
|
||||
"sections": {
|
||||
"github_trending": {
|
||||
"enabled": true,
|
||||
"max_items": 3,
|
||||
"max_deep_dive": 10,
|
||||
"readme_max_chars": 10000,
|
||||
"history_file": "news-data/trending-history.json",
|
||||
"request_timeout": 10,
|
||||
"tokenName": "GITHUB_TOKEN"
|
||||
},
|
||||
"hackernews": {
|
||||
"enabled": true,
|
||||
"select_k": 1,
|
||||
"top_comments": 30,
|
||||
"top_l2_per_l1": 3,
|
||||
"comment_max_chars": 2000,
|
||||
"comments_total_chars": 60000,
|
||||
"link_content_max_chars": 50000,
|
||||
"request_timeout": 10,
|
||||
"algolia_base": "https://hn.algolia.com/api/v1"
|
||||
},
|
||||
"insights": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"llm": {
|
||||
"prompts": {
|
||||
"score_batch": "prompts/score_batch.md",
|
||||
"immediate_push": "prompts/immediate_push.md",
|
||||
"digest": "prompts/digest.md",
|
||||
"section_github": "prompts/section_github.md",
|
||||
"section_hackernews_select": "prompts/section_hackernews_select.md",
|
||||
"section_hackernews": "prompts/section_hackernews.md",
|
||||
"insights": "prompts/insights.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
向后兼容:
|
||||
|
||||
- `sections` 整段缺失 → 等价于全部 `enabled=false` → push_job 走原有纯 RSS 路径
|
||||
- `push_cron` 为空 → 无早报触发,不生成新板块
|
||||
- 旧 push 文件没有 sentinel → `extract_section("rss", ...)` 返回整个 body,其他 section 返回空字符串
|
||||
- `GITHUB_TOKEN` 未设 → GH 模块匿名调用,照常运行
|
||||
|
||||
## 9. 失败隔离与降级策略
|
||||
|
||||
| 失败位置 | 行为 |
|
||||
|---|---|
|
||||
| `run_rss_section` 失败 | 整个 push_job 退出非 0(核心承诺不变) |
|
||||
| GH trending 抓取 / 解析失败 | `run_github_section` 返回 `("", error)` → 板块整段省略 → 告警 |
|
||||
| GH 单 repo metadata/readme 失败 | 该 repo 跳过 + 错误聚合,不阻塞其他 repo |
|
||||
| GH summarize LLM 失败 | 板块整段省略,告警 `notify_llm_errors("section_github", ...)` |
|
||||
| HN 首页抓取失败 | 同 GH |
|
||||
| HN 轻 LLM 初筛失败或返回空 | 板块整段省略,告警(初筛失败)或静默(结果为空) |
|
||||
| HN 单 story enrich 失败 | 字段留空,metadata 仍传给最终 LLM |
|
||||
| HN summarize LLM 失败 | 板块整段省略,告警 |
|
||||
| insights LLM 失败 | 洞察段省略,其他板块照常推送,告警 |
|
||||
| 早报判定为否 | 完全跳过 GH/HN/insights,不消耗任何额外 API |
|
||||
| `sections.xxx.enabled=false` | 对应模块直接返回 `("", None)`,静默跳过 |
|
||||
|
||||
整体准则:**RSS 是核心,其余是增强**。除 RSS 外的任何失败都不阻塞推送,但都通过现有 `notify_llm_errors` 通道发简单告警,方便事后排查。
|
||||
|
||||
## 10. 早报判定逻辑
|
||||
|
||||
```python
|
||||
def is_morning_push(now: datetime, config: Dict) -> bool:
|
||||
cron_list = config.get("schedule", {}).get("push_cron", [])
|
||||
if not cron_list:
|
||||
return False
|
||||
if len(cron_list) == 1:
|
||||
return True # 唯一定时即"最早",任何触发都视为早报
|
||||
|
||||
base = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_fires = [croniter(c, base).get_next(datetime) for c in cron_list]
|
||||
closest = min(today_fires, key=lambda f: abs(now - f))
|
||||
return closest == min(today_fires)
|
||||
```
|
||||
|
||||
规则:`push_cron` 中 now 离哪条 cron 最近就归为那条;最近的那条若是当天最早的 cron,则视为早报。
|
||||
|
||||
为什么不再用单独的 `morning_cron` + 容差:
|
||||
- 复用 `push_cron`,少一个配置项,加 cron 自动延展
|
||||
- 「最近匹配」自动等价于容差 = 到次近 cron 距离的一半,systemd timer 漂移更稳健
|
||||
- 单条 cron 部署自然变成"每次推送都是早报",与用户预期一致
|
||||
|
||||
## 11. 与现有模块的集成点
|
||||
|
||||
- `src/main.py::run_push_job` 改造:
|
||||
1. 开头加 `is_morning = is_morning_push(now, config)`
|
||||
2. `is_morning=False` 时走原路径(`compose_digest` only)
|
||||
3. `is_morning=True` 时进入 `_run_morning_push` 编排(四模块 + sentinel 拼装)
|
||||
- `src/storage.py` 新增:
|
||||
- `extract_section(content, section)` + `load_recent_section_titles(section, days, data_dir)`
|
||||
- `load_trending_history(path) -> TrendingHistory`、`TrendingHistory.cleanup/touch/save`
|
||||
- `save_push_file` 微调:frontmatter 带上 `profile: "morning"|"default"`,方便后续按 profile 分析
|
||||
- `cleanup_old_files` 增加对 `trending-history.json` 的"过期条目剪枝"(不删整个文件)
|
||||
- `src/push/` **不动**,平台层无感知
|
||||
- `src/llm.py` 新增 4 个函数:`select_ai_related_hn / summarize_github_trending / summarize_hackernews / generate_trend_insights`
|
||||
- `src/sections/` 全新模块树(4 个子包)
|
||||
|
||||
## 12. 测试策略
|
||||
|
||||
新增单元测试:
|
||||
|
||||
- `tests/pytest/test_sections_github_scraper.py`:本地 HTML fixture(保存几个真实 trending 页面快照)→ 测试解析
|
||||
- `tests/pytest/test_sections_github_history.py`:测试 `TrendingHistory.touch/cleanup`、URL 已存在/不存在分支
|
||||
- `tests/pytest/test_sections_github_enricher.py`:mock `aiohttp` → 测试 REST API 字段映射、archived 过滤、token 鉴权头
|
||||
- `tests/pytest/test_sections_hackernews_scraper.py`:HN 首页 fixture → 测试 30 条解析
|
||||
- `tests/pytest/test_sections_hackernews_enricher.py`:mock Algolia → 测试评论 top_comments 截断、Show HN 特例
|
||||
- `tests/pytest/test_storage_sections.py`:sentinel 切片(含老文件 fallback)、`load_recent_section_titles`
|
||||
- `tests/pytest/test_morning_detection.py`:cron 边界、容差、跨时区
|
||||
|
||||
新增交互式脚本(与现有 `tests/*.py` 风格一致):
|
||||
|
||||
- `tests/run_morning_push.py`:模拟一次完整早报,强制 `is_morning=True`
|
||||
- `tests/fetch_trending.py`:单独跑 GH 抓取 + deep-dive
|
||||
- `tests/fetch_hackernews.py`:单独跑 HN 抓取 + enrich
|
||||
|
||||
## 13. 实施步骤建议
|
||||
|
||||
按依赖顺序实施,每步可独立 PR / commit:
|
||||
|
||||
1. **storage 层**:sentinel 切片、`trending-history.json` 读写、`load_recent_section_titles`、`save_push_file` profile 字段、`cleanup_old_files` 对 history 文件的处理 + 单测
|
||||
2. **RSS 模块迁移**:把现有 `run_push_job` 中的 RSS 流程提取为 `run_rss_section`,验证行为不变
|
||||
3. **GitHub 模块**:trending scraper → history → enricher → section 入口 + 单测 + fixture
|
||||
4. **Hacker News 模块**:frontpage scraper → Algolia enricher → section 入口 + 单测 + fixture
|
||||
5. **LLM 函数 + Prompt 文件**:4 个新 LLM 函数 + 4 个 prompt
|
||||
6. **Insights 模块**:依赖 §5.4,相对简单
|
||||
7. **push_job 升级**:早报判定 + 四模块编排 + sentinel 拼装 + 失败隔离
|
||||
8. **配置 schema 升级**:`config.json.example` 与 `config.json` 同步;README 配置详解章节补全
|
||||
9. **文档同步**:`docs/tech-spec.md` 把架构升级合并;`docs/plan.md` 写进度
|
||||
|
||||
## 14. 关键决策记录
|
||||
|
||||
| 决策 | 方案 | 原因 |
|
||||
|---|---|---|
|
||||
| 新板块时机 | 仅早报(当天最早一次 `push_cron` 触发) | 板块价值更适合一日一报;晚报维持原有 RSS 节奏;复用 push_cron 不引入新配置 |
|
||||
| 模块边界 | `src/sections/<board>/` 各自封装抓取+LLM+总结 | 模块自治便于扩展、替换、单测;上游编排极简 |
|
||||
| sentinel 归属 | push_job 上游统一包 | 模块不感知自己的板块标识;新增板块零修改成本 |
|
||||
| GH trending 数据源 | 单页 HTML `https://github.com/trending`,无语言/since 过滤 | 用户决策:最简、最稳;语言过滤靠 topics + readme 在 LLM 层判 |
|
||||
| GH deep-dive 内容 | REST API 拿 metadata + topics + README | topics 是 AI 相关性最强信号;metadata 补 license/pushed_at;README 给内容深度 |
|
||||
| GH 鉴权 | GITHUB_TOKEN 可选 | 日 ~20 calls 远低于匿名 60 req/hr 上限;零配置即可跑 |
|
||||
| GH 筛选策略 | history 过滤 → 全部 deep-dive → 一次 LLM 选 1-3 | 候选量小(5-15);单次 LLM 比两阶段简单且选择质量高 |
|
||||
| GH 候选护栏 | `max_deep_dive=10` | 极端日(trending 大改)限制 HTTP 与 token 消耗 |
|
||||
| HN 数据源 | 首页 HTML + 评论/正文 Algolia | 首页要"现场感"走 HTML;Algolia 评论 JSON 结构清晰,免去 indent-tree 解析 |
|
||||
| HN 筛选策略 | 30 条 → 轻 LLM 选 K=1 → enrich → 最终 LLM 行文 | 用户决策:把 enrich 工作量压到 1 个 story;轻 LLM 用 title 已足够判 AI 相关 |
|
||||
| HN 评论结构 | L1 + 每个 L1 下挂 N 条 L2 回复,tree JSON 喂 LLM | 真实数据:L2 信息量与 L1 持平(24 条/8k chars vs 18/6k);拍平丢失父子关系,LLM 无法识别"回复反驳了顶层"。Tree 结构让 LLM 看清论辩链 |
|
||||
| HN 评论上限 | `top_comments=30`(L1)+ `top_l2_per_l1=3` + `comments_total_chars=60000`(总预算) | 真实平均:L1 18 条/6k chars,L2 24 条/8k。30+3 给足余量但平均只跑 ~14k。总预算硬上限拦住离群 story(309 评论那种) |
|
||||
| 无历史上下文 | GH / HN 板块均不传 recent_section_titles | 用户决策:避免不必要的上下文污染;GH/HN 风格与 RSS digest 差异已足够大 |
|
||||
| insights 历史窗口 | 复用 `filter.push_context_days` | 不引入新字段;insights 段需要历史防风格趋同 |
|
||||
| insights 输出结构 | 由 prompt 决定,code 不强加 | 用户决策:bullet 数量与栏目属于 prompt 工程,便于迭代 |
|
||||
| GH / HN 关注领域沉淀 | 在 §6.2 集中定义正面列表 + 负面排除,3 个 prompt 引用 | 避免领域定义在 prompt 间漂移;用户已明确兴趣边界(AI Agent / 模型 / 基础设施 / 大厂动态),排除嵌入式、底层系统、纯前端模板、学习资源等 |
|
||||
| 板块 sentinel 用 HTML 注释 | `<!-- SECTION:xxx BEGIN/END -->` | markdown 渲染不显示;机器易解析;老 push 文件零冲突 |
|
||||
| 失败降级粒度 | 单板块失败省略本段;RSS 失败整体退出 | RSS 是核心承诺,其他是增强 |
|
||||
| 早报判定 | cron + 容差,而非"今天第一次" | 早报失败时晚报不会错误升级为长版本 |
|
||||
| 截断参数初始值(2026-05-17 合入) | `readme_max_chars=3000` / `top_comments=20` / `comment_max_chars=500` / `link_content_max_chars=3000` | 凭直觉给出的保守默认;上线后通过真实数据校准 |
|
||||
| 截断参数校准(2026-05-17 合入后,激进路径) | `readme_max_chars: 5000→10000` / `top_comments: 20→50` / `link_content_max_chars: 6000→50000` / `comment_max_chars: 800` 不变 / `max_prompt_chars: 64000→150000` | 用户决策:把 LLM 上下文用到 DeepSeek v4 flash 128k tokens 限的合理水平,优先内容深度而非 API 成本。Worst-case 单次 LLM prompt:GH≈110k chars / HN≈95k chars,均在 150k budget 内。中英混合 100k chars ≈ 30-50k tokens,远低于 128k 模型限 |
|
||||
| 单板块 CLI(2026-05-17 合入后) | `python -m src.main github` / `hackernews` | 便于 prompt 调优期反复跑单板块而不消耗全套 LLM 调用 |
|
||||
|
||||
|
||||
hacker news 评论统计
|
||||
|
||||
┌─────────────┬──────────┬──────────────┬────────────────┬──────────┐
|
||||
│ 层级 │ 平均条数 │ 平均 md 字符 │ 平均单条 chars │ 最大单条 │
|
||||
├─────────────┼──────────┼──────────────┼────────────────┼──────────┤
|
||||
│ L1 顶层评论 │ 18 │ 5,952 │ ~330 │ 1,755 │
|
||||
├─────────────┼──────────┼──────────────┼────────────────┼──────────┤
|
||||
│ L2 一级回复 │ 24 │ 8,166 │ ~340 │ 2,381 │
|
||||
├─────────────┼──────────┼──────────────┼────────────────┼──────────┤
|
||||
│ L3+ 更深层 │ 41 │ 13,048 │ ~315 │ 2,291 │
|
||||
├─────────────┼──────────┼──────────────┼────────────────┼──────────┤
|
||||
│ ALL 全部 │ 83 │ 27,166 │ ~330 │ 2,381 │
|
||||
└─────────────┴──────────┴──────────────┴────────────────┴──────────┘
|
||||
@@ -0,0 +1,90 @@
|
||||
## TODO
|
||||
|
||||
当前待办
|
||||
|
||||
- [ ] 优化提示词: 推送格式;参考链接去除非官方信息; insights 不够深度
|
||||
- [ ] 日志系统,保存到文件,push和fetch分开,
|
||||
- [ ] 早报内容格式优化:参考appso / xiaohu / ai gap
|
||||
- [ ] 优先级顺序
|
||||
- [ ] 美化排版
|
||||
- [ ] 添加更多信息源,如 TechCrunch、
|
||||
- [ ] 允许fetch链接中的内容对信息进行扩展
|
||||
- [ ] 更多信息源以及信息获取不全: https://www.anthropic.com/research/glasswing-initial-update / github blog
|
||||
|
||||
|
||||
长期待办
|
||||
|
||||
- [ ] 增加图片/信息图
|
||||
- [ ] 推送到知乎 / 小红书 / 网站
|
||||
- [ ] llm api fallback
|
||||
|
||||
## 技术决策记录
|
||||
|
||||
| 决策 | 方案 | 原因 |
|
||||
|------|------|------|
|
||||
| 定时调度 | systemd timer(生产)+ croniter(loop 模式) | 进程崩溃/服务器重启可自愈,配置热更新,比内置 asyncio.gather 更稳健 |
|
||||
| 包管理 | uv | 速度快、单工具管理 venv/pip/lockfile,项目独立 `.venv` |
|
||||
| LLM 健康检查 | 仅在 `install.sh` / `loop` 启动时校验 | 每次 timer 触发都校验会增加无意义的 LLM API 调用,运行期异常由 `notify_llm_errors` 兜底 |
|
||||
| 日志方案 | journald 命名空间 `dnews` + `MaxRetentionSec` | 与系统其他服务隔离,按 `log.retention_days` 自动轮转,无需写文件日志 |
|
||||
| 数据格式 | JSON | 结构清晰、易处理、支持嵌套 |
|
||||
| 推送文件 | Markdown+YAML | 人工可读、Frontmatter 元数据 |
|
||||
| LLM 评分 | 批量 JSON | 减少 API 调用次数 |
|
||||
| 状态追踪 | 文件时间戳 | 无需外部数据库 |
|
||||
| RSS延迟防护 | fetch_lookback_minutes | 防止RSS延迟导致漏读 |
|
||||
| LLM异常通知 | 调用方统一上报 | 避免批次级刷屏,同时保留关键异常通知 |
|
||||
| 报告 metadata 统一 | 全部从 LLM frontmatter 解析(早报/晚报/即时) | 取代之前的 `extract_title_from_content` h1 提取+硬编日期标题;个性化标题 + lead 导读 + highlights 列表统一承载 |
|
||||
|
||||
## 开发进度
|
||||
|
||||
**2026-05-22**
|
||||
|
||||
- ✅ 推送 metadata 统一改造:晚报与即时消息也走 frontmatter(之前只有早报)
|
||||
- `prompts/digest.md` 增加 frontmatter(title/lead/highlights),删除正文「开头一句话定调」段
|
||||
- `prompts/insights.md` frontmatter 增加 `lead`(综合三段的 60-100 字前言)与 `highlights`(2-3 条卡片重点)
|
||||
- `prompts/immediate_push.md` 将 `# 标题` 迁到 frontmatter `title` 字段
|
||||
- `src/llm.py` 新增 `parse_digest_with_metadata` / `parse_immediate_push_with_metadata`,统一 `_parse_frontmatter` 帮手;移除 `extract_title_from_content`
|
||||
- `src/sections/rss/section.py` 返回三元组 `(body, metadata, err)`;早报丢弃 digest metadata,由 insights 段覆盖
|
||||
- 晚报现在拥有个性化标题 + lead + highlights,与早报对齐
|
||||
|
||||
|
||||
**2026-05-17**
|
||||
|
||||
- ✅ 早报扩展板块上线:在 RSS digest 之上叠加 GitHub Trending / Hacker News / 跨板块洞察三段,仅 `schedule.morning_cron` 命中时触发,晚报维持纯 RSS 行为
|
||||
- 设计与实施详见 [`docs/extra-sections-design.md`](extra-sections-design.md) 与 [`docs/superpowers/plans/2026-05-17-extra-sections.md`](superpowers/plans/2026-05-17-extra-sections.md)
|
||||
|
||||
**2026-05-15**
|
||||
- 优化提示词,修复长时运行下的新闻报告措辞趋同问题
|
||||
|
||||
**2026-05-14**
|
||||
- [x] 外置定时(已切换到 systemd timer)
|
||||
- [x] 系统服务一键运行(`scripts/install.sh`)
|
||||
- 使用uv进行python项目管理
|
||||
- 配置变更:`config.json` 新增 `log.retention_days` 字段
|
||||
- 新增 `daily-news` 系统级包装脚本:装在 `/usr/local/bin/daily-news`,提供 `start/stop/restart/status/logs` 等命令,封装 systemctl/journalctl 调用细节
|
||||
|
||||
**2026-03-08**
|
||||
- 新增 LLM 异常通知:`compose_digest`、`generate_immediate_push` 与 `score_batch` 的错误会通过现有推送渠道发送简单告警
|
||||
- 优化批量评分容错:`score_batch` 在批次返回数量不匹配时会按 `link` 回收可用结果,并聚合错误返回给调用方
|
||||
- 移除 `generate_immediate_push` 的 fallback 内容,生成失败时由调用方告警并跳过本次即时推送
|
||||
- 新增启动前 LLM 可用性检查:主程序在启动 fetch/push 双循环前先探测 LLM 接口,失败则直接退出
|
||||
- 修复 pytest 中遗留的旧推送平台命名问题,将 `wecom` 测试更新为当前 `feishu` 实现
|
||||
|
||||
**2026-03-03**
|
||||
- 采用 MIT 许可开源项目,添加 LICENSE 和 NOTICE 文件
|
||||
- 更新 RSS 源说明,致谢 BestBlogs 项目
|
||||
|
||||
**2026-03-02**
|
||||
- 修复RSS延迟漏读问题:新增 fetch_lookback_minutes 参数,fetch时读取过去更长一段时间的RSS条目进行去重
|
||||
- 新增飞书 Webhook 推送支持:使用卡片消息格式,支持 Markdown 渲染
|
||||
- 新增测试脚本 test_fetch_lookback.py
|
||||
- 更新 cleanup_old_files 函数支持 notify 文件清理
|
||||
|
||||
**2026-03-01**
|
||||
- 优化评分系统:通过更新 score 提示词提升评分质量
|
||||
- 即时推送去重:新增 notify-*.md 文件存储即时推送,LLM 调用时传入近期推送上下文避免重复
|
||||
- 汇总推送优化:新增 push_context_days 配置,汇总推送时传入近期推送上下文进行去重
|
||||
- 修复 score 类型问题:确保 LLM 返回的 score 为整数类型
|
||||
- 完善测试脚本:添加上下文参数和保存功能
|
||||
|
||||
**2026-02-28**
|
||||
- 初始化项目,MVP 已完成,支持 RSS 抓取、LLM 评分、定时推送、即时推送。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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`
|
||||
Reference in New Issue
Block a user