RSS源通常只是一个符合RSS 2.0或Atom规范的XML文档,它本身没有内置任何通知机制。浏览器或阅读器看到的自动更新,实际上是由订阅客户端周期性地重新下载该XML文件,再与本地缓存做比对。由于XML文件体积不大,定时轮询在很长一段时间里都是主流方案,但全量拉取会浪费流量,尤其是订阅大量源时会更明显。因此,现代RSS客户端普遍采用HTTP条件请求来减少无效传输。

RSS源更新的本质:轮询与HTTP条件请求
RSS源没有像WebSocket那样的长连接推送能力,订阅端拿到的就是一个URL。客户端每次访问这个URL,服务器返回最新的feed内容。轮询就是按固定时间间隔发起HTTP GET请求,例如每10分钟、每30分钟一次。间隔越短,更新越及时,但也会给源站带来压力,甚至触发限流。很多自建RSS服务会区分热门源和冷门源,对活跃源采用较短间隔,对更新缓慢的源拉长到几小时。
为了优化轮询,HTTP协议提供了条件请求机制。源服务器在第一次响应时可以返回ETag和Last-Modified头,客户端在后续请求中带上If-None-Match或If-Modified-Since。如果内容没有变化,服务器直接返回304状态码,并且不返回响应体。这样一次轮询可能只消耗几百字节的头部流量,而不是整个XML文档。下面是一个使用curl发送条件请求的示例:
# 第一次请求,记录 ETag 和 Last-Modified curl -I https://ipipp.com/feed.xml # 后续请求带上条件头 curl -H "If-None-Match: \"abc123\"" -I https://ipipp.com/feed.xml
用ETag与Last-Modified实现条件请求
ETag是实体标签,通常由服务器根据文件内容哈希或修改时间生成。Last-Modified则是文件最后修改时间。两者可以同时使用,也可以单独使用,同时使用时ETag优先级更高。以Python Flask为例,服务端可以读取feed文件,计算ETag,并检查请求头中的If-None-Match,命中则返回304。
import hashlib
from flask import Flask, request, make_response
app = Flask(__name__)
@app.route('/feed.xml')
def feed():
with open('feed.xml', 'rb') as f:
content = f.read()
etag = hashlib.md5(content).hexdigest()
if request.headers.get('If-None-Match') == etag:
return make_response('', 304)
resp = make_response(content)
resp.headers['ETag'] = etag
resp.headers['Content-Type'] = 'application/xml'
return resp
这个例子用MD5哈希作为ETag,也可以使用弱ETag加上W/前缀表示内容语义等价但不完全相同。客户端在收到304后,应该继续使用本地缓存的feed副本,并重置下次轮询时间。需要注意的是,有些CDN或反向代理会忽略条件头,反而返回200,因此客户端不能完全依赖304,仍然需要处理重复内容。
使用Last-Modified时,服务器返回的时间格式必须符合HTTP日期规范,客户端在If-Modified-Since中带上相同格式。相比ETag,Last-Modified的精度通常只有秒级,如果feed在同一秒内更新两次,可能会漏更新。因此许多阅读器会同时保存两个标识,并在源支持的情况下优先发送If-None-Match。
WebSub:让源更新后主动通知订阅端
轮询无论怎么优化,都存在平均延迟等于轮询间隔一半的问题。WebSub协议(原名PubSubHubbub)就是为解决这一问题设计的。它引入了一个中间枢纽Hub,源站发布内容后通知Hub,Hub再向所有订阅者发送回调。订阅端只需提供一个可公开访问的回调URL,就能在几秒内收到更新通知,而不必时刻轮询。
WebSub的工作流程分为三步。第一步,源站在feed中声明一个或多个Hub地址,通常使用Atom规范的link元素,并将rel属性设为hub。第二步,订阅端发现Hub后,向Hub发送订阅请求,携带主题URL和回调URL。第三步,发布者更新内容后向Hub发起ping,Hub收到ping后验证订阅关系,然后向各个订阅者的回调地址POST通知。订阅者收到通知后,再重新拉取feed即可。
在feed中声明Hub的XML片段如下:
<link rel="hub" href="https://ipipp.com/hub" /> <link rel="self" href="https://ipipp.com/feed.xml" />
客户端实现回调端点时,需要区分普通浏览请求和Hub的验证请求。Hub在订阅时会先发送一个GET请求,要求回调返回hub.challenge参数的值;在内容更新时则发送POST请求,回调只需要返回2xx状态码。下面是一段基于Flask的回调处理代码:
from flask import Flask, request
app = Flask(__name__)
@app.route('/callback')
def callback():
mode = request.args.get('hub.mode')
topic = request.args.get('hub.topic')
challenge = request.args.get('hub.challenge')
if mode == 'subscribe':
return challenge
return 'ok', 200
@app.route('/callback', methods=['POST'])
def receive_update():
print('Feed updated, re-fetch now')
return 'ok', 200
WebSub的优点很明显,但它要求订阅端有公网回调地址,且能承受Hub发来的并发通知。对于个人部署的RSS服务,如果运行在NAT后面或者没有固定域名,可以借助内网穿透或第三方Hub来中转。并非所有源都支持WebSub,因此轮询仍然需要保留,通常作为兜底策略。
构建一个可用的自动更新调度器
单纯发送请求并不难,难点在于把条件请求、解析、去重和任务调度组合起来。假设我们使用Python的feedparser库解析RSS,用APScheduler做定时任务,用SQLite存储已读条目标识,更新逻辑可以归纳为:定时从数据库取出所有订阅源,逐个请求,如果返回304则跳过;如果返回200则解析后按GUID或链接去重,新条目入库并通知用户。
import time
import feedparser
import requests
import sqlite3
from apscheduler.schedulers.blocking import BlockingScheduler
def fetch_feed(url, etag=None, modified=None):
headers = {}
if etag:
headers['If-None-Match'] = etag
if modified:
headers['If-Modified-Since'] = modified
resp = requests.get(url, headers=headers, timeout=15)
if resp.status_code == 304:
return None, etag, modified
resp.raise_for_status()
parsed = feedparser.parse(resp.content)
new_etag = resp.headers.get('ETag')
new_modified = resp.headers.get('Last-Modified')
return parsed, new_etag, new_modified
def check_loop():
feeds = [('https://ipipp.com/feed.xml', None, None)]
for url, etag, modified in feeds:
parsed, new_etag, new_modified = fetch_feed(url, etag, modified)
if parsed is None:
continue
for entry in parsed.entries:
guid = entry.get('id', entry.get('link'))
print(guid)
scheduler = BlockingScheduler()
scheduler.add_job(check_loop, 'interval', minutes=15)
scheduler.start()
这段代码没有真正实现数据库读写和用户通知,但展示了核心骨架。调度间隔需要根据源的实际更新频率动态调整。例如,如果某个源已经连续三次返回304,可以将其间隔从15分钟延长到60分钟;如果某个源频繁有新增条目,则缩短间隔。这样能在及时性和资源占用之间取得平衡。
去重是另一个容易忽略的问题。RSS条目中的GUID有时会缺失,有些源会用链接作为唯一标识,但链接可能带有跟踪参数导致变化。稳妥的做法是同时比较GUID、链接和发布时间,必要时对标题做归一化处理。对于不支持条件请求的历史源,客户端每次都会收到完整feed,因此更需要依靠条目GUID去重,避免重复发送通知。

