
讓 Astro 部落格被 Google 與 ChatGPT 找到 — robots.txt、JSON-LD 與 IndexNow
這個 Astro 部落格如何加上 robots.txt、JSON-LD、Markdown 複本與 IndexNow。Google 找不到它的原因不是網域,llms.txt 也不是解方。
本頁目錄
引言
我把自己一篇文章的標題原封不動丟到 Google 搜尋,結果什麼都沒有。三種語言、二十一篇文章,網站也已經上線好幾個月,但就 Google 而言,這些通通不存在。
我第一個懷疑的是網域。部落格當時由 oharu-tech-blog.vercel.app 提供,而這種在 Vercel 專案之間共用的免費子網域,看起來正是搜尋引擎會默默降低評價的東西。這個猜測在機制上是對的,在份量上是錯的。 網域的影響確實存在,但屬於次要。真正的原因平淡得多:網站從來沒有送交給任何搜尋引擎,沒有任何反向連結,而 /robots.txt 回傳 404。
修這件事又帶出第二個我沒預期到的問題。如果要讓網站對 Google 可讀,那要讓它對 ChatGPT 和 Claude 可讀又需要什麼?這一半帶來了更有用的意外:大家最先想到的那個檔案 llms.txt,幾乎沒有人去抓,而真正能把一篇文章送進 AI 回答裡的是 Bing。
本文整理這兩個部分:究竟是什麼讓網站找不到,以及在診斷正確之後,為搜尋引擎與回答引擎做了哪些東西。
網域是第一個被懷疑的對象
*.vercel.app 是非常多專案共用的子網域,其中也包含垃圾內容,因此搜尋引擎會把它整類一起限制抓取頻率並降低優先度。它也無法在 Google Search Console 註冊成網域資源,只能註冊成網址前置字元,那是一個範圍更小、也更難處理的東西。
所以網域確實是一道天花板。但天花板不是地板,它並不能解釋索引裡一筆都沒有這件事。放在評價偏低的主機上,網站還是會被抓取。
真正的線索是,啟動整個流程的那一件事,我一次也沒做過。搜尋引擎不會偶然發現一個新網站。 它要嘛順著連結過來,要嘛讀你交給它的網站地圖。這個部落格沒有反向連結,也沒有送出網站地圖,等於完全沒有給 Google 入口。
稽核實際找到的東西
不靠推論而是直接檢查線上的網站,得到一份依影響程度排序的清單:
| 發現 | 份量 |
|---|---|
| 從未送交 Search Console 或 Bing,也沒有反向連結 | 原因 |
*.vercel.app 共用子網域 |
真實存在,但次要 |
/robots.txt 回傳 404 |
沒有 Sitemap: 這一行,也沒有 AI 相關政策 |
hreflang 與 canonical 指向不同網址 |
真正的缺陷,意外發現 |
技術面大致沒問題,而那正是有用的資訊。每一頁都是靜態 HTML 且沒有 X-Robots-Tag,sitemap-index.xml 帶著三個語系與各自的替代語言回傳 200,每篇文章也早就帶有 BlogPosting 節點。網站是可以被抓取的,只是從來沒有人被邀請來抓。
robots.txt 回傳 404 這件事值得寫精確一點,因為很容易被過度解讀。缺少 robots.txt 本身不會擋住任何東西,慣例上「沒有」代表「全部都可以抓」。真正失去的是 Sitemap: 那一行,也就是一個沒帶著網站地圖前來的爬蟲唯一能找到它的地方,以及任何關於 AI 爬蟲的表態。
每篇文章都替自己標出兩個網址
稽核翻出一個沒有人在找的缺陷。
這個儲存庫裡的 localeUrl() 負責替導覽、hreflang 與 RSS 產生站內相對路徑,而它回傳的路徑結尾沒有斜線。canonical 標籤與 og:url 則是從 Astro.url.pathname 組出來的,在靜態建置中那個是帶斜線的,因為建置輸出的是 <dir>/index.html。
結果就出現在三種語言的每一篇文章上:
<link rel="canonical" href="https://oharu121.com/blog/rag-vs-finetuning-kb-search/"><link rel="alternate" hreflang="en-US" href="https://oharu121.com/blog/rag-vs-finetuning-kb-search">一個頁面兩個網址,然後交給爬蟲去自行調和。 沒有任何東西失敗。也沒有任何檢查抓到,因為沒有任何檢查會把這兩個字串拿來互相比對。
修法是在 localeUrl 內部正規化,並排除最後一段含有點號的路徑,這樣 /rss.xml 仍然能解析:
export function localeUrl(locale: Locale, path = '/'): string { const suffix = path.startsWith('/') ? path : `/${path}`; const isFile = suffix.slice(suffix.lastIndexOf('/') + 1).includes('.'); const normalized = isFile || suffix.endsWith('/') ? suffix : `${suffix}/`; return `${localePrefix(locale)}${normalized}`;}同一次變更裡還有一個副作用必須一起修。scripts/lib/article-lastmod.ts 以網址為鍵存放網站地圖的 lastmod,先前為了補償而自己加了一個斜線。放著不管的話每一筆都會變成 //,而且這種失敗是無聲的:鍵只是不再對上,然後每篇文章安靜地失去它的日期。
用名字逐一列出十一隻 AI 爬蟲並放行
robots.txt 是用路由產生的,不是靜態檔案,所以 Sitemap: 這一行由網站來源推導而來,換網域也不會壞。政策由我決定:放行所有 AI 爬蟲,包含蒐集訓練資料的那些。
理由是,這是一個目的就在於觸及範圍的個人部落格。被收進訓練語料是好處而不是代價。一則關於 Pagefind 與日文輸入法組字的回答,能不能帶著這個網站的推論過程,並不取決於當下有沒有爬蟲在線上。
代理程式的設計選擇是即使 User-agent: * 搭配 Allow: / 已經涵蓋,仍然把十一隻全部列名:
User-agent: GPTBotAllow: /
# OpenAI — the index behind ChatGPT searchUser-agent: OAI-SearchBotAllow: /看起來多餘,其實不是。訓練與檢索是不同的代理程式,開關也各自獨立,封鎖其中一個不會封鎖其他。Anthropic 針對自家三隻機器人明確寫出這件事:ClaudeBot 蒐集訓練資料,Claude-SearchBot 建立搜尋索引,Claude-User 則是因為有人開口要才去抓那一頁。真正的問題從來不是「要不要讓 AI 爬蟲進來」,而是你想不想在不被拿去訓練的前提下被引用,而那只能逐個 token 表達。
其中兩隻帶著值得知道的但書。ChatGPT-User 與 Claude-User 是代替某個指名要某頁的人在行動,OpenAI 自家文件也寫著 robots.txt 對它們「可能不適用」。在那裡寫 Disallow 是請求,不是保證。
llms.txt 得到關注,卻幾乎沒有流量
講到 AI 可見度,最直覺的做法就是 llms.txt,也就是放在固定路徑上的網站 Markdown 索引。在動手做之前,我請代理程式先去查它到底有沒有用。
證據比預期更糟。九十天的觀測裡,llms.txt 的請求大約 408 次,而 AI 機器人的造訪超過五億次;截至 2026 年初,也沒有任何主要廠商公開表示會在正式環境讀取它。導入率隨樣本差異極大,從 Tranco 前一千名的約 8.7%,到某些固定樣本的過半都有,但導入不等於被使用。
真正會去抓的是 IDE 的代理程式與 MCP 伺服器。對一個讀者是開發者的部落格來說,那是實際存在的一群讀者,所以 llms.txt 還是做了,比照既有的 RSS 路由,每個語系一份。它是照著它能換到的東西來決定規模的,是一份便宜的索引,而不是主角。
真正有效的槓桿,結果是另外兩件事。
把每篇文章也用 Markdown 提供
拿到 HTML 頁面的回答引擎,得先從導覽外殼、目錄、主題切換與分享列之中找出文章。拿到 /blog/<slug>.md,它直接得到內文。
於是每篇文章現在都提供兩次:一次是頁面,一次是同一路徑結尾加上 .md 的 Markdown,並且由文章自己的 <head> 連過去。以已發布的語系頁面計算,共 67 個檔案。
麻煩之處在於,這個部落格已發布的文章全都會 import 元件。 其中有 26 次 Figure 的 import、109 次 <Figure> 的使用,以及約 40 個一次性的內嵌 SVG 圖表元件,各自放在文章自己的 _figures/ 裡。Astro 元件並沒有可以降級成的 Markdown。
代理程式寫的轉換會移除 import,把 <Callout> 換成帶標籤的引言區塊,並把圖表換成它的圖說加上圖表名稱:
*Figure — RagVsFinetuning: RAG keeps facts outside the model; fine-tuning folds behaviour into it.*點陣圖保留替代文字、失去網址,因為建置後的資產路徑是一段轉換無從得知的內容雜湊,而相對路徑一旦搬到 /blog/slug.md 就不再成立。一個會 404 的連結比沒有連結更糟,因為引用它的模型會把壞掉的網址一路傳下去。
這是刻意的資訊損失。 內文、標題、表格與程式碼圍籬都原樣通過,而值得引用的內容實質上全在那裡。
檢查程式和那個錯誤有同一個盲點
這段轉換有一個錯誤,是代理程式寫的,而它被找出來的方式是本文最有用的部分。
在改寫任何標籤之前,必須先把程式碼圍籬遮起來,否則一篇引用 <Figure> 當範例的文章,自己的片段就會被改寫。那層遮罩本來就存在。處理行內程式碼的那一半用的是這個樣式:
`[^`\n]+`它要求分隔符號之間至少有一個非反引號字元。碰到雙重的區段就會失敗,而這個部落格的風格指南經常使用雙重區段,因為那正是在行內程式碼裡寫出反引號的方法:
`` `{ foo: 1 }` ``這個樣式改而比對到反引號、空白、反引號,於是該段落中它之後的每個反引號都錯開了一位。
有兩個檔案帶著這個損壞上線了。在 content collections 那篇文章的日文版與繁體中文版裡,行內的 `<Callout>text</Callout>` 落在遮罩之外,並在程式碼區段的內部被改寫成真正的引言:
`> **Note**>> text`はさらに2言語への翻訳に耐えますが、``は翻訳者に…接下來才是值得留下的部分。我看到的驗證結果是「乾淨」,因為它用的是同一個單一反引號的樣式來移除程式碼。 那段檢查程式,帶著它本來就是為了抓出來的那個盲點。是一個閱讀差異的程式碼審查代理程式,選擇回頭去看建置後的產物而不是相信檢查結果,才讓它在發布前被抓到。
修好之後立刻又引入第二個錯誤。放寬後的樣式會吞掉圍籬的佔位符,於是三個檔案裡混進了原始的 NUL 位元組。這兩個問題現在都改用連續長度來比對,也就是 Markdown 自己用的規則:一個區段以 N 個反引號的連續開啟,並在下一個恰好 N 個的連續處關閉。
檢查通過這件事,既是關於程式碼的證據,也同樣是關於那個檢查本身的證據。 當檢查與被檢查的對象共用同一個前提,它就會在最關鍵的那一刻回報「沒問題」。
IndexNow,因為 ChatGPT 讀的是 Bing
AI 可見度上效果最大的一項,和 AI 專用的檔案格式毫無關係。據報導,Bing 的索引支撐了 ChatGPT 引用來源的絕大多數,也就是說 Bing 沒有抓過的頁面,不管綱要寫得多乾淨,都不會出現在 ChatGPT 的回答裡。
IndexNow 就是推送到那個索引的通道。同一個端點同時服務 Bing、Yandex、Seznam 與 Naver。Google 並未參與,所以這是 Search Console 的另一半,而不是它的替代品。
金鑰是一個放在網站根目錄的檔案,內容與檔名本身相同,驗證方式就是「抓下來比對」。這個設計讓金鑰在本質上就是公開的,因此它被提交進儲存庫,而送出用的腳本是從 public/ 讀回它,而不是從環境變數。只要提供它的檔案就是被讀取的檔案,金鑰與證明就不可能彼此偏離。
要送出哪些網址是白撿的。buildLastmodMap 本來就存在,用來提供網站地圖裡各篇文章的 lastmod,而且它本來就會略過不是 status: published 的項目。所以腳本只要用日期區間去篩那份對應表,不必去比對 git 差異。
70 URL(s) for oharu121.com: …IndexNow accepted the batch (200).CI 在每次正式部署後執行它。第一次部署送出了落在預設七天區間內的 45 個網址,回傳 202,意思是已收到、金鑰驗證尚待處理。後來完整送一次時回傳 200,也就是已驗證並接受。
搬到 oharu121.com
診斷清楚之後我就買了網域。我最先挑的是一年三美元的 .click,代理程式反對:便宜的新奇頂級網域雖然沒有直接的排名懲罰,但被大量用於垃圾註冊,因而更難被建立索引,別人也更不願意連過來。那恰好就是當時已經卡住的那兩件事。我接受了這個論點,改買 oharu121.com。
我也決定使用不帶前綴的根網域,而不是 blog. 子網域。子網域不會自動與母網域共享權重,而在到處都沒有權重的情況下,把它拆成兩堆是錯的形狀。
DNS 留在 Cloudflare,但代理關閉。灰雲在這裡不是暫時狀態,而是長期正確的狀態。 Vercel 本來就用自己的邊緣網路擋在網站前面,所以代理等於多加一層 CDN,而它可能在部署後仍然提供舊的 HTML。更關鍵的是,Cloudflare 開始對新加入的網域預設封鎖 AI 訓練與代理型爬蟲,那會默默地讓上面那份放行清單失效。機器人規則只作用於經過代理的流量,因此維持純 DNS 就能讓它不生效。

Vercel 要求在根網域放一筆指向專案專屬主機名稱的 CNAME,而不是多數指南至今仍在引用的通用 cname.vercel-dns.com。Cloudflare 會在區域根做 CNAME 扁平化,所以即使 DNS 禁止在那裡放真正的 CNAME,這樣仍然可行。


舊主機現在會對新主機發出逐路徑的 308,那邊的 /blog/x/ 會落在這邊的 /blog/x/。這等於直接把兩個主機合併起來,比這種情境下一般建議的 canonical 標籤做法更強。被重新導向的請求,根本到不了任何一個爬蟲能讀取 head 的頁面。
在儲存庫這一側,搬遷只有一行,因為網站上所有絕對網址都由同一個常數解析而來:
export const SITE = 'https://oharu121.com';送交給 Google 與 Bing
Search Console 接受的是以 DNS TXT 驗證的網域資源,一次涵蓋根網域、www、兩種通訊協定,以及未來任何子網域。Google 與 Cloudflare 之間有一個一次性的授權流程,會替你寫下那筆記錄。


接著是讓結構化資料那部分工作顯得值得的地方。Google 的網址審查回報這一頁可供索引,並且回報了它找到的 BreadcrumbList 節點:

Bing 的設定快得多,因為驗證與網站地圖都能直接從 Search Console 匯入。
兩則看起來像失敗的訊息
送交過程中有兩件事讀起來像錯誤,實際上不是,而且兩件都花掉了時間。
第一件是我自己踩進去的。Search Console 以**「Invalid sitemap address. Please enter a valid path to a sitemap in your site.」**拒絕了網站地圖。我當時得到的建議是輸入裸路徑 sitemap-index.xml。那對網址前置字元資源是對的,因為欄位旁邊會顯示固定的主機。網域資源橫跨多個主機,所以裸路徑是有歧義的,會被拒絕。它要的是完整網址。
第二件是 Bing 打上紅色叉號,顯示**「Discovered but not crawled」**,並附上一行「URL cannot appear on Bing」。

把它讀成故障是很自然的誤會。Discovered on 18 Aug 2026 是 IndexNow 送出後落地的記錄,代表整條流程有作用。訊息內容只是建議去看通用準則,並且沒有指出任何具體問題,那正是那個面板在表示「還沒去抓」時會說的話。直接檢查也確認沒有東西需要修:robots.txt 裡一行 Disallow 都沒有,而帶著 Bingbot 使用者代理字串的請求,在首頁、文章與網站地圖上都拿到 200。
比較誠實的解讀是,一個上線才幾天、又沒有反向連結的網域,檢索配額趨近於零。IndexNow 能告訴 Bing 有這個網址存在,但沒辦法讓 Bing 想要它。
總結
這件工作乾淨地分成兩半,而兩半的形狀相同:聲音最大的那個答案,並不是真正承重的那一個。
- 在 Google 上缺席不是技術缺陷。 網站從上線第一天就可以被抓取。它只是從未被送交、也沒有反向連結,而這兩件事都不是標記語言能替代的。
- 主機的影響沒有看起來那麼大。 共用的
*.vercel.app子網域確實是一道天花板,但離真正的瓶頸還很遠。 llms.txt不是 AI 那一側的槓桿。 五億次 AI 機器人造訪對上大約 408 次抓取。為了 IDE 代理程式值得做,也值得照這個份量去做。- AI 那一側的槓桿是 Bing,因為它的索引支撐 ChatGPT 的搜尋,而 IndexNow 是推送進去的通道。
- Markdown 複本勝過檔案格式。 把頁面裝飾拿掉之後的內文,才是回答引擎真正用得上的東西。
- 和程式碼共用同一個前提的檢查,會在最關鍵的時候通過。 Markdown 轉換的驗證用的是轉換自己那個壞掉的樣式來移除程式碼,於是面對兩個損壞的檔案回報「乾淨」。
還沒解決的是最花時間、也決定上面這一切能不能兌現的那一項:反向連結。上面所有工作都讓網站變得可讀,但沒有一項會讓任何人連過來。
參考連結
- Google Search Console 的網站地圖報表,包含網域資源與網址前置字元資源的差異
- OpenAI 的爬蟲文件,列出 GPTBot、OAI-SearchBot 與 ChatGPT-User,以及使用者發起的抓取對 robots.txt 的但書
- Anthropic 的爬蟲文件,說明 ClaudeBot、Claude-SearchBot 與 Claude-User 可以各自獨立封鎖
- IndexNow 通訊協定規格,包含金鑰檔案的要求
- Vercel 關於避免 vercel.app 網址與自訂網域重複內容的指南
- CommonMark 規格的程式碼區段規則,也就是修正後的轉換所實作的連續長度規則