# 讓 Astro 部落格被 Google 與 ChatGPT 找到 — robots.txt、JSON-LD 與 IndexNow

> 這個 Astro 部落格如何加上 robots.txt、JSON-LD、Markdown 複本與 IndexNow。Google 找不到它的原因不是網域，llms.txt 也不是解方。

- Source: https://oharu121.com/zh-tw/blog/astro-blog-findable-google-chatgpt-indexnow/
- Published: 2026-08-19T12:42:01+09:00
- Tags: SEO, Astro, Vercel, Cloudflare

---
## 引言

我把自己一篇文章的標題原封不動丟到 Google 搜尋，結果什麼都沒有。三種語言、二十一篇文章，網站也已經上線好幾個月，但就 Google 而言，這些通通不存在。

我第一個懷疑的是網域。部落格當時由 `oharu-tech-blog.vercel.app` 提供，而這種在 Vercel 專案之間共用的免費子網域，看起來正是搜尋引擎會默默降低評價的東西。**這個猜測在機制上是對的，在份量上是錯的。** 網域的影響確實存在，但屬於次要。真正的原因平淡得多：**網站從來沒有送交給任何搜尋引擎，沒有任何反向連結，而 `/robots.txt` 回傳 404。**

修這件事又帶出第二個我沒預期到的問題。如果要讓網站對 Google 可讀，那要讓它對 ChatGPT 和 Claude 可讀又需要什麼？這一半帶來了更有用的意外：**大家最先想到的那個檔案 `llms.txt`，幾乎沒有人去抓**，而真正能把一篇文章送進 AI 回答裡的是 Bing。

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

*Figure — TwoEngineSplit: 從同一個網站延伸出的兩條路徑，兩者都無法取代對方。*

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

`*.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`。

結果就出現在三種語言的每一篇文章上：

```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` 仍然能解析：

```ts
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-User` 與 `Claude-User` 是代替某個指名要某頁的人在行動，OpenAI 自家文件也寫著 robots.txt 對它們「可能不適用」。在那裡寫 `Disallow` 是請求，不是保證。

*Figure — CrawlerMatrix: 九個代理程式、三家廠商，開關是以格為單位而不是以公司為單位。*

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

*Figure — MarkdownTwin: 兩邊的文字完全相同，但幾乎全是文章的只有一邊。*

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

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

*Figure — BacktickMasking: 同樣九個字元，兩種比對方式。後者再也回不來。*

有兩個檔案帶著這個損壞上線了。在 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 就能讓它不生效。

*Image: 在 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，這樣仍然可行。

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

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

*Image: 記錄傳播之後的同一個 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 的頁面。

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

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

## 送交給 Google 與 Bing

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

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

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

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

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

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

*Image: 針對一篇已發布文章的 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」。

*Image: 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 轉換的驗證用的是轉換自己那個壞掉的樣式來移除程式碼，於是面對兩個損壞的檔案回報「乾淨」。

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

## 參考連結

- [Google Search Console 的網站地圖報表，包含網域資源與網址前置字元資源的差異](https://support.google.com/webmasters/answer/7451001)
- [OpenAI 的爬蟲文件，列出 GPTBot、OAI-SearchBot 與 ChatGPT-User，以及使用者發起的抓取對 robots.txt 的但書](https://developers.openai.com/api/docs/bots)
- [Anthropic 的爬蟲文件，說明 ClaudeBot、Claude-SearchBot 與 Claude-User 可以各自獨立封鎖](https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler)
- [IndexNow 通訊協定規格，包含金鑰檔案的要求](https://www.indexnow.org/documentation)
- [Vercel 關於避免 vercel.app 網址與自訂網域重複內容的指南](https://vercel.com/kb/guide/avoiding-duplicate-content-with-vercel-app-urls)
- [CommonMark 規格的程式碼區段規則，也就是修正後的轉換所實作的連續長度規則](https://spec.commonmark.org/0.31.2/#code-spans)
