diff --git a/README.md b/README.md
index c82b90f..7d7f556 100644
--- a/README.md
+++ b/README.md
@@ -24,9 +24,19 @@ https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermo
## 사용법
-1. ChatGPT 답변 페이지에서 오른쪽 아래 `ChatGPT -> Suno` 패널을 확인합니다.
-2. 원하는 버전의 `저장` 버튼을 누릅니다.
+1. ChatGPT 답변 페이지에서 `Version A/B/C` 형식이 감지되면 오른쪽 위에 작은 `ChatGPT -> Suno` 패널이 나타납니다.
+2. 원하는 버전의 `S1` 또는 `S2` 버튼을 눌러 슬롯에 저장합니다.
3. SUNO Create 페이지로 이동합니다.
-4. 오른쪽 아래 `Suno Prompt Paste` 패널에서 `저장값 붙여넣기`를 누릅니다.
+4. 오른쪽 아래 `Suno Prompt Paste` 패널에서 슬롯을 고르고 `선택 슬롯 붙여넣기`를 누릅니다.
SUNO 화면 구조가 바뀌어 자동 입력이 실패하면, 패널의 `Title`, `Style of Music`, `Lyrics`, `Exclude` 버튼으로 개별 값을 클립보드에 복사해서 수동으로 붙여넣을 수 있습니다.
+
+## SUNO 다운로드
+
+SUNO 곡 상세/플레이어 화면에서 `WAV + 커버 + 메타 다운로드`를 누르면 현재 페이지에서 감지된 곡 정보를 기준으로 아래 파일을 다운로드합니다.
+
+- `{title}.wav`: clip id가 감지되면 `https://cdn1.suno.ai/{clipId}.wav` 후보를 내려받습니다.
+- `{title}.cover.*`: 페이지에서 감지된 앨범아트 URL을 내려받습니다.
+- `{title}.metadata.json`: title, clip id, audio/image URL, 감지된 원본 메타데이터, 현재 페이지 URL을 저장합니다.
+
+WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책에 따라 실패할 수 있습니다. 이 경우 metadata JSON의 `detectedAudioUrls`, `detectedImageUrls`, `detectedIds`를 보고 실제 페이지에서 어떤 URL이 잡혔는지 확인할 수 있습니다.
diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js
index 6646410..78f6836 100644
--- a/chatgpt-suno-tampermonkey.user.js
+++ b/chatgpt-suno-tampermonkey.user.js
@@ -1,7 +1,7 @@
// ==UserScript==
// @name ChatGPT to Suno Prompt Copier
// @namespace local.suno-helper
-// @version 0.1.3
+// @version 0.2.0
// @description Copy structured ChatGPT song prompt versions into Suno fields.
// @author local
// @match https://chatgpt.com/*
@@ -13,6 +13,7 @@
// @run-at document-idle
// @grant GM_addStyle
// @grant GM_getValue
+// @grant GM_download
// @grant GM_setClipboard
// @grant GM_setValue
// ==/UserScript==
@@ -20,7 +21,8 @@
(function () {
"use strict";
- const STORAGE_KEY = "chatgptSunoPromptPayload";
+ const STORAGE_KEY = "chatgptSunoPromptSlots";
+ const SLOT_COUNT = 2;
const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com");
const isSuno = location.hostname === "suno.com" || location.hostname.endsWith(".suno.com");
let chatRenderTimer = 0;
@@ -57,23 +59,6 @@
#cgs-panel.cgs-error {
border-color: rgba(255, 84, 84, 0.7);
}
- #cgs-launcher {
- position: fixed;
- right: 18px;
- top: 18px;
- z-index: 2147483647;
- min-width: 76px;
- min-height: 34px;
- padding: 7px 10px;
- color: #ffffff;
- background: #ef4444;
- border: 2px solid #ffffff;
- border-radius: 999px;
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
- cursor: pointer;
- font: 700 13px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- }
- #cgs-launcher:hover { background: #dc2626; }
#cgs-panel * { box-sizing: border-box; }
#cgs-panel.cgs-minimized .cgs-body { display: none; }
.cgs-head {
@@ -127,9 +112,21 @@
gap: 6px;
margin-top: 8px;
}
+ .cgs-select {
+ width: 100%;
+ min-height: 30px;
+ margin-top: 8px;
+ color: #f5f7fb;
+ background: rgba(255, 255, 255, 0.09);
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ border-radius: 7px;
+ padding: 4px 7px;
+ font: inherit;
+ }
`);
function createPanel(title, mode = "") {
+ document.querySelector("#cgs-launcher")?.remove();
const old = document.querySelector("#cgs-panel");
if (old) old.remove();
@@ -150,18 +147,6 @@
return panel.querySelector(".cgs-body");
}
- function ensureLauncher(label, onClick) {
- let launcher = document.querySelector("#cgs-launcher");
- if (!launcher) {
- launcher = document.createElement("button");
- launcher.id = "cgs-launcher";
- launcher.type = "button";
- document.body.appendChild(launcher);
- }
- launcher.textContent = label;
- launcher.onclick = onClick;
- }
-
function createErrorPanel(error) {
if (!document.body) return;
const body = createPanel("Suno Helper Error", "cgs-error");
@@ -260,24 +245,46 @@
.join("\n\n");
}
- async function savePayload(payload) {
- await GM_setValue(STORAGE_KEY, JSON.stringify(payload));
+ function emptySlots() {
+ return Array.from({ length: SLOT_COUNT }, () => null);
+ }
+
+ async function readSlots() {
+ const raw = await GM_getValue(STORAGE_KEY, "");
+ if (!raw) return emptySlots();
+ try {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) return emptySlots().map((_, index) => parsed[index] || null);
+ if (parsed && typeof parsed === "object") return [parsed, ...emptySlots()].slice(0, SLOT_COUNT);
+ } catch {
+ return emptySlots();
+ }
+ return emptySlots();
+ }
+
+ async function saveSlots(slots) {
+ await GM_setValue(STORAGE_KEY, JSON.stringify(emptySlots().map((_, index) => slots[index] || null)));
+ }
+
+ async function savePayload(payload, slotIndex) {
+ const slots = await readSlots();
+ slots[slotIndex] = { ...payload, savedAt: new Date().toISOString() };
+ await saveSlots(slots);
GM_setClipboard(formatPayload(payload), "text");
}
- async function readPayload() {
- const raw = await GM_getValue(STORAGE_KEY, "");
- if (!raw) return null;
- try {
- return JSON.parse(raw);
- } catch {
- return null;
- }
+ async function readPayload(slotIndex = 0) {
+ const slots = await readSlots();
+ return slots[slotIndex] || null;
+ }
+
+ function slotLabel(payload, index) {
+ if (!payload) return `Slot ${index + 1} - 비어있음`;
+ return `Slot ${index + 1} - ${payload.title || payload.version || "저장됨"}`;
}
function bootChatGPT() {
clearInterval(chatRenderTimer);
- ensureLauncher("Suno", () => bootChatGPT());
const body = createPanel("ChatGPT -> Suno", "cgs-chatgpt");
body.innerHTML = `
현재 답변에서 Version A/B/C를 찾아서 저장해.
@@ -288,29 +295,35 @@
`;
- const render = () => {
+ const render = async () => {
const list = body.querySelector("[data-cgs-list]");
const versions = parseVersions(getConversationText());
list.innerHTML = "";
if (!versions.length) {
- list.innerHTML = `아직 Version 형식을 못 찾았어.
`;
+ document.querySelector("#cgs-panel")?.remove();
return;
}
+ const slots = await readSlots();
for (const payload of versions) {
const row = document.createElement("div");
row.className = "cgs-row";
row.innerHTML = `
${escapeHtml(payload.version)}
-
+
+
`;
- const [saveButton, copyButton] = row.querySelectorAll("button");
- saveButton.addEventListener("click", async () => {
- await savePayload(payload);
- setStatus(body, `${payload.version} 저장하고 클립보드에도 복사했어.`);
- });
+ const copyButton = row.querySelector("button:not([data-slot])");
+ for (const saveButton of row.querySelectorAll("[data-slot]")) {
+ saveButton.title = slotLabel(slots[Number(saveButton.dataset.slot)], Number(saveButton.dataset.slot));
+ saveButton.addEventListener("click", async () => {
+ const slotIndex = Number(saveButton.dataset.slot);
+ await savePayload(payload, slotIndex);
+ setStatus(body, `${payload.version} -> Slot ${slotIndex + 1} 저장하고 복사했어.`);
+ });
+ }
copyButton.addEventListener("click", () => {
GM_setClipboard(formatPayload(payload), "text");
setStatus(body, `${payload.version} 전체 텍스트를 복사했어.`);
@@ -320,7 +333,7 @@
setStatus(body, `${versions.length}개 버전을 찾았어.`);
};
- body.querySelector("[data-cgs-rescan]").addEventListener("click", render);
+ body.querySelector("[data-cgs-rescan]").addEventListener("click", () => render());
render();
chatRenderTimer = setInterval(() => {
if (document.body.contains(body)) render();
@@ -435,30 +448,192 @@
return results;
}
+ function safeFilename(value, fallback = "suno-track") {
+ return String(value || fallback)
+ .replace(/[\\/:*?"<>|]+/g, " ")
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, 120) || fallback;
+ }
+
+ function extensionFromUrl(url, fallback) {
+ const clean = String(url || "").split("?")[0].split("#")[0];
+ const match = /\.([a-z0-9]{2,5})$/i.exec(clean);
+ return (match?.[1] || fallback).toLowerCase();
+ }
+
+ function unique(values) {
+ return [...new Set(values.filter(Boolean))];
+ }
+
+ function extractUrls(text, pattern) {
+ return unique([...String(text || "").matchAll(pattern)].map((match) => match[0].replace(/\\u002F/g, "/")));
+ }
+
+ function readJsonScripts() {
+ const results = [];
+ for (const script of document.querySelectorAll("script")) {
+ const text = script.textContent || "";
+ if (!text.includes("suno") && !text.includes("audio") && !text.includes("image")) continue;
+ try {
+ results.push(JSON.parse(text));
+ } catch {
+ results.push(text);
+ }
+ }
+ return results;
+ }
+
+ function flattenObjects(value, output = []) {
+ if (!value || typeof value !== "object") return output;
+ if (Array.isArray(value)) {
+ for (const item of value) flattenObjects(item, output);
+ return output;
+ }
+ output.push(value);
+ for (const item of Object.values(value)) flattenObjects(item, output);
+ return output;
+ }
+
+ function findCurrentSunoTrack() {
+ const pageText = document.body.innerText || "";
+ const htmlText = document.documentElement.innerHTML || "";
+ const scripts = readJsonScripts();
+ const objects = scripts.flatMap((item) => flattenObjects(item));
+ const audioElements = [...document.querySelectorAll("audio, video")].map((node) => node.currentSrc || node.src);
+ const allText = [htmlText, ...scripts.map((item) => typeof item === "string" ? item : JSON.stringify(item))].join("\n");
+ const uuidPattern = /[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 ids = unique([...allText.matchAll(uuidPattern)].map((match) => match[0]));
+ const cdnAudioUrls = extractUrls(allText, /https:\/\/cdn[0-9]*\.suno\.ai\/[^"' <>)]+?\.(?:mp3|wav|m4a|oga|ogg)(?:\?[^"' <>)]+)?/gi);
+ const cdnImageUrls = extractUrls(allText, /https:\/\/cdn[0-9]*\.suno\.ai\/[^"' <>)]+?\.(?:png|jpg|jpeg|webp)(?:\?[^"' <>)]+)?/gi);
+ const audioUrls = unique([...audioElements, ...cdnAudioUrls]);
+ const imageUrls = unique([
+ ...cdnImageUrls,
+ ...[...document.images].map((img) => img.currentSrc || img.src).filter((url) => /suno|cdn/i.test(url)),
+ ]);
+
+ const metadataObjects = objects.filter((item) => {
+ const text = JSON.stringify(item);
+ return /audio_url|image_url|clip|prompt|metadata|title|gpt_description_prompt/i.test(text)
+ && (ids.some((id) => text.includes(id)) || /cdn[0-9]*\.suno\.ai/i.test(text));
+ });
+ const metadata = metadataObjects[0] || {};
+ const title =
+ metadata.title ||
+ metadata.name ||
+ document.querySelector("h1")?.innerText ||
+ document.querySelector("[data-testid*='title' i]")?.innerText ||
+ document.title.replace(/\s*\|\s*Suno.*$/i, "");
+ const id = metadata.id || metadata.clip_id || metadata.clipId || ids[0] || "";
+ const imageUrl = metadata.image_url || metadata.imageUrl || metadata.image_large_url || imageUrls[0] || "";
+ const audioUrl = metadata.audio_url || metadata.audioUrl || audioUrls.find((url) => /\.wav(?:\?|$)/i.test(url)) || audioUrls[0] || "";
+ const wavUrl = metadata.wav_url || metadata.wavUrl || audioUrls.find((url) => /\.wav(?:\?|$)/i.test(url)) || (id ? `https://cdn1.suno.ai/${id}.wav` : "");
+
+ return {
+ id,
+ title: String(title || "suno-track").trim(),
+ audioUrl,
+ wavUrl,
+ imageUrl,
+ pageUrl: location.href,
+ detectedIds: ids,
+ detectedAudioUrls: audioUrls,
+ detectedImageUrls: imageUrls,
+ metadata,
+ pageTextPreview: pageText.slice(0, 3000),
+ capturedAt: new Date().toISOString(),
+ };
+ }
+
+ function downloadUrl(url, name, onerror) {
+ if (!url) {
+ onerror?.("URL 없음");
+ return;
+ }
+ GM_download({
+ url,
+ name,
+ saveAs: false,
+ onerror: (error) => onerror?.(error?.error || String(error)),
+ });
+ }
+
+ function downloadTextFile(text, name) {
+ const blob = new Blob([text], { type: "application/json;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = name;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+
+ function downloadSunoPackage(track, statusTarget) {
+ const baseName = safeFilename(track.title || track.id);
+ const messages = [];
+ const fail = (label) => (reason) => {
+ messages.push(`${label} 실패: ${reason}`);
+ setStatus(statusTarget, messages.join(" / "));
+ };
+
+ downloadTextFile(JSON.stringify(track, null, 2), `${baseName}.metadata.json`);
+ messages.push("metadata 저장");
+
+ if (track.imageUrl) {
+ downloadUrl(track.imageUrl, `${baseName}.cover.${extensionFromUrl(track.imageUrl, "jpg")}`, fail("cover"));
+ messages.push("cover 요청");
+ }
+ if (track.wavUrl) {
+ downloadUrl(track.wavUrl, `${baseName}.wav`, fail("wav"));
+ messages.push("wav 요청");
+ } else if (track.audioUrl) {
+ downloadUrl(track.audioUrl, `${baseName}.${extensionFromUrl(track.audioUrl, "audio")}`, fail("audio"));
+ messages.push("audio 요청");
+ }
+ setStatus(statusTarget, messages.join(" / "));
+ }
+
async function bootSuno() {
- ensureLauncher("Suno", () => bootSuno());
const body = createPanel("Suno Prompt Paste");
- const payload = await readPayload();
+ const slots = await readSlots();
body.innerHTML = `
+
-
+
${FIELD_DEFS.map((def) => ``).join("")}
+
+
+
+
+
+
`;
const summary = body.querySelector("[data-cgs-summary]");
- summary.textContent = payload
- ? `${payload.version || "저장된 프롬프트"} 준비됨: ${payload.title || "제목 없음"}`
- : "ChatGPT 쪽에서 먼저 Version을 저장해야 해.";
+ const slotSelect = body.querySelector("[data-cgs-slot]");
+ const updateSummary = () => {
+ const payload = slots[Number(slotSelect.value)];
+ const track = findCurrentSunoTrack();
+ const promptText = payload ? `${payload.version || "저장된 프롬프트"}: ${payload.title || "제목 없음"}` : "선택 슬롯 비어있음";
+ const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지";
+ summary.textContent = promptText + trackText;
+ };
+ updateSummary();
+ slotSelect.addEventListener("change", updateSummary);
body.querySelector("[data-cgs-fill]").addEventListener("click", async () => {
- const latest = await readPayload();
+ const latest = await readPayload(Number(slotSelect.value));
if (!latest) {
- setStatus(body, "저장된 값이 없어.");
+ setStatus(body, "선택 슬롯에 저장된 값이 없어.");
return;
}
const results = fillSuno(latest);
@@ -467,7 +642,7 @@
for (const button of body.querySelectorAll("[data-cgs-copy]")) {
button.addEventListener("click", async () => {
- const latest = await readPayload();
+ const latest = await readPayload(Number(slotSelect.value));
const key = button.getAttribute("data-cgs-copy");
const value = latest?.[key] || "";
if (!value) {
@@ -478,6 +653,21 @@
setStatus(body, `${button.textContent}만 복사했어.`);
});
}
+
+ body.querySelector("[data-cgs-refresh-track]").addEventListener("click", () => {
+ updateSummary();
+ const track = findCurrentSunoTrack();
+ setStatus(body, track.id || track.audioUrl ? `감지됨: ${track.title || track.id}` : "현재 화면에서 곡 URL을 못 찾았어.");
+ });
+
+ body.querySelector("[data-cgs-download-track]").addEventListener("click", () => {
+ const track = findCurrentSunoTrack();
+ if (!track.id && !track.audioUrl && !track.imageUrl) {
+ setStatus(body, "다운로드할 곡 정보를 못 찾았어. 곡 상세/플레이어 화면에서 다시 눌러봐.");
+ return;
+ }
+ downloadSunoPackage(track, body);
+ });
}
function startApp() {