今天打開 Search Console,看到這一行:

1
/sitemap.xml   2026年6月8日   2026年6月11日   成功   612

狀態「成功」,系統探索到的網頁 612。我盯著它看了幾秒,因為文章也累積到一些數量了,想著要讓Google搜尋能搜尋到,結果到現在,這一欄一直是紅色的「無法擷取」。整整三個月,Google 連我的 sitemap 都不願意讀,全站文章只有首頁被索引。

最後解決問題的那一步,跟 sitemap 的內容一點關係都沒有。這篇把整個排查過程寫下來,包含三次「修對了東西但沒解決問題」的彎路——如果你的 GitHub Pages 部落格也卡在這個狀態,也許可以少走幾步。

問題長什麼樣

我的部落格是 Hexo 生成、部署在 GitHub Pages(kyosora.github.io)。三月中建站時把 sitemap.xml 提交到 Search Console,狀態顯示「無法擷取」(Couldn't fetch)。

當時想說剛建站,Google 需要時間。結果這個狀態凍結了快三個月,期間「已送出」日期一直停在 3 月 17 日,系統探索到的網頁是 0。

詭異的是,sitemap 檔案本身完全正常。瀏覽器打得開,curl 模擬 Googlebot 的 UA 去抓也是 200:

1
2
3
curl -sSIL -A "Mozilla/5.0 (compatible; Googlebot/2.1; ...)" https://kyosora.github.io/sitemap.xml
→ HTTP/1.1 200 OK
→ Content-Type: application/xml

檔案抓得到、格式正確、大小 85KB 遠低於 50MB 上限。但 GSC 就是說它無法擷取。

第一輪:robots.txt 真的有問題

五月底我跟 Claude Code 一起把整個站翻了一遍,第一個有實質意義的發現來自 GSC 的「測試線上網址」功能——資源載入清單裡有一排紅字:

1
2
3
4
Googlebot 遭到 robots.txt 封鎖
/css/index.css
/js/about-cards.js
...

我建站時抄來的 robots.txt 範本封鎖了 /css//js/。本意大概是「不想讓爬蟲浪費時間在靜態資源上」,但對現在的 Google 來說這是反效果:Googlebot 要渲染頁面才能評估品質,你把 CSS/JS 全擋掉,它看到的就是一個破版的骨架。

把 robots.txt 改成全開,順手把 sitemap 申報加上去:

1
2
3
4
User-agent: *
Allow: /

Sitemap: https://kyosora.github.io/sitemap.xml

改完重新提交 sitemap。幾天後狀態真的變了——從「無法擷取」變成「無法讀取 Sitemap」。

聽起來像進度,實際上是換了一種失敗。「無法擷取」是 HTTP 層拿不到檔案,「無法讀取」是拿到了但解析不了。Google 終於肯下載我的 sitemap,然後告訴我內容有問題。

第二輪:sitemap 裡真的有重複 URL

既然 Google 說讀不了,那就把檔案拆開來驗。用 PowerShell 把所有 <loc> 抓出來分組,找到 6 個重複的 URL。腳本是 Claude Code 現場拼的,核心就這幾行:

1
2
3
$urls = [regex]::Matches((Get-Content sitemap.xml -Raw), '<loc>([^<]+)</loc>') |
ForEach-Object { $_.Groups[1].Value }
$urls | Group-Object | Where-Object { $_.Count -gt 1 } | Select-Object Count, Name

重複有兩種來源,第二種我覺得值得單獨講。

第一種:兩篇文章生成了同一個網址。我用 hexo-abbrlink 做永久連結,它拿文章內容算 CRC32。翻原始檔才發現 _posts 裡躺著一篇某次操作失誤產生的複製檔——內容跟另一篇一模一樣,只有標題欄位不同。內容相同,CRC32 就相同,兩篇文章指向同一個 URL。刪掉重複檔就解了。

第二種:tag 命名不一致。這個坑藏得深。Hexo 把 tag 名稱轉成網址時會做正規化——空格變連字號、大小寫合併。所以這些寫法:

文章 A 的 tag 文章 B 的 tag 生成的網址
AI Agent AI-Agent 都是 /tags/AI-Agent/
Hexo HEXO 都是 /tags/Hexo/
Prompt Engineering prompt engineering 都是 /tags/Prompt-Engineering/

在 Hexo 內部它們是不同的 tag 物件,各自產生一個 tag 頁,但網址撞在一起——sitemap 就老老實實輸出了兩次。我的站累積了 5 組這種重複。Google 官方文件沒明說重複 <loc> 會怎麼處理,但至少在我的案例裡,重複沒清掉之前,它就是一直停在「無法讀取」。

把少數派的 tag 寫法改成跟多數一致,hexo clean 重建,驗證 667 個 URL 零重複,部署,重新提交。

這次我很有信心。然後等了兩天,還是「無法讀取」。

第三輪:檔案已經完美了,所以問題不在檔案

到這裡我開始懷疑人生,把所有能想到的檢查全跑了一遍:

  • gzip 壓縮版驗證——GitHub Pages 會對 Googlebot 回傳 gzip,所以特地帶 Accept-Encoding: gzip 抓下來解壓驗證,正常
  • BOM 檢查——無
  • XML 控制字元——無
  • lastmod 日期——格式合法、沒有未來日期
  • 重複 URL——零

每一項都是綠的。一份在技術上挑不出任何毛病的 sitemap,Google 就是讀不了。

(這期間還試了兩條死路:Google 的 sitemap ping API,2023 年就廢棄了,打過去直接 404;IndexNow 只有 Bing 系在收,Google 不認。)

接著我看到有人分享:sitemap 換個檔名重新提交就好了。邏輯上說得通——GSC 的失敗狀態是綁在 URL 上快取的,sitemap.xml 這個網址被標記失敗後,重新提交同一個網址可能清不掉舊狀態;換個全新的檔名,Google 沒有它的歷史包袱,會當成新東西從頭處理。

hexo-generator-sitemappath 原生支援陣列,改一下設定就能同時輸出兩份:

1
2
3
4
sitemap:
path:
- sitemap.xml # 原樣保留,Bing 還認得它
- sitemapV2.xml # 全新網址給 Google

提交 sitemapV2.xml,再等。五天,還是「無法擷取」。

轉折:問題不在檔案,在路徑

等待的這幾天,GSC 的「檢索統計資料」報告把另一個事實攤在我面前:90 天裡 Google 總共只來爬了 83 次,平均一天不到一次,其中 36% 還撞在 404 上;全站被索引的頁面只有首頁 1 頁。Google 對 kyosora.github.io 這個 host 的態度,用冷淡形容都太客氣。

6 月 8 日,我找到 Ronnie Wong 的一篇文章,他在 GitHub Pages 上遇到一模一樣的狀況:sitemap 怎麼換檔名、換格式(XML、TXT、巢狀、sitemap index)都卡「無法擷取」,連用 API 查都只有 isPending: true,沒有錯誤也沒有下載紀錄。

他最後有效的做法,是把 sitemap 搬到 Cloudflare Worker 上,用 workers.dev 的網址另開一個 property 提交。幾天後,成功。

他的結論我直接引用,因為這就是整件事的核心:

當多個 sitemap 檔名和格式都失敗時,不要繼續只改 XML;要換 host、換 property、換提交路徑。

我前面三輪都在同一個假設裡打轉:「問題出在 sitemap 檔案本身」。但所有證據其實早就指向另一邊——檔案每一項檢查都過,Google 就是不下載。那有問題的不是檔案,是 Google 通往 kyosora.github.io 這個 host 的讀取路徑。具體卡在哪一層(CDN、GitHub Pages 的某種行為、GSC 內部對 github.io 的處理)我到現在也不知道,GSC 是黑箱,它不會告訴你。但既然路不通,那就換條路。

實作:30 行的 Worker

做法比想像中簡單。Worker 不用存任何內容,它就是個轉發器——Google 來要 sitemap,它即時去 GitHub Pages 抓最新版回給 Google:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const ORIGIN = 'https://kyosora.github.io';

export default {
async fetch(request) {
const url = new URL(request.url);

if (url.pathname === '/sitemap.xml') {
const resp = await fetch(`${ORIGIN}/sitemap.xml`, {
// 繞過 Cloudflare 的上游快取,永遠拿最新版
cf: { cacheTtl: 0 },
});
if (!resp.ok) {
return new Response('upstream sitemap fetch failed', { status: 502 });
}
return new Response(await resp.text(), {
status: 200,
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
});
}

// 首頁回 GSC 驗證用的 meta tag
return new Response(
'<!DOCTYPE html><html><head><meta name="google-site-verification" content="你的驗證碼" /></head><body>sitemap worker</body></html>',
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
},
};

cf.cacheTtl 是 Cloudflare 給 Workers fetch 的專有選項。一開始我沒設,結果改完原站 sitemap 後 Worker 一直吐快取的舊版,加上這行重新部署才即時更新——這是實際跑過驗證的版本,不是查文件抄的。)

wrangler deploy 推上去,拿到一個 *.workers.dev 網址。然後在 GSC:

  1. 新增資源 → URL 前置字元 → 填 Worker 網址
  2. 驗證方式選「HTML 標記」(meta tag 已經寫在 Worker 首頁回應裡)
  3. 進新 property 提交 sitemap.xml

因為 Worker 是即時轉發,以後我 hexo deploy 完全不用管它——Google 來要的永遠是最新版。維護成本是零。

提交當下狀態一樣顯示「無法擷取」,這是 GSC 的佔位狀態,「上次讀取時間」空白就代表 Google 還沒真的來抓,不用慌。

三天後,6 月 11 日:成功,612 頁。

兩件事先說清楚,免得你跟我一樣空歡喜或搞混:

第一,「系統探索到的網頁 612」是探索(discovered),不是索引。意思是 Google 終於拿到了完整的 URL 清單,知道這 612 頁存在——但要不要爬、要不要收進索引,它還是按自己的步調排隊。入口打通了,後面還是要等。

第二,property 不會搞混。Worker 那個 workers.dev property 只有一個用途:當 sitemap 的提交與監控入口。sitemap 裡列的全是 kyosora.github.io 的網址,Google 爬完索引完,成效、曝光、索引數據統統記在原本 kyosora.github.io 的 property 底下。日常分析照舊看原 property,Worker property 一年大概只需要點開一次確認 sitemap 還是綠的。

回頭看這三個月

把四輪修復攤開看:

  1. robots.txt 解除 CSS/JS 封鎖——該修,修完曝光確實開始爬升,但跟 sitemap 無關
  2. 清掉 6 個重複 URL——該修,髒的 sitemap 給誰都讀不了,但修完還是讀不了
  3. 換檔名——白做,因為還在同一個 host 上
  4. 換 host——三天解決

前兩輪不算白費,它們把「檔案有問題」這個變數徹底排除了,第四輪的判斷才站得住腳。真正浪費時間的是第三輪——我在同一個假設裡多繞了五天,因為「換檔名」感覺像是在改變什麼,實際上關鍵變數一個都沒動。

對了,第二輪那個 tag 命名的坑值得長記性:寫新文章加 tag 前,先查一下站內既有的寫法。我在修復期間發了篇新文章,tag 寫了 vibe coding,而站內已經有篇文章用 Vibe Coding——又一組重複就這樣誕生,被部署前的驗證腳本攔下來。這種錯誤你犯第二次的時候,連自己都想笑。

最後說個直白的感想。下次再遇到黑箱系統給模糊錯誤,我會早一點問自己:我是在修「它說有問題的東西」,還是在修「我修得動的東西」?這兩個常常不是同一個。