diff --git a/.env.example b/.env.example index 4496d1f..be90ebb 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # For more information, please see the README.md SUNO_COOKIE= TWOCAPTCHA_KEY= # Obtain from 2captcha.com -BROWSER=chromium # chromium or firefox +BROWSER=chromium # chromium or firefox, although chromium is highly recommended BROWSER_GHOST_CURSOR=false BROWSER_LOCALE=en BROWSER_HEADLESS=true \ No newline at end of file diff --git a/src/lib/SunoApi.ts b/src/lib/SunoApi.ts index 959fff4..2c3c77d 100644 --- a/src/lib/SunoApi.ts +++ b/src/lib/SunoApi.ts @@ -2,13 +2,13 @@ import axios, { AxiosInstance } from 'axios'; import UserAgent from 'user-agents'; import pino from 'pino'; import yn from 'yn'; -import { sleep, isPage } from '@/lib/utils'; +import { isPage, sleep, waitForRequests } from '@/lib/utils'; import * as cookie from 'cookie'; import { randomUUID } from 'node:crypto'; import { Solver } from '@2captcha/captcha-solver'; +import { paramsCoordinates } from '@2captcha/captcha-solver/dist/structs/2captcha'; import { BrowserContext, Page, Locator, chromium, firefox } from 'rebrowser-playwright-core'; import { createCursor, Cursor } from 'ghost-cursor-playwright'; -import { paramsCoordinates } from '@2captcha/captcha-solver/dist/structs/2captcha'; import { promises as fs } from 'fs'; import path from 'node:path'; @@ -180,7 +180,6 @@ class SunoApi { ctype: 'generation' }); logger.info(resp.data); - // await sleep(10); return resp.data.required; } @@ -281,47 +280,46 @@ class SunoApi { await page.goto('https://suno.com/create', { referer: 'https://www.google.com/', waitUntil: 'domcontentloaded', timeout: 0 }); logger.info('Waiting for Suno interface to load'); - //await page.locator('.react-aria-GridList').waitFor({ timeout: 60000 }); + // await page.locator('.react-aria-GridList').waitFor({ timeout: 60000 }); await page.waitForResponse('**/api/feed/v2**', { timeout: 60000 }); // wait for song list API call if (this.ghostCursorEnabled) this.cursor = await createCursor(page); logger.info('Triggering the CAPTCHA'); - await this.click(page, { x: 318, y: 13 }); // close all popups + try { + await page.getByLabel('Close').click({ timeout: 2000 }); // close all popups + // await this.click(page, { x: 318, y: 13 }); + } catch(e) {} const textarea = page.locator('.custom-textarea'); await this.click(textarea); await textarea.pressSequentially('Lorem ipsum', { delay: 80 }); const button = page.locator('button[aria-label="Create"]').locator('div.flex'); - await this.click(button); + this.click(button); + const controller = new AbortController(); new Promise(async (resolve, reject) => { const frame = page.frameLocator('iframe[title*="hCaptcha"]'); const challenge = frame.locator('.challenge-container'); try { + let wait = true; while (true) { - await page.waitForResponse('https://img**.hcaptcha.com/**', { timeout: 60000 }); // wait for hCaptcha image to load - while (true) { // wait for all requests to finish - try { - await page.waitForResponse('https://img**.hcaptcha.com/**', { timeout: 1000 }); - } catch(e) { - break - } - } + if (wait) + await waitForRequests(page, controller.signal); const drag = (await challenge.locator('.prompt-text').first().innerText()).toLowerCase().includes('drag'); let captcha: any; - for (let j = 0; j < 3; j++) { // try several times because sometimes 2Captcha could send an error + for (let j = 0; j < 3; j++) { // try several times because sometimes 2Captcha could return an error try { logger.info('Sending the CAPTCHA to 2Captcha'); const payload: paramsCoordinates = { - body: (await challenge.screenshot()).toString('base64'), + body: (await challenge.screenshot({ timeout: 5000 })).toString('base64'), lang: process.env.BROWSER_LOCALE }; if (drag) { // Say to the worker that he needs to click - payload.textinstructions = '! Instead of dragging, CLICK on the shapes as shown in the image above !'; + payload.textinstructions = 'CLICK on the shapes at their edge or center as shown above—please be precise!'; payload.imginstructions = (await fs.readFile(path.join(process.cwd(), 'public', 'drag-instructions.jpg'))).toString('base64'); } captcha = await this.solver.coordinates(payload); @@ -333,11 +331,17 @@ class SunoApi { else throw err; } - } + } if (drag) { const challengeBox = await challenge.boundingBox(); if (challengeBox == null) throw new Error('.challenge-container boundingBox is null!'); + if (captcha.data.length % 2) { + logger.info('Solution does not have even amount of points required for dragging. Requesting new solution...'); + this.solver.badReport(captcha.id); + wait = false; + continue; + } for (let i = 0; i < captcha.data.length; i += 2) { const data1 = captcha.data[i]; const data2 = captcha.data[i+1]; @@ -348,6 +352,7 @@ class SunoApi { await page.mouse.move(challengeBox.x + +data2.x, challengeBox.y + +data2.y, { steps: 30 }); await page.mouse.up(); } + wait = true; } else { for (const data of captcha.data) { logger.info(data); @@ -362,7 +367,8 @@ class SunoApi { }); } } catch(e: any) { - if (e.message.includes('been closed')) // catch error when closing the browser + if (e.message.includes('been closed') // catch error when closing the browser + || e.message == 'AbortError') // catch error when waitForRequests is aborted resolve(); else reject(e); @@ -377,6 +383,7 @@ class SunoApi { logger.info('hCaptcha token received. Closing browser'); route.abort(); browser.browser()?.close(); + controller.abort(); const request = route.request(); this.currentToken = request.headers().authorization.split('Bearer ').pop(); resolve(request.postDataJSON().token); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 10c1626..0fb70a1 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -29,6 +29,87 @@ export const isPage = (target: any): target is Page => { return target.constructor.name === 'Page'; } +/** + * Waits for an hCaptcha image requests and then waits for all of them to end + * @param page + * @param signal `const controller = new AbortController(); controller.status` + * @returns {Promise} + */ +export const waitForRequests = (page: Page, signal: AbortSignal): Promise => { + return new Promise((resolve, reject) => { + const urlPattern = /^https:\/\/img[a-zA-Z0-9]*\.hcaptcha\.com\/.*$/; + let timeoutHandle: NodeJS.Timeout | null = null; + let activeRequestCount = 0; + let requestOccurred = false; + + const cleanupListeners = () => { + page.off('request', onRequest); + page.off('requestfinished', onRequestFinished); + page.off('requestfailed', onRequestFinished); + }; + + const resetTimeout = () => { + if (timeoutHandle) + clearTimeout(timeoutHandle); + if (activeRequestCount === 0) { + timeoutHandle = setTimeout(() => { + cleanupListeners(); + resolve(); + }, 1000); // 1 second of no requests + } + }; + + const onRequest = (request: { url: () => string }) => { + if (urlPattern.test(request.url())) { + requestOccurred = true; + activeRequestCount++; + if (timeoutHandle) + clearTimeout(timeoutHandle); + } + }; + + const onRequestFinished = (request: { url: () => string }) => { + if (urlPattern.test(request.url())) { + activeRequestCount--; + resetTimeout(); + } + }; + + // Wait for an hCaptcha request for up to 1 minute + const initialTimeout = setTimeout(() => { + if (!requestOccurred) { + page.off('request', onRequest); + cleanupListeners(); + reject(new Error('No hCaptcha request occurred within 1 minute.')); + } else { + // Start waiting for no hCaptcha requests + resetTimeout(); + } + }, 60000); // 1 minute timeout + + page.on('request', onRequest); + page.on('requestfinished', onRequestFinished); + page.on('requestfailed', onRequestFinished); + + // Cleanup the initial timeout if an hCaptcha request occurs + page.on('request', (request: { url: () => string }) => { + if (urlPattern.test(request.url())) { + clearTimeout(initialTimeout); + } + }); + + const onAbort = () => { + cleanupListeners(); + clearTimeout(initialTimeout); + if (timeoutHandle) + clearTimeout(timeoutHandle); + signal.removeEventListener('abort', onAbort); + reject(new Error('AbortError')); + }; + + signal.addEventListener('abort', onAbort, { once: true }); + }); +} export const corsHeaders = { 'Access-Control-Allow-Origin': '*',