AIO / GEO - Part 4 - 實作篇:把一個網站改造成 AI 可引用

大多數人的做法:讀完方法論,開一份 Notion 待辦,然後三個月後還在第一項。 真正該做的事:照著步驟做,每一步都有可執行的驗收指令。

這一篇不談概念。 全部是可以複製貼上的東西。


一、實作目標與驗收標準

我們要把一個典型網站從 Level 0/1 推到 Level 2(Part 1 §9 的成熟度模型)。

                改造前                        改造後
──────────────────────────────────────────────────────────────
AI crawler      部分 403 / 內容不完整          8 個 bot 全 200,內容完整
無 JS 內容量     瀏覽器的 20%(CSR)           瀏覽器的 98%
結構化資料       無 / 只有基本 Article         Organization + Article +
                                             FAQPage + Breadcrumb,互相 @id 引用
chunk 邊界      靠段落換行                     H2/H3 分節 + section id + 錨點
機器可讀版本     只有 HTML                      HTML + .md + llms.txt
可驗收           靠人工檢查                     CI 自動化,PR 階段擋下退步

驗收腳本會在 §9 給出,可以直接放進 CI。建議先跳到 §9 跑一次,拿到基線,再回來逐步修。


二、Step 1:AI Crawler 存取層

這是唯一「0/1」的一步。做不完,後面七步全部無效。

2.1 robots.txt

# https://example.com/robots.txt

# ── 一般搜尋引擎(AI Overview 的前提)──────────────
User-agent: Googlebot
Allow: /

User-agent: Bingbot
Allow: /

# ── AI 檢索 bot:一律開放 ──────────────────────────
# 這些 bot 決定你在「今天」的 AI 回答中存不存在
User-agent: OAI-SearchBot
Allow: /

User-agent: ChatGPT-User
Allow: /

User-agent: Claude-SearchBot
Allow: /

User-agent: Claude-Web
Allow: /

User-agent: PerplexityBot
Allow: /

User-agent: Perplexity-User
Allow: /

User-agent: Applebot
Allow: /

# ── AI 訓練 bot:依商業立場決定 ─────────────────────
# 開放 = 有機會進入未來模型的內在知識
# 封鎖 = 保護內容資產,但放棄長期 LLMO
User-agent: GPTBot
Allow: /
Disallow: /pricing/quote/
Disallow: /customer-portal/

User-agent: ClaudeBot
Allow: /
Disallow: /pricing/quote/
Disallow: /customer-portal/

User-agent: Google-Extended
Allow: /

User-agent: Applebot-Extended
Allow: /

# 公開資料集:多數公司選擇封鎖(無法控制下游用途)
User-agent: CCBot
Disallow: /

# ── 全域預設 ────────────────────────────────────
User-agent: *
Allow: /
Disallow: /api/
Disallow: /admin/
Disallow: /*?utm_
Disallow: /search?

Sitemap: https://example.com/sitemap.xml

2.2 CDN / WAF:真正的兇手

robots.txt 是禮貌性協議,CDN 才是實際的閘門。超過一半的「AI 看不到我」案例出在這裡

Cloudflare

1. Security → Bots → 確認「Block AI Scrapers and Crawlers」為 OFF
   (若因版權考量要開,改用下方的 WAF 規則做精細控制)

2. Security → WAF → Custom rules,新增一條 Skip 規則置頂:

   規則名稱:Allow AI retrieval bots
   運算式:
     (http.user_agent contains "OAI-SearchBot") or
     (http.user_agent contains "ChatGPT-User") or
     (http.user_agent contains "Claude-SearchBot") or
     (http.user_agent contains "Claude-Web") or
     (http.user_agent contains "PerplexityBot") or
     (http.user_agent contains "Applebot")
   動作:Skip → 勾選 All remaining custom rules、Rate limiting、
                 Bot Fight Mode、Managed rules

3. Security → Settings → Security Level 若為 High,
   對 /blog/* 等內容路徑降為 Medium

注意 rate limiting:AI crawler 的抓取速度通常遠高於一般爬蟲(Perplexity 尤其明顯)。常見的「每 IP 每分鐘 60 次」設定會直接讓它們吃到 429。針對這些 UA 放寬到 300/min 是合理的。

AWS CloudFront + WAF

 1{
 2  "Name": "AllowAIRetrievalBots",
 3  "Priority": 0,
 4  "Action": { "Allow": {} },
 5  "Statement": {
 6    "ByteMatchStatement": {
 7      "SearchString": "SearchBot",
 8      "FieldToMatch": { "SingleHeader": { "Name": "user-agent" } },
 9      "TextTransformations": [{ "Priority": 0, "Type": "NONE" }],
10      "PositionalConstraint": "CONTAINS"
11    }
12  },
13  "VisibilityConfig": {
14    "SampledRequestsEnabled": true,
15    "CloudWatchMetricsEnabled": true,
16    "MetricName": "AllowAIRetrievalBots"
17  }
18}

Nginx(自架)

 1# 定義 AI bot
 2map $http_user_agent $is_ai_bot {
 3    default                 0;
 4    "~*OAI-SearchBot"       1;
 5    "~*ChatGPT-User"        1;
 6    "~*GPTBot"              1;
 7    "~*ClaudeBot"           1;
 8    "~*Claude-SearchBot"    1;
 9    "~*Claude-Web"          1;
10    "~*PerplexityBot"       1;
11    "~*Applebot"            1;
12    "~*Googlebot"           1;
13    "~*bingbot"             1;
14}
15
16# 給 AI bot 較寬鬆的 rate limit
17limit_req_zone $binary_remote_addr zone=general:10m rate=60r/m;
18limit_req_zone $binary_remote_addr zone=aibots:10m  rate=300r/m;
19
20server {
21    location / {
22        # 不要對 AI bot 做 JS challenge / cookie 檢查
23        if ($is_ai_bot) {
24            set $skip_challenge 1;
25        }
26        limit_req zone=general burst=20 nodelay;
27        # …
28    }
29
30    # 記錄 AI bot 存取,供 Part 5 的流量分析使用
31    access_log /var/log/nginx/ai-bots.log combined if=$is_ai_bot;
32}

2.3 分區策略:不是全開就是全關

對付費內容站 / 媒體,正確做法是分層。

路徑                    檢索 bot    訓練 bot    理由
──────────────────────────────────────────────────────────
/                       ✔          ✔          首頁與品牌頁全開
/blog/*                 ✔          ✔          內容行銷,被引用是目的
/docs/*                 ✔          ✔          文件被引用等於免費支援
/pricing                ✔          ✔          交易意圖,一定要被看到
/research/*(付費)      摘要層      ✘          給前 30%,標註 paywall
/members/*              ✘          ✘          會員專屬
/api/*, /admin/*        ✘          ✘          非內容

付費內容的誠實標註方式(比偽裝安全得多):

 1<script type="application/ld+json">
 2{
 3  "@context": "https://schema.org",
 4  "@type": "Article",
 5  "headline": "2026 台灣電商產業深度報告",
 6  "isAccessibleForFree": false,
 7  "hasPart": {
 8    "@type": "WebPageElement",
 9    "isAccessibleForFree": false,
10    "cssSelector": ".paywalled-content"
11  }
12}
13</script>
14
15<article>
16  <div class="free-preview">
17    <!-- 前 30%:完整、可引用、有數據 -->
18  </div>
19  <div class="paywalled-content">
20    <!-- 剩下 70% -->
21  </div>
22</article>

2.4 驗收

1./ai-crawler-check.sh https://example.com/blog/your-best-post/
2# 期望:所有列 200,bytes 差異 < 5%

三、Step 2:SSR / 預渲染決策

                你的網站是什麼?
                        │
        ┌───────────────┴───────────────┐
        ▼                               ▼
   靜態內容為主                     大量動態 / 個人化
   (部落格、文件、行銷站)            (SaaS 儀表板、電商後台)
        │                               │
        ▼                               ▼
   ┌─────────┐              ┌───────────────────────┐
   │  SSG    │              │  這些頁本來就不該被引用  │
   │ Hugo /  │              │  但你的「公開內容區」    │
   │ Astro / │              │  必須獨立出來做 SSG/SSR │
   │ Next.js │              └───────────────────────┘
   │ (SSG)   │
   └─────────┘
        │
   已經是 CSR SPA?三個選項:
   ┌────────────────────────────────────────────────────┐
   │ A. 遷移到 Next.js/Nuxt 的 SSR/SSG    最佳,成本最高  │
   │ B. 對內容路由做建置期預渲染           折衷,推薦      │
   │    (react-snap / prerender build)                  │
   │ C. 邊緣預渲染服務(Prerender.io 等)  最快,但是      │
   │    給 bot 不同來源 → 需嚴格保持一致,有 cloaking 風險 │
   └────────────────────────────────────────────────────┘

強烈建議 A 或 B。選項 C 雖然一天就能上線,但你等於維護兩套內容,長期一定會不同步。

Next.js App Router 的最小正確設定:

 1// app/blog/[slug]/page.tsx
 2import { notFound } from 'next/navigation'
 3import { getPost, getAllSlugs } from '@/lib/posts'
 4
 5// 建置期產生所有路徑 → 純靜態 HTML,AI crawler 100% 拿得到
 6export async function generateStaticParams() {
 7  const slugs = await getAllSlugs()
 8  return slugs.map((slug) => ({ slug }))
 9}
10
11// 每 1 小時重新驗證,兼顧新鮮度與靜態化
12export const revalidate = 3600
13export const dynamic = 'force-static'
14
15export async function generateMetadata({ params }) {
16  const post = await getPost(params.slug)
17  if (!post) return {}
18  return {
19    title: post.title,
20    description: post.description,
21    alternates: {
22      canonical: `https://example.com/blog/${params.slug}`,
23      types: { 'text/markdown': `https://example.com/blog/${params.slug}.md` },
24    },
25    openGraph: {
26      type: 'article',
27      publishedTime: post.datePublished,
28      modifiedTime: post.dateModified,
29      authors: [post.author.url],
30    },
31  }
32}
33
34export default async function Page({ params }) {
35  const post = await getPost(params.slug)
36  if (!post) notFound()
37  // 內容一律在伺服器端渲染成 HTML,不靠 client component
38  return <ArticleLayout post={post} />
39}

檢查點:任何包裹主要內容的 'use client' 元件都是紅旗。互動性放在葉節點(按鈕、表單),內容放在伺服器元件。


四、Step 3:JSON-LD 自動注入

手寫 JSON-LD 一定會漏。做成模板。

4.1 Hugo 實作

建立 layouts/partials/schema.html

 1{{/* ---------- Organization(全站,只在首頁輸出完整版)---------- */}}
 2{{ if .IsHome }}
 3<script type="application/ld+json">
 4{
 5  "@context": "https://schema.org",
 6  "@type": "Organization",
 7  "@id": "{{ .Site.BaseURL }}#organization",
 8  "name": {{ .Site.Params.orgName | jsonify }},
 9  "alternateName": {{ .Site.Params.orgAltNames | jsonify }},
10  "url": "{{ .Site.BaseURL }}",
11  "logo": {
12    "@type": "ImageObject",
13    "url": "{{ .Site.BaseURL }}images/logo.png",
14    "width": 512, "height": 512
15  },
16  "description": {{ .Site.Params.orgDescription | jsonify }},
17  "foundingDate": {{ .Site.Params.foundingDate | jsonify }},
18  "sameAs": {{ .Site.Params.sameAs | jsonify }},
19  "knowsAbout": {{ .Site.Params.knowsAbout | jsonify }}
20}
21</script>
22{{ end }}
23
24{{/* ---------- Article ---------- */}}
25{{ if eq .Type "posts" }}
26{{ $author := index .Site.Data.authors (index .Params.authors 0) }}
27<script type="application/ld+json">
28{
29  "@context": "https://schema.org",
30  "@type": "BlogPosting",
31  "@id": "{{ .Permalink }}#article",
32  "headline": {{ .Title | jsonify }},
33  "description": {{ .Description | jsonify }},
34  "datePublished": {{ .Date.Format "2006-01-02T15:04:05-07:00" | jsonify }},
35  "dateModified": {{ (default .Date .Lastmod).Format "2006-01-02T15:04:05-07:00" | jsonify }},
36  "author": {
37    "@type": "Person",
38    "@id": "{{ .Site.BaseURL }}authors/{{ index .Params.authors 0 }}/#person",
39    "name": {{ $author.name | jsonify }},
40    "jobTitle": {{ $author.jobTitle | jsonify }},
41    "url": "{{ .Site.BaseURL }}authors/{{ index .Params.authors 0 }}/",
42    "sameAs": {{ $author.sameAs | jsonify }},
43    "worksFor": { "@id": "{{ .Site.BaseURL }}#organization" }
44  },
45  "publisher": { "@id": "{{ .Site.BaseURL }}#organization" },
46  "mainEntityOfPage": "{{ .Permalink }}",
47  "articleSection": {{ (index .Params.categories 0) | jsonify }},
48  "keywords": {{ delimit .Params.tags ", " | jsonify }},
49  "wordCount": {{ .WordCount }},
50  "inLanguage": "{{ .Site.LanguageCode }}"
51}
52</script>
53{{ end }}
54
55{{/* ---------- FAQPage(從 front matter 的 faq 陣列產生)---------- */}}
56{{ with .Params.faq }}
57<script type="application/ld+json">
58{
59  "@context": "https://schema.org",
60  "@type": "FAQPage",
61  "mainEntity": [
62    {{ range $i, $item := . }}{{ if $i }},{{ end }}
63    {
64      "@type": "Question",
65      "name": {{ $item.q | jsonify }},
66      "acceptedAnswer": { "@type": "Answer", "text": {{ $item.a | jsonify }} }
67    }
68    {{ end }}
69  ]
70}
71</script>
72{{ end }}
73
74{{/* ---------- BreadcrumbList ---------- */}}
75{{ if .Parent }}
76<script type="application/ld+json">
77{
78  "@context": "https://schema.org",
79  "@type": "BreadcrumbList",
80  "itemListElement": [
81    {{ range $i, $p := .Ancestors.Reverse }}
82    { "@type": "ListItem", "position": {{ add $i 1 }},
83      "name": {{ $p.Title | jsonify }}, "item": "{{ $p.Permalink }}" },
84    {{ end }}
85    { "@type": "ListItem", "position": {{ add (len .Ancestors) 1 }},
86      "name": {{ .Title | jsonify }}, "item": "{{ .Permalink }}" }
87  ]
88}
89</script>
90{{ end }}

layouts/partials/head.html 末尾加上:

1{{ partial "schema.html" . }}

搭配的 front matter:

 1---
 2title: "OMS 導入成本完整拆解:2026 台灣市場實價"
 3date: 2026-06-14T09:00:00+08:00
 4lastmod: 2026-07-22T11:30:00+08:00
 5description: "拆解 OMS 導入的五類成本,附 12 家供應商實際報價區間。"
 6authors: ["chen-yiting"]
 7faq:
 8  - q: "OMS 導入的總成本大約是多少?"
 9    a: "以 20-50 人的零售企業為例,第一年總成本落在 NT$45 萬到 NT$180 萬之間,中位數約 NT$92 萬。其中軟體授權佔 40-55%、導入服務佔 30-40%、內部人力佔 10-20%。"
10  - q: "導入需要多久?"
11    a: "標準導入為 6-10 週:需求訪談 2-3 週、系統設定與客製 3-5 週、資料移轉 1-2 週、UAT 與教育訓練 1-2 週。若涉及 ERP 雙向整合,通常再加 4-6 週。"
12---

lastmod 的紀律:Hugo 的 Lastmod 預設可以取 Git commit time(enableGitInfo = true)。這比手動維護可靠,但要小心「只改錯字也算更新」。建議用 front matter 手動控制重要頁面。

4.2 Next.js 實作

 1// components/JsonLd.tsx
 2export function JsonLd({ data }: { data: Record<string, unknown> }) {
 3  return (
 4    <script
 5      type="application/ld+json"
 6      // JSON.stringify 已足以逸出但額外處理 </script> 邊界情況
 7      dangerouslySetInnerHTML={{
 8        __html: JSON.stringify(data).replace(/</g, '\\u003c'),
 9      }}
10    />
11  )
12}
13
14// lib/schema.ts
15const SITE = 'https://example.com'
16
17export const organizationSchema = {
18  '@context': 'https://schema.org',
19  '@type': 'Organization',
20  '@id': `${SITE}/#organization`,
21  name: 'OrderFlow',
22  alternateName: ['OrderFlow 訂單流'],
23  url: SITE,
24  description:
25    'OrderFlow 是台灣的多通路電商訂單管理系統(OMS),提供跨平台訂單同步、庫存整合與出貨自動化。',
26  sameAs: [
27    'https://www.linkedin.com/company/orderflow-tw',
28    'https://github.com/orderflow',
29    'https://www.wikidata.org/wiki/Q123456789',
30  ],
31  knowsAbout: ['訂單管理系統', '多通路電商', '庫存同步', 'OMS'],
32}
33
34export function articleSchema(post: Post) {
35  return {
36    '@context': 'https://schema.org',
37    '@type': 'BlogPosting',
38    '@id': `${SITE}/blog/${post.slug}/#article`,
39    headline: post.title,
40    description: post.description,
41    datePublished: post.datePublished,
42    dateModified: post.dateModified ?? post.datePublished,
43    author: {
44      '@type': 'Person',
45      '@id': `${SITE}/authors/${post.author.slug}/#person`,
46      name: post.author.name,
47      jobTitle: post.author.jobTitle,
48      url: `${SITE}/authors/${post.author.slug}/`,
49      sameAs: post.author.sameAs,
50      worksFor: { '@id': `${SITE}/#organization` },
51    },
52    publisher: { '@id': `${SITE}/#organization` },
53    mainEntityOfPage: `${SITE}/blog/${post.slug}/`,
54    wordCount: post.wordCount,
55    inLanguage: 'zh-Hant-TW',
56  }
57}
58
59export function faqSchema(faq: { q: string; a: string }[]) {
60  return {
61    '@context': 'https://schema.org',
62    '@type': 'FAQPage',
63    mainEntity: faq.map(({ q, a }) => ({
64      '@type': 'Question',
65      name: q,
66      acceptedAnswer: { '@type': 'Answer', text: a },
67    })),
68  }
69}

五、Step 4:Chunk 邊界工程

把 Part 2 §4 的理論變成 HTML 結構。

5.1 目標 DOM 形狀

 1<article itemscope itemtype="https://schema.org/BlogPosting">
 2  <header>
 3    <h1>OMS 導入成本完整拆解:2026 台灣市場實價</h1>
 4    <p class="meta">
 5      <time datetime="2026-07-22T11:30:00+08:00">更新於 2026 年 7 月 22 日</time>
 6      ·
 7      <a rel="author" href="/authors/chen-yiting/">陳怡婷</a>,解決方案架構師
 8    </p>
 9  </header>
10
11  <!-- TL;DR:一個完美的獨立 chunk -->
12  <aside class="tldr" aria-label="重點摘要">
13    <h2>重點摘要</h2>
14    <ul>
15      <li>20-50 人零售企業導入 OMS 的第一年總成本中位數為 NT$92 萬。</li>
16      <li>成本結構:軟體授權 40-55%、導入服務 30-40%、內部人力 10-20%。</li>
17      <li>標準導入時程 6-10 週;含 ERP 雙向整合再加 4-6 週。</li>
18    </ul>
19  </aside>
20
21  <!-- 每個 section = 一個 chunk,有 id、有問句標題、可獨立成立 -->
22  <section id="cost-items" aria-labelledby="h-cost-items">
23    <h2 id="h-cost-items">OMS 導入成本包含哪五類項目?</h2>
24    <p>
25      OMS(訂單管理系統)的導入成本可拆為五類:軟體授權費、
26      導入服務費、客製開發費、資料移轉費與內部人力成本……
27    </p>
28    <table></table>
29    <p class="source">
30      資料來源:本文彙整 2026 年 1-6 月 12 家台灣 OMS 供應商的實際報價,
31      樣本為 20-50 人規模的零售企業。
32    </p>
33  </section>
34
35  <section id="timeline" aria-labelledby="h-timeline">
36    <h2 id="h-timeline">OMS 導入需要多久時間?</h2>
37    <p>OMS 導入的標準時程為 6-10 週……</p>
38  </section>
39
40  <section id="faq" aria-labelledby="h-faq">
41    <h2 id="h-faq">常見問題</h2>
42    <details open>
43      <summary>中小企業有沒有更便宜的方案?</summary>
44      <p>有。SaaS 訂閱制方案的第一年成本可壓到 NT$12-30 萬……</p>
45    </details>
46  </section>
47</article>

四個關鍵設計:

設計                        作用
──────────────────────────────────────────────────────────────
<section id="...">          給引擎一個可引用的深層錨點
                            → /page#cost-items 的點擊率高於頁面級引用

<h2> 用完整問句              直接命中 query fan-out 產生的檢索查詢

<details open>              FAQ 預設展開,內容確實在 HTML 中
                            → 若用 JS accordion,內容可能不存在

<p class="source">          每個 section 自帶來源與方法論
                            → grounding 階段的存活關鍵

5.2 <details> 而非 JS accordion

 1<!-- ❌ 內容由 JS 注入,AI crawler 看不到 -->
 2<div class="accordion" data-content-url="/api/faq/1">
 3  <div class="accordion-header">導入需要多久?</div>
 4</div>
 5
 6<!-- ⚠ 內容在 HTML 但用 display:none —— 多數引擎仍可讀,但權重可能降低 -->
 7<div class="accordion">
 8  <div class="header">導入需要多久?</div>
 9  <div class="body" style="display:none">標準導入為 6-10 週……</div>
10</div>
11
12<!-- ✔ 原生元素,語意明確,內容一定在 HTML 中 -->
13<details open>
14  <summary>導入需要多久?</summary>
15  <p>標準導入為 6-10 週……</p>
16</details>

5.3 Hugo 的 heading anchor 與 section 自動包裹

Hugo 的 render-heading.html hook 可以自動加 id:

1{{/* layouts/_default/_markup/render-heading.html */}}
2<h{{ .Level }} id="{{ .Anchor }}">
3  {{ .Text | safeHTML }}
4  <a class="anchor" href="#{{ .Anchor }}" aria-label="連結到此段落">#</a>
5</h{{ .Level }}>

表格加上 overflow-x 容器,避免行動裝置版面破掉(同時不影響機器解析):

 1{{/* layouts/_default/_markup/render-table.html */}}
 2<div class="table-wrap" style="overflow-x:auto">
 3  <table>
 4    <thead>
 5      {{ range .THead }}<tr>
 6        {{ range . }}<th style="text-align:{{ .Alignment }}">{{ .Text | safeHTML }}</th>{{ end }}
 7      </tr>{{ end }}
 8    </thead>
 9    <tbody>
10      {{ range .TBody }}<tr>
11        {{ range . }}<td style="text-align:{{ .Alignment }}">{{ .Text | safeHTML }}</td>{{ end }}
12      </tr>{{ end }}
13    </tbody>
14  </table>
15</div>

六、Step 5:llms.txt 與 Markdown 雙軌輸出

6.1 定位先講清楚

llms.txt 目前沒有任何主流引擎承諾支援。做它的理由是:

  • 成本 30 分鐘,未來若成標準已就位
  • 對自家 RAG、內部 agent、客戶的 AI 工具立即有用
  • .md 版本的價值明確高於 llms.txt 本身

不要把它當成 GEO 的核心策略。 它是象限③(順手做)的事。

6.2 Hugo 產生 llms.txt

hugo.toml 加輸出格式:

 1[outputFormats.LLMS]
 2  mediaType = "text/plain"
 3  baseName = "llms"
 4  isPlainText = true
 5  notAlternative = true
 6
 7[outputFormats.LLMSFULL]
 8  mediaType = "text/plain"
 9  baseName = "llms-full"
10  isPlainText = true
11  notAlternative = true
12
13[outputFormats.MARKDOWN]
14  mediaType = "text/markdown"
15  suffix = "md"
16  isPlainText = true
17  notAlternative = true
18
19[outputs]
20  home = ["HTML", "RSS", "LLMS", "LLMSFULL"]
21  page = ["HTML", "MARKDOWN"]
22
23[mediaTypes."text/markdown"]
24  suffixes = ["md"]

layouts/index.llms.txt

 1# {{ .Site.Title }}
 2
 3> {{ .Site.Params.orgDescription }}
 4
 5本檔案為 LLM 友善的網站索引。每個連結後方的 .md 版本為純 Markdown 全文。
 6
 7## 關於
 8
 9- [關於我們]({{ .Site.BaseURL }}about/): {{ .Site.Params.orgDescription }}
10- [產品]({{ .Site.BaseURL }}product/): 產品功能與定價
11{{ with .Site.Params.contactEmail }}- 聯絡:{{ . }}{{ end }}
12
13## 文章
14{{ range where (where .Site.RegularPages "Type" "posts") "Params.draft" "!=" true }}
15- [{{ .Title }}]({{ .Permalink }}) ([md]({{ .Permalink }}index.md)): {{ .Description }}
16{{- end }}
17
18## 分類
19{{ range .Site.Taxonomies.categories }}
20- {{ .Page.Title }}{{ len .Pages }} 篇): {{ .Page.Permalink }}
21{{- end }}

layouts/index.llmsfull.txt(全文串接版):

 1# {{ .Site.Title }} — 全文
 2
 3> {{ .Site.Params.orgDescription }}
 4> 產生時間:{{ now.Format "2006-01-02" }}
 5
 6{{ range where (where .Site.RegularPages "Type" "posts") "Params.draft" "!=" true }}
 7{{ "\n\n---\n\n" }}
 8# {{ .Title }}
 9
10URL: {{ .Permalink }}
11發佈:{{ .Date.Format "2006-01-02" }} | 更新:{{ (default .Date .Lastmod).Format "2006-01-02" }}
12作者:{{ delimit .Params.authors ", " }}
13
14{{ .RawContent }}
15{{ end }}

layouts/_default/single.md(每篇的 Markdown 版):

 1# {{ .Title }}
 2
 3URL: {{ .Permalink }}
 4發佈:{{ .Date.Format "2006-01-02" }}
 5更新:{{ (default .Date .Lastmod).Format "2006-01-02" }}
 6作者:{{ delimit .Params.authors ", " }}
 7分類:{{ delimit .Params.categories ", " }}
 8
 9> {{ .Description }}
10
11{{ .RawContent }}

然後在 head.html 宣告 Markdown 替代版本:

1{{ if eq .Type "posts" }}
2<link rel="alternate" type="text/markdown" href="{{ .Permalink }}index.md" title="Markdown 版本">
3{{ end }}

6.3 Next.js 的 .md route

 1// app/blog/[slug]/[...md]/route.ts  或直接用 app/blog/[slug].md/route.ts
 2import { NextResponse } from 'next/server'
 3import { getPost, getAllSlugs } from '@/lib/posts'
 4
 5export async function generateStaticParams() {
 6  return (await getAllSlugs()).map((slug) => ({ slug }))
 7}
 8
 9export async function GET(_req: Request, { params }: { params: { slug: string } }) {
10  const post = await getPost(params.slug)
11  if (!post) return new NextResponse('Not found', { status: 404 })
12
13  const body = [
14    `# ${post.title}`,
15    '',
16    `URL: https://example.com/blog/${post.slug}/`,
17    `發佈:${post.datePublished.slice(0, 10)}`,
18    `更新:${(post.dateModified ?? post.datePublished).slice(0, 10)}`,
19    `作者:${post.author.name}${post.author.jobTitle})`,
20    '',
21    `> ${post.description}`,
22    '',
23    post.markdown,
24  ].join('\n')
25
26  return new NextResponse(body, {
27    headers: {
28      'Content-Type': 'text/markdown; charset=utf-8',
29      'Cache-Control': 'public, max-age=3600, s-maxage=86400',
30    },
31  })
32}

七、Step 6:內容遷移腳本

改造 20 篇既有文章,手動做會很痛。這支腳本產出「每篇缺什麼」的清單。

  1#!/usr/bin/env python3
  2"""geo-audit.py —— 掃描 content/posts/*.md,列出各篇的 GEO 缺口。
  3
  4用法:python3 geo-audit.py content/posts/ --entity "OrderFlow"
  5"""
  6import argparse, re, sys, pathlib, json
  7
  8NUM = re.compile(r"\d+(?:[.,]\d+)?\s*(?:%|%|ms|秒|分鐘|小時|天|週|個月|年|元|萬|億|USD|NT\$|\$|QPS|GB|TB)")
  9DATE = re.compile(r"20\d{2}\s*[-/年]\s*\d{1,2}")
 10PRONOUN_HEAD = re.compile(r"^(它|他們|這個|該|本|上述|如前所述|此)")
 11QUESTION_H = re.compile(r"[??]|如何|怎麼|多少|為什麼|哪些|是什麼|要不要|該不該")
 12
 13
 14def split_front_matter(text):
 15    if text.startswith("---"):
 16        end = text.find("\n---", 3)
 17        if end != -1:
 18            return text[3:end], text[end + 4 :]
 19    return "", text
 20
 21
 22def audit(path: pathlib.Path, entity: str):
 23    raw = path.read_text(encoding="utf-8")
 24    fm, body = split_front_matter(raw)
 25
 26    headings = re.findall(r"^(#{2,3})\s+(.+)$", body, flags=re.M)
 27    paras = [p.strip() for p in body.split("\n\n") if len(p.strip()) > 60 and not p.strip().startswith(("```", "|", "!["))]
 28
 29    issues = []
 30
 31    # 1. 標題是否為問句
 32    q_ratio = sum(1 for _, h in headings if QUESTION_H.search(h)) / max(len(headings), 1)
 33    if q_ratio < 0.5:
 34        issues.append(f"問句式標題僅 {q_ratio:.0%}(目標 ≥ 50%)")
 35
 36    # 2. TL;DR
 37    if not re.search(r"(重點摘要|TL;DR|一句話總結|tldr)", body[:1500], flags=re.I):
 38        issues.append("缺少開頭 TL;DR 摘要區塊")
 39
 40    # 3. 數據密度
 41    nums = len(NUM.findall(body))
 42    density = nums / max(len(paras), 1)
 43    if density < 0.8:
 44        issues.append(f"數據密度過低:{nums} 個帶單位數字 / {len(paras)} 段(目標 ≥ 0.8/段)")
 45
 46    # 4. 日期標註
 47    if not DATE.search(body):
 48        issues.append("內文沒有任何年月標註(時效性題目會失分)")
 49    if "lastmod" not in fm and "dateModified" not in fm:
 50        issues.append("front matter 缺少 lastmod")
 51
 52    # 5. 表格
 53    if body.count("\n|") < 3:
 54        issues.append("沒有 Markdown 表格(表格被引用率為散文的 2-3 倍)")
 55
 56    # 6. FAQ
 57    if "faq:" not in fm and not re.search(r"##\s*(常見問題|FAQ)", body):
 58        issues.append("沒有 FAQ 區塊 / faq front matter")
 59
 60    # 7. 外部具名引用
 61    ext_links = re.findall(r"\[([^\]]+)\]\(https?://(?!example\.com)[^)]+\)", body)
 62    if len(ext_links) < 2:
 63        issues.append(f"外部具名引用只有 {len(ext_links)} 個(目標 ≥ 3)")
 64
 65    # 8. chunk 主詞獨立性
 66    orphan = [p for p in paras if entity not in p and PRONOUN_HEAD.match(p)]
 67    if len(orphan) > len(paras) * 0.25:
 68        issues.append(f"{len(orphan)}/{len(paras)} 段以代詞開頭且無主詞(chunk 無法獨立)")
 69
 70    # 9. 過短段落(切出來的 chunk 資訊量不足)
 71    short = [p for p in paras if len(p) < 80]
 72    if len(short) > len(paras) * 0.4:
 73        issues.append(f"{len(short)}/{len(paras)} 段少於 80 字(chunk 資訊密度不足)")
 74
 75    score = max(0, 100 - len(issues) * 11)
 76    return {"file": str(path), "score": score, "issues": issues}
 77
 78
 79def main():
 80    ap = argparse.ArgumentParser()
 81    ap.add_argument("path")
 82    ap.add_argument("--entity", default="")
 83    ap.add_argument("--min-score", type=int, default=0, help="低於此分數則 exit 1(供 CI 使用)")
 84    ap.add_argument("--json", action="store_true")
 85    a = ap.parse_args()
 86
 87    files = sorted(pathlib.Path(a.path).rglob("*.md"))
 88    results = [audit(f, a.entity) for f in files]
 89    results.sort(key=lambda r: r["score"])
 90
 91    if a.json:
 92        print(json.dumps(results, ensure_ascii=False, indent=2))
 93    else:
 94        for r in results:
 95            print(f"\n{r['score']:3d}  {r['file']}")
 96            for i in r["issues"]:
 97                print(f"      · {i}")
 98        avg = sum(r["score"] for r in results) / max(len(results), 1)
 99        print(f"\n平均分數:{avg:.1f} 檔案數:{len(results)}")
100
101    if a.min_score and any(r["score"] < a.min_score for r in results):
102        sys.exit(1)
103
104
105if __name__ == "__main__":
106    main()

用法:

1# 先掃全站,拿到基線與優先清單
2python3 geo-audit.py content/posts/ --entity "OrderFlow"
3
4# 放進 CI,擋下低於 70 分的新文章
5python3 geo-audit.py content/posts/ --entity "OrderFlow" --min-score 70

八、Step 7:sitemap 與新鮮度訊號

 1{{/* layouts/sitemap.xml —— 覆寫 Hugo 預設,加上正確的 lastmod 與優先權 */}}
 2<?xml version="1.0" encoding="utf-8" standalone="yes"?>
 3<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
 4  {{ range .Data.Pages }}
 5  {{ if and (not .Params.draft) (not .Params.noindex) }}
 6  <url>
 7    <loc>{{ .Permalink }}</loc>
 8    <lastmod>{{ (default .Date .Lastmod).Format "2006-01-02T15:04:05-07:00" }}</lastmod>
 9    <changefreq>{{ if .IsHome }}daily{{ else if eq .Type "posts" }}monthly{{ else }}yearly{{ end }}</changefreq>
10    <priority>{{ if .IsHome }}1.0{{ else if eq .Type "posts" }}0.8{{ else }}0.5{{ end }}</priority>
11  </url>
12  {{ end }}
13  {{ end }}
14</urlset>

新鮮度的三個訊號要一致,任一不一致都會削弱效果:

訊號                    來源                    常見錯誤
──────────────────────────────────────────────────────────────
sitemap 的 lastmod      建置期產生               設成 build time → 全站每天都「更新」
JSON-LD dateModified    front matter            忘記加,只有 datePublished
頁面上可見的日期文字      模板                    只顯示發佈日,不顯示更新日
HTTP Last-Modified      伺服器                   靜態主機常給檔案 mtime → 每次部署都變

修正 HTTP 標頭(Netlify / Vercel 類似):

# netlify.toml
[[headers]]
  for = "/blog/*"
  [headers.values]
    Cache-Control = "public, max-age=0, must-revalidate, s-maxage=86400"

九、Step 8:自動化驗收(可放進 CI)

  1#!/usr/bin/env python3
  2"""geo-verify.py —— 對線上頁面做端到端 GEO 驗收。
  3
  4用法:python3 geo-verify.py https://example.com/blog/post/ [more urls...]
  5"""
  6import json, re, sys, urllib.request, urllib.error
  7
  8BOTS = {
  9    "GPTBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.1; +https://openai.com/gptbot",
 10    "OAI-SearchBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot",
 11    "ClaudeBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ClaudeBot/1.0",
 12    "PerplexityBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; PerplexityBot/1.0",
 13    "Googlebot": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
 14    "bingbot": "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
 15    "Browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124 Safari/537.36",
 16}
 17
 18def fetch(url, ua, timeout=20):
 19    req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept": "text/html,*/*"})
 20    try:
 21        with urllib.request.urlopen(req, timeout=timeout) as r:
 22            return r.status, r.read().decode("utf-8", "replace")
 23    except urllib.error.HTTPError as e:
 24        return e.code, ""
 25    except Exception as e:
 26        return 0, f"ERR {e}"
 27
 28def text_len(html):
 29    h = re.sub(r"<(script|style|noscript)[^>]*>.*?</\1>", " ", html, flags=re.S | re.I)
 30    h = re.sub(r"<!--.*?-->", " ", h, flags=re.S)
 31    return len(re.sub(r"<[^>]+>", " ", h).split())
 32
 33def jsonld_blocks(html):
 34    out = []
 35    for m in re.finditer(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', html, flags=re.S | re.I):
 36        try:
 37            out.append(json.loads(m.group(1).strip()))
 38        except json.JSONDecodeError:
 39            out.append({"__parse_error__": True})
 40    return out
 41
 42def check(url):
 43    print(f"\n{'='*72}\n{url}\n{'='*72}")
 44    ok = True
 45
 46    # 1. 各 bot 存取
 47    _, browser_html = fetch(url, BOTS["Browser"])
 48    base_words = text_len(browser_html)
 49    print(f"\n[1] Crawler 存取(瀏覽器基準 {base_words} 字)")
 50    for name, ua in BOTS.items():
 51        if name == "Browser":
 52            continue
 53        code, html = fetch(url, ua)
 54        w = text_len(html) if code == 200 else 0
 55        ratio = w / base_words if base_words else 0
 56        flag = "OK " if code == 200 and ratio > 0.9 else "FAIL"
 57        if flag == "FAIL":
 58            ok = False
 59        print(f"    {flag}  {name:<16} {code}  {w:>6} 字  ({ratio:.0%})")
 60
 61    html = browser_html
 62
 63    # 2. JSON-LD
 64    blocks = jsonld_blocks(html)
 65    types = []
 66    for b in blocks:
 67        items = b if isinstance(b, list) else [b]
 68        for it in items:
 69            if isinstance(it, dict):
 70                types.append(it.get("@type", "?"))
 71    print(f"\n[2] JSON-LD:{len(blocks)} 個區塊,型別 {types or '無'}")
 72    for req in ("Organization", "BlogPosting", "Article"):
 73        pass
 74    if not any(t in types for t in ("BlogPosting", "Article", "WebPage")):
 75        print("    FAIL  缺少 Article / BlogPosting"); ok = False
 76    if any(b.get("__parse_error__") for b in blocks if isinstance(b, dict)):
 77        print("    FAIL  有 JSON-LD 解析錯誤"); ok = False
 78
 79    # 3. 日期
 80    has_mod = "dateModified" in html
 81    has_time = bool(re.search(r'<time[^>]+datetime=', html))
 82    print(f"\n[3] 新鮮度:dateModified={has_mod}  <time datetime>={has_time}")
 83    if not (has_mod and has_time):
 84        ok = False
 85
 86    # 4. 結構
 87    h2 = re.findall(r"<h2[^>]*>(.*?)</h2>", html, flags=re.S | re.I)
 88    h2_text = [re.sub(r"<[^>]+>", "", h).strip() for h in h2]
 89    q = sum(1 for t in h2_text if re.search(r"[??]|如何|多少|為什麼|哪些|是什麼|怎麼", t))
 90    sect = len(re.findall(r'<section[^>]+id=', html))
 91    tables = len(re.findall(r"<table", html, flags=re.I))
 92    print(f"\n[4] 結構:H2 {len(h2)} 個(問句式 {q})|section[id] {sect} 個|table {tables} 個")
 93    if len(h2) and q / len(h2) < 0.4:
 94        print("    WARN  問句式標題比例偏低")
 95    if tables == 0:
 96        print("    WARN  沒有表格")
 97
 98    # 5. Markdown 替代版本
 99    md = re.search(r'<link[^>]+type="text/markdown"[^>]+href="([^"]+)"', html)
100    print(f"\n[5] Markdown 版本:{md.group(1) if md else '無(非必要,但建議)'}")
101
102    # 6. canonical
103    can = re.search(r'<link[^>]+rel="canonical"[^>]+href="([^"]+)"', html)
104    print(f"[6] canonical:{can.group(1) if can else 'FAIL 無'}")
105    if not can:
106        ok = False
107
108    print(f"\n結果:{'PASS' if ok else 'FAIL'}")
109    return ok
110
111if __name__ == "__main__":
112    urls = sys.argv[1:]
113    if not urls:
114        print(__doc__); sys.exit(2)
115    sys.exit(0 if all(check(u) for u in urls) else 1)

放進 GitHub Actions:

 1# .github/workflows/geo-check.yml
 2name: GEO check
 3on:
 4  pull_request:
 5    paths: ['content/**', 'layouts/**', 'static/robots.txt']
 6  schedule:
 7    - cron: '0 2 * * 1'   # 每週一 02:00 UTC 對正式站做健檢
 8
 9jobs:
10  content-audit:
11    runs-on: ubuntu-latest
12    steps:
13      - uses: actions/checkout@v4
14      - uses: actions/setup-python@v5
15        with: { python-version: '3.12' }
16      - name: Audit content
17        run: python3 scripts/geo-audit.py content/posts/ --entity "OrderFlow" --min-score 65
18
19  live-verify:
20    if: github.event_name == 'schedule'
21    runs-on: ubuntu-latest
22    steps:
23      - uses: actions/checkout@v4
24      - uses: actions/setup-python@v5
25        with: { python-version: '3.12' }
26      - name: Verify production pages
27        run: |
28          python3 scripts/geo-verify.py \
29            https://example.com/ \
30            https://example.com/pricing/ \
31            https://example.com/blog/oms-cost/          

十、常見坑與排查

坑                              症狀                        解法
──────────────────────────────────────────────────────────────────────────
改完 CDN 規則沒生效              仍然 403                    CDN 有快取層;
                                                            purge 後再測,
                                                            並確認規則優先權在最前面

geo-verify 顯示 bot 字數正常     實際還是沒被引用             這是 Level 1→2 的問題,
但引用率沒動                                                內容層才是瓶頸(Part 3 §2)

加了 FAQPage schema             schema 與可見內容不一致       acceptedAnswer.text 必須
但沒效果                                                    與頁面文字相同

Hugo 的 .md 輸出把 shortcode    Markdown 版有 {{ }} 殘留     用 .Plain 或先 render
原樣輸出                                                    再轉 Markdown;
                                                            或避免在核心內容用 shortcode

Next.js 頁面 curl 有內容        內容在 useEffect 裡才補完整   把資料抓取移到 server
但關鍵段落缺失                                              component / generateStaticParams

dateModified 每天變             Git commit time 太敏感        用 front matter 手動控制
                                                            重要頁面的 lastmod

llms-full.txt 太大              超過 5MB,抓取逾時            分檔(依 category),
                                                            或只放摘要不放全文

section id 撞名                 錨點跳錯位置                  用 Hugo 的 .Anchor
                                                            (已自動去重)

表格用 CSS grid 排版            解析器抓不到欄位關係          一定要用 <table>

改版後 GEO 分數掉了              沒人發現                     把 geo-audit 放進 CI,
                                                            設 min-score 門檻

收尾檢查清單

Layer 1(技術)
□ ai-crawler-check.sh 全部 200,字數比 > 90%
□ CDN/WAF 有 AI bot skip 規則且在最前面
□ robots.txt 區分檢索 bot 與訓練 bot
□ 核心內容全部 SSR/SSG,無 JS 也完整
□ sitemap lastmod 正確(不是 build time)

Layer 2(結構)
□ Organization JSON-LD(sameAs 填滿)
□ 每篇 Article JSON-LD(含 author 的 Person)
□ 有 FAQ 的頁面有 FAQPage schema,且與可見文字一致
□ 語意化 HTML:article / section[id] / h2[id] / time[datetime]
□ 表格是 <table>,不是圖片、不是 div

Layer 3(內容,用 geo-audit.py 驗)
□ 問句式 H2 比例 ≥ 50%
□ 每篇有 TL;DR
□ 數據密度 ≥ 0.8 個帶單位數字/段
□ 每篇至少一張表
□ 外部具名引用 ≥ 3
□ 無「代詞開頭且無主詞」的孤兒段落

流程
□ geo-audit 進 CI,設 min-score
□ geo-verify 排程每週跑正式站
□ 新文章模板內建 TL;DR / FAQ / 來源標註欄位

做完這一份清單,你就在 Level 2 了。下一篇要解決最後一個問題:你怎麼證明這些有效?


本系列文章:

Yen

Yen

Yen