Add prompt slots and Suno download tools

This commit is contained in:
DESKTOP-KSVGT20\shkim
2026-05-01 15:58:52 +09:00
parent 5a90711410
commit df6a653f16
2 changed files with 263 additions and 63 deletions
+13 -3
View File
@@ -24,9 +24,19 @@ https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermo
## 사용법 ## 사용법
1. ChatGPT 답변 페이지에서 오른쪽 아래 `ChatGPT -> Suno` 패널을 확인합니다. 1. ChatGPT 답변 페이지에서 `Version A/B/C` 형식이 감지되면 오른쪽 위에 작은 `ChatGPT -> Suno` 패널이 나타납니다.
2. 원하는 버전의 `저장` 버튼을 누릅니다. 2. 원하는 버전의 `S1` 또는 `S2` 버튼을 눌러 슬롯에 저장합니다.
3. SUNO Create 페이지로 이동합니다. 3. SUNO Create 페이지로 이동합니다.
4. 오른쪽 아래 `Suno Prompt Paste` 패널에서 `저장값 붙여넣기`를 누릅니다. 4. 오른쪽 아래 `Suno Prompt Paste` 패널에서 슬롯을 고르고 `선택 슬롯 붙여넣기`를 누릅니다.
SUNO 화면 구조가 바뀌어 자동 입력이 실패하면, 패널의 `Title`, `Style of Music`, `Lyrics`, `Exclude` 버튼으로 개별 값을 클립보드에 복사해서 수동으로 붙여넣을 수 있습니다. 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이 잡혔는지 확인할 수 있습니다.
+247 -57
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.1.3 // @version 0.2.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/*
@@ -13,6 +13,7 @@
// @run-at document-idle // @run-at document-idle
// @grant GM_addStyle // @grant GM_addStyle
// @grant GM_getValue // @grant GM_getValue
// @grant GM_download
// @grant GM_setClipboard // @grant GM_setClipboard
// @grant GM_setValue // @grant GM_setValue
// ==/UserScript== // ==/UserScript==
@@ -20,7 +21,8 @@
(function () { (function () {
"use strict"; "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 isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com");
const isSuno = location.hostname === "suno.com" || location.hostname.endsWith(".suno.com"); const isSuno = location.hostname === "suno.com" || location.hostname.endsWith(".suno.com");
let chatRenderTimer = 0; let chatRenderTimer = 0;
@@ -57,23 +59,6 @@
#cgs-panel.cgs-error { #cgs-panel.cgs-error {
border-color: rgba(255, 84, 84, 0.7); 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 * { box-sizing: border-box; }
#cgs-panel.cgs-minimized .cgs-body { display: none; } #cgs-panel.cgs-minimized .cgs-body { display: none; }
.cgs-head { .cgs-head {
@@ -127,9 +112,21 @@
gap: 6px; gap: 6px;
margin-top: 8px; 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 = "") { function createPanel(title, mode = "") {
document.querySelector("#cgs-launcher")?.remove();
const old = document.querySelector("#cgs-panel"); const old = document.querySelector("#cgs-panel");
if (old) old.remove(); if (old) old.remove();
@@ -150,18 +147,6 @@
return panel.querySelector(".cgs-body"); 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) { function createErrorPanel(error) {
if (!document.body) return; if (!document.body) return;
const body = createPanel("Suno Helper Error", "cgs-error"); const body = createPanel("Suno Helper Error", "cgs-error");
@@ -260,24 +245,46 @@
.join("\n\n"); .join("\n\n");
} }
async function savePayload(payload) { function emptySlots() {
await GM_setValue(STORAGE_KEY, JSON.stringify(payload)); 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"); GM_setClipboard(formatPayload(payload), "text");
} }
async function readPayload() { async function readPayload(slotIndex = 0) {
const raw = await GM_getValue(STORAGE_KEY, ""); const slots = await readSlots();
if (!raw) return null; return slots[slotIndex] || null;
try {
return JSON.parse(raw);
} catch {
return null;
} }
function slotLabel(payload, index) {
if (!payload) return `Slot ${index + 1} - 비어있음`;
return `Slot ${index + 1} - ${payload.title || payload.version || "저장됨"}`;
} }
function bootChatGPT() { function bootChatGPT() {
clearInterval(chatRenderTimer); clearInterval(chatRenderTimer);
ensureLauncher("Suno", () => bootChatGPT());
const body = createPanel("ChatGPT -> Suno", "cgs-chatgpt"); const body = createPanel("ChatGPT -> Suno", "cgs-chatgpt");
body.innerHTML = ` body.innerHTML = `
<div class="cgs-muted">현재 답변에서 Version A/B/C를 찾아서 저장해.</div> <div class="cgs-muted">현재 답변에서 Version A/B/C를 찾아서 저장해.</div>
@@ -288,29 +295,35 @@
<div class="cgs-status"></div> <div class="cgs-status"></div>
`; `;
const render = () => { const render = async () => {
const list = body.querySelector("[data-cgs-list]"); const list = body.querySelector("[data-cgs-list]");
const versions = parseVersions(getConversationText()); const versions = parseVersions(getConversationText());
list.innerHTML = ""; list.innerHTML = "";
if (!versions.length) { if (!versions.length) {
list.innerHTML = `<div class="cgs-status">아직 Version 형식을 못 찾았어.</div>`; document.querySelector("#cgs-panel")?.remove();
return; return;
} }
const slots = await readSlots();
for (const payload of versions) { for (const payload of versions) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "cgs-row"; row.className = "cgs-row";
row.innerHTML = ` row.innerHTML = `
<div class="cgs-version" title="${escapeHtml(payload.version)}">${escapeHtml(payload.version)}</div> <div class="cgs-version" title="${escapeHtml(payload.version)}">${escapeHtml(payload.version)}</div>
<button class="cgs-btn cgs-primary" type="button">저장</button> <button class="cgs-btn cgs-primary" type="button" data-slot="0">S1</button>
<button class="cgs-btn cgs-primary" type="button" data-slot="1">S2</button>
<button class="cgs-btn" type="button">복사</button> <button class="cgs-btn" type="button">복사</button>
`; `;
const [saveButton, copyButton] = row.querySelectorAll("button"); 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 () => { saveButton.addEventListener("click", async () => {
await savePayload(payload); const slotIndex = Number(saveButton.dataset.slot);
setStatus(body, `${payload.version} 저장하고 클립보드에도 복사했어.`); await savePayload(payload, slotIndex);
setStatus(body, `${payload.version} -> Slot ${slotIndex + 1} 저장하고 복사했어.`);
}); });
}
copyButton.addEventListener("click", () => { copyButton.addEventListener("click", () => {
GM_setClipboard(formatPayload(payload), "text"); GM_setClipboard(formatPayload(payload), "text");
setStatus(body, `${payload.version} 전체 텍스트를 복사했어.`); setStatus(body, `${payload.version} 전체 텍스트를 복사했어.`);
@@ -320,7 +333,7 @@
setStatus(body, `${versions.length}개 버전을 찾았어.`); setStatus(body, `${versions.length}개 버전을 찾았어.`);
}; };
body.querySelector("[data-cgs-rescan]").addEventListener("click", render); body.querySelector("[data-cgs-rescan]").addEventListener("click", () => render());
render(); render();
chatRenderTimer = setInterval(() => { chatRenderTimer = setInterval(() => {
if (document.body.contains(body)) render(); if (document.body.contains(body)) render();
@@ -435,30 +448,192 @@
return results; 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() { async function bootSuno() {
ensureLauncher("Suno", () => bootSuno());
const body = createPanel("Suno Prompt Paste"); const body = createPanel("Suno Prompt Paste");
const payload = await readPayload(); const slots = await readSlots();
body.innerHTML = ` body.innerHTML = `
<div class="cgs-muted" data-cgs-summary></div> <div class="cgs-muted" data-cgs-summary></div>
<select class="cgs-select" data-cgs-slot>
${slots.map((payload, index) => `<option value="${index}">${escapeHtml(slotLabel(payload, index))}</option>`).join("")}
</select>
<div class="cgs-row"> <div class="cgs-row">
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-fill>저장값 붙여넣기</button> <button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-fill>선택 슬롯 붙여넣기</button>
</div> </div>
<div class="cgs-field-list"> <div class="cgs-field-list">
${FIELD_DEFS.map((def) => `<button class="cgs-btn" type="button" data-cgs-copy="${def.key}">${escapeHtml(def.label)}</button>`).join("")} ${FIELD_DEFS.map((def) => `<button class="cgs-btn" type="button" data-cgs-copy="${def.key}">${escapeHtml(def.label)}</button>`).join("")}
</div> </div>
<div class="cgs-row">
<button class="cgs-btn cgs-wide" type="button" data-cgs-refresh-track>곡 감지</button>
</div>
<div class="cgs-row">
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-download-track>WAV + 커버 + 메타 다운로드</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]");
summary.textContent = payload const slotSelect = body.querySelector("[data-cgs-slot]");
? `${payload.version || "저장된 프롬프트"} 준비됨: ${payload.title || "제목 없음"}` const updateSummary = () => {
: "ChatGPT 쪽에서 먼저 Version을 저장해야 해."; 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 () => { body.querySelector("[data-cgs-fill]").addEventListener("click", async () => {
const latest = await readPayload(); const latest = await readPayload(Number(slotSelect.value));
if (!latest) { if (!latest) {
setStatus(body, "저장된 값이 없어."); setStatus(body, "선택 슬롯에 저장된 값이 없어.");
return; return;
} }
const results = fillSuno(latest); const results = fillSuno(latest);
@@ -467,7 +642,7 @@
for (const button of body.querySelectorAll("[data-cgs-copy]")) { for (const button of body.querySelectorAll("[data-cgs-copy]")) {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
const latest = await readPayload(); const latest = await readPayload(Number(slotSelect.value));
const key = button.getAttribute("data-cgs-copy"); const key = button.getAttribute("data-cgs-copy");
const value = latest?.[key] || ""; const value = latest?.[key] || "";
if (!value) { if (!value) {
@@ -478,6 +653,21 @@
setStatus(body, `${button.textContent}만 복사했어.`); 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() { function startApp() {