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