[CODE]

廣告自動化上線的兩個 bug:config domain 打錯、LLM 路徑亂猜

新民宿的廣告活動結構完整、文案正確——但每一個 URL 指向的 domain 根本不存在,另外還有四個 sitelink 連到 LLM 自己發明的頁面路徑。兩個獨立的 bug,同一次跑出來。

2 min read AI 生成
google-ads typescript automation bnb

恆春廣告引擎現在跑著五個民宿物件,背後三個業者。每一家都沒有行銷人員,沒有串接訂房系統能產生真實轉換訊號。優化訊號靠行為替代指標:電話點擊、LINE 聯繫按鈕、訂房頁進入,透過 GA4 記錄下來、送進 Google 當作訂房意圖最接近的代理訊號。

整個架構成立的前提,是每個物件的管理成本低到業者算得過來。這些都是小型家庭式民宿,對手是 OTA——佣金遠比外界想像的高、廣告預算完全不在同一個量級、還有長期累積的品牌信任。直接訂房是這些業者保住利潤的方式。引擎的任務,是讓搶回一部分直客流量變得負擔得起。

上線成本是這個算式的一部分。新物件加入引擎,帶著現成網站但沒有任何廣告歷史。沒有自動化工具,每個新物件就是一次手動建帳:讀網站、找高訂房意圖頁面、寫跟物件真實賣點對齊的廣告文案、從零建帳戶架構。偶爾一次可以接受。同時跑五個物件,這個成本就變得很明顯。

ads-setup-campaigns.ts 解決這件事。工具讀兩個輸入:site-config.yaml(存放 live domain 和帳戶識別資訊)和 business-context.md(物件賣點、房型、訂房訊號的結構化文件)。兩個都送進 Gemini 2.5 Pro,搭配 JSON schema 強制輸出格式,生出 15 條標題、5 條描述、15–25 個關鍵字(分精確、詞組、廣泛比對)、6 個 sitelink。再呼叫 Google Ads API,建兩個新的 PAUSED campaign——Performance Max 和 Search——把生成的素材掛上去。沒有人工啟用,廣告不會跑。

這個設計假設人工審閱會抓到文案問題:語氣、CJK 字元顯示寬度限制、相關性。結構正確性被視為理所當然。

第一次真實跑,廣告帳戶在每個有人看的地方都顯示正確。

同一次跑出兩個獨立的 bug

帳戶裡每一個 URL 全部指向一個不存在的 domain。

site-config.yamlsite_domain 欄位填的是這間民宿的 GitHub repo slug——沒有連字號、合成一個字。真實 live domain 是另一個字串:有連字號、分字方式不同。兩個都跟物件名稱有關聯,一個是 GitHub 從 repo 名稱自動產生的 slug 格式,一個是在網域申請商登記的實際網址。寫 config 的時候,填的是 repo slug,不是 live domain。

ads-setup-campaigns.tssite_domain 組裝所有 URL。兩個 campaign 的全部 sitelink finalUrl、Search RSA 的 final_urls、PMax asset group 的 final_urls——帳戶裡每一個 destination URL 都從這個單一欄位建出來。一個 YAML 欄位的字打錯,整個帳戶所有 URL 全部錯。

這在 Google Ads 管理介面的視覺審閱裡抓不到。介面顯示的是 campaign 架構、文案和狀態,不顯示目標 URL 是否可以連到。錯誤的 domain 看起來也像是合理的——它是物件名稱的一種縮略寫法,沒有讓人立刻起疑的地方。Campaign 架構對,廣告文案對,預算比例對,關鍵字結構對。錯的只有 domain,但那個錯誤值出現在每一個 URL 裡面。

這是第一個 bug。同一次跑,還出了第二個、獨立的失誤:六個 sitelink 裡面有四個,連向的頁面在任何 domain 底下都不存在。Gemini 生出來的路徑符合通用住宿網站的常見架構——設施頁、聯絡頁、交通位置頁、旅客評價頁——但這個網站的對應功能走不同的路徑。生成的路徑「合理」,不是「正確」。LLM 沒有拿到任何關於這個網站實際有哪些頁面的限制,所以就照著自己認識的標準住宿網站架構發揮了。

兩個獨立的失誤。一個來自人工填寫的 config 欄位,一個來自沒有限制的 LLM 生成。同一次上線跑出來。

兩層修復,有順序

先修 domain。ads-fix-domain.tsOLD_DOMAINNEW_DOMAIN 環境變數跑。Sitelink asset 的 final_urls 可以直接透過 mutations API 更新。RSA 的 final_urls 不行——Google Ads API 不允許對既有的 responsive search ad 直接更新 final_urls,唯一的方式是把舊的 RSA 刪掉、在同一個 ad group 重建一個新的,標題和描述文案不動。腳本處理兩件事:批次更新所有 sitelink asset,然後對每個受影響的 RSA 執行刪除 + 重建,帶正確 domain 的新 RSA 建回去。

再修路徑。ads-fix-sitelinks.ts 讀一個 mapping JSON——ads-sitelink-fix.json——裡面指定每個需要修正的 sitelink 的舊 URL 和正確替換目標:

  • 設施頁路徑 → 實際的 intro 頁路徑
  • 聯絡頁路徑 → 實際的 booking 頁路徑
  • 交通位置頁路徑 → 實際的 access 頁路徑
  • 評價頁路徑 → 首頁

四個 sitelink 透過同一套 mutations API 更新完成。帳戶是全新的,流量幾乎沒有——壞掉的訊號窗口還來不及累積。

根本原因的修法

Domain bug 是操作問題,修法是流程層面的。site-config.yaml 是人工填寫的。site_domain 欄位是工具生出所有 URL 的唯一來源,但在上線前沒有任何機制驗證這個值是否可以連到真實網站。正確的做法是在寫 config 的時候就確認 domain 能解析,對照 live 網站核實之後再跑 setup。這個值在 setup 之後是靜態的——就是要在那個時間點把它寫對。

路徑 bug 有結構性修法。ads-setup-campaigns.ts 現在讀取 site-state.json——由 site-snapshot job 預先產出的真實頁面清單——把實際存在的頁面路徑注入 LLM prompt。Gemini 呼叫現在拿到明確限制:sitelink 的 finalUrl 只能從這份清單裡挑。LLM 可以自由生成 sitelink 文字和描述;不能自己發明目標路徑。

這改變了上線流程的依賴順序。原本跑 setup 只需要 business-context.mdsite-config.yaml。現在還需要 site-state.json。site-snapshot job 必須在 campaign setup 之前跑完。新物件上線有了一個原本不存在的順序要求。

兩個 bug 揭露的共同問題

兩個失誤有一樣的結構:工具接受了一個輸入,沒有對照外部狀態驗證。

Domain 值是人工打進去的,進了一個沒有任何核實機制的欄位,然後這個欄位控制著工具生出的每一個 URL。頁面路徑是 LLM 生的,進了一個沒有任何限制的生成步驟,手邊也沒有實際頁面清單可以參照。Campaign 架構、文案品質、字元限制合規、關鍵字結構——那些全都正確。錯誤出在兩個邊界:人工 config 到工具執行的邊界,和 LLM 生成到真實網站狀態的邊界。

兩個邊界現在都有了管制。site-config.yaml 的 domain 打錯,在 campaign setup 跑之前就會被發現——因為 site-state.json 是從成功爬取網站產出的,爬取成功就代表 domain 可以連到。頁面路徑亂猜的問題在結構上不可能再發生,因為 LLM 現在從 site-state.json 確認存在的路徑裡挑選。

代價這次是低的,因為帳戶是全新的。同樣的 bug 如果發生在跑了一段時間的成熟帳戶,流量從頭到尾都在敲死頁,優化模型從一開始就在學習壞掉的訊號——那個資料污染要補救,遠比換 URL、重建 RSA 麻煩。第一次真實上線的價值,是在帳戶還不重要的時候,把兩個失誤模式都找出來了。

下一個透過工具上線的物件,要求 site-state.json 存在、頁面路徑從真實清單挑選,沒有 domain 問題,也沒有 404 sitelink。