RSS 技术订阅方案实现指南


一、什么是 RSS?

RSS(Really Simple Syndication / Rich Site Summary)是一种基于 XML 的内容聚合格式,用于发布经常更新的信息,如博客文章、新闻标题、播客等。它允许用户通过“RSS 阅读器”订阅网站内容,无需逐一访问各个网站即可获取最新内容更新。

RSS 的核心价值

  • 信息主权:用户自主决定订阅什么,不依赖算法推荐
  • 无广告干扰:纯内容获取,没有广告和追踪器
  • 高效阅读:多个信息源聚合到一个界面
  • 开放协议:任何人都可以构建 RSS 源和阅读器

二、RSS 生态系统概览

一个完整的 RSS 订阅方案包含以下三个核心角色:

┌─────────────┐      RSS Feed (XML)      ┌──────────────┐      展示内容      ┌──────────────┐
│  内容发布端   │  ──────────────────────> │  RSS 聚合服务  │  ──────────────> │  RSS 阅读器   │
│ (网站/博客)   │                         │ (解析/存储)    │                  │ (用户界面)    │
└─────────────┘                          └──────────────┘                  └──────────────┘
  1. 内容发布端:网站生成符合 RSS/Atom 规范的 XML 文件
  2. RSS 聚合/解析服务:负责拉取、解析、存储 RSS 源的内容
  3. RSS 阅读器:将解析后的内容以友好的界面展示给用户

三、RSS 规范格式

3.1 RSS 2.0 格式

RSS 2.0 是目前最广泛使用的版本:

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>示例博客</title>
    <link>https://example.com</link>
    <description>一个关于技术分享的博客</description>
    <language>zh-cn</language>
    <lastBuildDate>Mon, 31 Aug 2026 10:00:00 GMT</lastBuildDate>

    <item>
      <title>如何使用 RSS 订阅技术</title>
      <link>https://example.com/rss-guide</link>
      <description>本文将介绍 RSS 的基本使用方法...</description>
      <pubDate>Mon, 31 Aug 2026 08:00:00 GMT</pubDate>
      <guid>https://example.com/rss-guide</guid>
    </item>
  </channel>
</rss>

3.2 Atom 格式

Atom 是 RSS 的现代化替代方案,由 IETF 标准化:

<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>示例博客</title>
  <link href="https://example.com"/>
  <updated>2026-08-31T10:00:00Z</updated>
  <author>
    <name>作者名</name>
  </author>
  <id>tag:example.com,2026:feed</id>

  <entry>
    <title>如何使用 RSS 订阅技术</title>
    <link href="https://example.com/rss-guide"/>
    <id>tag:example.com,2026:rss-guide</id>
    <updated>2026-08-31T08:00:00Z</updated>
    <summary>本文将介绍 RSS 的基本使用方法...</summary>
  </entry>
</feed>

四、RSS 订阅方案的技术实现

4.1 方案一:基于服务端的 RSS 聚合服务

这是最经典的实现方式,适用于构建类似 Feedly、Inoreader 这样的在线 RSS 阅读器。

架构设计

┌────────────┐     ┌────────────┐     ┌────────────┐     ┌────────────┐
│  定时调度器  │────>│  抓取Worker │────>│  解析引擎   │────>│   数据库    │
│ (Scheduler) │     │  (Fetcher) │     │  (Parser)  │     │ (Storage)  │
└────────────┘     └────────────┘     └────────────┘     └────────────┘


                                                           ┌────────────┐
                                                           │  API 层    │
                                                           │ (REST/GraphQL)│
                                                           └────────────┘


                                                           ┌────────────┐
                                                           │ 前端阅读器  │
                                                           └────────────┘

核心实现步骤

Step 1:RSS 源管理

// 数据模型设计
const FeedSchema = {
  id: 'uuid',
  title: 'string',           // 源名称
  url: 'string',             // RSS 源地址
  siteUrl: 'string',         // 网站地址
  lastFetchedAt: 'datetime', // 上次抓取时间
  fetchInterval: 'number',   // 抓取间隔(秒)
  etag: 'string',            // HTTP ETag(用于条件请求)
  lastModified: 'string',    // Last-Modified 头
  status: 'enum',            // active / error / dead
  errorCount: 'number',      // 连续错误次数
};

const ArticleSchema = {
  id: 'uuid',
  feedId: 'uuid',            // 所属源
  title: 'string',           // 标题
  link: 'string',            // 原文链接
  content: 'text',           // 正文内容(HTML)
  summary: 'text',           // 摘要
  author: 'string',          // 作者
  publishedAt: 'datetime',   // 发布时间
  guid: 'string',            // 全局唯一标识(用于去重)
  isRead: 'boolean',         // 是否已读
  isStarred: 'boolean',      // 是否收藏
};

Step 2:RSS 抓取与解析

import Parser from 'rss-parser';

class RSSFetcher {
  constructor() {
    this.parser = new Parser({
      timeout: 10000,
      headers: {
        'User-Agent': 'MyRSSReader/1.0',
      },
      // 支持自定义字段映射
      customFields: {
        feed: ['subtitle', 'author'],
        item: [
          ['content:encoded', 'contentEncoded'],
          ['media:content', 'mediaContent', { keepArray: true }],
        ],
      },
    });
  }

  async fetchFeed(feed) {
    try {
      const options = {};
      
      // 使用 ETag 和 Last-Modified 实现条件请求,减少带宽
      if (feed.etag) {
        options.headers = {
          ...options.headers,
          'If-None-Match': feed.etag,
        };
      }
      if (feed.lastModified) {
        options.headers = {
          ...options.headers,
          'If-Modified-Since': feed.lastModified,
        };
      }

      const result = await this.parser.parseURL(feed.url);
      
      return {
        title: result.title,
        items: result.items.map(item => ({
          title: item.title,
          link: item.link,
          content: item['content:encoded'] || item.content,
          summary: item.contentSnippet,
          author: item.creator || item.author,
          publishedAt: item.pubDate ? new Date(item.pubDate) : new Date(),
          guid: item.guid || item.link || item.title,
        })),
      };
    } catch (error) {
      console.error(`Failed to fetch feed ${feed.url}:`, error.message);
      throw error;
    }
  }
}

Step 3:定时调度与增量更新

import cron from 'node-cron';

class FeedScheduler {
  constructor(fetcher, db) {
    this.fetcher = fetcher;
    this.db = db;
  }

  // 根据源的更新频率动态调整抓取间隔
  calculateNextInterval(feed) {
    const avgUpdateFreq = this.db.getAverageUpdateFrequency(feed.id);
    
    // 高频更新的源(如新闻站):每 15 分钟
    // 中频更新的源(如博客):每 1 小时
    // 低频更新的源(如周刊):每 6 小时
    if (avgUpdateFreq < 3600) return 900;       // 15 min
    if (avgUpdateFreq < 86400) return 3600;     // 1 hour
    return 21600;                                // 6 hours
  }

  async start() {
    // 每 5 分钟执行一次批量检查
    cron.schedule('*/5 * * * *', async () => {
      const dueFeeds = await this.db.getDueFeeds();
      
      // 并发控制:限制同时抓取的数量
      const concurrency = 10;
      for (let i = 0; i < dueFeeds.length; i += concurrency) {
        const batch = dueFeeds.slice(i, i + concurrency);
        await Promise.allSettled(
          batch.map(feed => this.processFeed(feed))
        );
      }
    });
  }

  async processFeed(feed) {
    try {
      const result = await this.fetcher.fetchFeed(feed);
      
      // 去重:通过 guid 判断是否为新文章
      const existingGuids = await this.db.getArticleGuids(feed.id);
      const newArticles = result.items.filter(
        item => !existingGuids.has(item.guid)
      );

      if (newArticles.length > 0) {
        await this.db.insertArticles(feed.id, newArticles);
        // 推送通知给订阅用户(WebSocket / Server-Sent Events)
        await this.notifySubscribers(feed.id, newArticles);
      }

      // 更新源的元信息
      await this.db.updateFeedMeta(feed.id, {
        lastFetchedAt: new Date(),
        status: 'active',
        errorCount: 0,
        fetchInterval: this.calculateNextInterval(feed),
      });
    } catch (error) {
      await this.db.incrementErrorCount(feed.id);
      
      // 连续错误超过 10 次标记为异常
      if (feed.errorCount + 1 >= 10) {
        await this.db.updateFeedStatus(feed.id, 'error');
      }
    }
  }
}

4.2 方案二:基于 WebSub 的实时推送

WebSub(前身是 PubSubHubbub)是 W3C 推荐的实时订阅协议,它变“拉取”为“推送”,实现了内容的即时分发。

工作流程

  ┌────────┐    1.发布内容    ┌────────┐    2.通知更新    ┌────────────┐    3.拉取内容    ┌────────┐
  │ Publisher│ ─────────────> │  Hub   │ ─────────────> │ Subscriber │ ─────────────> │ Reader │
  │ (发布者) │               │ (中枢) │               │  (订阅者)   │               │ (读者) │
  └────────┘                └────────┘               └────────────┘               └────────┘
  1. Publisher 发布新内容,通知 Hub
  2. Hub 收到通知后,向所有 Subscriber 发送回调
  3. Subscriber 收到回调后,拉取最新内容

实现代码

import express from 'express';
import crypto from 'crypto';

// ===== Subscriber 端实现 =====
class WebSubSubscriber {
  constructor(hubUrl, callbackBaseUrl) {
    this.hubUrl = hubUrl;
    this.callbackBaseUrl = callbackBaseUrl;
    this.app = express();
    this.subscriptions = new Map();
    
    this.setupCallbackEndpoint();
  }

  setupCallbackEndpoint() {
    this.app.post('/callback/:topic', express.text(), (req, res) => {
      const topic = req.params.topic;
      const content = req.body;
      
      // 验证签名(如果 Hub 支持)
      const signature = req.headers['x-hub-signature'];
      if (signature && !this.verifySignature(content, signature)) {
        return res.status(403).send('Invalid signature');
      }

      // 处理新内容
      this.handleNewContent(topic, content);
      res.status(200).send('OK');
    });
  }

  // 订阅某个 RSS 源
  async subscribe(topicUrl) {
    const callbackUrl = `${this.callbackBaseUrl}/callback/${encodeURIComponent(topicUrl)}`;
    
    const params = new URLSearchParams({
      'hub.mode': 'subscribe',
      'hub.topic': topicUrl,
      'hub.callback': callbackUrl,
      'hub.verify': 'async',
    });

    const response = await fetch(this.hubUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: params.toString(),
    });

    if (response.ok) {
      this.subscriptions.set(topicUrl, { status: 'pending' });
    }
  }

  // 验证 Hub 的验证请求
  handleVerification(req, res) {
    const mode = req.query['hub.mode'];
    const topic = req.query['hub.topic'];
    const challenge = req.query['hub.challenge'];
    const leaseSeconds = req.query['hub.lease_seconds'];

    if (mode === 'subscribe') {
      this.subscriptions.set(topic, {
        status: 'active',
        leaseSeconds: parseInt(leaseSeconds),
        subscribedAt: Date.now(),
      });
      res.status(200).send(challenge); // 必须返回 challenge 值
    }
  }

  verifySignature(content, signature) {
    const algorithm = signature.split('=')[0];
    const expectedSig = signature.split('=')[1];
    const hmac = crypto.createHmac(algorithm, this.secret);
    hmac.update(content);
    return hmac.digest('hex') === expectedSig;
  }
}

4.3 方案三:客户端纯前端实现

对于轻量级场景,可以在浏览器端直接实现 RSS 解析和展示。

// 使用浏览器端解析 RSS XML
class ClientRSSReader {
  constructor() {
    this.feeds = new Map();
    this.articles = [];
  }

  async addFeed(url) {
    // 通过 CORS 代理获取(生产环境需要自建代理或使用 CORS 友好的 RSS 源)
    const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`;
    const response = await fetch(proxyUrl);
    const xmlText = await response.text();
    
    return this.parseRSS(xmlText, url);
  }

  parseRSS(xmlText, sourceUrl) {
    const parser = new DOMParser();
    const doc = parser.parseFromString(xmlText, 'text/xml');
    
    // 检测是 RSS 还是 Atom
    const isAtom = doc.querySelector('feed') !== null;
    
    if (isAtom) {
      return this.parseAtom(doc, sourceUrl);
    } else {
      return this.parseRSS2(doc, sourceUrl);
    }
  }

  parseRSS2(doc, sourceUrl) {
    const channel = doc.querySelector('channel');
    const feed = {
      title: channel.querySelector('title')?.textContent || 'Untitled',
      link: channel.querySelector('link')?.textContent || sourceUrl,
      description: channel.querySelector('description')?.textContent || '',
    };

    const items = [...doc.querySelectorAll('item')].map(item => ({
      title: item.querySelector('title')?.textContent || '',
      link: item.querySelector('link')?.textContent || '',
      description: item.querySelector('description')?.textContent || '',
      content: item.getElementsByTagNameNS('*', 'encoded')[0]?.textContent || '',
      pubDate: item.querySelector('pubDate')?.textContent || '',
      guid: item.querySelector('guid')?.textContent || '',
    }));

    return { feed, items };
  }

  parseAtom(doc, sourceUrl) {
    const atomNS = 'http://www.w3.org/2005/Atom';
    const feedEl = doc.querySelector('feed');
    
    const feed = {
      title: feedEl.querySelector('title')?.textContent || 'Untitled',
      link: feedEl.querySelector('link')?.getAttribute('href') || sourceUrl,
    };

    const entries = [...feedEl.querySelectorAll('entry')].map(entry => ({
      title: entry.querySelector('title')?.textContent || '',
      link: entry.querySelector('link')?.getAttribute('href') || '',
      summary: entry.querySelector('summary')?.textContent || '',
      content: entry.querySelector('content')?.textContent || '',
      pubDate: entry.querySelector('updated')?.textContent || '',
      guid: entry.querySelector('id')?.textContent || '',
    }));

    return { feed, items: entries };
  }

  // 按时间排序合并所有源的文章
  getAllArticles() {
    return this.articles.sort((a, b) => 
      new Date(b.pubDate) - new Date(a.pubDate)
    );
  }
}

五、关键技术要点

5.1 RSS 源自动发现

很多网站在 HTML 中嵌入了 RSS 源的链接,可以通过解析 HTML 自动发现:

async function discoverFeedUrl(pageUrl) {
  const response = await fetch(pageUrl);
  const html = await response.text();
  
  // 匹配 <link> 标签中的 RSS/Atom 源
  const feedTypes = [
    'application/rss+xml',
    'application/atom+xml',
    'application/feed+json',
  ];

  const regex = /<link[^>]+type=["']([^"']+)["'][^>]+href=["']([^"']+)["']/gi;
  let match;
  
  while ((match = regex.exec(html)) !== null) {
    if (feedTypes.includes(match[1])) {
      // 将相对路径转为绝对路径
      return new URL(match[2], pageUrl).href;
    }
  }
  
  return null;
}

5.2 内容清洗与正文提取

RSS 源中的内容通常包含大量 HTML 标签,需要进行清洗:

import { JSDOM } from 'jsdom';

function cleanHTMLContent(html) {
  const dom = new JSDOM(html);
  const doc = dom.window.document;
  
  // 移除脚本和样式
  doc.querySelectorAll('script, style, iframe, noscript').forEach(el => el.remove());
  
  // 移除危险属性(onclick 等)
  doc.querySelectorAll('*').forEach(el => {
    [...el.attributes].forEach(attr => {
      if (attr.name.startsWith('on') || attr.value.startsWith('javascript:')) {
        el.removeAttribute(attr.name);
      }
    });
  });
  
  // 保留常用格式标签
  const allowedTags = new Set([
    'p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li',
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code',
    'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
  ]);
  
  // 递归清洗节点
  function sanitizeNode(node) {
    if (node.nodeType === 3) return; // 文本节点保留
    
    if (node.nodeType === 1) { // 元素节点
      if (!allowedTags.has(node.tagName.toLowerCase())) {
        // 用其内容替换掉该标签
        const fragment = dom.window.document.createDocumentFragment();
        while (node.firstChild) {
          fragment.appendChild(node.firstChild);
        }
        node.parentNode.replaceChild(fragment, node);
      } else {
        [...node.childNodes].forEach(child => sanitizeNode(child));
      }
    }
  }
  
  sanitizeNode(doc.body);
  return doc.body.innerHTML;
}

5.3 全文获取(Full-Text RSS)

很多 RSS 源只提供摘要,需要额外抓取全文:

import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';

async function fetchFullText(articleUrl) {
  const response = await fetch(articleUrl);
  const html = await response.text();
  
  const dom = new JSDOM(html, { url: articleUrl });
  const doc = dom.window.document;
  
  // 使用 Mozilla Readability 算法提取正文
  const reader = new Readability(doc);
  const article = reader.parse();
  
  return {
    title: article.title,
    content: article.content,
    textContent: article.textContent,
    excerpt: article.excerpt,
  };
}

六、性能优化策略

6.1 抓取层优化

策略 说明
条件请求 使用 ETag / If-None-MatchLast-Modified / If-Modified-Since 减少无效传输
增量解析 只处理新文章,通过 guid 去重
自适应频率 根据源的实际更新频率动态调整抓取间隔
并发控制 使用连接池限制并发数,避免被目标站点封禁
失败退避 连续失败时采用指数退避策略(1min → 2min → 4min → …)

6.2 存储层优化

// 使用 Redis 缓存热门文章,数据库持久化全量数据
const CACHE_TTL = 3600; // 1 小时

async function getArticles(userId, page, pageSize) {
  const cacheKey = `user:${userId}:articles:${page}:${pageSize}`;
  
  // 先查缓存
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
  
  // 查数据库(使用游标分页替代 OFFSET 提升性能)
  const articles = await db.query(`
    SELECT a.* FROM articles a
    JOIN subscriptions s ON a.feed_id = s.feed_id
    WHERE s.user_id = $1
    ORDER BY a.published_at DESC
    LIMIT $2
  `, [userId, pageSize]);
  
  // 写入缓存
  await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(articles));
  return articles;
}

6.3 推送层优化

使用 Server-Sent Events (SSE) 实现服务端向客户端的实时推送:

// 服务端
app.get('/api/stream', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  // 注册此连接到消息总线
  const listener = (article) => {
    res.write(`data: ${JSON.stringify(article)}\n\n`);
  };
  
  eventBus.on(`user:${req.userId}:newArticle`, listener);
  
  req.on('close', () => {
    eventBus.off(`user:${req.userId}:newArticle`, listener);
  });
});

七、OPML 导入导出

OPML 是 RSS 订阅列表的标准交换格式,支持导入导出是 RSS 阅读器的基本功能:

// 导出为 OPML
function exportOPML(subscriptions) {
  const outlines = subscriptions.map(sub => 
    `<outline type="rss" text="${escapeXml(sub.title)}" 
              title="${escapeXml(sub.title)}" 
              xmlUrl="${escapeXml(sub.feedUrl)}" 
              htmlUrl="${escapeXml(sub.siteUrl)}"/>`
  ).join('\n      ');

  return `<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
  <head>
    <title>My RSS Subscriptions</title>
    <dateCreated>${new Date().toUTCString()}</dateCreated>
  </head>
  <body>
    ${outlines}
  </body>
</opml>`;
}

// 解析 OPML 导入
function parseOPML(xmlText) {
  const dom = new DOMParser().parseFromString(xmlText, 'text/xml');
  const outlines = dom.querySelectorAll('outline[xmlUrl]');
  
  return [...outlines].map(outline => ({
    title: outline.getAttribute('text') || outline.getAttribute('title'),
    feedUrl: outline.getAttribute('xmlUrl'),
    siteUrl: outline.getAttribute('htmlUrl'),
  }));
}

八、现代 RSS 服务对比

服务 特点 协议支持 开源
Feedly 商业级体验,AI 辅助筛选 RSS, Atom
Inoreader 功能全面,支持规则过滤 RSS, Atom
Miniflux 轻量极简,Go 实现 RSS, Atom
FreshRSS 功能丰富,PHP 实现 RSS, Atom
Tiny Tiny RSS 插件生态丰富,PHP 实现 RSS, Atom
RSSHub 万物皆可 RSS,生成源 自定义

九、总结

RSS 订阅方案的技术实现可以归纳为三个层次:

  1. 基础层:XML 解析与内容提取 —— 理解 RSS/Atom 规范,正确解析源内容
  2. 服务层:定时抓取、增量更新、条件请求 —— 构建高效可靠的聚合后端
  3. 体验层:实时推送、全文获取、内容清洗 —— 提供优质的阅读体验

对于个人项目,可以选择“纯前端方案”快速验证;对于生产级产品,建议采用“服务端聚合 + WebSub 实时推送”的组合方案,兼顾可靠性与实时性。

RSS 作为开放的信息聚合协议,在算法推荐横行的今天,为用户保留了“主动选择信息”的权利。掌握 RSS 技术实现,不仅是工程能力的体现,更是对信息自由理念的践行。