
說來有趣,這個部落格寫了兩年多,中間搬過三次家。最早是 WordPress 架在 Bluehost,2025 年搬去 Notion(那段取捨寫在一年之後,我離開了 Wordpress);結果前端跳轉讓整站只有首頁進得了 Google 索引、流量幾乎歸零,今年三月索性打掉重練,用 AI 生成了一套 React 靜態站直接放在 Cloudflare 上(這段寫在一年後,我離開了 Notion)。
現在的站有快八十篇文章,全部預先渲染成靜態 HTML,canonical、sitemap、JSON-LD 這些該有的都有。Lighthouse 的分數也一直掛在手機 89、電腦 99,看起來沒什麼毛病,我也就一直沒特別理它。
直到這陣子比較有空,想說來做一次完整健檢。這次不看分數,改看有沒有東西在默默地扣分,結果一查下去,地雷比想像的多:www 根本進不來、隨便一個子網域都在服務同一份內容、沒有信箱的網域還留著舊主機的 DKIM 公鑰(等於誰能碰到那台舊機器,就能用我的網域簽名寄信)。
這篇就是這輪健檢的完整紀錄,做法、踩到的坑、還有幾個多想一下決定不做的地方。
開始之前先講結論
| 指標 | 之前 | 現在 |
|---|---|---|
| Lighthouse 效能(手機 / 電腦) | 89 / 99 | 96 / 100 |
| Lighthouse 無障礙 | 89 | 100 |
| 內文圖片總量 | 132.8 MB | 20.4 MB(WebP) |
| 有 alt 的圖片 | 16 / 337 | 266 / 337 |
| 重複的 heading id | 6 篇文章 | 0 |
| 斷掉的錨點連結 | 2 個 | 0 |
| 文章 metadata 檢查的警告 | 321 | 74 |
分數的變化其實是最不重要的那部分,後面幾項才是真的在影響索引品質。
一、www 進不來,原來是舊 Wordpress 留下的債
先講最嚇人的一個。某天我隨手 curl 一下:
$ curl -sI https://www.nickzhuang.com/
HTTP/2 403
cf-mitigated: challenge # Cloudflare 發人機驗證,連 Googlebot 的 UA 也一樣
$ curl -s -o /dev/null -w '%{http_code}' https://www.nickzhuang.com/robots.txt
526
$ curl -s -o /dev/null -w '%{http_code}' https://random-sub-xyz.nickzhuang.com/
200 # 隨便一個子網域都在服務整個部落格看到 403 加上 cf-mitigated: challenge,我第一個想法是 WAF 規則擋住了吧,但進去看,WAF 是空的。
真正讓我定位到問題的,是一組對照實驗。我拿兩個只存在於新部署裡的檔案去比 apex 與 www:
| 路徑 | www | apex |
|---|---|---|
/og/k8s-intro.png | 526 | 200 |
/assets/index-xxx.css | 526 | 200 |
這兩個檔案只存在於新的 Worker 裡。如果 www 的請求有進到 Worker,就不可能回 526,因為 526 的意思是 Cloudflare 連不到 origin,而 Worker 根本沒有 origin。
所以 www 的請求連 Worker 都沒到過。這也順便解釋了兩件怪事:那條 www 轉 apex 301 的 Redirect Rule 完全沒作用,以及 WAF 明明是空的卻出現 challenge(那個 challenge 不是這個 zone 發的)。
回頭看 DNS,原因就清楚了。2025 年那次搬家只把 nickzhuang.com 綁到新的 Worker,WordPress 時代的記錄一條都沒清:
| 記錄 | 內容 | 問題 |
|---|---|---|
*.nickzhuang.com | A → 舊主機 IP | 任何子網域都解析得到,再被 Worker route 接走,等於無限多個網址在服務同一份內容 |
www | CNAME → nickzhuang.com | 指回 apex,而 apex 是 Worker 自訂網域、沒有 origin,於是 526 |
imap / pop / smtp | CNAME → 已下線的郵件主機 | 沒有 MX,記錄卻還在 |
修法不難,但順序很重要:
- 到 Worker 的 Domains & Routes,刪掉
.nickzhuang.com/這條 route,改加一個 Custom domain:www.nickzhuang.com - 回 DNS,刪掉泛用記錄、www 的 CNAME、還有 imap/pop/smtp
一定要先加自訂網域、再刪舊記錄,順序反過來,中間那段時間 www 會直接 NXDOMAIN。
還有一個小知識:修完之後 www 回的是 200,不是 301。我一開始也想補一條轉址,後來發現不用,因為自訂網域的優先序高於 Redirect Rule,那條規則本來就是空轉的,而每一頁的 canonical 都是絕對網址,Google 自己會把 www 跟 apex 合併:
$ curl -s https://www.nickzhuang.com/ | grep -o 'rel="canonical" href="[^"]*'
rel="canonical" href="https://nickzhuang.com/順便提一個容易搞混的地方:Cloudflare 把 Workers 跟 Pages 放在同一個後台頁面,_headers、_redirects 的規則也幾乎一樣,所以我一直以為自己是放在 Pages 上,其實是 Worker 的靜態資產。
二、沒有信箱,不代表不會被冒用
清 DNS 的時候,我忽然想到一個問題:我沒有在用這個網域收信(沒有 MX 記錄),這樣是不是就不會有人拿我的網域寄信?
會,而且原本的設定幾乎沒有抵抗力。冒用防的是有人拿你的網域當寄件人,這件事跟收信完全無關,只看三種 TXT 記錄:
| 記錄 | 清理前 | 問題 |
|---|---|---|
| SPF | v=spf1 ip4:… include:websitewelcome.com ~all | ~all 是 softfail,收件端通常標記可疑但照樣收下;而且授權的是早就沒在用的 Bluehost 舊主機 |
| DMARC | 沒有 | 收件端想擋也沒有依據 |
| DKIM | default._domainkey 有記錄 | 最危險的一條:留著舊主機的公鑰,對應的私鑰在舊機器上 |
第三條我看到的時候背脊有點涼。那台舊機器還在不在我不確定,但只要有人能存取它,就能簽出一封通過 DKIM 驗證的信,收件端會認為那封信真的是我寄的。
修法只有三行:
v=spf1 -all
_dmarc TXT v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s; pct=100; rua=mailto:…; fo=1
default._domainkey TXT (刪除)-all 的意思是沒有任何伺服器被授權用這個網域寄信。既然我本來就沒有郵件服務,這是最乾淨的姿態,p=reject 則是讓驗證失敗的信直接拒收,不只標記。
代價要先講清楚:這是完全不寄信的設定。哪天我要用 Gmail 的以此地址寄信功能或 Google Workspace,就得先把 SPF 加上 include:_spf.google.com、再加新的 DKIM,不然我自己寄出去的信會被退回來。
三、無障礙從 89 到 100,扣分的幾乎都是顏色
無障礙這件事我一直以為有做(標題層級、圖片 alt、按鈕 aria-label),但 Lighthouse 給 89。用跟它同一套的 axe-core 在本機跑,才發現問題。
第一個收穫是:一定要用手機寬度掃。桌面寬度掃出來是 0 錯誤,因為 sm: 斷點同時改了字級跟顏色,桌面的樣式根本不會踩到那個組合。設成 412×823、mobile 模式之後,問題才浮出來:
text-muted-foreground(hsl 240 5% 45%)在深色底上只有 3.47:1,低於 AA 要求的 4.5:1第二個收穫比較有價值:修法不是把那個色碼調深,而是補一個語意色票。原本全站都用 --primary 當連結色,但 --primary 的用途是大片色塊上的白字,拿來當淺底上的文字就會不足。所以我加了一個 --primary-strong:
:root { --primary-strong: 262 83% 45%; } /* 7.42:1 */
.dark { --primary-strong: 262 83% 75%; } /* 深色模式反過來 */以後文字要用主色就統一用 text-primary-strong,不用在幾十個地方各自猜顏色。這種事跟寫程式一樣,變數取對了,後面的錯誤自然就少了。
另外兩類問題就比較單純:純圖示的按鈕沒有可辨識名稱(要補 aria-label),以及標題層級跳號(清單頁的卡片是 h3,但上一層也是 h3)。
這裡有個誤判:首頁的漸層文字被 axe 報 1.65:1,我一開始以為要改設計,後來自己把三個漸層停點算出來:4.12 / 11.17 / 6.40,全部過關(大字門檻是 3:1)。原因是 axe 對 background-clip: text 的漸層只讀得到第一個停點。工具報錯要自己驗算一次,不然會為了一個不存在的問題把設計改壞。
四、效能:先把第三方拔掉,其他再說
字型自託管
原本字型是從 Google Fonts 載,fonts.googleapis.com 跟 fonts.gstatic.com 兩個網域。改成自己放 woff2,另外用 unicode-range 只留拉丁字集(中文本來就走系統字型):
@font-face {
font-family: 'Plus Jakarta Sans';
src: url('/fonts/plus-jakarta-sans-latin-v1.woff2') format('woff2');
font-display: swap;
unicode-range: U+0000-00FF, …;
}講句老實話,分數的收益沒有想像中大,因為原本的字型 CSS 本來就是非阻塞載入(media="print" onload 那一招)。真正省下來的是兩個第三方來源的 DNS、TLS 握手,還有跨站 Cookie 的邊界,首頁的請求數從 10 個降到 7 個,對外網域歸零。
檔名記得帶版本(-v1),因為 /fonts/* 設了 immutable 快取,要換字型就得換檔名,不然使用者永遠拿到舊的。
LCP 到底在哪裡
這個不要猜,直接量:
new PerformanceObserver((l) => {
for (const e of l.getEntries()) console.log(e.element, e.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });實測結果,首頁的 LCP 元素是 <h1> 的文字,根本沒有圖片。所以網路上那些幫 hero 圖加 fetchpriority 改善 LCP 的建議,在這個站是無效的。真正會延後 LCP 的只有一個東西:CSS 下載。
一個決定不做的事
CSS gzip 之後只有 13.7 KB,它是全站唯一 render-blocking 的請求。理論上可以內嵌關鍵 CSS,但那樣我就得維護一份哪些算關鍵的清單,而且這個站每次建置檔名都會換 hash。
想了想,決定不做,並把理由寫進文件。不然每次看到 Eliminate render-blocking resources 這條建議,又會心動一次,然後再放棄一次。
五、圖片 132.8 MB 變成 20.4 MB,還有 266 張 alt
這個站圖很多,337 張,而且都是 PNG 截圖,加起來 132.8 MB。用 sharp 在建置時全部轉 WebP、順手寫出 width/height(避免 CLS)、首圖 fetchpriority="high"、其餘 lazy,最後剩 20.4 MB,大約是原本的 15%。
真人比壓縮麻煩多了,336 張圖原本只有 16 張有 alt。這種事不能靠通靈,我的做法是:
- 先用 OCR 把圖上的字讀出來(圖表的標題、座標軸、程式碼片段、UI 按鈕)
- 再讀圖前後幾段內文,確認這張圖在論證什麼
- 只寫 OCR 跟內文都支持的內容,不確定就留空
規則大概是這幾條:
- 描述資訊,不是寫這是一張圖
- 圖表要講軸、指標、關鍵數字
- 截圖要講它證明了什麼
- 專有名詞保留原文,不要翻
- 封面一律留空
最後一條後來破例了。有三張封面拿掉的話,讀者會損失資訊:一張是 NVIDIA 的完課證書、一張是我以前論文的架構心智圖、一張是 DLI 講師整理的教材架構。所以規則補了一條例外,判準改成「拿掉它,讀者有沒有損失」,而不是「它在不在第一行」。
順帶提一個容易誤會的地方。我一直以為那 71 張留空的封面,在 HTML 裡就是空的,後來才發現程式的 fallback 會自動補上最近的標題加示意圖:
alt="k8s 初探+簡介 示意圖"這件事值得知道,不然你會以為留空就等於螢幕閱讀器跳過,其實它還是有唸東西。
六、兩個小 bug:重複的 id 與斷掉的錨點
這是我完全沒預料到的一段。文章的標題 id 原本直接拿中文標題來用,我想說能用就好,實際量了 735 個標題(當時的數量)之後:
| 問題 | 數量 |
|---|---|
| 含空白、標點、全形括號(不是合法 slug) | 280 |
| 同一篇有重複標題,產生重複 id | 6 篇 |
| 內文提要區的錨點對不到任何標題 | 2 個 |
重複 id 是真的 bug。dli-notes 那篇有 6 個 ## 投影片資料,瀏覽器會把 6 個 <h2 id="投影片資料"> 全部指向第一個,後面 5 個永遠跳不到。
修法三件事:標題 slug 化(小寫、只留文字與數字,其餘收斂成連字號)、重複的依序加 -2、-3、還有內文的 #錨點 走同一套轉換。
第三點是關鍵。aigc-what 的提要寫 #什麼是AIGC?(半形問號),但標題是 ## 什麼是AIGC?(全形),兩邊都被正規化之後就自己接上了,一個字都不用改。
另外有一處是內容真的寫錯:diffusion 那篇,提要跟內文都寫去噪,標題卻打成 ## 去躁。這種就只能改內容了。
代價是 735 個標題裡有 380 個(52%)的錨點字串變了(主要是中文標題裡的英文被轉成小寫)。站內提要連結是程式自動重算的,不受影響;外部如果有人分享中文錨點,那個網址本來就是一大串 percent-encoding,實際上不會有人這樣分享。
七、updated 欄位:誠實比勤勞重要
這段是我覺得整輪最有價值的一個決定。
站上一直有 updated 欄位的管線,它會同步反映到四的地方:JSON-LD 的 dateModified、文章頁的更新於時間、sitemap 的 lastmod、還有 RSS。但 79 篇沒有一篇填(這是健檢當時的篇數)。
一開始我的想法很單純,那就把每篇的最後修改日填上去吧。動手之前先去翻 git 歷史,才發現不能這樣幹。這 79 篇文章、每篇最多 4 個 commit,而且長得都差不多:
2026-03-27 [feat] article splits to different directory ← 從 Notion 匯入
2026-04-01 [feat] modify all posts ← Notion 轉 Markdown 的清洗
2026-10-23 [content] 移除 60 篇文章開頭的 Author: 殘留行
2026-10-24 [content] 為 247 張文章插圖補上 alt 文字我還特別寫了一段程式,把每個 commit 前後的 Markdown 文字正規化之後比對,確認 2026-04-01 那次改的是標題層級、Notion 錨點換成站內錨點、程式碼區塊轉換、表格重排,沒有一篇是真的改過內容。
如果把例行整理當成更新日期,lastmod 就變成雜訊了。Google 判定這個欄位不可靠之後,會直接忽略它,那時候就算我真的改稿,也沒人理我。
所以結論是留空。dateModified 等於 datePublished,這是誠實的陳述。另外我加了幾條驗證,保證以後真的要修訂時不會填錯:格式必須是 YYYY-MM-DD、不能早於 date、不能晚於今天、跟 date 一樣就警告你別填。
八、把檢查自動化
這個站是靜態建置、手動上傳,有幾種錯誤不會讓建置失敗,只會在 Google 那邊默默扣分:excerpt 太短、id 不是合法 slug、標題超過 SERP 顯示寬度、category 不在清單裡、updated 早於 date。
所以最後加了 pre-commit hook,跑型別檢查跟文章健檢,有錯就擋。
這裡踩了一個很好笑的坑。我第一版這樣寫:
set -e
pnpm -s check:posts | tail -20看起來沒問題對吧?錯。husky 是用 sh 執行 hook,而 POSIX sh 沒有 pipefail,整條管線的結束碼是 tail 的,所以 check:posts 就算失敗也會放行。我還特別去實測:故意把 updated 填成早於 date,照樣提交成功。
改成明確判斷結束碼才擋得住:
if pnpm -s check:posts >"$LOG" 2>&1; then
grep '檢查' "$LOG"
else
grep '✗' "$LOG" | head -20
exit 1
fi還有一件事我也沒做:沒有裝 Prettier。這個 repo 從來沒用過它,一加上去,當時那 79 篇 Markdown 會被整批重排,321 張圖的 diff 會全部變成格式噪音,蓋掉真正的改動。hook 的重點是正確性,不是風格。
九、還沒做的,跟決定不做的
| 項目 | 決定 | 理由 |
|---|---|---|
| 目錄(TOC) | 延後 2~3 週 | 會動到版型,想先確認這輪改動有效,不要同時改太多變數 |
| 內嵌關鍵 CSS | 不做 | CSS gzip 才 13.7 KB,維護成本大於收益 |
| 原圖移出 repo | 還沒決定 | 目前 134 MB 都在版控裡,只有上傳的量變小 |
TOC 那一條我還留了一份觀察期備忘在文件裡,寫明 2~3 週後要比對三件事:GSC 的曝光跟點擊有沒有上升、手機 PSI 有沒有維持在 96 以上、有沒有新的索引問題。不然兩週後我只會記得當時說要等,卻不記得在等什麼。
幾件真的學到的事
- 分數不是目標,但有幾個數字代表真的壞了。 89 分的效能可以放著,可是 www 進不來、DNS 一堆垃圾記錄,這些都是實打實的損失。
- 找只存在於某個環境的證據。 526 之所以能證明 Worker 沒參與,是因為那兩個檔案只存在於新部署。這種證據比看十個錯誤訊息有用。
set -e不是萬靈丹。 管線會吃掉結束碼,任何我以為會擋的檢查,都要實際弄壞一次看看。- 工具的誤判要自己用算術驗一次。 漸層文字的 1.65:1 是假的,自己算完就不會亂改設計。
- SEO 這件事,「多做」有時候是負分。 硬填
updated、讓lastmod每個月都動、把重複子網域留在那裡,看起來都很勤勞,但對爬蟲來說是降低可信度。 - 文件要寫「為什麼不做」。 不然三個月後看到同一條建議,又會重新想一次,然後再放棄一次。
這輪整理下來,最大的感觸是:真正麻煩的從來不是那些看得見的分數,而是那些「好像沒事、其實一直在漏」的地方。就像健康檢查一樣,報告上紅字不可怕,可怕的是你根本沒去驗。
Nick 2026.10.26 於 新北市