204 lines
5.5 KiB
JavaScript
204 lines
5.5 KiB
JavaScript
/**
|
||
* Notion API 客户端封装
|
||
* GH-GMP-005 · M1 · Notion Sync Layer
|
||
*
|
||
* 封装 @notionhq/client,增加重试、退避、日志
|
||
* Token 从环境变量 GH_NOTION_TOKEN 读取
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { Client } = require('@notionhq/client');
|
||
|
||
const MAX_RETRIES = 3;
|
||
const BASE_DELAY_MS = 1000;
|
||
const MAX_DELAY_MS = 30000;
|
||
const REQUEST_TIMEOUT_MS = 30000;
|
||
|
||
class NotionSyncClient {
|
||
/**
|
||
* @param {object} opts
|
||
* @param {string} [opts.token] - Notion API token, defaults to env GH_NOTION_TOKEN
|
||
* @param {object} [opts.logger] - Logger with info/warn/error methods
|
||
*/
|
||
constructor(opts = {}) {
|
||
const token = opts.token || process.env.GH_NOTION_TOKEN;
|
||
if (!token) {
|
||
throw new Error('[notion-sync/client] GH_NOTION_TOKEN 未配置');
|
||
}
|
||
this.client = new Client({
|
||
auth: token,
|
||
timeoutMs: REQUEST_TIMEOUT_MS,
|
||
});
|
||
this.logger = opts.logger || console;
|
||
this._requestCount = 0;
|
||
this._errorCount = 0;
|
||
}
|
||
|
||
// ─── 数据库操作 ───
|
||
|
||
/**
|
||
* 查询数据库
|
||
* @param {string} databaseId
|
||
* @param {object} [filter]
|
||
* @param {Array} [sorts]
|
||
* @param {string} [startCursor]
|
||
* @returns {Promise<{results: Array, hasMore: boolean, nextCursor: string|null}>}
|
||
*/
|
||
async queryDatabase(databaseId, filter, sorts, startCursor) {
|
||
const params = { database_id: databaseId };
|
||
if (filter) params.filter = filter;
|
||
if (sorts) params.sorts = sorts;
|
||
if (startCursor) params.start_cursor = startCursor;
|
||
const resp = await this._retry(() => this.client.databases.query(params));
|
||
return {
|
||
results: resp.results,
|
||
hasMore: resp.has_more,
|
||
nextCursor: resp.next_cursor,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 查询数据库全部结果(自动分页)
|
||
*/
|
||
async queryDatabaseAll(databaseId, filter, sorts) {
|
||
const all = [];
|
||
let cursor = undefined;
|
||
do {
|
||
const { results, hasMore, nextCursor } = await this.queryDatabase(
|
||
databaseId, filter, sorts, cursor
|
||
);
|
||
all.push(...results);
|
||
cursor = hasMore ? nextCursor : undefined;
|
||
} while (cursor);
|
||
return all;
|
||
}
|
||
|
||
// ─── 页面操作 ───
|
||
|
||
async getPage(pageId) {
|
||
return this._retry(() => this.client.pages.retrieve({ page_id: pageId }));
|
||
}
|
||
|
||
async updatePage(pageId, properties) {
|
||
return this._retry(() =>
|
||
this.client.pages.update({ page_id: pageId, properties })
|
||
);
|
||
}
|
||
|
||
// ─── 块操作(页面内容读写)───
|
||
|
||
/**
|
||
* 读取页面所有子块(自动分页)
|
||
*/
|
||
async getBlockChildren(blockId) {
|
||
const all = [];
|
||
let cursor = undefined;
|
||
do {
|
||
const resp = await this._retry(() =>
|
||
this.client.blocks.children.list({
|
||
block_id: blockId,
|
||
start_cursor: cursor,
|
||
page_size: 100,
|
||
})
|
||
);
|
||
all.push(...resp.results);
|
||
cursor = resp.has_more ? resp.next_cursor : undefined;
|
||
} while (cursor);
|
||
return all;
|
||
}
|
||
|
||
/**
|
||
* 追加子块到页面末尾
|
||
* @param {string} blockId
|
||
* @param {Array} children - Notion block objects
|
||
*/
|
||
async appendBlockChildren(blockId, children) {
|
||
// Notion API 每次最多100个块
|
||
const BATCH = 100;
|
||
for (let i = 0; i < children.length; i += BATCH) {
|
||
const batch = children.slice(i, i + BATCH);
|
||
await this._retry(() =>
|
||
this.client.blocks.children.append({
|
||
block_id: blockId,
|
||
children: batch,
|
||
})
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── 搜索 ───
|
||
|
||
async search(query, filter) {
|
||
const params = {};
|
||
if (query) params.query = query;
|
||
if (filter) params.filter = filter;
|
||
return this._retry(() => this.client.search(params));
|
||
}
|
||
|
||
// ─── 健康检查 ───
|
||
|
||
async healthCheck() {
|
||
try {
|
||
// 用 search 空查询验证 token 有效性
|
||
await this.client.search({ query: '', page_size: 1 });
|
||
return { ok: true, requestCount: this._requestCount, errorCount: this._errorCount };
|
||
} catch (err) {
|
||
return { ok: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
// ─── 内部:重试逻辑 ───
|
||
|
||
async _retry(fn) {
|
||
let lastErr;
|
||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||
try {
|
||
this._requestCount++;
|
||
return await fn();
|
||
} catch (err) {
|
||
lastErr = err;
|
||
this._errorCount++;
|
||
const status = err.status || err.code;
|
||
|
||
// 429 Rate Limit → 指数退避重试
|
||
if (status === 429 || status === 'ECONNRESET' || status === 'ETIMEDOUT') {
|
||
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt), MAX_DELAY_MS);
|
||
const retryAfter = err.headers && err.headers['retry-after']
|
||
? parseInt(err.headers['retry-after'], 10) * 1000
|
||
: delay;
|
||
this.logger.warn(
|
||
`[notion-sync/client] ${status} · 重试 ${attempt + 1}/${MAX_RETRIES} · 等待 ${retryAfter}ms`
|
||
);
|
||
await this._sleep(retryAfter);
|
||
continue;
|
||
}
|
||
|
||
// 5xx → 重试
|
||
if (status >= 500 && status < 600) {
|
||
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
|
||
this.logger.warn(
|
||
`[notion-sync/client] ${status} · 重试 ${attempt + 1}/${MAX_RETRIES} · 等待 ${delay}ms`
|
||
);
|
||
await this._sleep(delay);
|
||
continue;
|
||
}
|
||
|
||
// 4xx 非429 → 不重试
|
||
throw err;
|
||
}
|
||
}
|
||
throw lastErr;
|
||
}
|
||
|
||
_sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
get stats() {
|
||
return { requestCount: this._requestCount, errorCount: this._errorCount };
|
||
}
|
||
}
|
||
|
||
module.exports = NotionSyncClient;
|