今天要解的問題

kiwi-walk 是我用 Hugo 架、部署在 Cloudflare Pages 的部落格。有讀者反應 RSS「無法使用」,我一開始只拿到這句話。後來問到細節,是這樣:

讀者是用 Chrome extension 去抓我 engineer 的

Chrome extension Get RSS Feed URL:在任何網頁按一下,它就列出那頁可以訂閱的 feed 網址。讀者在 /blogs/engineer/ 按下去,得到的是「Unable to find feed」。

技術背景

RSS 在網站這邊只是一個檔案

kiwi-walk 的 RSS 沒有資料庫,也沒有訂閱者名單。每次 push 到 main,Cloudflare Pages 跑一次 hugo,Hugo 用 PaperMod 的 rss.xml 模板把文章填進一份 XML,放在固定網址:全站是 /index.xml,每個分類、系列也各有一份,例如 /blogs/engineer/index.xml。

當時抓下來的全站 feed 有 112 個 <item>、77 KB,每篇只有標題、連結、pubDate、guid 和摘要,沒有全文:

<item>
  <title>系列總覽頁改版:兩欄卡片、只留一個標籤,最新文章帶日期</title>
  <link>https://kiwi-walk.com/blogs/engineer/series-overview/</link>
  <pubDate>Mon, 26 Oct 2026 22:00:00 +0800</pubDate>
  <guid>https://kiwi-walk.com/blogs/engineer/series-overview/</guid>
  <description>kiwi-walk 的 /series/ 原本三張卡片橫排三塊……</description>
</item>

送出去的工作全在閱讀器那一側:它定期回來 GET 這個檔,用 <guid> 判斷哪些是沒看過的新文章,存進自己的資料庫,再顯示給讀者。Cloudflare 回應時帶了 ETag,閱讀器下次帶 If-None-Match 來問,檔案沒變就只拿到 304:

Cache-Control: public, max-age=0, must-revalidate
ETag: "344abee559acb20f2e7f5314295b9790"

所以網站這邊能出錯的地方很少:檔案在不在、格式對不對、找不找得到。

「找得到」靠的是 <head> 裡的一行宣告

讀者通常不會自己猜 feed 網址,而是貼網頁網址、讓工具去找。工具找的是頁面 <head> 裡的 RSS 宣告。kiwi-walk 的 layouts/partials/head.html 為每一頁的 RSS 輸出寫這一行:

{{- /* RSS */}}
{{ range .AlternativeOutputFormats -}}
<link rel="{{ .Rel }}" type="{{ .MediaType.Type | html }}" href="{{ .Permalink | safeURL }}">
{{ end -}}

在 /blogs/engineer/ 上,它產生的就是指向 /blogs/engineer/index.xml 的那一行。

順帶一提,同樣是 Hugo 自動產生的 XML,/sitemap.xml 跟 RSS 是兩回事:

RSSsitemap
給誰讀者的閱讀器搜尋引擎爬蟲
內容文章標題、日期、摘要,新到舊全站網址與 lastmod
怎麼被找到<head> 的 <link rel="alternate">robots.txt 的 Sitemap:

這次的問題只跟左欄有關。

想法與取捨

這次沒有什麼選項,所以這段寫的是「為什麼是這個做法」和它的代價。

合法但修改

extension 強制要求有引號的 HTML 屬性,雖然沒有引號的 HTML 屬性也是合法的。代價是 HTML 變大一點。拿 engineer 頁來算,大約 330 組屬性會加回引號,每頁多幾百 bytes;Cloudflare 回應時本來就用 brotli 壓縮,引號重複性又高,實際差異可以忽略。

常見的 feed 網址也補上

找到真因時還有一個相關、但不是這次原因的缺口:extension 找不到宣告時會去猜 /feed、/rss.xml 這些網址,而它們在 kiwi-walk 全是 404。

補了幾條轉址,全部導到全站 feed。這條只能導到 /index.xml,導不到 engineer 分類的 feed,因為 extension 只在網域根目錄猜;讀者要在 engineer 頁抓到 engineer 的 feed,還是得靠上面那一行引號。

沒處理的:未來日期

排查時還看到一件事:feed 裡有發布日期在未來的文章(下一段會講是怎麼看到的)。原因是 hugo.yml 設了 buildFuture: true,預先寫好的文章一推上 main 就上線。要改得連帶改掉這種提早上線的發布方式,跟讀者訂不到 feed 也沒有關係,所以這次不動。

實作

壓縮時保留引號

hugo.yml 原本只開了 minifyOutput,加上 tdewolff minifier 的 keepQuotes:

minify:
  disableXML: true
  minifyOutput: true
  tdewolff:
    html:
      # Keep attribute quotes: some feed finders (e.g. the "Get RSS Feed URL"
      # Chrome extension) only match type="application/rss+xml" quoted.
      keepQuotes: true

Hugo 的 HTML 壓縮用的是 tdewolff/minify,它預設會把不需要引號的屬性值去掉引號。keepQuotes: true 只關掉這一項,空白、註解、inline CSS/JS 照樣壓。disableXML: true 本來就在,RSS 和 sitemap 這些 XML 不會被壓縮,所以 feed 的內容不受影響。

改完之後,engineer 頁的宣告從

<link rel=alternate type=application/rss+xml href=https://kiwi-walk.com/blogs/engineer/index.xml>

變成

<link rel="alternate" type="application/rss+xml" href="https://kiwi-walk.com/blogs/engineer/index.xml">

feed 網址的轉址

static/_redirects 會原樣複製到輸出根目錄,Cloudflare Pages 部署時讀它,在 edge 直接回 301,不經過 Hugo。在檔案最後加上:

# Common feed paths that feed finders guess (e.g. the "Get RSS Feed URL"
# Chrome extension tries these when a page has no <link> it can parse).
/feed                             /index.xml                             301
/feed/                            /index.xml                             301
/feed.xml                         /index.xml                             301
/rss                              /index.xml                             301
/rss/                             /index.xml                             301
/rss.xml                          /index.xml                             301
/atom.xml                         /index.xml                             301

規則是精確比對,所以 /feed 和 /feed/ 各寫一條。網站上沒有這些路徑的頁面,也跟既有的舊網址轉址(/posts/*、/books/* 等)前綴不同,不會互相蓋掉。

上線後確認

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://kiwi-walk.com/rss.xml

七條都回 301 https://kiwi-walk.com/index.xml。engineer 頁的 <head> 也已經是有引號的版本,拿 extension 的同一個正規表示式去比對,抓到了 /blogs/engineer/index.xml。

Get RSS Feed URL 在修正前後找 feed 的流程對照

踩到的坑

feed 完全正常

拿到「RSS 無法使用」這句話時,先檢查 feed 本身:

網址結果
/index.xml、/blogs/engineer/index.xml200,application/xml
/posts/index.xml(舊網址)301 到 /blogs/index.xml
/feed、/rss.xml404

接著換了七種 User-Agent 去抓全站 feed:一般瀏覽器、Feedly、Inoreader、curl、python-requests、Go,還有空白的,全部 200、大小一樣是 77,251 bytes,Cloudflare 沒有擋閱讀器。XML 用 Python 解析沒問題,丟進 W3C Feed Validator 的結果是合法、0 個錯誤、3 個警告。首頁 <head> 也有 RSS 宣告,頁尾和分類頁都有 RSS 連結。

誤導人的警告

三個警告裡,兩個是「Implausible date」:feed 第一篇的 pubDate 是 10/26,但那天才 10/13。這就是上一段說的 buildFuture。當時找不到其他毛病,就把它列成可能的原因,但也講明了沒辦法確定讀者遇到的就是這個,需要問讀者用什麼工具、看到什麼錯誤。

真因在 extension 的原始碼裡

知道讀者用的是 Get RSS Feed URL 之後,去讀它的原始碼。它不用 DOM parser 解析整頁,而是先用正規表示式從 HTML 原始碼裡挑出 <link>:

function extractLinkTags(html) {
    // ...
    let regex = /<link\s+(?![^>]*\b(?:rel=['"](stylesheet|icon|search)['"]|type=['"](text\/javascript|image\/(.*)|font\/(.*))['"]))[^>]*\btype=['"][^'"]+['"][^>]*>/gi;

    let match = html.match(regex);

    return match || [];
}

關鍵在最後那段 \btype=['"][^'"]+['"]:等號後面一定要接引號。而線上的 engineer 頁長這樣:

<link rel=alternate type=application/rss+xml href=https://kiwi-walk.com/blogs/engineer/index.xml>

模板裡明明寫了引號,是 minifyOutput: true 在壓縮時拿掉的。這行對瀏覽器和一般閱讀器都沒問題,對這個正規表示式就是不存在。

比對落空之後,extension 改到網域根目錄依序試一串常見網址:

const FEED_URL_SUFFIXES = [
    '/feed',
    '/feed/',
    '/rss',
    '/rss/',
    '/rss.xml',
    '/feed.xml',
    // ...
];

這些在 kiwi-walk 全是 404,最後就是「Unable to find feed」。

為了確認沒讀錯,把同一個正規表示式用 Python 跑在兩份 HTML 上:線上的 engineer 頁,以及加了 keepQuotes 後在本機用 Hugo 0.139.4 build 出來的版本。

版本比對結果
線上(修正前)空
本機 build(keepQuotes)/blogs/engineer/index.xml

小結

整體只改動兩個檔案:hugo.yml 多一行 keepQuotes,static/_redirects 多七條轉址。

  • 現在用 Get RSS Feed URL 抓得到自己分類的 feed
  • extension 找不到宣告時會去猜的 /feed、/rss.xml 等網址,也都轉到全站 feed。

真正的問題在一個讀者手上的 extension 裡,所以讀者說「不能用」的時候,問清楚他用什麼工具,比反覆檢查自己的設定更快找到答案。