Implement hCaptcha solving via 2Captcha & other stuff
- implement hCaptcha solving via paid service 2Captcha and browser automation library Playwright with rebrowser-patches - implement sunoApi instances caching so sessions won't be constantly updated - add support for entering cookies not only in SUNO_COOKIE, but also the Cookie HTTP header - update docs and add Russian docs
This commit is contained in:
@@ -19,7 +19,7 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const audioInfo = await (await sunoApi).getClip(clipId);
|
||||
const audioInfo = await (await sunoApi()).getClip(clipId);
|
||||
|
||||
return new NextResponse(JSON.stringify(audioInfo), {
|
||||
status: 200,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { cookies } from 'next/headers'
|
||||
import { sunoApi } from "@/lib/SunoApi";
|
||||
import { corsHeaders } from "@/lib/utils";
|
||||
|
||||
@@ -18,7 +19,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
});
|
||||
}
|
||||
const audioInfo = await (await sunoApi).concatenate(clip_id);
|
||||
const audioInfo = await (await sunoApi((await cookies()).toString())).concatenate(clip_id);
|
||||
return new NextResponse(JSON.stringify(audioInfo), {
|
||||
status: 200,
|
||||
headers: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { cookies } from 'next/headers';
|
||||
import { DEFAULT_MODEL, sunoApi } from "@/lib/SunoApi";
|
||||
import { corsHeaders } from "@/lib/utils";
|
||||
|
||||
@@ -10,7 +11,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { prompt, tags, title, make_instrumental, model, wait_audio, negative_tags } = body;
|
||||
const audioInfo = await (await sunoApi).custom_generate(
|
||||
const audioInfo = await (await sunoApi((await cookies()).toString())).custom_generate(
|
||||
prompt, tags, title,
|
||||
Boolean(make_instrumental),
|
||||
model || DEFAULT_MODEL,
|
||||
@@ -25,18 +26,9 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error generating custom audio:', error.response.data);
|
||||
if (error.response.status === 402) {
|
||||
return new NextResponse(JSON.stringify({ error: error.response.data.detail }), {
|
||||
status: 402,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...corsHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
return new NextResponse(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
console.error('Error generating custom audio:', error);
|
||||
return new NextResponse(JSON.stringify({ error: error.response?.data?.detail || error.toString() }), {
|
||||
status: error.response?.status || 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...corsHeaders
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { cookies } from 'next/headers'
|
||||
import { DEFAULT_MODEL, sunoApi } from "@/lib/SunoApi";
|
||||
import { corsHeaders } from "@/lib/utils";
|
||||
|
||||
@@ -8,7 +9,7 @@ export async function POST(req: NextRequest) {
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { audio_id, prompt, continue_at, tags, title, model } = body;
|
||||
const { audio_id, prompt, continue_at, tags, negative_tags, title, model, wait_audio } = body;
|
||||
|
||||
if (!audio_id) {
|
||||
return new NextResponse(JSON.stringify({ error: 'Audio ID is required' }), {
|
||||
@@ -20,8 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const audioInfo = await (await sunoApi)
|
||||
.extendAudio(audio_id, prompt, continue_at, tags, title, model || DEFAULT_MODEL);
|
||||
const audioInfo = await (await sunoApi((await cookies()).toString()))
|
||||
.extendAudio(audio_id, prompt, continue_at, tags || '', negative_tags || '', title, model || DEFAULT_MODEL, wait_audio || false);
|
||||
|
||||
return new NextResponse(JSON.stringify(audioInfo), {
|
||||
status: 200,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { cookies } from 'next/headers'
|
||||
import { DEFAULT_MODEL, sunoApi } from "@/lib/SunoApi";
|
||||
import { corsHeaders } from "@/lib/utils";
|
||||
|
||||
@@ -10,7 +11,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { prompt, make_instrumental, model, wait_audio } = body;
|
||||
|
||||
const audioInfo = await (await sunoApi).generate(
|
||||
const audioInfo = await (await sunoApi((await cookies()).toString())).generate(
|
||||
prompt,
|
||||
Boolean(make_instrumental),
|
||||
model || DEFAULT_MODEL,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { cookies } from 'next/headers'
|
||||
import { sunoApi } from "@/lib/SunoApi";
|
||||
import { corsHeaders } from "@/lib/utils";
|
||||
|
||||
@@ -10,7 +11,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { prompt } = body;
|
||||
|
||||
const lyrics = await (await sunoApi).generateLyrics(prompt);
|
||||
const lyrics = await (await sunoApi((await cookies()).toString())).generateLyrics(prompt);
|
||||
|
||||
return new NextResponse(JSON.stringify(lyrics), {
|
||||
status: 200,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { cookies } from 'next/headers';
|
||||
import { DEFAULT_MODEL, sunoApi } from "@/lib/SunoApi";
|
||||
import { corsHeaders } from "@/lib/utils";
|
||||
|
||||
@@ -20,7 +21,7 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const audioInfo = await (await sunoApi)
|
||||
const audioInfo = await (await sunoApi((await cookies()).toString()))
|
||||
.generateStems(audio_id);
|
||||
|
||||
return new NextResponse(JSON.stringify(audioInfo), {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, NextRequest } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { sunoApi } from '@/lib/SunoApi';
|
||||
import { corsHeaders } from '@/lib/utils';
|
||||
|
||||
@@ -10,13 +11,14 @@ export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const songIds = url.searchParams.get('ids');
|
||||
const page = url.searchParams.get('page');
|
||||
const cookie = (await cookies()).toString();
|
||||
|
||||
let audioInfo = [];
|
||||
if (songIds && songIds.length > 0) {
|
||||
const idsArray = songIds.split(',');
|
||||
audioInfo = await (await sunoApi).get(idsArray, page);
|
||||
audioInfo = await (await sunoApi(cookie)).get(idsArray, page);
|
||||
} else {
|
||||
audioInfo = await (await sunoApi).get(undefined, page);
|
||||
audioInfo = await (await sunoApi(cookie)).get(undefined, page);
|
||||
}
|
||||
|
||||
return new NextResponse(JSON.stringify(audioInfo), {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { cookies } from 'next/headers'
|
||||
import { sunoApi } from "@/lib/SunoApi";
|
||||
import { corsHeaders } from "@/lib/utils";
|
||||
|
||||
@@ -20,7 +21,7 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const lyricAlignment = await (await sunoApi).getLyricAlignment(song_id);
|
||||
const lyricAlignment = await (await sunoApi((await cookies()).toString())).getLyricAlignment(song_id);
|
||||
|
||||
|
||||
return new NextResponse(JSON.stringify(lyricAlignment), {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { cookies } from 'next/headers'
|
||||
import { sunoApi } from "@/lib/SunoApi";
|
||||
import { corsHeaders } from "@/lib/utils";
|
||||
|
||||
@@ -8,7 +9,7 @@ export async function GET(req: NextRequest) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
|
||||
const limit = await (await sunoApi).get_credits();
|
||||
const limit = await (await sunoApi((await cookies()).toString())).get_credits();
|
||||
|
||||
|
||||
return new NextResponse(JSON.stringify(limit), {
|
||||
|
||||
@@ -9,7 +9,12 @@ type Props = {
|
||||
const SwaggerUI = dynamic(() => import('swagger-ui-react'), { ssr: false });
|
||||
|
||||
function Swagger({ spec }: Props) {
|
||||
return <SwaggerUI spec={spec}/>;
|
||||
return <SwaggerUI spec={spec} requestInterceptor={(req) => {
|
||||
// Remove cookies before sending requests
|
||||
req.credentials = 'omit';
|
||||
console.log(req);
|
||||
return req;
|
||||
}} />;
|
||||
}
|
||||
|
||||
export default Swagger;
|
||||
@@ -231,6 +231,11 @@
|
||||
"description": "Music genre",
|
||||
"example": ""
|
||||
},
|
||||
"negative_tags": {
|
||||
"type": "string",
|
||||
"description": "Negative Music genre",
|
||||
"example":""
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model name ,default is chirp-v3-5",
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
|
||||
const audioInfo = await (await sunoApi).generate(userMessage.content, true, DEFAULT_MODEL, true);
|
||||
const audioInfo = await (await sunoApi()).generate(userMessage.content, true, DEFAULT_MODEL, true);
|
||||
|
||||
const audio = audioInfo[0]
|
||||
const data = `## Song Title: ${audio.title}\n\n### Lyrics:\n${audio.lyric}\n### Listen to the song: ${audio.audio_url}`
|
||||
|
||||
+330
-73
@@ -1,9 +1,18 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import UserAgent from 'user-agents';
|
||||
import pino from 'pino';
|
||||
import { wrapper } from 'axios-cookiejar-support';
|
||||
import { CookieJar } from 'tough-cookie';
|
||||
import { sleep } from '@/lib/utils';
|
||||
import yn from 'yn';
|
||||
import { sleep, isPage } from '@/lib/utils';
|
||||
import * as cookie from 'cookie';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Solver } from '@2captcha/captcha-solver';
|
||||
import { BrowserContext, Page, Locator, chromium, firefox } from 'rebrowser-playwright-core';
|
||||
import { createCursor, Cursor } from 'ghost-cursor-playwright';
|
||||
|
||||
// sunoApi instance caching
|
||||
const globalForSunoApi = global as unknown as { sunoApiCache?: Map<string, SunoApi> };
|
||||
const cache = globalForSunoApi.sunoApiCache || new Map<string, SunoApi>();
|
||||
globalForSunoApi.sunoApiCache = cache;
|
||||
|
||||
const logger = pino();
|
||||
export const DEFAULT_MODEL = 'chirp-v3-5';
|
||||
@@ -30,37 +39,58 @@ export interface AudioInfo {
|
||||
class SunoApi {
|
||||
private static BASE_URL: string = 'https://studio-api.prod.suno.com';
|
||||
private static CLERK_BASE_URL: string = 'https://clerk.suno.com';
|
||||
private static JSDELIVR_BASE_URL: string = 'https://data.jsdelivr.com';
|
||||
private static CLERK_VERSION = '5.15.0';
|
||||
|
||||
private readonly client: AxiosInstance;
|
||||
private clerkVersion?: string;
|
||||
private sid?: string;
|
||||
private currentToken?: string;
|
||||
private deviceId?: string;
|
||||
private userAgent?: string;
|
||||
private cookies: Record<string, string | undefined>;
|
||||
private solver = new Solver(process.env.TWOCAPTCHA_KEY + '');
|
||||
private ghostCursorEnabled = yn(process.env.BROWSER_GHOST_CURSOR, { default: false });
|
||||
private cursor?: Cursor;
|
||||
|
||||
constructor(cookie: string) {
|
||||
const cookieJar = new CookieJar();
|
||||
const randomUserAgent = new UserAgent(/Chrome/).random().toString();
|
||||
this.client = wrapper(
|
||||
axios.create({
|
||||
jar: cookieJar,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'User-Agent': randomUserAgent,
|
||||
Cookie: cookie
|
||||
}
|
||||
})
|
||||
);
|
||||
this.client.interceptors.request.use((config) => {
|
||||
if (this.currentToken) {
|
||||
// Use the current token status
|
||||
config.headers['Authorization'] = `Bearer ${this.currentToken}`;
|
||||
constructor(cookies: string) {
|
||||
this.userAgent = new UserAgent(/Macintosh/).random().toString(); // Usually Mac systems get less amount of CAPTCHAs
|
||||
this.cookies = cookie.parse(cookies);
|
||||
this.deviceId = this.cookies.ajs_anonymous_id || randomUUID();
|
||||
this.client = axios.create({
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Affiliate-Id': 'undefined',
|
||||
'Device-Id': `"${this.deviceId}"`,
|
||||
'x-suno-client': 'Android prerelease-4nt180t 1.0.42',
|
||||
'X-Requested-With': 'com.suno.android',
|
||||
'sec-ch-ua': '"Chromium";v="130", "Android WebView";v="130", "Not?A_Brand";v="99"',
|
||||
'sec-ch-ua-mobile': '?1',
|
||||
'sec-ch-ua-platform': '"Android"',
|
||||
'User-Agent': this.userAgent
|
||||
}
|
||||
});
|
||||
this.client.interceptors.request.use(config => {
|
||||
if (this.currentToken && !config.headers.Authorization)
|
||||
config.headers.Authorization = `Bearer ${this.currentToken}`;
|
||||
const cookiesArray = Object.entries(this.cookies).map(([key, value]) =>
|
||||
cookie.serialize(key, value as string)
|
||||
);
|
||||
config.headers.Cookie = cookiesArray.join('; ');
|
||||
return config;
|
||||
});
|
||||
this.client.interceptors.response.use(resp => {
|
||||
const setCookieHeader = resp.headers['set-cookie'];
|
||||
if (Array.isArray(setCookieHeader)) {
|
||||
const newCookies = cookie.parse(setCookieHeader.join('; '));
|
||||
for (const [key, value] of Object.entries(newCookies)) {
|
||||
this.cookies[key] = value;
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
})
|
||||
}
|
||||
|
||||
public async init(): Promise<SunoApi> {
|
||||
await this.getClerkLatestVersion();
|
||||
//await this.getClerkLatestVersion();
|
||||
await this.getAuthToken();
|
||||
await this.keepAlive();
|
||||
return this;
|
||||
@@ -68,7 +98,8 @@ class SunoApi {
|
||||
|
||||
/**
|
||||
* Get the clerk package latest version id.
|
||||
*/
|
||||
* This method is commented because we are now using a hard-coded Clerk version, hence this method is not needed.
|
||||
|
||||
private async getClerkLatestVersion() {
|
||||
// URL to get clerk version ID
|
||||
const getClerkVersionUrl = `${SunoApi.JSDELIVR_BASE_URL}/v1/package/npm/@clerk/clerk-js`;
|
||||
@@ -80,26 +111,28 @@ class SunoApi {
|
||||
);
|
||||
}
|
||||
// Save clerk version ID for auth
|
||||
// this.clerkVersion = versionListResponse?.data?.['tags']['latest'];
|
||||
// Use a Clerk version released before fraud detection was implemented
|
||||
this.clerkVersion = "5.34.0";
|
||||
SunoApi.clerkVersion = versionListResponse?.data?.['tags']['latest'];
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the session ID and save it for later use.
|
||||
*/
|
||||
private async getAuthToken() {
|
||||
logger.info('Getting the session ID');
|
||||
// URL to get session ID
|
||||
const getSessionUrl = `${SunoApi.CLERK_BASE_URL}/v1/client?_clerk_js_version=${this.clerkVersion}`;
|
||||
const getSessionUrl = `${SunoApi.CLERK_BASE_URL}/v1/client?_is_native=true&_clerk_js_version=${SunoApi.CLERK_VERSION}`;
|
||||
// Get session ID
|
||||
const sessionResponse = await this.client.get(getSessionUrl);
|
||||
if (!sessionResponse?.data?.response?.['last_active_session_id']) {
|
||||
const sessionResponse = await this.client.get(getSessionUrl, {
|
||||
headers: { Authorization: this.cookies.__client }
|
||||
});
|
||||
if (!sessionResponse?.data?.response?.last_active_session_id) {
|
||||
throw new Error(
|
||||
'Failed to get session id, you may need to update the SUNO_COOKIE'
|
||||
);
|
||||
}
|
||||
// Save session ID for later use
|
||||
this.sid = sessionResponse.data.response['last_active_session_id'];
|
||||
this.sid = sessionResponse.data.response.last_active_session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,16 +144,238 @@ class SunoApi {
|
||||
throw new Error('Session ID is not set. Cannot renew token.');
|
||||
}
|
||||
// URL to renew session token
|
||||
const renewUrl = `${SunoApi.CLERK_BASE_URL}/v1/client/sessions/${this.sid}/tokens?_clerk_js_version==${this.clerkVersion}`;
|
||||
const renewUrl = `${SunoApi.CLERK_BASE_URL}/v1/client/sessions/${this.sid}/tokens?_is_native=true&_clerk_js_version=${SunoApi.CLERK_VERSION}`;
|
||||
// Renew session token
|
||||
const renewResponse = await this.client.post(renewUrl);
|
||||
logger.info('KeepAlive...\n');
|
||||
const renewResponse = await this.client.post(renewUrl, {}, {
|
||||
headers: { Authorization: this.cookies.__client }
|
||||
});
|
||||
if (isWait) {
|
||||
await sleep(1, 2);
|
||||
}
|
||||
const newToken = renewResponse.data['jwt'];
|
||||
const newToken = renewResponse.data.jwt;
|
||||
// Update Authorization field in request header with the new JWT token
|
||||
this.currentToken = newToken;
|
||||
logger.info(this.currentToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session token (not to be confused with session ID) and save it for later use.
|
||||
*/
|
||||
private async getSessionToken() {
|
||||
const tokenResponse = await this.client.post(
|
||||
`${SunoApi.BASE_URL}/api/user/create_session_id/`,
|
||||
{
|
||||
session_properties: JSON.stringify({ deviceId: this.deviceId }),
|
||||
session_type: 1
|
||||
}
|
||||
);
|
||||
return tokenResponse.data.session_id;
|
||||
}
|
||||
|
||||
private async captchaRequired(): Promise<boolean> {
|
||||
const resp = await this.client.post(`${SunoApi.BASE_URL}/api/c/check`, {
|
||||
ctype: 'generation'
|
||||
});
|
||||
logger.info(resp.data);
|
||||
// await sleep(10);
|
||||
return resp.data.required;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on a locator or XY vector. This method is made because of the difference between ghost-cursor-playwright and Playwright methods
|
||||
*/
|
||||
private async click(target: Locator|Page, position?: { x: number, y: number }): Promise<void> {
|
||||
if (this.ghostCursorEnabled) {
|
||||
let pos: any = isPage(target) ? { x: 0, y: 0 } : await target.boundingBox();
|
||||
if (position)
|
||||
pos = {
|
||||
...pos,
|
||||
x: pos.x + position.x,
|
||||
y: pos.y + position.y,
|
||||
width: null,
|
||||
height: null,
|
||||
};
|
||||
return this.cursor?.actions.click({
|
||||
target: pos
|
||||
});
|
||||
} else {
|
||||
if (isPage(target))
|
||||
return target.mouse.click(position?.x ?? 0, position?.y ?? 0);
|
||||
else
|
||||
return target.click({ force: true, position });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the BrowserType from the `BROWSER` environment variable.
|
||||
* @returns {BrowserType} chromium, firefox or webkit. Default is chromium
|
||||
*/
|
||||
private getBrowserType() {
|
||||
const browser = process.env.BROWSER?.toLowerCase();
|
||||
switch (browser) {
|
||||
case 'firefox':
|
||||
return firefox;
|
||||
/*case 'webkit': ** doesn't work with rebrowser-patches
|
||||
case 'safari':
|
||||
return webkit;*/
|
||||
default:
|
||||
return chromium;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches a browser with the necessary cookies
|
||||
* @returns {BrowserContext}
|
||||
*/
|
||||
private async launchBrowser(): Promise<BrowserContext> {
|
||||
const browser = await this.getBrowserType().launch({
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-web-security',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-features=site-per-process',
|
||||
'--disable-features=IsolateOrigins',
|
||||
'--disable-extensions',
|
||||
'--disable-infobars'
|
||||
],
|
||||
headless: yn(process.env.BROWSER_HEADLESS, { default: true })
|
||||
});
|
||||
const context = await browser.newContext({ userAgent: this.userAgent, locale: process.env.BROWSER_LOCALE, viewport: null });
|
||||
const cookies = [];
|
||||
const lax: 'Lax' | 'Strict' | 'None' = 'Lax';
|
||||
cookies.push({
|
||||
name: '__session',
|
||||
value: this.currentToken+'',
|
||||
domain: '.suno.com',
|
||||
path: '/',
|
||||
sameSite: lax
|
||||
});
|
||||
for (const key in this.cookies) {
|
||||
cookies.push({
|
||||
name: key,
|
||||
value: this.cookies[key]+'',
|
||||
domain: '.suno.com',
|
||||
path: '/',
|
||||
sameSite: lax
|
||||
})
|
||||
}
|
||||
await context.addCookies(cookies);
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for CAPTCHA verification and solves the CAPTCHA if needed
|
||||
* @returns {string|null} hCaptcha token. If no verification is required, returns null
|
||||
*/
|
||||
public async getCaptcha(): Promise<string|null> {
|
||||
if (!await this.captchaRequired())
|
||||
return null;
|
||||
|
||||
logger.info('CAPTCHA required. Launching browser...')
|
||||
const browser = await this.launchBrowser();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://suno.com/create', { referer: 'https://www.google.com/', waitUntil: 'domcontentloaded', timeout: 0 });
|
||||
|
||||
page.on('request', request => console.log('>>', request.method(), request.url()));
|
||||
page.on('response', response => console.log('<<', response.status(), response.url()));
|
||||
|
||||
logger.info('Waiting for Suno interface to load');
|
||||
await page.locator('.react-aria-GridList').waitFor({ timeout: 60000 });
|
||||
|
||||
if (this.ghostCursorEnabled)
|
||||
this.cursor = await createCursor(page);
|
||||
|
||||
logger.info('Triggering the CAPTCHA');
|
||||
await this.click(page, { x: 318, y: 13 }); // close all popups
|
||||
|
||||
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);
|
||||
|
||||
new Promise<void>(async (resolve, reject) => {
|
||||
const frame = page.frameLocator('iframe[title*="hCaptcha"]');
|
||||
const challenge = frame.locator('.challenge-container');
|
||||
while (true) {
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
//await sleep(0.1); // sometimes it takes a couple of seconds to display the image itself. unfortunately, the only option is to wait and hope that it loads
|
||||
const drag = (await challenge.locator('.prompt-text').first().innerText()).toLowerCase().includes('drag');
|
||||
if (drag) {
|
||||
logger.info('Got a dragging hCaptcha. This type of hCaptcha is currently not supported. Skipping...');
|
||||
this.click(frame.locator('.button-submit'));
|
||||
continue;
|
||||
}
|
||||
let captcha: any;
|
||||
for (let j = 0; j < 3; j++) { // try several times because sometimes 2Captcha could send an error
|
||||
try {
|
||||
logger.info('Sending the CAPTCHA to 2Captcha');
|
||||
captcha = await this.solver.coordinates({
|
||||
body: (await challenge.screenshot()).toString('base64'),
|
||||
lang: process.env.BROWSER_LOCALE
|
||||
});
|
||||
break;
|
||||
} catch(err: any) {
|
||||
logger.info(err.message);
|
||||
if (j != 2)
|
||||
logger.info('Retrying...');
|
||||
else
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
for (const data of captcha.data) {
|
||||
logger.info(data);
|
||||
await this.click(challenge, { x: +data.x, y: +data.y });
|
||||
}
|
||||
/*await*/ this.click(frame.locator('.button-submit')); // await is commented because we need to call waitForResponse at the same time
|
||||
} catch(e: any) {
|
||||
if (e.message.includes('viewport') || e.message.includes('timeout')) // when hCaptcha window has been closed due to inactivity,
|
||||
this.click(button); // click the Create button again to trigger the CAPTCHA
|
||||
else if (e.message.includes('been closed')) // catch error when closing the browser
|
||||
resolve();
|
||||
else
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
}).catch(e => {
|
||||
//if (!e.message.includes('been closed'))
|
||||
throw e;
|
||||
});
|
||||
return (new Promise((resolve, reject) => {
|
||||
page.route('**/api/generate/v2/**', async (route: any) => {
|
||||
try {
|
||||
logger.info('hCaptcha token received. Closing browser');
|
||||
route.abort();
|
||||
browser.browser()?.close();
|
||||
const request = route.request();
|
||||
this.currentToken = request.headers().authorization.split('Bearer ').pop();
|
||||
resolve(request.postDataJSON().token);
|
||||
} catch(err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Imitates Cloudflare Turnstile loading error. Unused right now, left for future
|
||||
*/
|
||||
private async getTurnstile() {
|
||||
return this.client.post(
|
||||
`https://clerk.suno.com/v1/client?__clerk_api_version=2021-02-05&_clerk_js_version=${SunoApi.CLERK_VERSION}&_method=PATCH`,
|
||||
{ captcha_error: '300030,300030,300030' },
|
||||
{ headers: { 'content-type': 'application/x-www-form-urlencoded' } });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,6 +480,8 @@ class SunoApi {
|
||||
* @param make_instrumental Indicates if the generated song should be instrumental.
|
||||
* @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning.
|
||||
* @param negative_tags Negative tags that should not be included in the generated audio.
|
||||
* @param task Optional indication of what to do. Enter 'extend' if extending an audio, otherwise specify null.
|
||||
* @param continue_clip_id
|
||||
* @returns A promise that resolves to an array of AudioInfo objects representing the generated songs.
|
||||
*/
|
||||
private async generateSongs(
|
||||
@@ -235,14 +492,21 @@ class SunoApi {
|
||||
make_instrumental?: boolean,
|
||||
model?: string,
|
||||
wait_audio: boolean = false,
|
||||
negative_tags?: string
|
||||
negative_tags?: string,
|
||||
task?: string,
|
||||
continue_clip_id?: string,
|
||||
continue_at?: number
|
||||
): Promise<AudioInfo[]> {
|
||||
await this.keepAlive(false);
|
||||
await this.keepAlive();
|
||||
const payload: any = {
|
||||
make_instrumental: make_instrumental,
|
||||
mv: model || DEFAULT_MODEL,
|
||||
prompt: '',
|
||||
generation_type: 'TEXT'
|
||||
generation_type: 'TEXT',
|
||||
continue_at: continue_at,
|
||||
continue_clip_id: continue_clip_id,
|
||||
task: task,
|
||||
token: await this.getCaptcha()
|
||||
};
|
||||
if (isCustom) {
|
||||
payload.tags = tags;
|
||||
@@ -276,13 +540,10 @@ class SunoApi {
|
||||
timeout: 10000 // 10 seconds timeout
|
||||
}
|
||||
);
|
||||
logger.info(
|
||||
'generateSongs Response:\n' + JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Error response:' + response.statusText);
|
||||
}
|
||||
const songIds = response.data['clips'].map((audio: any) => audio.id);
|
||||
const songIds = response.data.clips.map((audio: any) => audio.id);
|
||||
//Want to wait for music file generation
|
||||
if (wait_audio) {
|
||||
const startTime = Date.now();
|
||||
@@ -303,8 +564,7 @@ class SunoApi {
|
||||
}
|
||||
return lastResponse;
|
||||
} else {
|
||||
await this.keepAlive(true);
|
||||
return response.data['clips'].map((audio: any) => ({
|
||||
return response.data.clips.map((audio: any) => ({
|
||||
id: audio.id,
|
||||
title: audio.title,
|
||||
image_url: audio.image_url,
|
||||
@@ -366,26 +626,14 @@ class SunoApi {
|
||||
public async extendAudio(
|
||||
audioId: string,
|
||||
prompt: string = '',
|
||||
continueAt: string = '0',
|
||||
continueAt: number,
|
||||
tags: string = '',
|
||||
negative_tags: string = '',
|
||||
title: string = '',
|
||||
model?: string
|
||||
): Promise<AudioInfo> {
|
||||
await this.keepAlive(false);
|
||||
const response = await this.client.post(
|
||||
`${SunoApi.BASE_URL}/api/generate/v2/`,
|
||||
{
|
||||
continue_clip_id: audioId,
|
||||
continue_at: continueAt,
|
||||
mv: model || DEFAULT_MODEL,
|
||||
prompt: prompt,
|
||||
tags: tags,
|
||||
task: 'extend',
|
||||
title: title
|
||||
}
|
||||
);
|
||||
console.log('response:\n', response);
|
||||
return response.data;
|
||||
model?: string,
|
||||
wait_audio?: boolean
|
||||
): Promise<AudioInfo[]> {
|
||||
return this.generateSongs(prompt, true, tags, title, false, model, wait_audio, negative_tags, 'extend', audioId, continueAt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -460,7 +708,7 @@ class SunoApi {
|
||||
page?: string | null
|
||||
): Promise<AudioInfo[]> {
|
||||
await this.keepAlive(false);
|
||||
let url = new URL(`${SunoApi.BASE_URL}/api/feed/`);
|
||||
let url = new URL(`${SunoApi.BASE_URL}/api/feed/v2`);
|
||||
if (songIds) {
|
||||
url.searchParams.append('ids', songIds.join(','));
|
||||
}
|
||||
@@ -469,11 +717,11 @@ class SunoApi {
|
||||
}
|
||||
logger.info('Get audio status: ' + url.href);
|
||||
const response = await this.client.get(url.href, {
|
||||
// 3 seconds timeout
|
||||
timeout: 3000
|
||||
// 10 seconds timeout
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const audios = response.data;
|
||||
const audios = response.data.clips;
|
||||
|
||||
return audios.map((audio: any) => ({
|
||||
id: audio.id,
|
||||
@@ -523,13 +771,22 @@ class SunoApi {
|
||||
}
|
||||
}
|
||||
|
||||
const newSunoApi = async (cookie: string) => {
|
||||
const sunoApi = new SunoApi(cookie);
|
||||
return await sunoApi.init();
|
||||
};
|
||||
export const sunoApi = async (cookie?: string) => {
|
||||
const resolvedCookie = cookie || process.env.SUNO_COOKIE;
|
||||
if (!resolvedCookie) {
|
||||
logger.info('No cookie provided! Aborting...\nPlease provide a cookie either in the .env file or in the Cookie header of your request.')
|
||||
throw new Error('Please provide a cookie either in the .env file or in the Cookie header of your request.');
|
||||
}
|
||||
|
||||
if (!process.env.SUNO_COOKIE) {
|
||||
console.log('Environment does not contain SUNO_COOKIE.', process.env);
|
||||
}
|
||||
// Check if the instance for this cookie already exists in the cache
|
||||
const cachedInstance = cache.get(resolvedCookie);
|
||||
if (cachedInstance)
|
||||
return cachedInstance;
|
||||
|
||||
export const sunoApi = newSunoApi(process.env.SUNO_COOKIE || '');
|
||||
// If not, create a new instance and initialize it
|
||||
const instance = await new SunoApi(resolvedCookie).init();
|
||||
// Cache the initialized instance
|
||||
cache.set(resolvedCookie, instance);
|
||||
|
||||
return instance;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import pino from "pino";
|
||||
import { Page } from "rebrowser-playwright-core";
|
||||
|
||||
const logger = pino();
|
||||
|
||||
@@ -20,6 +21,14 @@ export const sleep = (x: number, y?: number): Promise<void> => {
|
||||
return new Promise(resolve => setTimeout(resolve, timeout));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param target A Locator or a page
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isPage = (target: any): target is Page => {
|
||||
return target.constructor.name === 'Page';
|
||||
}
|
||||
|
||||
|
||||
export const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
|
||||
Reference in New Issue
Block a user