EN 中文
← 回文章列表
遷移 · 成本紀律

把 $7.88/月的 n8n on Zeabur 換成 178 行的 OpenClaw 本地腳本

把一個家庭行程提醒從託管的 n8n 搬到本地 OpenClaw cron — 順手修了一個還沒爆的 bug。

2026-06-10 myps6415
$7.88 → $0
每月費用
10 → 1
n8n 節點 → Python 檔
+1
順手修掉的潛在 bug

背景

過去一年,我在 Zeabur 上養著一個 n8n workflow:family_schedule_alert。每天早上 6:00 抓 Google Calendar 當日的事件,篩出 summary 含有家人名字的,整理成一段易讀的內容,推到一個照護群成員都在的 LINE 群組。

當初選 n8n 是阻力最小的路:視覺化編輯器、內建 Google Calendar OAuth、Schedule trigger、HTTP node 打 LINE Push。十個 node、收工。Zeabur 跑容器、每月帳單寄來,6 點機器人準時報到。月費高峰是 USD $7.88

觸發點

2026 年中,我把 OpenClaw 跑在家裡的機器上已經一段時間 —— 長到能放心交付真正在做的工作,也長到開始意識到:Zeabur 上那個僅存的 n8n workflow 做的事,OpenClaw 本地早就做得到了。

當替代品就在同一張桌子上的時候,一個託管 workflow 撐不起每月 $7.88 的 line item。搬。

Case study:家人每日行程提醒

原本的 n8n graph 有十個 node:

  • Schedule Trigger — 06:00 觸發
  • Google Calendar — 抓 $today$today.plus(1, "day") 之間的所有事件
  • If_keyword_in_summary — 用 summary contains <家人名>
  • daily_schedule_list — Set node,投影欄位
  • if_location_undefined + add_location + Merge — 沒有地點就補「未標示」
  • Aggregate — 把所有事件收成一個 payload
  • Code — Python code node,格式化成一段 LINE 友善的文字
  • line_push — HTTP POST 打 LINE Push API

視覺化 graph 截圖很漂亮,但每個 node 實際上都只是包了兩三行真實邏輯的薄殼,外加散在六七個 JSON 設定面板裡的 n8n 專屬 expression 語法。真正的 程式碼住在 Python Code node 裡 —— 視覺抽象到那邊就放棄了,剩下你還是要手寫字串模板拼 Python。

OpenClaw 重寫版

一個檔案、178 行、純 Python stdlib —— 沒有 n8n、沒有 node_modules、沒有第三方套件。實際在做的三件事是 token refresh、calendar fetch、LINE push:

def get_access_token():
  with open(TOKEN_PATH) as f:
      creds = json.load(f)
  body = urllib.parse.urlencode({
      "client_id": creds["client_id"],
      "client_secret": creds["client_secret"],
      "refresh_token": creds["refresh_token"],
      "grant_type": "refresh_token",
  }).encode("utf-8")
  req = urllib.request.Request(
      creds["token_uri"], data=body,
      headers={"Content-Type": "application/x-www-form-urlencoded"},
  )
  with urllib.request.urlopen(req, timeout=30) as resp:
      return json.loads(resp.read().decode("utf-8"))["access_token"]


def fetch_today_events(access_token):
  now_tw = dt.datetime.now(TZ)
  today_start = now_tw.replace(hour=0, minute=0, second=0, microsecond=0)
  today_end = today_start + dt.timedelta(days=1)
  params = {
      "timeMin": today_start.isoformat(),
      "timeMax": today_end.isoformat(),
      "q": KEYWORD,           # server-side prefilter
      "singleEvents": "true",
      "orderBy": "startTime",
  }
  url = (f"https://www.googleapis.com/calendar/v3/calendars/"
         f"{urllib.parse.quote(CALENDAR_ID)}/events"
         f"?{urllib.parse.urlencode(params)}")
  req = urllib.request.Request(
      url, headers={"Authorization": f"Bearer {access_token}"}
  )
  with urllib.request.urlopen(req, timeout=30) as resp:
      return json.loads(resp.read().decode("utf-8")).get("items", [])
      ~/.openclaw/scripts/family_schedule_alert.py — 關鍵段落
    

OAuth 拆成兩支腳本。setup_google_oauth.py 跑一次:開瀏覽器、用本機 loopback 接 auth code、換 refresh token、把結果寫到 ~/.openclaw/credentials/google_oauth_token.json (權限 0600)。之後每天的腳本只用 refresh token (不會過期),每次跑都換一個新的 access token。

順手修掉的潛在 bug

原本 n8n Set node 是這樣投影時間欄位的:

格式化事件的起 / 迄時間
- date = $json.start.dateTime.toDateTime().format('yyyy-MM-dd')
- start = $json.start.dateTime.toDateTime().format('HH:mm:ss')
- end = $json.end.dateTime.toDateTime().format('HH:mm:ss')
+ if 'dateTime' in start:
+ start_dt = dt.datetime.fromisoformat(start['dateTime']).astimezone(TZ)
+ date_s = start_dt.strftime('%Y-%m-%d')
+ start_s = start_dt.strftime('%H:%M:%S')
+ end_s = ...
+ else: # all-day event
+ date_s = start.get('date', '')
+ start_s = end_s = '全日'

Google Calendar 對全日事件回傳的是 start.date,不是 start.dateTime。n8n 版本在第一次有人把家人的全日事件加進行事曆的當下就會炸。它從來沒炸 —— 是因為實務上還沒人這樣加而已。重寫的時候順手就修了,這種「閱讀時發現、不是 06:01 在 debug 時發現」的修法是最好的修法。

OpenClaw cron + agentTurn

OpenClaw 的 cron 不是 crontab 的薄殼。每個 job 的 payload 是 agentTurn —— 排程到時喚醒一個 agent、丟一段 prompt 給它、讓 agent 去做、最後把 agent 的回覆當成這次 run 的 summary。這個 job 的 prompt 故意寫得很直白:

{
"id": "86ce0520-afc8-47c7-a47f-7681fc6cba6f",
"name": "每日行程提醒",
"schedule": { "kind": "cron", "expr": "0 6 * * *", "tz": "Asia/Taipei" },
"agentId": "main",
"payload": {
  "kind": "agentTurn",
  "message": "Run this bash command and report nothing else: /usr/bin/python3 /Users/fish/.openclaw/scripts/family_schedule_alert.py",
  "timeoutSeconds": 60
},
"delivery": { "mode": "announce", "channel": "telegram", "to": "971742907" }
}
      cron job 86ce0520-…-6cba6f payload
    

用 LLM agent 去執行一行 bash 確實有點殺雞用牛刀 —— 一次 fire 用掉 290 input / 145 output token 也不是免費的。但同一個排程框架除了跑這種笨 cron job,也跑那種 payload 寫著 「整理今天 Threads 的留言、有需要我注意的就跟我說」 的 reasoning job。一個 cron 介面、一套 delivery、一個歷史檢視器。讓 agent 去執行簡單情境的成本,就是不必另外跑第二套排程系統的代價。

這台機器上 OpenClaw 還在做什麼

搬一個 workflow 本身不真的值 $7.88;價值是把搬遷成本攤平在同一台機器上已經在跑的其他事情上。同一個 OpenClaw cron registry 還在跑:

  • 排程化的內容生產線 —— 數條主題垂直的多段式流程,每條各有自己的節奏。
  • 社群平台維運 —— 排程化的內容整理、報告,與免人工的憑證續期。
  • 市場 / 研究 —— 各自節奏的市場掃描與週期性研究報告。
  • 自我照護基礎設施 —— gateway 每 30 分鐘自我修復、記憶每小時備份、凌晨 3 點 memory-promote、每月 Daily Log 歸檔。
  • 以及,現在多了一個家庭行程提醒。

全部跑在同一台機器、同一個排程系統、運維狀態統一推到一個 Telegram chat。再塞一個同樣形狀的本地 job 來取代 n8n workflow,邊際成本基本上是 0。

心得

關於成本紀律

$7.88/月 是那種不會觸發警報的金額 —— 三杯咖啡、一個串流訂閱。但紀律就是要持續問「這筆錢買的是什麼、我還需要它嗎?」一旦答案是「一個我下午就能重寫的 workflow」,決定其實已經做完了。

關於視覺化 workflow 與腳本

十個 n8n node 在 canvas 上看起來架構感很強,但程式碼其實沒多少。一旦出現 Code node —— 那個放諸四海皆通的「我放棄視覺抽象」訊號 —— workflow 真正的邏輯都已經搬出 graph 了。Graph 是包裝;包裝才是你在付錢的東西。

關於框架攤平

一個 cron job 撐不起一套排程系統,二十個就可以。最好的遷移時機是:你已經為了其他理由付過框架成本,然後手上還剩一個僅靠單一 workflow 撐著的託管工具。