標籤
白色卡片上的 Google Search Console 標誌與灰色字樣,標誌是一個黃色放大鏡疊在紅、綠、藍三根高度遞增的圓角長條上

讓 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。

本文整理這兩個部分:究竟是什麼讓網站找不到,以及在診斷正確之後,為搜尋引擎與回答引擎做了哪些東西。

一個網站,兩條送出路徑網站分成兩條路徑。要進入 Google,是在 Search Console 送出網站地圖。要進入 ChatGPT 則繞得比較遠:IndexNow 把已變更的網址推送到 Bing 索引,而 ChatGPT 的搜尋會引用 Bing 索引到的內容。Google 並未參與 IndexNow,因此兩條路徑都無法取代對方。搜尋引擎回答引擎oharu121.com67 pagesSearch Console送出網站地圖Google自然搜尋IndexNow推送已變更的網址Bing背後的索引ChatGPT引用 Bing 索引的內容Google 並未參與 IndexNow,兩條路徑都無法取代對方。
從同一個網站延伸出的兩條路徑,兩者都無法取代對方。

網域是第一個被懷疑的對象

*.vercel.app 是非常多專案共用的子網域,其中也包含垃圾內容,因此搜尋引擎會把它整類一起限制抓取頻率並降低優先度。它也無法在 Google Search Console 註冊成網域資源,只能註冊成網址前置字元,那是一個範圍更小、也更難處理的東西。

所以網域確實是一道天花板。但天花板不是地板,它並不能解釋索引裡一筆都沒有這件事。放在評價偏低的主機上,網站還是會被抓取。

真正的線索是,啟動整個流程的那一件事,我一次也沒做過。搜尋引擎不會偶然發現一個新網站。 它要嘛順著連結過來,要嘛讀你交給它的網站地圖。這個部落格沒有反向連結,也沒有送出網站地圖,等於完全沒有給 Google 入口。

稽核實際找到的東西

不靠推論而是直接檢查線上的網站,得到一份依影響程度排序的清單:

發現 份量
從未送交 Search Console 或 Bing,也沒有反向連結 原因
*.vercel.app 共用子網域 真實存在,但次要
/robots.txt 回傳 404 沒有 Sitemap: 這一行,也沒有 AI 相關政策
hreflangcanonical 指向不同網址 真正的缺陷,意外發現

技術面大致沒問題,而那正是有用的資訊。每一頁都是靜態 HTML 且沒有 X-Robots-Tagsitemap-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: GPTBot
Allow: /
# OpenAI — the index behind ChatGPT search
User-agent: OAI-SearchBot
Allow: /

看起來多餘,其實不是。訓練與檢索是不同的代理程式,開關也各自獨立,封鎖其中一個不會封鎖其他。Anthropic 針對自家三隻機器人明確寫出這件事:ClaudeBot 蒐集訓練資料,Claude-SearchBot 建立搜尋索引,Claude-User 則是因為有人開口要才去抓那一頁。真正的問題從來不是「要不要讓 AI 爬蟲進來」,而是你想不想在不被拿去訓練的前提下被引用,而那只能逐個 token 表達。

其中兩隻帶著值得知道的但書。ChatGPT-UserClaude-User 是代替某個指名要某頁的人在行動,OpenAI 自家文件也寫著 robots.txt 對它們「可能不適用」。在那裡寫 Disallow 是請求,不是保證。

依廠商與用途區分的 AI 爬蟲三家廠商對三種用途的表格。OpenAI 以 GPTBot 進行訓練、以 OAI-SearchBot 建立搜尋索引、以 ChatGPT-User 處理使用者發起的抓取。Anthropic 對應的則是 ClaudeBot、Claude-SearchBot 與 Claude-User。Perplexity 有 PerplexityBot 與 Perplexity-User,但沒有另外公開訓練專用的代理程式。每一格都是獨立的 robots.txt 開關,封鎖其中一個並不會封鎖其他。訓練搜尋索引使用者發起OpenAIGPTBotOAI-SearchBotChatGPT-UserAnthropicClaudeBotClaude-SearchBotClaude-UserPerplexity未公開PerplexityBotPerplexity-User每一格都是獨立的 robots.txt 開關,封鎖其中一個不會封鎖其他。使用者發起的代理程式是代替提出要求的人行動,robots.txt 可能不適用。
九個代理程式、三家廠商,開關是以格為單位而不是以公司為單位。

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 個檔案。

同一篇文章,一個帶著整個頁面,一個沒有左邊是 HTML 頁面:頁首、目錄、文章內文、分享列與頁尾,內文只是五個區塊之一。右邊是同一個 slug 加上 .md 的 Markdown 複本,只有內文與一小段中繼資料標頭。兩者的文字完全相同,但只有後者幾乎全是文章本身。HTML 頁面/blog/slug/頁首與導覽目錄文章內文分享列頁尾Markdown 複本/blog/slug.md標題、日期、標籤、來源網址文章內文內文只是五個區塊之一只有內文,以及標示來源的標頭
兩邊的文字完全相同,但幾乎全是文章的只有一邊。

麻煩之處在於,這個部落格已發布的文章全都會 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 }` ``

這個樣式改而比對到反引號、空白、反引號,於是該段落中它之後的每個反引號都錯開了一位。

找出程式碼區段結尾的兩種方式同一段原始碼,用兩種方式比對。以連續長度比對時,會在兩個反引號的連續處開啟,並在下一個恰好兩個的連續處關閉,因此能涵蓋整個區段。單一反引號的樣式則要求分隔符號之間至少有一個非反引號字元,於是跳過了開頭那一對,改而比對到反引號、空白、反引號。從那個位置之後的每個反引號都錯開一位,因而讓落在遮罩之外的 Callout 標籤被改寫成引言。連續長度:以 N 個開啟,在下一個恰好 N 個的連續處關閉``·`{ foo: 1 }`·``整個區段都被遮住單一反引號:分隔符號之間需要一個非反引號字元``·`{ 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 就能讓它不生效。

在 oharu121.com 的 DNS 記錄頁面上開啟的 Cloudflare Add record 對話框,Type 設為 CNAME,Name 欄位提示根網域要用 @,Proxy status 的切換是關閉的並顯示 DNS only

代理關閉。位於請求路徑上的 Cloudflare 會破壞 Vercel 的憑證簽發,並快取它無從得知已被部署替換的 HTML。

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

記錄尚未傳播前的 Vercel Domains 清單,oharu121.com 標著紅色的 Invalid Configuration,以 308 重新導向到 www.oharu121.com,下方是 DNS Records 與 Vercel DNS 兩個分頁

記錄傳播之前:根網域無法通過驗證,仍然重新導向到 www 子網域。

記錄傳播之後的同一個 Vercel Domains 頁面,oharu121.com 標示為 Valid Configuration 與 Production,而 www.oharu121.com 與 oharu-tech-blog.vercel.app 都標示為 Valid Configuration,並以 308 重新導向到 oharu121.com

傳播之後。根網域是正式網域,www 與舊的 vercel.app 主機都導向它。

舊主機現在會對新主機發出逐路徑的 308,那邊的 /blog/x/ 會落在這邊的 /blog/x/這等於直接把兩個主機合併起來,比這種情境下一般建議的 canonical 標籤做法更強。被重新導向的請求,根本到不了任何一個爬蟲能讀取 head 的頁面。

在儲存庫這一側,搬遷只有一行,因為網站上所有絕對網址都由同一個常數解析而來:

export const SITE = 'https://oharu121.com';

送交給 Google 與 Bing

Search Console 接受的是以 DNS TXT 驗證的網域資源,一次涵蓋根網域、www、兩種通訊協定,以及未來任何子網域。Google 與 Cloudflare 之間有一個一次性的授權流程,會替你寫下那筆記錄。

Cloudflare 的一次性授權畫面,列出 Google 將為 oharu121.com 新增的 DNS 記錄:一筆位於根網域的 TXT 記錄,內容是被遮蔽的 google-site-verification 值,TTL 為一小時,Proxy status 為 DNS only

驗證記錄由 Google 自己寫入一次,並且維持代理關閉。

oharu121.com 的 Google Search Console 網站地圖頁面,針對 https://oharu121.com/sitemap-index.xml 顯示綠色的 Sitemap submitted successfully 確認訊息

網域資源要的是完整的網站地圖網址,被拒絕的是那個裸路徑。

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

針對一篇已發布文章的 Google Search Console 網址審查,顯示 URL is available to Google、Page availability 為 Page can be indexed,並在 Enhancements and Experience 下方有一列 Breadcrumbs 寫著 1 valid item detected

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」。

Bing Webmaster Tools 的網址審查,顯示紅色的 Discovered but not crawled 狀態並附註 URL cannot appear on Bing,Discover 階段為綠色且日期是 2026 年 8 月 18 日,Crawl 階段則失敗,附上一段建議參考 Bing Webmaster Guidelines 的通用訊息

在 IndexNow 送出的那一天就被發現。抓取階段沒有指出任何具體問題,那正是「還沒去抓」時的顯示方式。

把它讀成故障是很自然的誤會。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 轉換的驗證用的是轉換自己那個壞掉的樣式來移除程式碼,於是面對兩個損壞的檔案回報「乾淨」。

還沒解決的是最花時間、也決定上面這一切能不能兌現的那一項:反向連結。上面所有工作都讓網站變得可讀,但沒有一項會讓任何人連過來。

參考連結

分享這篇文章