今天要解的問題

之前幫 kiwi-walk 做了一次 SEO 健檢,repo 裡能改的都改了,剩下幾件只能在 Cloudflare 後台做:把 kiwi-walk.pages.dev 和 www.kiwi-walk.com 都轉到正式網域、確認 AI 搜尋的爬蟲沒被擋。

照清單跑檢查指令時,www 的結果不對勁:

$ curl -sI https://www.kiwi-walk.com/ | grep -iE "^HTTP|^location"
HTTP/2 302
location: http://www.kiwi-walk.com/login

我的部落格沒有 /login 這個頁面。

想法與取捨

pages.dev 轉址為什麼不勾 Include subdomains

Cloudflare Pages 只對預覽部署自動加 noindex,正式的 kiwi-walk.pages.dev 會完整提供整站內容,等於一份可以被收錄的複本。轉址用 Bulk Redirect 設 301,但「Include subdomains」刻意不勾:勾了的話,每個分支的預覽網址(<hash>.kiwi-walk.pages.dev、<分支>.kiwi-walk.pages.dev)也會一起被轉走,上線前就沒辦法先看改動。之後的分支照常出現預覽網址,證明這個選擇是對的。

www 要轉走,不是只要能開

刪掉錯的記錄、把 www 接到 Pages 專案之後,www 開始回 200,顯示的是自己的網站。但這樣 www 又成了主站的完整複本。每頁的 canonical 都指向 https://kiwi-walk.com/...,Google 多半會自己判斷正本,但直接 301 最明確,所以再加一條 Redirect Rule。

實作

找出 www 後面是誰

把跳轉後的頁面抓回來,是別人的服務:一個西班牙文的發票系統登入頁。

<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>(發票系統的名稱)</title>

回應標頭也不是 Cloudflare Pages 會給的:

x-powered-by: PHP/8.3.11
set-cookie: AWSELB=...
server: cloudflare

server: cloudflare 代表流量有經過我的 Cloudflare,所以問題在我自己的 DNS;AWSELB 和 PHP 代表後面是一台 AWS 上的主機。其他子網域(blog.、mail.、隨機名稱)都解析不到,所以不是萬用記錄,只有 www。

DNS 裡的兩筆 A 記錄

Cloudflare DNS 裡 www 有兩筆 proxied 的 A 記錄,各指向一個 IP:

Cloudflare DNS 列表:www.kiwi-walk.com 有兩筆 A 記錄,都是 Proxied,指向的 IP 已隱藏

用 dig -x 反查這兩個 IP,主機名稱都是這種格式:

ec2-<IP>.us-west-1.compute.amazonaws.com.

兩個都是 Amazon EC2 美西的公用 IP,不是我的任何服務。EC2 的 IP 釋放後會分配給別的客戶,這兩個 IP 剛好落到那個發票系統手上。這兩筆記錄是怎麼來的不確定:可能是以前用過的服務留下的,也可能是網域接進 Cloudflare 時從舊 DNS 自動匯入的。

這種情況叫子網域接管(subdomain takeover)。風險不只是 SEO:我的網域上掛著一個別人的登入表單,輸入的帳密會送到那台陌生主機,對方也能隨時把內容換成任何東西。

修正

  1. 刪掉兩筆 A 記錄。兩筆都要刪,只刪一筆的話 Cloudflare 還會把部分流量送到另一個 IP。
  2. Pages 專案的 Custom domains 加上 www.kiwi-walk.com,Cloudflare 自動建一筆 CNAME 指到 kiwi-walk.pages.dev。
  3. Redirect Rule:Hostname 等於 www.kiwi-walk.com 時,301 到 https://kiwi-walk.com 加原路徑,保留查詢字串。

修完一次檢查所有非正式的網址:

https://www.kiwi-walk.com/            301 -> https://kiwi-walk.com/
https://www.kiwi-walk.com/blogs/?a=1  301 -> https://kiwi-walk.com/blogs/?a=1
https://kiwi-walk.pages.dev/          301 -> https://kiwi-walk.com/
http://kiwi-walk.com/                 301 -> https://kiwi-walk.com/

AI 搜尋的爬蟲沒被擋

清單上的另一項是確認 Cloudflare 沒有擋掉 AI 搜尋的爬蟲(ChatGPT 搜尋靠 OAI-SearchBot,Perplexity 靠 PerplexityBot)。AI Crawl Control 裡這些爬蟲都是 Allow,線上的 robots.txt 也沒有任何 Disallow:

$ curl -s https://kiwi-walk.com/robots.txt
User-agent: *
Disallow:
Sitemap: https://kiwi-walk.com/sitemap.xml

這些爬蟲實際來了多少次、成功多少,另外整理在 AI 爬蟲的數據觀察。

cdn 這筆記錄沒問題

順手把 DNS 列表其他記錄也看一遍。cdn.kiwi-walk.com 的類型是 R2,目標是存放所有圖片的 R2 bucket,記錄旁邊有鎖頭,只能從 bucket 的設定改。它綁的是自己帳號裡的 bucket,不會像 www 那樣指到一個可能換主人的 IP。它是 Proxied(橘色雲),流量會先經過 Cloudflare,後面的快取規則才有地方作用。

CDN 加快取規則

用 PageSpeed Insights 檢查時,有一項是「Use efficient cache lifetimes」。CDN 上的圖片回應沒有 cache-control,一張圖會經過三個地方:訪客的瀏覽器、離訪客近的 Cloudflare 機房、R2。現在只有 R2 在工作,每個訪客、每次換頁,圖片都從 R2 重新下載。

在 Cloudflare 的 Cache Rules 建一條規則,只套用在 cdn.kiwi-walk.com:

Cache Rule 編輯畫面:Hostname equals cdn.kiwi-walk.com,Cache eligibility 選 Eligible for cache

(http.host eq "cdn.kiwi-walk.com")
Cache eligibility: Eligible for cache
Edge TTL:    Ignore cache-control header and use this TTL — 1 month
Browser TTL: Override origin and use this TTL — 1 month

只管 cdn、不管部落格頁面本身,是因為頁面要「改了馬上看得到」,現在的 max-age=0, must-revalidate 是對的;圖片上傳之後就不會再換內容,可以放心存久。

代價是瀏覽器的快取清不掉:Cloudflare 機房的快取可以從後台清,訪客電腦裡的不行。之後要換一張已經上傳的圖,就上傳成新檔名,不要覆蓋同名檔。

踩到的坑

規則明明生效了,curl 卻一直說 DYNAMIC

建好快取規則後,用檢查清單上的指令確認:

$ curl -sI https://cdn.kiwi-walk.com/static/images/logos/logo-nobg-192.png | grep -iE "^cache-control|^cf-cache-status"
cf-cache-status: DYNAMIC

DYNAMIC 是 Cloudflare 表示「這個請求不快取」。規則的 Browser TTL 設了覆寫,生效的話每張圖都該帶上 cache-control,結果一張都沒有,看起來就是規則沒在執行。接著做了一連串排查:

  • 對照組:部落格本身的 kiwi-walk.com/images/favicon-32x32.png 是 MISS、有 cache-control,所以整個網域的快取沒被關掉。
  • 把 192px 的 logo 重新上傳,直接在檔案上寫 cache-control: public, max-age=2592000。回應多了這個標頭,但還是 DYNAMIC。一張明確允許快取的 .png 卻不被快取,像是有別的設定在擋。
  • 回後台看:規則是 active,列表裡只有這一條,條件也寫對了。

最後發現問題在檢查方法本身:curl -I 送的是 HEAD 請求,只問標頭、不拿內容。改用一般的 GET:

$ curl -s -o /dev/null -D - https://cdn.kiwi-walk.com/static/images/og-default.png | grep -iE "^cache-control|^cf-cache-status|^age"
cache-control: max-age=2678400
cf-cache-status: HIT
age: 2584

HIT 代表由 Cloudflare 機房直接回應;age: 2584 代表這份已經存了約 43 分鐘,也就是規則一建好就開始快取了;max-age=2678400 是 31 天,也就是規則裡的「1 month」。在這個網域上,HEAD 請求一律回 DYNAMIC,也不帶規則加的 cache-control。瀏覽器和 PageSpeed 用的都是 GET,所以訪客拿到的一直是快取版本,前面那串排查都是在查一個不存在的問題。檢查清單上的指令後來也改成 GET。

小結

SEO 健檢在 repo 外的部分都收完了:

  • www.kiwi-walk.com、kiwi-walk.pages.dev、http:// 三個非正式網址都 301 到 https://kiwi-walk.com,分支的預覽網址照常可用。
  • 指向別人伺服器的兩筆 www 記錄刪掉了,網域上不再掛著別人的登入頁。
  • AI 搜尋的爬蟲沒被擋。
  • CDN 上的圖片由 Cloudflare 機房快取,瀏覽器也存 31 天。

這次留下兩個判斷。第一,DNS 裡指向 IP 的 A 記錄,只要那台主機不是自己的、或已經不用了,就要刪掉:雲端的 IP 會換主人,留著就是讓別人的網站掛在自己的網域上。第二,檢查快取要用 GET(curl -s -o /dev/null -D -),不要用 curl -I,在這個網域上 HEAD 請求看到的永遠是 DYNAMIC。