Add prompt slots and Suno download tools
This commit is contained in:
@@ -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이 잡혔는지 확인할 수 있습니다.
|
||||
|
||||
@@ -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 = `
|
||||
<div class="cgs-muted">현재 답변에서 Version A/B/C를 찾아서 저장해.</div>
|
||||
@@ -288,29 +295,35 @@
|
||||
<div class="cgs-status"></div>
|
||||
`;
|
||||
|
||||
const render = () => {
|
||||
const render = async () => {
|
||||
const list = body.querySelector("[data-cgs-list]");
|
||||
const versions = parseVersions(getConversationText());
|
||||
list.innerHTML = "";
|
||||
|
||||
if (!versions.length) {
|
||||
list.innerHTML = `<div class="cgs-status">아직 Version 형식을 못 찾았어.</div>`;
|
||||
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 = `
|
||||
<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>
|
||||
`;
|
||||
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 = `
|
||||
<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">
|
||||
<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 class="cgs-field-list">
|
||||
${FIELD_DEFS.map((def) => `<button class="cgs-btn" type="button" data-cgs-copy="${def.key}">${escapeHtml(def.label)}</button>`).join("")}
|
||||
</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>
|
||||
`;
|
||||
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user