Add Suno library batch download

This commit is contained in:
DESKTOP-KSVGT20\shkim
2026-05-01 16:44:45 +09:00
parent df6a653f16
commit 778ec302c0
2 changed files with 130 additions and 2 deletions
+3
View File
@@ -35,8 +35,11 @@ SUNO 화면 구조가 바뀌어 자동 입력이 실패하면, 패널의 `Title`
SUNO 곡 상세/플레이어 화면에서 `WAV + 커버 + 메타 다운로드`를 누르면 현재 페이지에서 감지된 곡 정보를 기준으로 아래 파일을 다운로드합니다. SUNO 곡 상세/플레이어 화면에서 `WAV + 커버 + 메타 다운로드`를 누르면 현재 페이지에서 감지된 곡 정보를 기준으로 아래 파일을 다운로드합니다.
SUNO 라이브러리 목록 화면에서는 `라이브러리 곡 감지`를 누른 뒤 `감지된 라이브러리 일괄 다운로드`를 누릅니다. 현재 브라우저에 로드된 곡 목록을 기준으로 처리하므로, 무한 스크롤 목록에서는 먼저 아래쪽까지 스크롤해서 곡을 로드해야 합니다.
- `{title}.wav`: clip id가 감지되면 `https://cdn1.suno.ai/{clipId}.wav` 후보를 내려받습니다. - `{title}.wav`: clip id가 감지되면 `https://cdn1.suno.ai/{clipId}.wav` 후보를 내려받습니다.
- `{title}.cover.*`: 페이지에서 감지된 앨범아트 URL을 내려받습니다. - `{title}.cover.*`: 페이지에서 감지된 앨범아트 URL을 내려받습니다.
- `{title}.metadata.json`: title, clip id, audio/image URL, 감지된 원본 메타데이터, 현재 페이지 URL을 저장합니다. - `{title}.metadata.json`: title, clip id, audio/image URL, 감지된 원본 메타데이터, 현재 페이지 URL을 저장합니다.
- `suno-library-{date}.manifest.json`: 일괄 다운로드 시 감지된 전체 곡 목록을 저장합니다.
WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책에 따라 실패할 수 있습니다. 이 경우 metadata JSON의 `detectedAudioUrls`, `detectedImageUrls`, `detectedIds`를 보고 실제 페이지에서 어떤 URL이 잡혔는지 확인할 수 있습니다. WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책에 따라 실패할 수 있습니다. 이 경우 metadata JSON의 `detectedAudioUrls`, `detectedImageUrls`, `detectedIds`를 보고 실제 페이지에서 어떤 URL이 잡혔는지 확인할 수 있습니다.
+127 -2
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name ChatGPT to Suno Prompt Copier // @name ChatGPT to Suno Prompt Copier
// @namespace local.suno-helper // @namespace local.suno-helper
// @version 0.2.0 // @version 0.3.0
// @description Copy structured ChatGPT song prompt versions into Suno fields. // @description Copy structured ChatGPT song prompt versions into Suno fields.
// @author local // @author local
// @match https://chatgpt.com/* // @match https://chatgpt.com/*
@@ -10,6 +10,8 @@
// @match https://*.suno.com/* // @match https://*.suno.com/*
// @updateURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js // @updateURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js
// @downloadURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js // @downloadURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js
// @connect cdn*.suno.ai
// @connect *.suno.com
// @run-at document-idle // @run-at document-idle
// @grant GM_addStyle // @grant GM_addStyle
// @grant GM_getValue // @grant GM_getValue
@@ -495,6 +497,84 @@
return output; return output;
} }
function firstValue(object, keys) {
for (const key of keys) {
if (object?.[key]) return object[key];
}
return "";
}
function isLikelyTrackObject(object) {
if (!object || typeof object !== "object" || Array.isArray(object)) return false;
const id = firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]);
const hasTrackText = firstValue(object, ["title", "name", "prompt", "gpt_description_prompt", "metadata"]);
const hasMedia = firstValue(object, ["audio_url", "audioUrl", "image_url", "imageUrl", "image_large_url", "wav_url", "wavUrl"]);
return Boolean(id && (hasTrackText || hasMedia));
}
function normalizeTrackObject(object) {
const id = String(firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]) || "").trim();
const title = String(firstValue(object, ["title", "name"]) || id || "suno-track").trim();
const imageUrl = firstValue(object, ["image_url", "imageUrl", "image_large_url", "imageLargeUrl", "cover_url", "coverUrl"]);
const audioUrl = firstValue(object, ["audio_url", "audioUrl", "play_url", "playUrl", "mp3_url", "mp3Url"]);
const wavUrl = firstValue(object, ["wav_url", "wavUrl"]) || (id ? `https://cdn1.suno.ai/${id}.wav` : "");
return {
id,
title,
audioUrl,
wavUrl,
imageUrl,
pageUrl: location.href,
metadata: object,
capturedAt: new Date().toISOString(),
};
}
function mergeTrack(target, source) {
if (!target) return source;
for (const key of ["title", "audioUrl", "wavUrl", "imageUrl", "pageUrl"]) {
if (!target[key] && source[key]) target[key] = source[key];
}
target.metadata = target.metadata && Object.keys(target.metadata).length ? target.metadata : source.metadata;
return target;
}
function scanSunoLibraryTracks() {
const scripts = readJsonScripts();
const objects = scripts.flatMap((item) => flattenObjects(item));
const tracks = new Map();
for (const object of objects) {
if (!isLikelyTrackObject(object)) continue;
const track = normalizeTrackObject(object);
if (!track.id) continue;
tracks.set(track.id, mergeTrack(tracks.get(track.id), track));
}
const htmlText = document.documentElement.innerHTML || "";
const idMatches = [...htmlText.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi)];
const fallbackIds = unique(idMatches
.filter((match) => /clip|audio|image|title|cdn[0-9]*\.suno\.ai/i.test(htmlText.slice(Math.max(0, match.index - 500), match.index + 500)))
.map((match) => match[0]));
for (const id of fallbackIds) {
if (tracks.has(id)) continue;
tracks.set(id, {
id,
title: id,
audioUrl: "",
wavUrl: `https://cdn1.suno.ai/${id}.wav`,
imageUrl: "",
pageUrl: location.href,
metadata: {},
capturedAt: new Date().toISOString(),
});
}
return [...tracks.values()]
.filter((track) => track.id || track.audioUrl || track.imageUrl)
.sort((a, b) => String(a.title).localeCompare(String(b.title), "ko"));
}
function findCurrentSunoTrack() { function findCurrentSunoTrack() {
const pageText = document.body.innerText || ""; const pageText = document.body.innerText || "";
const htmlText = document.documentElement.innerHTML || ""; const htmlText = document.documentElement.innerHTML || "";
@@ -595,6 +675,30 @@
setStatus(statusTarget, messages.join(" / ")); setStatus(statusTarget, messages.join(" / "));
} }
async function downloadSunoBatch(tracks, statusTarget) {
if (!tracks.length) {
setStatus(statusTarget, "다운로드할 곡을 못 찾았어. 라이브러리에서 스크롤을 좀 내려서 곡을 로드한 뒤 다시 눌러봐.");
return;
}
const manifest = {
pageUrl: location.href,
capturedAt: new Date().toISOString(),
count: tracks.length,
tracks,
};
downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`);
setStatus(statusTarget, `${tracks.length}곡 감지. manifest 저장 후 순차 다운로드 시작.`);
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
setStatus(statusTarget, `${index + 1}/${tracks.length} 요청 중: ${track.title || track.id}`);
downloadSunoPackage(track, statusTarget);
await new Promise((resolve) => setTimeout(resolve, 650));
}
setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`);
}
async function bootSuno() { async function bootSuno() {
const body = createPanel("Suno Prompt Paste"); const body = createPanel("Suno Prompt Paste");
const slots = await readSlots(); const slots = await readSlots();
@@ -615,17 +719,25 @@
<div class="cgs-row"> <div class="cgs-row">
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-download-track>WAV + 커버 + 메타 다운로드</button> <button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-download-track>WAV + 커버 + 메타 다운로드</button>
</div> </div>
<div class="cgs-row">
<button class="cgs-btn cgs-wide" type="button" data-cgs-scan-library>라이브러리 곡 감지</button>
</div>
<div class="cgs-row">
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-download-library>감지된 라이브러리 일괄 다운로드</button>
</div>
<div class="cgs-status"></div> <div class="cgs-status"></div>
`; `;
const summary = body.querySelector("[data-cgs-summary]"); const summary = body.querySelector("[data-cgs-summary]");
const slotSelect = body.querySelector("[data-cgs-slot]"); const slotSelect = body.querySelector("[data-cgs-slot]");
let libraryTracks = [];
const updateSummary = () => { const updateSummary = () => {
const payload = slots[Number(slotSelect.value)]; const payload = slots[Number(slotSelect.value)];
const track = findCurrentSunoTrack(); const track = findCurrentSunoTrack();
const promptText = payload ? `${payload.version || "저장된 프롬프트"}: ${payload.title || "제목 없음"}` : "선택 슬롯 비어있음"; const promptText = payload ? `${payload.version || "저장된 프롬프트"}: ${payload.title || "제목 없음"}` : "선택 슬롯 비어있음";
const libraryText = libraryTracks.length ? ` / 라이브러리 ${libraryTracks.length}` : "";
const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지"; const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지";
summary.textContent = promptText + trackText; summary.textContent = promptText + trackText + libraryText;
}; };
updateSummary(); updateSummary();
slotSelect.addEventListener("change", updateSummary); slotSelect.addEventListener("change", updateSummary);
@@ -668,6 +780,19 @@
} }
downloadSunoPackage(track, body); downloadSunoPackage(track, body);
}); });
body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => {
libraryTracks = scanSunoLibraryTracks();
updateSummary();
const sample = libraryTracks.slice(0, 3).map((track) => track.title || track.id).join(", ");
setStatus(body, libraryTracks.length ? `${libraryTracks.length}곡 감지: ${sample}` : "라이브러리 곡을 못 찾았어. 곡 목록을 스크롤해서 로드한 뒤 다시 눌러봐.");
});
body.querySelector("[data-cgs-download-library]").addEventListener("click", async () => {
if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks();
updateSummary();
await downloadSunoBatch(libraryTracks, body);
});
} }
function startApp() { function startApp() {