From df6a653f1641b712d5f2d119f30cf686b9234d0e Mon Sep 17 00:00:00 2001 From: "DESKTOP-KSVGT20\\shkim" Date: Fri, 1 May 2026 15:58:52 +0900 Subject: [PATCH] Add prompt slots and Suno download tools --- README.md | 16 +- chatgpt-suno-tampermonkey.user.js | 310 ++++++++++++++++++++++++------ 2 files changed, 263 insertions(+), 63 deletions(-) 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() {