From d0dcc5076467cdb4f09a1e6150ba1bc366fd5213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=9B=E8=83=A4=E6=B1=A0?= Date: Sat, 17 Dec 2022 11:35:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9A=82=E6=97=B6=E4=B8=8D=E4=BD=BF?= =?UTF-8?q?=E7=94=A8chatgpt-api=E5=BA=93=EF=BC=8C=E6=8B=B7=E8=B4=9D?= =?UTF-8?q?=E4=BA=86=E9=83=A8=E5=88=86=E5=85=B6=E4=BB=A3=E7=A0=81=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/chat.js | 14 +- config/index.js | 9 +- utils/browser.js | 951 ++++++++++++++++++++++++++++++++++++++++++- utils/openai-auth.js | 202 +++++++++ utils/text.js | 9 - 5 files changed, 1166 insertions(+), 19 deletions(-) create mode 100644 utils/openai-auth.js delete mode 100644 utils/text.js diff --git a/apps/chat.js b/apps/chat.js index df8f24d..594ae47 100644 --- a/apps/chat.js +++ b/apps/chat.js @@ -4,6 +4,7 @@ import _ from 'lodash' import { Config } from '../config/index.js' import showdown from 'showdown' import mjAPI from 'mathjax-node' +import { ChatGPTPuppeteer } from '../utils/browser.js' // import puppeteer from '../utils/browser.js' // import showdownKatex from 'showdown-katex' const blockWords = '屏蔽词1,屏蔽词2,屏蔽词3' @@ -181,11 +182,11 @@ export class chatgpt extends plugin { // option.debug = true option.email = Config.username option.password = Config.password - this.chatGPTApi = new ChatGPTAPIBrowser(option) + this.chatGPTApi = new ChatGPTPuppeteer(option) await redis.set('CHATGPT:API_OPTION', JSON.stringify(option)) } else { let option = JSON.parse(api) - this.chatGPTApi = new ChatGPTAPIBrowser(option) + this.chatGPTApi = new ChatGPTPuppeteer(option) } let question = e.msg.trimStart() await this.reply('我正在思考如何回复你,请稍等', true, { recallMsg: 5 }) @@ -194,6 +195,7 @@ export class chatgpt extends plugin { await this.chatGPTApi.init() } catch (e) { await this.reply('chatgpt初始化出错:' + e.msg, true) + await this.chatGPTApi.close() } let previousConversation = await redis.get(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`) let conversation = null @@ -234,6 +236,7 @@ export class chatgpt extends plugin { const blockWord = blockWords.split(',').find(word => response.toLowerCase().includes(word.toLowerCase())) if (blockWord) { await this.reply('返回内容存在敏感词,我不想回答你', true) + await this.chatGPTApi.close() return } let userSetting = await redis.get(`CHATGPT:USER:${e.sender.user_id}`) @@ -247,18 +250,24 @@ export class chatgpt extends plugin { if (userSetting.usePicture) { let endTokens = ['.', '。', '……', '!', '!', ']', ')', ')', '】', '?', '?', '~', '"', "'"] while (!endTokens.find(token => response.trimEnd().endsWith(token))) { + // while (!response.trimEnd().endsWith('.') && !response.trimEnd().endsWith('。') && !response.trimEnd().endsWith('……') && + // !response.trimEnd().endsWith('!') && !response.trimEnd().endsWith('!') && !response.trimEnd().endsWith(']') && !response.trimEnd().endsWith('】') + // ) { await this.reply('内容有点多,我正在奋笔疾书,请再等一会', true, { recallMsg: 5 }) const responseAppend = await this.chatGPTApi.sendMessage('Continue') + // console.log(responseAppend) // 检索是否有屏蔽词 const blockWord = blockWords.split(',').find(word => responseAppend.toLowerCase().includes(word.toLowerCase())) if (blockWord) { await this.reply('返回内容存在敏感词,我不想回答你', true) + await this.chatGPTApi.close() return } if (responseAppend.indexOf('conversation') > -1 || responseAppend.startsWith("I'm sorry")) { logger.warn('chatgpt might forget what it had said') break } + response = response + responseAppend } // logger.info(response) @@ -275,5 +284,6 @@ export class chatgpt extends plugin { logger.error(e) await this.reply(`与OpenAI通信异常,请稍后重试:${e}`, true, { recallMsg: e.isGroup ? 10 : 0 }) } + await this.chatGPTApi.close() } } diff --git a/config/index.js b/config/index.js index 856d11a..5f803c8 100644 --- a/config/index.js +++ b/config/index.js @@ -15,12 +15,13 @@ export const Config = { // 改为true后,全局默认以图片形式回复,并自动发出Continue命令补全回答 defaultUsePicture: true, // 每个人发起的对话保留时长。超过这个时长没有进行对话,再进行对话将开启新的对话。单位:秒 - conversationPreserveTime: 600, + // conversationPreserveTime: 600, // UA: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', - // headless: false, + // 服务器无interface的话只能用true,但是可能遇到验证码就一定要配置下面的2captchaToken了 + headless: false, // 为空使用默认puppeteer的chromium,也可以传递自己本机安装的Chrome可执行文件地址,提高通过率 // 目前库暂时不支持指定chrome - // chromePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - // 可注册2captcha实现跳过验证码,收费服务但很便宜 + chromePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + // 可注册2captcha实现跳过验证码,收费服务但很便宜。否则需要手点 '2captchaToken': '' } diff --git a/utils/browser.js b/utils/browser.js index 3300cc7..bde2417 100644 --- a/utils/browser.js +++ b/utils/browser.js @@ -1,7 +1,13 @@ import lodash from 'lodash' -import cfg from '../../../lib/config/config.js' import { Config } from '../config/index.js' import StealthPlugin from 'puppeteer-extra-plugin-stealth' +import { getOpenAIAuth } from './openai-auth.js' +import delay from 'delay' +import { v4 as uuidv4 } from 'uuid' +import pTimeout, { TimeoutError } from 'p-timeout' +import { getBrowser } from 'chatgpt' +import { PuppeteerExtraPluginRecaptcha } from 'puppeteer-extra-plugin-recaptcha' +const chatUrl = 'https://chat.openai.com/chat' let puppeteer = {} class Puppeteer { @@ -9,7 +15,7 @@ class Puppeteer { let args = [ '--exclude-switches', '--no-sandbox', - 'enable-automation', + 'enable-automation' // '--shm-size=1gb' ] if (Config.proxy) { @@ -34,6 +40,16 @@ class Puppeteer { puppeteer = (await import('puppeteer-extra')).default const pluginStealth = StealthPlugin() puppeteer.use(pluginStealth) + if (Config['2captchaToken']) { + const pluginCaptcha = (await import('puppeteer-extra-plugin-recaptcha')).default + puppeteer.use(pluginCaptcha({ + provider: { + id: '2captcha', + token: Config['2captchaToken'] // REPLACE THIS WITH YOUR OWN 2CAPTCHA API KEY ⚡ + }, + visualFeedback: true + })) + } return puppeteer } @@ -67,7 +83,7 @@ class Puppeteer { /** 监听Chromium实例是否断开 */ this.browser.on('disconnected', (e) => { - logger.error('Chromium实例关闭或崩溃!') + logger.info('Chromium实例关闭或崩溃!') this.browser = false }) @@ -75,7 +91,31 @@ class Puppeteer { } } -class ChatGPTPuppeteer extends Puppeteer { +export class ChatGPTPuppeteer extends Puppeteer { + constructor (opts = {}) { + super() + const { + email, + password, + markdown = true, + debug = false, + isGoogleLogin = false, + minimize = true, + captchaToken, + executablePath + } = opts + + this._email = email + this._password = password + + this._markdown = !!markdown + this._debug = !!debug + this._isGoogleLogin = !!isGoogleLogin + this._minimize = !!minimize + this._captchaToken = captchaToken + this._executablePath = executablePath + } + async getBrowser () { if (this.browser) { return this.browser @@ -83,6 +123,909 @@ class ChatGPTPuppeteer extends Puppeteer { return await this.browserInit() } } + + async init () { + if (this.inited) { + return + } + try { + // this.browser = await getBrowser({ + // captchaToken: this._captchaToken, + // executablePath: this._executablePath + // }) + this.browser = await this.getBrowser() + this._page = + (await this.browser.pages())[0] || (await this.browser.newPage()) + await maximizePage(this._page) + this._page.on('request', this._onRequest.bind(this)) + this._page.on('response', this._onResponse.bind(this)) + // bypass cloudflare and login + let preCookies = await redis.get('CHATGPT:RAW_COOKIES') + if (preCookies) { + await this._page.setCookie(...JSON.parse(preCookies)) + } + await this._page.goto(chatUrl, { + waitUntil: 'networkidle2' + }) + if (!await this.getIsAuthenticated()) { + console.log('需要登录,准备进行自动化登录') + await getOpenAIAuth({ + email: this._email, + password: this._password, + browser: this.browser, + page: this._page, + isGoogleLogin: this._isGoogleLogin + }) + console.log('登陆完成') + } + this.inited = true + } catch (err) { + if (this.browser) { + await this.browser.close() + } + + this.browser = null + this._page = null + + throw err + } + + const url = this._page.url().replace(/\/$/, '') + + if (url !== chatUrl) { + await this._page.goto(chatUrl, { + waitUntil: 'networkidle2' + }) + } + + // dismiss welcome modal (and other modals) + do { + const modalSelector = '[data-headlessui-state="open"]' + + if (!(await this._page.$(modalSelector))) { + break + } + + try { + await this._page.click(`${modalSelector} button:last-child`) + } catch (err) { + // "next" button not found in welcome modal + break + } + + await delay(300) + } while (true) + + if (!await this.getIsAuthenticated()) { + return false + } + + if (this._minimize) { + await minimizePage(this._page) + } + + return true + } + + _onRequest = (request) => { + const url = request.url() + if (!isRelevantRequest(url)) { + return + } + + const method = request.method() + let body + + if (method === 'POST') { + body = request.postData() + + try { + body = JSON.parse(body) + } catch (_) { + } + + // if (url.endsWith('/conversation') && typeof body === 'object') { + // const conversationBody: types.ConversationJSONBody = body + // const conversationId = conversationBody.conversation_id + // const parentMessageId = conversationBody.parent_message_id + // const messageId = conversationBody.messages?.[0]?.id + // const prompt = conversationBody.messages?.[0]?.content?.parts?.[0] + + // // TODO: store this info for the current sendMessage request + // } + } + + if (this._debug) { + console.log('\nrequest', { + url, + method, + headers: request.headers(), + body + }) + } + } + + _onResponse = async (response) => { + const request = response.request() + + const url = response.url() + if (!isRelevantRequest(url)) { + return + } + + const status = response.status() + + let body + try { + body = await response.json() + } catch (_) { + } + + if (this._debug) { + console.log('\nresponse', { + url, + ok: response.ok(), + status, + statusText: response.statusText(), + headers: response.headers(), + body, + request: { + method: request.method(), + headers: request.headers(), + body: request.postData() + } + }) + } + + if (url.endsWith('/conversation')) { + if (status === 403) { + await this.handle403Error() + } + } else if (url.endsWith('api/auth/session')) { + if (status === 403) { + await this.handle403Error() + } else { + const session = body + + if (session?.accessToken) { + this._accessToken = session.accessToken + } + } + } + } + + async handle403Error () { + console.log(`ChatGPT "${this._email}" session expired; refreshing...`) + try { + await maximizePage(this._page) + await this._page.reload({ + waitUntil: 'networkidle2', + timeout: 2 * 60 * 1000 // 2 minutes + }) + if (this._minimize) { + await minimizePage(this._page) + } + } catch (err) { + console.error( + `ChatGPT "${this._email}" error refreshing session`, + err.toString() + ) + } + } + + async getIsAuthenticated () { + try { + const inputBox = await this._getInputBox() + return !!inputBox + } catch (err) { + // can happen when navigating during login + return false + } + } + + // async getLastMessage(): Promise { + // const messages = await this.getMessages() + + // if (messages) { + // return messages[messages.length - 1] + // } else { + // return null + // } + // } + + // async getPrompts(): Promise { + // // Get all prompts + // const messages = await this._page.$$( + // '.text-base:has(.whitespace-pre-wrap):not(:has(button:nth-child(2))) .whitespace-pre-wrap' + // ) + + // // Prompts are always plaintext + // return Promise.all(messages.map((a) => a.evaluate((el) => el.textContent))) + // } + + // async getMessages(): Promise { + // // Get all complete messages + // // (in-progress messages that are being streamed back don't contain action buttons) + // const messages = await this._page.$$( + // '.text-base:has(.whitespace-pre-wrap):has(button:nth-child(2)) .whitespace-pre-wrap' + // ) + + // if (this._markdown) { + // const htmlMessages = await Promise.all( + // messages.map((a) => a.evaluate((el) => el.innerHTML)) + // ) + + // const markdownMessages = htmlMessages.map((messageHtml) => { + // // parse markdown from message HTML + // messageHtml = messageHtml + // .replaceAll('Copy code', '') + // .replace(/Copy code\s*<\/button>/gim, '') + + // return html2md(messageHtml, { + // ignoreTags: [ + // 'button', + // 'svg', + // 'style', + // 'form', + // 'noscript', + // 'script', + // 'meta', + // 'head' + // ], + // skipTags: ['button', 'svg'] + // }) + // }) + + // return markdownMessages + // } else { + // // plaintext + // const plaintextMessages = await Promise.all( + // messages.map((a) => a.evaluate((el) => el.textContent)) + // ) + // return plaintextMessages + // } + // } + + async sendMessage ( + message, + opts = {} + ) { + const { + conversationId, + parentMessageId = uuidv4(), + messageId = uuidv4(), + action = 'next', + // TODO + timeoutMs, + // onProgress, + onConversationResponse + } = opts + + const inputBox = await this._getInputBox() + if (!inputBox || !this._accessToken) { + console.log(`chatgpt re-authenticating ${this._email}`) + let isAuthenticated = false + + try { + isAuthenticated = await this.init() + } catch (err) { + console.warn( + `chatgpt error re-authenticating ${this._email}`, + err.toString() + ) + } + + if (!isAuthenticated || !this._accessToken) { + const error = new Error('Not signed in') + error.statusCode = 401 + throw error + } + } + + const url = 'https://chat.openai.com/backend-api/conversation' + const body = { + action, + messages: [ + { + id: messageId, + role: 'user', + content: { + content_type: 'text', + parts: [message] + } + } + ], + model: 'text-davinci-002-render', + parent_message_id: parentMessageId + } + + if (conversationId) { + body.conversation_id = conversationId + } + + // console.log('>>> EVALUATE', url, this._accessToken, body) + const result = await this._page.evaluate( + browserPostEventStream, + url, + this._accessToken, + body, + timeoutMs + ) + // console.log('<<< EVALUATE', result) + + if (result.error) { + const error = new Error(result.error.message) + error.statusCode = result.error.statusCode + error.statusText = result.error.statusText + + if (error.statusCode === 403) { + await this.handle403Error() + } + + throw error + } + + // TODO: support sending partial response events + if (onConversationResponse) { + onConversationResponse(result.conversationResponse) + } + + return result.response + + // const lastMessage = await this.getLastMessage() + + // await inputBox.focus() + // const paragraphs = message.split('\n') + // for (let i = 0; i < paragraphs.length; i++) { + // await inputBox.type(paragraphs[i], { delay: 0 }) + // if (i < paragraphs.length - 1) { + // await this._page.keyboard.down('Shift') + // await inputBox.press('Enter') + // await this._page.keyboard.up('Shift') + // } else { + // await inputBox.press('Enter') + // } + // } + + // const responseP = new Promise(async (resolve, reject) => { + // try { + // do { + // await delay(1000) + + // // TODO: this logic needs some work because we can have repeat messages... + // const newLastMessage = await this.getLastMessage() + // if ( + // newLastMessage && + // lastMessage?.toLowerCase() !== newLastMessage?.toLowerCase() + // ) { + // return resolve(newLastMessage) + // } + // } while (true) + // } catch (err) { + // return reject(err) + // } + // }) + + // if (timeoutMs) { + // return pTimeout(responseP, { + // milliseconds: timeoutMs + // }) + // } else { + // return responseP + // } + } + + async resetThread () { + try { + await this._page.click('nav > a:nth-child(1)') + } catch (err) { + // ignore for now + } + } + + async close () { + await this.browser.close() + this._page = null + this.browser = null + } + + protected + + async _getInputBox () { + // [data-id="root"] + return this._page.$('textarea') + } } export default new ChatGPTPuppeteer() + +export async function minimizePage (page) { + const session = await page.target().createCDPSession() + const goods = await session.send('Browser.getWindowForTarget') + const { windowId } = goods + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'minimized' } + }) +} + +export async function maximizePage (page) { + const session = await page.target().createCDPSession() + const goods = await session.send('Browser.getWindowForTarget') + const { windowId } = goods + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' } + }) +} + +export function isRelevantRequest (url) { + let pathname + + try { + const parsedUrl = new URL(url) + pathname = parsedUrl.pathname + url = parsedUrl.toString() + } catch (_) { + return false + } + + if (!url.startsWith('https://chat.openai.com')) { + return false + } + + if ( + !pathname.startsWith('/backend-api/') && + !pathname.startsWith('/api/auth/session') + ) { + return false + } + + if (pathname.endsWith('backend-api/moderations')) { + return false + } + + return true +} + +/** + * This function is injected into the ChatGPT webapp page using puppeteer. It + * has to be fully self-contained, so we copied a few third-party sources and + * included them in here. + */ +export async function browserPostEventStream ( + url, + accessToken, + body, + timeoutMs +) { + // Workaround for https://github.com/esbuild-kit/tsx/issues/113 + globalThis.__name = () => undefined + + const BOM = [239, 187, 191] + + let conversationResponse + let conversationId = body?.conversation_id + let messageId = body?.messages?.[0]?.id + let response = '' + + try { + console.log('browserPostEventStream', url, accessToken, body) + + let abortController = null + if (timeoutMs) { + abortController = new AbortController() + } + + const res = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + signal: abortController?.signal, + headers: { + accept: 'text/event-stream', + 'x-openai-assistant-app-id': '', + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + } + }) + + console.log('browserPostEventStream response', res) + + if (!res.ok) { + return { + error: { + message: `ChatGPTAPI error ${res.status || res.statusText}`, + statusCode: res.status, + statusText: res.statusText + }, + response: null, + conversationId, + messageId + } + } + + const responseP = new Promise( + async (resolve, reject) => { + function onMessage (data) { + if (data === '[DONE]') { + return resolve({ + error: null, + response, + conversationId, + messageId, + conversationResponse + }) + } + + try { + const convoResponseEvent = + JSON.parse(data) + conversationResponse = convoResponseEvent + if (convoResponseEvent.conversation_id) { + conversationId = convoResponseEvent.conversation_id + } + + if (convoResponseEvent.message?.id) { + messageId = convoResponseEvent.message.id + } + + const partialResponse = + convoResponseEvent.message?.content?.parts?.[0] + if (partialResponse) { + response = partialResponse + } + } catch (err) { + console.warn('fetchSSE onMessage unexpected error', err) + reject(err) + } + } + + const parser = createParser((event) => { + if (event.type === 'event') { + onMessage(event.data) + } + }) + + for await (const chunk of streamAsyncIterable(res.body)) { + const str = new TextDecoder().decode(chunk) + parser.feed(str) + } + } + ) + + if (timeoutMs) { + if (abortController) { + // This will be called when a timeout occurs in order for us to forcibly + // ensure that the underlying HTTP request is aborted. + responseP.cancel = () => { + abortController.abort() + } + } + + return await pTimeout(responseP, { + milliseconds: timeoutMs, + message: 'ChatGPT timed out waiting for response' + }) + } else { + return await responseP + } + } catch (err) { + const errMessageL = err.toString().toLowerCase() + + if ( + response && + (errMessageL === 'error: typeerror: terminated' || + errMessageL === 'typeerror: terminated') + ) { + // OpenAI sometimes forcefully terminates the socket from their end before + // the HTTP request has resolved cleanly. In my testing, these cases tend to + // happen when OpenAI has already send the last `response`, so we can ignore + // the `fetch` error in this case. + return { + error: null, + response, + conversationId, + messageId, + conversationResponse + } + } + + return { + error: { + message: err.toString(), + statusCode: err.statusCode || err.status || err.response?.statusCode, + statusText: err.statusText || err.response?.statusText + }, + response: null, + conversationId, + messageId, + conversationResponse + } + } + + async function * streamAsyncIterable (stream) { + const reader = stream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + return + } + yield value + } + } finally { + reader.releaseLock() + } + } + + // @see https://github.com/rexxars/eventsource-parser + function createParser (onParse) { + // Processing state + let isFirstChunk + let buffer + let startingPosition + let startingFieldLength + + // Event state + let eventId + let eventName + let data + + reset() + return { feed, reset } + + function reset () { + isFirstChunk = true + buffer = '' + startingPosition = 0 + startingFieldLength = -1 + + eventId = undefined + eventName = undefined + data = '' + } + + function feed (chunk) { + buffer = buffer ? buffer + chunk : chunk + + // Strip any UTF8 byte order mark (BOM) at the start of the stream. + // Note that we do not strip any non - UTF8 BOM, as eventsource streams are + // always decoded as UTF8 as per the specification. + if (isFirstChunk && hasBom(buffer)) { + buffer = buffer.slice(BOM.length) + } + + isFirstChunk = false + + // Set up chunk-specific processing state + const length = buffer.length + let position = 0 + let discardTrailingNewline = false + + // Read the current buffer byte by byte + while (position < length) { + // EventSource allows for carriage return + line feed, which means we + // need to ignore a linefeed character if the previous character was a + // carriage return + // @todo refactor to reduce nesting, consider checking previous byte? + // @todo but consider multiple chunks etc + if (discardTrailingNewline) { + if (buffer[position] === '\n') { + ++position + } + discardTrailingNewline = false + } + + let lineLength = -1 + let fieldLength = startingFieldLength + let character + + for ( + let index = startingPosition; + lineLength < 0 && index < length; + ++index + ) { + character = buffer[index] + if (character === ':' && fieldLength < 0) { + fieldLength = index - position + } else if (character === '\r') { + discardTrailingNewline = true + lineLength = index - position + } else if (character === '\n') { + lineLength = index - position + } + } + + if (lineLength < 0) { + startingPosition = length - position + startingFieldLength = fieldLength + break + } else { + startingPosition = 0 + startingFieldLength = -1 + } + + parseEventStreamLine(buffer, position, fieldLength, lineLength) + + position += lineLength + 1 + } + + if (position === length) { + // If we consumed the entire buffer to read the event, reset the buffer + buffer = '' + } else if (position > 0) { + // If there are bytes left to process, set the buffer to the unprocessed + // portion of the buffer only + buffer = buffer.slice(position) + } + } + + function parseEventStreamLine ( + lineBuffer, + index, + fieldLength, + lineLength + ) { + if (lineLength === 0) { + // We reached the last line of this event + if (data.length > 0) { + onParse({ + type: 'event', + id: eventId, + event: eventName || undefined, + data: data.slice(0, -1) // remove trailing newline + }) + + data = '' + eventId = undefined + } + eventName = undefined + return + } + + const noValue = fieldLength < 0 + const field = lineBuffer.slice( + index, + index + (noValue ? lineLength : fieldLength) + ) + let step = 0 + + if (noValue) { + step = lineLength + } else if (lineBuffer[index + fieldLength + 1] === ' ') { + step = fieldLength + 2 + } else { + step = fieldLength + 1 + } + + const position = index + step + const valueLength = lineLength - step + const value = lineBuffer + .slice(position, position + valueLength) + .toString() + + if (field === 'data') { + data += value ? `${value}\n` : '\n' + } else if (field === 'event') { + eventName = value + } else if (field === 'id' && !value.includes('\u0000')) { + eventId = value + } else if (field === 'retry') { + const retry = parseInt(value, 10) + if (!Number.isNaN(retry)) { + onParse({ type: 'reconnect-interval', value: retry }) + } + } + } + } + function hasBom (buffer) { + return BOM.every( + (charCode, index) => buffer.charCodeAt(index) === charCode + ) + } + + /** + TODO: Remove AbortError and just throw DOMException when targeting Node 18. + */ + function getDOMException (errorMessage) { + return globalThis.DOMException === undefined + ? new Error(errorMessage) + : new DOMException(errorMessage) + } + + /** + TODO: Remove below function and just 'reject(signal.reason)' when targeting Node 18. + */ + function getAbortedReason (signal) { + const reason = + signal.reason === undefined + ? getDOMException('This operation was aborted.') + : signal.reason + + return reason instanceof Error ? reason : getDOMException(reason) + } + + // @see https://github.com/sindresorhus/p-timeout + function pTimeout ( + promise, + options + ) { + const { + milliseconds, + fallback, + message, + customTimers = { setTimeout, clearTimeout } + } = options + + let timer + + const cancelablePromise = new Promise((resolve, reject) => { + if (typeof milliseconds !== 'number' || Math.sign(milliseconds) !== 1) { + throw new TypeError( + `Expected \`milliseconds\` to be a positive number, got \`${milliseconds}\`` + ) + } + + if (milliseconds === Number.POSITIVE_INFINITY) { + resolve(promise) + return + } + + if (options.signal) { + const { signal } = options + if (signal.aborted) { + reject(getAbortedReason(signal)) + } + + signal.addEventListener('abort', () => { + reject(getAbortedReason(signal)) + }) + } + + timer = customTimers.setTimeout.call( + undefined, + () => { + if (fallback) { + try { + resolve(fallback()) + } catch (error) { + reject(error) + } + + return + } + + const errorMessage = + typeof message === 'string' + ? message + : `Promise timed out after ${milliseconds} milliseconds` + const timeoutError = + message instanceof Error ? message : new TimeoutError(errorMessage) + + if (typeof promise.cancel === 'function') { + promise.cancel() + } + + reject(timeoutError) + }, + milliseconds + ) + ;(async () => { + try { + resolve(await promise) + } catch (error) { + reject(error) + } finally { + customTimers.clearTimeout.call(undefined, timer) + } + })() + }) + + cancelablePromise.clear = () => { + customTimers.clearTimeout.call(undefined, timer) + timer = undefined + } + + return cancelablePromise + } +} diff --git a/utils/openai-auth.js b/utils/openai-auth.js new file mode 100644 index 0000000..53fed67 --- /dev/null +++ b/utils/openai-auth.js @@ -0,0 +1,202 @@ +import { Config } from '../config/index.js' +import delay from 'delay' + +let hasRecaptchaPlugin = !!Config['2captchaToken'] + +export async function getOpenAIAuth (opt) { + let { + email, + password, + browser, + page, + timeoutMs = 2 * 60 * 1000, + isGoogleLogin = false, + captchaToken = Config['2captchaToken'], + executablePath = Config.chromePath + } = opt + const origBrowser = browser + const origPage = page + + try { + const userAgent = await browser.userAgent() + if (!page) { + page = (await browser.pages())[0] || (await browser.newPage()) + page.setDefaultTimeout(timeoutMs) + } + await page.goto('https://chat.openai.com/auth/login', { + waitUntil: 'networkidle2' + }) + + // NOTE: this is where you may encounter a CAPTCHA + if (hasRecaptchaPlugin) { + await page.solveRecaptchas() + } + + await checkForChatGPTAtCapacity(page) + + // once we get to this point, the Cloudflare cookies should be available + + // login as well (optional) + if (email && password) { + await waitForConditionOrAtCapacity(page, () => + page.waitForSelector('#__next .btn-primary', { timeout: timeoutMs }) + ) + await delay(500) + + // click login button and wait for navigation to finish + await Promise.all([ + page.waitForNavigation({ + waitUntil: 'networkidle2', + timeout: timeoutMs + }), + + page.click('#__next .btn-primary') + ]) + + await checkForChatGPTAtCapacity(page) + + let submitP + + if (isGoogleLogin) { + await page.click('button[data-provider="google"]') + await page.waitForSelector('input[type="email"]') + await page.type('input[type="email"]', email, { delay: 10 }) + await Promise.all([ + page.waitForNavigation(), + await page.keyboard.press('Enter') + ]) + await page.waitForSelector('input[type="password"]', { visible: true }) + await page.type('input[type="password"]', password, { delay: 10 }) + submitP = () => page.keyboard.press('Enter') + } else { + await page.waitForSelector('#username') + await page.type('#username', email, { delay: 20 }) + await delay(100) + + if (hasRecaptchaPlugin) { + // console.log('solveRecaptchas()') + const res = await page.solveRecaptchas() + // console.log('solveRecaptchas result', res) + } + + await page.click('button[type="submit"]') + await page.waitForSelector('#password', { timeout: timeoutMs }) + await page.type('#password', password, { delay: 10 }) + submitP = () => page.click('button[type="submit"]') + } + + await Promise.all([ + waitForConditionOrAtCapacity(page, () => + page.waitForNavigation({ + waitUntil: 'networkidle2', + timeout: timeoutMs + }) + ), + submitP() + ]) + } else { + await delay(2000) + await checkForChatGPTAtCapacity(page) + } + + const pageCookies = await page.cookies() + await redis.set('CHATGPT:RAW_COOKIES', JSON.stringify(pageCookies)) + const cookies = pageCookies.reduce( + (map, cookie) => ({ ...map, [cookie.name]: cookie }), + {} + ) + + const authInfo = { + userAgent, + clearanceToken: cookies.cf_clearance?.value, + sessionToken: cookies['__Secure-next-auth.session-token']?.value, + cookies + } + + return authInfo + } catch (err) { + throw err + } finally { + if (origBrowser) { + if (page && page !== origPage) { + await page.close() + } + } else if (browser) { + await browser.close() + } + + page = null + browser = null + } +} + +async function checkForChatGPTAtCapacity (page) { + // console.log('checkForChatGPTAtCapacity', page.url()) + let res + + try { + res = await page.$x("//div[contains(., 'ChatGPT is at capacity')]") + // console.log('capacity1', els) + // if (els?.length) { + // res = await Promise.all( + // els.map((a) => a.evaluate((el) => el.textContent)) + // ) + // console.log('capacity2', res) + // } + } catch (err) { + // ignore errors likely due to navigation + } + + if (res?.length) { + const error = new Error('ChatGPT is at capacity') + error.statusCode = 503 + throw error + } +} + +async function waitForConditionOrAtCapacity ( + page, + condition, + opts = {} +) { + const { pollingIntervalMs = 500 } = opts + + return new Promise((resolve, reject) => { + let resolved = false + + async function waitForCapacityText () { + if (resolved) { + return + } + + try { + await checkForChatGPTAtCapacity(page) + + if (!resolved) { + setTimeout(waitForCapacityText, pollingIntervalMs) + } + } catch (err) { + if (!resolved) { + resolved = true + return reject(err) + } + } + } + + condition() + .then(() => { + if (!resolved) { + resolved = true + resolve() + } + }) + .catch((err) => { + if (!resolved) { + resolved = true + reject(err) + } + }) + + setTimeout(waitForCapacityText, pollingIntervalMs) + }) +} diff --git a/utils/text.js b/utils/text.js deleted file mode 100644 index 6cd9dd3..0000000 --- a/utils/text.js +++ /dev/null @@ -1,9 +0,0 @@ - -/** - * 判断一段markdown文档中是否包含代码 - * @param text - */ -export function codeExists (text = '') { - let regex = /^[\s\S]*\$.*\$[\s\S]*/ - return regex.test(text) -}