Thomas Bonnet 10 月 21 号那篇帖子,我看了两遍才确认自己没看错。
119 个关注标签,约 2150 篇候选文章,其中不带他关注标签的,31 篇。这 31 篇里,反应数过 20 的,2 篇。
2150 进,31 出。
画外音:我上次捣鼓的那个推荐功能,转化率都比这个好看。
devto-digest 这项目一句话能说完:读你的 dev.to 账号,每周给你发一封摘要邮件。For you 汇总关注标签里的热门,Discover 推圈外的。第二块基本是空的——原因就是上面那行数字。
关注标签这个动作,本质是把「我感兴趣的东西」从脑子里搬进一个列表。搬的时候很爽,觉得自己在系统化管理信息摄入。实际上你只是把噪音源从一个变成了 119 个。作者自己也认了:关注 119 个标签让信息流更嘈杂,内容发现一点没改善。
一个人关注了 119 个标签,然后写文章承认这没让他发现更多东西。这句话我建议所有做信息聚合的人打印出来贴显示器上。
我也干过。以前拿 RSS 订阅当兴趣信号,觉得列表越长说明兴趣越广、摄入越全面。后来某天翻订阅列表,两百多个源,每周真正点开的不超过八个。剩下那些,订阅的时候我以为自己会成为「每天都读」的人,实际上我订阅的是一个想象中的自己。
扯远了。
For you 的排序逻辑倒是挺正常,没什么好挑的:
score = reactions + 2 * comments;
score *= timeDecay(article.publishedAt);
评论权重给到反应的两倍,这个我认同——点个赞几乎零成本,写条评论得先想清楚自己要说什么,两者的信息量不在一个量级。时间衰减的具体曲线作者没写,但这东西本来也不需要多精确,能压住三天前的高分文章就算及格。
标签还按稀有度加权,htmx 这种小众标签的权重高于 discuss 这种大众标签。对的。大众标签下的「热门」,你打开 dev.to 首页就能看到,不需要一封邮件再来告诉你一遍。小众标签里的热门,才是这封邮件存在的理由。
真正让我想写这篇的是 Discover 那个补丁。
2150 篇里只有 31 篇在圈外,这意味着 Discover 要么空着,要么就拿那 31 篇里的 2 篇反复轮播。作者的处理方式是加了个配置项:
discoveryMaxOverlap: 1
允许 Discover 的文章共享一个你关注的标签,同时优先推完全在圈外的。
我第一反应是这补丁很怂。你做的是「圈外推荐」,数据告诉你圈外没东西,你的反应是把「圈外」的定义放宽一格,让板块看起来有内容。这不就是给功能凑 KPI。
写到这儿我改主意了。
「完全在关注范围之外」这个定义本身就是洁癖。119 个标签的覆盖面已经接近 dev.to 热门话题全集了,那「完全圈外」剩下的只有两类:没人看的,和标签体系根本没覆盖到的。在这两类里做推荐,做出来的不是宝藏,是随机数。一封每周发一次的邮件,靠随机数撑起一个板块,那才叫敷衍。
放宽到「共享一个标签」,等于承认一件更接近事实的事:兴趣不是分类,是重叠。一个人的兴趣边界不是一个圆,是一堆互相咬合的圆,每个都跟旁边搭一点边。Discover 该干的不是把你拽进一个完全陌生的领域,是从你熟的那个标签旁边往外挪半步。
半步就够了。挪太远读者不认,不挪,那要 Discover 干什么。
顺带说个这项目里最贵的坑。
dev.to 的公共 API 有个 /api/articles,看着很好用。但复数形式的 tags= 会静默忽略 top=:
# 你以为你在按时间窗口查
GET /api/articles?tags=htmx,node&top=7
# 实际拿到的:约 3000 天前的文章,top 被吃掉了
不报错,不警告,不返回 400。就给你一堆看起来挺正常的文章,点进去一看是三年前的。
这种坑最贵,因为它顺着你。你写代码的时候不会怀疑它,你会怀疑自己时间窗口算错了、怀疑时区、怀疑本地缓存,折腾半天回头一看,是参数名少了个 s。
单数的 tag= 才认时间窗口。我猜是历史遗留,多标签查询走的是另一条路径,那条路径压根没实现 top 过滤。但 API 不告诉你,它只是安静地返回错误的东西。
首次运行还有个更蠢的:119 个标签就是 119 次请求,一口气甩出去,挂起约两分钟,最后收到 HTTP 429。客户端后来改成每次请求间隔 500 毫秒,并且老实读 Retry-After:
await sleep(500);
if (res.status === 429) {
const wait = Number(res.headers.get('retry-after')) * 1000;
await sleep(wait || 5000);
}
粗暴但有效。代价是每次运行至少多花一分钟——所以「完整运行不到 2 分钟」这个数字,你得分一半给 sleep。
整套东西跑在 GitHub Actions 上,每天 07:00 UTC 收集候选,状态文件 state.json 靠 Actions 缓存跨运行活着。没有数据库,没有 Redis,一个每周发一封邮件的项目,不值得为它架一套存储。手动触发的时候还能选 collect、run 或 force-send,collect 只刷新候选池不发送——这个开关很实用,调排序的时候不用等一周。
密钥塞在仓库 secrets 里,GitHub 会在日志里遮蔽。作者拿这个项目报了 Best Use of GitHub Copilot 类别,理由是收集、发送、手动运行、强制发送全在 Actions 里完成。这个申报角度不算取巧。
还有个细节:排序完之后、发送之前,系统会再通过 dev.to API 复核一遍,把已经下线的文章删掉,由下一篇补位。这说明作者知道候选池是会过期的——从收集到发送隔了好几天,中间作者删帖、改标题、文章被降权,都可能发生。写发信功能的时候能想到「发出去那一刻的内容可能已经不是几天前的内容」,这个意识比很多正经项目强。
然后是一张截图的事。
411 像素宽的手机截图,卡片里的文字超出边缘 24 像素。原因是堆叠的单元格同时用了 width:100% 和内边距:
td {
width: 100%;
padding: 12px;
}
/* 实际盒宽 = 100% + 24px */
CSS 盒模型的老坑,2026 年了还在坑人。但真正让我停在这儿的是下一句:作者已经为对比度、HTML 转义、标签数量写了测试,全过了。没有一条测出这个溢出。最后是靠真浏览器渲染一遍邮件才看出来的。
测试是照着你想到的东西写的。你想到了对比度,想到了转义,想到了标签数量,就是没想到「这封邮件在 411 像素宽的屏幕上长什么样」——因为你在 27 寸显示器上写了三个月,浏览器窗口从来没窄过 1000 像素。
我让 AI 写测试也是同一个毛病。你跟它说「给这个函数写测试」,它给你一堆漂亮的、覆盖分支的、全绿的东西——因为函数是你描述的,边界是你告诉它的。它不会替你想你没想过的那一格。画外音:这周期的实习生不是能力不行,是它不知道你不知道什么。
可访问性那套倒是做得挺硬:所有文字与背景组合,包括标签颜色,必须达到 4.5:1 的 WCAG AA 对比度,否则测试套件直接失败;标签永远写成 #tag,颜色只作装饰,不作为唯一的信息信号。这两条加起来的意思是,色盲用户和低对比度屏幕用户都能用。做玩具项目还能把这条落到测试里,不多见。
最后说模型那块,这是我觉得整个项目最值得抄的地方。
摘要默认走 mistral-small-latest,通过 Mistral 的托管 API 调用。但模型不是必需项:没配密钥,或者调用失败,邮件照发,摘要退回 dev.to 自带的文章描述。
const summary = modelSummary ?? article.description;
模型只是增强项,不决定邮件能不能发出去。我见过太多「AI 项目」把模型放在关键路径上,模型一抖整个功能就废,然后开始加三层重试和降级逻辑。反过来想一下:一封摘要邮件,没有 AI 摘要就不是摘要邮件了吗?不是。
prompt 那边也做了该做的:摘要限一句话、最多 25 个词,要求用 2 到 4 个加粗关键词突出要点,禁止出现引号和 URL,并且明确把文章正文当作不可信数据、要求模型不执行文章里的任何指令;摘要进邮件之前再做一遍 HTML 转义。这是 prompt injection 的最低防线,做玩具项目能想到这一层的不多。
数据外流这件事作者也没藏:默认调托管 API,意味着文章文本会离开本机;把 baseUrl 指向本地 Ollama 理论上可行,但他明说没对本地模型做过基准测试。
这句「没做」比任何吹本地模型的话都值钱。
下次再想给一个工具加「猜你喜欢」,先花十分钟把圈外候选的数量算出来。算完你大概率会发现,圈子里从来不缺内容,缺的是删订阅的那只手。
我这只手,到现在也没伸出去。
