2022-12-15 04:41:43 +00:00
|
|
|
import delay from 'delay'
|
|
|
|
import html2md from 'html-to-md'
|
2022-12-15 07:35:09 +00:00
|
|
|
import pTimeout from 'p-timeout'
|
2022-12-15 06:22:31 +00:00
|
|
|
import type { Browser, HTTPRequest, HTTPResponse, Page } from 'puppeteer'
|
2022-12-15 04:41:43 +00:00
|
|
|
|
|
|
|
import { getBrowser, getOpenAIAuth } from './openai-auth'
|
2022-12-15 06:44:13 +00:00
|
|
|
import { isRelevantRequest, maximizePage, minimizePage } from './utils'
|
2022-12-15 04:41:43 +00:00
|
|
|
|
|
|
|
export class ChatGPTAPIBrowser {
|
|
|
|
protected _markdown: boolean
|
|
|
|
protected _debug: boolean
|
|
|
|
protected _isGoogleLogin: boolean
|
|
|
|
protected _captchaToken: string
|
|
|
|
|
|
|
|
protected _email: string
|
|
|
|
protected _password: string
|
|
|
|
|
|
|
|
protected _browser: Browser
|
|
|
|
protected _page: Page
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a new client wrapper for automating the ChatGPT webapp.
|
|
|
|
*/
|
|
|
|
constructor(opts: {
|
|
|
|
email: string
|
|
|
|
password: string
|
|
|
|
|
|
|
|
/** @defaultValue `true` **/
|
|
|
|
markdown?: boolean
|
|
|
|
|
|
|
|
/** @defaultValue `false` **/
|
|
|
|
debug?: boolean
|
|
|
|
|
|
|
|
isGoogleLogin?: boolean
|
|
|
|
captchaToken?: string
|
|
|
|
}) {
|
|
|
|
const {
|
|
|
|
email,
|
|
|
|
password,
|
|
|
|
markdown = true,
|
|
|
|
debug = false,
|
|
|
|
isGoogleLogin = false,
|
|
|
|
captchaToken
|
|
|
|
} = opts
|
|
|
|
|
|
|
|
this._email = email
|
|
|
|
this._password = password
|
|
|
|
|
|
|
|
this._markdown = !!markdown
|
|
|
|
this._debug = !!debug
|
|
|
|
this._isGoogleLogin = !!isGoogleLogin
|
|
|
|
this._captchaToken = captchaToken
|
|
|
|
}
|
|
|
|
|
|
|
|
async init() {
|
|
|
|
if (this._browser) {
|
|
|
|
await this._browser.close()
|
|
|
|
this._page = null
|
|
|
|
this._browser = null
|
|
|
|
}
|
|
|
|
|
2022-12-15 08:14:17 +00:00
|
|
|
try {
|
|
|
|
this._browser = await getBrowser({ captchaToken: this._captchaToken })
|
|
|
|
this._page =
|
|
|
|
(await this._browser.pages())[0] || (await this._browser.newPage())
|
|
|
|
|
|
|
|
// bypass cloudflare and login
|
|
|
|
await getOpenAIAuth({
|
|
|
|
email: this._email,
|
|
|
|
password: this._password,
|
|
|
|
browser: this._browser,
|
|
|
|
page: this._page,
|
|
|
|
isGoogleLogin: this._isGoogleLogin
|
|
|
|
})
|
|
|
|
} catch (err) {
|
|
|
|
if (this._browser) {
|
|
|
|
await this._browser.close()
|
|
|
|
}
|
|
|
|
|
|
|
|
this._browser = null
|
|
|
|
this._page = null
|
|
|
|
|
|
|
|
throw err
|
|
|
|
}
|
2022-12-15 04:41:43 +00:00
|
|
|
|
|
|
|
const chatUrl = 'https://chat.openai.com/chat'
|
|
|
|
const url = this._page.url().replace(/\/$/, '')
|
|
|
|
|
|
|
|
if (url !== chatUrl) {
|
|
|
|
await this._page.goto(chatUrl, {
|
|
|
|
waitUntil: 'networkidle0'
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// dismiss welcome modal
|
|
|
|
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(500)
|
|
|
|
} while (true)
|
|
|
|
|
|
|
|
if (!this.getIsAuthenticated()) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2022-12-15 06:44:13 +00:00
|
|
|
await minimizePage(this._page)
|
2022-12-15 06:22:31 +00:00
|
|
|
|
|
|
|
this._page.on('request', this._onRequest.bind(this))
|
|
|
|
this._page.on('response', this._onResponse.bind(this))
|
|
|
|
|
2022-12-15 04:41:43 +00:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2022-12-15 06:22:31 +00:00
|
|
|
_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
|
|
|
|
// }
|
|
|
|
}
|
|
|
|
|
2022-12-15 06:44:13 +00:00
|
|
|
if (this._debug) {
|
|
|
|
console.log('\nrequest', {
|
|
|
|
url,
|
|
|
|
method,
|
|
|
|
headers: request.headers(),
|
|
|
|
body
|
|
|
|
})
|
|
|
|
}
|
2022-12-15 06:22:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
_onResponse = async (response: HTTPResponse) => {
|
|
|
|
const request = response.request()
|
|
|
|
|
|
|
|
const url = response.url()
|
|
|
|
if (!isRelevantRequest(url)) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-12-15 06:44:13 +00:00
|
|
|
const status = response.status()
|
|
|
|
|
2022-12-15 06:22:31 +00:00
|
|
|
let body: any
|
|
|
|
try {
|
|
|
|
body = await response.json()
|
|
|
|
} catch (_) {}
|
|
|
|
|
2022-12-15 06:44:13 +00:00
|
|
|
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()
|
|
|
|
}
|
|
|
|
})
|
2022-12-15 06:22:31 +00:00
|
|
|
}
|
|
|
|
|
2022-12-15 06:44:13 +00:00
|
|
|
if (url.endsWith('/conversation')) {
|
|
|
|
if (status === 403) {
|
|
|
|
await this.handle403Error()
|
2022-12-15 06:22:31 +00:00
|
|
|
}
|
2022-12-15 06:44:13 +00:00
|
|
|
} else if (url.endsWith('api/auth/session')) {
|
|
|
|
if (status === 403) {
|
|
|
|
await this.handle403Error()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async handle403Error() {
|
|
|
|
console.log(`ChatGPT "${this._email}" session expired; refreshing...`)
|
|
|
|
try {
|
|
|
|
await maximizePage(this._page)
|
|
|
|
await this._page.reload({
|
2022-12-15 07:39:09 +00:00
|
|
|
waitUntil: 'networkidle0',
|
|
|
|
timeout: 2 * 60 * 1000 // 2 minutes
|
2022-12-15 06:44:13 +00:00
|
|
|
})
|
|
|
|
await minimizePage(this._page)
|
|
|
|
} catch (err) {
|
|
|
|
console.error(
|
|
|
|
`ChatGPT "${this._email}" error refreshing session`,
|
|
|
|
err.toString()
|
|
|
|
)
|
|
|
|
}
|
2022-12-15 06:22:31 +00:00
|
|
|
}
|
2022-12-15 04:41:43 +00:00
|
|
|
|
|
|
|
async getIsAuthenticated() {
|
|
|
|
try {
|
|
|
|
const inputBox = await this._getInputBox()
|
|
|
|
return !!inputBox
|
|
|
|
} catch (err) {
|
|
|
|
// can happen when navigating during login
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async getLastMessage(): Promise<string | null> {
|
|
|
|
const messages = await this.getMessages()
|
|
|
|
|
|
|
|
if (messages) {
|
|
|
|
return messages[messages.length - 1]
|
|
|
|
} else {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async getPrompts(): Promise<string[]> {
|
|
|
|
// 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<string[]> {
|
|
|
|
// 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
|
2022-12-16 01:00:09 +00:00
|
|
|
messageHtml = messageHtml
|
|
|
|
.replaceAll('Copy code</button>', '</button>')
|
|
|
|
.replace(/Copy code\s*<\/button>/gim, '</button>')
|
|
|
|
|
2022-12-15 04:41:43 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-15 07:35:09 +00:00
|
|
|
async sendMessage(
|
|
|
|
message: string,
|
|
|
|
opts: {
|
|
|
|
timeoutMs?: number
|
|
|
|
} = {}
|
|
|
|
): Promise<string> {
|
|
|
|
const { timeoutMs } = opts
|
|
|
|
|
2022-12-15 04:41:43 +00:00
|
|
|
const inputBox = await this._getInputBox()
|
|
|
|
if (!inputBox) throw new Error('not signed in')
|
|
|
|
|
|
|
|
const lastMessage = await this.getLastMessage()
|
|
|
|
|
2022-12-15 06:22:31 +00:00
|
|
|
await inputBox.focus()
|
2022-12-15 06:24:03 +00:00
|
|
|
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')
|
|
|
|
}
|
|
|
|
}
|
2022-12-15 04:41:43 +00:00
|
|
|
|
2022-12-15 07:35:09 +00:00
|
|
|
const responseP = new Promise<string>(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)
|
2022-12-15 04:41:43 +00:00
|
|
|
}
|
2022-12-15 07:35:09 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
if (timeoutMs) {
|
|
|
|
return pTimeout(responseP, {
|
|
|
|
milliseconds: timeoutMs
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
return responseP
|
|
|
|
}
|
2022-12-15 04:41:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async resetThread() {
|
|
|
|
const resetButton = await this._page.$('nav > a:nth-child(1)')
|
|
|
|
if (!resetButton) throw new Error('not signed in')
|
|
|
|
|
|
|
|
await resetButton.click()
|
|
|
|
}
|
|
|
|
|
|
|
|
async close() {
|
|
|
|
await this._browser.close()
|
|
|
|
this._page = null
|
|
|
|
this._browser = null
|
|
|
|
}
|
|
|
|
|
|
|
|
protected async _getInputBox() {
|
|
|
|
// [data-id="root"]
|
|
|
|
return this._page.$('textarea')
|
|
|
|
}
|
|
|
|
}
|