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

RSS(三):把 XML 拼对——转义、CDATA 与两种生成方式

从标题里一个 & 让整份源静默失效的现场出发,给出一个够用的转义函数、CDATA 的边界处理,以及构建时与运行时两种生成方式的取舍。


先看标题里一个 & 造成的静默失败

用字符串模板拼 XML 是最省事的写法,也是最容易在某一天突然坏掉的写法。假设当天的文章标题是「Q&A:一次真实的对齐问题」,拼出来的结果是:

xml
<item>
  <title>Q&A:一次真实的对齐问题</title>
  <link>https://www.chengyunlai.top/zh/blog/some-post</link>
</item>

在 XML 里 & 是实体引用的起始字符,&A 不是合法实体,于是整份文档不再良构(well-formed)。XML 1.0 规范对良构性违反的后果说得很直接:这属于 fatal error,处理器必须检测并向应用报告;一旦检测到,就不得继续正常处理(XML 1.0 第五版,§1.2、§2.1,访问于 2026-09-28)。

后果不是「这一条坏了」,而是「整份源坏了」:

正在绘制图表…
查看 Mermaid 源码
flowchart LR
    a["标题里的一个未转义字符"] --> b["生成的 XML 不再良构"]
    b --> c["解析器报 fatal error"]
    c --> d["整份 feed 读不出来<br/>item 数量为 0"]

    classDef cause fill:#fff7ed,stroke:#ea580c,color:#0f172a
    classDef effect fill:#eff6ff,stroke:#2563eb,color:#0f172a
    class a,b cause
    class c,d effect

Mermaid 大图

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

错误只发生在第 2 行,坏掉的是整份文档。阅读器通常只会显示一句「无法读取此源」,没有任何一行指向那个 &——这就是它被称为静默失败的原因。

一个够用的转义函数

要修的是「往外写的时候有没有做转义」,所以最小构件是一个转义函数:

javascript
const XML_ENTITIES = {
  "&": "&amp;",
  "<": "&lt;",
  ">": "&gt;",
  '"': "&quot;",
  "'": "&apos;",
};

function escapeXml(value) {
  return String(value).replace(/[&<>"']/g, (char) => XML_ENTITIES[char]);
}

三个补充说明:

  1. 一次扫描胜过五次替换。 如果写成五次链式 replace,顺序必须让 & 排在其它字符之前,否则先转出的 &lt; 会被再转一次,变成 &amp;lt;。用一条正则一次扫描,这个顺序问题自然消失。
  2. 哪些是硬要求。 & 与 < 在文本内容里必须转义;> 只在构成 ]]> 时必须(XML 1.0 §2.4)。" 与 ' 只对属性值有要求,但属性值就在这份文件里——enclosure url="..." 就是——所以一并转掉更省心。
  3. 不要对已经是实体的内容再转一次。 RSS 2.0 规范允许 description 里放 entity-encoded HTML,也就是说 &amp; 这种文本可能本来就该原样出现。把「带实体的 HTML」再送进 escapeXml,结果是 &amp;amp;,读者看到的是一串字面量。我的判断是:先把输入约定成纯文本,所有字段一律走转义;确实需要放 HTML 时,用另一个明确命名的函数,而不是让所有字段共用同一条路径。

CDATA 的边界

XML 还提供了另一条路。CDATA 段以 <![CDATA[ 开始、以 ]]> 结束(XML 1.0 §2.7):段内只有 ]]> 会被当作标记,< 与 & 可以字面出现,并且不能用实体转义,CDATA 段也不能嵌套。

xml
<description><![CDATA[<p>带 <b>标签</b> 的摘要,& 符号照写</p>]]></description>

本站就用这条路径写摘要(见 src/app/api/rss/route.ts 里写 <description> 的那一处)。

这样只剩一个问题:内容里真的出现了 ]]>。段内不能转义,也不能嵌套,唯一的办法是在这一处断开,拼成两个相邻的 CDATA 段:

javascript
// 中间的 "]]>" 处断开,得到 <![CDATA[a]]]]><![CDATA[>b]]>
const safe = value.replace(/]]>/g, "]]]]><![CDATA[>");

有一个细节值得记住:RSS 2.0 规范全文没有出现 CDATA,它只说 description 允许 entity-encoded HTML(RSS 2.0 规范,访问于 2026-09-28)。CDATA 属于 XML 层面的表达能力,用它并不意味着规范给了额外的许可——该不该在摘要里放 HTML 仍然是内容策略问题。

enclosure:同样是属性值,坏得同样安静

enclosure 是 item 的子元素,用来挂一个附件。规范对它的措辞是「it has three required attributes」:url 说明位置、length 说明它有多少字节、type 说明它的 MIME 类型,并且 url 必须是一个 http url。

原始用途是播客音频,本站用它挂文章配图(见 src/app/api/rss/route.ts 里写 <enclosure> 的那一处):

xml
<enclosure url="https://oss.chengyunlai.top/..." type="image/jpeg" />

length 是规范列的三个必需属性之一,所以它不是「能给就给」的加分项 —— 三个属性要么齐全,要么这个元素整个不出现。真正的约束在别处:你得真的知道那个文件的字节数。本站只在能读到真实大小时才写出 enclosure —— 本地 public/ 下的图读文件大小;外链(OSS / CDN)读不到,就整条省略。

留一个只有 url 和 type 的残件比省略更危险:残缺的 enclosure 在严格阅读器里是「不合规的附件」,而不写它只是「这条没有附件」—— 后者是合法状态。省略是信息少一点,残件是签了个不能兑现的承诺。

还有一个只在实现里才看得见的顺序问题:判断「能不能拿到字节数」必须在把图片地址转成绝对地址之前做。读文件的函数只认以 / 开头的站点相对路径;转换之后它拿到的是 https://…,会一律返回「读不到」。结果是连本地图也永远拿不到长度,而且不报任何错 —— 每一条 enclosure 都静静地退化成那个只有两个属性的残件,本地开发时看起来一切正常。

enclosure 值得在这里单独说一句,是因为它的 url 是属性值。属性里的 & 同样会被解析,而属性值如果被拼接了两段地址,得到的会是一个语法合法、语义完全错误的 URL:XML 校验不会报错,只有真的去打开那个地址时才发现是 404。第四章的第二个坑正是这样来的。

构建时生成,还是运行时生成

同样的 XML,可以有两种产生时机。它们不是性能差异,而是失效模式不同:

维度构建时静态生成运行时动态生成
内容来源构建时能读到的文件运行时能读到的文件、数据库或上游 API
内容更新后需要重新构建并部署下一次请求即可生效
单次请求成本接近零,直接返回静态字节读取、拼装,可能还有查询
缓存与校验器生成端只能把校验器给出去(ETag / Last-Modified);能不能落成 304 由部署层决定,见第二章实测要自己写 ETag 与 Cache-Control,否则每次都是 200
典型失效模式构建产物是旧的运行时依赖缺失——容器里没有那个目录、数据库连不上
适合内容来自版本库的静态博客内容库频繁更新,或要聚合多个来源

本站选了构建时,理由不止性能:生产镜像里根本没有内容目录,运行时读不到文章(第四章详述)。这里要带走的是那张表最后两行的区别——静态生成坏在「产物是旧的」,动态生成坏在「运行时缺东西」,而后者往往在本地 next start 时完全正常。

让阅读器和爬虫找到这份文件

三件收尾的事。

自动发现。 在 HTML 的 head 里放一个 link 元素,浏览器和插件就能自己找到订阅地址(RSS Autodiscovery,RSS Advisory Board v1.0,2006-11-27,访问于 2026-09-28):

html
<link rel="alternate" type="application/rss+xml" title="RSS" href="https://www.chengyunlai.top/api/rss">

规范里的硬约束:必须放在 head 内;rel 必须是 alternate(小写,且不能掺其它关键字);RSS 1.0 与 2.0 的 type 必须是 application/rss+xml(小写);href 建议写完整 URL;一个页面只放一个自动发现链接,多放时第一个应当是站点主源。

响应类型。 内容本身要有一个 XML 类型。本站给的是 Content-Type: application/rss+xml; charset=utf-8(见 route.ts 的响应头)—— 规范推荐的具体类型;通用类型 application/xml 也能用,主流阅读器看的是 XML 结构,但既然能说具体就别含糊。真正必须严格写成 application/rss+xml 的是上面那个 link 元素的 type:那是阅读器(和浏览器)用来识别自动发现的依据,写错就直接不认。

验证用真解析器。 不要用正则判断「像不像 XML」,也不要用自己写的解析器去验收自己写的东西。命令行上最省事的是 xmllint(libxml2 自带):

bash
xmllint --noout .next/server/app/api/rss.body

它只回答一个问题:这份文档良构吗。良构之外的业务事实(条目数量、链接前缀、地址有没有被拼坏)要另外断言,那是下一章的做法。

这两句「不要」都不是空话。配套实验在仓库的 examples/rss-subscription/,两个公开入口的分工是固定的:生成走 src/lib/rss.ts(线上那份代码),验收走系统里的成熟解析器(Python 标准库的 expat,以及装了就跑的 xmllint)。实验自己另写一份生成器、或者用自己的解析器当判据,都只能是自证 —— 前者证明的是「实验作者会写转义」,后者证明的是「我同意我」。

bash
node examples/rss-subscription/user_code/main.mjs

它跑出一张三种判据并排的表,结果值得原样抄在这里:一条只找漏转义 & 的正则,在 5 份非法文档上漏报 4 份(&#0;、原始 U+0001、裸露的 ]]>、标签不匹配全都放过),又在 3 份合法文档上误报 2 份(它认不出 CDATA 里的字面 & 是合法的)。两个方向同时错,这才是「正则判 XML」的完整成绩单:它既不安全,也不准确,只是恰好对最常见的那一种错误有效。

参考资料

  • XML 1.0(第五版):良构性与 fatal error §2.1、预定义实体与 ]]> §2.4、CDATA 段 §2.7(官方规范,访问于 2026-09-28)
  • RSS 2.0 Specification:enclosure 的三个必需属性、guid 与 description 的定义(官方规范,访问于 2026-09-28)
  • RSS Autodiscovery:link 元素与 rel / type / href 的约束(RSS Advisory Board,访问于 2026-09-28)