import delay from 'delay' import type { Browser, HTTPRequest, HTTPResponse, Page } from 'puppeteer' import { v4 as uuidv4 } from 'uuid' import * as types from './types' import { AChatGPTAPI } from './abstract-chatgpt-api' import { getBrowser, getOpenAIAuth } from './openai-auth' import { browserPostEventStream, isRelevantRequest, markdownToText, maximizePage, minimizePage } from './utils' const CHAT_PAGE_URL = 'https://chat.openai.com/chat' export class ChatGPTAPIBrowser extends AChatGPTAPI { protected _markdown: boolean protected _debug: boolean protected _minimize: boolean protected _isGoogleLogin: boolean protected _isWindowsLogin: boolean protected _captchaToken: string protected _accessToken: string protected _email: string protected _password: string protected _executablePath: string protected _browser: Browser protected _page: Page /** * Creates a new client for automating the ChatGPT webapp. */ constructor(opts: { email: string password: string /** @defaultValue `true` **/ markdown?: boolean /** @defaultValue `false` **/ debug?: boolean /** @defaultValue `false` **/ isGoogleLogin?: boolean /** @defaultValue `false` **/ isWindowsLogin?: boolean /** @defaultValue `true` **/ minimize?: boolean /** @defaultValue `undefined` **/ captchaToken?: string /** @defaultValue `undefined` **/ executablePath?: string }) { super() const { email, password, markdown = true, debug = false, isGoogleLogin = false, isWindowsLogin = false, minimize = true, captchaToken, executablePath } = opts this._email = email this._password = password this._markdown = !!markdown this._debug = !!debug this._isGoogleLogin = !!isGoogleLogin this._isWindowsLogin = !!isWindowsLogin this._minimize = !!minimize this._captchaToken = captchaToken this._executablePath = executablePath if (!this._email) { const error = new types.ChatGPTError('ChatGPT invalid email') error.statusCode = 401 throw error } if (!this._password) { const error = new types.ChatGPTError('ChatGPT invalid password') error.statusCode = 401 throw error } } override async initSession() { if (this._browser) { await this.closeSession() } try { this._browser = await getBrowser({ captchaToken: this._captchaToken, executablePath: this._executablePath }) this._page = (await this._browser.pages())[0] || (await this._browser.newPage()) // bypass annoying popup modals this._page.evaluateOnNewDocument(() => { window.localStorage.setItem('oai/apps/hasSeenOnboarding/chat', 'true') window.localStorage.setItem( 'oai/apps/hasSeenReleaseAnnouncement/2022-12-15', 'true' ) }) await maximizePage(this._page) this._page.on('request', this._onRequest.bind(this)) this._page.on('response', this._onResponse.bind(this)) // bypass cloudflare and login await getOpenAIAuth({ email: this._email, password: this._password, browser: this._browser, page: this._page, isGoogleLogin: this._isGoogleLogin, isWindowsLogin: this._isWindowsLogin }) } catch (err) { if (this._browser) { await this._browser.close() } this._browser = null this._page = null throw err } if (!this.isChatPage || this._isGoogleLogin || this._isWindowsLogin) { await this._page.goto(CHAT_PAGE_URL, { waitUntil: 'networkidle2' }) } // dismiss welcome modal (and other modals) do { const modalSelector = '[data-headlessui-state="open"]' try { if (!(await this._page.$(modalSelector))) { break } 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())) { throw new types.ChatGPTError('Failed to authenticate session') } if (this._minimize) { return minimizePage(this._page) } } _onRequest = (request: HTTPRequest) => { const url = request.url() if (!isRelevantRequest(url)) { return } const method = request.method() let body: any 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: HTTPResponse) => { const request = response.request() const url = response.url() if (!isRelevantRequest(url)) { return } const status = response.status() let body: any 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.refreshSession() } } else if (url.endsWith('api/auth/session')) { if (status === 401) { await this.resetSession() } else if (status === 403) { await this.refreshSession() } else { const session: types.SessionResult = body if (session?.accessToken) { this._accessToken = session.accessToken } } } } /** * Attempts to handle 401 errors by re-authenticating. */ async resetSession() { console.log( `ChatGPT "${this._email}" session expired; re-authenticating...` ) try { await this.closeSession() await this.initSession() console.log(`ChatGPT "${this._email}" re-authenticated successfully`) } catch (err) { console.error( `ChatGPT "${this._email}" error re-authenticating`, err.toString() ) } } /** * Attempts to handle 403 errors by refreshing the page. */ async refreshSession() { console.log(`ChatGPT "${this._email}" session expired (403); refreshing...`) try { await maximizePage(this._page) await this._page.reload({ waitUntil: 'networkidle2', timeout: 2 * 60 * 1000 // 2 minutes }) if (this._minimize && this.isChatPage) { await minimizePage(this._page) } console.log(`ChatGPT "${this._email}" refreshed session successfully`) } catch (err) { console.error( `ChatGPT "${this._email}" error refreshing session`, err.toString() ) } } async getIsAuthenticated() { try { if (!this._accessToken) { return false } 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 // } // } override async sendMessage( message: string, opts: types.SendMessageOptions = {} ): Promise { const { conversationId, parentMessageId = uuidv4(), messageId = uuidv4(), action = 'next', timeoutMs // TODO // onProgress } = opts if (!(await this.getIsAuthenticated())) { console.log(`chatgpt re-authenticating ${this._email}`) try { await this.resetSession() } catch (err) { console.warn( `chatgpt error re-authenticating ${this._email}`, err.toString() ) } if (!(await this.getIsAuthenticated())) { const error = new types.ChatGPTError('Not signed in') error.statusCode = 401 throw error } } const url = `https://chat.openai.com/backend-api/conversation` const body: types.ConversationJSONBody = { 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 ('error' in result) { const error = new types.ChatGPTError(result.error.message) error.statusCode = result.error.statusCode error.statusText = result.error.statusText if (error.statusCode === 403) { await this.refreshSession() } throw error } else { if (!this._markdown) { result.response = markdownToText(result.response) } return result } // 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 } } override async closeSession() { await this._browser.close() this._page = null this._browser = null this._accessToken = null } protected async _getInputBox() { try { return await this._page.$('textarea') } catch (err) { return null } } get isChatPage(): boolean { try { const url = this._page?.url().replace(/\/$/, '') return url === CHAT_PAGE_URL } catch (err) { return false } } }