[CODE]

用 Google Sheets 當自動化流程的狀態機

廣告自動化系統的狀態不存在資料庫,而是存在 Google Sheets。客戶可以直接看到、甚至手動編輯狀態,不需要另外做後台介面。

2 min read
google-sheets automation python architecture

我在做 bnb-ads-manager(一個幫民宿業者自動管理 Google Ads 關鍵字的系統)的時候,直覺反應是:開一個 Postgres table,寫一個後台介面,全部串起來。一整套標準流程。

但我停下來想了一下:這個後台介面,到底誰要用?

客戶是台灣的小型家庭民宿老闆。他們沒有 IT 人員,不是開發者,靠手機和筆電管生意,每天真正在用的軟體就是 Google Sheets。所以我賭了一把:如果試算表本身就是資料庫呢?

這不只是圖方便的決定,而是一個關於「對這個客群而言,什麼叫做系統可以正常運作」的問題。如果系統的狀態存在 Postgres table 裡,只有我能讀。如果它存在試算表裡,客戶現在就可以打開來看到底發生了什麼,甚至可以自己動手改——不用開 ticket,不用問我。

Keyword_Inventory 試算表

系統的核心是一張叫 Keyword_Inventory 的 sheet。每一列是一個關鍵字,每個關鍵字有一組欄位追蹤它的目前狀態:

欄位用途
CriterionIDGoogle Ads 關鍵字的 criterion ID
Keyword搜尋字詞
AdGroupID這個關鍵字所屬的廣告群組
StatusActiveRemoved
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 讀寫資料

整個流程大概長這樣:

  1. Python 透過 Sheets API v4 讀整張表
  2. 過濾掉保護期內的列
  3. 把剩下的關鍵字連同廣告效能資料一起丟給 Claude
  4. Claude 回傳決策
  5. 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: RemovedDate_Removed、以及白話文的 Reason。客戶拿手機打開試算表就能看到:「移除 — 30 天 3 次點擊,0 次轉換。」不用問我,不用看什麼 dashboard。

如果客戶不同意呢?他可以直接把 Status 改回 Active,清掉 Date_Removed。系統下一次跑的時候就會把它當作正常投放中的關鍵字。沒有 API,不用開 ticket。客戶對系統有直接的控制權,不需要任何技術背景,只需要知道試算表的儲存格是什麼。

這聽起來很小,但其實比表面上重要得多。多數小型商業自動化工具在客戶和資料之間設了一道牆——你看到的是 dashboard 的摘要,但你碰不到底層狀態。這套系統裡,客戶是第一公民。他們的修改在下一次執行時會被當作正式輸入,沒有任何問號。

當然也有風險:客戶可能不小心刪掉欄位,或貼進去奇怪的資料。這個我在 Python 這邊做了防禦性處理——每一列處理前都會驗證必填欄位,有問題就跳過那列。不是無懈可擊,但對只有幾個客戶、自己也很在乎廣告費的工具來說,目前運作得不錯。