← 返回文章选择

隐藏几个来源以后,列表怎么少了一大半

来源质量分怎么算、低于哪条线降档、隐藏来源后列表怎么补页。

一篇文章分数低,点开却只有登录提示、播放器按钮和几行摘要,这时再调评分 prompt 意义不大。模型没读到正文,分数自然也不能拿来评价原作者。所以先给来源加一些抓取质量信息,放在均分旁边。

比较来源之前,先看样本量

统计包括正文完整率、抓取错误率、平均分和高分比例,并带上样本量。实现取的是全站最新最多 300 篇,再按来源分组。这 300 篇是全站按时间取的同一批,不是每个来源各取一份,分组只是把它按来源切开。这里对照的是五月中旬 b733e6a 版本的 feeds/server.ts。

代码里就是一次不带 offset 的请求,常量 FEED_QUALITY_SAMPLE_LIMIT = 300,状态取 all。也就是说,已读和未读的文章都算进样本。

这样高频来源会占更多位置,低频来源这次可能一篇都没进来。两个来源并排看时,先看各自样本量差多少,再决定完整率和错误率能不能比。只有两条样本的来源,不能和几十条的来源当作同样充分的统计。

正文完整率其实是一条 280 字的线

“正文完整”的判据很朴素:清洗后的正文不到 280 字,或者命中一组错误页正则(enable javascript、just a moment、checking your browser、sign in to view 之类),就记为 partial。前者标成 rss_fragment,后者标成 blocked_or_error_page。

这条线只看长度,不看内容。一篇本来就短的快讯,也会被算成片段。

旁边那几个短标签也都有阈值:

标签 条件
高分(单篇) 总分 ≥ 70
正文完整率高 来源完整率 ≥ 0.7
正文片段偏多 来源完整率 < 0.35
抓取常遇到错误页/登录墙 错误页占比 ≥ 0.2

同一个来源最多显示 4 条理由。

重新抓正文也有一道门槛:新正文要超过 max(旧正文 + 24 字, 旧正文 × 1.08) 才替换,否则记为 not_better,保留 RSS 里原来那段;抓回来是错误页就直接拒收。所以会出现“重抓了,正文没变”的情况。抓得更长才算数,但更长不等于更好,这个判据我也只当作粗筛。

降档和隐藏是两回事

我把自动降档和手动隐藏分开:

  • 低质量分影响默认排序的位置;
  • 隐藏由 AI Reader 自己数据库里的 reader_feed_preferences 保存,不删除 Miniflux 的订阅关系。

暂时不想在信息流看到一个来源,可以先藏起来,以后再恢复。

质量分怎么算,低到多少才降档

来源质量分不是均分换个名字,而是五项加权再减两项惩罚:

45 × 正文完整率 + 30 × 均分项 + 15 × 高分比例 + 10 × 互动项 − 25 × 错误页占比 − 8 × 片段占比

权重最大的是正文完整率,抓到错误页扣得最狠。一篇评分都没有的来源,均分项按 0.35 兜底,相当于 35 分,不按 0 算,免得新来源一上来就垫底。

降档也不是连续扣分,而是一条线:质量分低于 45 的来源整档沉到下面,45 分以上的同在上一档,档内只按时间排。完全没有质量数据的来源留在上一档,不降。

这里有一层我不太放心的循环:完整率靠 280 字这条长度线判断,又占了质量分最大的权重。一个专发短讯的来源,内容再好也可能因此被压到下一档。

为什么隐藏要自己建表

隐藏写在 reader_feed_preferences 里,键是租户加 Miniflux 的 feed id,写法是 upsert。重新隐藏时用 COALESCE 保住第一次的隐藏时间,取消隐藏才清空。整个过程不碰 Miniflux。

没用上游,是因为我想按视图区分隐藏。Miniflux 有来源级的全局隐藏(hide_globally),管的是全局未读列表,API 查询也能按 globally_visible 过滤;但它是一个来源一个开关,我要的是信息流里藏起来,星标、项目、稍后读里照样能看到。退订(DELETE /v1/feeds/{id})又会丢掉订阅关系,所以我选了自己多一张表,代价是每次出列表都要多读一次偏好。

隐藏之后还得往后取

如果先取一批文章,再过滤隐藏来源,原本足够的一页会剩得很少。页面显示几条就停,读者容易以为到底了,其实后面还有可见内容。

同版本的 articles/server.ts 因此会继续扫描后面的条目,但有上限。拿请求 50 条为例,单批取 300 条,最多扫描 1,000 条,过程如下:

隐藏来源后继续补页,凑够50条或扫描至1000条即返回

文字版
取 300 → 过滤 → 不足 50,继续
再取 300 → 过滤 → 不足 50,继续
再取 300 → 过滤 → 不足 50,继续
最后取 100 → 到扫描上限,返回已有结果

底层翻页用 Miniflux API 的 offset 和 limit。凑够了会提前停;最后仍不足,就返回较短的列表,不无限往后找。

实际发出去的请求还固定带着 order=published_at&direction=desc,按发布时间倒序取,再按模块加 status 或 starred 过滤。“最新”的口径由上游排序保证。

上限管的是扫描量,不是页面显示几条。每多取一批,都要再向上游要一次、再过滤一遍,取到 1,000 条已经走了四趟。这个 1,000 是该请求条件下的扫描上限,和前面质量统计的 300 条样本是两回事。

“该请求条件下”可以写得更具体。单批是 max(limit, 300),扫描上限是 max(300, min(1000, limit × 20))。列表默认 50 条、最多 100 条:50 条时 20 倍正好是 1,000;要是一页只要 10 条,上限就只有 300,一趟就结束。1,000 这个数是自己定的,Miniflux 文档对 offset 和 limit 都没写默认值和上限。

隐藏按视图区分

隐藏按视图区分。信息流可以过滤这个来源,但在候选、项目、稍后读各自的视图里,已选的文章仍能找到。不必为了清理信息流,把自己挑过的材料一起删掉。反过来,在别的视图能找到,也不表示收藏过的文章会在所有视图自动绕过隐藏规则。规则集中在 articles/service.ts。

代码里这是一行白名单:星标、项目、稍后读三个模块保留隐藏来源,其余全部过滤。新增视图默认就会过滤,不用每个视图各写一遍。

显式选择“最新”时,按发布时间优先;默认排序仍可能先看来源质量档。两个入口要让人分得清,否则刚发布的文章往后跑,用户只会觉得时间排序坏了。具体说,选“最新”时先比发布时间,质量档只在同一秒发布时当次级比较;默认排序先比质量档,档内信息流按发布时间排。

后来仓库里 lib/feeds/ 整个目录没了,但质量档和隐藏逻辑都还在,搬进了 lib/articles/service.ts,函数名没变。

接下来先清正文里的杂质

当时检查来源,Bloomberg 有抓取片段偏短的情况,Al Jazeera 里出现过 Toggle Play、Read more 和分享按钮残留。这些只说明那次抓取和清洗有要处理的地方,不评价现在的内容质量;Bloomberg 那条是 280 字的长度线判出来的,和内容好坏无关。

下一步该清的是这些实际混进正文的文本,再回头看评分有没有改善。

上游其实有现成的路。Miniflux 的规则文档支持在 feed 上写 rewrite rules,逗号分隔,remove(...) 用 CSS 选择器删节点,正好能去掉 Toggle Play、Read more 这类残留;还有 scraper rules 指定抓哪块正文。代价是规则落在 Miniflux 侧,要逐个来源维护。和隐藏不同,清洗这件事上游有能力,要不要自己在应用里再做一层,我会先按来源试规则再决定。

留下几句话 读者来信

读到这里,有什么想法?欢迎留下你的看法。