程云来 / 杭州
返回文章·RSS
·约 19 分钟

RSS(四):本站的订阅地址是怎么长出来的

回到这个仓库,讲订阅地址 /api/rss 的四处真实坑位:standalone 镜像里没有 content/、绝对地址被再拼一次 baseURL、条目链接为什么要给终态地址、以及子路径部署下入口为什么会消失;另附为什么这份 feed 刻意不带 Last-Modified。


订阅地址是什么

本站的订阅地址是 https://www.chengyunlai.top/api/rss。入口是 src/app/api/rss/route.ts(一个 App Router 的 Route Handler),而「一篇文章怎么变成一条合法 <item>」这段纯逻辑在 src/lib/rss.ts。拆开的理由正是第三章末尾那两条:生成与验收都不能自证,而要跑线上那份生成代码,它就得能被裸 Node 直接加载 —— Route Handler 依赖 next/server 与 @/resources,加载不了。

它按第一章的结构输出 channel 加若干 item,条目按 publishedAt 从新到旧排序;标题与属性值做 XML 转义,摘要走 CDATA 并对 ]]> 做边界拆分,配图走 enclosure。

按 2026-09-28 的构建产物 .next/server/app/api/rss.body,它有 25 个 item(其中 4 个就是这本书的章节)。这个数字后面还会用到:它是判断「这份文件是不是空的」的唯一依据。

前面几章讲的规范细节,在这一章会换成四个具体故障。它们共同的特点是:本地 npm run dev 和 npm start 都看不出问题。

坑一:生产镜像里没有 content/,运行时读到 0 篇

现象。 线上 /api/rss 长期是一个「频道信息齐全、0 个 item」的空文件:标题、描述、头像都在,列表是空的。

原因。 这个路由原本在运行时调用 getContent("posts"),而它读的是 process.cwd()/content/posts。生产镜像是 output: "standalone",runner 阶段只 copy 一份显式清单——server.js、node_modules、.next、.next/static、public、blog.config.json(Dockerfile 第 28–36 行),里面没有 content/。构建机上有内容目录,运行容器里没有。

正在绘制图表…
查看 Mermaid 源码
flowchart LR
    subgraph build["构建阶段:content/ 一定在"]
        src["content/posts"] --> gen["route.ts 读取文章"]
        gen --> body[".next/server/app/api/rss.body<br/>25 个 item"]
    end
    subgraph run["运行阶段:镜像里没有 content/"]
        req["GET /api/rss"] --> serve["直接返回已生成的文件"]
    end
    body --> serve

    classDef stage fill:#eff6ff,stroke:#2563eb,color:#0f172a
    classDef out fill:#fff7ed,stroke:#ea580c,color:#0f172a
    class src,gen,req stage
    class body,serve out

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

修复。 加一行 export const dynamic = "force-static"(就在 route.ts 顶部),把这次读取挪到构建阶段——那里 content/ 一定存在,产物跟着镜像一起走,运行时不再需要内容目录。

这里有一个值得说清的判断:force-static 是绕过问题还是解决问题?我的判断是后者的一种形式。真正的原因是「构建环境与运行环境看到的文件集不同」,正确的做法是把工作放在文件集完整的那一侧;而在容器里补拷一份 content/ 只是把差异抹平一次——镜像会因此带上整个内容库,下一次新增目录时仍可能漏掉。

验证。 在构建产物上数条目,而不是打开浏览器看:

bash
grep -c "<item>" .next/server/app/api/rss.body   # 应当大于 0

坑二:已是绝对地址的图片被再拼一次 baseURL

现象。 频道 image 里的头像曾经是 https://www.chengyunlai.tophttps://oss.chengyunlai.top/... 这种地址。配图和封面走的是同一段逻辑,所以它们是同一个故障的潜在受害面。

原因。 原来的写法是把配置值无条件拼在站点域名后面:${baseURL}${person.avatar}。而 blog.config.json 里的 person.avatar 本身已经是一个 OSS 绝对地址(https://oss.chengyunlai.top/my-info/58059527.png)。两个绝对地址接在一起,得到的是一个语法上完全合法、语义上完全错误的 URL——XML 良构,所有检查都过,只有真的打开这张图时才发现是 404。

修复。 加一个能区分两种来源的判断(route.ts 里的 absoluteUrl()):

javascript
const absolute = (value) =>
  /^https?:\/\//.test(value) ? value : `${baseURL}${value}`;

同一条函数同时管住了频道头像和每条 enclosure。可以推广成一句规则:配置里的地址有两种来源——站点相对路径和外部 CDN 绝对地址——拼接函数必须能识别两者,否则错误会安静地穿过所有 XML 检查。

坑三:条目链接要给终态地址

link 与 guid 用的都是终态地址 https://www.chengyunlai.top/zh/blog/{slug}(两者取同一个值),而它们原本是 /blog/{slug}。

本站是中文单语,locales 只有 zh,不带语言前缀的 /blog 会被 next-intl 中间件重定向到 /zh/blog。所以如果 feed 里写的是 /blog/...,每个订阅者、每个抓取器在点开每一条时都要多走一次重定向(本站是 307)。

正在绘制图表…
查看 Mermaid 源码
flowchart LR
    subgraph bad["link 写成 /blog/x"]
        r1["订阅者与爬虫"] --> b1["/blog/x"] --> b2["307 跳转"] --> b3["/zh/blog/x"]
    end
    subgraph good["link 写成 /zh/blog/x"]
        r2["订阅者与爬虫"] --> g1["/zh/blog/x"]
    end

    classDef slow fill:#fff7ed,stroke:#ea580c,color:#0f172a
    classDef fast fill:#eff6ff,stroke:#2563eb,color:#0f172a
    class b1,b2,b3 slow
    class g1 fast

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

一次重定向本身不贵,但它是「每条 × 每个订阅者 × 每次抓取」的乘法,而 feed 恰好是一份会被反复读取的文件。里面的每个地址都值得写成终态。

补一条不显眼但重要的连带关系:guid 用的是同一个值,所以这个地址选择同时决定了订阅者本地的已读记录(第一章)。以后要改 URL 结构,得把这两件事一起考虑——改 slug 就是改 guid。

坑四:换个子路径部署,订阅入口就没了

前面三个坑都在 feed 的内容里。这一个是关于 feed 在哪里被找到,它的失败方式最安静:页面一切正常。

现象。 站点部署到域名根目录时,/api/rss 就是订阅地址,一切正常。部署到某个子路径(本站的 GitHub Pages 构建就是这种情况,前缀是 /lai-simply-blog)之后,页脚那个 RSS 链接和每个页面 head 里的自动发现链接都指向域名根目录,也就是指向一个不存在的东西。页面不会报错,feed 本身也仍然正常,只是没有任何阅读器能自动发现它。

原因。 Next 的 basePath 只自动作用于 next/link 与 next/image。手写的 <a href="/api/rss">、静态的 <link rel="alternate" href="…">,以及我们自己用字符串拼出来的绝对地址,它一概不管。于是同一个前缀在四处被漏掉:页脚入口、head 里的自动发现、feed 自己的 atom:link rel="self"、以及每条 item 的 link。

修复。 把这个前缀收成唯一的来源,让这四处都从它拼。三个要点:

  1. 不要用配置里那个 basePath 顶替。 本站 blog.config.json 里的 site.basePath 是静态资源前缀(封面、头像),与构建时由 next.config.mjs 决定的路由前缀是两件事,恰好都叫这个名字。用错了不会报错,只会让一批地址多一段或漏一段。
  2. 页脚那条要有意地用相对路径(${routeBasePath}/api/rss),而不是绝对地址 —— 预览域名上它也能点。
  3. 两处拼接规则必须同进同退。 feed 里的条目地址(src/lib/rss.ts 的 pageUrlFor())和站点的 canonical / sitemap(src/lib/seo.ts 的 pageUrl())是同一个格式的两次实现。它们分在两个文件里是因为前者要保持零依赖(第三章末尾讲的加载约束),但格式一旦分叉,同一篇文章会在 feed 和 sitemap 里指向两个地址。

契约对这一条的问法是逐页的:遍历产物里所有 /zh 页面,要求自动发现链接的 href 与页脚入口的 href 都等于 ${basePath}/api/rss。只抽查首页会给出假绿 —— 首页恰好常常是唯一被手工确认过的那一页。

这份文件在哪里被看见

feed 自己声明了自身地址:atom:link rel="self"。rel="self" 表示这个链接指向与所在元素等价的资源,定义在 RFC 4287 §4.2.7.2(Atom Syndication Format,访问于 2026-09-28)。

但「feed 知道自己」和「阅读器、人知道它」是三件不同的事,后两件要单独补。

自动发现。 link rel="alternate" type="application/rss+xml" 是读者把一个站点地址粘进阅读器之后,阅读器找到 feed 的唯一依据(RSS Autodiscovery)。

这里有一步看起来「自然而然」、实际会写错:不要把它交给框架的 metadata 声明。以 Next 为例,generateMetadata 返回的 alternates 是按键浅合并的——只要页面自己写了 alternates: { canonical },父层 layout 里那个 alternates(连同 types)就会被整个覆盖。结果是这条 link 只在「没有自己声明 alternates 的那几页」存在;而首页通常不在其中 —— 于是你手查首页时看着像已经生效。

本站把它写成 layout <head> 里的静态标签,不受合并规则约束,每个页面都有。但静态标签也意味着它不受框架的 basePath 影响(坑四),所以 href 必须自己带上那个前缀。判据也落进了契约:遍历构建产物里的所有 .html,逐页要求这条 link 的 href 正确。只查首页会给出假绿。

可见入口。 自动发现解决机器怎么找;人需要一个能点的地方。本站放在页脚,每一页都在,成本是零。

两件事必须同时做,理由不对称:只在 head 里声明,人找不到入口;只在页脚放链接,阅读器又不会自己发现。

怎么验证,而不是靠肉眼

仓库的做法是断言在构建产物上,而不是打开浏览器看(tools/verify/run-checks.sh 的说明里就写着:收录契约直接读 .next,不打服务;每套的判据都落成可执行的数字)。RSS 这套是 tools/verify/rss/verify-rss.mjs,排在全套的第二位 —— 紧跟收录契约,理由相同:两者都要扫产物里的 .html,而任何一次请求都会让 Next 把渲染结果落盘成新的页面,之后再扫就会看到凭空多出来的东西。

产物层它断这些:

  • feed 良构 —— 而且这一条有两个独立判据:契约自己的手写状态机,以及 python3 标准库的 expat(装了 xmllint 再跑一遍)。三者结论不一致即失败
  • item 数 大于 0(直接回归「频道齐全、0 个 item」那个故障)
  • 每条 item 的标题都能在中文侧源文件里找到 —— 这是「locale 必须显式传」那条修复的回归测试
  • guid 全局唯一、与 link 一致;每个 link 都是终态绝对地址(含 basePath)
  • enclosure 只有「三属性齐全」和「整条省略」两种形态,没有两属性的残件
  • lastBuildDate 由内容决定 —— 没有 updatedAt 声明时它必须等于最新的 pubDate,不能是构建时刻
  • 全文档没有 https://…https://… 这种二次拼接
  • 每个页面都有自动发现 link 且 href 正确;带页脚的页面都有可见订阅入口

良构性那一条要单独说,因为它同时踩过两个坑。第一个坑是不能用正则判:正则判 XML 会同时给出假绿与假红 —— &amp; 和裸 & 都含 &;]]> 出现在 CDATA 内和文本里含义完全相反;而 &#0; 与控制字符它根本认不出来。所以契约里是一个手写的状态机:跳过 CDATA / 注释 / 处理指令,开始标签入栈、结束标签必须与栈顶同名,文本节点与属性值里查裸 &,再按 XML 1.0 §2.2 的 Char 产生式逐个校验字符引用。

第二个坑更值得记:这个手写状态机自己也漏过 &#0; —— 它只校了「码点在 Unicode 范围内」,没校「码点在 XML 允许的范围内」。补上之后仍然不够,因为「我改好了」不是一个判据。契约现在额外跑一遍 expat,让它与手写状态机对答案:两个实现结论不一致就失败。判据不能自己判自己的卷子 —— 手写实现的那个洞,是靠一个不是自己写的实现照出来的。

顺带一条纪律:一条断言如果没有覆盖到,要说出来。 这次构建的 25 条 item 一条 enclosure 都没有(内容里没有一篇配 image),契约因此打印一行「这条断言这次没有实际覆盖到」,而不是安静地判绿 —— 不然「enclosure 形态合法」会被读成「这一块验过了」。

服务层它另外断响应头,并在报告里如实记下探测结果。2026-09-28 本地 next start 的实测:content-type 是 application/rss+xml; charset=utf-8、cache-control 是 public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400、响应带着 ETag、不带 Last-Modified;带 If-None-Match 复取仍然返回 200。

最后这一条值得说清,因为它太容易被写成一句漂亮话:设了 ETag 不等于会返回 304。Next 的静态 route handler 自己不比较 If-None-Match(handler 在构建期就跑完了,运行时由框架直接吐静态内容);单纯反代的 nginx 也不做条件判断,不开 proxy_cache 时同样照常 200。304 是部署层的能力,不是生成端的。所以契约把这条写成只探测、不判失败,并把结论打印出来 —— 判失败的仍然只有「响应没有 ETag」。在这个仓库当前的部署里,条件请求的省流收益是零;ETag 是留给将来的 CDN / 反向代理用的。

为什么这份 feed 不带 Last-Modified

ETag 之外本来还有一个候选:Last-Modified。它被拿掉了,理由值得单独写一段,因为它是一条坏起来很安静的响应头。

Last-Modified 会被 If-Modified-Since 直接消费:客户端把它原样送回来,服务端比较「我的版本比它新吗」。由此得出两条硬约束 —— 它必须单调不回退,也必须真的是内容的时间。值给旧了,客户端就会拿着 304 一直留旧内容,而页面、日志、XML 良构性检查全都看不出异常。

按这两条约束筛一遍可用的来源:

  1. 源文件的 mtime。 容器构建里不可靠:CI 的 actions/checkout 会把所有文件写成检出时刻,于是这个值基本等于「部署时间」而不是「内容时间」。更麻烦的是删掉一篇刚改过的旧文章会让这个最大值回退 —— 正好制造出会误判 304 的那种输入。
  2. 最新文章的 publishedAt。 这就是修之前的状态:改标题、改摘要、删一篇旧文章都不动它。正文已经变了,时间却不变;将来真有人基于它做条件请求,就会漏掉这次更新。
  3. 作者写在 frontmatter 里的 updatedAt。 跟着内容进版本库,可复核、单调、与构建机无关。

只有第三种算数。所以规则很短:有 updatedAt 声明才给这个头,没有就整个省略,绝不拿别的时间顶上。 一个既不在页面上、也不在正文里的错误时间,读者和缓存都无从发现 —— 省略只是「我们不知道」,顶替是「我们在编」。

本站当前没有一篇文章声明 updatedAt,所以线上这个头是不存在的。要让它出现,改一篇旧文章时在 frontmatter 里补一行:

yaml
publishedAt: "2026-09-28"   # 首次出现的时间,不改
updatedAt: "2026-10-05"     # 正文最后一次变动的时间

两者的分工在 feed 里也是分开的:<pubDate> 始终用 publishedAt(改标题不该让一篇旧文章跳回列表顶部),而 updatedAt 只参与 lastBuildDate 与那个响应头。缓存校验的主力因此仍然是 ETag:它是正文的哈希,精确、不受时钟影响,也不需要构建机与阅读器对表。

bash
# 手动核对要用真解析器(契约里的手写状态机只是零依赖的兜底,外加一遍 expat 对答案)
xmllint --noout .next/server/app/api/rss.body

# 响应头与条件请求只能实测
curl -sI https://www.chengyunlai.top/api/rss | grep -i 'content-type\|cache-control\|etag\|last-modified'
curl -sI -H 'If-None-Match: "<上面取到的 etag>"' https://www.chengyunlai.top/api/rss | head -1

仓库的约定记录在 docs/context.md 里——小册机制、公开入口、验证脚本的位置都写在那一份文件里;以后这类能力有变更,描述要跟着一起改。

四章回到同一件事

第一章给出的是一份公开、可机读的更新列表;第二章说明这份列表怎么被合理地反复取走,以及哪些状态注定只能留在读者本地;第三章说明它怎么被正确地写出来;本章说明它在真实仓库里怎么落地——而四处坑位正好落在四条线上:内容从哪来(构建期与运行期的文件集)、地址怎么拼(属性值里的 URL)、身份怎么写稳(终态链接即 guid),以及这份文件在哪里被找到(框架管不到的手写 URL)。

什么时候值得做 RSS?它给读者的承诺只有一句话:不用回来刷页面。代价是维护一份 XML 的正确性(转义、guid 稳定性、地址终态)和一条构建期管道。对静态博客,这笔成本基本是一次性的;反过来,如果内容主体是视频、长图或交互页面,RSS 能给出的只是标题和一个链接,收益会按内容类型递减。

本站的订阅地址:https://www.chengyunlai.top/api/rss。

参考资料

  • RSS 2.0 Specification:channel / item 结构与 guid、enclosure 定义(访问于 2026-09-28)
  • RSS Autodiscovery:自动发现 link 元素(访问于 2026-09-28)
  • RFC 4287:The Atom Syndication Format:rel 属性 §4.2.7.2(访问于 2026-09-28)
  • RFC 9110 §8.8.2:Last-Modified 与「何时不该给」的讨论(HTTP 语义,访问于 2026-09-28)
  • 仓库内文件(2026-09-28 的工作副本):src/app/api/rss/route.ts、src/lib/rss.ts、src/lib/seo.ts、src/components/Footer.tsx、next.config.mjs、Dockerfile、blog.config.json、docs/context.md、tools/verify/run-checks.sh
  • 配套实验:node examples/rss-subscription/user_code/main.mjs(生产生成器 + 独立解析器验收)与 node examples/rss-subscription/user_code/reader.mjs(条件请求与增量同步)