RSS订阅最大的魅力在于信息聚合,但订阅源数量一多,问题也随之而来:同一篇文章在时间线里反复出现,有时候是三个不同的技术博客转了同一篇新闻,有时候是同一个站点更新了feed导致链接参数变化,阅读器把它当成新条目又推了一遍。这些重复内容不仅浪费时间,还会淹没有价值的原创文章。这篇文章就来系统地聊聊RSS去重的思路和具体做法。

重复内容是怎么产生的
想解决问题,先要理解问题的根源。RSS中的重复内容大致可以分成三类,每一类的成因不同,处理方式也不一样。
第一类是URL层面的伪重复。有些站点生成feed时,会在链接后面附加跟踪参数,比如utm_source=rss之类的query string。同一天里参数变了,条目的guid也跟着变,阅读器自然认为是新文章。这种重复本质上不是内容重复,而是标识重复,处理起来相对简单,只要对URL做规范化(去掉query参数或统一域名形式)再比较即可。
第二类是转载导致的真重复。一篇文章被A站原创发布,B站、C站全文转载,标题一样、正文基本一样,但URL完全不同。这种情况下靠链接去重完全无效,必须对标题甚至正文内容做相似度判断。这也是去重方案里最复杂的部分。
第三类是feed本身的结构问题。有些聚合型feed会周期性地把旧条目重新推送,或者条目的发布时间被更新,导致阅读器重复拉取。这类问题可以通过记录已见条目的指纹来规避。
利用阅读器自带功能做基础去重
如果你用的是Feedly、Inoreader这类在线阅读器,先看看它们内置的规则功能。Inoreader的过滤器支持按标题关键词、来源等条件自动标记已读,对付转载类重复有一定效果。比如你发现某个聚合站总是转载你已订阅的源,直接设置一条规则把该源的条目自动标记为已读,简单粗暴但很有效。
对于自建方案,FreshRSS和Tiny Tiny RSS都提供了插件机制。Tiny Tiny RSS有社区维护的去重插件,可以按标题相似度合并条目;FreshRSS则可以通过自定义过滤表达式隐藏符合条件的条目。这类方案的优点是不需要写代码,配置成本低,缺点是灵活性有限,遇到复杂的重复模式就无能为力了。
另外一个小技巧是合并重复订阅源。如果你同时在订阅一个站点的全文feed和摘要feed,果断只保留一个,很多时候重复内容的来源就是这么低级。
自建中间层:用脚本实现精准去重
如果阅读器的原生功能满足不了需求,最灵活的方案是在阅读器和订阅源之间加一层自己控制的服务。思路很简单:定时拉取原始feed,去重清洗后再输出成新的feed地址,阅读器订阅这个清洗后的地址即可。
下面是一个用Python实现的去重脚本,核心逻辑是对URL规范化和标题指纹做双重判断:
import hashlib
import re
import feedparser
import sqlite3
# 初始化本地数据库,存储已见条目的指纹
conn = sqlite3.connect('rss_dedup.db')
conn.execute('''CREATE TABLE IF NOT EXISTS seen (
fingerprint TEXT PRIMARY KEY,
title TEXT,
url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
def normalize_url(url):
# 去掉URL中的跟踪参数和锚点
url = url.split('#')[0]
url = re.sub(r'[?&]utm_[^&]+', '', url)
return url.strip().rstrip('/')
def make_fingerprint(entry):
# 标题去空格后取哈希,作为内容指纹
title = re.sub(r'\s+', '', entry.title)
return hashlib.md5(title.encode('utf-8')).hexdigest()
feed = feedparser.parse('https://example-site.com/feed.xml')
clean_entries = []
for entry in feed.entries:
url_fp = normalize_url(entry.link)
title_fp = make_fingerprint(entry)
# URL指纹和标题指纹任一命中都视为重复
exists = conn.execute(
'SELECT COUNT(*) FROM seen WHERE fingerprint IN (?, ?)',
(url_fp, title_fp)
).fetchone()[0]
if exists == 0:
conn.execute('INSERT INTO seen VALUES (?, ?, ?)',
(url_fp, entry.title, url_fp))
conn.execute('INSERT OR IGNORE INTO seen VALUES (?, ?, ?)',
(title_fp, entry.title, url_fp))
clean_entries.append(entry)
conn.commit()
print(f'本批次共 {len(feed.entries)} 条,去重后 {len(clean_entries)} 条')
这个脚本用了双重指纹:URL规范化后的哈希负责拦截参数变化导致的伪重复,标题哈希负责拦截跨站转载。数据库选用SQLite是因为轻量、零配置,适合跑在低配VPS甚至树莓派上。配合cron定时执行,再用一个简单的Web框架把清洗后的条目输出为RSS格式,一个完整的去重中间层就搭好了。
标题哈希有一个明显的短板:只要标题改动一个字就失效了。如果要进一步提升召回率,可以引入simhash这类局部敏感哈希,对正文提取关键词后计算相似度,阈值设在0.9以上判定为重复。simhash的好处是指纹相近的文档哈希值也相近,特别适合这种模糊匹配场景。
去重方案的取舍与注意事项
不同的去重策略各有代价。纯URL去重几乎不会误杀,但拦截不了转载;标题精确匹配实现简单,但容易被一个标点符号绕过;正文相似度判断效果最好,可计算成本高,而且有误杀风险——比如同系列的连载文章正文结构相似,可能被误判为重复。
实际操作中建议分层过滤:第一层用URL规范化拦截参数型重复,这一层可以放心开启;第二层用标题匹配,对判定为重复的条目不要直接删除,先打上标记观察一段时间;第三层的正文相似度判断要谨慎,给一个手动白名单机制,把误杀的源排除掉。去重这件事宁可漏杀,不可误杀,毕竟手动划过一条重复条目只要一秒钟,而错过一篇好文章的损失要大得多。
最后别忘了定期清理指纹数据库。运行半年之后,库里的指纹可能积累到几十万条,查询性能会明显下降。可以设置一个保留策略,比如只保留最近90天的指纹,因为绝大多数重复推送都发生在条目发布后的短时间内,过期数据没有留存价值。把这些细节处理好,你的RSS时间线就能长期保持干净高效。