最近在做一個自動化流程,需要同時串接兩個 Notion workspace——一個是我自己的個人任務管理,另一個是公司的專案管理。聽起來很單純對吧?兩個 API token、兩個 database,各打各的就好。結果實際做下去,踩了好幾個坑,有些還蠻痛的。這篇就來記錄一下整個過程,希望能幫到有類似需求的人。
先搞懂 Notion Integration 的 Workspace 隔離
在開始之前,有一個很重要的觀念要先建立:Notion 的每個 Integration token 只能存取「建立它的那個 workspace」。這不是 bug,是 by design。
什麼意思呢?假設你有 Workspace A(個人)和 Workspace B(公司),你在 A 建立了一個 Integration 拿到 token-A,在 B 建立了另一個 Integration 拿到 token-B。那麼 token-A 就只能操作 A 裡面的 database 和 page,拿它去打 B 的任何資源,都會得到 object_not_found 或權限錯誤。
這件事文件上有寫,但實際操作時很容易忘記,尤其是當你的程式碼同時處理兩邊的時候。更容易犯的錯是:你以為 object_not_found 代表 database ID 錯了或是權限沒開,但其實是 token 拿錯了。這個誤判會讓你在錯誤的方向上浪費大量 debug 時間。
另外值得一提的是,每個 Integration 建立之後,還需要在 Notion 介面裡手動把 database 或 page 「連接」到這個 Integration。就算 token 是對的,如果 database 沒有加這個 Integration 的存取權限,API 一樣會報 object_not_found。這個步驟超級容易忘記,我每次新增一個 database 都至少忘過一次。
踩坑 1:Token 覆蓋事件——Debug 到懷疑人生
這個坑是最痛的一個。
事情是這樣的:我本來有兩個 token 檔案,分別存在不同路徑。某天在調整自動化腳本的時候,一個手滑,把 Workspace A(個人)的 token 檔案內容覆蓋成了 Workspace B(公司)的 token。
然後災難就開始了。
所有對 Workspace A 的 API 呼叫都回 object_not_found。我第一反應是去檢查 database ID 有沒有貼錯,結果 ID 完全正確。接著我懷疑是不是 database 的 Integration 權限沒開,跑去 Notion 介面裡確認,也是開著的。
1 | { |
就這個錯誤,反覆出現。我甚至開始懷疑是不是 Notion API 本身有問題,去查了他們的 status page。
最後是因為拿同一個 token 去打 Workspace B 的 database 時成功了,我才驚覺:「等等,這個 token 怎麼能打到 B?它應該是 A 的 token 啊……」然後 cat 了一下 token 檔案,果然,兩個檔案內容一模一樣。
整個 debug 過程大概花了快一個小時。回頭看覺得很蠢,但當下真的完全沒往這個方向想。因為你理所當然覺得兩個不同路徑的檔案內容不一樣嘛,腦子根本不會去質疑這件事。
教訓與防護措施
token 檔案的命名一定要明確。不要用什麼 notion-token、token-1、token-2 這種模糊的名字。我後來改成了:
notion-personal-token— 個人 workspacenotion-company-token— 公司 workspace
而且在程式碼裡,我會在初始化 client 的時候加上 workspace 名稱的註解和 log:
1 | // Personal workspace client |
後來我還加了一個啟動時的 sanity check——用每個 token 打一個已知存在的 page,確認能正常存取。如果 check 失敗就直接 throw error 不讓程式繼續跑,避免用錯 token 又不知道:
1 | async function validateToken(client, testPageId, label) { |
這個小小的 check 後來不只一次救了我。
踩坑 2:批量建立重複資料——36 筆幽靈日報
第二個坑跟批量操作有關。
需求很簡單:用腳本批量建立 28 天的日報紀錄到 Notion database。每天一筆,跑個迴圈就搞定了。
腳本寫好、測試通過,正式跑的時候前面十幾筆都很順利,結果跑到一半 timeout 了。我看了一下,大概建了 15 筆就斷掉。
正常人的反應:重跑一次。
但問題來了——腳本沒有做 idempotency 檢查。它不會去看「這個日期的紀錄是不是已經存在了」,它就是無腦 create。所以重跑之後,前 15 天的日報就各多了一筆。
更慘的是,第二次跑到第 20 筆又 timeout 了(Notion API 在短時間大量 create 時有 rate limit),我又重跑了第三次。最後的結果:28 天的日報,居然產生了 64 筆紀錄,其中 36 筆是重複的,有些日期甚至有 3 筆。
看著 database 裡一堆重複的紀錄,我只能苦笑。
去重策略
手動刪太蠢了,我寫了一個去重腳本:
- Query 所有紀錄:用 Notion API 的 database query,把所有日報都拉下來
- 按日期分組:用 Date 欄位的值做 grouping
- 每組保留最早的一筆:比對
created_time,保留最早建立的那筆 - Archive 其餘的:Notion API 沒有
DELETEendpoint,要「刪除」一筆紀錄只能用 archive
Archive 的做法是對 page 做 PATCH,把 archived 設成 true:
1 | await client.pages.update({ |
這樣紀錄不會真的消失,在 Notion 介面裡還是可以找到(在垃圾桶裡),但不會出現在 database view 裡了。
整個去重腳本大概 30 行,跑完之後從 64 筆降回正確的 28 筆。但這個過程讓我學到:批量寫入一定要做 idempotency。最簡單的做法就是在 create 之前先 query 一下那個日期有沒有紀錄了,有就 skip。
防止未來再犯
除了加 idempotency check,我後來也加了 rate limit 的處理。Notion API 的 rate limit 是每秒大約 3 次請求,超過的話會回 HTTP 429。我的做法是:
1 | async function rateLimitedCreate(client, params, delayMs = 350) { |
每次請求之間加 350ms 的間隔,加上 429 的 retry 機制,之後就沒再遇過 timeout 的問題了。
踩坑 3:Pagination 的隱形陷阱
這個坑比較隱晦。Notion API 的 database query 預設每次最多回傳 100 筆結果。如果你的 database 超過 100 筆,就需要做 pagination——用 start_cursor 參數去拿下一頁。
問題是:一開始你的 database 可能只有二三十筆,query 一次就拿完了,所以你的程式碼根本沒做 pagination。等到有一天 database 長到 100 筆以上,程式開始漏資料,而你可能要過好一陣子才會發現。
我就是這樣中招的。我的工時紀錄 database 在前兩個月都正常,第三個月某天突然發現統計的工時跟實際不符。查了半天才發現是 query 只拿了前 100 筆,後面的全部漏掉了。
正確的做法是從一開始就把 pagination 寫進去:
1 | async function queryAll(client, databaseId, filter) { |
不管你的 database 現在有幾筆,先把 pagination 做好。這是一個「現在不做,以後一定會後悔」的事。
Notion API 實用技巧分享
踩完坑之後,整理一些實際用下來覺得值得記的技巧:
filter + sorts 做精準查詢
Notion API 的 database query 支援很彈性的 filter 和 sort。比如要查某個日期的所有紀錄:
1 | const response = await client.databases.query({ |
這個 filter 系統支援 and、or 巢狀,可以組合出很複雜的查詢條件。比如「找出本週未完成且優先級為高的任務」:
1 | filter: { |
rich_text 的 content 要從 array 取
Notion 的 rich_text 型別欄位回傳的不是一個字串,而是一個 array。要拿到文字內容,得這樣取:
1 | const title = page.properties['Name'].title[0]?.plain_text ?? ''; |
注意那個 [0],一開始很容易忘記,然後就會拿到 undefined。
更要注意的是,如果文字裡面有混合格式(比如部分粗體、部分有連結),rich_text array 就會有多個元素。這時候你需要把所有元素的 plain_text 拼起來才能拿到完整內容:
1 | const fullText = page.properties['備註'].rich_text |
Date 欄位支援時間區段
Date 欄位可以同時設 start 和 end,適合需要記錄時間區段的場景(像是工時紀錄):
1 | properties: { |
這裡要特別注意時區。如果你省略時區後綴(比如只寫 2026-01-15T09:00:00),Notion 會用 UTC 來解讀。結果你以為設的是早上九點,實際存進去的是台灣時間下午五點。我在這裡也踩過一次坑。
created_by 不是你以為的那個人
用 API 建立的 page,created_by 回傳的是 Integration bot 的 ID,不是你這個使用者的 ID。這在做某些邏輯判斷的時候要注意,不能用 created_by 來判斷「是誰建的」,因為透過 API 建的全部都是同一個 bot。
如果你需要追蹤「是誰觸發了這筆紀錄的建立」,建議自己多加一個 People 或 Rich Text 欄位來記錄。
建立 block children 做列表
要在 page 裡面加入列表,可以用 append block children:
1 | await client.blocks.children.append({ |
寫起來有點冗長,建議自己封裝一個 helper function 來簡化。我自己封裝了一個 makeListItem(text) 就省事多了。
跨 Workspace 整合模式
回到最開始的需求:同時操作兩個 workspace。
前面提過,一個 token 只能打一個 workspace,所以程式裡必須維護兩組 client。這個情境其實蠻常見的——比如我需要從個人 workspace 讀取今天的任務清單,然後把摘要寫入公司 workspace 的日報。
我的做法是封裝成明確的 helper function:
1 | async function readPersonalTasks(date) { |
關鍵是:function 的名稱要明確標示它操作的是哪個 workspace。不要寫成 readTasks 和 writeReport,這樣三個月後回來看 code 會搞不清楚是打哪邊。
另外一個建議是把兩組 token 的初始化放在同一個地方,方便管理:
1 | const config = { |
這樣即使以後要加第三個 workspace(比如某個客戶的),架構也能很自然地擴展。
小結
Notion API 的文件看起來蠻清楚的,endpoint 不多,概念也不複雜。但實際整合的時候,邊界條件比想像中多很多——尤其是多 workspace 的場景。
總結幾個重點:
- Token 檔案命名要明確到讓你不可能搞混,加上啟動時的 sanity check
- 批量寫入一定要做 idempotency 檢查,順便處理好 rate limit
- 從第一天就實作 pagination,不要等到 database 超過 100 筆才補
- Notion 沒有 DELETE,只有 archive,心態要調整
rich_text是 array,created_by是 bot ID,Date 要注意時區——這些小細節不注意就會 debug 很久- 跨 workspace 整合就是維護多組 client,把函式命名和設定管理做好就不會亂
這些坑每一個都不難解,但都是那種「沒遇到過就完全想不到」的問題。如果這篇能幫你少踩一兩個坑,那我的血淚就沒白流了 😄