nodejs / src /services /NotionClient.js
clash-linux's picture
Upload 27 files
146bdba verified
import fetch from 'node-fetch';
import { JSDOM } from 'jsdom';
import { randomUUID } from 'crypto';
import { createLogger } from '../utils/logger.js';
import { config } from '../config/index.js';
import {
NotionTranscriptConfigValue,
NotionTranscriptContextValue,
NotionTranscriptItem,
NotionDebugOverrides,
NotionRequestBody,
NotionTranscriptItemByuser,
ChoiceDelta,
Choice,
ChatCompletionChunk
} from '../models.js';
import { proxyPool } from '../ProxyPool.js';
import { cookieManager } from '../CookieManager.js';
import { streamManager } from './StreamManager.js';
const logger = createLogger('NotionClient');
/**
* Notion API 客户端
* 封装与Notion API的所有交互逻辑
*/
export class NotionClient {
constructor() {
this.currentCookieData = null;
this.initialized = false;
}
/**
* 初始化客户端
*/
async initialize() {
logger.info('初始化Notion客户端...');
// 初始化cookie管理器
let initResult = false;
if (config.cookie.filePath) {
logger.info(`检测到COOKIE_FILE配置: ${config.cookie.filePath}`);
initResult = await cookieManager.loadFromFile(config.cookie.filePath);
if (!initResult) {
logger.error('从文件加载cookie失败,尝试使用环境变量中的NOTION_COOKIE');
}
}
if (!initResult) {
if (!config.cookie.envCookies) {
throw new Error('未设置NOTION_COOKIE环境变量或COOKIE_FILE路径');
}
logger.info('正在从环境变量初始化cookie管理器...');
initResult = await cookieManager.initialize(config.cookie.envCookies);
if (!initResult) {
throw new Error('初始化cookie管理器失败');
}
}
// 获取第一个可用的cookie数据
this.currentCookieData = cookieManager.getNext();
if (!this.currentCookieData) {
throw new Error('没有可用的cookie');
}
logger.success(`成功初始化cookie管理器,共有 ${cookieManager.getValidCount()} 个有效cookie`);
logger.info(`当前使用的cookie对应的用户ID: ${this.currentCookieData.userId}`);
logger.info(`当前使用的cookie对应的空间ID: ${this.currentCookieData.spaceId}`);
this.initialized = true;
}
/**
* 构建Notion请求
* @param {Object} requestData - OpenAI格式的请求数据
* @returns {NotionRequestBody} Notion格式的请求体
*/
buildRequest(requestData) {
// 确保有当前的cookie数据
if (!this.currentCookieData) {
this.currentCookieData = cookieManager.getNext();
if (!this.currentCookieData) {
throw new Error('没有可用的cookie');
}
}
const now = new Date();
const isoString = now.toISOString();
// 生成随机名称
const randomWords = ["Project", "Workspace", "Team", "Studio", "Lab", "Hub", "Zone", "Space"];
const userName = `User${Math.floor(Math.random() * 900) + 100}`;
const spaceName = `${randomWords[Math.floor(Math.random() * randomWords.length)]} ${Math.floor(Math.random() * 99) + 1}`;
const transcript = [];
// 添加配置项
const modelName = config.modelMapping[requestData.model] || requestData.model;
if (requestData.model === 'anthropic-sonnet-3.x-stable') {
transcript.push(new NotionTranscriptItem({
type: "config",
value: new NotionTranscriptConfigValue({})
}));
} else {
transcript.push(new NotionTranscriptItem({
type: "config",
value: new NotionTranscriptConfigValue({ model: modelName })
}));
}
// 添加上下文项
transcript.push(new NotionTranscriptItem({
type: "context",
value: new NotionTranscriptContextValue({
userId: this.currentCookieData.userId,
spaceId: this.currentCookieData.spaceId,
surface: "home_module",
timezone: "America/Los_Angeles",
userName: userName,
spaceName: spaceName,
spaceViewId: randomUUID(),
currentDatetime: isoString
})
}));
// 添加agent-integration项
transcript.push(new NotionTranscriptItem({
type: "agent-integration"
}));
// 添加消息
for (const message of requestData.messages) {
let content = this.normalizeMessageContent(message.content);
if (message.role === "system" || message.role === "user") {
transcript.push(new NotionTranscriptItemByuser({
type: "user",
value: [[content]],
userId: this.currentCookieData.userId,
createdAt: message.createdAt || isoString
}));
} else if (message.role === "assistant") {
transcript.push(new NotionTranscriptItem({
type: "markdown-chat",
value: content,
traceId: message.traceId || randomUUID(),
createdAt: message.createdAt || isoString
}));
}
}
// 构建基本请求体
const requestBodyData = {
spaceId: this.currentCookieData.spaceId,
transcript: transcript,
createThread: false,
traceId: randomUUID(),
debugOverrides: new NotionDebugOverrides({
cachedInferences: {},
annotationInferences: {},
emitInferences: false
}),
generateTitle: false,
saveAllThreadOperations: false
};
// 只有在有threadId时才添加相关字段
if (this.currentCookieData.threadId) {
requestBodyData.threadId = this.currentCookieData.threadId;
}
// 如果没有threadId,threadId字段不会被包含在请求体中
return new NotionRequestBody(requestBodyData);
}
/**
* 标准化消息内容
* @param {string|Array} content - 消息内容
* @returns {string} 标准化后的字符串内容
*/
normalizeMessageContent(content) {
if (Array.isArray(content)) {
let textContent = "";
for (const part of content) {
if (part && typeof part === 'object' && part.type === 'text') {
if (typeof part.text === 'string') {
textContent += part.text;
}
}
}
return textContent || "";
} else if (typeof content !== 'string') {
return "";
}
return content;
}
/**
* 创建流式响应
* @param {NotionRequestBody} notionRequestBody - Notion请求体
* @returns {Promise<Stream>} 响应流
*/
async createStream(notionRequestBody) {
// 确保有当前的cookie数据
if (!this.currentCookieData) {
this.currentCookieData = cookieManager.getNext();
if (!this.currentCookieData) {
throw new Error('没有可用的cookie');
}
}
// 创建流
const stream = streamManager.createStream();
// 添加初始数据,确保连接建立
stream.write(':\n\n');
// 设置HTTP头
const headers = this.buildHeaders();
// 设置超时处理
const timeoutId = setTimeout(() => {
if (stream.isClosed()) return;
logger.warning('请求超时,30秒内未收到响应');
this.sendErrorToStream(stream, '请求超时,未收到Notion响应。', 'timeout');
}, config.timeout.request);
// 启动fetch处理
this.fetchAndStream(
stream,
notionRequestBody,
headers,
this.currentCookieData.cookie,
timeoutId
).catch((error) => {
if (stream.isClosed()) return;
logger.error(`流处理出错: ${error.message}`, error);
clearTimeout(timeoutId);
this.sendErrorToStream(stream, `处理请求时出错: ${error.message}`, 'error');
});
return stream;
}
/**
* 构建请求头
* @returns {Object} HTTP请求头
*/
buildHeaders() {
return {
'Content-Type': 'application/json',
'accept': 'application/x-ndjson',
'accept-language': 'en-US,en;q=0.9',
'notion-audit-log-platform': 'web',
'notion-client-version': config.notion.clientVersion,
'origin': config.notion.origin,
'referer': config.notion.referer,
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
'x-notion-active-user-header': this.currentCookieData.userId,
'x-notion-space-id': this.currentCookieData.spaceId
};
}
/**
* 发送错误消息到流
* @param {Stream} stream - 目标流
* @param {string} message - 错误消息
* @param {string} finishReason - 结束原因
*/
sendErrorToStream(stream, message, finishReason) {
try {
const errorChunk = new ChatCompletionChunk({
choices: [
new Choice({
delta: new ChoiceDelta({ content: message }),
finish_reason: finishReason
})
]
});
streamManager.safeWrite(stream, `data: ${JSON.stringify(errorChunk)}\n\n`);
streamManager.safeWrite(stream, 'data: [DONE]\n\n');
} catch (e) {
logger.error(`发送错误消息时出错: ${e.message}`);
} finally {
if (!stream.isClosed()) stream.end();
}
}
/**
* 执行fetch请求并处理流式响应
*/
async fetchAndStream(stream, notionRequestBody, headers, notionCookie, timeoutId) {
let responseReceived = false;
let dom = null;
try {
// 创建JSDOM实例
dom = this.createDOMEnvironment();
// 设置cookie
dom.window.document.cookie = notionCookie;
// 创建fetch选项
const fetchOptions = await this.buildFetchOptions(headers, notionCookie, notionRequestBody);
// 发送请求
const response = await this.executeRequest(fetchOptions);
// 处理401错误
if (response.status === 401) {
await this.handle401Error(stream, notionRequestBody, headers, timeoutId);
return;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 处理流式响应
await this.processStreamResponse(response, stream, responseReceived, timeoutId);
} catch (error) {
logger.error(`Notion API请求失败: ${error.message}`, error);
if (timeoutId) clearTimeout(timeoutId);
if (!responseReceived && !stream.isClosed()) {
this.sendErrorToStream(stream, `Notion API请求失败: ${error.message}`, 'error');
}
throw error;
} finally {
// 清理DOM环境
this.cleanupDOMEnvironment();
if (dom) dom.window.close();
}
}
/**
* 创建DOM环境
*/
createDOMEnvironment() {
const dom = new JSDOM("", {
url: "https://www.notion.so",
referrer: "https://www.notion.so/chat",
contentType: "text/html",
includeNodeLocations: true,
storageQuota: 10000000,
pretendToBeVisual: true,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
});
const { window } = dom;
// 安全设置全局对象
try {
if (!global.window) global.window = window;
if (!global.document) global.document = window.document;
if (!global.navigator) {
Object.defineProperty(global, 'navigator', {
value: window.navigator,
writable: true,
configurable: true
});
}
} catch (error) {
logger.warning(`设置全局对象时出错: ${error.message}`);
}
return dom;
}
/**
* 清理DOM环境
*/
cleanupDOMEnvironment() {
try {
if (global.window) delete global.window;
if (global.document) delete global.document;
if (global.navigator) {
try {
delete global.navigator;
} catch (error) {
Object.defineProperty(global, 'navigator', {
value: undefined,
writable: true,
configurable: true
});
}
}
} catch (error) {
logger.warning(`清理全局对象时出错: ${error.message}`);
}
}
/**
* 构建fetch选项
*/
async buildFetchOptions(headers, notionCookie, notionRequestBody) {
const fetchOptions = {
method: 'POST',
headers: {
...headers,
'user-agent': global.window.navigator.userAgent,
'Cookie': notionCookie
},
body: JSON.stringify(notionRequestBody),
};
// 添加代理配置
if (config.proxy.useNativePool && !config.proxy.url) {
const proxy = proxyPool.getProxy();
if (proxy) {
logger.info(`使用代理: ${proxy.full}`);
if (!config.proxy.enableServer) {
const { HttpsProxyAgent } = await import('https-proxy-agent');
fetchOptions.agent = new HttpsProxyAgent(proxy.full);
}
fetchOptions.proxy = proxy;
}
} else if (config.proxy.url) {
logger.info(`使用代理: ${config.proxy.url}`);
if (!config.proxy.enableServer) {
const { HttpsProxyAgent } = await import('https-proxy-agent');
fetchOptions.agent = new HttpsProxyAgent(config.proxy.url);
}
fetchOptions.proxyUrl = config.proxy.url;
}
return fetchOptions;
}
/**
* 执行请求
*/
async executeRequest(fetchOptions) {
if (config.proxy.enableServer) {
const proxyRequest = {
method: 'POST',
url: config.notion.apiUrl,
headers: fetchOptions.headers,
body: fetchOptions.body,
stream: true
};
if (fetchOptions.proxy) {
proxyRequest.proxy = fetchOptions.proxy.full;
} else if (fetchOptions.proxyUrl) {
proxyRequest.proxy = fetchOptions.proxyUrl;
}
return await fetch(`http://127.0.0.1:${config.proxy.serverPort}/proxy`, {
method: 'POST',
body: JSON.stringify(proxyRequest)
});
}
return await fetch(config.notion.apiUrl, fetchOptions);
}
/**
* 处理401错误
*/
async handle401Error(stream, notionRequestBody, headers, timeoutId) {
logger.error('收到401未授权错误,cookie可能已失效');
cookieManager.markAsInvalid(this.currentCookieData.userId);
this.currentCookieData = cookieManager.getNext();
if (!this.currentCookieData) {
throw new Error('所有cookie均已失效,无法继续请求');
}
// 重新构建请求并重试
const newHeaders = {
...headers,
'x-notion-active-user-header': this.currentCookieData.userId,
'x-notion-space-id': this.currentCookieData.spaceId
};
return this.fetchAndStream(
stream,
notionRequestBody,
newHeaders,
this.currentCookieData.cookie,
timeoutId
);
}
/**
* 处理流式响应
*/
async processStreamResponse(response, stream, responseReceived, timeoutId) {
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body;
let buffer = '';
reader.on('data', (chunk) => {
if (stream.isClosed()) {
try {
reader.destroy();
} catch (error) {
logger.error(`销毁reader时出错: ${error.message}`);
}
return;
}
try {
if (!responseReceived) {
responseReceived = true;
logger.info('已连接Notion API');
clearTimeout(timeoutId);
}
const text = chunk.toString('utf8');
buffer += text;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const jsonData = JSON.parse(line);
if (jsonData?.type === "markdown-chat" && typeof jsonData?.value === "string") {
const content = jsonData.value;
if (!content) continue;
const chunk = new ChatCompletionChunk({
choices: [
new Choice({
delta: new ChoiceDelta({ content }),
finish_reason: null
})
]
});
const dataStr = `data: ${JSON.stringify(chunk)}\n\n`;
if (!streamManager.safeWrite(stream, dataStr)) {
try {
reader.destroy();
} catch (error) {
logger.error(`写入失败后销毁reader时出错: ${error.message}`);
}
return;
}
}
} catch (jsonError) {
logger.error(`解析JSON出错: ${jsonError.message}`);
}
}
} catch (error) {
logger.error(`处理数据块出错: ${error.message}`);
}
});
reader.on('end', () => {
try {
logger.info('响应完成');
if (cookieManager.getValidCount() > 1) {
this.currentCookieData = cookieManager.getNext();
logger.info(`切换到下一个cookie: ${this.currentCookieData.userId}`);
}
if (!responseReceived) {
this.handleNoContentResponse(stream);
}
this.sendEndChunk(stream);
if (timeoutId) clearTimeout(timeoutId);
if (!stream.isClosed()) stream.end();
} catch (error) {
logger.error(`处理流结束时出错: ${error.message}`);
if (timeoutId) clearTimeout(timeoutId);
if (!stream.isClosed()) stream.end();
}
});
reader.on('error', (error) => {
logger.error(`流错误: ${error.message}`);
if (timeoutId) clearTimeout(timeoutId);
this.sendErrorToStream(stream, `流读取错误: ${error.message}`, 'error');
});
}
/**
* 处理无内容响应
*/
handleNoContentResponse(stream) {
if (!config.proxy.enableServer) {
logger.warning('未从Notion收到内容响应,请尝试启用tls代理服务');
} else if (config.proxy.useNativePool) {
logger.warning('未从Notion收到内容响应,请重roll,或者切换cookie');
} else {
logger.warning('未从Notion收到内容响应,请更换ip重试');
}
const noContentChunk = new ChatCompletionChunk({
choices: [
new Choice({
delta: new ChoiceDelta({ content: "未从Notion收到内容响应,请更换ip重试。" }),
finish_reason: "no_content"
})
]
});
streamManager.safeWrite(stream, `data: ${JSON.stringify(noContentChunk)}\n\n`);
}
/**
* 发送结束块
*/
sendEndChunk(stream) {
const endChunk = new ChatCompletionChunk({
choices: [
new Choice({
delta: new ChoiceDelta({ content: null }),
finish_reason: "stop"
})
]
});
streamManager.safeWrite(stream, `data: ${JSON.stringify(endChunk)}\n\n`);
streamManager.safeWrite(stream, 'data: [DONE]\n\n');
}
/**
* 获取状态信息
*/
getStatus() {
return {
initialized: this.initialized,
validCookies: cookieManager.getValidCount(),
currentUserId: this.currentCookieData?.userId || null,
currentSpaceId: this.currentCookieData?.spaceId || null
};
}
}
// 创建全局NotionClient实例
export const notionClient = new NotionClient();