MapleCheng

在浩瀚的網路世界中無限潛水欸少年郎!

0%

Notion API 雙 Workspace 踩坑記:Token 搞混、批量操作與去重實戰

最近在做一個自動化流程,需要同時串接兩個 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
2
3
4
5
6
{
"object": "error",
"status": 404,
"code": "object_not_found",
"message": "Could not find database with ID: 7e22fe15-xxxx-xxxx-xxxx-xxxxxxxxxxxx."
}

就這個錯誤,反覆出現。我甚至開始懷疑是不是 Notion API 本身有問題,去查了他們的 status page。

最後是因為拿同一個 token 去打 Workspace B 的 database 時成功了,我才驚覺:「等等,這個 token 怎麼能打到 B?它應該是 A 的 token 啊……」然後 cat 了一下 token 檔案,果然,兩個檔案內容一模一樣。

整個 debug 過程大概花了快一個小時。回頭看覺得很蠢,但當下真的完全沒往這個方向想。因為你理所當然覺得兩個不同路徑的檔案內容不一樣嘛,腦子根本不會去質疑這件事。

教訓與防護措施

token 檔案的命名一定要明確。不要用什麼 notion-tokentoken-1token-2 這種模糊的名字。我後來改成了:

  • notion-personal-token — 個人 workspace
  • notion-company-token — 公司 workspace

而且在程式碼裡,我會在初始化 client 的時候加上 workspace 名稱的註解和 log:

1
2
3
4
5
6
7
// Personal workspace client
const personalClient = new Client({ auth: personalToken });
console.log('Initialized personal workspace client');

// Company workspace client
const companyClient = new Client({ auth: companyToken });
console.log('Initialized company workspace client');

後來我還加了一個啟動時的 sanity check——用每個 token 打一個已知存在的 page,確認能正常存取。如果 check 失敗就直接 throw error 不讓程式繼續跑,避免用錯 token 又不知道:

1
2
3
4
5
6
7
8
async function validateToken(client, testPageId, label) {
try {
await client.pages.retrieve({ page_id: testPageId });
console.log(`✅ ${label} token validated`);
} catch (err) {
throw new Error(`❌ ${label} token validation failed: ${err.message}`);
}
}

這個小小的 check 後來不只一次救了我。

踩坑 2:批量建立重複資料——36 筆幽靈日報

第二個坑跟批量操作有關。

需求很簡單:用腳本批量建立 28 天的日報紀錄到 Notion database。每天一筆,跑個迴圈就搞定了。

腳本寫好、測試通過,正式跑的時候前面十幾筆都很順利,結果跑到一半 timeout 了。我看了一下,大概建了 15 筆就斷掉。

正常人的反應:重跑一次。

但問題來了——腳本沒有做 idempotency 檢查。它不會去看「這個日期的紀錄是不是已經存在了」,它就是無腦 create。所以重跑之後,前 15 天的日報就各多了一筆。

更慘的是,第二次跑到第 20 筆又 timeout 了(Notion API 在短時間大量 create 時有 rate limit),我又重跑了第三次。最後的結果:28 天的日報,居然產生了 64 筆紀錄,其中 36 筆是重複的,有些日期甚至有 3 筆。

看著 database 裡一堆重複的紀錄,我只能苦笑。

去重策略

手動刪太蠢了,我寫了一個去重腳本:

  1. Query 所有紀錄:用 Notion API 的 database query,把所有日報都拉下來
  2. 按日期分組:用 Date 欄位的值做 grouping
  3. 每組保留最早的一筆:比對 created_time,保留最早建立的那筆
  4. Archive 其餘的:Notion API 沒有 DELETE endpoint,要「刪除」一筆紀錄只能用 archive

Archive 的做法是對 page 做 PATCH,把 archived 設成 true

1
2
3
4
await client.pages.update({
page_id: pageId,
archived: true
});

這樣紀錄不會真的消失,在 Notion 介面裡還是可以找到(在垃圾桶裡),但不會出現在 database view 裡了。

整個去重腳本大概 30 行,跑完之後從 64 筆降回正確的 28 筆。但這個過程讓我學到:批量寫入一定要做 idempotency。最簡單的做法就是在 create 之前先 query 一下那個日期有沒有紀錄了,有就 skip。

防止未來再犯

除了加 idempotency check,我後來也加了 rate limit 的處理。Notion API 的 rate limit 是每秒大約 3 次請求,超過的話會回 HTTP 429。我的做法是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function rateLimitedCreate(client, params, delayMs = 350) {
try {
const result = await client.pages.create(params);
await sleep(delayMs); // 每次請求之間固定等待
return result;
} catch (err) {
if (err.status === 429) {
const retryAfter = err.headers?.['retry-after'] || 1;
console.log(`Rate limited, waiting ${retryAfter}s...`);
await sleep(retryAfter * 1000);
return rateLimitedCreate(client, params, delayMs); // retry
}
throw err;
}
}

每次請求之間加 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function queryAll(client, databaseId, filter) {
let results = [];
let cursor = undefined;

do {
const response = await client.databases.query({
database_id: databaseId,
filter: filter,
start_cursor: cursor,
page_size: 100
});
results.push(...response.results);
cursor = response.has_more ? response.next_cursor : undefined;
} while (cursor);

return results;
}

不管你的 database 現在有幾筆,先把 pagination 做好。這是一個「現在不做,以後一定會後悔」的事。

Notion API 實用技巧分享

踩完坑之後,整理一些實際用下來覺得值得記的技巧:

filter + sorts 做精準查詢

Notion API 的 database query 支援很彈性的 filter 和 sort。比如要查某個日期的所有紀錄:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const response = await client.databases.query({
database_id: dbId,
filter: {
property: '日期',
date: {
equals: '2026-01-15'
}
},
sorts: [
{
timestamp: 'created_time',
direction: 'ascending'
}
]
});

這個 filter 系統支援 andor 巢狀,可以組合出很複雜的查詢條件。比如「找出本週未完成且優先級為高的任務」:

1
2
3
4
5
6
7
filter: {
and: [
{ property: '狀態', select: { does_not_equal: '完成' } },
{ property: '優先級', select: { equals: '高' } },
{ property: '日期', date: { on_or_after: '2026-01-27' } }
]
}

rich_text 的 content 要從 array 取

Notion 的 rich_text 型別欄位回傳的不是一個字串,而是一個 array。要拿到文字內容,得這樣取:

1
2
const title = page.properties['Name'].title[0]?.plain_text ?? '';
const note = page.properties['備註'].rich_text[0]?.plain_text ?? '';

注意那個 [0],一開始很容易忘記,然後就會拿到 undefined

更要注意的是,如果文字裡面有混合格式(比如部分粗體、部分有連結),rich_text array 就會有多個元素。這時候你需要把所有元素的 plain_text 拼起來才能拿到完整內容:

1
2
3
const fullText = page.properties['備註'].rich_text
.map(rt => rt.plain_text)
.join('');

Date 欄位支援時間區段

Date 欄位可以同時設 startend,適合需要記錄時間區段的場景(像是工時紀錄):

1
2
3
4
5
6
7
8
properties: {
'工作時段': {
date: {
start: '2026-01-15T09:00:00+08:00',
end: '2026-01-15T18:00:00+08:00'
}
}
}

這裡要特別注意時區。如果你省略時區後綴(比如只寫 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
await client.blocks.children.append({
block_id: pageId,
children: [
{
object: 'block',
type: 'numbered_list_item',
numbered_list_item: {
rich_text: [{ type: 'text', text: { content: '第一項工作' } }]
}
},
{
object: 'block',
type: 'numbered_list_item',
numbered_list_item: {
rich_text: [{ type: 'text', text: { content: '第二項工作' } }]
}
}
]
});

寫起來有點冗長,建議自己封裝一個 helper function 來簡化。我自己封裝了一個 makeListItem(text) 就省事多了。

跨 Workspace 整合模式

回到最開始的需求:同時操作兩個 workspace

前面提過,一個 token 只能打一個 workspace,所以程式裡必須維護兩組 client。這個情境其實蠻常見的——比如我需要從個人 workspace 讀取今天的任務清單,然後把摘要寫入公司 workspace 的日報。

我的做法是封裝成明確的 helper function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function readPersonalTasks(date) {
// 使用 personalClient,只讀個人 workspace
return await personalClient.databases.query({
database_id: PERSONAL_TASKS_DB,
filter: { /* ... */ }
});
}

async function writeCompanyReport(date, content) {
// 使用 companyClient,只寫公司 workspace
return await companyClient.pages.create({
parent: { database_id: COMPANY_REPORTS_DB },
properties: { /* ... */ }
});
}

關鍵是:function 的名稱要明確標示它操作的是哪個 workspace。不要寫成 readTaskswriteReport,這樣三個月後回來看 code 會搞不清楚是打哪邊。

另外一個建議是把兩組 token 的初始化放在同一個地方,方便管理:

1
2
3
4
5
6
7
8
9
10
const config = {
personal: {
token: readToken('notion-personal-token'),
databases: { tasks: 'xxx-xxx-xxx' }
},
company: {
token: readToken('notion-company-token'),
databases: { reports: 'yyy-yyy-yyy', timesheet: 'zzz-zzz-zzz' }
}
};

這樣即使以後要加第三個 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,把函式命名和設定管理做好就不會亂

這些坑每一個都不難解,但都是那種「沒遇到過就完全想不到」的問題。如果這篇能幫你少踩一兩個坑,那我的血淚就沒白流了 😄