diff --git a/README.md b/README.md index 32d5b0a..1251074 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,40 @@ # 云崽qq机器人的chatgpt插件 * 支持单人连续对话Conversation -* 目前使用 GPT-3 API尽可能逼近ChatGPT体验,支持模型参数调整 ~~使用`text-chat-davinci-002-sh-alpha-aoruigiofdj83`模型,原汁原味ChatGPT体验(只存活了五分钟)~~ +* API模式下,使用 GPT-3 API及相关模型配置尽可能逼近ChatGPT体验,支持自定义部分模型参数 ~~使用`text-chat-davinci-002-sh-alpha-aoruigiofdj83`模型,原汁原味ChatGPT体验(20230211只存活了五分钟)~~ * 支持问答图片截图 * 仅需OpenAI Api Key,开箱即用 +* 提供基于浏览器的解决方案作为备选,有条件且希望得到更好回答质量可以选择使用浏览器模式。 ## 版本要求 Node.js >= 18 / Node.js >= 14(with node-fetch) - ## 安装 +首先判断自己需要使用哪种模式,本插件支持API和浏览器两种模式。也可以选择**我全都要**,通过qq发送命令`#chatgpt切换浏览器/API`实时切换。对于轻量用户可以先使用API模式,有较高要求再转为使用浏览器模式。 + +> API模式和浏览器模式如何选择? +> +> * API模式会调用OpenAI官方提供的GPT-3 LLM API,只需要提供API Key。一般情况下,该种方式响应速度更快,可配置项多,且不会像chatGPT官网一样总出现不可用的现象,但其聊天效果明显较官网差。但注意GPT-3的API调用是收费的,新用户有18美元试用金可用于支付,价格为`$0.0200/ 1K tokens`.(问题和回答加起来算token) +> * 浏览器模式通过在本地启动Chrome等浏览器模拟用户访问ChatGPT网站,使得获得和官方一模一样的回复质量。缺点是本方法对环境要求较高,需要提供桌面环境和一个可用的代理(能够访问ChatGPT的IP地址),且响应速度不如API,而且高峰期容易无法使用。 + 1. 进入 Yunzai根目录 2. 检查 Node.js 版本,并根据对应的 Node.js 版本选择安装教程。 + ``` node -v ``` ---- - ### Node.js >= 18 + 1. 进入 Yunzai根目录 2. 安装依赖 + ``` -pnpm install -w undici chatgpt showdown mathjax-node delay uuid remark strip-markdown +pnpm install -w undici chatgpt showdown mathjax-node delay uuid remark strip-markdown random puppeteer-extra-plugin-recaptcha puppeteer-extra puppeteer-extra-plugin-stealth ``` -**chatgpt的版本号注意要大于4.0.0** + +**若使用API模式,chatgpt的版本号注意要大于4.2.0** + +若不使用浏览器模式,可以不安装`random puppeteer-extra-plugin-recaptcha puppeteer-extra puppeteer-extra-plugin-stealth`这几个 3. 克隆项目 ``` @@ -31,7 +42,7 @@ git clone https://github.com/ikechan8370/chatgpt-plugin.git ./plugins/chatgpt-pl ``` 4. 修改配置 -编辑`plugins/chatgpt-plugin/config/index.js`文件主要修改其中的`apiKey` +编辑`plugins/chatgpt-plugin/config/index.js`文件,根据其中的注释修改必要配置项。 --- @@ -44,7 +55,9 @@ git clone https://github.com/ikechan8370/chatgpt-plugin.git ./plugins/chatgpt-pl ``` pnpm install -w undici chatgpt showdown mathjax-node delay uuid remark strip-markdown node-fetch ``` -**chatgpt的版本号注意要大于4.0.0** +**若使用API模式,chatgpt的版本号注意要大于4.2.0** + +若不使用浏览器模式,可以不安装`random puppeteer-extra-plugin-recaptcha puppeteer-extra puppeteer-extra-plugin-stealth`这几个 3. 克隆项目 ``` @@ -63,7 +76,7 @@ import fetch from 'node-fetch'; globalThis.fetch = fetch; ``` -再编辑`Yunzai根目录/plugins/chatgpt-plugin/config/index.js`文件,主要修改其中的`apiKey` +再编辑`Yunzai根目录/plugins/chatgpt-plugin/config/index.js`文件,根据其中的注释修改必要配置项。 --- @@ -92,27 +105,38 @@ globalThis.fetch = fetch; ## TODO * 更灵活的Conversation管理 -* 恢复网页版支持 (Browser Based Solution) * 支持Bing版本 -## 关于openai token获取 +## 关于openai账号 1. 注册openai账号 进入https://chat.openai.com/ ,选择signup注册。目前openai不对包括俄罗斯、乌克兰、伊朗、中国等国家和地区提供服务,所以自行寻找办法使用其他国家和地区的ip登录。此外,注册可能需要验证所在国家和地区的手机号码,如果没有国外手机号可以试试解码网站,收费的推荐https://sms-activate.org/。 2. 获取API key 进入账户后台创建API key:https://platform.openai.com/account/api-keys -其他问题可以参考使用的api库https://github.com/transitive-bullshit/chatgpt-api +其他问题可以参考使用的api库 https://github.com/transitive-bullshit/chatgpt-api ## 其他 -OpenAI 即将开放其官方ChatGPT API,请等待此部分内容更新。 +### 关于未来更新 -> 该api响应速度可能由于模型本身及网络原因不会太快,请勿频繁重发。因网络问题和模型响应速度问题可能出现500、503、404等各种异常状态码,此时等待官方恢复即可。实测复杂的中文对话更容易触发503错误(超时)。如出现429则意味着超出了免费账户调用频率,只能暂时停用,放置一段时间再继续使用。 -> -> ~~openai目前开放chatgpt模型的免费试用,在此期间本项目应该都可用,后续如果openai调整其收费策略,到时候视情况进行调整。~~ GPT-3的API调用是收费的,新用户有18美元试用金可用于支付,价格为`$0.0200/ 1K tokens`,问题和回答加起来算。 -> -> 如果在linux系统上发现emoj无法正常显示,可以搜索安装支持emoj的字体,如Ubuntu可以使用`sudo apt install fonts-noto-color-emoji` +OpenAI 即将开放其官方ChatGPT API,且微软必应也公开发布了基于ChatGPT的问答搜索,能够为实现更好、更快的聊天机器人提供更多途径。 + +### 常见问题 + +1. 如果在linux系统上发现图片模式下emoj无法正常显示,可以搜索安装支持emoj的字体,如Ubuntu可以使用`sudo apt install fonts-noto-color-emoji` + +2. linux云服务器可以安装窗口管理器和vnc创建并访问虚拟桌面环境。 + + > 以ubuntu为例给出一个可行的方案: + > + > 1. 安装xvfb和fluxbox + > `sudo apt-get install x11vnc xvfb fluxbox` + > 2. 启动桌面环境。建议用tmux或screen等使其能够后台运行。注意本命令使用默认5900端口和无密码,注意通过防火墙等保护。 + > `x11vnc -create -env FD_PROG=/usr/bin/fluxbox -env X11VNC_FINDDISPLAY_ALWAYS_FAILS=1 -env X11VNC_CREATE_GEOM=${1:-1024x768x16} -nopw -forever` + > 3. 使用vnc客户端连接至云桌面,右键Applications > Shells > Bash打开终端,然后进入Yunzai目录下运行node app即可。 + > + > 实测该方案资源占用低,运行稳定,基本1核2G的轻量云服务器就足够了。 ## 感谢 * https://github.com/transitive-bullshit/chatgpt-api diff --git a/apps/chat.js b/apps/chat.js index d99c5d6..af3ccbf 100644 --- a/apps/chat.js +++ b/apps/chat.js @@ -7,6 +7,7 @@ import { uuid } from 'oicq/lib/common.js' import delay from 'delay' import { ChatGPTAPI } from 'chatgpt' import { getMessageById, tryTimes, upsertMessage } from '../utils/common.js' +import { ChatGPTPuppeteer } from '../utils/browser.js' // import puppeteer from '../utils/browser.js' // import showdownKatex from 'showdown-katex' const blockWords = Config.blockWords @@ -78,11 +79,23 @@ export class chatgpt extends plugin { }, { reg: '#清空(chat)?队列', - fnc: 'emptyQueue' + fnc: 'emptyQueue', + permission: 'master' }, { reg: '#移出(chat)?队列首位', - fnc: 'removeQueueFirst' + fnc: 'removeQueueFirst', + permission: 'master' + }, + { + reg: '#chatgpt切换浏览器', + fnc: 'useBrowserBasedSolution', + permission: 'master' + }, + { + reg: '#chatgpt切换[(api)|(API)]', + fnc: 'useOpenAIAPIBasedSolution', + permission: 'master' } ] }) @@ -194,19 +207,6 @@ export class chatgpt extends plugin { return false } } - let completionParams = {} - if (Config.model) { - completionParams.model = Config.model - } - - this.chatGPTApi = new ChatGPTAPI({ - apiKey: Config.apiKey, - debug: false, - upsertMessage, - getMessageById, - completionParams, - assistantLabel: Config.assistantLabel - }) let randomId = uuid() // 队列队尾插入,开始排队 @@ -258,16 +258,17 @@ export class chatgpt extends plugin { } try { - let chatMessage = await this.sendMessage(prompt, conversation, this.chatGPTApi) + let chatMessage = await this.sendMessage(prompt, conversation) previousConversation.conversation = { conversationId: chatMessage.conversationId, parentMessageId: chatMessage.id } + console.log(chatMessage) let response = chatMessage?.text previousConversation.num = previousConversation.num + 1 await redis.set(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`, JSON.stringify(previousConversation), CONVERSATION_PRESERVE_TIME > 0 ? { EX: CONVERSATION_PRESERVE_TIME } : {}) // 检索是否有屏蔽词 - const blockWord = blockWords.split(',').find(word => response.toLowerCase().includes(word.toLowerCase())) + const blockWord = blockWords.find(word => response.toLowerCase().includes(word.toLowerCase())) if (blockWord) { await this.reply('返回内容存在敏感词,我不想回答你', true) return false @@ -289,7 +290,7 @@ export class chatgpt extends plugin { // !response.trimEnd().endsWith('!') && !response.trimEnd().endsWith('!') && !response.trimEnd().endsWith(']') && !response.trimEnd().endsWith('】') // ) { await this.reply('内容有点多,我正在奋笔疾书,请再等一会', true, { recallMsg: 5 }) - let responseAppend = await this.sendMessage('Continue', conversation, this.chatGPTApi) + let responseAppend = await this.sendMessage('Continue', conversation) previousConversation.conversation = { conversationId: responseAppend.conversationId, parentMessageId: responseAppend.id @@ -298,7 +299,7 @@ export class chatgpt extends plugin { await redis.set(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`, JSON.stringify(previousConversation), CONVERSATION_PRESERVE_TIME > 0 ? { EX: CONVERSATION_PRESERVE_TIME } : {}) // console.log(responseAppend) // 检索是否有屏蔽词 - const blockWord = blockWords.split(',').find(word => responseAppendText.toLowerCase().includes(word.toLowerCase())) + const blockWord = blockWords.find(word => responseAppendText.toLowerCase().includes(word.toLowerCase())) if (blockWord) { await this.reply('返回内容存在敏感词,我不想回答你', true) return @@ -330,18 +331,46 @@ export class chatgpt extends plugin { } } - async sendMessage (prompt, conversation, api) { - const currentDate = new Date().toISOString().split('T')[0] - let promptPrefix = `You are ${Config.assistantLabel}, a large language model trained by OpenAI. ${Config.promptPrefixOverride || defaultPropmtPrefix} + async sendMessage (prompt, conversation) { + const use = await redis.get('CHATGPT:USE') + console.log(use) + if (use === 'browser') { + return await this.chatgptBrowserBased(prompt, conversation) + } else { + let completionParams = {} + if (Config.model) { + completionParams.model = Config.model + } + this.chatGPTApi = new ChatGPTAPI({ + apiKey: Config.apiKey, + debug: false, + upsertMessage, + getMessageById, + completionParams, + assistantLabel: Config.assistantLabel + }) + const currentDate = new Date().toISOString().split('T')[0] + let promptPrefix = `You are ${Config.assistantLabel}, a large language model trained by OpenAI. ${Config.promptPrefixOverride || defaultPropmtPrefix} Current date: ${currentDate}` - let option = { - timeoutMs: 120000, - promptPrefix + let option = { + timeoutMs: 120000, + promptPrefix + } + if (conversation) { + option = Object.assign(option, conversation) + } + return await tryTimes(async () => await this.chatGPTApi.sendMessage(prompt, option), 5) } - if (conversation) { - option = Object.assign(option, conversation) - } - return await tryTimes(async () => await api.sendMessage(prompt, option), 5) + } + + async useBrowserBasedSolution (e) { + await redis.set('CHATGPT:USE', 'browser') + await this.reply('已切换到基于浏览器的解决方案') + } + + async useOpenAIAPIBasedSolution (e) { + await redis.set('CHATGPT:USE', 'api') + await this.reply('已切换到基于OpenAI API的解决方案') } async emptyQueue (e) { @@ -357,4 +386,28 @@ export class chatgpt extends plugin { await this.reply('已移出等待队列首位: ' + uid) } } + + /** + * #chatgpt + * @param prompt 问题 + * @param conversation 对话 + */ + async chatgptBrowserBased (prompt, conversation) { + let option = { markdown: true } + if (Config['2captchaToken']) { + option.captchaToken = Config['2captchaToken'] + } + // option.debug = true + option.email = Config.username + option.password = Config.password + this.chatGPTApi = new ChatGPTPuppeteer(option) + logger.info(`chatgpt prompt: ${prompt}`) + let sendMessageOption = { + timeoutMs: 120000 + } + if (conversation) { + sendMessageOption = Object.assign(sendMessageOption, conversation) + } + return await this.chatGPTApi.sendMessage(prompt, sendMessageOption) + } } diff --git a/apps/help.js b/apps/help.js index cab7fe5..91adf87 100644 --- a/apps/help.js +++ b/apps/help.js @@ -46,19 +46,24 @@ let helpData = [ desc: '结束该用户当前对话,下次开启对话机器人将遗忘掉本次对话内容。' }, { - icon: 'destroy', + icon: 'queue', title: '#清空chat队列', desc: '清空当前对话等待队列。仅建议前方卡死时使用。' }, { - icon: 'destroy-other', + icon: 'queue', title: '#移出chat队列首位', desc: '移出当前对话等待队列中的首位。若前方对话卡死可使用本命令。' }, { - icon: 'destroy-other', + icon: 'confirm', title: '#chatgpt开启/关闭问题确认', - desc: '开启或关闭机器人收到消息后的确认回复消息。私聊无效。' + desc: '开启或关闭机器人收到消息后的确认回复消息。' + }, + { + icon: 'switch', + title: '#chatgpt切换浏览器/API', + desc: '切换使用的后端为浏览器或OpenAI API' }, { icon: 'help', diff --git a/apps/management.js b/apps/management.js index 52dfc47..0fa287a 100644 --- a/apps/management.js +++ b/apps/management.js @@ -10,11 +10,13 @@ export class ChatgptManagement extends plugin { rule: [ { reg: '#chatgpt开启(问题)?(回复)?确认', - fnc: 'turnOnConfirm' + fnc: 'turnOnConfirm', + permission: 'master' }, { reg: '#chatgpt关闭(问题)?(回复)?确认', - fnc: 'turnOffConfirm' + fnc: 'turnOffConfirm', + permission: 'master' } ] }) diff --git a/config/index.js b/config/index.js index 5db8cde..4a39058 100644 --- a/config/index.js +++ b/config/index.js @@ -1,25 +1,45 @@ +// 例如http://127.0.0.1:7890 const PROXY = '' const API_KEY = '' export const Config = { - // 模型名称。如无特殊需求保持默认即可,会使用chatgpt-api库提供的当前可用的最适合的默认值。保底可用的是 text-davinci-003。当发现新的可用的chatGPT模型会更新这里的值 - // 20230211: text-chat-davinci-002-sh-alpha-aoruigiofdj83 中午存活了几分钟 - model: '', - // 如果回答包括屏蔽词,就不返回。例如:'屏蔽词1,屏蔽词2,屏蔽词3' - blockWords: '', - apiKey: API_KEY, - // 暂时不支持proxy - proxy: PROXY, - // 改为true后,全局默认以图片形式回复,并自动发出Continue命令补全回答 + // *********************************************************************************************************************************** + // 通用配置 * + // *********************************************************************************************************************************** + // 如果回答包括屏蔽词,就不返回。 + blockWords: ['屏蔽词1', '屏蔽词b'], + // 改为true后,全局默认以图片形式回复,并自动发出Continue命令补全回答。长回复可能会有bug。 defaultUsePicture: false, // 每个人发起的对话保留时长。超过这个时长没有进行对话,再进行对话将开启新的对话。单位:秒 conversationPreserveTime: 0, // 触发方式 可选值:at 或 prefix 。at模式下只有at机器人才会回复。prefix模式下不需要at,但需要添加前缀#chat toggleMode: 'prefix', - // 默认完整值:`You are ${this._assistantLabel}, a large language model trained by OpenAI. You answer as concisely as possible for each response (e.g. don’t be verbose). It is very important that you answer as concisely as possible, so please remember this. If you are generating a list, do not have too many items. Keep the number of items short. Current date: ${currentDate}\n\n + // *********************************************************************************************************************************** + // 以下为API方式(默认)的配置 * + // *********************************************************************************************************************************** + apiKey: API_KEY, + // 模型名称,选填。如无特殊需求保持默认即可,会使用chatgpt-api库提供的当前可用的最适合的默认值。保底可用的是 text-davinci-003。当发现新的可用的chatGPT模型会更新这里的值 + // 20230211: text-chat-davinci-002-sh-alpha-aoruigiofdj83 中午存活了几分钟 + model: '', + // 给模型的暗示promt。选填。默认完整值:`You are ${this._assistantLabel}, a large language model trained by OpenAI. You answer as concisely as possible for each response (e.g. don’t be verbose). It is very important that you answer as concisely as possible, so please remember this. If you are generating a list, do not have too many items. Keep the number of items short. Current date: ${currentDate}\n\n // 此项配置会覆盖掉中间部分。保持为空将使用网友从对话中推测出的指令。 // 你可以在这里写入你希望AI回答的风格,比如希望优先回答中文,回答长一点等 promptPrefixOverride: 'Your answer shouldn\'t be too verbose. If you are generating a list, do not have too many items. Keep the number of items short. Prefer to answer in Chinese.', // AI认为的自己的名字,当你问他你是谁是他会回答这里的名字。 - assistantLabel: 'ChatGPT' + assistantLabel: 'ChatGPT', + // *********************************************************************************************************************************** + // 以下为浏览器方式的配置 * + // *********************************************************************************************************************************** + username: '', + password: '', + // UA: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', + // 服务器无interface的话只能用true,但是可能遇到验证码就一定要配置下面的2captchaToken了 + // true时使用无头模式,无界面的服务器可以为true,但遇到验证码时可能无法使用。(实测很容易卡住,几乎不可用) + headless: false, + // 为空使用默认puppeteer的chromium,也可以传递自己本机安装的Chrome可执行文件地址,提高通过率。windows可以是‘C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe’,linux通过which查找路径 + chromePath: '', + // 可注册2captcha实现跳过验证码,收费服务但很便宜。否则可能会遇到验证码而卡住。 + '2captchaToken': '', + // http或socks5代理 + proxy: PROXY } diff --git a/resources/help/help.css b/resources/help/help.css index d89db75..6be8707 100644 --- a/resources/help/help.css +++ b/resources/help/help.css @@ -13,13 +13,13 @@ body { font-family: sans-serif; font-size: 16px; - width: 530px; + width: 630px; color: #1e1f20; transform: scale(1.5); transform-origin: 0 0; } .container { - width: 530px; + width: 630px; padding: 20px 15px 10px 15px; background-color: #f5f6fb; } @@ -98,7 +98,7 @@ body { } .list .item { - width: 235px; + width: 285px; display: flex; align-items: center; background: #f1f1f1; diff --git a/resources/img/icon/confirm.png b/resources/img/icon/confirm.png new file mode 100644 index 0000000..518617d Binary files /dev/null and b/resources/img/icon/confirm.png differ diff --git a/resources/img/icon/queue.png b/resources/img/icon/queue.png new file mode 100644 index 0000000..7359e16 Binary files /dev/null and b/resources/img/icon/queue.png differ diff --git a/resources/img/icon/switch.png b/resources/img/icon/switch.png new file mode 100644 index 0000000..4e96057 Binary files /dev/null and b/resources/img/icon/switch.png differ diff --git a/utils/browser.js b/utils/browser.js new file mode 100644 index 0000000..e6ac7f1 --- /dev/null +++ b/utils/browser.js @@ -0,0 +1,1107 @@ +import lodash from 'lodash' +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' + +const chatUrl = 'https://chat.openai.com/chat' +let puppeteer = {} + +class Puppeteer { + constructor () { + let args = [ + '--exclude-switches', + '--no-sandbox', + 'enable-automation', + '--remote-debugging-port=51777', + '--disable-setuid-sandbox', + '--disable-infobars', + '--disable-dev-shm-usage', + '--disable-blink-features=AutomationControlled', + '--ignore-certificate-errors', + '--no-first-run', + '--no-service-autorun', + '--password-store=basic', + '--system-developer-mode', + '--mute-audio', + '--disable-default-apps', + '--no-zygote', + '--disable-accelerated-2d-canvas', + '--disable-web-security' + // '--shm-size=1gb' + ] + if (Config.proxy) { + args.push(`--proxy-server=${Config.proxy}`) + } + this.browser = false + this.lock = false + this.config = { + headless: Config.headless, + args + } + + if (Config.chromePath) { + this.config.executablePath = Config.chromePath + } + + this.html = {} + } + + async initPupp () { + if (!lodash.isEmpty(puppeteer)) return 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 + } + + /** + * 初始化chromium + */ + async browserInit () { + await this.initPupp() + if (this.browser) return this.browser + if (this.lock) return false + this.lock = true + + logger.mark('chatgpt puppeteer 启动中...') + const browserURL = 'http://127.0.0.1:51777' + try { + this.browser = await puppeteer.connect({ browserURL }) + } catch (e) { + /** 初始化puppeteer */ + this.browser = await puppeteer.launch(this.config).catch((err) => { + logger.error(err.toString()) + if (String(err).includes('correct Chromium')) { + logger.error('没有正确安装Chromium,可以尝试执行安装命令:node ./node_modules/puppeteer/install.js') + } + }) + } + this.lock = false + + if (!this.browser) { + logger.error('chatgpt puppeteer 启动失败') + return false + } + + logger.mark('chatgpt puppeteer 启动成功') + + /** 监听Chromium实例是否断开 */ + this.browser.on('disconnected', (e) => { + logger.info('Chromium实例关闭或崩溃!') + this.browser = false + }) + + return this.browser + } +} + +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 + } else { + return await this.browserInit() + } + } + + async init () { + // if (this.inited) { + // return true + // } + logger.info('init chatgpt browser') + 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)) + } + // const url = this._page.url().replace(/\/$/, '') + // bypass annoying popup modals + await this._page.evaluateOnNewDocument(() => { + window.localStorage.setItem('oai/apps/hasSeenOnboarding/chat', 'true') + const chatGPTUpdateDates = ['2022-12-15', '2022-12-19', '2023-01-09', '2023-01-30', '2023-02-10'] + chatGPTUpdateDates.forEach(date => { + window.localStorage.setItem( + `oai/apps/hasSeenReleaseAnnouncement/${date}`, + 'true' + ) + }) + }) + await this._page.goto(chatUrl, { + waitUntil: 'networkidle2' + }) + let timeout = 30000 + try { + while (timeout > 0 && (await this._page.title()).toLowerCase().indexOf('moment') > -1) { + // if meet captcha + if (Config['2captchaToken']) { + await this._page.solveRecaptchas() + } + await delay(300) + timeout = timeout - 300 + } + } catch (e) { + // navigation后获取title会报错,报错说明已经在navigation了正合我意。 + } + if (timeout < 0) { + logger.error('wait for cloudflare navigation timeout. 可能遇见验证码') + throw new Error('wait for cloudflare navigation timeout. 可能遇见验证码') + } + try { + await this._page.waitForNavigation({ timeout: 3000 }) + } catch (e) {} + + if (!await this.getIsAuthenticated()) { + await redis.del('CHATGPT:RAW_COOKIES') + logger.info('需要登录,准备进行自动化登录') + await getOpenAIAuth({ + email: this._email, + password: this._password, + browser: this.browser, + page: this._page, + isGoogleLogin: this._isGoogleLogin + }) + logger.info('登录完成') + } else { + logger.info('无需登录') + } + } 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() + ) + throw err + } + let timeout = 10000 + if (isAuthenticated) { + while (!this._accessToken) { + // wait for async response hook result + await delay(300) + timeout = timeout - 300 + if (timeout < 0) { + const error = new Error('Not signed in') + error.statusCode = 401 + throw error + } + } + } else if (!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 { + text: result.response, + conversationId: result.conversationResponse.conversation_id, + id: messageId, + parentMessageId + } + + // 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 () { + if (this.browser) { + 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 _checkJson = JSON.parse(data) + } catch (error) { + console.log('warning: parse error.') + return + } + 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 Error(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..280bf79 --- /dev/null +++ b/utils/openai-auth.js @@ -0,0 +1,279 @@ +import { Config } from '../config/index.js' +import delay from 'delay' +import random from 'random' + +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' + }) + logger.mark('chatgpt checkForChatGPTAtCapacity') + + await checkForChatGPTAtCapacity(page) + + // NOTE: this is where you may encounter a CAPTCHA + if (hasRecaptchaPlugin) { + logger.mark('RecaptchaPlugin key exists, try to solve recaptchas') + await page.solveRecaptchas() + } + + logger.mark('chatgpt checkForChatGPTAtCapacity again') + await checkForChatGPTAtCapacity(page) + + // once we get to this point, the Cloudflare cookies should be available + + // login as well (optional) + if (email && password) { + let retry = 3 + while (retry > 0) { + try { + await waitForConditionOrAtCapacity(page, () => + page.waitForSelector('#__next .btn-primary', { timeout: 2000 }) + ) + } catch (e) { + await checkForChatGPTAtCapacity(page) + } + retry-- + } + await waitForConditionOrAtCapacity(page, () => + page.waitForSelector('#__next .btn-primary', { timeout: 2000 }) + ) + 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 + } + logger.info('chatgpt登录成功') + + return authInfo + } catch (err) { + throw err + } finally { + await page.screenshot({ + type: 'png', + path: './error.png' + }) + if (origBrowser) { + if (page && page !== origPage) { + await page.close() + } + } else if (browser) { + await browser.close() + } + + page = null + browser = null + } +} + +async function checkForChatGPTAtCapacity (page, opts = {}) { + const { + timeoutMs = 2 * 60 * 1000, // 2 minutes + pollingIntervalMs = 3000, + retries = 10 + } = opts + + // console.log('checkForChatGPTAtCapacity', page.url()) + let isAtCapacity = false + let numTries = 0 + + do { + try { + await solveSimpleCaptchas(page) + + const res = await page.$x("//div[contains(., 'ChatGPT is at capacity')]") + isAtCapacity = !!res?.length + + if (isAtCapacity) { + if (++numTries >= retries) { + break + } + + // try refreshing the page if chatgpt is at capacity + await page.reload({ + waitUntil: 'networkidle2', + timeout: timeoutMs + }) + + await delay(pollingIntervalMs) + } + } catch (err) { + // ignore errors likely due to navigation + ++numTries + break + } + } while (isAtCapacity) + + if (isAtCapacity) { + 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) + }) +} + +async function solveSimpleCaptchas (page) { + try { + const verifyYouAreHuman = await page.$('text=Verify you are human') + if (verifyYouAreHuman) { + logger.mark('encounter cloudflare simple captcha "Verify you are human"') + await delay(2000) + await verifyYouAreHuman.click({ + delay: random.int(5, 25) + }) + await delay(1000) + } + const verifyYouAreHumanCN = await page.$('text=确认您是真人') + if (verifyYouAreHumanCN) { + logger.mark('encounter cloudflare simple captcha "确认您是真人"') + await delay(2000) + await verifyYouAreHumanCN.click({ + delay: random.int(5, 25) + }) + await delay(1000) + } + + const cloudflareButton = await page.$('.hcaptcha-box') + if (cloudflareButton) { + await delay(2000) + await cloudflareButton.click({ + delay: random.int(5, 25) + }) + await delay(1000) + } + } catch (err) { + // ignore errors + } +}