开篇:Hacker News 好内容太多,时间太少
我每天会刷 Hacker News,但问题是:
- HN 每 10 分钟更新一次,一天下来几百篇文章
- 我不可能每篇都点进去看
- 等到晚上有空刷的时候,很多好内容已经被顶下去了
- 第二天又有一批新的,继续焦虑
我相信这是很多技术人的共同困境。
最近我花了半天时间,用 AI + 自动化工具构建了一个"个人 HN 邮件订阅"系统。现在每天早上 8 点,我会收到一封邮件,包含:
- 当天最值得读的 10 篇文章
- 每篇文章的摘要和推荐理由
- 我可以直接在邮件里点击阅读
整个过程不需要我做任何事。
这篇文章,我会把整个系统的构建过程、代码、踩坑经验全部分享出来。
一、系统架构:三个核心组件
这个系统的架构其实很简单,只有三个组件:
Hacker News (数据源)
↓
RSS Feed 抓取
↓
LLM 摘要生成
↓
Email 定时发送
为什么选择这个架构?
- RSS vs API:HN 有官方 API,但有频率限制。RSS 抓取更简单,而且 HN 的 RSS feed 包含了所有文章的基本信息
- LLM 摘要:不用 AI 的话,只能按热度排序,无法知道文章到底讲了什么
- 定时 Email:最可靠的推送方式,不需要安装 App,不需要打开网页
二、数据获取:RSS Feed 抓取
2.1 基础 RSS 抓取
HN 提供官方的 RSS feed:
- Top Stories: https://hnrss.org/frontpage
- Best Stories: https://hnrss.org/best
- New Stories: https://hnrss.org/newest
Python 抓取代码:
import feedparser
from dataclasses import dataclass
from datetime import datetime
from typing import List
@dataclass
class HackerNewsItem:
"""HN 文章条目"""
title: str
link: str
description: str
published: str
score: int
comments: int
@property
def is_recent(self) -> bool:
"""判断是否是最近 24 小时内的文章"""
# 简化判断,实际需要解析 published 时间
return True
def fetch_hn_feed(feed_url: str = "https://hnrss.org/frontpage") -> List[HackerNewsItem]:
"""抓取 HN RSS feed"""
feed = feedparser.parse(feed_url)
items = []
for entry in feed.entries[:30]: # 取前 30 篇
item = HackerNewsItem(
title=entry.get("title", ""),
link=entry.get("link", ""),
description=entry.get("summary", ""),
published=entry.get("published", ""),
score=int(entry.get("hn_score", 0)),
comments=int(entry.get("hn_comments", 0))
)
items.append(item)
return items
# 测试
items = fetch_hn_feed()
print(f"抓取到 {len(items)} 篇文章")
for item in items[:3]:
print(f"- {item.title} ({item.score} points)")
这段代码能抓取文章标题、链接、摘要、发布时间、分数和评论数。
2.2 过滤低质量内容
不是所有文章都值得读。系统需要过滤:
from dateutil import parser as dateparser
class HNItemFilter:
"""HN 文章过滤器"""
# 最低分数阈值(24小时内)
MIN_SCORE = 30
# 排除的域名(通常是低质量内容农场)
EXCLUDED_DOMAINS = [
"medium.com", # 文章质量参差,很多需要付费
"twitter.com", # 推文链接
"x.com", # 推文链接
"youtube.com", # 视频,不适合文字摘要
"substack.com", # 付费订阅内容
"功利性太强": None
]
@classmethod
def is_worthy_of_digest(cls, item: HackerNewsItem) -> bool:
"""判断文章是否值得进入 digest"""
# 分数太低
if item.score < cls.MIN_SCORE:
return False
# 排除特定域名
for excluded in cls.EXCLUDED_DOMAINS:
if excluded in item.link:
return False
# 检查时间(只选最近 24 小时内的)
try:
published_time = dateparser.parse(item.published)
age_hours = (datetime.now() - published_time).total_seconds() / 3600
if age_hours > 24:
return False
except:
pass
# 标题太短(可能是低信息量内容)
if len(item.title) < 20:
return False
return True
@classmethod
def filter_items(cls, items: List[HackerNewsItem]) -> List[HackerNewsItem]:
"""批量过滤"""
return [item for item in items if cls.is_worthy_of_digest(item)]
过滤逻辑根据实际阅读体验调整。我的标准是:分数 > 30、不是内容农场、24 小时内、标题有实质内容。
三、内容摘要:LLM 帮忙读文章
抓到了文章列表,接下来要让 LLM 生成摘要。
3.1 为什么不用全文?
有人会说:为什么不直接把文章全文发给 LLM 摘要?
原因:
1. 大多数文章太长,一篇就是几千字
2. 30 篇文章全文加起来,token 消耗是天文数字
3. HN 文章质量参差,有些文章读完发现是垃圾
更好的方式是:先让 LLM 根据标题和摘要判断是否值得读,然后只为值得读的文章抓取全文。
3.2 两阶段摘要
from typing import Optional
import requests
class LLMArticleSummarizer:
"""LLM 文章摘要生成器"""
def __init__(self, api_key: str, base_url: str = "https://api.minimax.chat"):
self.api_key = api_key
self.base_url = base_url
self.system_prompt = """你是一个技术文章筛选和摘要助手。
你的任务是:
1. 评估文章是否值得深入阅读
2. 如果值得,生成 2-3 句话的简洁摘要
输出格式:
SCORE: [1-10](1=不值得读,10=必读)
SUMMARY: [2-3 句摘要]
REASON: [推荐理由,一句话]
如果 SCORE < 7,说明文章不够有价值,只需要输出 SCORE 即可。"""
def evaluate_article(self, item: HackerNewsItem) -> dict:
"""评估单篇文章"""
prompt = f"""文章标题: {item.title}
文章链接: {item.link}
文章摘要: {item.description[:500]}
请评估这篇文章的价值。"""
response = self._call_llm(prompt)
return self._parse_evaluation(response)
def _call_llm(self, prompt: str, max_tokens: int = 300) -> str:
"""调用 LLM API"""
# 这里使用 MiniMax API 作为示例
url = f"{self.base_url}/v1/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
payload = {
"model": "MiniMax-M2.7",
"messages": [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": prompt}
],
"max_tokens": max_tokens,
"temperature": 0.3 # 低温度,保持输出稳定
}
response = requests.post(url, json=payload, headers=headers)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
def _parse_evaluation(self, response: str) -> dict:
"""解析 LLM 评估结果"""
result = {
"score": 0,
"summary": None,
"reason": None
}
for line in response.split("\n"):
if line.startswith("SCORE:"):
try:
result["score"] = int(line.split(":")[1].strip())
except:
pass
elif line.startswith("SUMMARY:"):
result["summary"] = line.split(":", 1)[1].strip()
elif line.startswith("REASON:"):
result["reason"] = line.split(":", 1)[1].strip()
return result
def batch_evaluate(self, items: List[HackerNewsItem]) -> List[dict]:
"""批量评估文章"""
evaluations = []
for item in items:
try:
eval_result = self.evaluate_article(item)
eval_result["item"] = item
evaluations.append(eval_result)
# 避免请求过快
import time
time.sleep(0.5)
except Exception as e:
print(f"评估文章失败: {item.title}, 错误: {e}")
continue
# 按分数排序,选取前 10 篇
evaluations.sort(key=lambda x: x["score"], reverse=True)
return evaluations[:10]
3.3 可选:抓取全文做深度摘要
如果某些高分文章内容特别丰富,你想生成更详细的摘要:
def fetch_article_content(url: str) -> Optional[str]:
"""抓取文章正文内容"""
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
# 简单的 HTML 解析,提取正文
# 实际应用中应该用更健壮的库如 newspaper3k 或 readability
from html.parser import HTMLParser
class TextExtractor(HTMLParser):
def __init__(self):
super().__init__()
self.text_parts = []
self.in_article = False
def handle_data(self, data):
text = data.strip()
if len(text) > 50: # 过滤短文本
self.text_parts.append(text)
def get_text(self):
return "\n".join(self.text_parts)
extractor = TextExtractor()
extractor.feed(response.text)
return extractor.get_text()[:5000] # 限制长度
except Exception as e:
print(f"抓取文章内容失败: {url}, 错误: {e}")
return None
四、邮件发送:定时推送
4.1 邮件内容生成
from typing import List
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import smtplib
class DigestEmailGenerator:
"""Digest 邮件生成器"""
def generate_html(self,
articles: List[dict],
date: str) -> str:
"""生成 HTML 邮件内容"""
html_header = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hacker News Daily Digest - {date}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 700px; margin: 0 auto; padding: 20px; }}
h1 {{ color: #ff6600; border-bottom: 2px solid #ff6600; padding-bottom: 10px; }}
.article {{ margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 8px; }}
.article-title {{ font-size: 18px; font-weight: bold; margin-bottom: 8px; }}
.article-title a {{ color: #ff6600; text-decoration: none; }}
.article-meta {{ color: #666; font-size: 12px; margin-bottom: 10px; }}
.article-summary {{ color: #333; line-height: 1.6; }}
.article-reason {{ color: #22863a; font-style: italic; margin-top: 8px; }}
.score {{ color: #ff6600; font-weight: bold; }}
.footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<h1>📰 Hacker News Daily Digest</h1>
<p style="color: #666;">{date} · 今日精选 {len(articles)} 篇文章</p>
"""
articles_html = ""
for i, article in enumerate(articles, 1):
item = article["item"]
articles_html += f"""
<div class="article">
<div class="article-title">
<a href="{item.link}">{i}. {item.title}</a>
</div>
<div class="article-meta">
🔺 <span class="score">{item.score} points</span> ·
💬 {item.comments} comments
</div>
<div class="article-summary">
{article.get('summary', '无摘要')}
</div>
<div class="article-reason">
推荐理由:{article.get('reason', 'HN 社区高分推荐')}
</div>
</div>
"""
html_footer = f"""
<div class="footer">
<p>🤖 此邮件由 AI 自动生成</p>
<p>📋 每篇文章都经过 LLM 评估筛选,确保高质量</p>
<p>⚙️ 如需修改订阅设置,请访问 <a href="https://www.jzhix.com/hn-digest">配置页面</a></p>
</div>
</body>
</html>
"""
return html_header + articles_html + html_footer
def generate_plain_text(self, articles: List[dict], date: str) -> str:
"""生成纯文本版本(兼容不支持 HTML 的邮件客户端)"""
lines = [
f"HACKER NEWS DAILY DIGEST - {date}",
"=" * 50,
f"今日精选 {len(articles)} 篇文章",
"",
]
for i, article in enumerate(articles, 1):
item = article["item"]
lines.append(f"{i}. {item.title}")
lines.append(f" 🔺 {item.score} points | 💬 {item.comments} comments")
lines.append(f" 🔗 {item.link}")
if article.get("summary"):
lines.append(f" 📝 {article['summary']}")
if article.get("reason"):
lines.append(f" ✅ {article['reason']}")
lines.append("")
lines.append("-" * 50)
lines.append("此邮件由 AI 自动生成")
return "\n".join(lines)
4.2 邮件发送
import smtplib
from email.mime.multipart import MIMEMultipart
class EmailSender:
"""邮件发送器"""
def __init__(self,
smtp_host: str,
smtp_port: int,
smtp_user: str,
smtp_password: str,
from_email: str):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.smtp_user = smtp_user
self.smtp_password = smtp_password
self.from_email = from_email
def send(self,
to_email: str,
subject: str,
html_content: str,
plain_content: str):
"""发送邮件"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = self.from_email
msg["To"] = to_email
# 纯文本版本
msg.attach(MIMEText(plain_content, "plain"))
# HTML 版本
msg.attach(MIMEText(html_content, "html"))
# 发送
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port) as server:
server.login(self.smtp_user, self.smtp_password)
server.sendmail(self.from_email, [to_email], msg.as_string())
print(f"邮件发送成功: {to_email}")
五、定时任务:自动化每天执行
5.1 主程序
def run_daily_digest():
"""
执行每日 HN Digest 的完整流程:
1. 抓取 HN RSS
2. 过滤低质量内容
3. LLM 评估每篇文章
4. 生成邮件内容
5. 发送邮件
"""
from datetime import date
print(f"[{datetime.now()}] 开始执行每日 HN Digest...")
# Step 1: 抓取 HN 热门文章
print("Step 1: 抓取 HN RSS...")
items = fetch_hn_feed()
print(f" 抓取到 {len(items)} 篇文章")
# Step 2: 过滤低质量内容
print("Step 2: 过滤低质量内容...")
filtered_items = HNItemFilter.filter_items(items)
print(f" 过滤后剩余 {len(filtered_items)} 篇")
# Step 3: LLM 评估
print("Step 3: LLM 评估文章...")
summarizer = LLMArticleSummarizer(api_key=os.environ["LLM_API_KEY"])
evaluations = summarizer.batch_evaluate(filtered_items)
print(f" 评估完成,选取分数最高的 {len(evaluations)} 篇")
# Step 4: 生成邮件内容
print("Step 4: 生成邮件内容...")
email_gen = DigestEmailGenerator()
today = date.today().strftime("%Y-%m-%d")
html_content = email_gen.generate_html(evaluations, today)
plain_content = email_gen.generate_plain_text(evaluations, today)
# Step 5: 发送邮件
print("Step 5: 发送邮件...")
sender = EmailSender(
smtp_host="smtp.gmail.com",
smtp_port=465,
smtp_user=os.environ["SMTP_USER"],
smtp_password=os.environ["SMTP_PASSWORD"],
from_email=os.environ["FROM_EMAIL"]
)
sender.send(
to_email=os.environ["TO_EMAIL"],
subject=f"📰 HN Daily Digest - {today}",
html_content=html_content,
plain_content=plain_content
)
print(f"[{datetime.now()}] 每日 Digest 执行完成!")
5.2 设置定时任务(Linux Cron)
# 编辑 crontab
crontab -e
# 添加以下内容:每天早上 8 点执行
0 8 * * * /usr/bin/python3 /path/to/hn_digest.py >> /var/log/hn_digest.log 2>&1
5.3 使用 systemd timer(更可靠)
如果你的服务器用 systemd 而不是 cron:
# /etc/systemd/system/hn-digest.service
[Unit]
Description=Hacker News Daily Digest
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/hn_digest/main.py
Environment="PYTHONUNBUFFERED=1"
User=hnuser
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/hn-digest.timer
[Unit]
Description=Run HN Digest daily at 8am
[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true
[Install]
WantedBy=timers.target
# 启用定时器
sudo systemctl enable hn-digest.timer
sudo systemctl start hn-digest.timer
# 查看状态
systemctl list-timers | grep hn-digest
六、Gmail SMTP 配置注意事项
很多人卡在 Gmail SMTP 这一步。Google 的安全机制比较严格,需要额外配置:
6.1 获取 Gmail App Password
- 登录 Google 账户
- 进入"安全"设置
- 开启"两步验证"(必须)
- 在"应用密码"中生成一个新的应用密码
- 使用这个 16 位密码而不是你的账户密码
6.2 常见错误处理
# 错误1: "Authentication failed"
# 原因:使用了账户密码而不是 App Password
# 解决:使用 Gmail App Password
# 错误2: "SMTP AUTH extension not supported by server"
# 原因:Gmail 需要 TLS/SSL
# 解决:使用 SMTP_SSL 而非 SMTP
# 错误3: "Daily sending limit exceeded"
# 原因:Gmail 免费版每天最多 500 封
# 解决:使用 SendGrid / Mailgun 等服务,或使用企业账户
七、进阶优化:让 Digest 更个性化
7.1 按主题偏好筛选
如果你是后端开发者,可能对前端内容不感兴趣。添加主题过滤:
class TopicAwareFilter(HNItemFilter):
"""支持主题偏好的过滤器"""
def __init__(self, preferred_topics: List[str]):
self.preferred_topics = preferred_topics
def is_topic_relevant(self, title: str, description: str) -> bool:
"""判断文章是否匹配用户偏好主题"""
content = (title + " " + description).lower()
for topic in self.preferred_topics:
if topic.lower() in content:
return True
return False
7.2 避免重复推荐
如果某篇文章昨天已经推荐过,今天不再推荐:
class DeduplicationFilter:
"""去重过滤器:避免重复推荐"""
def __init__(self, db_path: str = "/tmp/hn_digested.db"):
self.db_path = db_path
self._init_db()
def _init_db(self):
import sqlite3
self.conn = sqlite3.connect(self.db_path)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS digested_articles (
link TEXT PRIMARY KEY,
digested_date TEXT,
title TEXT
)
""")
def is_already_digested(self, link: str) -> bool:
"""检查文章是否已经在过去 7 天内推荐过"""
cursor = self.conn.execute(
"SELECT COUNT(*) FROM digested_articles WHERE link = ? AND date(digested_date) > date('now', '-7 days')",
(link,)
)
return cursor.fetchone()[0] > 0
def mark_digested(self, link: str, title: str):
"""标记文章已推荐"""
today = date.today().isoformat()
self.conn.execute(
"INSERT OR REPLACE INTO digested_articles (link, digested_date, title) VALUES (?, ?, ?)",
(link, today, title)
)
self.conn.commit()
八、完整项目代码结构
hn-daily-digest/
├── main.py # 主程序入口
├── config.py # 配置(API keys, SMTP 等)
├── scraper.py # RSS 抓取
├── filter.py # 文章过滤
├── summarizer.py # LLM 评估
├── email_generator.py # 邮件生成
├── email_sender.py # 邮件发送
├── deduplication.py # 去重
├── requirements.txt # 依赖
└── .env.example # 环境变量示例
requirements.txt
feedparser>=6.0.0
python-dateutil>=2.8.0
requests>=2.28.0
python-dotenv>=1.0.0
九、我的观点:自动化是为了更好地信息获取
写这篇文章的时候,我在想一个问题:这种"自动化信息获取"会不会让人变得懒于主动搜索信息?
我的答案是:不会。
自动化帮我过滤掉的是噪音,让我能把注意力放在真正有价值的内容上。省下来的时间,我可以:
- 仔细阅读高分文章,做笔记
- 跟进感兴趣的话题,深入研究
- 把 HN 刷帖的时间用在更有创造性的事情上
工具的价值在于放大人的能力,而不是替代人的思考。
十、总结与下一步
这篇文章覆盖了一个完整的"HN Daily Digest"系统的构建过程:
- 数据获取:RSS 抓取 HN 热门文章
- 内容过滤:基于分数、时间、域名过滤低质量内容
- LLM 评估:用 AI 判断文章是否值得读
- 邮件发送:定时推送个性化 digest
- 去重机制:避免重复推荐
下一步你可以做的:
- 添加更多数据源(Product Hunt、TechCrunch 等)
- 实现网页端订阅管理
- 添加"回复邮件提问"功能
- 接入 Telegram / Discord / Slack 推送
整个系统的代码大约 500 行,部署在任何一个能访问外网的 Linux 服务器上都可以运行。如果你也有"HN 文章太多看不完"的困扰,这个系统值得一试。
文章由文字工作者编写。项目代码基于真实运行经验。