Spaces:
Paused
Paused
| import express from 'express'; | |
| import dotenv from 'dotenv'; | |
| import { randomUUID } from 'crypto'; | |
| import { fileURLToPath } from 'url'; | |
| import { dirname, join } from 'path'; | |
| import chalk from 'chalk'; | |
| import { | |
| ChatMessage, ChatCompletionRequest, Choice, ChoiceDelta, ChatCompletionChunk | |
| } from './models.js'; | |
| import { cookieManager } from './CookieManager.js'; | |
| import { PassThrough } from 'stream'; | |
| import fetch from 'node-fetch'; | |
| // 获取当前文件的目录路径 | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = dirname(__filename); | |
| // 加载环境变量 | |
| dotenv.config({ path: join(dirname(__dirname), '.env') }); | |
| // 初始化状态变量 | |
| let INITIALIZED_SUCCESSFULLY = false; | |
| // 活跃流管理器 - 用于跟踪和管理当前活跃的请求流 | |
| const activeStreams = new Map(); | |
| // 日志配置 | |
| 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}`)), | |
| request: (method, path, status, time) => { | |
| const statusColor = status >= 500 ? chalk.red : | |
| status >= 400 ? chalk.yellow : | |
| status >= 300 ? chalk.cyan : | |
| status >= 200 ? chalk.green : chalk.white; | |
| console.log(`${chalk.magenta(`[${method}]`)} - ${path} ${statusColor(status)} ${chalk.gray(`${time}ms`)}`); | |
| } | |
| }; | |
| // 认证配置 | |
| const EXPECTED_TOKEN = process.env.PROXY_AUTH_TOKEN || "default_token"; | |
| // 代理配置 | |
| const PROXY_URL = process.env.PROXY_URL; | |
| async function validateProxy() { | |
| if (!PROXY_URL) { | |
| logger.info('未配置代理,将使用直连访问'); | |
| return true; | |
| } | |
| try { | |
| const { HttpsProxyAgent } = await import('https-proxy-agent'); | |
| const testResponse = await fetch('https://httpbin.org/ip', { | |
| method: 'GET', | |
| agent: new HttpsProxyAgent(PROXY_URL), | |
| timeout: 15000 | |
| }); | |
| if (testResponse.ok) { | |
| const ipInfo = await testResponse.json(); | |
| const safeProxyUrl = PROXY_URL.replace(/:([^:@]+)@/, ':****@'); | |
| logger.success(`✅ 代理验证成功 - IP: ${ipInfo.origin} - 代理: ${safeProxyUrl}`); | |
| return true; | |
| } else { | |
| logger.error(`❌ 代理验证失败 - 状态码: ${testResponse.status}`); | |
| return false; | |
| } | |
| } catch (error) { | |
| logger.error(`❌ 代理验证异常: ${error.message}`); | |
| return false; | |
| } | |
| } | |
| // 初始化函数 | |
| async function initialize() { | |
| logger.info('开始系统初始化...'); | |
| try { | |
| const envCookie = process.env.NOTION_COOKIE; | |
| const envCookieFile = process.env.COOKIE_FILE; | |
| if (envCookie) { | |
| logger.info('发现环境变量中的NOTION_COOKIE,正在初始化...'); | |
| const success = await cookieManager.initialize(envCookie); | |
| if (success) { | |
| logger.success('Cookie初始化成功'); | |
| return true; | |
| } else { | |
| logger.error('Cookie初始化失败'); | |
| return false; | |
| } | |
| } else if (envCookieFile) { | |
| logger.info(`发现环境变量中的COOKIE_FILE: ${envCookieFile},正在加载...`); | |
| const success = await cookieManager.loadFromFile(envCookieFile); | |
| if (success) { | |
| logger.success('从文件加载Cookie成功'); | |
| return true; | |
| } else { | |
| logger.error('从文件加载Cookie失败'); | |
| return false; | |
| } | |
| } else { | |
| logger.warning('未找到NOTION_COOKIE或COOKIE_FILE环境变量'); | |
| return false; | |
| } | |
| } catch (error) { | |
| logger.error(`初始化过程出错: ${error.message}`); | |
| return false; | |
| } | |
| } | |
| // 流式响应函数 | |
| async function streamNotionResponse(notionRequestBody) { | |
| const stream = new PassThrough(); | |
| let streamClosed = false; | |
| const originalEnd = stream.end; | |
| stream.end = function(...args) { | |
| if (streamClosed) return; | |
| streamClosed = true; | |
| return originalEnd.apply(this, args); | |
| }; | |
| stream.write(':\n\n'); | |
| const timeoutId = setTimeout(() => { | |
| if (streamClosed) return; | |
| 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}`); | |
| if (!streamClosed) stream.end(); | |
| } | |
| }, 30000); | |
| fetchNotionResponse(stream, notionRequestBody, timeoutId).catch((error) => { | |
| if (streamClosed) return; | |
| 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 { | |
| if (!streamClosed) stream.end(); | |
| } | |
| }); | |
| return stream; | |
| } | |
| // 实际请求Notion API的函数 | |
| async function fetchNotionResponse(chunkQueue, notionRequestBody, timeoutId) { | |
| let responseReceived = false; | |
| const isStreamClosed = () => { | |
| return chunkQueue.destroyed || (typeof chunkQueue.closed === 'boolean' && chunkQueue.closed); | |
| }; | |
| const safeWrite = (data) => { | |
| if (!isStreamClosed()) { | |
| try { | |
| return chunkQueue.write(data); | |
| } catch (error) { | |
| logger.error(`流写入错误: ${error.message}`); | |
| return false; | |
| } | |
| } | |
| return false; | |
| }; | |
| try { | |
| const cookieData = cookieManager.getNext(); | |
| if (!cookieData) { | |
| throw new Error('没有可用的cookie'); | |
| } | |
| 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': cookieData.userId, | |
| 'x-notion-space-id': cookieData.spaceId, | |
| 'Cookie': cookieData.cookie | |
| }; | |
| const fetchOptions = { | |
| method: 'POST', | |
| headers: headers, | |
| body: JSON.stringify(notionRequestBody), | |
| }; | |
| // 添加代理配置 | |
| if (PROXY_URL) { | |
| const { HttpsProxyAgent } = await import('https-proxy-agent'); | |
| fetchOptions.agent = new HttpsProxyAgent(PROXY_URL); | |
| logger.info(`使用固定代理连接Notion API`); | |
| } | |
| const response = await fetch('https://www.notion.so/api/v3/runInferenceTranscript', fetchOptions); | |
| if (response.status === 401) { | |
| logger.error(`收到401未授权错误,cookie可能已失效`); | |
| cookieManager.markAsInvalid(cookieData.userId); | |
| throw new Error('Cookie已失效'); | |
| } | |
| 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) => { | |
| if (isStreamClosed()) { | |
| 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 (!safeWrite(dataStr)) { | |
| try { | |
| reader.destroy(); | |
| } catch (error) { | |
| logger.error(`写入失败后销毁reader时出错: ${error.message}`); | |
| } | |
| return; | |
| } | |
| } | |
| } catch (parseError) { | |
| logger.error(`解析JSON失败: ${parseError.message}`); | |
| } | |
| } | |
| } catch (error) { | |
| logger.error(`处理数据块时出错: ${error.message}`); | |
| } | |
| }); | |
| reader.on('end', () => { | |
| logger.info('Notion API响应流结束'); | |
| clearTimeout(timeoutId); | |
| try { | |
| const endChunk = new ChatCompletionChunk({ | |
| choices: [ | |
| new Choice({ | |
| delta: new ChoiceDelta({ content: "" }), | |
| finish_reason: "stop" | |
| }) | |
| ] | |
| }); | |
| safeWrite(`data: ${JSON.stringify(endChunk)}\n\n`); | |
| safeWrite('data: [DONE]\n\n'); | |
| } catch (error) { | |
| logger.error(`发送结束标记时出错: ${error.message}`); | |
| } | |
| if (!isStreamClosed()) { | |
| chunkQueue.end(); | |
| } | |
| }); | |
| reader.on('error', (error) => { | |
| logger.error(`读取响应流时出错: ${error.message}`); | |
| clearTimeout(timeoutId); | |
| if (!isStreamClosed()) { | |
| chunkQueue.end(); | |
| } | |
| }); | |
| } catch (error) { | |
| logger.error(`请求Notion API失败: ${error.message}`); | |
| clearTimeout(timeoutId); | |
| if (!isStreamClosed()) { | |
| chunkQueue.end(); | |
| } | |
| throw error; | |
| } | |
| } | |
| // 构建Notion请求的函数 | |
| function buildNotionRequest(requestData) { | |
| const cookieData = cookieManager.getNext(); | |
| if (!cookieData) { | |
| throw new Error('没有可用的cookie'); | |
| } | |
| const now = new Date().toISOString(); | |
| const transcript = []; | |
| // 添加配置 | |
| if (requestData.model === 'google-gemini-2.5-pro') { | |
| transcript.push({ | |
| id: randomUUID(), | |
| type: "config", | |
| value: { model: 'vertex-gemini-2.5-pro' } | |
| }); | |
| } else if (requestData.model === 'google-gemini-2.5-flash') { | |
| transcript.push({ | |
| id: randomUUID(), | |
| type: "config", | |
| value: { model: 'vertex-gemini-2.5-flash' } | |
| }); | |
| } else { | |
| transcript.push({ | |
| id: randomUUID(), | |
| type: "config", | |
| value: { model: requestData.model } | |
| }); | |
| } | |
| // 添加上下文 | |
| transcript.push({ | |
| id: randomUUID(), | |
| type: "context", | |
| value: { | |
| userId: cookieData.userId, | |
| spaceId: cookieData.spaceId, | |
| surface: "home_module", | |
| timezone: "America/Los_Angeles", | |
| userName: `User${Math.floor(Math.random() * 900) + 100}`, | |
| spaceName: `Project ${Math.floor(Math.random() * 99) + 1}`, | |
| spaceViewId: randomUUID(), | |
| currentDatetime: now | |
| } | |
| }); | |
| // 添加消息 | |
| for (const message of requestData.messages) { | |
| let content = message.content; | |
| if (Array.isArray(content)) { | |
| content = content.map(part => part.type === 'text' ? part.text : '').join(''); | |
| } | |
| if (message.role === "user" || message.role === "system") { | |
| transcript.push({ | |
| id: randomUUID(), | |
| type: "user", | |
| value: [[content]], | |
| userId: cookieData.userId, | |
| createdAt: now | |
| }); | |
| } else if (message.role === "assistant") { | |
| transcript.push({ | |
| id: randomUUID(), | |
| type: "markdown-chat", | |
| value: content, | |
| traceId: randomUUID(), | |
| createdAt: now | |
| }); | |
| } | |
| } | |
| return { | |
| spaceId: cookieData.spaceId, | |
| transcript: transcript, | |
| createThread: true, | |
| traceId: randomUUID(), | |
| debugOverrides: { | |
| cachedInferences: {}, | |
| annotationInferences: {}, | |
| emitInferences: false | |
| }, | |
| generateTitle: false, | |
| saveAllThreadOperations: false | |
| }; | |
| } | |
| // 创建Express应用 | |
| const app = express(); | |
| app.use(express.json({ limit: '50mb' })); | |
| app.use(express.urlencoded({ extended: true, limit: '50mb' })); | |
| // 请求日志中间件 | |
| app.use((req, res, next) => { | |
| const start = Date.now(); | |
| // 保存原始的 end 方法 | |
| const originalEnd = res.end; | |
| // 重写 end 方法以记录请求完成时间 | |
| res.end = function(...args) { | |
| const duration = Date.now() - start; | |
| logger.request(req.method, req.path, res.statusCode, duration); | |
| return originalEnd.apply(this, args); | |
| }; | |
| next(); | |
| }); | |
| // 认证中间件 | |
| function authenticate(req, res, next) { | |
| const authHeader = req.headers.authorization; | |
| if (!authHeader || !authHeader.startsWith('Bearer ')) { | |
| return res.status(401).json({ | |
| error: { | |
| message: "Authentication required. Please provide a valid Bearer token.", | |
| type: "authentication_error" | |
| } | |
| }); | |
| } | |
| const token = authHeader.split(' ')[1]; | |
| if (token !== EXPECTED_TOKEN) { | |
| return res.status(401).json({ | |
| error: { | |
| message: "Invalid authentication credentials", | |
| type: "authentication_error" | |
| } | |
| }); | |
| } | |
| next(); | |
| } | |
| // 流管理函数 | |
| function manageStream(clientId, stream) { | |
| // 如果该客户端已有活跃流,先关闭它 | |
| if (activeStreams.has(clientId)) { | |
| try { | |
| const oldStream = activeStreams.get(clientId); | |
| logger.info(`关闭客户端 ${clientId} 的旧流`); | |
| oldStream.end(); | |
| } catch (error) { | |
| logger.error(`关闭旧流时出错: ${error.message}`); | |
| } | |
| } | |
| // 注册新流 | |
| activeStreams.set(clientId, stream); | |
| // 当流结束时从管理器中移除 | |
| stream.on('end', () => { | |
| if (activeStreams.get(clientId) === stream) { | |
| activeStreams.delete(clientId); | |
| //logger.info(`客户端 ${clientId} 的流已结束并移除`); | |
| } | |
| }); | |
| stream.on('error', (error) => { | |
| logger.error(`流错误: ${error.message}`); | |
| if (activeStreams.get(clientId) === stream) { | |
| activeStreams.delete(clientId); | |
| } | |
| }); | |
| return stream; | |
| } | |
| // API路由 | |
| // 获取模型列表 | |
| app.get('/v1/models', authenticate, (req, res) => { | |
| // 返回可用模型列表 | |
| const modelList = { | |
| data: [ | |
| { id: "openai-gpt-4.1" }, | |
| { id: "anthropic-opus-4" }, | |
| { id: "anthropic-sonnet-4" }, | |
| { id: "anthropic-sonnet-3.x-stable" }, | |
| { id: "google-gemini-2.5-pro"}, //vertex-gemini-2.5-pro | |
| { id: "google-gemini-2.5-flash"}, //vertex-gemini-2.5-flash | |
| ] | |
| }; | |
| res.json(modelList); | |
| }); | |
| // 聊天完成端点 | |
| app.post('/v1/chat/completions', authenticate, async (req, res) => { | |
| try { | |
| // 生成或获取客户端ID | |
| const clientId = req.headers['x-client-id'] || randomUUID(); | |
| // 检查是否成功初始化 | |
| if (!INITIALIZED_SUCCESSFULLY) { | |
| return res.status(500).json({ | |
| error: { | |
| message: "系统未成功初始化。请检查您的NOTION_COOKIE是否有效。", | |
| type: "server_error" | |
| } | |
| }); | |
| } | |
| // 检查是否有可用的cookie | |
| if (cookieManager.getValidCount() === 0) { | |
| return res.status(500).json({ | |
| error: { | |
| message: "没有可用的有效cookie。请检查您的NOTION_COOKIE配置。", | |
| type: "server_error" | |
| } | |
| }); | |
| } | |
| // 验证请求数据 | |
| const requestData = req.body; | |
| if (!requestData.messages || !Array.isArray(requestData.messages) || requestData.messages.length === 0) { | |
| return res.status(400).json({ | |
| error: { | |
| message: "Invalid request: 'messages' field must be a non-empty array.", | |
| type: "invalid_request_error" | |
| } | |
| }); | |
| } | |
| // 构建Notion请求 | |
| const notionRequestBody = buildNotionRequest(requestData); | |
| // 处理流式响应 | |
| if (requestData.stream) { | |
| res.setHeader('Content-Type', 'text/event-stream'); | |
| res.setHeader('Cache-Control', 'no-cache'); | |
| res.setHeader('Connection', 'keep-alive'); | |
| logger.info(`开始流式响应`); | |
| const stream = await streamNotionResponse(notionRequestBody); | |
| // 管理流 | |
| manageStream(clientId, stream); | |
| stream.pipe(res); | |
| // 处理客户端断开连接 | |
| req.on('close', () => { | |
| logger.info(`客户端 ${clientId} 断开连接`); | |
| if (activeStreams.has(clientId)) { | |
| try { | |
| activeStreams.get(clientId).end(); | |
| activeStreams.delete(clientId); | |
| } catch (error) { | |
| logger.error(`关闭流时出错: ${error.message}`); | |
| } | |
| } | |
| }); | |
| } else { | |
| // 非流式响应 | |
| // 创建一个内部流来收集完整响应 | |
| logger.info(`开始非流式响应`); | |
| const chunks = []; | |
| const stream = await streamNotionResponse(notionRequestBody); | |
| // 管理流 | |
| manageStream(clientId, stream); | |
| return new Promise((resolve, reject) => { | |
| stream.on('data', (chunk) => { | |
| const chunkStr = chunk.toString(); | |
| if (chunkStr.startsWith('data: ') && !chunkStr.includes('[DONE]')) { | |
| try { | |
| const dataJson = chunkStr.substring(6).trim(); | |
| if (dataJson) { | |
| const chunkData = JSON.parse(dataJson); | |
| if (chunkData.choices && chunkData.choices[0].delta && chunkData.choices[0].delta.content) { | |
| chunks.push(chunkData.choices[0].delta.content); | |
| } | |
| } | |
| } catch (error) { | |
| logger.error(`解析非流式响应块时出错: ${error}`); | |
| } | |
| } | |
| }); | |
| stream.on('end', () => { | |
| const fullResponse = { | |
| id: `chatcmpl-${randomUUID()}`, | |
| object: "chat.completion", | |
| created: Math.floor(Date.now() / 1000), | |
| model: requestData.model, | |
| choices: [ | |
| { | |
| index: 0, | |
| message: { | |
| role: "assistant", | |
| content: chunks.join('') | |
| }, | |
| finish_reason: "stop" | |
| } | |
| ], | |
| usage: { | |
| prompt_tokens: null, | |
| completion_tokens: null, | |
| total_tokens: null | |
| } | |
| }; | |
| res.json(fullResponse); | |
| resolve(); | |
| }); | |
| stream.on('error', (error) => { | |
| logger.error(`非流式响应出错: ${error}`); | |
| reject(error); | |
| }); | |
| // 处理客户端断开连接 | |
| req.on('close', () => { | |
| logger.info(`客户端 ${clientId} 断开连接(非流式)`); | |
| if (activeStreams.has(clientId)) { | |
| try { | |
| activeStreams.get(clientId).end(); | |
| activeStreams.delete(clientId); | |
| } catch (error) { | |
| logger.error(`关闭流时出错: ${error.message}`); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| } catch (error) { | |
| logger.error(`聊天完成端点错误: ${error}`); | |
| res.status(500).json({ | |
| error: { | |
| message: `Internal server error: ${error.message}`, | |
| type: "server_error" | |
| } | |
| }); | |
| } | |
| }); | |
| // 健康检查端点 | |
| app.get('/health', (req, res) => { | |
| res.json({ | |
| status: 'ok', | |
| timestamp: new Date().toISOString(), | |
| initialized: INITIALIZED_SUCCESSFULLY, | |
| valid_cookies: cookieManager.getValidCount(), | |
| active_streams: activeStreams.size | |
| }); | |
| }); | |
| // Cookie状态查询端点 | |
| app.get('/cookies/status', authenticate, (req, res) => { | |
| res.json({ | |
| total_cookies: cookieManager.getValidCount(), | |
| cookies: cookieManager.getStatus() | |
| }); | |
| }); | |
| // 启动服务器 | |
| const PORT = process.env.PORT || 7860; | |
| // 初始化并启动服务器 | |
| initialize().then(async (initResult) => { | |
| // 设置初始化状态 | |
| INITIALIZED_SUCCESSFULLY = initResult !== false; | |
| // 验证代理配置 | |
| if (PROXY_URL) { | |
| try { | |
| await validateProxy(); | |
| } catch (error) { | |
| logger.warning(`代理验证失败: ${error.message}`); | |
| } | |
| } | |
| // 启动服务器 | |
| app.listen(PORT, '0.0.0.0', () => { | |
| logger.info(`服务已在 0.0.0.0:${PORT} 上启动`); | |
| logger.info(`访问地址: http://0.0.0.0:${PORT}`); | |
| // 显示代理状态 | |
| if (PROXY_URL) { | |
| const safeProxyUrl = PROXY_URL.replace(/:([^:@]+)@/, ':****@'); | |
| logger.info(`代理配置: ${safeProxyUrl}`); | |
| } else { | |
| logger.info(`代理配置: 未配置代理,使用直连`); | |
| } | |
| if (INITIALIZED_SUCCESSFULLY) { | |
| logger.success(`系统初始化状态: ✅`); | |
| logger.success(`可用cookie数量: ${cookieManager.getValidCount()}`); | |
| } else { | |
| logger.warning(`系统初始化状态: ❌`); | |
| logger.warning(`警告: 系统未成功初始化,API调用将无法正常工作`); | |
| logger.warning(`请检查NOTION_COOKIE配置是否有效`); | |
| } | |
| }); | |
| }).catch((error) => { | |
| logger.error(`初始化失败: ${error}`); | |
| INITIALIZED_SUCCESSFULLY = false; | |
| // 即使初始化失败也启动服务器 | |
| app.listen(PORT, '0.0.0.0', () => { | |
| logger.warning(`服务已在 0.0.0.0:${PORT} 上启动(初始化失败模式)`); | |
| }); | |
| }); | |