Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

204 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;