From 4b278b422150386126d14fe809e443c69488b351 Mon Sep 17 00:00:00 2001 From: papavini Date: Sat, 11 Apr 2026 23:35:33 +0300 Subject: [PATCH] feat: add passkey automation scripts and setup guide - scripts/passkey-server.mjs: HTTP server on :3099 with bookmarklet UI for easy SUNO_PASSKEY_TOKEN renewal via real browser (Cloudflare-safe) - scripts/refresh-passkey.mjs: Playwright-based automated passkey capture with xvfb/system-Chrome fallback for Linux headless environments - SETUP.md: comprehensive setup guide covering cookie extraction, passkey renewal, API usage, systemd deployment, and troubleshooting - package.json: add 6 npm scripts for passkey management - SunoApi.ts: fix BASE_URL to studio-api-prod.suno.com, update DEFAULT_MODEL to chirp-fenix, add CAPTCHA bypass, align generate payload with web client Co-Authored-By: Claude Sonnet 4.6 --- SETUP.md | 194 ++++++++++++++++ package.json | 8 +- scripts/passkey-server.mjs | 226 ++++++++++++++++++ scripts/refresh-passkey.mjs | 442 ++++++++++++++++++++++++++++++++++++ src/lib/SunoApi.ts | 46 +++- 5 files changed, 906 insertions(+), 10 deletions(-) create mode 100644 SETUP.md create mode 100644 scripts/passkey-server.mjs create mode 100644 scripts/refresh-passkey.mjs diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 00000000..c57556db --- /dev/null +++ b/SETUP.md @@ -0,0 +1,194 @@ +# Настройка suno-api для проекта Подари Трек + +Этот файл описывает **нашу конкретную** настройку suno-api. Общая документация — в README.md репозитория. + +--- + +## Переменные окружения (.env) + +```env +# ─── Обязательные ──────────────────────────────────────────────────────────── + +# Полная cookie-строка из браузера на suno.com +# DevTools → Application → Cookies → suno.com → выделить все → скопировать как строку +SUNO_COOKIE=singular_device_id=... + +# Антибот-токен Cloudflare (ротируемый, протухает через несколько часов) +# Как получить — см. раздел ниже +SUNO_PASSKEY_TOKEN=P1_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... + +# UUID тира пользователя — берётся из декодированного __session cookie (поле claims/user_id) +# Или из URL профиля suno.com +SUNO_USER_TIER=3eaebef3-ef46-446a-931c-3d50cd1514f1 + +# UUID сессии создания — из cookie __session +SUNO_CREATE_SESSION_TOKEN=e6ccdfd5-8aa0-476d-89cc-f7d09e0a7ab4 + +# ─── Браузер (для headless-режима) ─────────────────────────────────────────── +BROWSER=chromium +BROWSER_HEADLESS=true +BROWSER_GHOST_CURSOR=false +BROWSER_LOCALE=en + +# ─── 2Captcha (оставить пустым если не используешь) ────────────────────────── +TWOCAPTCHA_KEY= +``` + +--- + +## Как получить SUNO_PASSKEY_TOKEN + +Это самый частая проблема — токен ротируется и протухает. + +**Симптом протухшего токена:** генерация возвращает HTTP 422. + +**Способ получения:** + +1. Открой Chrome с залогиненным аккаунтом suno.com +2. Перейди на `https://suno.com/create` +3. Открой DevTools (F12) → вкладка **Console** +4. В поле описания напиши любой текст и нажми **Create** +5. В консоли найди строку вида: + ``` + captcha verified P1_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.haJwZAC... + ``` +6. Скопируй всё начиная с `P1_` (токен очень длинный — несколько КБ) +7. Вставь в `.env`: + ```env + SUNO_PASSKEY_TOKEN=P1_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... + ``` + +**После обновления токена — ОБЯЗАТЕЛЬНО пересобрать:** + +```bash +npm run build +# И перезапустить сервис +sudo systemctl restart suno-api +# или вручную: +npm start +``` + +> **Почему нужна пересборка?** suno-api — это Next.js. Переменные окружения встраиваются в сборку. `npm start` запускает **предыдущий** билд. Без `npm run build` старый токен останется в сборке. + +--- + +## Как получить SUNO_COOKIE + +1. Зайди на `https://suno.com` через Chrome +2. Авторизуйся под нужным аккаунтом +3. DevTools → Application → Storage → Cookies → https://www.suno.com +4. Нажми правой кнопкой на любой cookie → "Copy all" +5. Вставь в `.env` как одну строку (без переносов) + +Cookie содержит Clerk JWT (`__session`, `__session_*`) — он истекает примерно через месяц. Когда истечёт — `/api/get_limit` начнёт возвращать 401/403. + +--- + +## Эндпоинты (используются ботом) + +| Метод | Путь | Описание | +|---|---|---| +| `POST` | `/api/generate` | Запустить генерацию по описанию (description mode) | +| `GET` | `/api/get?ids=id1,id2` | Получить статус/результат клипов | +| `GET` | `/api/get_limit` | Остаток кредитов | + +### Пример запуска генерации + +```bash +curl -X POST http://localhost:3000/api/generate \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Создай душевную песню на русском. Повод: День рождения. Жанр: поп.", + "make_instrumental": false, + "wait_audio": false + }' +``` + +Ответ — массив из 2 объектов с `id` и `status: "submitted"`. + +### Polling статуса + +```bash +curl "http://localhost:3000/api/get?ids=UUID1,UUID2" +``` + +Статусы: `submitted` → `queued` → `streaming` → `complete` (или `error`). +`complete` + непустой `audio_url` = трек готов. + +--- + +## Запуск на Linux + +```bash +# Установка зависимостей +npm install + +# Сборка (обязательно перед каждым запуском npm start) +npm run build + +# Запуск +npm start +``` + +### Через systemd + +```ini +# /etc/systemd/system/suno-api.service +[Unit] +Description=SUNO API Proxy +After=network.target + +[Service] +Type=simple +User=ubuntu +WorkingDirectory=/home/ubuntu/suno-api +ExecStart=/usr/bin/npm start +Restart=on-failure +RestartSec=10 +EnvironmentFile=/home/ubuntu/suno-api/.env +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable suno-api +sudo systemctl start suno-api +journalctl -u suno-api -f # логи в реальном времени +``` + +--- + +## Проверка работоспособности + +```bash +# Должен вернуть JSON с credits_left +curl http://localhost:3000/api/get_limit + +# Пример успешного ответа: +# {"credits_left":1532,"period":"month","monthly_limit":2500,"monthly_usage":963} +``` + +Если `credits_left` есть → сервис работает нормально. + +--- + +## Частые проблемы + +**`BUILD_ID` not found при старте:** +``` +Error: ENOENT: no such file or directory, open '.next/BUILD_ID' +``` +→ Не была выполнена сборка. Запусти `npm run build` перед `npm start`. + +**HTTP 422 при генерации:** +→ Протух `SUNO_PASSKEY_TOKEN`. Обнови по инструкции выше и пересобери. + +**HTTP 401/403 при любых запросах:** +→ Истекли cookie. Обнови `SUNO_COOKIE` из браузера. + +**Сервис отвечает, но генерация виснет навсегда:** +→ Проверь логи: `journalctl -u suno-api -n 100`. Если цикл `Get audio status` без конца — SUNO перегружен, жди или попробуй позже. diff --git a/package.json b/package.json index efd925e7..a3501426 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,13 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "refresh-passkey": "node scripts/refresh-passkey.mjs", + "refresh-passkey:rebuild": "node scripts/refresh-passkey.mjs --rebuild", + "refresh-passkey:restart": "node scripts/refresh-passkey.mjs --restart", + "passkey-server": "node scripts/passkey-server.mjs", + "passkey-server:rebuild": "node scripts/passkey-server.mjs --rebuild", + "passkey-server:restart": "node scripts/passkey-server.mjs --restart" }, "dependencies": { "@2captcha/captcha-solver": "^1.3.0", diff --git a/scripts/passkey-server.mjs b/scripts/passkey-server.mjs new file mode 100644 index 00000000..0bb8a514 --- /dev/null +++ b/scripts/passkey-server.mjs @@ -0,0 +1,226 @@ +/** + * passkey-server.mjs + * + * Tiny HTTP сервер, который принимает P1_ токен от bookmarklet в браузере, + * записывает его в .env и пересобирает suno-api. + * + * ЗАПУСК: + * node scripts/passkey-server.mjs # просто принять токен + * node scripts/passkey-server.mjs --rebuild # принять + npm run build + * node scripts/passkey-server.mjs --restart # принять + build + restart + * + * ИСПОЛЬЗОВАНИЕ: + * 1. Запусти этот скрипт + * 2. Он выведет bookmarklet — добавь его в браузер или скопируй код + * 3. Зайди на suno.com/create в браузере + * 4. Кликни bookmarklet (или вставь код в DevTools Console) + * 5. Нажми кнопку Create на suno.com + * 6. Токен автоматически придёт на сервер → .env обновится → rebuild + */ + +import { createServer } from 'http'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { execSync } from 'child_process'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const ENV_FILE = resolve(ROOT, '.env'); +const PORT = 3099; + +const ARGS = process.argv.slice(2); +const REBUILD = ARGS.includes('--rebuild') || ARGS.includes('--restart'); +const RESTART = ARGS.includes('--restart'); + +const log = (m) => console.log(`[passkey-server] ${new Date().toISOString()} ${m}`); + +function updateEnvFile(key, value) { + if (!existsSync(ENV_FILE)) throw new Error(`.env не найден: ${ENV_FILE}`); + let content = readFileSync(ENV_FILE, 'utf8'); + const re = new RegExp(`^${key}=.*$`, 'm'); + content = re.test(content) ? content.replace(re, `${key}=${value}`) : content + `\n${key}=${value}\n`; + writeFileSync(ENV_FILE, content, 'utf8'); + log(`✅ ${key} обновлён`); +} + +// ─── Bookmarklet код ────────────────────────────────────────────────────────── +// Патчит turnstile.execute и при получении P1_ токена +// отправляет POST на localhost:3099/token +function makeBookmarklet() { + const code = ` +(function(){ + if(window.__passkeyPatchDone){alert('Уже установлен! Просто нажми Create.');return;} + window.__passkeyPatchDone=true; + function patch(t){ + if(!t||t.__pk)return;t.__pk=1; + var e=t.execute.bind(t); + t.execute=function(sk,p){ + if(p&&typeof p.callback==='function'){ + var cb=p.callback; + p.callback=function(tok){ + if(tok&&tok.indexOf('P1_')===0){ + fetch('http://localhost:${PORT}/token',{ + method:'POST', + headers:{'Content-Type':'text/plain','Origin':'https://suno.com'}, + body:tok + }).then(function(r){return r.text();}).then(function(s){ + alert('✅ Passkey обновлён! '+s); + }).catch(function(err){ + prompt('Сервер недоступен. Скопируй токен вручную:',tok); + }); + } + cb(tok); + }; + } + return e(sk,p); + }; + } + if(window.turnstile)patch(window.turnstile); + var _t; + Object.defineProperty(window,'turnstile',{ + get:function(){return _t;}, + set:function(v){_t=v;patch(v);}, + configurable:true + }); + alert('✅ Перехватчик установлен! Теперь нажми Create.'); +})() + `.replace(/\s+/g, ' ').trim(); + return `javascript:${encodeURIComponent(code)}`; +} + +// ─── HTTP сервер ────────────────────────────────────────────────────────────── +function startServer() { + const server = createServer((req, res) => { + // CORS — нужен чтобы suno.com мог делать fetch на localhost + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); res.end(); return; + } + + if (req.method === 'POST' && req.url === '/token') { + let body = ''; + req.on('data', c => body += c); + req.on('end', async () => { + const token = body.trim(); + if (!token.startsWith('P1_')) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Ожидался P1_ токен'); + return; + } + + log(`Токен получен (${token.length} символов)`); + + try { + updateEnvFile('SUNO_PASSKEY_TOKEN', token); + + if (REBUILD) { + log('🔨 npm run build...'); + execSync('npm run build', { cwd: ROOT, stdio: 'pipe' }); + log('✅ Build завершён'); + } + + if (RESTART) { + for (const cmd of ['systemctl restart suno-api', 'pm2 restart suno-api']) { + try { execSync(cmd, { stdio: 'pipe' }); log(`✅ ${cmd}`); break; } + catch (_) {} + } + } + + const msg = REBUILD + ? 'Токен обновлён, build завершён' + : 'Токен обновлён. Запусти: npm run build && npm start'; + + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(msg); + log('=== Готово ==='); + + if (!REBUILD) { + log('⚠️ Не забудь пересобрать: npm run build && npm start'); + } + + // Не останавливаем сервер — может потребоваться повторно + log('Сервер продолжает работать. Ctrl+C для остановки.'); + + } catch (e) { + log(`❌ Ошибка: ${e.message}`); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end(`Ошибка: ${e.message}`); + } + }); + return; + } + + // Главная страница с инструкцией + if (req.method === 'GET' && req.url === '/') { + const bm = makeBookmarklet(); + const html = ` + +SUNO Passkey Updater + + + +

🎵 SUNO Passkey Updater

+
Сервер запущен на порту ${PORT}. Ожидаю токен...
+ +
+

Шаг 1 — Добавь bookmarklet в браузер

+

Перетащи эту кнопку в панель закладок:

+ 🔑 Capture Passkey +

Или скопируй код для DevTools Console:

+ +
+ +
+

Шаг 2 — Перейди на suno.com/create

+

Убедись что ты залогинен в аккаунт SUNO.

+
+ +
+

Шаг 3 — Кликни bookmarklet

+

После клика появится alert "Перехватчик установлен!"

+
+ +
+

Шаг 4 — Нажми Create на suno.com

+

Введи любой текст и нажми Create. Появится alert "✅ Passkey обновлён!"

+
+ +

После обновления токена сервер продолжает работать. +${REBUILD ? 'Build запускается автоматически.' : 'Запусти npm run build && npm start вручную.'}

+`; + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); return; + } + + res.writeHead(404); res.end('Not found'); + }); + + server.listen(PORT, '127.0.0.1', () => { + log('=== SUNO Passkey Server запущен ==='); + log(`Открой в браузере: http://127.0.0.1:${PORT}/`); + log(''); + log('Инструкция:'); + log(` 1. Открой http://127.0.0.1:${PORT}/ — там есть bookmarklet`); + log(' 2. Зайди на https://suno.com/create'); + log(' 3. Кликни bookmarklet (или вставь код в DevTools Console)'); + log(' 4. Нажми Create'); + log(' 5. Токен автоматически придёт сюда'); + log(''); + log('Ctrl+C для остановки.'); + }); +} + +startServer(); diff --git a/scripts/refresh-passkey.mjs b/scripts/refresh-passkey.mjs new file mode 100644 index 00000000..4588df5d --- /dev/null +++ b/scripts/refresh-passkey.mjs @@ -0,0 +1,442 @@ +/** + * refresh-passkey.mjs + * + * Автоматически обновляет SUNO_PASSKEY_TOKEN в .env. + * + * КАК РАБОТАЕТ: + * Cloudflare Turnstile генерирует P1_ токен при загрузке suno.com/create. + * Скрипт перехватывает вызов turnstile.execute() через Object.defineProperty + * и сохраняет токен в window.__capturedPasskey. Кредиты НЕ тратятся. + * + * ВАЖНО — HEADLESS: + * Cloudflare Turnstile не работает в headless браузере. + * На Windows: скрипт запускается в visible окне (мелькнёт и закроется). + * На Linux: нужен виртуальный дисплей: + * sudo apt install xvfb + * xvfb-run -a node scripts/refresh-passkey.mjs --rebuild + * + * ИСПОЛЬЗОВАНИЕ: + * node scripts/refresh-passkey.mjs # обновить .env + * node scripts/refresh-passkey.mjs --rebuild # обновить + npm run build + * node scripts/refresh-passkey.mjs --restart # обновить + build + systemctl restart + * node scripts/refresh-passkey.mjs --headless # принудительно headless (может не работать) + * + * АВТОЗАПУСК (Linux, каждые 3 часа): + * 0 *\/3 * * * ubuntu xvfb-run -a node /home/ubuntu/suno-api/scripts/refresh-passkey.mjs --rebuild >> /var/log/refresh-passkey.log 2>&1 + * + * npm-скрипты (для удобства): + * npm run refresh-passkey # только .env + * npm run refresh-passkey:rebuild # .env + build + * npm run refresh-passkey:restart # .env + build + restart + */ + +import { chromium } from 'rebrowser-playwright-core'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { execSync, spawn } from 'child_process'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { tmpdir } from 'os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const ENV_FILE = resolve(ROOT, '.env'); + +// ─── Загрузка .env вручную (без dotenv) ─────────────────────────────────────── +function loadEnv(filePath) { + if (!existsSync(filePath)) return; + for (const line of readFileSync(filePath, 'utf8').split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + const eq = t.indexOf('='); + if (eq === -1) continue; + const k = t.slice(0, eq).trim(); + const v = t.slice(eq + 1).trim(); + if (k && !(k in process.env)) process.env[k] = v; + } +} +loadEnv(ENV_FILE); + +// ─── Аргументы ──────────────────────────────────────────────────────────────── +const ARGS = process.argv.slice(2); +const REBUILD = ARGS.includes('--rebuild') || ARGS.includes('--restart'); +const RESTART = ARGS.includes('--restart'); +const HEADLESS = ARGS.includes('--headless'); // принудительно headless +const TIMEOUT_MS = 45_000; + +// ─── Утилиты ────────────────────────────────────────────────────────────────── +const log = (m) => console.log(`[refresh-passkey] ${new Date().toISOString()} ${m}`); + +function parseCookies(str) { + if (!str) return []; + return str.split(';').map(pair => { + const eq = pair.indexOf('='); + if (eq === -1) return null; + const name = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + return (name && value) ? { name, value, domain: '.suno.com', path: '/', sameSite: 'Lax' } : null; + }).filter(Boolean); +} + +function updateEnvFile(key, value) { + if (!existsSync(ENV_FILE)) throw new Error(`.env не найден: ${ENV_FILE}`); + let content = readFileSync(ENV_FILE, 'utf8'); + const re = new RegExp(`^${key}=.*$`, 'm'); + content = re.test(content) ? content.replace(re, `${key}=${value}`) : content + `\n${key}=${value}\n`; + writeFileSync(ENV_FILE, content, 'utf8'); + log(`✅ ${key} обновлён в .env`); +} + +// ─── Inject-скрипт: патч window.turnstile ───────────────────────────────────── +// Cloudflare присваивает window.turnstile ПОСЛЕ загрузки страницы. +// Object.defineProperty перехватывает это присвоение и патчит .execute. +const INJECT_SCRIPT = ` +(function() { + var _orig; + function patch(t) { + if (!t || typeof t.execute !== 'function' || t.__patched) return; + t.__patched = true; + var origExec = t.execute.bind(t); + t.execute = function(sitekey, params) { + if (params && typeof params.callback === 'function') { + var cb = params.callback; + params.callback = function(token) { + if (token && token.indexOf('P1_') === 0) { + window.__capturedPasskey = token; + try { console.log('PASSKEY_CAPTURED:' + token); } catch(e){} + } + cb(token); + }; + } + return origExec(sitekey, params); + }; + } + // Если turnstile уже есть — патчим сразу + if (window.turnstile) { patch(window.turnstile); } + // Перехватываем будущее присвоение + Object.defineProperty(window, 'turnstile', { + get: function() { return _orig; }, + set: function(val) { _orig = val; patch(val); }, + configurable: true + }); +})(); +`; + +// ─── Получение токена через системный Chrome + CDP ──────────────────────────── +async function getPasskeyViaCDP(chromePath, cookieStr, cookies) { + const port = 9337; + const userDataDir = resolve(tmpdir(), 'suno-passkey-profile'); + log(`Запускаю системный Chrome (${chromePath})...`); + + const proc = spawn(chromePath, [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${userDataDir}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-popup-blocking', + 'about:blank', + ], { detached: false, stdio: 'ignore' }); + + // Ждём пока Chrome запустится + await new Promise(r => setTimeout(r, 2000)); + + let browser; + try { + browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + } catch (e) { + proc.kill(); + throw new Error(`Не удалось подключиться к Chrome CDP: ${e.message}`); + } + + const context = browser.contexts()[0] || await browser.newContext(); + + // Инжектируем патч + await context.addInitScript(INJECT_SCRIPT); + + // Добавляем cookies + await context.addCookies(cookies); + + const page = await context.newPage(); + + return new Promise(async (resolve, reject) => { + let done = false; + + const cleanup = async () => { + await browser.close().catch(() => {}); + proc.kill(); + }; + + const finish = async (token, src) => { + if (done) return; done = true; + log(`✅ Токен получен из: ${src} (${token.length} символов)`); + await cleanup(); + resolve(token); + }; + const fail = async (msg) => { + if (done) return; done = true; + await cleanup(); + reject(new Error(msg)); + }; + + setTimeout(() => fail( + `Токен не получен за ${TIMEOUT_MS / 1000} секунд. Проверь SUNO_COOKIE.` + ), TIMEOUT_MS); + + page.on('console', msg => { + const t = msg.text(); + if (t.startsWith('PASSKEY_CAPTURED:')) { + const token = t.replace('PASSKEY_CAPTURED:', '').trim(); + if (token.startsWith('P1_')) finish(token, 'turnstile (CDP Chrome)'); + } + }); + + const poll = setInterval(async () => { + if (done) { clearInterval(poll); return; } + try { + const token = await page.evaluate(() => window.__capturedPasskey || null); + if (token?.startsWith('P1_')) { clearInterval(poll); finish(token, '__capturedPasskey poll (CDP)'); } + } catch (_) {} + }, 400); + + await page.route('**/api/generate/v2**', async route => { + try { + const data = route.request().postDataJSON(); + if (data?.token?.startsWith('P1_')) { + await route.abort(); + finish(data.token, 'network route (CDP)'); + return; + } + } catch (_) {} + await route.abort().catch(() => {}); + }); + + try { + await page.goto('https://suno.com/create', { waitUntil: 'domcontentloaded', timeout: 25_000 }); + } catch (e) { return fail(`Не удалось загрузить страницу: ${e.message}`); } + + log('Страница загружена, ожидаю Turnstile...'); + try { await page.waitForLoadState('networkidle', { timeout: 12_000 }); } catch (_) {} + + // Принудительно инжектируем патч через evaluate (запасной вариант если initScript не успел) + try { + await page.evaluate(INJECT_SCRIPT); + log('Патч turnstile.execute инжектирован через evaluate'); + } catch (_) {} + + // Диагностика + try { + const diag = await page.evaluate(() => ({ + hasTurnstile: typeof window.turnstile !== 'undefined', + captured: window.__capturedPasskey?.substring(0, 20) || null, + title: document.title, + loggedIn: document.cookie.includes('__session') || document.body.innerHTML.includes('Create'), + })); + log(`Диагностика: ${JSON.stringify(diag)}`); + } catch (_) {} + + if (!done) { + // Нажимаем Create чтобы триггернуть Turnstile + try { + const ta = page.locator('textarea[placeholder="Describe the sound you want"]'); + await ta.waitFor({ timeout: 4000 }); + await ta.fill('test song for passkey'); + await page.waitForTimeout(500); + await page.click('button[aria-label="Create song"]'); + log('Create нажат, ожидаю токен...'); + await page.waitForTimeout(8000); // Turnstile может занять несколько секунд + } catch (e) { log(`Create click: ${e.message}`); } + } + }); +} + +// ─── Найти путь к системному Chrome ────────────────────────────────────────── +function findChrome() { + const candidates = [ + // Linux + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium-browser', + '/usr/bin/chromium', + // Windows + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + // macOS + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + return null; +} + +// ─── Основной процесс ───────────────────────────────────────────────────────── +async function getPasskeyToken() { + const cookieStr = process.env.SUNO_COOKIE || ''; + if (!cookieStr) throw new Error('SUNO_COOKIE не задан в .env'); + + const cookies = parseCookies(cookieStr); + if (!cookies.length) throw new Error('Не удалось распарсить SUNO_COOKIE'); + log(`Загружено ${cookies.length} cookies`); + + // Если не headless — запускаем системный Chrome через CDP. + // Это обходит ограничения rebrowser-playwright-core для non-headless. + if (!HEADLESS) { + const chromePath = findChrome(); + if (chromePath) { + return getPasskeyViaCDP(chromePath, cookieStr, cookies); + } + log('⚠️ Системный Chrome не найден, пробую headless Chromium...'); + } + + log(`Запускаю Chromium (headless=true)...`); + const browser = await chromium.launch({ + headless: true, + args: [ + '--disable-blink-features=AutomationControlled', + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-features=site-per-process,IsolateOrigins', + '--disable-extensions', + '--disable-infobars', + '--disable-gpu', + ], + }); + + const context = await browser.newContext({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + locale: process.env.BROWSER_LOCALE || 'en-US', + viewport: { width: 1280, height: 800 }, + }); + + await context.addCookies(cookies); + + // Инжектируем патч до загрузки любого скрипта страницы + await context.addInitScript(INJECT_SCRIPT); + + const page = await context.newPage(); + + return new Promise(async (resolve, reject) => { + let done = false; + + const finish = async (token, src) => { + if (done) return; + done = true; + log(`✅ Токен получен из: ${src} (${token.length} символов)`); + await browser.close().catch(() => {}); + resolve(token); + }; + + const fail = async (msg) => { + if (done) return; + done = true; + await browser.close().catch(() => {}); + reject(new Error(msg)); + }; + + // Таймаут + const timer = setTimeout(() => fail( + `Токен не получен за ${TIMEOUT_MS / 1000} секунд.\n` + + (headless + ? ` Headless режим — Cloudflare Turnstile заблокирован.\n Попробуй без --headless или через: xvfb-run -a node scripts/refresh-passkey.mjs --rebuild` + : ` Проверь SUNO_COOKIE — возможно сессия истекла.`) + ), TIMEOUT_MS); + + // ── Перехват из console.log (сигнал от нашего inject-скрипта) ───────────── + page.on('console', (msg) => { + const text = msg.text(); + if (text.startsWith('PASSKEY_CAPTURED:')) { + const token = text.replace('PASSKEY_CAPTURED:', '').trim(); + if (token.startsWith('P1_')) { + clearTimeout(timer); + finish(token, 'turnstile.execute (console)'); + } + } + }); + + // ── Polling window.__capturedPasskey каждые 400ms ───────────────────────── + const poll = setInterval(async () => { + if (done) { clearInterval(poll); return; } + try { + const token = await page.evaluate(() => window.__capturedPasskey || null); + if (token && token.startsWith('P1_')) { + clearInterval(poll); + clearTimeout(timer); + finish(token, 'window.__capturedPasskey (poll)'); + } + } catch (_) {} + }, 400); + + log('Открываю https://suno.com/create ...'); + try { + await page.goto('https://suno.com/create', { waitUntil: 'domcontentloaded', timeout: 25_000 }); + } catch (e) { + clearTimeout(timer); clearInterval(poll); + return fail(`Не удалось загрузить страницу: ${e.message}`); + } + + log('Страница загружена. Ожидаю Cloudflare Turnstile...'); + + // Ждём полной загрузки JS (Turnstile инициализируется после DOMContentLoaded) + try { await page.waitForLoadState('networkidle', { timeout: 15_000 }); } catch (_) {} + + // Если после networkidle токена нет — пробуем кликнуть Create (без кредитов — route abort) + if (!done) { + log('Turnstile не сработал сам — нажимаю Create (запрос будет прерван)...'); + await page.route('**/api/generate/v2**', async (route) => { + // Перехватываем тело запроса, потом abort — кредиты не тратятся + try { + const data = route.request().postDataJSON(); + if (data?.token?.startsWith('P1_')) { + clearTimeout(timer); clearInterval(poll); + await route.abort(); + finish(data.token, 'network route (aborted)'); + return; + } + } catch (_) {} + await route.abort().catch(() => {}); + }); + + try { + const textarea = page.locator('textarea[placeholder="Describe the sound you want"]'); + await textarea.waitFor({ timeout: 5000 }); + await textarea.fill('test'); + await page.click('button[aria-label="Create song"]'); + log('Create нажат, ожидаю токен...'); + } catch (e) { + log(`⚠️ Не смог нажать Create: ${e.message}`); + } + } + }); +} + +// ─── Точка входа ────────────────────────────────────────────────────────────── +async function main() { + log('=== Обновление SUNO_PASSKEY_TOKEN ==='); + if (!HEADLESS) log('Режим: visible браузер (нужен для Cloudflare Turnstile)'); + + const token = await getPasskeyToken().catch(e => { log(`❌ ${e.message}`); process.exit(1); }); + + updateEnvFile('SUNO_PASSKEY_TOKEN', token); + + if (REBUILD) { + log('🔨 npm run build...'); + execSync('npm run build', { cwd: ROOT, stdio: 'inherit' }); + log('✅ Build завершён'); + } + + if (RESTART) { + for (const cmd of ['systemctl restart suno-api', 'pm2 restart suno-api']) { + try { execSync(cmd, { stdio: 'inherit' }); log(`✅ ${cmd}`); break; } + catch (_) {} + } + } + + if (!REBUILD) { + log(''); + log('⚠️ Применить изменения: npm run build && npm start'); + log(' Или: npm run refresh-passkey:rebuild'); + } + + log('=== Готово ==='); +} + +main(); diff --git a/src/lib/SunoApi.ts b/src/lib/SunoApi.ts index cad506ec..ccab088d 100644 --- a/src/lib/SunoApi.ts +++ b/src/lib/SunoApi.ts @@ -18,7 +18,7 @@ const cache = globalForSunoApi.sunoApiCache || new Map(); globalForSunoApi.sunoApiCache = cache; const logger = pino(); -export const DEFAULT_MODEL = 'chirp-v3-5'; +export const DEFAULT_MODEL = 'chirp-fenix'; export interface AudioInfo { id: string; // Unique identifier for the audio @@ -68,7 +68,7 @@ interface PersonaResponse { } class SunoApi { - private static BASE_URL: string = 'https://studio-api.prod.suno.com'; + private static BASE_URL: string = 'https://studio-api-prod.suno.com'; private static CLERK_BASE_URL: string = 'https://auth.suno.com'; private static CLERK_VERSION = '5.117.0'; @@ -305,6 +305,10 @@ class SunoApi { * @returns {string|null} hCaptcha token. If no verification is required, returns null */ public async getCaptcha(): Promise { + // PATCH: skip captcha entirely — Playwright selectors are broken after suno.com UI update + logger.info('CAPTCHA bypass: returning null token'); + return null; + if (!await this.captchaRequired()) return null; @@ -558,15 +562,39 @@ class SunoApi { continue_at?: number ): Promise { await this.keepAlive(); + // PATCH: build payload matching real suno.com web client format + const passkeyToken = process.env.SUNO_PASSKEY_TOKEN || ''; + const txUuid = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : Math.random().toString(36); const payload: any = { - make_instrumental: make_instrumental, + token: passkeyToken, + generation_type: 'TEXT', mv: model || DEFAULT_MODEL, prompt: '', - generation_type: 'TEXT', - continue_at: continue_at, - continue_clip_id: continue_clip_id, - task: task, - token: await this.getCaptcha() + gpt_description_prompt: '', + make_instrumental: make_instrumental, + user_uploaded_images_b64: null, + metadata: { + web_client_pathname: '/create', + is_max_mode: false, + is_mumble: false, + create_mode: 'simple', + user_tier: process.env.SUNO_USER_TIER || '3eaebef3-ef46-446a-931c-3d50cd1514f1', + create_session_token: process.env.SUNO_CREATE_SESSION_TOKEN || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : ''), + disable_volume_normalization: false, + lyrics_model: 'default', + }, + override_fields: [], + cover_clip_id: null, + cover_start_s: null, + cover_end_s: null, + persona_id: null, + artist_clip_id: null, + artist_start_s: null, + artist_end_s: null, + continue_clip_id: continue_clip_id || null, + continued_aligned_prompt: null, + continue_at: continue_at || null, + transaction_uuid: txUuid, }; if (isCustom) { payload.tags = tags; @@ -594,7 +622,7 @@ class SunoApi { ) ); const response = await this.client.post( - `${SunoApi.BASE_URL}/api/generate/v2/`, + `${SunoApi.BASE_URL}/api/generate/v2-web/`, payload, { timeout: 10000 // 10 seconds timeout