[AI 工作流] · · 10min read · ★ FEATURED

我請 AI 幫我把 44 篇舊文重做 SEO:批次 PATCH Firestore、IA 重整、4 個 topic hub 完整覆盤

部落格累積到 44 篇後,每篇單獨 SEO 都做了,GSC 曝光卻一直撞牆。盤點才發現 28/44 都歸類在「AI 工具」、tags 一團亂、系列文沒 topic hub。本文覆盤這次「狠狠改一次」的全過程:6 個 category 收斂、tags 受控詞彙、17 個 duplicate 清理、批次 PATCH Firestore 踩到 timestampValue schema 雷、開 4 個 topic hub。AI 跑 1.5 小時、人類 10 分鐘決策。

章節目錄 · 12
TL;DR
- 本文解決:「站上 44 篇舊文累積出 IA 一團亂、tags 一團亂、category 一團亂,每篇單獨 SEO 都做了卻撈不到流量」這種典型部落格中期病。
- 推薦給:個人部落格累積 30 篇以上、開始覺得「站內亂、tags 沒在用、SEO 不漲」的人,以及打算用 AI 跑批次資料庫操作的人。
- 讀完你會知道:6 個 category 收斂法、tags 受控詞彙建法、批次 PATCH Firestore 踩到的 schema 雷、4 個 topic hub 怎麼開、AI 跑這種任務為什麼會卡 1.5 小時。
44 篇舊文批次 SEO 改造前後對照 — category 從 28 篇亂寫到 6 個受控、4 個 topic hub 收文

📌 目錄

  • 為什麼會做這次改造

  • 改造前的 IA 病灶 — 不是文章不好,是站亂

  • Step 1:定 6 個 category 受控詞彙(為什麼是 6 不是 10)

  • Step 2:tags 受控詞彙表 — 哪些禁用、哪些常駐

  • Step 3:17 個 hermes-agent duplicate 怎麼清

  • Step 4:批次 PATCH Firestore — 踩到 publishDate schema 雷

  • Step 5:開 4 個 topic hub 收散文

  • AI 該做 vs 你該做 對照表

  • 為什麼這種「看似單純」的批次任務會卡 1.5 小時

  • ❓ 常見問題

  • 🔗 延伸資源
  • 為什麼會做這次改造

    yanchen.app 寫到第 44 篇的時候,我開始注意到一個怪現象:每篇文章我都認真寫了 title、excerpt、FAQ、JSON-LD,但 Google Search Console 上的曝光成長停在某個水位,怎麼發新文都拉不上去。

    我一開始以為是「新站沙盒期」,繼續寫。但寫到第 50 篇還是這樣的時候,我請 Claude Code 幫我盤點全站,看到了真相 —

    站內 IA(資訊架構)一團亂。 不是文章寫不好,是「站」這個整體看起來像一堆無關的碎片:

    • 28 篇 category 寫 AI 工具,吃掉 64% 的文章 → schema.org articleSection 沒有任何主題權威信號
    • 同概念 tags 用 3-4 種拼法:AI Agent / AI 代理人 / AI agent / CLI agent 互相分散權重
    • 系列文沒有 topic hub 收:Claude Code 17 篇散在站上、Hermes 5 篇沒 hub、Skills 10 篇沒 hub
    • tags 過度泛化:AI 工具工程師日常開發實錄 收不到任何長尾流量
    這就是個人部落格的中期典型病 — 文章單獨看都還好,但「站」看起來像一堆無關碎片。Google 對這種站的判斷是「主題不集中、無法建立 topical authority」,所以即使單篇 SEO 都做了,整站就是上不去。

    於是我請 Claude Code 跑了一輪「狠狠的改一次」批次改造:44 篇逐篇盤點 + 改 category + 改 tags + 重寫 30 篇 title + 重寫 30 篇 excerpt + 開 4 個 topic hub + 清 17 個 duplicate doc。整個流程做完,我這邊實際投入時間約 10 分鐘(驗收 + 最終決策),AI 投入時間 1.5 小時。這篇是覆盤。

    改造前的 IA 病灶 — 不是文章不好,是站亂

    先看數字。改造前我請 AI 對 bob_blog_posts collection 做了 aggregate:

    病灶改造前數字為什麼是病灶
    category="AI 工具" 篇數28 / 44(64%)schema.org articleSection 一個分類吃 2/3 內容,等於沒分類
    不同 tags 總數100+ 個大量重複、近義詞分散權重,內鏈撈不到相關文章
    Claude Code 系列分散17 篇散落、無 hubGoogle 看不到「這站是寫 Claude Code 的」訊號
    Hermes Agent 系列重複 doc17 個 unpublished + 5 個 published同 slug 在 Firestore 寫了 4-5 次(之前批次發文沒過 dedupe)
    過度泛化的 tagsAI 工具工程師日常開發實錄AI教學競爭極高,個人站收不到任何流量
    這時候你能做的最便宜、效益最高的事,就是「站內 IA 收斂」。 不是再寫 10 篇新文,是把現有 44 篇的「分類訊號」整理乾淨。

    Step 1:定 6 個 category 受控詞彙(為什麼是 6 不是 10)

    第一個關鍵決策:整站 category 只能是這 6 個之一。不是 10 個、不是 20 個、就 6 個。

    category涵蓋主題對應 topic hub
    Claude CodeCLI、Plugin、Skill、Hook、Anthropic 官方 plugin/blog/topics/claude-code/
    AI AgentHermes、RuFlow、多 agent 框架、agent 架構/blog/topics/hermes-agent/
    AI 工作流LLM Wiki、RAG、mempalace、blog 自動化、SEO 自動化/blog/topics/llm-wiki//mempalace//claude-code-skills/
    AI 影片fal.ai、Wan、LoRA、ComfyUI、影片生成/blog/topics/ai-video/
    AI 部署DNS、網域、Cloudflare、本地 LLM、CI/CD、Astro/blog/topics/local-llm-mobile/
    AI 教學概論課、推廣簡報、企業內訓、Claude Code 教學課(未來新增)
    為什麼是 6 不是 10? 個人部落格的內容主軸通常不超過 5-7 個。多了會散,少了會擠。我的站實際分布跑下來剛好填滿 6 格:Claude Code 19、AI 工作流 10、AI 教學 7、AI Agent 6、AI 影片 3、AI 部署 3 — 沒有一格小於 3。

    為什麼是這 6 個? 我盤點完 44 篇之後,發現它們自然分群成 6 個內容簇。不是我先想好分類再硬塞,是先列篇再歸納 — 這順序很重要。先有內容才有分類 ,不是反過來。

    這步必須人類拍板。 AI 可以幫你聚類、提建議,但「我的部落格主軸是哪 6 個」是業務層決策,AI 不能代決定。我跟 Claude 來回討論了大概 15 分鐘才確定這 6 個。

    44 篇舊文

    盤點 + 聚類

    Claude Code 19 篇

    AI 工作流 10 篇

    AI 教學 7 篇

    AI Agent 6 篇

    AI 影片 3 篇

    AI 部署 3 篇

    topics/claude-code

    topics/llm-wiki

    topics/hermes-agent

    topics/ai-video

    Step 2:tags 受控詞彙表 — 哪些禁用、哪些常駐

    tags 不能自由發揮。 每次寫 tag 前,先翻受控詞彙表,沒對應的才新增(且要全站一致)。我把這次整理出來的對照表貼下面,可以直接拿走用:

    主題群受控 tags
    Claude Code 系列Claude Code, Claude Code CLI, Claude Code Plugin, Skills, Hooks, Subagents, Ralph Loop, Stop Hook, CLAUDE.md, Superpowers, /loop, /schedule, /powerup
    AI Agent 系列Hermes Agent, NousResearch, AI Agent, CLI Agent, Multi-Agent, MCP Server, Sandbox
    知識 / 記憶LLM Wiki, Karpathy, RAG, MemPalace, AI 知識管理, Obsidian, 編譯式知識庫
    AI 影片Wan 2.2, LoRA, LoRA 訓練, fal.ai, ComfyUI, I2V, musubi-tuner, AI 影片
    本地 LLM本地 LLM, PocketPal AI, Apple Intelligence, Ollama, GGUF, llama.cpp, on-device AI
    SEO / 部落格SEO, AEO, JSON-LD, FAQ Schema, 部落格寫作, GSC, Brand Voice
    廠商 / 平台Anthropic, OpenAI, ChatGPT, Cursor, Cloudflare, Astro, GitHub Pages, Vercel
    部署 / 網路DNS, 網域註冊, CI/CD, Porkbun, Bing Webmaster, launchd, Firestore, SMTP
    禁用 tags 清單:
    • AI 工具工程師日常開發實錄AI教學分享筆記
    • ❌ 純情緒詞:心得雜談隨筆
    判斷準則一句話:寫一個 tag 上去之前問自己「有人會在 Google 搜這個詞嗎?」會 → 留;不會 → 拿掉。

    AI 沒人搜(誰會只搜「AI」),心得 沒人搜(搜尋意圖太散),工程師日常 沒人搜(純自我表達)。這些 tag 寫上去 = 浪費資料庫欄位空間 + Google 看到一團糊。

    Step 3:17 個 hermes-agent duplicate 怎麼清

    盤點過程中發現了一個埋很久的雷:Hermes Agent 系列在 Firestore 有 22 個 docs,其中 17 個是不同 docid 但同 slug 的重複。原因是之前批次發文時沒做 dedupe,每次重發都另開新 doc。

    我考慮過兩個做法:

    做法優點缺點
    A. 硬刪 17 個 docs乾淨、Firestore 少 17 筆不可逆,萬一某個 doc 有獨特內容會丟掉;Google 已索引的舊網址會 404
    B. soft-delete(published: false可逆、保留 audit trail;Astro 的 getAllPublishedPosts() filter 自動排除Firestore 還是有 17 筆「殭屍 doc」
    選了 B。原因不只是「可逆」這個技術考量 — 「不可逆動作」是業務層決策,必須跟 Bob 雙確認。而 published: false 走 Astro filter 排除,前台完全看不到,效果跟刪除一樣,但保留反悔權。

    實際指令:

    # 對每個 duplicate docid 跑這個(用 updateMask 只改 published 欄位)
    curl -X PATCH \
      "https://firestore.googleapis.com/v1/projects/forbidden-beauty/databases/(default)/documents/bob_blog_posts/$DOCID?updateMask.fieldPaths=published&key=$KEY" \
      -H "Content-Type: application/json" \
      -d '{"fields":{"published":{"booleanValue":false}}}'

    跑完之後 Astro src/lib/firestore.ts:145getAllPublishedPosts() 會自動把它們 filter 掉,topic hub 跟首頁列表都看不到。

    Step 4:批次 PATCH Firestore — 踩到 publishDate schema 雷

    這步是這次改造最費時的環節。理論上「改 44 篇 docs 的 5 個欄位」應該是個 5 行 Python 腳本的事,實際上花了快 30 分鐘 debug。

    雷 1:publishDate 用了 timestampValue 整站炸掉

    第一次改造的時候,我隨手把 publishDate 寫成:

    publishDate: { timestampValue: "2026-05-20T00:00:00Z" }

    PATCH 200 OK 看起來沒事。但下一次 npm run build 整站炸:

    [ERROR] b.post.publishDate.localeCompare is not a function
      at /src/pages/blog/[slug].astro:50:30

    原因是 Firestore SDK 把 timestampValue 反序列化成 JavaScript Date 物件,但 src/lib/firestore.ts:82 預期 data.publishDate as string,[src/pages/blog/[slug].astro:50](https://github.com/yanchen184/ai-lecturer-bob/blob/master/src/pages/blog/[slug].astro#L50) 跑 b.post.publishDate.localeCompare(a.post.publishDate) — Date 物件沒有 .localeCompare() 方法。

    而且不只新改的這篇炸,連帶其他文章的 prerender 也都炸(因為排序步驟整個 throw)。

    修法是把 schema 改回字串:

    publishDate: { stringValue: "2026-05-20" }  // YYYY-MM-DD 字串,不是 ISO timestamp

    教訓:Firestore 同一個欄位混用 timestampValue 跟 stringValue 是地獄。一旦某個 doc 寫成 timestampValue,整站排序立刻死。

    雷 2:不用 dry-run 直接 PATCH 44 篇 → 萬一格式錯整批爛掉

    第一次跑全量前,先寫了一個 patch-seo-single.mjs 對單一篇試跑(Step skill 把它收進 SOP):

    // 先用最複雜的一篇(含 title rewrite + excerpt rewrite)試 PATCH
    const post = plan.posts.find((p) => p.new_title);
    const url = ${BASE_URL}/${post.docid}?${maskParam}&key=${API_KEY};
    const res = await fetch(url, { method: 'PATCH', ... });
    // 然後 GET 回來驗證每個欄位都對

    確認 schema 對、updateMask 對、欄位回寫成功之後,才跑全量 patch-seo.mjs這 5 分鐘的 dry-run 救了我一次 — 因為第一次的 timestampValue 就是在 single test 抓到的。

    雷 3:updateMask 一定要精確列出要改的欄位

    Firestore PATCH 的 updateMask.fieldPaths 決定哪些欄位被替換。沒列在 mask 裡的欄位即使你在 body 寫了也不會動,列在 mask 裡的欄位即使 body 沒寫也會被清空

    const updateMaskFields = ['category', 'tags', 'updateDate'];
    if (post.new_title) {
      fields.title = { stringValue: post.new_title };
      updateMaskFields.push('title');  // 動態 push,沒改 title 的篇不會碰
    }
    const maskParam = updateMaskFields.map((f) => updateMask.fieldPaths=${f}).join('&');

    這樣設計可以保證「沒改 title 的 14 篇文章 title 完全不會被動到」。如果 mask 寫 * 或漏列,會發生「我只想改 category,結果 publishDate 被清空」這種事故。

    Step 5:開 4 個 topic hub 收散文

    44 篇收完 category 跟 tags 還不夠。Google 要看到「這站是一個 X 主題的權威站」需要 topic hub

    我在 src/lib/topics.ts 開了 4 個新 hub:

    Topic hub slug收文tagMatchers
    claude-code20 篇Claude Code, Claude Code Plugin, Skills, Hooks, Ralph Loop, Stop Hook, ...
    hermes-agent5 篇Hermes Agent, NousResearch, MCP Server, Sandbox, ...
    ai-video3 篇Wan 2.2, LoRA, fal.ai, ComfyUI, ...
    claude-code-skills10 篇Skills, Anthropic Skill, ...
    每個 hub 是一個 Astro static page,含:
    • 自己的 </code> 跟 <code><meta name="description"></code>(命中 hub 主關鍵字)</li> <li>H1(例:「Claude Code 完整指南」)</li> <li>3 段 intro paragraph(給人類讀 + 給 Google 看主題深度)</li> <li>自動聚合 <code>tagMatchers</code> 命中的文章 + 手動加入 <code>articleSlugs</code> 補強</li> <li>FAQPage JSON-LD 結構化資料(5 題 hub-level FAQ)</li> <li>完整 keywords 陣列</li> </ul><strong>Astro 用 <code>getStaticPaths()</code> 自動 build 出 <code>/blog/topics/{slug}/</code></strong>,build 時掃 Firestore 撈 tag 命中的文章組成清單。每篇文章可以同時出現在多個 hub(一篇 Claude Code Skill 文同時收進 <code>claude-code</code> 跟 <code>claude-code-skills</code> hub)。 <h3>為什麼這比每篇文 SEO 重要</h3> <table><thead><tr><th>訊號類型</th><th>單篇 SEO(title/excerpt/FAQ)</th><th>Topic hub</th></tr></thead><tbody><tr><td>Google 看到的主題權威</td><td>一篇文</td><td><strong>整站</strong>主題權威</td></tr><tr><td>內鏈權重</td><td>散</td><td>從 hub 流向相關文</td></tr><tr><td>長尾捕捉</td><td>該篇的主關鍵字</td><td>hub 把「主題 + 介紹」「主題 + 完整指南」「主題 + 是什麼」全收</td></tr><tr><td>給 LLM 引用</td><td>一篇</td><td>一個權威頁帶整個系列</td></tr></tbody></table> <strong>個人部落格寫到 30 篇以上必開 topic hub。</strong> 沒開 = 把 30 篇的累積權重浪費掉。 <h2 id="ai-該做-vs-你該做-對照表">AI 該做 vs 你該做 對照表</h2> <p>整個改造流程拆解出哪些是 AI 可以全包、哪些必須人類拍板:</p> <table><thead><tr><th>工作類型</th><th>適合 AI</th><th>必須人類</th></tr></thead><tbody><tr><td>盤點全站 44 篇撈 category 分布</td><td>✅</td><td></td></tr><tr><td>聚類建議「分成幾個 category 比較好」</td><td>✅</td><td></td></tr><tr><td>寫 master plan 表(每篇要改什麼)</td><td>✅</td><td></td></tr><tr><td>寫批次 PATCH 腳本(含 updateMask、dry-run)</td><td>✅</td><td></td></tr><tr><td>改 44 篇 metadata、跑 PATCH、驗證</td><td>✅</td><td></td></tr><tr><td>寫 4 個 topic hub 的 metadata + intro paragraph</td><td>✅</td><td></td></tr><tr><td>跑 build + 觸發 deploy + round-trip 驗證</td><td>✅</td><td></td></tr><tr><td><strong>拍板 6 個 category 是哪 6 個</strong></td><td></td><td>✅</td></tr><tr><td><strong>拍板 tags 受控詞彙表初版有哪些</strong></td><td></td><td>✅</td></tr><tr><td><strong>決定 duplicate doc 走 soft-delete 還是硬刪</strong>(不可逆)</td><td></td><td>✅</td></tr><tr><td><strong>拍板每個 topic hub 的標題、定位、FAQ 寫什麼</strong></td><td></td><td>✅</td></tr><tr><td><strong>驗收 30 篇 title rewrite 是否符合 Brand Voice</strong></td><td></td><td>✅</td></tr></tbody></table> <strong>原則:</strong> 凡是「不可逆動作」、「資訊架構決策」、「品牌定位」、「初版 schema 設計」必須人類拍板。AI 可以提建議、可以執行決策,但不能代替決策。 <h2 id="為什麼這種看似單純的批次任務會卡-15-小時">為什麼這種「看似單純」的批次任務會卡 1.5 小時</h2> <p>這是我請 AI 跑的時候沒料到的 — Bob 跟我說:「我沒想到你居然弄了這麼久」。</p> <p>我覆盤了一下,發現「44 篇改 5 個欄位」這種看似 5 分鐘的事,實際上時間都花在哪裡:</p> <table><thead><tr><th>階段</th><th>耗時</th><th>為什麼</th></tr></thead><tbody><tr><td>全站盤點 + aggregate(Firestore list 200 筆 + 解析 category 分布)</td><td>5 分鐘</td><td>要打 Firestore REST API 撈全量 + 寫 Python 腳本 aggregate</td></tr><tr><td><strong>逐篇決定每篇的 new_category、new_tags、new_title、new_excerpt</strong></td><td><strong>40 分鐘</strong></td><td>不是機械替換,每篇要看內容判斷 tag 對不對、title 命中關鍵字夠不夠</td></tr><tr><td>跟 Bob 討論「6 個 category 是哪 6 個」</td><td>15 分鐘</td><td>業務層決策,來回討論</td></tr><tr><td>寫 master plan JSON、寫 patch 腳本、單篇 dry-run</td><td>10 分鐘</td><td></td></tr><tr><td>踩到 <code>timestampValue</code> 雷 → 整站 build 炸 → 修 → 重 PATCH</td><td>8 分鐘</td><td>看到 build 炸的時候會慌一下</td></tr><tr><td>跑 44 篇全量 PATCH</td><td>3 分鐘</td><td>寫好之後就是 wait for fetch</td></tr><tr><td>寫 4 個 topic hub 的完整 metadata + intro + FAQ</td><td>15 分鐘</td><td>每個 hub 要寫 5 題 FAQ + 3 段 intro,需要對主題有理解</td></tr><tr><td>跑 build 驗證 + 觸發 deploy + round-trip 驗證 5 篇</td><td>4 分鐘</td><td></td></tr></tbody></table> 加總約 <strong>100 分鐘</strong>。其中 <strong>「逐篇 metadata 決策」吃掉 40 分鐘</strong> — 這部分根本不可能機械化,每篇都要看內容判斷。 <h3>為什麼比人類快但沒快很多</h3> <p>如果是人手動做這件事:</p> <ul><li>盤點 + 寫 plan 大概 2 小時(你會邊看邊改主意)</li> <li>改 44 篇 Firestore docs 至少 2 小時(每篇要打開 console 點點點)</li> <li>寫 4 個 topic hub 至少 1.5 小時</li> <li>總計 <strong>5.5 小時</strong></li> </ul>AI 跑 1.5 小時是比手動快約 3.7 倍。<strong>但不是「秒殺」級別</strong>。因為: <p><li class="ol"><strong>「逐篇判斷」這個步驟不能機械化</strong> — 你需要看文章內容才能決定 tag。AI 是讀很快,但還是要讀。</li><br/><li class="ol"><strong>業務層決策必須 round-trip</strong> — 「6 個 category 是哪 6 個」沒辦法 AI 自己決定,要跟人來回。</li><br/><li class="ol"><strong>批次任務的 debug 成本被低估</strong> — 一個 schema 雷會卡 8 分鐘,相當於跑完所有 PATCH 的時間。</li></p> <p><strong>所以「我請 AI 幫我做批次資料庫操作」應該預期 30 分鐘到 2 小時,不是 5 分鐘。</strong> 如果你覺得「不過就改 44 個欄位嘛」,那是你低估了「決策內容」這部分。</p> <h2 id="-常見問題">❓ 常見問題</h2> <h3>我的部落格才 10 篇,需要做這種整理嗎?</h3> <p>不用。<strong>這套整理在 30 篇以上才開始有意義</strong>。10 篇的時候你的 IA 還沒成型、tags 也沒亂、topic 也撐不起來 — 這時候應該專注寫新文章,不要過度設計。整理是「累積到一定量、開始覺得亂」的時候才做。</p> <h3>我不用 Firestore,用 WordPress / Ghost / Notion,這套還適用嗎?</h3> <p><strong>核心概念全部適用,只有「批次 PATCH」這步不同</strong>。WordPress 用 WP CLI 或 REST API 批次改 category;Ghost 用 Ghost Admin API;Notion 用 Notion API。受控詞彙表、6 個 category、topic hub 這三個概念是 SEO 通用 best practice,跟 backend 無關。</p> <h3>Topic hub 在 Astro 之外怎麼做?</h3> <p>WordPress 用 category archive page 本身就是 topic hub(記得加自訂 intro paragraph + FAQ);Ghost 有 tag page;Hugo 有 taxonomy template。<strong>重點不在框架,在「這個 hub page 有沒有 metadata、intro、FAQ、tagMatchers 設計」</strong>。一個沒有任何 content 的 tag archive 不算 hub。</p> <h3>改完之後多久看得到 SEO 效果?</h3> <p>我自己的觀察是 <strong>2-4 週開始有變化</strong>。改完 metadata Google 要重新 crawl 整站、重新評估 topic authority,這需要時間。觀察指標:GSC 上「曝光」會先漲(Google 重新評估)、然後「點擊」會漲(title/excerpt 命中改善)。<strong>不要改完一個禮拜沒看到變化就回滾</strong>,太早。</p> <h3>我擔心改 title / excerpt 會把原本排到的關鍵字弄丟,怎麼辦?</h3> <p>兩個保護機制:(1) 改之前去 GSC 撈該篇近 30 天的 query,<strong>有曝光的關鍵字必須保留在新 title / excerpt 裡</strong>;(2) 改的時候用 git commit 保留版本,發現新版本 SEO 變差可以 PATCH 回去。我這次跑的 30 篇 title rewrite 都是「加上主關鍵字」而不是「換掉主關鍵字」 — 加法比減法安全。</p> <h2 id="-延伸資源">🔗 延伸資源</h2> <ul><li><a href="/blog/ai-buy-domain-migration-yanchen-app/">我把「買網域 + 整站搬家」交給 AI 跑:人類只花 15 分鐘的 8 步驟流程</a> — 這篇的姐妹文,同樣是「AI 主導 + 人類補位」的批次任務覆盤</li> <li><a href="/blog/karpathy-llm-wiki/">Karpathy LLM Wiki 跑兩週實測:編譯式知識庫怎麼建</a> — 內容知識管理的另一條路徑</li> <li><a href="/blog/topics/claude-code/">Claude Code 完整指南</a> — 本站 Claude Code 主題 hub,這次改造新開的 4 個 hub 之一</li> <li><a href="https://docs.astro.build/en/guides/content-collections/" target="_blank" rel="noopener noreferrer">Astro Docs — Content Collections</a> — 如果你想自己建 topic hub,Astro 的 collections + getStaticPaths 是最乾淨的做法</li> <li><a href="https://firebase.google.com/docs/firestore/reference/rest/v1/projects.databases.documents/patch" target="_blank" rel="noopener noreferrer">Firestore REST API — Patch Document</a> — <code>updateMask.fieldPaths</code> 是這次的核心,官方文件有完整 schema</li> </ul>--- <blockquote>我輩修行之人,以聖的標準要求自己,以凡的眼光理解別人。</blockquote></article> </div> </div> <script> (function () { function init() { const links = document.querySelectorAll('[data-toc-link]'); if (!links.length) return; const map = new Map(); links.forEach((a) => { const id = a.getAttribute('data-toc-link'); const target = document.getElementById(id); if (target) map.set(id, { link: a, target }); }); if (!map.size) return; const setActive = (id) => { map.forEach((v, key) => { v.link.classList.toggle('is-active', key === id); }); }; const io = new IntersectionObserver( (entries) => { // 取目前最靠近頂部的那個 visible heading const visible = entries .filter((e) => e.isIntersecting) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); if (visible[0]) setActive(visible[0].target.id); }, { rootMargin: '-100px 0px -60% 0px', threshold: [0, 1] }, ); map.forEach((v) => io.observe(v.target)); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); </script> <!-- Prism.js syntax highlighting (CDN, 不進 bundle) --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"> <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js"></script> <script> /* 程式碼區塊:filename header + copy 按鈕 */ (function () { function init() { const pres = document.querySelectorAll('article.blog-prose pre[data-lang]'); pres.forEach((pre) => { if (pre.dataset.codeEnhanced) return; pre.dataset.codeEnhanced = '1'; const lang = pre.dataset.lang || 'code'; const filename = pre.dataset.filename || ''; const label = filename || lang; // wrap pre in .code-block const wrap = document.createElement('div'); wrap.className = 'code-block'; pre.parentNode.insertBefore(wrap, pre); wrap.appendChild(pre); // header: label + copy btn const header = document.createElement('div'); header.className = 'code-block-header'; const labelEl = document.createElement('span'); labelEl.className = 'code-block-label'; labelEl.textContent = label; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'code-block-copy'; btn.setAttribute('aria-label', '複製程式碼'); btn.textContent = 'copy'; btn.addEventListener('click', async () => { const code = pre.querySelector('code')?.innerText ?? ''; try { await navigator.clipboard.writeText(code); btn.textContent = 'copied ✓'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); } catch { btn.textContent = 'failed'; setTimeout(() => (btn.textContent = 'copy'), 1600); } }); header.appendChild(labelEl); header.appendChild(btn); wrap.insertBefore(header, pre); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); </script> <script> /* 幫會橫向 overflow 的 table / pre 加提示,依元素類型用不同文案 */ (function () { const LABELS = { table: { idle: '← 左右滑動看完整表格 →', end: '← 滑回表格開頭' }, pre: { idle: '← 左右滑動看程式碼 →', end: '← 回到程式碼開頭' }, }; function decorate() { const targets = document.querySelectorAll('article.blog-prose table, article.blog-prose pre'); targets.forEach((el) => { if (el.dataset.scrollHinted) return; if (el.scrollWidth <= el.clientWidth + 1) return; const kind = el.tagName === 'TABLE' ? 'table' : 'pre'; const labels = LABELS[kind]; const hint = document.createElement('div'); hint.className = 'scroll-hint'; hint.setAttribute('aria-hidden', 'true'); hint.textContent = labels.idle; // 若 pre 被 .code-block 包起來,把 hint 插在 wrapper 前面;否則插在 el 前 const anchor = el.closest('.code-block') || el; anchor.parentNode.insertBefore(hint, anchor); el.dataset.scrollHinted = '1'; const update = () => { const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 2; hint.classList.toggle('at-end', atEnd); hint.textContent = atEnd ? labels.end : labels.idle; }; update(); el.addEventListener('scroll', update, { passive: true }); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', decorate); } else { decorate(); } window.addEventListener('resize', decorate, { passive: true }); })(); </script> <!-- Author + Share --> <section class="px-6 md:px-10 py-12 border-t-2 border-black bg-white"> <div class="max-w-5xl mx-auto grid gap-6 md:grid-cols-2"> <div> <div class="text-xs uppercase tracking-widest mb-2 opacity-60 font-mono"> author </div> <div class="flex items-start gap-3"> <div class="w-12 h-12 bg-[var(--color-neub-yellow)] border-2 border-black flex items-center justify-center font-black"> 陳 </div> <div> <div class="font-black">陳彥彤</div> <p class="text-sm opacity-70 leading-relaxed"> AI 工程師 · AI 顧問。Java 後端 8 年、AI 工程師 2 年。AI 內訓 · AI 導入顧問 · 前後端與雲端培訓。 </p> </div> </div> </div> <div> <div class="text-xs uppercase tracking-widest mb-2 opacity-60 font-mono"> support </div> <p class="text-sm opacity-80 leading-relaxed"> 覺得文章有用可以到 GitHub 給個 star,或是透過信箱聊聊 AI 內訓、AI 導入顧問或前後端 / 雲端培訓。 </p> <div class="mt-3 flex flex-wrap gap-2 font-mono"> <a href="mailto:bobchen184@gmail.com" class="px-3 py-1.5 text-xs uppercase tracking-widest border-2 border-black hover:bg-[#ffff00]">email</a> <a href="https://github.com/yanchen184" target="_blank" rel="noopener" class="px-3 py-1.5 text-xs uppercase tracking-widest border-2 border-black hover:bg-[#ffff00]">github</a> </div> </div> </div> </section> <!-- Related Posts --> <section class="px-6 md:px-10 py-14 border-t-2 border-black"> <div class="max-w-5xl mx-auto"> <div class="text-xs uppercase tracking-widest opacity-60 mb-2 font-mono"> related </div> <h2 class="text-2xl md:text-3xl font-black tracking-tight mb-6"> 相關文章 </h2> <div class="grid gap-0 md:grid-cols-3 border-t-2 border-black"> <a href="/blog/ai-search-faq-jsonld" class="block p-4 border-b-2 border-black hover:bg-[#ffff00] transition-colors md:border-r-2"> <div class="text-xs uppercase tracking-widest opacity-60 mb-2 font-mono"> [AI 工作流] · 8min </div> <div class="font-black leading-tight mb-2">AEO 完整教學:用 FAQ + JSON-LD 結構化資料讓 ChatGPT / Perplexity 引用你的文章</div> <div class="text-xs opacity-70 line-clamp-2">AEO(AI 搜尋優化)是 2026 比 SEO 還重要的流量入口。本文教你用 FAQPage JSON-LD schema 一個結構化資料兩邊賺:被 ChatGPT / Perplexity 引用 + 撈 Google People Also Ask 流量。含 Astro 整合範本、JSON-LD 寫法、實測被引用的證據。</div> </a><a href="/blog/ai-buy-domain-migration-yanchen-app" class="block p-4 border-b-2 border-black hover:bg-[#ffff00] transition-colors md:border-r-2"> <div class="text-xs uppercase tracking-widest opacity-60 mb-2 font-mono"> [AI 部署] · 22min </div> <div class="font-black leading-tight mb-2">我把「買網域 + 整站搬家」交給 AI 跑:人類只花 15 分鐘的 8 步驟流程</div> <div class="text-xs opacity-70 line-clamp-2">我把舊站 yanchen184.github.io/ai-lecturer-bob 搬到自有網域 yanchen.app,整個流程交給 Claude Code 跑。人類負責的事只有 15 分鐘:刷卡、改 nameserver、按 GSC verify、按 Bing 匯入。AI 負責的 1.5 小時包含:跨來源比價、17 個檔案批次改 URL、跑 CI/CD 自我除錯、寫 SEO 設定、監看 SSL 簽發。本文拆解 8 步驟、AI 該做 vs 你該做對照表、整個流程踩到的 5 個坑(1.1.1.1 stale cache、GSC Change of Address 灰掉的真相⋯⋯),給「想搬家但被 DNS / SSL / redirect / SEO 嚇到」的人一條完整路徑。</div> </a><a href="/blog/blog-create-skill-overview" class="block p-4 border-b-2 border-black hover:bg-[#ffff00] transition-colors"> <div class="text-xs uppercase tracking-widest opacity-60 mb-2 font-mono"> [AI 工作流] · 9min </div> <div class="font-black leading-tight mb-2">blog-create Skill 完整總覽:用 Claude Code 自動寫 SEO + AEO 部落格(P0-P3 升級路線圖)</div> <div class="text-xs opacity-70 line-clamp-2">想用 Claude Code 自動產出 SEO 達標的部落格?blog-create Skill 把整個寫作流程做成一條龍:P0 量化驗收 → P1 AEO 結構化資料 → P2 防數字過時 → P3 風格防漂移。本文是 4 個升級的優先順序、互補關係、新手照寫滿幾篇對照升級的完整導讀。</div> </a> </div> <div class="mt-6 text-center"> <a href="/blog" class="inline-block px-4 py-2 text-xs uppercase tracking-widest border-2 border-black hover:bg-[#ffff00] font-mono">← all posts</a> </div> </div> </section> </main> <footer class="bg-black text-white mt-20"> <div class="max-w-7xl mx-auto px-6 md:px-10 py-12 grid gap-8 md:grid-cols-3"> <div> <div class="text-2xl font-black tracking-tight">陳彥彤 YC · AI 顧問</div> <p class="mt-3 text-sm opacity-70 leading-relaxed"> Java 後端 8 年 · AI 工程師 2 年。<br> AI 內訓 · AI 導入顧問 · 前後端與雲端培訓。 </p> </div> <div> <div class="text-xs uppercase tracking-widest opacity-60 mb-3 font-mono">導覽</div> <ul class="space-y-1.5 text-sm"> <li><a href="/" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">首頁</a></li> <li><a href="/#about" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">關於</a></li> <li><a href="/#courses" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">課程與服務</a></li> <li><a href="/blog" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">部落格</a></li> <li><a href="/rss.xml" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">RSS</a></li> </ul> </div> <div> <div class="text-xs uppercase tracking-widest opacity-60 mb-3 font-mono">聯繫</div> <ul class="space-y-1.5 text-sm"> <li><a href="mailto:bobchen184@gmail.com" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">bobchen184@gmail.com</a></li> <li><a href="https://github.com/yanchen184" target="_blank" rel="noopener" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">GitHub</a></li> </ul> </div> </div> <div class="border-t border-white/10"> <div class="max-w-7xl mx-auto px-6 md:px-10 py-4 text-xs opacity-60 flex flex-wrap justify-between gap-2"> <span>© 2026 陳彥彤 Bob Chen. All rights reserved.</span> <span class="font-mono">build: astro-ssg</span> </div> </div> </footer> <div id="__post_meta" data-slug="ai-batch-seo-refactor-44-articles" data-title="我請 AI 幫我把 44 篇舊文重做 SEO:批次 PATCH Firestore、IA 重整、4 個 topic hub 完整覆盤" hidden></div> <script type="module" src="/_astro/_slug_.astro_astro_type_script_index_0_lang.BK1GbZ4K.js"></script> <script type="module" src="/_astro/RootLayout.astro_astro_type_script_index_0_lang.CoK0GmW1.js"></script> </body> </html>