從 WordPress 遷移到 Hugo 的水深比想像的還深

把部落格從 WordPress 搬到 Hugo,一開始看起來像是一個很乾淨的工程題:資料匯出、Markdown 轉換、theme 改寫、build、deploy。

真正做完之後才發現,水比想像的深。

為什麼要從 WordPress 遷移到 Hugo?

主要有兩個考量。

第一個是長期成本。

WordPress 很方便,但它本質上是一套動態系統。你需要 PHP、資料庫、外掛、登入後台、更新維護、備份策略,也要面對源源不絕的掃描與攻擊。即使網站流量不大,只要公開在網路上,wp-login.phpxmlrpc.php、舊外掛漏洞掃描就不會停止。

對一個主要用來寫文章的個人 Blog 來說,這些成本有點重。

Hugo 則剛好相反。文章變成 Markdown,網站變成 static rendered pages。部署出去的是 HTML、CSS、圖片與少量 JavaScript。沒有資料庫,沒有 WordPress 後台,也沒有 PHP runtime。從長期角度看,hosting 成本可以大幅下降,攻擊面也小很多。

第二個是寫作流程。

我平常很多內容都先在 Obsidian 裡寫。理想流程不是登入 WordPress 後台慢慢貼文,而是讓 AI agent 從 Obsidian 筆記提取內容,自動整理成 Blog 文章,並在發布前做 body clean。

這裡的 body clean 很重要。筆記裡可能混到帳號、密碼、token、內部路徑、暫存資訊或其他不該公開的內容。公開 Blog 文章必須先經過清理,敏感資訊要遮蔽或移除。

簡單說,我想要的是:

flowchart LR
    A[Obsidian 筆記] --> B[AI agent 整理內容]
    B --> C[Body clean 移除敏感資訊]
    C --> D[Hugo Markdown]
    D --> E[Static Blog]

原因也很誠實:我很懶惰。

能讓工具自動處理的事情,就不要靠自己每次手動複製貼上。

一開始真的很順

第一階段幾乎沒有太大阻礙。

我請 Claude 從 WordPress MySQL export 裡把文章匯出成 .md 檔案。WordPress 的 wp_posts 裡有文章標題、日期、slug、正文內容,這些資料都能轉成 Hugo front matter 與 Markdown body。

接著把原本 WordPress theme 的視覺與結構轉成 Hugo layout。這一步雖然要重寫 template,但 Hugo 的模型很清楚:

content/posts/*.md
layouts/
static/
config/

文章清單、單篇文章、RSS、sitemap、Open Graph、JSON-LD schema 都可以逐步補上。新版 Blog 也順手加了一些自己的改進,例如 SEO/AEO metadata、FAQ schema、llms.txt、OG image fallback、AI crawler 相關設定。

表面上看起來,遷移完成。

直到我打開 Cloudflare。

問題是在 Cloudflare AI Crawl Control 發現的

今天查看 Cloudflare 的紀錄時,我在 AI Crawl Control 頁面看到一堆 3xx4xx

一開始直覺是:是不是 Cloudflare bot 設定又有什麼奇怪問題?

但仔細看 path 之後答案很明顯。

大量錯誤都是舊 WordPress URL,例如:

/2016/01/04/sublime-text-%E8%88%87-simplenote-%E5%85%B1%E8%88%9E
/2016/01/22/%E7%84%A1%E6%B3%95%E7%A7%BB%E9%99%A4-dropbox-%E7%9A%84-mac-%E7%89%88%E6%9C%AC
/2015/03/18/digitalocean-%E6%87%B6%E4%BA%BA%E4%B8%80%E9%8D%B5%E5%BF%AB%E9%80%9F%E5%AE%89%E8%A3%9D-wordpress

沒錯,就是您想的那樣。

原本的 WordPress permalink 全部消失了。

Hugo 新文章 canonical URL 長這樣。因為 slug 裡有中文,canonical 會以 URL encoded 形式呈現:

/posts/sublime-text-%E8%88%87-simplenote-%E5%85%B1%E8%88%9E/

但舊搜尋結果、外部連結、AI crawler 記錄、書籤、歷史文章引用,仍然都在打舊網址:

/yyyy/mm/dd/post-slug

文章內容成功搬家,不代表 URL 權重和歷史連結也一起搬家。

這就是 migration 很容易被低估的地方。

先界定:5xx、4xx、3xx 各自代表什麼

處理前先分級。

5xx 是伺服器錯誤。如果有 500502503504,代表網站本身不穩,這要最先處理。

這次 5xx 沒有明顯問題,所以往下看。

4xx 是找不到或不能存取。WordPress 搬 Hugo 後,最常見就是舊文章 URL 變成 404。這會傷使用者體驗,也會讓搜尋引擎慢慢把舊 URL 當成失效頁。

3xx 是 redirect。它本身不是錯誤,但要檢查有沒有:

redirect chain
redirect loop
301 後最後落到 404
302 應該改 301
導到錯誤 canonical host

所以這次策略是:

Migration 收尾順序
  1. 確認沒有 5xx
  2. 先修舊文章 4xx
  3. 再 audit 3xx redirect chain
  4. 圖片與低價值路徑最後處理

匯出 Cloudflare 錯誤 CSV

Cloudflare 可以把 path 報表匯出成 CSV。這一步非常重要,因為不要憑感覺修 redirect,要看真實 crawler 打到哪些 URL。

我匯出了兩份資料:

4xx path report
3xx path report

CSV 欄位大致像這樣:

path,host,url,requests,referralTraffic,bytesTransferred

這樣就能用實際請求數排序,先處理最常被打到的舊連結。

而從這一步往後,後面那些整理 CSV、比對 WordPress 與 Hugo、產生 redirect 清單、檢查 redirect chain 的工作,基本上都是我先把規則講清楚,再交給 AI agent 協助批次處理。我自己主要負責定義判斷邏輯、看輸出結果、抽查關鍵案例,避免整件事變成手動土法煉鋼。

用 WordPress SQL 和 Hugo 文章做批次比對

手動一條一條對會瘋掉,所以我先把比對規則定清楚,再讓 AI agent 幫我做一個小工具來批次比對。

資料來源有兩邊。

第一邊是 WordPress MySQL export。重點是 wp_posts,主要欄位包括:

ID
post_date
post_name
post_title
post_type
post_status

第二邊是 Hugo 文章目錄:

content/posts/*.md

Hugo front matter 裡有:

title
date
slug

比對策略是保守的:

1. 先用日期比對
2. 再比對 WordPress post_name 和 Hugo slug
3. 再比對 WordPress title 和 Hugo title
4. 高信心才自動產生 redirect
5. 低信心標成 manual

這次 WordPress SQL 裡抓到 40 筆 published posts/pages,Hugo 文章有 53 篇。比對結果:

high: 37
manual: 3

接著再把 Cloudflare 4xx CSV 交給 AI agent 一起處理,只針對實際出現的錯誤 URL 產生 redirect 清單。

結果分類如下:

high: 30
page_index: 6
manual: 23
asset_missing: 49

這裡的 asset_missing 大多是舊 WordPress 圖片:

/wp-content/uploads/...

因為 repo 裡沒有那些舊圖檔,所以我沒有讓 AI agent 直接亂導。圖片 404 和文章 404 的 SEO 價值不同,先修文章比較重要。

為什麼不用 JavaScript redirect?

Static HTML 當然可以用 JavaScript 讀 URL,然後 location.replace() 導到新文章。

但這只適合補救真人使用者。

對 SEO、crawler、AI bot 來說,最好還是 HTTP 層的 301。也就是:

flowchart LR
    A[舊網址] --> B[HTTP 301]
    B --> C[新網址]

而不是:

flowchart LR
    A[舊網址] --> B[200 HTML]
    B --> C[JavaScript redirect]

我先確認過遠端 web server 是 nginx,不是 Apache,所以 .htaccess 不會生效。nginx 可以做 redirect,但需要 sudo 修改 server config。最後我和 AI agent 收斂出來的做法,是直接用 Cloudflare Bulk Redirects。

用 Cloudflare API 建立 Bulk Redirect

我建立了本機設定檔:

~/.cloudflare.conf

內容只放必要設定,例如:

CLOUDFLARE_API_TOKEN=REDACTED
CLOUDFLARE_ZONE_NAME=stanwu.org

實際 token 不會進 repo,也不會寫進文章。

接著由 AI agent 按照前面的規則讀取 redirect CSV,建立 Cloudflare Bulk Redirect List:

blog_stanwu_org_legacy_redirects

這份 list 裡放的是實際需要修補的舊 URL,例如:

flowchart LR
    A["/2016/01/04/sublime-text-%E8%88%87-simplenote-%E5%85%B1%E8%88%9E"]
    B["/posts/sublime-text-%E8%88%87-simplenote-%E5%85%B1%E8%88%9E/"]
    A -->|301| B

Cloudflare 這裡也踩了一個坑。

一開始 AI agent 先照我的思路把 Bulk Redirect rule 掛在 zone-level ruleset,API 回:

phase "http_request_redirect" not allowed at zone level

後來再一起確認,Bulk Redirect 要使用 account-level ruleset。也就是 list 是 account 資源,啟用規則也要掛在 account 的 http_request_redirect phase。

修正後,流程變成:

Cloudflare Bulk Redirect API 步驟
  1. 建立或取得 account-level redirect list
  2. 匯入 redirect items
  3. 建立 account-level http_request_redirect ruleset
  4. from_list 啟用這份 redirect list

最後成功匯入 36 條實際 4xx path redirect。

3xx 檢查:不是看到 redirect 就安心

修好 4xx 後,下一步是看 3xx。

3xx 不一定是壞事。這次我們本來就是要建立 301。真正要找的是:

flowchart TD
    A[舊 URL] --> B[301]
    B --> C[301]
    C --> D[200]
    E[舊 URL] --> F[301]
    F --> G[404]
    H[舊 URL] --> I[Redirect loop]

所以我再把檢查規則交給 AI agent,讓它做一個小工具,讀 Cloudflare 3xx CSV,對每條 URL 跑:

curl -L -I

然後輸出 redirect chain audit。

第一次結果是:

ok: 27
bad_final: 18
review: 2

大多數文章都正常,一跳就到新文章:

flowchart LR
    A[舊文章 URL] -->|301| B[新文章 URL]
    B --> C[200]

真正有問題的是兩條舊分頁:

/page/7
/page/9

Hugo 文章列表只到第 6 頁,但它們被導到不存在的 /posts/page/7//posts/page/9/,結果變成:

flowchart LR
    A[舊分頁] -->|301| B[不存在的新分頁]
    B --> C[404]

這種 301 -> 404 比單純 404 更容易被忽略,因為錯誤被藏到下一跳。

確認問題後,我再讓 AI agent 把它們改成:

flowchart LR
    A["/page/7"] -->|301| C["/posts/"]
    B["/page/9"] -->|301| C

再整份替換 Cloudflare Bulk Redirect List。

第二次 audit 結果變成:

ok: 29
bad_final: 16
review: 2

/page/7/page/9 都變成:

flowchart LR
    A[舊分頁] -->|301| B["/posts/"]
    B --> C[200]

成果驗收

最後由 AI agent 批次跑 curl -I 做第一輪驗收,我再抽查舊文章、舊分頁與新文章 URL。重點很簡單:

驗收結果

舊文章 permalink 會回 301,新 Hugo 文章會回 200。 舊 /page/7/page/9 會回 301,並導到 /posts/

flowchart LR
    A[舊文章 permalink] -->|301| B[新 Hugo 文章]
    B --> C[200]

這才是 WordPress migration 裡真正應該收尾的狀態。

還沒處理的項目

不是所有錯誤都值得立刻修。

目前還保留幾類:

舊圖片 /wp-content/uploads/...
舊 category 頁
舊 author 頁
少數找不到對應內容的文章

圖片如果原始檔還找得到,最好的方式是補回 static/uploads/。如果找不到,就不要硬導到首頁。

舊 category 和 author 頁可以之後再 mapping 到 Hugo 的 categoriestags,但這需要人工判斷,不適合全自動亂導。

有些舊文章在 SQL 和 Hugo 內容裡都找不到可靠對應,例如 MarsEdit + htaccess 那篇,就先保留 manual。除非能找回內容,否則不應該假裝它有正確新頁。

這次學到的事

WordPress 遷移 Hugo,不只是「把文章轉成 Markdown」。

真正完整的 migration 至少包含:

WordPress 到 Hugo 的完整收尾清單
  • 內容轉換
  • theme/template 轉換
  • SEO metadata
  • sitemap/rss/canonical
  • 舊 URL redirect
  • 圖片資產遷移
  • Cloudflare / CDN 規則
  • 3xx chain audit
  • 4xx 分級處理

靜態網站的好處很明確:便宜、快、攻擊面小、容易納入 AI agent 自動化流程。

但靜態網站不會自動記得 WordPress 的歷史包袱。

如果舊 URL 沒有處理好,搜尋引擎和 AI crawler 看到的不是「你成功搬家」,而是「一堆內容消失了」。

所以這次最大的教訓是:

Migration 上線不是結束。Cloudflare、Search Console 和 redirect audit 才是收尾。

幸好這些問題可以被系統化處理。只要有 WordPress SQL、Hugo 內容和 Cloudflare CSV,再配合 AI agent 協助寫工具、整理結果、批次驗收,就能把原本很瑣碎的收尾工作變成可重複的流程。

水很深,但有 AI agent 這艘船,很多原本會讓人放棄的細節,終於可以一段一段渡過去。

想看更多作品、服務與主站整理,請前往 stanwu.org