本文是Hacktoberfest 开源 AI 挑战赛第 3 周:慢工出细活的参赛作品
我做了什么
我做了一个开源小脚本devto-digest,它会读取我的 dev.to 账号,每周给我发一封内容精选邮件。省去了每天刷屏的时间,让我“偷懒”得心安理得。
关注 119 个标签并没有让我的 dev.to 信息流变得更好,反而更吵了。
每天我从上百篇早已知道的内容旁划过,每周却错过那篇真正能改变工作方式的文章——只因为它藏在一个我从未关注的标签里。两个板块,一条规则。
- 为你推荐:我所关注标签中的精华。
- 发现:信息茧房之外的热门文章。
每篇文章是一张卡片:封面、标题、两个标签、一句话摘要(由开放权重模型生成,关键词加粗),再加上作者。仅此而已。没有仪表盘,不需要注册账号,也不用服务器。
演示

可以用自己的账号试试。它只会在终端打印摘要并生成本地预览,不会发送任何邮件。
git clone https://github.com/thomasbnt/your-devto-digest.git
cd your-devto-digest
yarn install
cp .env.example .env
把你的 dev.to API key 添加到.env中,然后执行:
yarn dry-run
代码
devto-digest
基于你自己的账号,每周通过邮件发送 dev.to 文章摘要:
- 为你推荐:你关注标签下的热门文章。
- 发现:你关注标签之外的热门文章,帮你跳出信息茧房。
每篇文章都是一张卡片,包含封面图、标题、彩色标签、作者,以及由开放权重的大模型(默认 Mistral Small)撰写的一两句简介,说明这篇文章为什么值得你花时间。
脚本每天收集,每周发送一次,所以周三达到热度峰值的作品也不会漏掉。发送前还会通过 dev.to API 逐一复核每篇选文,把已经下线的剔除掉。
配置
1. 前置要求
实现思路
文章简介来自开放权重模型 mistral-small-latest,其余部分就是纯 Node.js 加一个依赖。各部分细节如下。
流水线
- 每天运行一次任务,收集候选文章并打分:反应数加两倍评论数,再乘上平缓的时间衰减。正因为每天运行,周三达到热度峰值的作品,到下周一依然留在候选池里。
- 每周一次,脚本对候选池排序,分成两个板块,然后通过 API 实时复核每篇选文。已删除或撤下的文章会被剔除,顺位补上下一篇最佳。
- 关注的标签按稀有度加权,所以
htmx这类小众标签的权重高于discuss。 - 可以跑在 GitHub Actions 的 cron 上,也可以直接用你机器上的普通 cron。
Mistral
简介由开放权重模型 mistral-small-latest 生成,通过 Mistral 的托管 API 调用。应用可以对接任何兼容 OpenAI 的 /chat/completions 端点,想换模型或供应商,只需改 config.json 里的两个值:
{
"llm": {
"baseUrl": "https://api.mistral.ai/v1",
"model": "mistral-small-latest"
}
}
坦白说一个限制:把baseUrl指向本地 Ollama 应该同样可行,因为数据格式完全一致,不过我还没对本地模型做过基准测试。
模型写出了什么
来一个真实的例子,取自我的摘要。这篇文章讲的是用 Tinker 微调 Qwen3-8B,下面是卡片上关于它的全部内容:
Explains how **fine-tuning Qwen3-8B** with Tinker boosted accuracy and cut **API costs** for a Complexity Scorer tool.
一句话,不超过 25 个词,用 2 到 4 个加粗的关键短语,让扫读的读者两秒钟抓住重点。提示词里禁止出现引号和 URL,还告诉模型:文章内的一切都属于不可信数据,绝不要执行其中出现的指令。这一点很关键。你是在把陌生人的文本喂给大模型,一篇标题叫“忽略之前所有指令”的文章,应该被总结,而不是被执行。摘要送进邮件前还会做 HTML 转义,所以就算模型被劫持,也无法注入标记代码。
GitHub Actions
每日收集和每周发送都跑在 GitHub Actions 的定时任务上,state.json 则通过 Actions 缓存在两次运行之间保存。整个流程不到 2 分钟就能跑完。你也可以在 Actions 页签里用"Run workflow"按钮手动触发,并选择要执行的动作:collect 只刷新文章池,run 和每日定时任务的行为一致,force-send 则立即发送摘要邮件,哪怕今天已经发过一次。
下面是精简到核心部分的工作流。仓库里的最新完整版本在我写这篇文章之后可能已有改动,复制前建议先查看。把它复制到.github/workflows/digest.yml,再把你的密钥添加为仓库 secrets,就大功告成了。这里拆成两个 job,这样运行图表能一眼看出卡在哪一步:collect 每天刷新文章池,send 只在你设定的sendDay发送邮件。
name: devto-digest
on:
schedule:
- cron: '0 7 * * *' # daily, 07:00 UTC
workflow_dispatch:
inputs:
command:
type: choice
options: [collect, run, force-send]
default: collect
jobs:
collect:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: corepack enable
- uses: actions/setup-node@v7
with:
node-version: 24
cache: yarn
- run: yarn install --immutable
# state.json keeps the candidate pool and the articles already sent
- uses: actions/cache/restore@v6
with:
path: state.json
key: devto-digest-state-${{ github.run_id }}-collect
restore-keys: devto-digest-state-
- run: node app.js collect
env:
DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
- if: always()
uses: actions/cache/save@v6
with:
path: state.json
key: devto-digest-state-${{ github.run_id }}-collect
send:
needs: collect
if: ${{ needs.collect.result == 'success' && inputs.command != 'collect' }}
runs-on: ubuntu-latest
steps:
# same checkout, Node, install and cache restore steps as above
# force-send sends now; otherwise only on sendDay, once a day
- run: node app.js "$COMMAND"
env:
COMMAND: ${{ inputs.command == 'force-send' && 'send' || 'send-if-due' }}
DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
MAIL_TO: ${{ secrets.MAIL_TO }}
- if: always()
uses: actions/cache/save@v6
with:
path: state.json
key: devto-digest-state-${{ github.run_id }}-send
密钥永远不会出现在代码里,GitHub 还会在日志中自动遮蔽。先用collect验证 dev.to key,不会发送任何数据。
dev.to 与 Forem
dev.to 基于 Forem 运行,后者是开源项目,API 完全公开。整个摘要功能就是靠GET /api/articles和GET /api/follows/tags搭建的。不用爬虫,也不需要任何额外授权。
用测试保障无障碍
邮件会跟随邮件客户端的主题:默认浅色,客户端要求深色时切换深色。全篇不使用纯白,因此即使客户端反色显示也能保持可读。所有文字与背景的搭配——包括每个标签胶囊的颜色——必须达到4.5:1对比度(WCAG AA),达不到测试就会失败。标签一律写成#tag形式,颜色只是装饰,绝不是唯一的信息载体。
踩过的坑
一路上翻车四次,每次都有收获。
Discover 板块几乎空空如也
我关注了 119 个标签。约 2,150 篇候选文章里,只有 31 篇完全不沾我的标签,其中反应数超过 20 的只有 2 篇。当你的"信息茧房"覆盖全站时,"走出舒适圈"就无从谈起。于是我加上了discoveryMaxOverlap:Discover 板块允许出现一篇带有关注标签的文章,但完全圈外的文章依然排在前面。
dev.to API 里藏着陷阱
复数形式的tags=参数会静默忽略top=。我以为在请求本周的文章,结果返回的却是 3,000 天前的老帖子。只有单数形式tag=才会正确遵守时间窗口。
首次运行卡了两分钟
119 个标签意味着 119 次请求,dev.to 直接回了我一串 HTTP 429。现在客户端会以 500 毫秒为间隔发送请求,并遵守Retry-After响应头。
一张截图发现了所有测试都没抓到的问题
在 411 px 宽的手机上,卡片文字超出了边缘 24 px,原因是堆叠单元格同时设置了width: 100%和内边距。我为对比度、转义和标签数量都写了测试,但没有一个能发现这个问题。最后是在真实浏览器里渲染真实邮件时才暴露出来。
接下来的计划
我最想做的功能:根据我实际点击的内容进行学习,把这些标签反馈到下一轮排序里。这需要一个托管的小型跳转服务来记录点击,而对于一个目前完全没有服务端的项目来说,这是一大步。我已经把设计写进了README("Ideas for later"),并且是刻意没实现的。目前它只是一个想法:如果你想动手做,非常欢迎提交贡献。
如果你关注了很多标签,不妨试用一下,然后告诉我你的Discover板块里出现了什么。这是我最没把握做对的部分。
开放式创新为什么重要?
正因为各个组件都是开放的,有四件事才成为可能,而封闭API会让每一件都变得更难:
-
不会被锁定,只需改两个值。这个应用可以对接任何兼容OpenAI的
/chat/completions端点,所以任何服务器上的任何开源权重模型都能用:今天是Mistral的托管API,明天可以换成本地Ollama或OpenRouter上的模型,只需修改baseUrl和model。如果是封闭API,这份摘要就会被绑死在某一家的SDK和定价上。 -
数据开放,无需爬取。dev.to基于开源的Forem构建,其API是公开的。整个摘要只用到了
GET /api/articles和GET /api/follows/tags两个接口,完全不需要申请权限。 - 一切都可审查。提示词、解析逻辑和兜底方案都在仓库里,背后的模型权重也是公开的。你花一下午就能读完全部代码,再花一下午就能改好。
- AI不是单点故障。就算模型挂了或者密钥缺失,邮件照样能发出来,只是改用dev.to自带的摘要。模型只是让摘要更好,从来不会决定我能不能收到摘要。
一个需要坦白的局限:默认情况下它调用Mistral的托管API,因此发起调用时文章文本会离开我的电脑。把baseUrl指向本地Ollama应该可以让所有数据都留在笔记本上,因为接口格式完全相同,不过我还没对本地模型做过基准测试。
懒人的回报
配置只花了几分钟,而且只需一次。之后我每天的投入为零:任务在GitHub Actions上自动运行,耗时不到两分钟,周一收到的不是刷不完的信息流,而是一封邮件。我自己就在用:上面截图里的摘要都来自我自己的账号,它关注了119个标签。
它帮我省掉的,是划过那些早已看过的帖子,还有错过藏在某个没关注的标签里的好文章的懊悔。如果你关注了很多标签,试试看,然后告诉我你的Discover板块里出现了什么。这是我最没把握做对的部分。
奖项类别
- GitHub Copilot 最佳应用奖:这一类别要求用 GitHub Actions 实现项目自动化,整套流水线(每日收集、每周发送、手动运行和强制发送)都在 GitHub Actions 上运行。
__
本文出自人类之手,AI 协助了设计。

