RSS的多端同步,本质上是在解决一个RSS协议本身没有定义的问题:阅读状态的管理。RSS源只负责把最新的文章条目推送给所有请求它的客户端,至于你在哪个设备上已经读过哪一篇、收藏了哪一条,它完全不关心。因此,要实现手机、平板、电脑之间的阅读进度一致,就必须在RSS内容流之外增加一层状态存储和同步机制。

一、先搞清楚RSS为什么不能直接多端同步
RSS 2.0 和 Atom 本质上都是一份不断更新的XML清单。以RSS 2.0为例,每个 <item> 元素只包含 <title>、<link>、<description>、<guid>、<pubDate> 等基础字段,没有任何字段用来描述这篇文章是否被你读过。下面是一段典型的订阅源内容:
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Example Feed</title>
<link>https://ipipp.com</link>
<description>示例订阅源</description>
<item>
<title>第一篇文章</title>
<link>https://ipipp.com/post/1</link>
<guid>https://ipipp.com/post/1</guid>
<pubDate>Mon, 01 Jan 2024 08:00:00 GMT</pubDate>
<description>文章摘要</description>
</item>
</channel>
</rss>
既然RSS源不带状态,客户端就只能把阅读记录存在本地。早期桌面阅读器通常用SQLite保存文章链接的哈希值,例如把 <guid> 的值做一次MD5后写入 read_items 表。这种方式在单机场景下没有问题,但当你同时使用手机客户端、电脑客户端和Web阅读器时,每一端都维护着一份互不相通的本地数据库,自然就出现了手机已读、电脑未读的错位。
要实现多端同步,不能只盯着内容源,而应该把目标拆成三块:订阅列表同步、已读状态同步、收藏与标签同步。其中订阅列表变化频率较低,可以通过OPML导入导出来解决;收藏和标签更新频率中等,通常可以在网络空闲时批量同步;已读状态更新最频繁,用户在阅读过程中每打开一篇文章就可能产生一次变更,因此需要准实时或增量同步机制。
二、用第三方托管服务快速实现同步
如果不想自己维护服务,最省事的办法是使用Feedly、Inoreader这类云端RSS服务。它们把订阅源和阅读状态全部存在服务端,客户端只负责拉取和提交变更。以Feedly为例,开发者可以通过OAuth获取访问令牌,然后调用其REST API获取未读列表、标记已读、添加订阅。优点是几乎不用关心后端细节,缺点是数据不在自己手里,而且免费用户通常会受到文章数量或刷新频率的限制。
以下JavaScript示例演示了如何调用Feedly的标记接口,把一篇文章设置为已读状态。标记完成后,所有使用同一账号登录的客户端都会在下次同步时看到更新。
// 使用 Feedly API 将某篇文章标记为已读
const FEEDLY_ACCESS_TOKEN = 'your_oauth_token';
const articleId = 'feed/http://feeds.bbci.co.uk/news/rss.xml?entry=abc123';
async function markAsRead(articleId) {
const response = await fetch('https://cloud.feedly.com/v3/markers', {
method: 'POST',
headers: {
'Authorization': `Bearer ${FEEDLY_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'markAsRead',
type: 'entries',
entryIds: [articleId]
})
});
if (!response.ok) {
throw new Error(`标记失败: ${response.status}`);
}
console.log('已同步到云端');
}
markAsRead(articleId);
在同步流程上,客户端通常不是每次阅读都立刻请求接口,而是在本地先标记已读,再合并批量上报。比如阅读器可以维护一个待提交队列,每读5篇文章或应用切到后台时,把队列中的所有文章ID一次性发送给服务端。这样既减少了请求次数,也能在网络不稳定时保证状态最终一致。
不过,托管服务并非适合所有场景。如果你订阅的RSS源包含公司内部系统、私有博客或敏感行业动态,把完整的阅读清单交给第三方可能并不合适。这时就需要考虑在自己的设备或服务器上搭建同步后端。
三、自建FreshRSS同步服务
FreshRSS是目前最成熟的开源自建方案之一。它用PHP编写,界面轻量,支持多人使用,并且实现了Google Reader API兼容接口。这意味着大量现成的RSS客户端可以像连接Feedly一样直接连接FreshRSS,不需要二次开发。
部署FreshRSS最方便的方式是使用Docker。以下命令会在本机8080端口启动一个实例,并把数据保存在Docker卷中:
docker run -d \ --name freshrss \ -p 8080:80 \ -e TZ=Asia/Shanghai \ -e CRON_MIN='*/15' \ -v freshrss_data:/var/www/FreshRSS/data \ -v freshrss_extensions:/var/www/FreshRSS/extensions \ freshrss/freshrss:latest
容器启动后,访问本机8080端口完成安装。安装向导会让你选择数据库,个人使用可以直接选择SQLite,省去额外数据库服务。安装完成后进入设置,在认证或API相关选项中开启API访问,并设置一个独立密码。FreshRSS的Google Reader API兼容端点一般位于 /api/greader.php,很多客户端在添加账号时需要填写这个地址。
下面这段Python代码展示了如何登录FreshRSS、拉取前50条未读文章并把其中一篇标记为已读。它使用的是旧式Google Reader API流程,先通过 ClientLogin 获取Token,再在所有后续请求中携带该Token。
import requests
base_url = "https://rss.ipipp.com/api/greader.php"
username = "your_username"
password = "your_api_password"
session = requests.Session()
# 登录获取Token
auth_resp = session.post(f"{base_url}/accounts/ClientLogin", data={
"Email": username,
"Passwd": password
})
auth_line = auth_resp.text.splitlines()
token = None
for line in auth_line:
if line.startswith("Auth="):
token = line.split("=", 1)[1]
break
headers = {"Authorization": f"GoogleLogin auth={token}"}
# 获取未读文章ID列表
unread = session.get(f"{base_url}/reader/api/0/stream/items/ids?output=json&n=50&xt=user/-/state/com.google/read", headers=headers)
unread_ids = unread.json().get("itemRefs", [])
# 标记第一篇为已读
if unread_ids:
item = unread_ids[0]
mark_resp = session.post(f"{base_url}/reader/api/0/edit-tag", data={
"i": item["id"],
"a": "user/-/state/com.google/read",
"ac": "edit-tags"
}, headers=headers)
print(f"标记状态: {mark_resp.status_code}")
使用FreshRSS这类自建服务后,手机和电脑只要都配置同一个API地址和账号,已读状态就会存储在服务端数据库中。即使某台设备离线一段时间,上线后重新拉取未读列表也能恢复到最新状态。配合FreshRSS的WebSub支持,新文章到达时还能主动推送通知给客户端,而不必让每台设备反复轮询。
四、设计一套最小可用的同步REST API
如果你有更进一步的定制需求,或者想把RSS同步能力嵌入到自己的产品中,自己设计一套同步接口并不复杂。核心数据模型可以概括为:用户订阅了哪些源,每个源有哪些文章,以及每个用户对哪些文章做了已读标记。下面是一组PostgreSQL建表语句,它把订阅关系、文章条目和已读状态分开存储,方便后续扩展。
CREATE TABLE users ( id SERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL ); CREATE TABLE subscriptions ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), feed_url TEXT NOT NULL, title TEXT, UNIQUE (user_id, feed_url) ); CREATE TABLE entries ( id SERIAL PRIMARY KEY, feed_id INTEGER NOT NULL REFERENCES subscriptions(id), guid TEXT NOT NULL, title TEXT, url TEXT, published_at TIMESTAMPTZ, UNIQUE (feed_id, guid) ); CREATE TABLE read_states ( user_id INTEGER NOT NULL REFERENCES users(id), entry_id INTEGER NOT NULL REFERENCES entries(id), read_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (user_id, entry_id) );
有了表结构后,REST接口可以设计得非常直观:获取未读列表用 GET /api/entries/unread,标记已读用 POST /api/entries/{id}/read,取消已读用 DELETE /api/entries/{id}/read。下面以Node.js Express为例,给出两个核心接口的实现示例。真实项目中还需要加入认证中间件、参数校验和错误处理。
const express = require('express');
const app = express();
app.use(express.json());
// 标记已读
app.post('/api/entries/:id/read', async (req, res) => {
const userId = req.user.id; // 假设认证中间件已设置
const entryId = parseInt(req.params.id, 10);
await db.query(
`INSERT INTO read_states (user_id, entry_id, read_at)
VALUES ($1, $2, now())
ON CONFLICT (user_id, entry_id) DO NOTHING`,
[userId, entryId]
);
res.json({ ok: true });
});
// 获取未读列表
app.get('/api/entries/unread', async (req, res) => {
const userId = req.user.id;
const result = await db.query(
`SELECT e.id, e.title, e.url, e.published_at
FROM entries e
LEFT JOIN read_states r ON r.entry_id = e.id AND r.user_id = $1
WHERE r.entry_id IS NULL
ORDER BY e.published_at DESC
LIMIT 100`,
[userId]
);
res.json(result.rows);
});
在客户端同步策略上,建议引入游标或时间戳,避免每次全量拉取。服务端可以在 entries 表上维护 updated_at 字段,客户端记录最后一次同步时间,下次只拉取此时间之后的新增或更新文章。已读操作则采用批量上报,例如阅读器在本地缓存未提交状态,达到阈值后统一调用 POST /api/entries/read,把一组文章ID一次性提交。
另一个容易忽略的问题是去重。同一个新闻事件可能被多个RSS源重复收录,如果只按 guid 区分,不同源的guid 并不相同。更稳妥的做法是优先使用文章的原始链接URL作为全局唯一键,或对标题和正文摘要计算内容哈希。这样用户在任何一个源读到这篇文章,其他重复条目也能自动被视为已读。
五、方案对比与落地选择建议
把前面提到的三种方案放在一起,可以得到一个清晰的对比:
| 方案 | 优点 | 缺点 | 适合人群 |
|---|---|---|---|
| 第三方托管 | 零运维、实时性好 | 数据不私有、API限额 | 普通用户 |
| 自建FreshRSS | 数据自持、兼容性强 | 需要维护服务器 | 个人或家庭用户 |
| 自研同步接口 | 完全定制、可集成内部系统 | 开发成本高 | 团队或产品开发者 |
对大多数个人用户来说,FreshRSS是最平衡的选择。你不需要从零处理认证、订阅解析和数据库设计,只要在NAS、树莓派或一台便宜的云主机上跑一个Docker容器,然后让所有设备通过Google Reader API协议连接即可。需要注意的是,外网访问时要配置好HTTPS反向代理,避免API密码在明文HTTP中传输。
客户端的选择也会直接影响多端同步体验。支持Google Reader API协议的客户端通常都能对接FreshRSS,例如iOS和macOS上的Reeder、NetNewsWire,Linux上的NewsFlash。配置时尽量关闭客户端的本地全量下载功能,让未读列表由服务端统一管理,这样即便更换设备,也不会因为本地缓存差异造成状态混乱。
从架构角度看,RSS多端同步的核心思想并不复杂:把阅读状态从内容本身中抽离出来,交给一个稳定可靠的中心节点管理。内容流仍然可以采用标准的RSS抓取机制,而状态流则通过API、WebSocket或长轮询保持准实时。理解这一点之后,无论选择托管服务、自建FreshRSS还是自己写同步接口,都能设计出适合自己的多端阅读环境。

