Spaces:
Paused
Paused
| 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(); | |