---
title: 建站知識庫：從 Lovable 骨架到 Cloudflare Pages 上線
h1: 建站知識庫：從骨架到上線、被 Google 與 AI 搜尋找到
subtitle: 下次建行銷官網照這份做——每一步做什麼、去哪做、會踩什麼坑、怎麼驗證做對了
chips: [{text: 範例站 guidant-ai.stg.jedicotech.com, kind: ok}, {text: TanStack Start＋Cloudflare Pages 純靜態, kind: ok}, {text: 最常踩：Cloudflare zone 層預設會擋 AI 爬蟲, kind: warn}]
---

# 建站知識庫：從骨架到上線、被 Google 與 AI 搜尋找到

> 給下一個要建官網、但不熟 Cloudflare 與 SEO 的人。每一節都是「要做什麼 → 去哪做 → 會踩什麼坑 → 怎麼驗」。範例值取自 Guidant AI 官網（程式碼在 `~/Projects/Billows/Audit-Manager/guidant-ai-website`），換站時把網域、專案名換掉即可。
>
> 坑一律三段：**症狀**（你會看到什麼）→ **原因** → **修法與驗證**。指令可直接複製跑；`$B` 代表站台網址（不帶結尾斜線），先 `export B=https://guidant-ai.stg.jedicotech.com`。

## 0. 一頁總覽

```
骨架 → 內容頁 → SEO 配備 → 截圖 → 部署 → 網域 → 拿 noindex → 關掉 Cloudflare 預設攔截（含 Email 模糊化）→ 填 Web Analytics token → 搜尋引擎登記 → 分析 → 驗收
```

| # | 步驟 | 一句話 | 誰做 | 約多久 |
|---|---|---|---|---|
| 1 | 骨架 | 用 Lovable 生出版型後拉回自己的 git repo，之後不再回 Lovable 改 | 工程 | 半天 |
| 2 | 靜態輸出 | 開 prerender，每個網址產一支 `index.html` | 工程 | 半天 |
| 3 | 內容頁 | 文案進 i18n json／Markdown，元件不寫字串 | 工程 | 每 5 頁約 1 天 |
| 4 | SEO 配備 | head 五欄、robots、sitemap、JSON-LD、og 圖、llms.txt | 工程 | 2～3 天 |
| 5 | 截圖 | 從自家系統的 Demo 資料截亮暗兩套，壓 WebP | 工程 | 1～2 天（含建 Demo 資料） |
| 6 | 部署 | Cloudflare Pages 連 GitLab，push 即部署 | 決策者後台＋工程 | 1 小時 |
| 7 | 網域 | Pages 綁自訂網域，`VITE_SITE_URL` 指它 | 決策者後台 | 15 分鐘 |
| 8 | 拿 noindex | 刪 `_headers` 的 `X-Robots-Tag: noindex`；會改 canonical 的修正（結尾斜線）要先上線 | 工程 | 10 分鐘 |
| 9 | 關 zone 層攔截 | AI 爬蟲政策、Bot Preference Sync、Email 模糊化三個開關 | 決策者後台 | 15 分鐘 |
| 10 | 填 Web Analytics token | 帳號層建站點、拿 token 填 Pages 環境變數、重新部署 | 決策者後台 | 15 分鐘 |
| 11 | 搜尋引擎登記 | Google Search Console 驗證＋送 sitemap，Bing 一鍵匯入 | 有公司帳號的人 | 30 分鐘 |
| 12 | 分析 | 感謝頁當轉換、看 referrer、三個後台分工 | 決策者後台＋工程 | 30 分鐘 |
| 13 | 驗收 | 用各家爬蟲真實身分抓原始 HTML、schema 驗證、Lighthouse | 工程 | 半天 |

**三個最貴的教訓**（後面各節細講）：

1. **AI 爬蟲不跑 JavaScript**，所以內文一定要在 HTML 裡——用 prerender 出靜態檔，不要做純前端 SPA。
2. **Cloudflare 預設會擋 AI 爬蟲、還會改寫你的 robots.txt 與 email**，這三個開關在網域（zone）層，不在 Pages 專案裡，換網域要再關一次。
3. **測爬蟲要用各家完整的 User-Agent 字串**，只寫 `-A GPTBot` 會假綠。

## 1. 框架與靜態輸出

### 為什麼一定要靜態 HTML

Google 會分兩輪：先讀 HTML，之後才排隊跑 JavaScript 渲染；GPTBot、ClaudeBot、PerplexityBot 這些 AI 爬蟲**完全不跑 JavaScript**。純 React／Vue SPA 給它們的是一個空的 `<div id="root">`，等於沒有網站。

所以框架要能「build 時把每頁先算好成 HTML」（這叫 **prerender／靜態預產生**）。TanStack Start、Next.js、Astro、Nuxt 都能做；本站用 TanStack Start，因為 Lovable 產出的骨架就是它，開 prerender 即可，零轉換成本。

### Lovable 骨架拉回來之後

- Lovable 是用對話生網頁的工具，產出 TanStack Start＋React＋Tailwind 4＋shadcn/ui。拉回本機 repo 後**所有修改都在 repo 做**，不要兩邊改。
- `vite.config.ts` 被 `@lovable.dev/vite-tanstack-config` 包了一層，它已經內建 tanstackStart、react、tailwind、nitro 等 plugin。**不要自己再加這些 plugin**，會重複載入而壞掉；要加設定就塞進它的 `defineConfig({ ... })`。
- 用 npm，不用 bun（Lovable 預設 bun，`bun.lock` 不必帶回來）。

### 開 prerender

```ts
// vite.config.ts
import { defineConfig } from "@lovable.dev/vite-tanstack-config";

export default defineConfig({
  nitro: { preset: "cloudflare-module" },          // 一定要固定，見下方坑
  tanstackStart: {
    server: { entry: "server" },
    prerender: { enabled: true, crawlLinks: true, autoSubfolderIndex: true },
    pages: [
      ...["/", "/en/", "/demo/thanks/", "/contact/thanks/", "/en/demo/thanks/", "/en/contact/thanks/"]
        .map((path) => ({ path })),
      { path: "/404/", prerender: { outputPath: "/404.html" } },
      { path: "/en/404/", prerender: { outputPath: "/en/404.html" } },
    ],
  },
});
```

- `npm run build` 後產物在 `.output/public/`，每個網址一支 `index.html`（`autoSubfolderIndex: true` → `/product/index.html`）。這個資料夾就是要部署的東西。
- `crawlLinks: true`：從首頁開始順著 `<a href>` 爬，爬到的頁都會產出。動態路由（例如指南 `/resources/guides/$slug`）只要清單頁有連到每一篇，就會自動被爬到，不必手列 slug。
- **沒有任何連結指到的頁**（表單感謝頁、404）爬不到，要在 `pages` 明列。

驗證：

```bash
cd ~/Projects/Billows/Audit-Manager/guidant-ai-website
npm run build
find .output/public -name index.html | wc -l     # 應等於 頁數 × 語言數
ls .output/public/404.html .output/public/en/404.html
npx serve -l 5180 .output/public                 # 本機看靜態產物（不是 dev server）
```

### 坑：Cloudflare 的 build 機上 prerender 全部 500

- **症狀**：本機 `npm run build` 正常，push 後 Cloudflare Pages 的 build log 每一頁 prerender 都 500，部署失敗。
- **原因**：nitro（TanStack Start 底下的打包器）會看環境變數猜部署平台。Pages build 機有 `CF_PAGES=1`，nitro 自動切成 `cloudflare-pages` preset，伺服器檔改放 `dist/_worker.js`，prerender 找不到 server 檔。
- **修法與驗證**：`vite.config.ts` 固定 `nitro: { preset: "cloudflare-module" }`。本機模擬 build 機：

```bash
CF_PAGES=1 npm run build && find .output/public -name index.html | wc -l   # 數字要跟不帶 CF_PAGES 時一樣
```

### 坑：明列 `pages` 之後首頁不見了

- **症狀**：為了感謝頁加了 `pages: [...]`，build 完 `.output/public/index.html` 消失，全站只剩感謝頁。
- **原因**：一給 `pages`，prerender 就不再預設從 `/` 起爬。
- **修法與驗證**：`pages` 裡一定要放 `/` 與 `/en/`（各語系首頁）。`ls .output/public/index.html .output/public/en/index.html` 都要在。

### 坑：404 落成 `/404/index.html`，Pages 不認

- **症狀**：不存在的網址回 200＋首頁內容（「軟 404」），搜尋引擎會收到一堆重複首頁。
- **原因**：Cloudflare Pages 找不到檔時，只認站根的 `404.html`；沒有就把首頁當 SPA 回退頁、回 200。
- **修法與驗證**：路由加 `src/routes/404.tsx`（＋`en/404.tsx`），在 `pages` 用 `prerender: { outputPath: "/404.html" }` 指定落檔位置；404 頁自己加 `<meta name="robots" content="noindex">`。

```bash
curl -s -o /dev/null -w '%{http_code}\n' $B/nonexistent-page/    # 404
```

### 坑：Markdown 前置資料套件在瀏覽器端壞掉

- **症狀**：直接開文章頁正常，但從站內點連結換到另一篇時白畫面，console 報 `Buffer is not defined`。
- **原因**：`gray-matter` 內部用 Node 的 `Buffer`。prerender 時跑在 Node 沒事，站內換頁時同一段程式跑在瀏覽器就壞。
- **修法與驗證**：front matter 自己用 regex 切出來、交給 `js-yaml` 解析。文章用 Vite 的 `import.meta.glob('/content/guides/*.md', { query: '?raw', import: 'default', eager: true })` 在 build 時讀進來。驗證方式是 headless 瀏覽器從清單頁點進文章、再點文內連結換篇，console 無錯。

## 2. SEO 配備清單

### 每頁 `<head>` 要有什麼

| 欄位 | 範例 | 說明 |
|---|---|---|
| `title` | `CMMC｜Guidant AI`（英文 `CMMC \| Guidant AI`） | 60 字內、每頁不同 |
| `meta description` | 該頁副標 | 150 字內 |
| `link rel=canonical` | `https://guidant-ai.stg.jedicotech.com/solutions/cmmc/` | 告訴搜尋引擎「這頁的正式網址」，含網域、**帶結尾斜線** |
| `link rel=alternate hreflang` | `zh-TW`／`en`／`x-default` 三筆 | 中英互指；x-default 指中文 |
| `og:title`／`og:description`／`og:url`／`og:image`／`og:locale` | og 圖要絕對網址＋`og:image:width` 1200、`og:image:height` 630 | 社群分享卡與部分 AI 引用會抓 |

做法：寫一支共用函式（本站 `src/i18n/index.ts` 的 `seoTags(pathname)`），每支路由的 `head()` 只傳自己的實際路徑進去，網域從 `VITE_SITE_URL` 組。**不要放在最外層 `__root` 的 head**：TanStack 站內換頁不一定重跑最外層的 head，canonical 會停在上一頁。

### 站根靜態檔（prebuild 腳本產，不手寫、不入版控）

| 檔 | 是什麼 | 本站腳本 |
|---|---|---|
| `robots.txt` | 給爬蟲的進出規則。`User-agent: *` 已放行全部，另外明列 AI 爬蟲是「明確表態」（部分爬蟲先找針對自己的規則） | `scripts/gen-sitemap.mjs` |
| `sitemap.xml` | 全站網址清單，中英每筆帶三組 hreflang | 同上，掃 `src/routes` 與 `content/guides` 自動產 |
| `llms.txt` | 給 AI 助理的網站導覽：一段公司介紹＋每頁「標題、網址、一句說明」（規範見 llmstxt.org） | `scripts/gen-llms.mjs` |
| `llms-full.txt` | 全站文案＋指南原文攤平成純文字，AI 一次抓完 | `scripts/gen-llms-full.mjs` |

robots.txt 明列的 11 個 AI 爬蟲：`GPTBot`、`OAI-SearchBot`、`ChatGPT-User`、`ClaudeBot`、`Claude-User`、`Claude-SearchBot`、`PerplexityBot`、`Google-Extended`、`Amazonbot`、`CCBot`、`meta-externalagent`（`Claude-Web` 是已棄用舊名，不加）。

`package.json` 掛法：`"prebuild": "node scripts/gen-sitemap.mjs && node scripts/gen-llms.mjs && node scripts/gen-llms-full.mjs"`。頁清單由腳本掃路由檔產生，新加頁不用改腳本；某頁在 i18n 找不到標題說明時讓 build 直接中止，避免被靜默漏列。占位頁（隱私、條款「內容準備中」）從 llms 排除、sitemap 保留。

### JSON-LD 結構化資料

JSON-LD 是放在 `<script type="application/ld+json">` 的一段 JSON，用 schema.org 的詞彙告訴搜尋引擎「這頁是什麼」。B2B 軟體官網用這五型：

| 型別 | 掛哪 | 關鍵欄位 |
|---|---|---|
| `Organization` | 全站（root head） | name、alternateName（中英互指）、url、logo、PostalAddress、telephone、email、sameAs；`@id` 固定 `<網域>/#organization`，其他型別用 `@id` 指回 |
| `BreadcrumbList` | 每頁 | 最後一層等於該頁 canonical；**中間層必須是打得開的網址**，沒有自己頁面的分組（如「解決方案」「資源」）不列 |
| `SoftwareApplication` | 產品總覽、功能細頁 | applicationCategory `BusinessApplication`；報價制就不填 offers |
| `Article` | 指南文章 | headline、datePublished、dateModified、author、inLanguage |
| `FAQPage` | 有問答段的頁 | 每題一組 Question／Answer，答案第一句就是答案 |

公司資訊集中一支檔（本站 `src/lib/site.ts` 的 `companyFor(lang)`），英文頁用英文公司名與地址。

### 坑：hreflang 頁內與 sitemap 寫法不一致

- **症狀**：Search Console 報 hreflang 衝突，或兩語版互相搶排名。
- **原因**：頁內寫 `zh-Hant`、sitemap 寫 `zh-TW`，Google 當成兩個不同語系。
- **修法與驗證**：兩邊用同一個值（本站 `zh-TW`）。JSON-LD 的 `inLanguage` 是另一件事，用 `zh-Hant` 沒問題。

```bash
curl -s $B/product/ | /usr/bin/grep -ao 'hrefLang="[^"]*"' | sort -u
curl -s $B/sitemap.xml | /usr/bin/grep -ao 'hreflang="[^"]*"' | sort -u     # 兩邊同一組值
```

### 坑：網址沒有結尾斜線，每筆都被 308 轉一次

- **症狀**：sitemap 裡的網址打下去先 308 才 200；canonical 指向一個會轉址的網址，Google 判成訊號衝突。
- **原因**：靜態檔是 `/product/index.html`，Pages 對 `/product` 會 308 到 `/product/`。產網址的程式（sitemap、canonical、hreflang、og:url、llms、站內連結）若沒帶斜線，就和伺服器不一致。
- **修法與驗證**：全部一律帶結尾 `/`（共用 `withSlash()`），TanStack Router 設 `trailingSlash: "always"` 讓站內 `Link` 自動補。

```bash
curl -s $B/sitemap.xml | /usr/bin/grep -o '<loc>[^<]*' | sed 's/<loc>//' \
  | while read u; do c=$(curl -s -o /dev/null -w '%{http_code}' "$u"); [ "$c" = 200 ] || echo "$c $u"; done   # 無輸出才對
```

### 坑：og 圖用 webp，LinkedIn 不顯示

- **症狀**：貼到 LinkedIn、部分 LINE 版本沒有預覽圖。
- **原因**：這些平台不支援 webp 當分享圖。
- **修法與驗證**：og 圖出 PNG（1200×630、每張 < 300KB），`og:image` 指 PNG、絕對網址；webp 可留著。產圖腳本（本站 `scripts/make-og.mjs`）用 headless Chromium 截一張 HTML。

```bash
curl -sI $B/og/home.png | /usr/bin/grep -i 'content-type'       # image/png
curl -s $B/ | /usr/bin/grep -ao 'property="og:image" content="[^"]*"'   # https://… .png
```

### 坑：產 og 圖時中文悄悄變成別的字型

- **症狀**：og 圖的中文字形怪、粗細不對，但腳本沒報錯。
- **原因**：Chromium 找不到指定字型時會靜默改用系統字。
- **修法與驗證**：腳本在截圖前 `await document.fonts.ready` 並檢查 `document.fonts.check('700 48px "Noto Sans TC"')`，沒載到就丟錯。產完逐張開圖目視。

### 坑：JSON-LD 裡的文案把 `<script>` 提早關掉

- **症狀**：某頁 JSON-LD 解析失敗，或頁面後半段被當成文字。
- **原因**：文案含 `</script>` 字樣，原樣塞進 `<script>` 會提早結束標籤（也是注入面）。
- **修法與驗證**：輸出前 `JSON.stringify(data).replace(/</g, "\\u003c")`（本站 `src/lib/jsonld.ts` 的 `jsonLdScriptHtml`）。把整頁原始碼貼到 https://validator.schema.org/ 要 0 錯。

### 坑：hover 才出現的選單，grep prerender HTML 驗不到

- **症狀**：驗收「導覽下拉有六個連結」用 grep 靜態 HTML 得到 0，以為壞了。
- **原因**：下拉選單與手機選單是點開或 hover 才渲染，prerender 的 HTML 裡本來就沒有。
- **修法與驗證**：這類 UI 用 headless 瀏覽器實際 hover／點開後抓連結數，不要 grep 靜態檔。（反過來說：只在 hover 後才出現的連結，爬蟲看不到——重要頁面要另外從頁尾或內文連到。）

### 坑：grep 壓成一行的 HTML 數不出關鍵字

- **症狀**：`grep -c CMMC index.html` 得 1 或被判成 binary。
- **原因**：prerender 輸出整頁是一行；`grep -c` 數的是「行」，中文還可能讓 grep 判成 binary。
- **修法與驗證**：先去標籤再數出現次數：`sed 's/<[^>]*>//g' f.html | /usr/bin/grep -ao 'CMMC' | wc -l`。

## 3. 網域、環境變數、noindex

### 一個變數驅動全部網址

`VITE_SITE_URL`（例：`https://guidant-ai.stg.jedicotech.com`）決定 canonical、hreflang、og:url、og:image、sitemap、robots 的 Sitemap 行、llms、JSON-LD 的 url 與 @id。**換網域只改這一個值、重新 build**。

- 本機寫在 `.env.local`（不入版控）；Cloudflare Pages 寫在專案環境變數。
- 沒設就讓 build 直接失敗（`throw new Error("缺 VITE_SITE_URL")`），否則會默默產出 `undefined/product`。
- `VITE_` 開頭的變數是 **build 時寫死進 HTML**，改了後台的值要重新部署才生效。

### 測試期全站 noindex

上線前不想被收錄，用 `public/_headers`（Pages 讀這支檔加 HTTP 標頭）：

```
/*
  X-Robots-Tag: noindex
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()

/og/*
  Cache-Control: public, max-age=86400
```

- `noindex` 只擋「收進搜尋結果」，**不擋爬蟲讀內文**——爬蟲驗收、結構化資料驗證、社群預覽卡都照樣能做。
- 要開放收錄那天，刪掉 `X-Robots-Tag: noindex` 那一行（其餘安全標頭留著），push 部署。
- 感謝頁、404 頁的頁內 `<meta name="robots" content="noindex">` 是刻意的，永遠保留。

```bash
curl -sI $B/ | /usr/bin/grep -i x-robots-tag      # 測試期有 noindex；開放後應無輸出
```

### 只對某個網域 noindex

同一個 Pages 專案綁多個網址（例如 stg 網址＋正式網址＋`*.pages.dev`），內容一模一樣，要避免被當重複內容。Cloudflare 官方文件寫明 `_headers` 支援**絕對網址規則**，可以依網域分開：

```
https://:project.pages.dev/*
  X-Robots-Tag: noindex

https://:version.:project.pages.dev/*
  X-Robots-Tag: noindex

https://guidant-ai.stg.jedicotech.com/*
  X-Robots-Tag: noindex
```

本站尚未實測這個寫法；用之前先在 preview 部署跑下面兩行確認，兩個網址結果不同才算成功。若驗不過，退而用兩個 Pages 專案（同 repo、不同 production branch 或不同 `_headers` 產生方式）。另外 canonical 一律指正式網址，本身就會把權重集中過去。

```bash
curl -sI https://guidant-ai-website.pages.dev/ | /usr/bin/grep -i x-robots-tag     # noindex
curl -sI https://<正式網域>/ | /usr/bin/grep -i x-robots-tag                        # 無輸出
```

### 網址裡有 `stg` 會不會影響排名

不會。Google 不看網址裡的字樣判斷是不是測試站，只看 noindex、內容與連結。用 stg 網址先開放收錄「試水溫」是可行的，代價在換網址那天（下一段）。

拿 noindex 的時機：**會改 canonical 的修正（例如第 2 節的結尾斜線）要先上線，再拿 noindex**。反過來的話，Google 會先收錄舊的（會被轉址的）網址，之後還要再轉址一輪才收斂。canonical 都穩了，測試網址（含 `stg` 字樣）直接開放收錄沒問題。

### 之後換正式網址要付的代價

收錄與排名綁在**網址**上，不綁帳號也不綁網站內容。換網址時：

1. 舊網址全部 **301** 轉到新網址的對應頁（不是都轉首頁）。Cloudflare 可用 zone 的 Redirect Rules（「規則 → 重新導向規則」）整個 hostname 轉，保留路徑。
2. Search Console 在**舊資源**用「網址變更」工具通報新網址（需兩邊都已驗證）。
3. Google 搬移權重要幾週到幾個月；AI 搜尋的引用更慢。期間兩邊都會短暫出現。

## 4. Cloudflare Pages 設定逐步

Cloudflare Pages 是 Cloudflare 的靜態網站託管：連 git repo，每次 push 自動 build 並部署到全球 CDN，免費額度對官網足夠。不需要 Workers（Workers 是每次請求即時跑程式，官網用不到）。

### 建專案

1. Cloudflare 後台 → **Workers & Pages** → **建立** → **Pages** 分頁 → **連接到 Git**（Connect to Git）。
2. 授權 GitLab，選 repo（本站 `auditmanager/guidant-ai-website`）。
3. 設定：

| 欄位 | 本站值 | 說明 |
|---|---|---|
| 專案名稱 | `guidant-ai-website` | 決定備用網址 `guidant-ai-website.pages.dev` |
| Production branch | `feature/FR-139` | push 到這支就部署正式；其他 branch 產 preview 網址 |
| Framework preset | None | 不用選 preset，自己填下面兩欄 |
| Build command | `npm run build` | prebuild 會自動先跑 |
| Build output directory | `.output/public` | 不是 `dist`、不是 `out` |
| Root directory | 留空 | |

4. **環境變數**（設定 → 變數和密碼），**Production 與 Preview 兩邊都要填**，只填 Production 的話 preview 部署會因缺 `VITE_SITE_URL` build 失敗：

| 變數 | 本站值 | 必填 |
|---|---|---|
| `VITE_SITE_URL` | `https://guidant-ai.stg.jedicotech.com` | 必填 |
| `NODE_VERSION` | `22` | 必填（build 機預設 Node 版本可能太舊） |
| `VITE_CF_BEACON_TOKEN` | Web Analytics 的 token（第 7 節） | 要分析才填 |
| `VITE_FORM_KEY` | 表單服務 key（例 Web3Forms） | 要表單寄信才填；留空時表單走 mailto 退路 |

5. 儲存並部署。第一次 build 約 2～4 分鐘。

### 綁自訂網域

專案 → **自訂網域**（Custom domains）→ 設定自訂網域 → 輸入 `guidant-ai.stg.jedicotech.com`。

- 網域的 DNS 在**同一個 Cloudflare 帳號**：CNAME 自動建，幾分鐘生效、自動發 SSL 憑證。
- DNS 不在 Cloudflare：後台會顯示要加的 CNAME（指到 `<專案名>.pages.dev`），交給管 DNS 的人加。

### 日常

- **push 即部署**：push 到 production branch 自動 build。這代表「push」等於「上線」，要由有權決定上線的人按。
- **Deploy Hook**：設定 → 組建 → 部署掛鉤，產生一個 URL，POST 它就重新 build。接 CMS（例如 Payload）時，存檔打這個 URL 讓內容 1～2 分鐘後生效。
- **回滾**：部署清單裡任一筆舊部署 → 「回復到此部署」。

```bash
curl -s -o /dev/null -w '%{http_code}\n' https://guidant-ai-website.pages.dev/     # 200
curl -s -o /dev/null -w '%{http_code}\n' $B/                                      # 200
curl -s $B/ | /usr/bin/grep -ao 'rel="canonical" href="[^"]*"'                     # 從 pages.dev 打也該指正式網址
```

## 5. Cloudflare zone 層會擋你的預設（最重要的一節）

**zone** 是 Cloudflare 對「一個網域」的管理單位（例如 `jedicotech.com`），底下所有子網域共用 zone 的安全設定。下面三個預設都在 zone 層，**Pages 專案裡看不到、`_headers` 也改不動**。網站掛到新的網域（新 zone）就要再關一次；同 zone 加子網域則不用。

### 坑 5-1：AI 爬蟲被 403

- **症狀**：Googlebot、Bingbot 正常 200，但用 ClaudeBot、GPTBot、Amazonbot 的真實 UA 抓會拿到 403，內容是一行純文字（含 blocked 字樣），不是你的網頁。在後台 **AI Crawl Control → 網路安全**（AI Crawl Control → Security）的爬蟲清單裡，每個爬蟲一列、藍色開關代表「封鎖中」；直接去關開關關不掉，會提示 "being blocked by an existing AI bot policy → Configure AI bot policies"。
- **原因**：Cloudflare 新帳號在 AI 爬蟲政策對「訓練用途」的 AI 爬蟲預設**不允許**。ClaudeBot、GPTBot、Amazonbot、CCBot 等被歸在訓練類；清單上的開關是政策的結果，不是獨立設定。
- **修法**：左欄 **AI Crawl Control** → **網路安全**（Security）→ 按提示的 **Configure AI bot policies**（**設定 AI 機器人政策**），對話框裡有三格：**搜尋**（Search）、**代理**（Agents）、**訓練**（Training）。把**訓練**改為**允許（不封鎖）**（Allow）並儲存；搜尋、代理兩格一併確認是允許。回到清單，開關會自動變灰（不再封鎖）。舊版介面路徑是 Security → Bots →「Block AI bots」，關掉。
- **驗證**：用第 8 節腳本跑 13 個 UA，全部 200 且關鍵字次數 > 0。

### 坑 5-2：robots.txt 被換成 Cloudflare 的版本

- **症狀**：`curl $B/robots.txt` 看到的不是你寫的，開頭多了 `Content-Signal: search=yes, ai-train=no`，還 `Disallow` 了 Amazonbot 等。
- **原因**：AI Crawl Control 的 **Bot Preference Sync**（又稱 Content Signals／管理 robots.txt）會把 Cloudflare 產的規則蓋在你的 robots.txt 前面。
- **修法**：在坑 5-1 同一個「設定 AI 機器人政策」（Configure AI bot policies）對話框，**最下面一格**是 **Bot Preference Sync**（機器人偏好同步），關掉並儲存。網域總覽（Overview）頁右側欄的「管理 AI 機器人存取 → 啟用機器人偏好同步」（Manage AI bot access → Enable bot preference sync）是同一個開關的另一個入口，看到它是「啟用」按鈕代表目前沒開。
- **驗證**：

```bash
curl -s $B/robots.txt | /usr/bin/grep -ic 'content-signal'     # 0
curl -s $B/robots.txt | /usr/bin/grep -c '^User-agent:'        # 等於自己寫的數量（本站 12）
```

### 坑 5-3：email 變成 `[email protected]`

- **症狀**：頁面原始碼裡的 email 被換成 `[email protected]` 加一個 `/cdn-cgi/l/email-protection` 連結；爬蟲與不跑 JS 的使用者讀不到信箱；React 頁面還丟 hydration 錯誤（React #418：伺服器 HTML 與瀏覽器端渲染結果對不上）。對 CRA 法定的漏洞通報頁來說，信箱讀不到是合規問題。
- **原因**：zone 的「電子郵件位址模糊化」（Email Address Obfuscation，屬 Scrape Shield）預設開啟，在 CDN 邊緣改寫 HTML。
- **修法**：後台 → 該網域 → 左欄 **網路安全**（Security）→ **設定**（Settings）→ **電子郵件位址模糊化**（Email Address Obfuscation）關閉。新版介面沒有「Scrape Shield」這個名稱，舊文件寫的 Scrape Shield 就是這一頁。只想對單一網站關，可用 Configuration Rules 針對 hostname 關。
- **驗證**：

```bash
curl -s $B/trust/security/ | /usr/bin/grep -ac 'email-protection'    # 0
curl -s $B/trust/security/ | /usr/bin/grep -ao 'security@[a-z.]*'     # 看得到明文信箱
```

### 換網域時的檢查單

新網域若在**另一個 zone**：上面三項全部重做一次。若在同一個 zone 只是新子網域：不用重做，但第 8 節腳本照樣對新網址跑一次。

## 6. 跟搜尋引擎登記

### Google Search Console 是什麼

Google 給網站主的後台：證明「這個網址是我的」之後，可以送 sitemap 請 Google 來抓、看收錄了幾頁、哪些查詢詞帶來曝光與點擊、有沒有抓取錯誤。不登記 Google 也會慢慢找到，登記是讓它快一點、並讓你看得到數據。

### 驗證網址

1. https://search.google.com/search-console → 新增資源。
2. 選 **網址前置字元**（URL prefix），填 `https://guidant-ai.stg.jedicotech.com/`（含結尾斜線）。另一種「網域」資源要加 DNS TXT 紀錄、涵蓋整個網域所有子網域，需要管 DNS 的權限；只管一個網站時用網址前置字元較單純。
3. 驗證方式選 **HTML 標記**，會給一行 `<meta name="google-site-verification" content="…">`。
4. 把它加進全站共用的 head（本站 `src/routes/__root.tsx` 的 `head().meta`：`{ name: "google-site-verification", content: "…" }`），commit、push 部署。
5. 確認線上有了再回後台按「驗證」：

```bash
curl -s $B/ | /usr/bin/grep -ao 'google-site-verification" content="[^"]*"'
```

這行 meta **要一直留著**，拿掉會失去驗證。

### 送 sitemap

驗證後左側 **Sitemap**（站點地圖）→ 填 `sitemap.xml` → 提交。當天「已找到的網址」多半是 0，幾天內會長出來。若站還在 noindex，報表會列「已排除：遭『noindex』標記排除」，這是預期結果。

### 坑：sitemap 送出後狀態顯示「無法擷取」

- **症狀**：提交當天，sitemap 狀態是「無法擷取」（Couldn't fetch），「上次讀取時間」（Last read）空白。同一份 sitemap 在 Bing 當天就顯示 Success。
- **原因**：Google 的抓取是排程的，還沒輪到；這個狀態在新送的 sitemap 上是常態，不是檔案有錯。
- **修法與驗證**：先確認 Google 真的抓得到，抓得到就不用動，隔一兩天再看：

```bash
curl -s -o /dev/null -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' -w '%{http_code} %{content_type}\n' $B/sitemap.xml   # 200 application/xml
```

要即時確認，用頁面頂端的 **網址檢查**（URL Inspection）貼 sitemap 網址，按 **測試線上網址**（Test live URL）。

### Bing

1. https://www.bing.com/webmasters 第一頁是「Welcome」兩欄。左欄 **Import from GSC**（從 Google Search Console 匯入）按 **Import**，不要填右邊的手動新增。
2. 跳 Google 登入授權（用同一個 Google 帳號），勾選要匯入的站，確認匯入；網站與 sitemap 一起帶過來，不用重做驗證。
3. 成功的樣子：左上站名下拉選單出現該站，頂端提示「up to 48 hours」（資料最多 48 小時才會出現）。
4. 左側 **Sitemaps** 會列出匯入的 sitemap，本站當天即顯示 **Success**、**48 URLs discovered**。Bing 後台資料反應慢屬正常，數字沒立刻出現不必動。
5. 左側 **AI Performance（Beta）** 是 Bing 提供的「網站在 Copilot／AI 搜尋被引用」數據，登記後約兩週再看才有東西。
6. **IndexNow** 可以拿一把 key，新頁上線時主動通知 Bing 來抓，不必等它輪到（做法見下一小節）。

**ChatGPT 的搜尋功能用 Bing 的索引**，所以 Bing 對 AI 搜尋特別重要。

### IndexNow：主動推新頁給 Bing

- **key 去哪拿**：Bing Webmaster 左側的 **IndexNow** 點進去是對外介紹站，不是後台功能；key 在該站 **Get Started** 產生。key 是一串 32 字元的十六進位字串，公開無妨（本來就要放在網站上讓人驗證）。
- **key 檔**：站根放一支 `public/<key>.txt`，內容就是 key 本身（本站 `public/a42033575e8a46da956cfc2978fd63f0.txt`）。
- **推送**：`POST https://api.indexnow.org/indexnow`，JSON 欄位 `host`、`key`、`keyLocation`（key 檔的完整網址）、`urlList`（一次最多 10,000 筆）。本站用 sitemap 的全部 `<loc>`（48 筆）一次推：

```bash
export B=https://guidant-ai.stg.jedicotech.com KEY=a42033575e8a46da956cfc2978fd63f0
urls=$(curl -s $B/sitemap.xml | /usr/bin/grep -ao '<loc>[^<]*' | sed 's/<loc>//' | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read().split()))')
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.indexnow.org/indexnow \
  -H 'Content-Type: application/json' \
  -d "{\"host\":\"${B#https://}\",\"key\":\"$KEY\",\"keyLocation\":\"$B/$KEY.txt\",\"urlList\":$urls}"     # 202
```

- **坑：先推再放 key 檔也會回 202，看不出失敗**
  - **症狀**：推送回 202，但 Bing 一直沒收這批網址。
  - **原因**：**202 只代表收件**。Bing 之後才會去抓 `keyLocation` 驗證你擁有這個網站，key 檔 404 或內容不等於 key，整批作廢，而且不會回頭通知你。
  - **修法與驗證**：**先部署 key 檔、再推送**。推送前確認：

```bash
curl -s -o /dev/null -w '%{http_code}\n' $B/$KEY.txt    # 200
curl -s $B/$KEY.txt | grep -c "^$KEY$"                  # 1，內容等於 key
```

- **看結果**：Bing Webmaster 的 IndexNow 頁會顯示 submitted URLs 與處理狀態，反應慢屬正常。
- **之後**：有新頁或改版時再推；正式上線後掛進部署流程自動推。
- **Google 沒有 IndexNow**：只能在 Search Console 用 **網址檢查**（URL Inspection）→ **要求建立索引**（Request indexing）逐頁送。

### 帳號的常見疑問

- 一個資源可加多位擁有者／使用者（設定 → 使用者和權限），建議用公司帳號當擁有者，個人帳號加為使用者。
- 換管理帳號**不影響**收錄與排名；排名綁的是網址，不是帳號。
- 換網址等於新資源，要重新驗證、重新送 sitemap（見第 3 節換網址代價）。

### 結構化資料的 Google 版檢查

https://search.google.com/test/rich-results 要登入、沒有 API，只能手動貼：首頁、一篇指南、一個解決方案頁各一次。它和 schema.org validator 規則不完全相同，Google 對 FAQPage 的顯示資格另有限制（不一定會顯示成摺疊問答），以「沒有錯誤」為準。

## 7. 分析工具

### Cloudflare Web Analytics（預設首選）

- 不用 cookie，所以**不需要 cookie 同意橫幅**；免費；看得到瀏覽數、來源網站（referrer）、國家、裝置、Core Web Vitals。
- 開法：入口在**帳號層**左欄 **Observability** → **Web Analytics**（不在網域底下，也不在 Analytics & Logs）→ **新增網站**（Add a site）→ 輸入 Pages 的自訂網域 → 按**完成**（Done）→ 複製 snippet 裡 `data-cf-beacon` 的 `token`。這個 token 是公開的，可以出現在 HTML；填進 Pages 環境變數時類型選「文字」（Text）即可，不必用「密碼」。
- 填 Pages 自訂網域時會跳出「此主機名稱不屬於 Cloudflare 帳戶中的任何網站，必須安裝 JS 程式碼片段」（This hostname is not part of any site in your Cloudflare account; JS snippet required）。**這是正常的**：Pages 自訂網域不算 zone 的 proxy 主機，不能「自動設定」，只能走 snippet。列表上主網域（本站是 `jedicotech.com`）那筆「自動設定」是另一個站的，不要混用它的 token。
- 掛法：token 放 Pages 環境變數 `VITE_CF_BEACON_TOKEN`，在 root head 只在「正式 build 且有 token」時載入 beacon：

```ts
const token = import.meta.env["VITE_CF_BEACON_TOKEN"] as string | undefined;
const cfBeacon = import.meta.env.PROD && token
  ? [{ src: "https://static.cloudflareinsights.com/beacon.min.js", defer: true,
       "data-cf-beacon": JSON.stringify({ token, spa: true }) }]
  : [];
```

`spa: true` 讓它攔截站內換頁，每次換頁算一次瀏覽。填完 token 要**重新部署**（VITE_ 變數是 build 時寫死的）。

```bash
curl -s $B/ | /usr/bin/grep -ao 'static.cloudflareinsights.com/beacon[^"]*'    # 有輸出才算掛上
```

### 轉換怎麼算：感謝頁

Web Analytics 不支援自訂事件。做法是表單送出成功後**導到真的感謝頁**（`/demo/thanks/`、`/contact/thanks/`），後台看這兩頁的瀏覽數就是轉換數。感謝頁加頁內 noindex、不進 sitemap、在 prerender `pages` 明列。

### 坑：用 pushState 假造虛擬路徑記轉換

- **症狀**：表單送出後 `history.pushState` 到 `/demo/thanks` 想讓分析記一筆，結果畫面變 404。
- **原因**：TanStack Router 會攔截 pushState 當成真的換頁，沒有對應路由就渲染 404。
- **修法與驗證**：做成真的路由頁。重新整理感謝頁也要正常顯示。

### GA4 什麼時候才需要

要做廣告投放（Google Ads 轉換追蹤、再行銷名單）、要細到按鈕點擊的自訂事件、要漏斗分析時才上 GA4。代價是要 cookie 同意橫幅（歐盟訪客）、設定複雜。純官網曝光與詢問量，Web Analytics＋感謝頁就夠。

### 誰來爬我們網站：各後台的分工

| 後台 | 位置 | 看的是什麼 | 視角 |
|---|---|---|---|
| **AI Crawl Control → 網路安全**（Security）清單 | zone 層左欄 | 每家 AI 爬蟲來了幾次、被放行還是封鎖 | 爬蟲 |
| **Web Analytics** 的 Referrers | 帳號層 Observability | 從 ChatGPT、Perplexity 等 AI 平台**點連結進站**的真人 | 人 |
| **Search Console**（以及 Bing 的 Sitemaps／AI Performance） | search.google.com／bing.com/webmasters | 搜尋引擎收錄了幾頁、哪些查詢詞帶來曝光 | Google／Bing |
| **Bing Webmaster 的 IndexNow 頁** | bing.com/webmasters 左側 | 我們主動推送的網址（submitted URLs）與處理狀態 | 我們推給 Bing |

各後台互補不重疊：爬蟲來了不代表被引用，被引用不代表有人點，有人點才會出現在 Referrers。

### 「AI SEO 成效」怎麼看

沒有任何工具能直接告訴你「ChatGPT 推薦了你幾次」。只能拼三個間接訊號：

1. **AI 平台帶來的流量**：Web Analytics 的 Referrers 看 `chatgpt.com`、`perplexity.ai`、`claude.ai`、`copilot.microsoft.com`、`gemini.google.com`。
2. **Bing 收錄數**：Bing Webmaster 的頁面收錄（ChatGPT 搜尋靠它）。
3. **每月手動問 AI**：固定一組題目（例：「CMMC 合規管理工具推薦」「台灣 ISO 27001 稽核軟體」），在 ChatGPT、Perplexity、Claude、Gemini 各問一次，記錄有沒有提到、引用哪一頁。

對外貼的連結一律帶 `utm_source`／`utm_medium`，才分得出是自己投放還是自然引用。

## 8. 怎麼驗證「爬得到」

### 原則

- **打線上網址、抓原始 HTML**，不是用瀏覽器看（瀏覽器會跑 JS，爬蟲不會）。
- **用各家完整的 UA 字串**。Cloudflare 的 AI 爬蟲規則認的是完整 UA 樣式；`curl -A GPTBot` 這種裸名字它認不出來，就當一般訪客放行 → 你看到 200，真的 GPTBot 卻被 403。這就是「假綠」。
- 數關鍵字前先去掉 `<script>` 與標籤，確認是**內文**裡有，不是 JSON-LD 裡有。
- 從你的電腦用真 UA 測的是「UA 規則」；Cloudflare 另有以 IP 驗證真假爬蟲的機制，本機模擬不到。上線一週後到 AI Crawl Control 的流量頁看各爬蟲實際請求與狀態碼，作為最終確認。

### 13 個爬蟲 UA 全文（2026-10，以各家官方頁為準，改版時更新）

| 名稱 | 用途 | UA 字串 |
|---|---|---|
| GPTBot | OpenAI 訓練 | `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)` |
| OAI-SearchBot | ChatGPT 搜尋索引 | `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)` |
| ChatGPT-User | 使用者叫 ChatGPT 開網頁 | `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot` |
| ClaudeBot | Anthropic 訓練 | `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)` |
| Claude-User | 使用者叫 Claude 開網頁 | `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-User/1.0; +Claude-User@anthropic.com)` |
| Claude-SearchBot | Claude 搜尋索引 | `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; +Claude-SearchBot@anthropic.com)` |
| PerplexityBot | Perplexity 索引 | `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)` |
| Amazonbot | Amazon（Alexa 等） | `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Amazonbot/0.1; +https://developer.amazon.com/support/amazonbot) Chrome/119.0.6045.214 Safari/537.36` |
| CCBot | Common Crawl（多家模型的訓練來源） | `CCBot/2.0 (https://commoncrawl.org/faq/)` |
| meta-externalagent | Meta AI | `meta-externalagent/1.1 (+https://developers.facebook.com/docs/sharing/webmasters/crawler)` |
| Googlebot | Google 搜尋 | `Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)` |
| Bingbot | Bing 搜尋（ChatGPT 搜尋也靠它） | `Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)` |
| Applebot | Apple（Siri、Spotlight） | `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 (Applebot/0.1; +http://www.apple.com/go/applebot)` |

`Google-Extended` 不是獨立爬蟲（它是 robots.txt 裡控制 Gemini 訓練的代號，實際抓取用 Googlebot），所以不在測試清單。官方 UA 頁：OpenAI https://platform.openai.com/docs/bots 、Anthropic https://support.anthropic.com/en/articles/8896518 、Perplexity https://docs.perplexity.ai/guides/bots 。

### 爬蟲檢查腳本

存成 `check-crawlers.sh`，用法 `bash check-crawlers.sh <站台網址> <路徑> <關鍵字>`：

```bash
#!/usr/bin/env bash
# 例：bash check-crawlers.sh https://guidant-ai.stg.jedicotech.com /solutions/cmmc/ CMMC
B=${1:?站台網址}; P=${2:-/}; KW=${3:-Guidant}
GREP=/usr/bin/grep
while IFS='|' read -r name ua; do
  [ -z "$name" ] && continue
  body=$(curl -s -A "$ua" -w '\n%{http_code}' "$B$P")
  code=${body##*$'\n'}; html=${body%$'\n'*}
  n=$(printf '%s' "$html" | sed 's/<script[^>]*>[^<]*<\/script>//g; s/<[^>]*>//g' | $GREP -ao "$KW" | wc -l | tr -d ' ')
  blocked=$(printf '%s' "$html" | head -c 300 | $GREP -aic 'blocked\|access denied')
  printf '%-20s %s  %s=%s %s\n' "$name" "$code" "$KW" "$n" "$([ "$blocked" != 0 ] && echo '← 被擋')"
done <<'UAS'
GPTBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)
OAI-SearchBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)
ChatGPT-User|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot
ClaudeBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)
Claude-User|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-User/1.0; +Claude-User@anthropic.com)
Claude-SearchBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; +Claude-SearchBot@anthropic.com)
PerplexityBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)
Amazonbot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Amazonbot/0.1; +https://developer.amazon.com/support/amazonbot) Chrome/119.0.6045.214 Safari/537.36
CCBot|CCBot/2.0 (https://commoncrawl.org/faq/)
meta-externalagent|meta-externalagent/1.1 (+https://developers.facebook.com/docs/sharing/webmasters/crawler)
Googlebot|Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
Bingbot|Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)
Applebot|Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 (Applebot/0.1; +http://www.apple.com/go/applebot)
UAS
```

合格標準：13 行全部 `200`、關鍵字 > 0、沒有「← 被擋」。中英各挑一頁內容頁跑（例 `/solutions/cmmc/`、`/en/solutions/cmmc/`）。

### 整站健檢腳本

存成 `site-check.sh`，用法 `bash site-check.sh <站台網址>`：

```bash
#!/usr/bin/env bash
B=${1:?站台網址，不帶結尾斜線}
G="/usr/bin/grep -a"
echo "== 標頭"
curl -sI "$B/" | $G -i 'x-robots-tag\|x-frame-options\|x-content-type'
echo "== robots.txt（數 User-agent；Content-Signal 應為 0）"
curl -s "$B/robots.txt" | $G -c '^User-agent:'
curl -s "$B/robots.txt" | $G -ic 'content-signal'
curl -s "$B/robots.txt" | $G '^Sitemap:'
echo "== sitemap 每筆狀態碼（只印非 200）"
urls=$(curl -s "$B/sitemap.xml" | $G -o '<loc>[^<]*' | sed 's/<loc>//')
echo "共 $(echo "$urls" | wc -l | tr -d ' ') 筆"
for u in $urls; do c=$(curl -s -o /dev/null -w '%{http_code}' "$u"); [ "$c" = 200 ] || echo "$c $u"; done
echo "== 首頁 head"
home=$(curl -s "$B/")
echo "$home" | $G -o 'rel="canonical" href="[^"]*"'
echo "$home" | $G -o 'hrefLang="[^"]*" href="[^"]*"'
echo "$home" | $G -o 'property="og:image" content="[^"]*"'
echo "JSON-LD 段數：$(echo "$home" | $G -o 'application/ld+json' | wc -l | tr -d ' ')"
echo "== 404"
curl -s -o /dev/null -w '/nonexistent-page/ → %{http_code}（應 404）\n' "$B/nonexistent-page/"
echo "== email 沒被 Cloudflare 改寫（應 0）"
curl -s "$B/" "$B/contact/" | $G -c 'email-protection'
echo "== llms"
curl -s -o /dev/null -w 'llms.txt %{http_code}\n' "$B/llms.txt"
curl -s -o /dev/null -w 'llms-full.txt %{http_code}\n' "$B/llms-full.txt"
```

### 其他驗證

| 驗什麼 | 怎麼驗 |
|---|---|
| 結構化資料 | https://validator.schema.org/ 貼網址或整頁原始碼，0 錯 0 警告；Google 版見第 6 節 |
| 社群預覽卡 | 用平台爬蟲 UA 抓 og 標籤（下方指令）；要看實際渲染再登入 Facebook Sharing Debugger、LinkedIn Post Inspector |
| 效能與無障礙 | Lighthouse（`npx lighthouse $B/ --only-categories=performance,seo,accessibility --form-factor=mobile --chrome-flags=--headless`），目標手機 Performance ≥ 85；noindex 期間 SEO 分數只扣 `is-crawlable` 一項是正常 |
| 手機版不爆版 | headless 390 寬開每頁，`document.documentElement.scrollWidth - innerWidth` 要 0 |

```bash
for ua in 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)' 'LinkedInBot/1.0 (compatible; Mozilla/5.0; +http://www.linkedin.com)' 'Twitterbot/1.0' 'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)'; do
  curl -s -A "$ua" $B/ | /usr/bin/grep -ao 'property="og:\(title\|image\)" content="[^"]*"' | head -2; echo; done
```

### 坑：首頁手機 LCP 太慢

- **症狀**：Lighthouse 手機 Performance 70 幾，`lcp-lazy-loaded` 不及格。
- **原因**：首屏主圖有亮暗兩張，只有亮色那張 `loading="eager"`；手機量測抓到的最大元素是 `loading="lazy"` 的那張。
- **修法與驗證**：首屏主圖亮暗都 `loading="eager"`＋`fetchpriority="high"`。同時檢查標題層級不跳級（h1 後直接 h3 會扣無障礙分）。重跑 Lighthouse 手機版確認。

## 9. 多人／多 session 共用工作樹的紀律

多個人或多個 Claude session 同時在同一個 repo 資料夾改不同頁，最容易出事的是共用檔：`src/i18n/zh-TW.json`、`en.json`、`SiteHeader.tsx`、`src/i18n/index.ts`、`vite.config.ts`。

### 坑 9-1：整檔 `git add` 帶走別人的改動

- **症狀**：你的 commit 裡多了一段不是你寫的 i18n key；對方 commit 時發現自己的改動「已經被 commit 了」，而且在別人的 commit message 裡完全沒提。
- **原因**：共用工作樹裡，別人還沒 commit 的 hunk 就在同一支檔裡，`git add <檔名>` 會整檔帶走。
- **修法與驗證**：

```bash
git status --short                 # 先看有沒有不是自己的改動
git diff src/i18n/zh-TW.json       # add 共用檔前一定看
git add -p src/i18n/zh-TW.json     # 有別人的 hunk 就逐段挑，只 y 自己的
git diff --cached --stat           # commit 前再看一次 staged 範圍
```

禁止 `git add -A`、`git commit -am`。

### 坑 9-2：別人改一半，整站 build 失敗

- **症狀**：你的頁面沒問題，但 `npm run build` 在別人的元件報 `undefined.map`。
- **原因**：另一個 session 改到一半（例如 json 改了 key 名，元件還沒跟上）。
- **修法與驗證**：不要替別人修。用乾淨的 worktree 只帶自己的改動驗：`git worktree add ../site-verify HEAD`，把自己的檔複製過去 `npm install && npm run build`，驗完 `git worktree remove ../site-verify`。

### 坑 9-3：一個 session 的修改把另一個的 head 函式蓋掉

- **症狀**：加 JSON-LD 的人改了 `headFrom()`，把加 canonical 的人寫的 `seoTags()` 呼叫拿掉，全站 canonical 消失。
- **原因**：兩邊同時改同一個共用函式，後 commit 的人用自己的舊版本覆蓋。
- **修法與驗證**：改共用函式前 `git log -3 -- <檔>` 看最近誰動過、先 pull 到最新；commit 前 build 一次並確認別人的成果還在：

```bash
for f in $(find .output/public -name index.html); do /usr/bin/grep -aq 'rel="canonical"' "$f" || echo "MISSING $f"; done
```

### 坑 9-4：macOS 的 `sed -E -i` 產生怪檔

- **症狀**：跑了 `sed -i -E 's/…//' *.md`，內容沒變，資料夾多出一堆 `*.md-E`。
- **原因**：macOS 內建的是 BSD sed，`-i` 後面一個參數是備份檔副檔名，`-E` 被吃掉當副檔名。
- **修法與驗證**：用 `sed -i '' -E 's/…//' file`，或改用 python／perl（`perl -pi -e 's/…//' file`）。改完 `git status` 確認沒有多出 `-E` 結尾的檔。

### 坑 9-5：這台機器的 grep 把 HTML 當 binary

- **症狀**：`grep -q canonical index.html` 全部失敗，驗收迴圈把每頁都印成 MISSING。
- **原因**：shell 把 `grep` 包成函式／別名，加了會把含特殊字元的單行大檔判成 binary 的選項。
- **修法與驗證**：驗收一律寫 `/usr/bin/grep -a`。`type grep` 可看出是不是被包過。

### 坑 9-6：已 staged 的 rename 還原不乾淨

- **症狀**：想丟掉一批實驗改動，`git checkout -- .`＋`git clean` 之後還有殘留。
- **原因**：已經 `git add` 的 rename 不受 `checkout -- .` 影響。
- **修法與驗證**：這是破壞性操作，**只在確認沒有別的 session 在這個工作樹時才做**；先 `git status` 看清楚、必要時先問人。真的要整個回到某個 commit 才用 `git reset --hard <commit>`。

## 10. 正式上線 checklist

從測試網址（或試水溫網址）換到正式網址，照順序做。打勾前每項都跑對應的驗證指令。

**A. 程式（工程，一個 commit）**

- [ ] `public/_headers` 刪 `X-Robots-Tag: noindex` 那行；若測試網址要繼續留著，改用第 3 節的絕對網址規則只對測試網址 noindex
- [ ] 感謝頁、404 的頁內 noindex 保留
- [ ] Search Console 驗證 meta 換成新資源給的那行（或並存兩行）

**B. Cloudflare 後台（決策者）**

- [ ] Pages → 自訂網域加正式網域，DNS 生效、SSL 綠燈
- [ ] Pages 環境變數 `VITE_SITE_URL` 改正式網域（Production 與 Preview 都改）
- [ ] 正式網域所在 zone：AI Crawl Control「訓練」改允許、Bot Preference Sync 關（第 5 節）
- [ ] 正式網域所在 zone：電子郵件位址模糊化關（第 5 節）
- [ ] 舊網址 301 到新網址對應路徑（zone → 規則 → 重新導向規則）
- [ ] `VITE_CF_BEACON_TOKEN` 換成正式網域的 Web Analytics 站點 token
- [ ] 以上改完，push（或後台「重試部署」）觸發重新 build

**C. 線上驗證（工程）**

```bash
export B=https://<正式網域>
curl -sI $B/ | /usr/bin/grep -ic x-robots-tag                            # 0
curl -s $B/sitemap.xml | /usr/bin/grep -c '<loc>'                         # 等於頁數 × 語言數
curl -s $B/sitemap.xml | /usr/bin/grep -c 'stg\.'                          # 0，舊網域沒殘留
curl -s $B/product/ | /usr/bin/grep -ao 'rel="canonical" href="[^"]*"'    # 新網域、結尾 /
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://<舊網域>/product/   # 301 → 新網域同路徑
bash check-crawlers.sh $B /solutions/cmmc/ CMMC                           # 13 行全 200
bash site-check.sh $B
```

**D. 搜尋引擎（有帳號的人）**

- [ ] Search Console 新增正式網址資源、驗證、送 sitemap
- [ ] 舊資源用「網址變更」工具通報新網址
- [ ] Bing Webmaster 從 Search Console 匯入新資源
- [ ] Rich Results Test 手動貼首頁、一篇指南、一個方案頁

**E. 站外訊號（行銷，正式網址定了才做）**

AI 搜尋判斷「推不推薦你」很看別人怎麼說你。公司名、地址、電話、產品名在各平台**一字不差**：

- [ ] LinkedIn 公司頁與產品頁填正式網址
- [ ] G2／Capterra 建產品檔案
- [ ] 合作夥伴、產業媒體的連結指向正式網址
- [ ] 母公司網站（本站是 billows.tech）互連

**F. 上線一週後**

- [ ] Search Console「已找到的網址」開始長、無抓取錯誤
- [ ] AI Crawl Control 流量頁看得到 GPTBot、ClaudeBot 等實際請求且是 200
- [ ] Web Analytics 有流量、看得到 referrer
- [ ] 第一次每月 AI 題目抽查（第 7 節）
