從 WordPress 遷移到 Hugo 的水深比想像的還深
把部落格從 WordPress 搬到 Hugo,一開始看起來像是一個很乾淨的工程題:資料匯出、Markdown 轉換、theme 改寫、build、deploy。
真正做完之後才發現,水比想像的深。
為什麼要從 WordPress 遷移到 Hugo?
主要有兩個考量。
第一個是長期成本。
WordPress 很方便,但它本質上是一套動態系統。你需要 PHP、資料庫、外掛、登入後台、更新維護、備份策略,也要面對源源不絕的掃描與攻擊。即使網站流量不大,只要公開在網路上,wp-login.php、xmlrpc.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 頁面看到一堆 3xx、4xx。
一開始直覺是:是不是 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 是伺服器錯誤。如果有 500、502、503、504,代表網站本身不穩,這要最先處理。
這次 5xx 沒有明顯問題,所以往下看。
4xx 是找不到或不能存取。WordPress 搬 Hugo 後,最常見就是舊文章 URL 變成 404。這會傷使用者體驗,也會讓搜尋引擎慢慢把舊 URL 當成失效頁。
3xx 是 redirect。它本身不是錯誤,但要檢查有沒有:
redirect chain
redirect loop
301 後最後落到 404
302 應該改 301
導到錯誤 canonical host
所以這次策略是:
Migration 收尾順序
- 確認沒有 5xx
- 先修舊文章 4xx
- 再 audit 3xx redirect chain
- 圖片與低價值路徑最後處理
匯出 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 步驟
- 建立或取得 account-level redirect list
- 匯入 redirect items
- 建立 account-level
http_request_redirectruleset- 用
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 的 categories 或 tags,但這需要人工判斷,不適合全自動亂導。
有些舊文章在 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。