我在做 bnb-ads-manager(一個幫民宿業者自動管理 Google Ads 關鍵字的系統)的時候,直覺反應是:開一個 Postgres table,寫一個後台介面,全部串起來。一整套標準流程。
但我停下來想了一下:這個後台介面,到底誰要用?
客戶是台灣的小型家庭民宿老闆。他們沒有 IT 人員,不是開發者,靠手機和筆電管生意,每天真正在用的軟體就是 Google Sheets。所以我賭了一把:如果試算表本身就是資料庫呢?
這不只是圖方便的決定,而是一個關於「對這個客群而言,什麼叫做系統可以正常運作」的問題。如果系統的狀態存在 Postgres table 裡,只有我能讀。如果它存在試算表裡,客戶現在就可以打開來看到底發生了什麼,甚至可以自己動手改——不用開 ticket,不用問我。
Keyword_Inventory 試算表
系統的核心是一張叫 Keyword_Inventory 的 sheet。每一列是一個關鍵字,每個關鍵字有一組欄位追蹤它的目前狀態:
| 欄位 | 用途 |
|---|---|
CriterionID | Google Ads 關鍵字的 criterion ID |
Keyword | 搜尋字詞 |
AdGroupID | 這個關鍵字所屬的廣告群組 |
Status | Active 或 Removed |
Date_Added | 關鍵字加入日期(YYYY-MM-DD) |
Date_Removed | 移除日期;若仍在投放則留空 |
Reason | 上一次狀態變更的白話文說明 |
Reason 欄值得多說幾句。每次移除關鍵字,Python 都會把白話文原因寫回去:「投放 30 天,3 次點擊,0 次轉換」或「與效果更好的同義詞重疊」。民宿老闆拿手機打開就能讀懂,不用問我。這種可追溯性,是讓系統感覺像「自己的工具」而不是「盲目信任的黑盒子」的關鍵。
狀態機的核心邏輯其實藏在 Date_Added 這欄。每次執行時,Python 在把關鍵字送給 Claude 之前,會先計算這個關鍵字是否已經跑滿 14 天。新的關鍵字需要時間累積資料,才有辦法做出有意義的判斷——跑三天的曝光量送給 Claude 分析,產出的是雜訊,不是訊號。保護期就是用來過濾這個問題的。
from datetime import date
def is_protected(row: dict) -> bool:
date_added = row.get("Date_Added", "")
if not date_added:
return False
try:
added = date.fromisoformat(date_added)
return (date.today() - added).days <= 14
except ValueError:
return False
就這樣。沒有 SQL,沒有 ORM,沒有 migration。Python 讀一個日期字串,算一下天數差。
透過 Sheets API v4 讀寫資料
整個流程大概長這樣:
- Python 透過 Sheets API v4 讀整張表
- 過濾掉保護期內的列
- 把剩下的關鍵字連同廣告效能資料一起丟給 Claude
- Claude 回傳決策
- Python 把決策寫回試算表
讀取很直覺——一個 spreadsheets.values.get,然後把表頭列和每一行資料 zip 成 dict 的 list。寫入的部分我用 batch update,避免逐列呼叫 API 打到 rate limit。
不過早期測試時確實踩到了。Sheets API 的限制是每個使用者每 100 秒 100 次請求。我一開始傻傻地依序寫,偶爾會收到 429。修法是加指數退避加亂數 jitter:
import time
import random
def sheets_write_with_retry(service, spreadsheet_id, range_, values, max_retries=5):
for attempt in range(max_retries):
try:
service.spreadsheets().values().update(
spreadsheetId=spreadsheet_id,
range=range_,
valueInputOption="RAW",
body={"values": values},
).execute()
return
except HttpError as e:
if e.resp.status == 429 and attempt < max_retries - 1:
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)
else:
raise
沒什麼特別的,但跑起來穩了很多。
Race Condition 問題(以及一個很無聊的解法)
用試算表當狀態機最讓我擔心的就是並發寫入。如果兩個執行同時跑,可能會發生交錯寫入讓狀態出錯——兩個 process 同時讀到第 5 列是「active」,各自決定暫停,然後各自寫回去,第二次寫入可能會覆蓋掉第一次的結果。
我想過 file lock、在試算表加一個 lease 欄位,甚至放一個輕量 Redis flag。最後我做了一個更簡單的選擇:這個 job 只透過 GitHub Actions 的 workflow_dispatch 手動觸發,完全沒有排程 cron。如果之後要加排程,我會在 workflow YAML 設 concurrency: cancel-in-progress。
這是一個限制,不是一個通用解法。但它符合實際使用情境——管幾個廣告活動的民宿老闆不需要這個系統每 5 分鐘跑一次。一週一次就夠了,一週一次的話 race condition 根本不會是真正的風險。
為什麼客戶喜歡這個設計
我一直覺得最有價值的地方是稽核紀錄。每一次移除都會在表格裡留下一列記錄:Status: Removed、Date_Removed、以及白話文的 Reason。客戶拿手機打開試算表就能看到:「移除 — 30 天 3 次點擊,0 次轉換。」不用問我,不用看什麼 dashboard。
如果客戶不同意呢?他可以直接把 Status 改回 Active,清掉 Date_Removed。系統下一次跑的時候就會把它當作正常投放中的關鍵字。沒有 API,不用開 ticket。客戶對系統有直接的控制權,不需要任何技術背景,只需要知道試算表的儲存格是什麼。
這聽起來很小,但其實比表面上重要得多。多數小型商業自動化工具在客戶和資料之間設了一道牆——你看到的是 dashboard 的摘要,但你碰不到底層狀態。這套系統裡,客戶是第一公民。他們的修改在下一次執行時會被當作正式輸入,沒有任何問號。
當然也有風險:客戶可能不小心刪掉欄位,或貼進去奇怪的資料。這個我在 Python 這邊做了防禦性處理——每一列處理前都會驗證必填欄位,有問題就跳過那列。不是無懈可擊,但對只有幾個客戶、自己也很在乎廣告費的工具來說,目前運作得不錯。