先看一个人追五个站点的现场
设想一个具体的工作日。09:10,我打开 A 站的首页,扫一眼有没有新文章;09:18,切到 B 站的归档页;09:26,在 C 站的「最新动态」里翻找;D 站只挂了一个订阅图标,看不出最近更新时间;E 站上周改版,文章列表挪到了另一条路径上。
半小时之后,得到的结论是「好像有三篇是新的,其中一篇不确定上周是不是已经看过」。
问题不在于更新太少,而在于每个站点都只把更新呈现给人看。浏览器里是一张排版好的页面,没有一份「我更新了哪些条目」的固定答案,机器读不了,也就没法替人做比对。
更麻烦的是两个问题会缠在一起:某篇文章是不是上周已经读过,页面不会告诉你,只能靠记忆。人记不住五份互不相干的列表,机器可以——前提是这份列表有一个固定的结构。
要解决它,需要的不是更勤快地刷新,而是一份公开、结构固定、按约定生成的更新列表:站点生成一次,任何阅读器取走后都能解析、比对、显示。这份列表就是订阅源(feed),最常见的一种格式是 RSS1。
一份 XML 就是 channel 加若干 item
RSS 是一份 XML 文档,结构只有两层:一个 channel 说明「这是谁的站」,下面挂若干 item 说明「更新了哪几篇」。
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>程云来的博客</title>
<link>https://www.chengyunlai.top/zh/blog</link>
<description>程云来的技术文章</description>
<item>
<title>RSS(一):一份给阅读器看的更新列表</title>
<link>https://www.chengyunlai.top/zh/blog/rss-what-a-feed-is</link>
<guid isPermaLink="false">rss-what-a-feed-is</guid>
<pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
<description>从一天追五个站点的现场出发……</description>
</item>
</channel>
</rss>按规范,channel 的 title、link、description 是必需元素;item 的所有子元素都是可选的,但 title 与 description 至少要有一个(RSS 2.0 规范,访问于 2026-09-28)。
每个字段各回答一个具体问题:
| 元素 | 回答的问题 | 规范里的约束 |
|---|---|---|
channel.title / link / description | 这是谁的站、主页面在哪、一句话是什么 | 三者在 channel 上都是必需的 |
item.title | 这篇叫什么 | 与 description 至少存在一个 |
item.description | 在列表里显示什么摘要 | 允许 entity-encoded HTML |
item.pubDate | 什么时候发的 | RFC 822 日期,年份四位优先 |
item.link | 点进去看哪一页 | 可选 |
item.guid | 这是哪一篇 | 可选,无语法约束,唯一性由源站保证 |
字段很多,但「列表能被比对」这件事只靠其中两个完成:pubDate 让阅读器知道新旧,guid 让阅读器知道两次取到的是不是同一篇。link 只决定点开之后去哪里。
guid 决定「哪篇已经读过了」
阅读器并不知道你看过什么,它只知道上次取回的列表里有哪些条目。要判断「这篇是新的」,它需要一个稳定的身份。规范对 guid 的措辞是:
guid stands for globally unique identifier. It's a string that uniquely identifies the item. When present, an aggregator may choose to use this string to determine if an item is new.
换句话说,阅读器只需要维护两样东西:一份「上次见过的 guid」集合,以及每个 guid 的已读状态。看到集合里没有的 guid,就把它标成未读;看到已经存在的,什么都不做。整个过程它不必理解文章内容,也不需要访问站点以外的任何信息。
规范接着说,guid 没有语法规则,聚合器必须把它当字符串看,唯一性由源站自己保证;isPermaLink 属性默认为 true,此时读者可以把它当作一个能在浏览器里打开的永久链接。
由此得到一条容易被忽视的工程约束:guid 一旦发布就不能变。如果生成时把当前时间戳、或者带 ?utm_source=rss 的链接写成 guid,每次请求都会得到一个新字符串,阅读器就会把全部旧文重新推给你。guid 也不能重复使用:两篇不同的文章共用同一个 guid,后一篇永远不会出现在未读里。
本站把 guid 写成条目链接 https://www.chengyunlai.top/zh/blog/{slug}(见 src/app/api/rss/route.ts 里生成 link 与 guid 的那一处 —— 两者用的是同一个值)。这条选择带来一个连带后果:slug 就是 guid 的一部分。LangGraph 那本小册在重编章节号时,正文文件从 01-... 改成 05-...,却在 frontmatter 里显式写回了旧 slug——保住旧地址的同时,也保住了订阅者本地的已读记录(仓库提交 e6584b2)。
列表是「拉」模型
查看 Mermaid 源码
flowchart LR
site["站点<br/>只发布一份 feed 文件"] -->|构建时生成| feed["/api/rss<br/>channel + 若干 item"]
readerA["阅读器 A<br/>本地保存已读 guid"] -->|GET| feed
readerB["阅读器 B<br/>本地保存已读 guid"] -->|GET| feed
feed -->|返回同一份列表| readerA
feed -->|返回同一份列表| readerB
classDef store fill:#eff6ff,stroke:#2563eb,color:#0f172a
classDef actor fill:#fff7ed,stroke:#ea580c,color:#0f172a
class site,feed store
class readerA,readerB actor站点只发布一份文件,两个阅读器各自来取,各自在本地维护自己那份已读集合;站点既不知道谁订阅了,也没有「推送」这个动作。这就是拉(pull)模型。
它的好处是站点侧成本接近零:不需要注册、不需要存储订阅关系、不需要投递状态、也不关心读者用哪个客户端。代价同样明确:站点无法主动通知任何人,阅读器只能自己决定多久来看一次。
而这正是下一章的问题:一个订阅了几千个源的阅读器,凭什么不每次都把全部内容完整拉一遍。
参考资料
- RSS 2.0 Specification(官方规范,访问于 2026-09-28)
Footnotes
-
RSS 2.0 Specification(RSS Advisory Board,version 2.0.11,2009-03-30;访问于 2026-09-28):给出
channel必需元素、item子元素、guid与pubDate的规范措辞。 ↩