Compare commits

...

10 Commits

Author SHA1 Message Date
42cb19214f 优化代理 2025-09-07 18:42:24 +08:00
blueeon
0646ebc59b
Merge pull request #234 from sontl/persona-api
feat(api): Add persona endpoint for retrieving persona information and clips
2025-03-17 22:24:33 +08:00
Son Tran Lam
2bc500723f feat(api): Add persona endpoint for retrieving persona information and clips
- Implement `/api/persona` GET endpoint to fetch persona details
- Add Swagger documentation for the new persona API endpoint
- Update docs page to include new `/api/persona` route description
- Extend SunoApi class with `getPersonaPaginated` method to support persona data retrieval
2025-02-17 19:28:27 +08:00
blueeon
c3a8c568a5
Merge pull request #225 from CharlesCNorton/patch-1
fix(readme): remove the extra quotation mark in the <h1> tag
2025-01-31 16:13:17 +08:00
CharlesCNorton
defaaf1b7f
fix(readme): remove the extra quotation mark in the <h1> tag
An extra quotation mark in the <h1> align attribute was causing
syntax issues in the README. This commit corrects that to ensure
valid HTML rendering.
2025-01-28 10:48:35 -05:00
blueeon
48d667b064
Merge pull request #222 from gohoski/patch-1
Implement hCaptcha solving via 2Captcha [URGENT MERGE]
2025-01-27 22:39:18 +08:00
gohoski
48a39a77f4 implement cookie check, use browser NPM package for auto install instead of a manual command, fix Docker & add notice about macOS recommendation 2025-01-21 23:15:53 +03:00
gohoski
72bdbe083e change song API url in interface wait trigger 2025-01-15 23:17:00 +03:00
gohoski
881c6c773c changed wait for hCaptcha image logic & other stuff
- fixed bug in dragging type of hCaptcha when worker did not select an even amount of coordinates and it would crash
- change waitForResponse function to a waitForRequests util function with more proper checks
2025-01-11 01:48:17 +03:00
gohoski
52ad4dea00 properly catch hCaptcha window closing after timeout.
please note that you can't increase the timeout in any way, even by clicking,so the only option we have is to just reinstate the solving process
2025-01-08 03:10:49 +03:00
13 changed files with 583 additions and 5441 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 BROWSER=chromium # `chromium` or `firefox`, although `chromium` is highly recommended
BROWSER_GHOST_CURSOR=false BROWSER_GHOST_CURSOR=false
BROWSER_LOCALE=en BROWSER_LOCALE=en
BROWSER_HEADLESS=true BROWSER_HEADLESS=true

View File

@ -1,25 +1,31 @@
# syntax=docker/dockerfile:1
FROM node:lts-alpine AS builder FROM node:lts-bookworm 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-alpine FROM node:lts-bookworm
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
ARG BROWSER 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
RUN if [ -z "$SUNO_COOKIE" ]; then echo "Warning: SUNO_COOKIE is not set"; fi
ENV SUNO_COOKIE=${SUNO_COOKIE} ENV SUNO_COOKIE=${SUNO_COOKIE}
RUN if [ -z "$BROWSER" ]; then echo "Warning: BROWSER is not set; will use chromium by default"; fi # Disable GPU acceleration, as with it suno-api won't work in a Docker environment
ENV BROWSER=${BROWSER:-chromium} ENV BROWSER_DISABLE_GPU=true
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,7 +64,11 @@ 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).
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. > [!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.
> [!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
@ -80,9 +84,11 @@ 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,7 +64,11 @@ 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).
Если вы находитесь в России или Беларуси, используйте интерфейс [ruCaptcha](https://rucaptcha.com) вместо 2Captcha. Это абсолютно тот же сервис, но данный интерфейс поддерживает платежи из этих стран. > [!NOTE]
> Если вы находитесь в России или Беларуси, используйте интерфейс [ruCaptcha](https://rucaptcha.com) вместо 2Captcha. Это абсолютно тот же сервис, но данный интерфейс поддерживает платежи из этих стран.
> [!TIP]
> Если вы хотите получать как можно меньше капч, рекомендуется использовать macOS. Системы на macOS обычно получают меньше капч, чем Linux и Windows — это связано с их непопулярностью в сфере веб-скрейпинга. Запуск suno-api на Windows и Linux будет работать, но в некоторых случаях вы можете получить довольно большое количество капч.
### 3. Скачайте и запустите проект ### 3. Скачайте и запустите проект
@ -80,9 +84,10 @@ 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,11 +2,13 @@ version: '3'
services: services:
suno-api: suno-api:
build: image: registry.cn-shanghai.aliyuncs.com/easyaigc/suno-api:latest
context: . # build:
args: # context: .
SUNO_COOKIE: ${SUNO_COOKIE} # args:
# SUNO_COOKIE: ${SUNO_COOKIE}
volumes: volumes:
- ./public:/app/public - ./public:/app/public
ports: ports:
- "3000:3000" - "3013:3000"
env_file: ".env"

86
package-lock.json generated
View File

@ -10,6 +10,7 @@
"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",
@ -17,16 +18,17 @@
"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",
@ -603,6 +605,19 @@
"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",
@ -1587,6 +1602,15 @@
"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",
@ -4596,6 +4620,19 @@
"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",
@ -4716,6 +4753,15 @@
"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",
@ -8389,6 +8435,44 @@
"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,13 +9,14 @@
"version": "1.1.0", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "PORT=3013 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",
@ -23,6 +24,7 @@
"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",
@ -32,6 +34,7 @@
"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",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
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,6 +33,7 @@ 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,6 +588,149 @@
} }
} }
} }
},
"/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,15 +2,16 @@ 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 { sleep, isPage } from '@/lib/utils'; import { isPage, sleep, waitForRequests } 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> };
@ -39,6 +40,34 @@ 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';
@ -58,6 +87,29 @@ 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: {
@ -69,7 +121,8 @@ 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)
@ -180,7 +233,6 @@ 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;
} }
@ -231,8 +283,7 @@ class SunoApi {
* @returns {BrowserContext} * @returns {BrowserContext}
*/ */
private async launchBrowser(): Promise<BrowserContext> { private async launchBrowser(): Promise<BrowserContext> {
const browser = await this.getBrowserType().launch({ const args = [
args: [
'--disable-blink-features=AutomationControlled', '--disable-blink-features=AutomationControlled',
'--disable-web-security', '--disable-web-security',
'--no-sandbox', '--no-sandbox',
@ -241,9 +292,20 @@ class SunoApi {
'--disable-features=IsolateOrigins', '--disable-features=IsolateOrigins',
'--disable-extensions', '--disable-extensions',
'--disable-infobars' '--disable-infobars'
], ];
headless: yn(process.env.BROWSER_HEADLESS, { default: true }) // Check for GPU acceleration, as it is recommended to turn it off for Docker
}); 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';
@ -265,6 +327,10 @@ class SunoApi {
} }
await context.addCookies(cookies); await context.addCookies(cookies);
return context; return context;
}catch ( e){
console.log(e);
throw e;
}
} }
/** /**
@ -281,47 +347,46 @@ class SunoApi {
await page.goto('https://suno.com/create', { referer: 'https://www.google.com/', waitUntil: 'domcontentloaded', timeout: 0 }); await page.goto('https://suno.com/create', { referer: 'https://www.google.com/', waitUntil: 'domcontentloaded', timeout: 0 });
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/feed/v2**', { timeout: 60000 }); // wait for song list API call await page.waitForResponse('**/api/project/**\\?**', { 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');
await this.click(page, { x: 318, y: 13 }); // close all popups try {
await page.getByLabel('Close').click({ timeout: 2000 }); // close all popups
// await this.click(page, { x: 318, y: 13 });
} catch(e) {}
const textarea = page.locator('.custom-textarea'); 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');
await this.click(button); 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) {
try { if (wait)
await page.waitForResponse('https://img**.hcaptcha.com/**', { timeout: 60000 }); // wait for hCaptcha image to load await waitForRequests(page, controller.signal);
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 send an error for (let j = 0; j < 3; j++) { // try several times because sometimes 2Captcha could return 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()).toString('base64'), body: (await challenge.screenshot({ timeout: 5000 })).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 = '! Instead of dragging, CLICK on the shapes as shown in the image above !'; payload.textinstructions = 'CLICK on the shapes at their edge or center as shown above—please be precise!';
payload.imginstructions = (await fs.readFile(path.join(process.cwd(), 'public', 'drag-instructions.jpg'))).toString('base64'); 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);
@ -338,6 +403,12 @@ 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];
@ -348,22 +419,27 @@ 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 });
}; };
} }
/*await*/ this.click(frame.locator('.button-submit')); // await is commented because we need to call waitForResponse at the same time this.click(frame.locator('.button-submit')).catch(e => {
} catch(e: any) { if (e.message.includes('viewport')) // when hCaptcha window has been closed due to inactivity,
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 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 else
throw e;
});
}
} catch(e: any) {
if (e.message.includes('been closed') // catch error when closing the browser
|| e.message == 'AbortError') // catch error when waitForRequests is aborted
resolve(); resolve();
else else
reject(e); reject(e);
} }
}
}).catch(e => { }).catch(e => {
browser.browser()?.close(); browser.browser()?.close();
throw e; throw e;
@ -374,6 +450,7 @@ 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);
@ -409,7 +486,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 = this.generateSongs( const audios = await this.generateSongs(
prompt, prompt,
false, false,
undefined, undefined,
@ -785,10 +862,28 @@ 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 || process.env.SUNO_COOKIE; 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*)
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,6 +29,87 @@ 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': '*',