Compare commits

..

No commits in common. "42cb19214f7217260060996ccf3dae2c1431e3db" and "9141a226b5f4fb53691ef4bca5756e893c7cab4c" have entirely different histories.

13 changed files with 5441 additions and 583 deletions

View File

@ -1,7 +1,7 @@
# For more information, please see the README.md # For more information, please see the README.md
SUNO_COOKIE= SUNO_COOKIE=
TWOCAPTCHA_KEY= # Obtain from 2captcha.com TWOCAPTCHA_KEY= # Obtain from 2captcha.com
BROWSER=chromium # `chromium` or `firefox`, although `chromium` is highly recommended BROWSER=chromium # chromium or firefox
BROWSER_GHOST_CURSOR=false BROWSER_GHOST_CURSOR=false
BROWSER_LOCALE=en BROWSER_LOCALE=en
BROWSER_HEADLESS=true BROWSER_HEADLESS=true

View File

@ -1,31 +1,25 @@
# syntax=docker/dockerfile:1
FROM node:lts-bookworm AS builder FROM node:lts-alpine AS builder
WORKDIR /src WORKDIR /src
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM node:lts-bookworm FROM node:lts-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y libnss3 \
libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libxkbcommon0 libasound2 libcups2 xvfb
ARG SUNO_COOKIE ARG SUNO_COOKIE
RUN if [ -z "$SUNO_COOKIE" ]; then echo "Warning: SUNO_COOKIE is not set. You will have to set the cookies in the Cookie header of your requests."; fi ARG BROWSER
RUN if [ -z "$SUNO_COOKIE" ]; then echo "Warning: SUNO_COOKIE is not set"; fi
ENV SUNO_COOKIE=${SUNO_COOKIE} ENV SUNO_COOKIE=${SUNO_COOKIE}
# Disable GPU acceleration, as with it suno-api won't work in a Docker environment RUN if [ -z "$BROWSER" ]; then echo "Warning: BROWSER is not set; will use chromium by default"; fi
ENV BROWSER_DISABLE_GPU=true ENV BROWSER=${BROWSER:-chromium}
RUN npm install --only=production RUN npm install --only=production
RUN npx playwright install $BROWSER
# Install all supported browsers, else switching browsers requires an image rebuild
RUN npx playwright install chromium
# RUN npx playwright install firefox
COPY --from=builder /src/.next ./.next COPY --from=builder /src/.next ./.next
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "start"] CMD ["npm", "run", "start"]

View File

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<h1 align="center"> <h1 align="center"">
Suno AI API Suno AI API
</h1> </h1>
<p>Use API to call the music generation AI of Suno.ai and easily integrate it into agents like GPTs.</p> <p>Use API to call the music generation AI of Suno.ai and easily integrate it into agents like GPTs.</p>
@ -64,11 +64,7 @@ We have deployed an example bound to a free Suno account, so it has daily usage
[Create](https://2captcha.com/auth/register?userType=customer) a new 2Captcha account, [top up](https://2captcha.com/pay) your balance and [get your API key](https://2captcha.com/enterpage#recognition). [Create](https://2captcha.com/auth/register?userType=customer) a new 2Captcha account, [top up](https://2captcha.com/pay) your balance and [get your API key](https://2captcha.com/enterpage#recognition).
> [!NOTE] If you are located in Russia or Belarus, use the [ruCaptcha](https://rucaptcha.com) interface instead of 2Captcha. It's the same service, but it supports payments from those countries.
> If you are located in Russia or Belarus, use the [ruCaptcha](https://rucaptcha.com) interface instead of 2Captcha. It's the same service, but it supports payments from those countries.
> [!TIP]
> If you want as few CAPTCHAs as possible, it is recommended to use a macOS system. macOS systems usually get fewer CAPTCHAs than Linux and Windows—this is due to its unpopularity in the web scraping industry. Running suno-api on Windows and Linux will work, but in some cases, you could get a pretty large number of CAPTCHAs.
### 3. Clone and deploy this project ### 3. Clone and deploy this project
@ -84,11 +80,9 @@ You can choose your preferred deployment method:
git clone https://github.com/gcui-art/suno-api.git git clone https://github.com/gcui-art/suno-api.git
cd suno-api cd suno-api
npm install npm install
npx playwright install chromium
``` ```
#### Docker #### Docker
>[!IMPORTANT]
> GPU acceleration will be disabled in Docker. If you have a slow CPU, it is recommended to [deploy locally](#run-locally).
Alternatively, you can use [Docker Compose](https://docs.docker.com/compose/). However, follow the step below before running. Alternatively, you can use [Docker Compose](https://docs.docker.com/compose/). However, follow the step below before running.
```bash ```bash

View File

@ -64,11 +64,7 @@ Suno — потрясающий сервис для ИИ-музыки. Несм
[Создайте](https://2captcha.com/ru/auth/register?userType=customer) новый аккаунт, [пополните](https://2captcha.com/ru/pay) баланс и [получите свой API-ключ](https://2captcha.com/ru/enterpage#recognition). [Создайте](https://2captcha.com/ru/auth/register?userType=customer) новый аккаунт, [пополните](https://2captcha.com/ru/pay) баланс и [получите свой API-ключ](https://2captcha.com/ru/enterpage#recognition).
> [!NOTE] Если вы находитесь в России или Беларуси, используйте интерфейс [ruCaptcha](https://rucaptcha.com) вместо 2Captcha. Это абсолютно тот же сервис, но данный интерфейс поддерживает платежи из этих стран.
> Если вы находитесь в России или Беларуси, используйте интерфейс [ruCaptcha](https://rucaptcha.com) вместо 2Captcha. Это абсолютно тот же сервис, но данный интерфейс поддерживает платежи из этих стран.
> [!TIP]
> Если вы хотите получать как можно меньше капч, рекомендуется использовать macOS. Системы на macOS обычно получают меньше капч, чем Linux и Windows — это связано с их непопулярностью в сфере веб-скрейпинга. Запуск suno-api на Windows и Linux будет работать, но в некоторых случаях вы можете получить довольно большое количество капч.
### 3. Скачайте и запустите проект ### 3. Скачайте и запустите проект
@ -84,10 +80,9 @@ Suno — потрясающий сервис для ИИ-музыки. Несм
git clone https://github.com/gcui-art/suno-api.git git clone https://github.com/gcui-art/suno-api.git
cd suno-api cd suno-api
npm install npm install
npx playwright install chromium
``` ```
#### Docker #### Docker
>[!IMPORTANT]
> Аппаратное видеоускорение браузера будет отключено в Docker. Если у вас медленный процессор, рекомендуется [развернуть локально](#локально).
Также можно использовать [Docker Compose](https://docs.docker.com/compose/), однако перед запуском выполните шаг ниже. Также можно использовать [Docker Compose](https://docs.docker.com/compose/), однако перед запуском выполните шаг ниже.
```bash ```bash

View File

@ -2,13 +2,11 @@ version: '3'
services: services:
suno-api: suno-api:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/suno-api:latest build:
# build: context: .
# context: . args:
# args: SUNO_COOKIE: ${SUNO_COOKIE}
# SUNO_COOKIE: ${SUNO_COOKIE}
volumes: volumes:
- ./public:/app/public - ./public:/app/public
ports: ports:
- "3013:3000" - "3000:3000"
env_file: ".env"

86
package-lock.json generated
View File

@ -10,7 +10,6 @@
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@2captcha/captcha-solver": "^1.3.0", "@2captcha/captcha-solver": "^1.3.0",
"@playwright/browser-chromium": "^1.49.1",
"@vercel/analytics": "^1.2.2", "@vercel/analytics": "^1.2.2",
"axios": "^1.7.8", "axios": "^1.7.8",
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
@ -18,17 +17,16 @@
"cookie": "^1.0.2", "cookie": "^1.0.2",
"electron": "^33.2.1", "electron": "^33.2.1",
"ghost-cursor-playwright": "^2.1.0", "ghost-cursor-playwright": "^2.1.0",
"https-proxy-agent": "^7.0.6",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "14.1.4", "next": "14.1.4",
"next-swagger-doc": "^0.4.0", "next-swagger-doc": "^0.4.0",
"pino": "^8.19.0", "pino": "^8.19.0",
"pino-pretty": "^11.0.0", "pino-pretty": "^11.0.0",
"playwright-core": "^1.49.1",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"rebrowser-playwright-core": "^1.49.1", "rebrowser-playwright-core": "^1.49.1",
"socks-proxy-agent": "^8.0.5",
"swagger-ui-react": "^5.18.2", "swagger-ui-react": "^5.18.2",
"tough-cookie": "^4.1.4", "tough-cookie": "^4.1.4",
"user-agents": "^1.1.156", "user-agents": "^1.1.156",
@ -605,19 +603,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/browser-chromium": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.49.1.tgz",
"integrity": "sha512-LLeyllKSucbojsJBOpdJshwW27ZXZs3oypqffkVWLUvxX2azHJMOevsOcWpjCfoYbpevkaEozM2xHeSUGF00lg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.49.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -1602,15 +1587,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -4620,19 +4596,6 @@
"node": ">=10.19.0" "node": ">=10.19.0"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -4753,15 +4716,6 @@
"loose-envify": "^1.0.0" "loose-envify": "^1.0.0"
} }
}, },
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-alphabetical": { "node_modules/is-alphabetical": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@ -8435,44 +8389,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/sonic-boom": { "node_modules/sonic-boom": {
"version": "3.8.1", "version": "3.8.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz",

View File

@ -9,14 +9,13 @@
"version": "1.1.0", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "PORT=3013 next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@2captcha/captcha-solver": "^1.3.0", "@2captcha/captcha-solver": "^1.3.0",
"@playwright/browser-chromium": "^1.49.1",
"@vercel/analytics": "^1.2.2", "@vercel/analytics": "^1.2.2",
"axios": "^1.7.8", "axios": "^1.7.8",
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
@ -24,7 +23,6 @@
"cookie": "^1.0.2", "cookie": "^1.0.2",
"electron": "^33.2.1", "electron": "^33.2.1",
"ghost-cursor-playwright": "^2.1.0", "ghost-cursor-playwright": "^2.1.0",
"https-proxy-agent": "^7.0.6",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "14.1.4", "next": "14.1.4",
"next-swagger-doc": "^0.4.0", "next-swagger-doc": "^0.4.0",
@ -34,7 +32,6 @@
"react-dom": "^18", "react-dom": "^18",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"rebrowser-playwright-core": "^1.49.1", "rebrowser-playwright-core": "^1.49.1",
"socks-proxy-agent": "^8.0.5",
"swagger-ui-react": "^5.18.2", "swagger-ui-react": "^5.18.2",
"tough-cookie": "^4.1.4", "tough-cookie": "^4.1.4",
"user-agents": "^1.1.156", "user-agents": "^1.1.156",

5345
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +0,0 @@
import { NextResponse, NextRequest } from "next/server";
import { sunoApi } from "@/lib/SunoApi";
import { corsHeaders } from "@/lib/utils";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
if (req.method === 'GET') {
try {
const url = new URL(req.url);
const personaId = url.searchParams.get('id');
const page = url.searchParams.get('page');
if (personaId == null) {
return new NextResponse(JSON.stringify({ error: 'Missing parameter id' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
...corsHeaders
}
});
}
const pageNumber = page ? parseInt(page) : 1;
const personaInfo = await (await sunoApi()).getPersonaPaginated(personaId, pageNumber);
return new NextResponse(JSON.stringify(personaInfo), {
status: 200,
headers: {
'Content-Type': 'application/json',
...corsHeaders
}
});
} catch (error) {
console.error('Error fetching persona:', error);
return new NextResponse(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
...corsHeaders
}
});
}
} else {
return new NextResponse('Method Not Allowed', {
headers: {
Allow: 'GET',
...corsHeaders
},
status: 405
});
}
}
export async function OPTIONS(request: Request) {
return new Response(null, {
status: 200,
headers: corsHeaders
});
}

View File

@ -33,7 +33,6 @@ export default function Docs() {
- \`/api/get_aligned_lyrics\`: Get list of timestamps for each word in the lyrics - \`/api/get_aligned_lyrics\`: Get list of timestamps for each word in the lyrics
- \`/api/clip\`: Get clip information based on ID passed as query parameter \`id\` - \`/api/clip\`: Get clip information based on ID passed as query parameter \`id\`
- \`/api/concat\`: Generate the whole song from extensions - \`/api/concat\`: Generate the whole song from extensions
- \`/api/persona\`: Get persona information and clips based on ID and page number
\`\`\` \`\`\`
Feel free to explore the detailed API parameters and conduct tests on this page. Feel free to explore the detailed API parameters and conduct tests on this page.

View File

@ -588,149 +588,6 @@
} }
} }
} }
},
"/api/persona": {
"get": {
"summary": "Get persona information and clips.",
"description": "Retrieve persona information, including associated clips and pagination data.",
"tags": ["default"],
"parameters": [
{
"name": "id",
"in": "query",
"required": true,
"description": "Persona ID",
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
"required": false,
"description": "Page number (defaults to 1)",
"schema": {
"type": "integer",
"default": 1
}
}
],
"responses": {
"200": {
"description": "success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"persona": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Persona ID"
},
"name": {
"type": "string",
"description": "Persona name"
},
"description": {
"type": "string",
"description": "Persona description"
},
"image_s3_id": {
"type": "string",
"description": "Persona image URL"
},
"root_clip_id": {
"type": "string",
"description": "Root clip ID"
},
"clip": {
"type": "object",
"description": "Root clip information"
},
"persona_clips": {
"type": "array",
"items": {
"type": "object",
"properties": {
"clip": {
"type": "object",
"description": "Clip information"
}
}
}
},
"is_suno_persona": {
"type": "boolean",
"description": "Whether this is a Suno official persona"
},
"is_public": {
"type": "boolean",
"description": "Whether this persona is public"
},
"upvote_count": {
"type": "integer",
"description": "Number of upvotes"
},
"clip_count": {
"type": "integer",
"description": "Number of clips"
}
}
},
"total_results": {
"type": "integer",
"description": "Total number of results"
},
"current_page": {
"type": "integer",
"description": "Current page number"
},
"is_following": {
"type": "boolean",
"description": "Whether the current user is following this persona"
}
}
}
}
}
},
"400": {
"description": "Missing parameter id",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string",
"example": "Missing parameter id"
}
}
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string",
"example": "Internal server error"
}
}
}
}
}
}
}
}
} }
}, },
"components": { "components": {

View File

@ -2,16 +2,15 @@ import axios, { AxiosInstance } from 'axios';
import UserAgent from 'user-agents'; import UserAgent from 'user-agents';
import pino from 'pino'; import pino from 'pino';
import yn from 'yn'; import yn from 'yn';
import { isPage, sleep, waitForRequests } from '@/lib/utils'; import { sleep, isPage } from '@/lib/utils';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Solver } from '@2captcha/captcha-solver'; 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 { BrowserContext, Page, Locator, chromium, firefox } from 'rebrowser-playwright-core';
import { createCursor, Cursor } from 'ghost-cursor-playwright'; import { createCursor, Cursor } from 'ghost-cursor-playwright';
import { paramsCoordinates } from '@2captcha/captcha-solver/dist/structs/2captcha';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'node:path'; import path from 'node:path';
import { SocksProxyAgent } from 'socks-proxy-agent';
// sunoApi instance caching // sunoApi instance caching
const globalForSunoApi = global as unknown as { sunoApiCache?: Map<string, SunoApi> }; const globalForSunoApi = global as unknown as { sunoApiCache?: Map<string, SunoApi> };
@ -40,34 +39,6 @@ export interface AudioInfo {
error_message?: string; // Error message if any error_message?: string; // Error message if any
} }
interface PersonaResponse {
persona: {
id: string;
name: string;
description: string;
image_s3_id: string;
root_clip_id: string;
clip: any; // You can define a more specific type if needed
user_display_name: string;
user_handle: string;
user_image_url: string;
persona_clips: Array<{
clip: any; // You can define a more specific type if needed
}>;
is_suno_persona: boolean;
is_trashed: boolean;
is_owned: boolean;
is_public: boolean;
is_public_approved: boolean;
is_loved: boolean;
upvote_count: number;
clip_count: number;
};
total_results: number;
current_page: number;
is_following: boolean;
}
class SunoApi { 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://clerk.suno.com'; private static CLERK_BASE_URL: string = 'https://clerk.suno.com';
@ -87,29 +58,6 @@ class SunoApi {
this.userAgent = new UserAgent(/Macintosh/).random().toString(); // Usually Mac systems get less amount of CAPTCHAs this.userAgent = new UserAgent(/Macintosh/).random().toString(); // Usually Mac systems get less amount of CAPTCHAs
this.cookies = cookie.parse(cookies); this.cookies = cookie.parse(cookies);
this.deviceId = this.cookies.ajs_anonymous_id || randomUUID(); this.deviceId = this.cookies.ajs_anonymous_id || randomUUID();
const proxyUrl = process.env.PROXY_URL;
let extraConfig: Record<string, any> = {};
if (proxyUrl) {
if (proxyUrl.startsWith('socks')) {
// SOCKS5 代理
const agent = new SocksProxyAgent(proxyUrl);
extraConfig = {
httpAgent: agent,
httpsAgent: agent,
proxy: false // 一定要关掉 axios 自带的 proxy
};
} else {
// HTTP/HTTPS 代理
const url = new URL(proxyUrl);
extraConfig = {
proxy: {
protocol: url.protocol.replace(':', ''), // 去掉末尾冒号
host: url.hostname,
port: Number(url.port)
}
};
}
}
this.client = axios.create({ this.client = axios.create({
withCredentials: true, withCredentials: true,
headers: { headers: {
@ -121,8 +69,7 @@ class SunoApi {
'sec-ch-ua-mobile': '?1', 'sec-ch-ua-mobile': '?1',
'sec-ch-ua-platform': '"Android"', 'sec-ch-ua-platform': '"Android"',
'User-Agent': this.userAgent 'User-Agent': this.userAgent
}, }
...extraConfig,
}); });
this.client.interceptors.request.use(config => { this.client.interceptors.request.use(config => {
if (this.currentToken && !config.headers.Authorization) if (this.currentToken && !config.headers.Authorization)
@ -233,6 +180,7 @@ class SunoApi {
ctype: 'generation' ctype: 'generation'
}); });
logger.info(resp.data); logger.info(resp.data);
// await sleep(10);
return resp.data.required; return resp.data.required;
} }
@ -283,7 +231,8 @@ class SunoApi {
* @returns {BrowserContext} * @returns {BrowserContext}
*/ */
private async launchBrowser(): Promise<BrowserContext> { private async launchBrowser(): Promise<BrowserContext> {
const args = [ const browser = await this.getBrowserType().launch({
args: [
'--disable-blink-features=AutomationControlled', '--disable-blink-features=AutomationControlled',
'--disable-web-security', '--disable-web-security',
'--no-sandbox', '--no-sandbox',
@ -292,20 +241,9 @@ class SunoApi {
'--disable-features=IsolateOrigins', '--disable-features=IsolateOrigins',
'--disable-extensions', '--disable-extensions',
'--disable-infobars' '--disable-infobars'
]; ],
// Check for GPU acceleration, as it is recommended to turn it off for Docker headless: yn(process.env.BROWSER_HEADLESS, { default: true })
if (yn(process.env.BROWSER_DISABLE_GPU, { default: false })) });
args.push('--enable-unsafe-swiftshader',
'--disable-gpu',
'--disable-setuid-sandbox');
try {
const browser = await this.getBrowserType().launch({
args,
headless: yn(process.env.BROWSER_HEADLESS, { default: true }),
...(process.env.PROXY_URL &&{ proxy: {
server: process.env.PROXY_URL,
}})
})
const context = await browser.newContext({ userAgent: this.userAgent, locale: process.env.BROWSER_LOCALE, viewport: null }); const context = await browser.newContext({ userAgent: this.userAgent, locale: process.env.BROWSER_LOCALE, viewport: null });
const cookies = []; const cookies = [];
const lax: 'Lax' | 'Strict' | 'None' = 'Lax'; const lax: 'Lax' | 'Strict' | 'None' = 'Lax';
@ -327,10 +265,6 @@ class SunoApi {
} }
await context.addCookies(cookies); await context.addCookies(cookies);
return context; return context;
}catch ( e){
console.log(e);
throw e;
}
} }
/** /**
@ -348,45 +282,46 @@ class SunoApi {
logger.info('Waiting for Suno interface to load'); 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/project/**\\?**', { timeout: 60000 }); // wait for song list API call await page.waitForResponse('**/api/feed/v2**', { timeout: 60000 }); // wait for song list API call
if (this.ghostCursorEnabled) if (this.ghostCursorEnabled)
this.cursor = await createCursor(page); this.cursor = await createCursor(page);
logger.info('Triggering the CAPTCHA'); logger.info('Triggering the CAPTCHA');
try { await this.click(page, { x: 318, y: 13 }); // close all popups
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'); const textarea = page.locator('.custom-textarea');
await this.click(textarea); await this.click(textarea);
await textarea.pressSequentially('Lorem ipsum', { delay: 80 }); await textarea.pressSequentially('Lorem ipsum', { delay: 80 });
const button = page.locator('button[aria-label="Create"]').locator('div.flex'); const button = page.locator('button[aria-label="Create"]').locator('div.flex');
this.click(button); await this.click(button);
const controller = new AbortController();
new Promise<void>(async (resolve, reject) => { new Promise<void>(async (resolve, reject) => {
const frame = page.frameLocator('iframe[title*="hCaptcha"]'); const frame = page.frameLocator('iframe[title*="hCaptcha"]');
const challenge = frame.locator('.challenge-container'); const challenge = frame.locator('.challenge-container');
try {
let wait = true;
while (true) { while (true) {
if (wait) try {
await waitForRequests(page, controller.signal); 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
}
}
const drag = (await challenge.locator('.prompt-text').first().innerText()).toLowerCase().includes('drag'); const drag = (await challenge.locator('.prompt-text').first().innerText()).toLowerCase().includes('drag');
let captcha: any; let captcha: any;
for (let j = 0; j < 3; j++) { // try several times because sometimes 2Captcha could return an error for (let j = 0; j < 3; j++) { // try several times because sometimes 2Captcha could send an error
try { try {
logger.info('Sending the CAPTCHA to 2Captcha'); logger.info('Sending the CAPTCHA to 2Captcha');
const payload: paramsCoordinates = { const payload: paramsCoordinates = {
body: (await challenge.screenshot({ timeout: 5000 })).toString('base64'), body: (await challenge.screenshot()).toString('base64'),
lang: process.env.BROWSER_LOCALE lang: process.env.BROWSER_LOCALE
}; };
if (drag) { if (drag) {
// Say to the worker that he needs to click // Say to the worker that he needs to click
payload.textinstructions = 'CLICK on the shapes at their edge or center as shown above—please be precise!'; payload.textinstructions = '! Instead of dragging, CLICK on the shapes as shown in the image above !';
payload.imginstructions = (await fs.readFile(path.join(process.cwd(), 'public', 'drag-instructions.jpg'))).toString('base64'); payload.imginstructions = (await fs.readFile(path.join(process.cwd(), 'public', 'drag-instructions.jpg'))).toString('base64');
} }
captcha = await this.solver.coordinates(payload); captcha = await this.solver.coordinates(payload);
@ -403,12 +338,6 @@ class SunoApi {
const challengeBox = await challenge.boundingBox(); const challengeBox = await challenge.boundingBox();
if (challengeBox == null) if (challengeBox == null)
throw new Error('.challenge-container boundingBox is 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) { for (let i = 0; i < captcha.data.length; i += 2) {
const data1 = captcha.data[i]; const data1 = captcha.data[i];
const data2 = captcha.data[i+1]; const data2 = captcha.data[i+1];
@ -419,27 +348,22 @@ class SunoApi {
await page.mouse.move(challengeBox.x + +data2.x, challengeBox.y + +data2.y, { steps: 30 }); await page.mouse.move(challengeBox.x + +data2.x, challengeBox.y + +data2.y, { steps: 30 });
await page.mouse.up(); await page.mouse.up();
} }
wait = true;
} else { } else {
for (const data of captcha.data) { for (const data of captcha.data) {
logger.info(data); logger.info(data);
await this.click(challenge, { x: +data.x, y: +data.y }); await this.click(challenge, { x: +data.x, y: +data.y });
}; };
} }
this.click(frame.locator('.button-submit')).catch(e => { /*await*/ this.click(frame.locator('.button-submit')); // await is commented because we need to call waitForResponse at the same time
if (e.message.includes('viewport')) // when hCaptcha window has been closed due to inactivity,
this.click(button); // click the Create button again to trigger the CAPTCHA
else
throw e;
});
}
} catch(e: any) { } catch(e: any) {
if (e.message.includes('been closed') // catch error when closing the browser if (e.message.includes('viewport') || e.message.includes('timeout')) // when hCaptcha window has been closed due to inactivity,
|| e.message == 'AbortError') // catch error when waitForRequests is aborted 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(); resolve();
else else
reject(e); reject(e);
} }
}
}).catch(e => { }).catch(e => {
browser.browser()?.close(); browser.browser()?.close();
throw e; throw e;
@ -450,7 +374,6 @@ class SunoApi {
logger.info('hCaptcha token received. Closing browser'); logger.info('hCaptcha token received. Closing browser');
route.abort(); route.abort();
browser.browser()?.close(); browser.browser()?.close();
controller.abort();
const request = route.request(); const request = route.request();
this.currentToken = request.headers().authorization.split('Bearer ').pop(); this.currentToken = request.headers().authorization.split('Bearer ').pop();
resolve(request.postDataJSON().token); resolve(request.postDataJSON().token);
@ -486,7 +409,7 @@ class SunoApi {
): Promise<AudioInfo[]> { ): Promise<AudioInfo[]> {
await this.keepAlive(false); await this.keepAlive(false);
const startTime = Date.now(); const startTime = Date.now();
const audios = await this.generateSongs( const audios = this.generateSongs(
prompt, prompt,
false, false,
undefined, undefined,
@ -862,28 +785,10 @@ class SunoApi {
monthly_usage: response.data.monthly_usage monthly_usage: response.data.monthly_usage
}; };
} }
public async getPersonaPaginated(personaId: string, page: number = 1): Promise<PersonaResponse> {
await this.keepAlive(false);
const url = `${SunoApi.BASE_URL}/api/persona/get-persona-paginated/${personaId}/?page=${page}`;
logger.info(`Fetching persona data: ${url}`);
const response = await this.client.get(url, {
timeout: 10000 // 10 seconds timeout
});
if (response.status !== 200) {
throw new Error('Error response: ' + response.statusText);
}
return response.data;
}
} }
export const sunoApi = async (cookie?: string) => { export const sunoApi = async (cookie?: string) => {
const resolvedCookie = cookie && cookie.includes('__client') ? cookie : process.env.SUNO_COOKIE; // Check for bad `Cookie` header (It's too expensive to actually parse the cookies *here*) const resolvedCookie = cookie || process.env.SUNO_COOKIE;
if (!resolvedCookie) { 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.') 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.'); throw new Error('Please provide a cookie either in the .env file or in the Cookie header of your request.');

View File

@ -29,87 +29,6 @@ export const isPage = (target: any): target is Page => {
return target.constructor.name === '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<void>}
*/
export const waitForRequests = (page: Page, signal: AbortSignal): Promise<void> => {
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 = { export const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',