diff --git a/.env.example b/.env.example index 90748cea..c48e6b6f 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,10 @@ TWOCAPTCHA_KEY= # Obtain from 2captcha.com 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 +BROWSER_HEADLESS=true + +# CAPTCHA strategy: +# unset / false → automatic only (requires TWOCAPTCHA_KEY; may break on UI redesigns) +# true → manual only (skip auto, open a visible browser for the human to solve) +# fallback → try automatic first, fall back to manual on error (recommended for local use) +MANUAL_CAPTCHA= \ No newline at end of file diff --git a/README.md b/README.md index 59fb3894..3415ed27 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ docker compose build && docker compose up - `BROWSER_GHOST_CURSOR` — use ghost-cursor-playwright to simulate smooth mouse movements. Please note that it doesn't seem to make any difference in the rate of CAPTCHAs, so you can set it to `false`. Retained for future testing. - `BROWSER_LOCALE` — the language of the browser. Using either `en` or `ru` is recommended, since those have the most workers on 2Captcha. [List of supported languages](https://2captcha.com/2captcha-api#language) - `BROWSER_HEADLESS` — run the browser without the window. You probably want to set this to `true`. +- `MANUAL_CAPTCHA` *(new)* — how to handle CAPTCHAs. See the **CAPTCHA strategy** section below. Leave unset for the original automatic behavior. ```bash SUNO_COOKIE=<…> TWOCAPTCHA_KEY=<…> @@ -114,8 +115,22 @@ BROWSER=chromium BROWSER_GHOST_CURSOR=false BROWSER_LOCALE=en BROWSER_HEADLESS=true +MANUAL_CAPTCHA= # unset / true / fallback — see below ``` +#### CAPTCHA strategy (`MANUAL_CAPTCHA`) + +Suno's web UI is redesigned periodically, and the automatic CAPTCHA solver depends on DOM selectors that can drift (e.g. the v5.5 redesign removed the `.custom-textarea` selector, causing automatic flows to time out — issue [#263](https://github.com/gcui-art/suno-api/issues/263)). `MANUAL_CAPTCHA` lets you opt into a human-in-the-loop fallback that survives UI changes. + +| Value | Behavior | +| --- | --- | +| *unset* / `false` | **Automatic only** — the original behavior. Requires `TWOCAPTCHA_KEY`. Fastest path when it works. | +| `true` | **Manual only** — skip the automatic flow entirely. Every CAPTCHA opens a visible browser; you fill the Suno UI and click *Create* yourself. The hCaptcha token is harvested from the network request you trigger. No DOM selectors involved, so it survives UI changes. | +| `fallback` | **Try automatic, fall back to manual on error.** Recommended for local/personal use: you get automation when Suno cooperates, and a working escape hatch when the UI changed or the 2Captcha solver fails. | + +Manual / fallback modes are **not suitable for headless server deployments**, since they require a human to interact with the visible browser. For Vercel / Docker deployments, stick with the default automatic mode + `TWOCAPTCHA_KEY`. + + ### 5. Run suno-api - If you’ve deployed to Vercel: diff --git a/src/lib/SunoApi.ts b/src/lib/SunoApi.ts index cad506ec..a2b0f5ae 100644 --- a/src/lib/SunoApi.ts +++ b/src/lib/SunoApi.ts @@ -122,6 +122,12 @@ class SunoApi { public async init(): Promise { //await this.getClerkLatestVersion(); + const captchaMode = (process.env.MANUAL_CAPTCHA || '').toLowerCase().trim(); + if (captchaMode === 'true' || captchaMode === '1' || captchaMode === 'yes') { + logger.info('CAPTCHA mode: MANUAL — every captcha will open a visible browser for human solving'); + } else if (captchaMode === 'fallback') { + logger.info('CAPTCHA mode: FALLBACK — automatic first, manual on error'); + } await this.getAuthToken(); await this.keepAlive(); return this; @@ -301,10 +307,99 @@ class SunoApi { } /** - * Checks for CAPTCHA verification and solves the CAPTCHA if needed - * @returns {string|null} hCaptcha token. If no verification is required, returns null + * Manual CAPTCHA mode: opens a visible browser, lets the human user fill the Suno + * web UI and click Create themselves. We just hijack the /api/generate/v2 request + * to steal the hCaptcha token from it. Works with any Suno UI version (no DOM + * selectors needed). Triggered by env var MANUAL_CAPTCHA=true. + */ + public async getCaptchaManual(): Promise { + if (!await this.captchaRequired()) + return null; + + logger.info('CAPTCHA required. Opening VISIBLE browser for manual solve...'); + // Force the browser to be visible even if BROWSER_HEADLESS=true is set. + const prevHeadless = process.env.BROWSER_HEADLESS; + process.env.BROWSER_HEADLESS = 'false'; + const browser = await this.launchBrowser(); + process.env.BROWSER_HEADLESS = prevHeadless; + + const page = await browser.newPage(); + await page.goto('https://suno.com/create', { waitUntil: 'domcontentloaded', timeout: 0 }); + try { await page.bringToFront(); } catch {} // force window to front on macOS + + logger.info('================================================================='); + logger.info('ACTION REQUIRED IN THE BROWSER:'); + logger.info(' 1. Fill the Suno UI (anything: description, lyrics, style, ...)'); + logger.info(' 2. Click the "Create" button'); + logger.info(' 3. Solve any hCaptcha that appears'); + logger.info('The browser will close automatically once the token is captured.'); + logger.info('================================================================='); + + return new Promise((resolve, reject) => { + let captured = false; + page.route('**/api/generate/v2/**', async (route: any) => { + if (captured) { + try { await route.continue(); } catch {} + return; + } + captured = true; + try { + const request = route.request(); + const auth = request.headers().authorization; + if (auth) this.currentToken = auth.split('Bearer ').pop(); + const body = request.postDataJSON(); + const token = body?.token; + logger.info(`hCaptcha token captured (length=${token?.length}). Closing browser.`); + try { await route.abort(); } catch {} + try { await browser.browser()?.close(); } catch {} + if (!token) reject(new Error('No token field in intercepted /api/generate/v2 body')); + else resolve(token); + } catch (err: any) { + reject(err); + } + }); + // No timeout — let the user take as long as they need. + // Detect browser closed by user as a cancel. + browser.on('close', () => { + if (!captured) reject(new Error('Browser closed by user before captcha solved')); + }); + }); + } + + /** + * Public CAPTCHA dispatcher. Routes to the automatic flow, the manual flow, or + * the fallback flow (try auto, on error use manual) based on MANUAL_CAPTCHA. + * + * MANUAL_CAPTCHA unset / false → automatic only (original gcui-art behavior) + * MANUAL_CAPTCHA=true → manual only (skip auto entirely) + * MANUAL_CAPTCHA=fallback → try auto, fall back to manual on any error */ public async getCaptcha(): Promise { + const mode = (process.env.MANUAL_CAPTCHA || '').toLowerCase().trim(); + if (mode === 'true' || mode === '1' || mode === 'yes') { + return this.getCaptchaManual(); + } + if (mode === 'fallback') { + try { + return await this.getCaptchaAuto(); + } catch (e: any) { + logger.warn({ err: e?.message ?? String(e) }, + 'Automatic CAPTCHA failed — falling back to MANUAL mode'); + return this.getCaptchaManual(); + } + } + return this.getCaptchaAuto(); + } + + /** + * Automated CAPTCHA flow: launches a headless browser, navigates the Suno web UI, + * and uses 2Captcha to solve any hCaptcha challenge. Fragile to UI changes — + * known to break against Suno v5.5+ where `.custom-textarea` no longer exists. + * Use MANUAL_CAPTCHA=true or MANUAL_CAPTCHA=fallback to opt into the manual flow. + * + * @returns {string|null} hCaptcha token. If no verification is required, returns null. + */ + private async getCaptchaAuto(): Promise { if (!await this.captchaRequired()) return null;