| | import fetch from 'node-fetch'; |
| | import { JSDOM } from 'jsdom'; |
| | import dotenv from 'dotenv'; |
| | import { randomUUID } from 'crypto'; |
| | import { fileURLToPath } from 'url'; |
| | import { dirname, join } from 'path'; |
| | import { PassThrough } from 'stream'; |
| | import chalk from 'chalk'; |
| | import { |
| | NotionTranscriptConfigValue, |
| | NotionTranscriptContextValue, NotionTranscriptItem, NotionDebugOverrides, |
| | NotionRequestBody, ChoiceDelta, Choice, ChatCompletionChunk, NotionTranscriptItemByuser |
| | } from './models.js'; |
| | import { proxyPool } from './ProxyPool.js'; |
| | import { proxyServer } from './ProxyServer.js'; |
| | import { cookieManager } from './CookieManager.js'; |
| |
|
| | |
| | const __filename = fileURLToPath(import.meta.url); |
| | const __dirname = dirname(__filename); |
| |
|
| | |
| | dotenv.config({ path: join(dirname(__dirname), '.env') }); |
| |
|
| | |
| | const logger = { |
| | info: (message) => console.log(chalk.blue(`[info] ${message}`)), |
| | error: (message) => console.error(chalk.red(`[error] ${message}`)), |
| | warning: (message) => console.warn(chalk.yellow(`[warn] ${message}`)), |
| | success: (message) => console.log(chalk.green(`[success] ${message}`)), |
| | }; |
| |
|
| | |
| | const NOTION_API_URL = "https://www.notion.so/api/v3/runInferenceTranscript"; |
| | |
| | let currentCookieData = null; |
| | const USE_NATIVE_PROXY_POOL = process.env.USE_NATIVE_PROXY_POOL === 'true'; |
| | const ENABLE_PROXY_SERVER = process.env.ENABLE_PROXY_SERVER === 'true'; |
| | let proxy = null; |
| |
|
| | |
| | const PROXY_URL = process.env.PROXY_URL || ""; |
| |
|
| | |
| | let INITIALIZED_SUCCESSFULLY = false; |
| |
|
| | |
| | process.on('exit', () => { |
| | try { |
| | if (proxyServer) { |
| | proxyServer.stop(); |
| | } |
| | } catch (error) { |
| | logger.error(`程序退出时关闭代理服务器出错: ${error.message}`); |
| | } |
| | }); |
| |
|
| | |
| | ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach(signal => { |
| | process.on(signal, () => { |
| | logger.info(`收到${signal}信号,正在关闭代理服务器...`); |
| | try { |
| | if (proxyServer) { |
| | proxyServer.stop(); |
| | } |
| | } catch (error) { |
| | logger.error(`关闭代理服务器出错: ${error.message}`); |
| | } |
| | process.exit(0); |
| | }); |
| | }); |
| |
|
| | |
| | function buildNotionRequest(requestData) { |
| | |
| | if (!currentCookieData) { |
| | currentCookieData = cookieManager.getNext(); |
| | if (!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 = []; |
| | |
| | |
| | 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: requestData.model |
| | }) |
| | })); |
| | } |
| |
|
| | |
| | |
| | transcript.push(new NotionTranscriptItem({ |
| | type: "context", |
| | value: new NotionTranscriptContextValue({ |
| | userId: currentCookieData.userId, |
| | spaceId: currentCookieData.spaceId, |
| | surface: "home_module", |
| | timezone: "America/Los_Angeles", |
| | userName: userName, |
| | spaceName: spaceName, |
| | spaceViewId: randomUUID(), |
| | currentDatetime: isoString |
| | }) |
| | })); |
| | |
| | |
| | transcript.push(new NotionTranscriptItem({ |
| | type: "agent-integration" |
| | })); |
| | |
| | |
| | for (const message of requestData.messages) { |
| | |
| | let content = message.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; |
| | } |
| | } |
| | } |
| | content = textContent || ""; |
| | } else if (typeof content !== 'string') { |
| | content = ""; |
| | } |
| | |
| | if (message.role === "system") { |
| | |
| | transcript.push(new NotionTranscriptItemByuser({ |
| | type: "user", |
| | value: [[content]], |
| | userId: currentCookieData.userId, |
| | createdAt: message.createdAt || isoString |
| | })); |
| | } else if (message.role === "user") { |
| | |
| | transcript.push(new NotionTranscriptItemByuser({ |
| | type: "user", |
| | value: [[content]], |
| | userId: 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 |
| | })); |
| | } |
| | } |
| | |
| | |
| | return new NotionRequestBody({ |
| | spaceId: currentCookieData.spaceId, |
| | transcript: transcript, |
| | createThread: true, |
| | traceId: randomUUID(), |
| | debugOverrides: new NotionDebugOverrides({ |
| | cachedInferences: {}, |
| | annotationInferences: {}, |
| | emitInferences: false |
| | }), |
| | generateTitle: false, |
| | saveAllThreadOperations: false |
| | }); |
| | } |
| |
|
| | |
| | async function streamNotionResponse(notionRequestBody) { |
| | |
| | if (!currentCookieData) { |
| | currentCookieData = cookieManager.getNext(); |
| | if (!currentCookieData) { |
| | throw new Error('没有可用的cookie'); |
| | } |
| | } |
| |
|
| | |
| | const stream = new PassThrough(); |
| | |
| | |
| | stream.write(':\n\n'); |
| | |
| | |
| | const headers = { |
| | 'Content-Type': 'application/json', |
| | 'accept': 'application/x-ndjson', |
| | 'accept-language': 'en-US,en;q=0.9', |
| | 'notion-audit-log-platform': 'web', |
| | 'notion-client-version': '23.13.0.3686', |
| | 'origin': 'https://www.notion.so', |
| | 'referer': 'https://www.notion.so/chat', |
| | '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': currentCookieData.userId, |
| | 'x-notion-space-id': currentCookieData.spaceId |
| | }; |
| | |
| | |
| | const timeoutId = setTimeout(() => { |
| | logger.warning(`请求超时,30秒内未收到响应`); |
| | try { |
| | |
| | const endChunk = new ChatCompletionChunk({ |
| | choices: [ |
| | new Choice({ |
| | delta: new ChoiceDelta({ content: "请求超时,未收到Notion响应。" }), |
| | finish_reason: "timeout" |
| | }) |
| | ] |
| | }); |
| | stream.write(`data: ${JSON.stringify(endChunk)}\n\n`); |
| | stream.write('data: [DONE]\n\n'); |
| | stream.end(); |
| | } catch (error) { |
| | logger.error(`发送超时消息时出错: ${error}`); |
| | stream.end(); |
| | } |
| | }, 30000); |
| | |
| | |
| | fetchNotionResponse( |
| | stream, |
| | notionRequestBody, |
| | headers, |
| | NOTION_API_URL, |
| | currentCookieData.cookie, |
| | timeoutId |
| | ).catch((error) => { |
| | logger.error(`流处理出错: ${error}`); |
| | clearTimeout(timeoutId); |
| | |
| | try { |
| | |
| | const errorChunk = new ChatCompletionChunk({ |
| | choices: [ |
| | new Choice({ |
| | delta: new ChoiceDelta({ content: `处理请求时出错: ${error.message}` }), |
| | finish_reason: "error" |
| | }) |
| | ] |
| | }); |
| | stream.write(`data: ${JSON.stringify(errorChunk)}\n\n`); |
| | stream.write('data: [DONE]\n\n'); |
| | } catch (e) { |
| | logger.error(`发送错误消息时出错: ${e}`); |
| | } finally { |
| | stream.end(); |
| | } |
| | }); |
| | |
| | return stream; |
| | } |
| |
|
| | |
| | async function fetchNotionResponse(chunkQueue, notionRequestBody, headers, notionApiUrl, notionCookie, timeoutId) { |
| | let responseReceived = false; |
| | let dom = null; |
| | |
| | try { |
| | |
| | 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) { |
| | try { |
| | Object.defineProperty(global, 'navigator', { |
| | value: window.navigator, |
| | writable: true, |
| | configurable: true |
| | }); |
| | } catch (navError) { |
| | logger.warning(`无法设置navigator: ${navError.message},继续执行`); |
| | |
| | } |
| | } |
| | } catch (globalError) { |
| | logger.warning(`设置全局对象时出错: ${globalError.message}`); |
| | } |
| | |
| | |
| | document.cookie = notionCookie; |
| | |
| | |
| | const fetchOptions = { |
| | method: 'POST', |
| | headers: { |
| | ...headers, |
| | 'user-agent': window.navigator.userAgent, |
| | 'Cookie': notionCookie |
| | }, |
| | body: JSON.stringify(notionRequestBody), |
| | }; |
| | |
| | |
| | if (USE_NATIVE_PROXY_POOL) { |
| | proxy = proxyPool.getProxy(); |
| | if (proxy !== null) |
| | { |
| | const { HttpsProxyAgent } = await import('https-proxy-agent'); |
| | fetchOptions.agent = new HttpsProxyAgent(proxy.full); |
| | logger.info(`使用代理: ${proxy.full}`); |
| | } |
| | else{ |
| | logger.warning(`没有可用代理`); |
| | } |
| | } else if(PROXY_URL) { |
| | const { HttpsProxyAgent } = await import('https-proxy-agent'); |
| | fetchOptions.agent = new HttpsProxyAgent(PROXY_URL); |
| | logger.info(`使用代理: ${PROXY_URL}`); |
| | } |
| | let response = null; |
| | |
| | if (ENABLE_PROXY_SERVER){ |
| | response = await fetch('http://127.0.0.1:10655/proxy', { |
| | method: 'POST', |
| | body: JSON.stringify({ |
| | method: 'POST', |
| | url: notionApiUrl, |
| | headers: fetchOptions.headers, |
| | body: fetchOptions.body, |
| | stream:true |
| | }), |
| | }); |
| | }else{ |
| | response = await fetch(notionApiUrl, fetchOptions); |
| | } |
| |
|
| | |
| | if (response.status === 401) { |
| | logger.error(`收到401未授权错误,cookie可能已失效`); |
| | |
| | cookieManager.markAsInvalid(currentCookieData.userId); |
| | |
| | currentCookieData = cookieManager.getNext(); |
| | |
| | if (!currentCookieData) { |
| | throw new Error('所有cookie均已失效,无法继续请求'); |
| | } |
| | |
| | |
| | const newRequestBody = buildNotionRequest({ |
| | model: notionRequestBody.transcript[0]?.value?.model || '', |
| | messages: [] |
| | }); |
| | |
| | |
| | return fetchNotionResponse( |
| | chunkQueue, |
| | newRequestBody, |
| | { |
| | ...headers, |
| | 'x-notion-active-user-header': currentCookieData.userId, |
| | 'x-notion-space-id': currentCookieData.spaceId |
| | }, |
| | notionApiUrl, |
| | currentCookieData.cookie, |
| | timeoutId |
| | ); |
| | } |
| | |
| | if (!response.ok) { |
| | throw new Error(`HTTP error! status: ${response.status}`); |
| | } |
| | |
| | |
| | if (!response.body) { |
| | throw new Error("Response body is null"); |
| | } |
| | |
| | |
| | const reader = response.body; |
| | let buffer = ''; |
| | |
| | |
| | reader.on('data', (chunk) => { |
| | 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`; |
| | chunkQueue.write(dataStr); |
| | } else if (jsonData?.recordMap) { |
| | |
| | } else { |
| | |
| | } |
| | } catch (jsonError) { |
| | logger.error(`解析JSON出错: ${jsonError}`); |
| | } |
| | } |
| | } catch (error) { |
| | logger.error(`处理数据块出错: ${error}`); |
| | } |
| | }); |
| | |
| | |
| | reader.on('end', () => { |
| | try { |
| | logger.info(`响应完成`); |
| | if (cookieManager.getValidCount() > 1){ |
| | |
| | currentCookieData = cookieManager.getNext(); |
| | logger.info(`切换到下一个cookie: ${currentCookieData.userId}`); |
| | } |
| | |
| | |
| | if (!responseReceived) { |
| | logger.warning(`未从Notion收到内容响应,请更换ip重试`); |
| | if (USE_NATIVE_PROXY_POOL) { |
| | proxyPool.removeProxy(proxy.ip, proxy.port); |
| | } |
| | |
| | const noContentChunk = new ChatCompletionChunk({ |
| | choices: [ |
| | new Choice({ |
| | delta: new ChoiceDelta({ content: "未从Notion收到内容响应,请更换ip重试。" }), |
| | finish_reason: "no_content" |
| | }) |
| | ] |
| | }); |
| | chunkQueue.write(`data: ${JSON.stringify(noContentChunk)}\n\n`); |
| | } |
| | |
| | |
| | const endChunk = new ChatCompletionChunk({ |
| | choices: [ |
| | new Choice({ |
| | delta: new ChoiceDelta({ content: null }), |
| | finish_reason: "stop" |
| | }) |
| | ] |
| | }); |
| | |
| | |
| | chunkQueue.write(`data: ${JSON.stringify(endChunk)}\n\n`); |
| | chunkQueue.write('data: [DONE]\n\n'); |
| | |
| | |
| | if (timeoutId) clearTimeout(timeoutId); |
| | |
| | |
| | try { |
| | if (global.window) delete global.window; |
| | if (global.document) delete global.document; |
| | |
| | |
| | if (global.navigator) { |
| | try { |
| | delete global.navigator; |
| | } catch (navError) { |
| | |
| | try { |
| | Object.defineProperty(global, 'navigator', { |
| | value: undefined, |
| | writable: true, |
| | configurable: true |
| | }); |
| | } catch (defineError) { |
| | logger.warning(`无法清理navigator: ${defineError.message}`); |
| | } |
| | } |
| | } |
| | } catch (cleanupError) { |
| | logger.warning(`清理全局对象时出错: ${cleanupError.message}`); |
| | } |
| | |
| | |
| | chunkQueue.end(); |
| | } catch (error) { |
| | logger.error(`Error in stream end handler: ${error}`); |
| | if (timeoutId) clearTimeout(timeoutId); |
| | |
| | |
| | try { |
| | if (global.window) delete global.window; |
| | if (global.document) delete global.document; |
| | |
| | |
| | if (global.navigator) { |
| | try { |
| | delete global.navigator; |
| | } catch (navError) { |
| | |
| | try { |
| | Object.defineProperty(global, 'navigator', { |
| | value: undefined, |
| | writable: true, |
| | configurable: true |
| | }); |
| | } catch (defineError) { |
| | logger.warning(`无法清理navigator: ${defineError.message}`); |
| | } |
| | } |
| | } |
| | } catch (cleanupError) { |
| | logger.warning(`清理全局对象时出错: ${cleanupError.message}`); |
| | } |
| | |
| | chunkQueue.end(); |
| | } |
| | }); |
| | |
| | |
| | reader.on('error', (error) => { |
| | logger.error(`Stream error: ${error}`); |
| | if (timeoutId) clearTimeout(timeoutId); |
| | |
| | |
| | try { |
| | if (global.window) delete global.window; |
| | if (global.document) delete global.document; |
| | |
| | |
| | if (global.navigator) { |
| | try { |
| | delete global.navigator; |
| | } catch (navError) { |
| | |
| | try { |
| | Object.defineProperty(global, 'navigator', { |
| | value: undefined, |
| | writable: true, |
| | configurable: true |
| | }); |
| | } catch (defineError) { |
| | logger.warning(`无法清理navigator: ${defineError.message}`); |
| | } |
| | } |
| | } |
| | } catch (cleanupError) { |
| | logger.warning(`清理全局对象时出错: ${cleanupError.message}`); |
| | } |
| | |
| | try { |
| | const errorChunk = new ChatCompletionChunk({ |
| | choices: [ |
| | new Choice({ |
| | delta: new ChoiceDelta({ content: `流读取错误: ${error.message}` }), |
| | finish_reason: "error" |
| | }) |
| | ] |
| | }); |
| | chunkQueue.write(`data: ${JSON.stringify(errorChunk)}\n\n`); |
| | chunkQueue.write('data: [DONE]\n\n'); |
| | } catch (e) { |
| | logger.error(`Error sending error message: ${e}`); |
| | } finally { |
| | chunkQueue.end(); |
| | } |
| | }); |
| | } catch (error) { |
| | logger.error(`Notion API请求失败: ${error}`); |
| | |
| | try { |
| | if (global.window) delete global.window; |
| | if (global.document) delete global.document; |
| | |
| | |
| | if (global.navigator) { |
| | try { |
| | delete global.navigator; |
| | } catch (navError) { |
| | |
| | try { |
| | Object.defineProperty(global, 'navigator', { |
| | value: undefined, |
| | writable: true, |
| | configurable: true |
| | }); |
| | } catch (defineError) { |
| | logger.warning(`无法清理navigator: ${defineError.message}`); |
| | } |
| | } |
| | } |
| | } catch (cleanupError) { |
| | logger.warning(`清理全局对象时出错: ${cleanupError.message}`); |
| | } |
| | |
| | if (timeoutId) clearTimeout(timeoutId); |
| | if (chunkQueue) chunkQueue.end(); |
| | |
| | |
| | try { |
| | if (!responseReceived && chunkQueue) { |
| | const errorChunk = new ChatCompletionChunk({ |
| | choices: [ |
| | new Choice({ |
| | delta: new ChoiceDelta({ content: `Notion API请求失败: ${error.message}` }), |
| | finish_reason: "error" |
| | }) |
| | ] |
| | }); |
| | chunkQueue.write(`data: ${JSON.stringify(errorChunk)}\n\n`); |
| | chunkQueue.write('data: [DONE]\n\n'); |
| | } |
| | } catch (e) { |
| | logger.error(`发送错误消息时出错: ${e}`); |
| | } |
| | |
| | throw error; |
| | } |
| | } |
| |
|
| | |
| | async function initialize() { |
| | logger.info(`初始化Notion配置...`); |
| | |
| | |
| | try { |
| | await proxyServer.start(); |
| | } catch (error) { |
| | logger.error(`启动代理服务器失败: ${error.message}`); |
| | } |
| | |
| | |
| | let initResult = false; |
| | |
| | |
| | const cookieFilePath = process.env.COOKIE_FILE; |
| | if (cookieFilePath) { |
| | logger.info(`检测到COOKIE_FILE配置: ${cookieFilePath}`); |
| | initResult = await cookieManager.loadFromFile(cookieFilePath); |
| | |
| | if (!initResult) { |
| | logger.error(`从文件加载cookie失败,尝试使用环境变量中的NOTION_COOKIE`); |
| | } |
| | } |
| | |
| | |
| | if (!initResult) { |
| | const cookiesString = process.env.NOTION_COOKIE; |
| | if (!cookiesString) { |
| | logger.error(`错误: 未设置NOTION_COOKIE环境变量或COOKIE_FILE路径,应用无法正常工作`); |
| | logger.error(`请在.env文件中设置有效的NOTION_COOKIE值或COOKIE_FILE路径`); |
| | INITIALIZED_SUCCESSFULLY = false; |
| | return; |
| | } |
| | |
| | logger.info(`正在从环境变量初始化cookie管理器...`); |
| | initResult = await cookieManager.initialize(cookiesString); |
| | |
| | if (!initResult) { |
| | logger.error(`初始化cookie管理器失败,应用无法正常工作`); |
| | INITIALIZED_SUCCESSFULLY = false; |
| | return; |
| | } |
| | } |
| | |
| | |
| | currentCookieData = cookieManager.getNext(); |
| | if (!currentCookieData) { |
| | logger.error(`没有可用的cookie,应用无法正常工作`); |
| | INITIALIZED_SUCCESSFULLY = false; |
| | return; |
| | } |
| | |
| | logger.success(`成功初始化cookie管理器,共有 ${cookieManager.getValidCount()} 个有效cookie`); |
| | logger.info(`当前使用的cookie对应的用户ID: ${currentCookieData.userId}`); |
| | logger.info(`当前使用的cookie对应的空间ID: ${currentCookieData.spaceId}`); |
| | |
| | if (process.env.USE_NATIVE_PROXY_POOL === 'true') { |
| | logger.info(`正在初始化本地代理池...`); |
| | await proxyPool.initialize(); |
| | } |
| | |
| | INITIALIZED_SUCCESSFULLY = true; |
| | } |
| |
|
| | |
| | export { |
| | initialize, |
| | streamNotionResponse, |
| | buildNotionRequest, |
| | INITIALIZED_SUCCESSFULLY |
| | }; |