广告位 · 页头(后台-模块管理-新建模块后替换此区域)

RSS如何实现多端同步?

RSS的多端同步,本质上是在解决一个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还是自己写同步接口,都能设计出适合自己的多端阅读环境。

广告位 · 文章底部(后台-模块管理替换此区域)