在之前的文章 Docker 自建 n8n 並且連結上 Google OAuth Credential 中主要透過大家熟悉的 Google Sheet 作為 Trigger 工具,在許多個人工作以及商用上已經解決了非常多的問題。
然而當如果我們情境做的比較複雜,要觸發 Google sheet 頻率增加時,你可能會跟我一樣發現到一個問題 - 為什麼有時候 Google Sheet 的觸發會失敗
n8n 原生 Google Sheet 原理
當我們去打開 n8n 上的 Google sheet trigger 節點,發現我們要設定的第一個參數其實是 Poll Times,從這邊我們其實大概就可以猜到了,n8n 的做法其實是在你所設定的條件下去定期去海巡 Google Sheet,把資料抓下來之後暫存,等到下一次 Poll 再比對差異時再去觸發 workflow。
老實說我一開始覺得沒什麼問題,然而當我資料量變多時,有發現對於這個 Trigger 來說,精準度越來越低。或許是因為我的 Table 過於複雜,也可能是我在同一個 Workflow 中塞太多 Trigger 節點。總之就造成了我在的觸發 跟六脈神劍一樣 有時靈有時不靈,造成我每次進行表單 Trigger 之後,還要去監控 n8n 有沒有觸發成功,滿麻煩且令人焦躁的。
改變思維!透過 Event 來處發 Workflow
其實就跟 Routine 工作一樣,固定時間海巡碰到批次或者資料量大時,出錯頻率本身就會比較高,也對 n8n loading 較大,因此如果我們換一個思維 - 當 Google Sheet 指定的欄位變動為指定"值"時,發 Event 給 n8n ,如此一來就可以解除透過海巡方式來觸發 n8n 的問題。
經過 Survey 後其實不出意外,就是透過 Google Apps Script (GAS)這個萬用法寶來解決問題,以及順便加了一個小功能,讓我可以更快的追蹤我究竟有沒有成功觸發 n8n。
User Story 與 實作階段
來吧!開始進行我們的規劃,先來看看我們的 User Story
我是 活動的報名管理員
我想要 當我在 Google Sheet 上指定欄位寫上指定資料時,把這一欄的資料整理寄信給參加者
如此一來 省去手工時間
也就是說現在我一樣是在某個 Google Sheet 的 欄位,例如 “send_email”,當我寫入指定的資料例如 “send” 後,會把我整 row 的 data 送給 n8n 去執行,並且幫我把這一欄位改成 “Already sent”
實作包含以下:
- n8n 設置 Webhook 節點
- 撰寫 GAS 腳本
- 部署 GAS 與設定觸發
- n8n 資料設定新增 javascript 處理資料
1. n8n 設置 Webhook 節點
首先,先到你的 n8n workflow 上,新增一個節點叫做 Webhook,取代你的 Google Sheet 作為你的觸發節點。

進入節點設定,這裡主要有三個可以注意的地方:
- HTTP Method - 由於之後要透過 GAS 觸發,因此我們選擇
POST - Path - 這是設定你 Webhook 的 URL,你可以自己調整,但我覺得原生的亂數其實也滿不錯的,可以不改。
- Webhook URLs - 可以在節點最上方看到 URL 欄位,要注意他有區分為
Test URL以及Production URL,Test 主要是如果你現在要測試資料的話可以使用(例如現在用 Postman 打),但正式啟用時就只有Production URL可以使用,在這裡我們直接用 Production 版本。

接著把我們的 Production URL Copy 起來,就可以準備去設定我們的 GAS 了
https://your-n8n-domain/webhook/mypath
2. 撰寫 GAS 腳本
打開你的 Google Sheet,經由上方的擴充功能,進入 Google Apps Script,寫一份腳本寫下以下內容,目的是建立一個 function onEditHandler

function onEditHandler(e) {
try {
const sheet = e.source.getActiveSheet();
const sheetName = sheet.getName();
const range = e.range;
// 寫入你要監聽的欄位名稱
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const triggerColIndex = headers.indexOf("trigger") + 1;
const BATCH_SIZE = 50;
const MAX_TOTAL = 200;
// 填入 sheet 名稱以及 n8n Webhook
const config = {
"your_sheet_name": { webhook: "https://your-n8n-domain/webhook/mypath" }
};
if (!config[sheetName]) return;
const editedColStart = range.getColumn();
const editedColEnd = editedColStart + range.getNumColumns() - 1;
if (triggerColIndex < editedColStart || triggerColIndex > editedColEnd) {
return;
}
const editedValues = range.getValues();
const startRow = range.getRow();
const triggerOffset = triggerColIndex - editedColStart;
const allData = [];
const allRows = [];
editedValues.forEach((rowValue, idx) => {
const rowNumber = startRow + idx;
if (rowNumber === 1) return; // header 忽略
const triggerValue = rowValue[triggerOffset];
if (triggerValue !== "send") return; // 監聽哪一個 value
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const rowData = sheet.getRange(rowNumber, 1, 1, headers.length).getValues()[0];
const jsonData = { _row: rowNumber, _sheet: sheetName };
headers.forEach((h, i) => {
let value = rowData[i];
if (value instanceof Date) {
value = Utilities.formatDate(value, Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
}
jsonData[h] = value;
});
allData.push(jsonData);
allRows.push(rowNumber);
});
if (allData.length === 0) return;
if (allData.length > MAX_TOTAL) {
Logger.log(`Reach to data limit:${allData.length}/${MAX_TOTAL}`);
return;
}
const totalBatches = Math.ceil(allData.length / BATCH_SIZE);
let successCount = 0;
let failedBatches = [];
for (let i = 0; i < totalBatches; i++) {
const start = i * BATCH_SIZE;
const end = Math.min((i + 1) * BATCH_SIZE, allData.length);
const batchData = allData.slice(start, end);
const batchRows = allRows.slice(start, end);
try {
const payload = {
sheet: sheetName,
batch: i + 1,
totalBatches,
count: batchData.length,
data: batchData
};
const response = UrlFetchApp.fetch(config[sheetName].webhook, {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
if (response.getResponseCode() === 200) {
batchRows.forEach(row => {
sheet.getRange(row, triggerColIndex).setValue("Already sent"); // 傳送完成時,欄位 "send" 更新為 "Already sent"
});
successCount += batchData.length;
} else {
failedBatches.push(i + 1);
Logger.log(`Batch ${i + 1} failed: ${response.getResponseCode()} ${response.getContentText()}`);
}
if (i < totalBatches - 1) Utilities.sleep(500);
} catch (err) {
failedBatches.push(i + 1);
Logger.log(`Batch ${i + 1} error: ${err}`);
}
}
Logger.log(`Done. successCount=${successCount}, failedBatches=${failedBatches.join(",")}`);
} catch (error) {
Logger.log("onEditHandler error: " + error.toString());
}
}
大多要注意的我寫在備注了,整理的話要調整的如下:
- 你要監聽的欄位名稱,如 “trigger”
- 你要監聽的 Sheet 名稱,如 “your_sheet_name”
- 你的 n8n webhook url,如 “https://your-n8n-domain/webhook/mypath”
- 當 “trigger” 欄位監聽到什麼 value 時,會觸發 n8n webhook,我這裡用 “send” 作為觸發條件
- 觸發成功之後,欄位更新的 value,我這裡用 “Already sent” 作為更新完成後條件
完成後就儲存吧!
3. 部署 GAS 與設定觸發
再來是處理 GAS 的部署問題,有三個步驟要處理,這裡會需要大量授權你的 Google Account f分別為 Script 執行、Script 部署、設定觸發條件
Script 執行
Script 儲存之後,直接點擊上方的 執行 按鈕,基本上就是一次讓 你的 Google Account 許可執行腳本 的過程ㄑ

Script 部署
右上角新增部署,選 網頁應用程式 ,存取選 所有人(其實這裡選自己應該沒有差),部署下去就好了

設定觸發條件
從 GAS 最左側 Menu,進入 觸發條件 頁面,新增一個出發條件,主要設定可以參考以下圖片,最重要的是 選擇你的 functioon onEditHandler 以及 活動類型選編輯文件時,完成之後又會進入授權,最後就會看到成功設定的觸發了!

4. n8n 資料設定新增 javascript 處理資料
這一段我們回到 n8n 來說明 Webhook 送進來的資料內容,我把比較不重要的資料剔除後,會是長這樣:
{
"headers": {...},
"params": {},
"query": {},
"body": {
"sheet": "your_sheet_name",
"batch": 1,
"totalBatches": 1,
"count": 1,
"data": [
{
"_row": 2,
"_sheet": "your_sheet_name",
"columnA_name": "value",
"columnB_name": "value",
"columnC_name": "value",
"trigger": "send"
}
]
},
"webhookUrl": "https://your-n8n-domain/webhook/mypath",
"executionMode": "production"
}
由此可見我們真正要的資料內容其實是包在 body.data 並用一個 Array 來傳送資料,因此在 n8n 中我們要在 webhook 後透過一個 function 節點,把 Array 資料整理出來,往下執行。
建立一個 Code in Javascript 節點,設定如下
// 取得 webhook 回傳的資料
const items = $input.all();
const outputItems = [];
// 把 array 資料轉成獨立 item
for (const item of items) {
const dataArray = item.json.body?.data || [];
for (const record of dataArray) {
outputItems.push({
json: { // 這裡也可以篩選資料,把不要的拿掉
columnA_name: record["columnA_name"],
columnB_name: record["columnB_name"]
}
});
}
}
return outputItems;
這樣子就可以讓這個 javascript 產出我們真正可以使用的資料了,後續就可以使用以下類似的變數組合
{{ $json.columnA_name}}
{{ $json["columnA_name"]}}
{{ $('javascript').item.json.columnA_name }}
{{ $('javascript').item.json["columnA_name"] }}
完成!來測試資料吧
萬事俱備之後,你接下來就只要在你的 Google Sheet 的指定欄位,寫下你要變動的資料即可,就可以觸發 n8n webhook 了!
因為我的示範有一些機密資料,就不給圖了,這裡稍微點一下常見的問題:
- n8n webhook 忘記設定成
POST - GAS 內使用到 test-webhook URL
- GAS 任何一個步驟的權限沒有設定到(這部倒是真的很麻煩…)
- 成功觸發,但 n8n 內在 webhook 後的 javascript 節點沒有處理好資料
祝福大家測試順利
我是用 “send” 與 “Already sent” 來區分是否已經成功傳送過了
結語,為什麼我要做一個跟 Google Sheet Trigger 比起來更反直覺以及多步驟的內容
我會將 Google Sheet Trigger 轉為 GAS Webhook 觸發,其實都只是為了一件事
穩定性
今年我毛遂自薦把自己的系統推薦給一個韓國朋友,希望可以解決他活動報名上過多的手動操作,然而這個韓國活動實在是太熱門…資料的數量一定非常大,因此在測試過程中我就注意到了過往 Google Sheet Trigger 可能的風險,因此才修改了系統,犧牲方便但追求了穩定性。
總之,這個 Run 起來的效果比我想像的還好,也讓我對 GAS 的理解更深入了一些,未來可能會走向 GAS 的比例高一點,來取代相對不嚴謹或可能失誤的地方。