Files
suno-helper/chatgpt-suno-tampermonkey.user.js
T
2026-05-01 17:53:48 +09:00

1002 lines
36 KiB
JavaScript

// ==UserScript==
// @name ChatGPT to Suno Prompt Copier
// @namespace local.suno-helper
// @version 0.4.1
// @description Copy structured ChatGPT song prompt versions into Suno fields.
// @author local
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @match https://suno.com/*
// @match https://*.suno.com/*
// @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
// @connect cdn*.suno.ai
// @connect *.suno.com
// @run-at document-start
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_download
// @grant GM_setClipboard
// @grant GM_setValue
// @grant unsafeWindow
// ==/UserScript==
(function () {
"use strict";
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");
const capturedSunoResponses = [];
let chatRenderTimer = 0;
const FIELD_DEFS = [
{ key: "title", label: "Title", aliases: ["Title", "제목"] },
{ key: "style", label: "Style of Music", aliases: ["Style of Music", "Style", "장르", "음악 스타일"] },
{ key: "lyrics", label: "Lyrics", aliases: ["Lyrics", "가사"] },
{ key: "exclude", label: "Exclude", aliases: ["Exclude", "Negative Prompt", "제외", "제외 프롬프트"] },
{ key: "extend", label: "Extend Prompt", aliases: ["Extend용 추가 프롬프트", "Extend", "추가 프롬프트"] },
];
function rememberSunoResponse(url, data) {
if (!isSuno || !data) return;
const text = typeof data === "string" ? data : JSON.stringify(data);
if (!/clip|audio_url|image_url|song|playlist|metadata|cdn[0-9]*\.suno\.ai/i.test(text)) return;
capturedSunoResponses.push({
url: String(url || location.href),
data,
capturedAt: new Date().toISOString(),
});
if (capturedSunoResponses.length > 60) capturedSunoResponses.shift();
}
function tryParseJson(text) {
if (!text || typeof text !== "string") return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
function installSunoNetworkCapture() {
if (!isSuno) return;
const page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
if (page.__cgsNetworkCaptureInstalled) return;
page.__cgsNetworkCaptureInstalled = true;
const originalFetch = page.fetch;
if (typeof originalFetch === "function") {
page.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
const url = args[0]?.url || args[0] || response.url || "";
if (/suno/i.test(String(url))) {
response.clone().text().then((text) => {
rememberSunoResponse(url, tryParseJson(text) || text);
}).catch(() => {});
}
return response;
};
}
const OriginalXHR = page.XMLHttpRequest;
if (typeof OriginalXHR === "function") {
page.XMLHttpRequest = function () {
const xhr = new OriginalXHR();
let requestUrl = "";
const open = xhr.open;
xhr.open = function (method, url, ...rest) {
requestUrl = String(url || "");
return open.call(xhr, method, url, ...rest);
};
xhr.addEventListener("load", () => {
if (!/suno/i.test(requestUrl || xhr.responseURL || "")) return;
const responseText = typeof xhr.responseText === "string" ? xhr.responseText : "";
rememberSunoResponse(requestUrl || xhr.responseURL, tryParseJson(responseText) || responseText);
});
return xhr;
};
}
}
installSunoNetworkCapture();
GM_addStyle(`
#cgs-panel {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 2147483647;
width: min(360px, calc(100vw - 32px));
color: #f5f7fb;
background: rgba(16, 18, 24, 0.94);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.3);
font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overflow: hidden;
}
#cgs-panel.cgs-chatgpt {
right: 18px;
top: 72px;
bottom: auto;
border-color: rgba(47, 124, 246, 0.55);
}
#cgs-panel.cgs-error {
border-color: rgba(255, 84, 84, 0.7);
}
#cgs-panel * { box-sizing: border-box; }
#cgs-panel.cgs-minimized .cgs-body { display: none; }
.cgs-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.06);
font-weight: 700;
}
.cgs-body { padding: 10px 12px 12px; }
.cgs-row {
display: flex;
gap: 6px;
align-items: center;
margin-top: 8px;
}
.cgs-version {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #dce3ef;
}
.cgs-muted { color: #aeb7c6; font-size: 12px; }
.cgs-btn {
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.09);
color: #f5f7fb;
border-radius: 7px;
min-height: 30px;
padding: 5px 8px;
cursor: pointer;
font: inherit;
}
.cgs-btn:hover { background: rgba(255, 255, 255, 0.16); }
.cgs-primary { background: #2f7cf6; border-color: #2f7cf6; }
.cgs-primary:hover { background: #438bff; }
.cgs-wide { width: 100%; justify-content: center; }
.cgs-status {
min-height: 18px;
margin-top: 9px;
color: #b7c9ff;
font-size: 12px;
}
.cgs-field-list {
display: grid;
grid-template-columns: 1fr 1fr;
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();
const panel = document.createElement("section");
panel.id = "cgs-panel";
if (mode) panel.classList.add(mode);
panel.innerHTML = `
<div class="cgs-head">
<span>${escapeHtml(title)}</span>
<button class="cgs-btn" type="button" data-cgs-toggle>_</button>
</div>
<div class="cgs-body"></div>
`;
document.body.appendChild(panel);
panel.querySelector("[data-cgs-toggle]").addEventListener("click", () => {
panel.classList.toggle("cgs-minimized");
});
return panel.querySelector(".cgs-body");
}
function createErrorPanel(error) {
if (!document.body) return;
const body = createPanel("Suno Helper Error", "cgs-error");
body.innerHTML = `
<div class="cgs-muted">스크립트 UI를 만드는 중 오류가 났어.</div>
<div class="cgs-status">${escapeHtml(error?.message || String(error))}</div>
`;
}
function setStatus(body, text) {
const status = body.querySelector(".cgs-status");
if (status) status.textContent = text;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function normalizeBlock(value) {
return String(value ?? "")
.replace(/^\s*```(?:txt|text|markdown|md)?\s*/i, "")
.replace(/\s*```\s*$/i, "")
.replace(/\r\n/g, "\n")
.trim();
}
function getConversationText() {
const turns = [...document.querySelectorAll("main article, [data-message-author-role='assistant'], .markdown")];
const versionTurns = turns
.map((node) => node.innerText || node.textContent || "")
.filter((text) => /Version\s+[A-Z]\s*-/i.test(text));
return (versionTurns.at(-1) || document.body.innerText || "").replace(/\r\n/g, "\n");
}
function parseVersions(text) {
const normalized = text.replace(/\r\n/g, "\n");
const headerPattern = /(?:^|\n)(?:#{1,6}\s*)?Version\s+([A-Z])\s*-\s*([^\n]+)/gi;
const headers = [];
let match;
while ((match = headerPattern.exec(normalized))) {
headers.push({
start: match.index + (match[0].startsWith("\n") ? 1 : 0),
afterHeader: headerPattern.lastIndex,
letter: match[1].toUpperCase(),
name: match[2].trim(),
});
}
return headers.map((header, index) => {
const next = headers[index + 1]?.start ?? normalized.length;
const block = normalized.slice(header.afterHeader, next);
const payload = {
version: `Version ${header.letter} - ${header.name}`,
capturedAt: new Date().toISOString(),
};
for (const def of FIELD_DEFS) {
payload[def.key] = extractField(block, def.aliases);
}
return payload;
}).filter((payload) => payload.title || payload.style || payload.lyrics);
}
function extractField(block, aliases) {
const labels = [];
for (const def of FIELD_DEFS) {
for (const alias of def.aliases) {
labels.push(escapeRegExp(alias));
}
}
const target = aliases.map(escapeRegExp).join("|");
const labelPattern = new RegExp(`(?:^|\\n)\\s*\\[(?:${target})\\]\\s*\\n`, "i");
const match = labelPattern.exec(block);
if (!match) return "";
const start = match.index + match[0].length;
const rest = block.slice(start);
const nextPattern = new RegExp(`\\n\\s*\\[(?:${labels.join("|")})\\]\\s*\\n`, "i");
const next = nextPattern.exec(rest);
const raw = next ? rest.slice(0, next.index) : rest;
return normalizeBlock(raw.replace(/\n-{3,}\s*$/g, ""));
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function formatPayload(payload) {
return FIELD_DEFS
.filter((def) => payload[def.key])
.map((def) => `[${def.label}]\n\n${payload[def.key]}`)
.join("\n\n");
}
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(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);
const body = createPanel("ChatGPT -> Suno", "cgs-chatgpt");
body.innerHTML = `
<div class="cgs-muted">현재 답변에서 Version A/B/C를 찾아서 저장해.</div>
<div data-cgs-list></div>
<div class="cgs-row">
<button class="cgs-btn cgs-wide" type="button" data-cgs-rescan>다시 찾기</button>
</div>
<div class="cgs-status"></div>
`;
const render = async () => {
const list = body.querySelector("[data-cgs-list]");
const versions = parseVersions(getConversationText());
list.innerHTML = "";
if (!versions.length) {
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" 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 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} 전체 텍스트를 복사했어.`);
});
list.appendChild(row);
}
setStatus(body, `${versions.length}개 버전을 찾았어.`);
};
body.querySelector("[data-cgs-rescan]").addEventListener("click", () => render());
render();
chatRenderTimer = setInterval(() => {
if (document.body.contains(body)) render();
}, 3000);
}
function getAllControls() {
const selectors = [
"textarea",
"input:not([type]), input[type='text'], input[type='search']",
"[contenteditable='true']",
"[role='textbox']",
".ProseMirror",
];
const controls = [...document.querySelectorAll(selectors.join(","))];
return [...new Set(controls)].filter(isVisibleControl);
}
function isVisibleControl(node) {
const rect = node.getBoundingClientRect();
const style = getComputedStyle(node);
return rect.width > 20 && rect.height > 16 && style.display !== "none" && style.visibility !== "hidden";
}
function controlText(node) {
const attrs = [
node.getAttribute("aria-label"),
node.getAttribute("placeholder"),
node.getAttribute("name"),
node.getAttribute("id"),
node.getAttribute("data-testid"),
node.className,
].filter(Boolean).join(" ");
const area = closestUsefulContainer(node);
const nearby = area ? (area.innerText || area.textContent || "") : "";
return `${attrs} ${nearby}`.toLowerCase();
}
function closestUsefulContainer(node) {
let current = node;
for (let i = 0; i < 5 && current; i += 1) {
const text = current.innerText || current.textContent || "";
if (text.length > 0 && text.length < 1200) return current;
current = current.parentElement;
}
return node.parentElement;
}
function scoreControl(node, keywords) {
const haystack = controlText(node);
let score = 0;
for (const keyword of keywords) {
const lower = keyword.toLowerCase();
if (haystack.includes(lower)) score += lower.length > 6 ? 4 : 2;
}
const tag = node.tagName.toLowerCase();
if (tag === "textarea") score += 1;
if ((node.getAttribute("placeholder") || "").toLowerCase().includes(keywords[0].toLowerCase())) score += 6;
return score;
}
function findBestControl(def, used) {
const keywordsByField = {
title: ["title", "song title", "제목"],
style: ["style", "style of music", "describe style", "음악", "장르"],
lyrics: ["lyrics", "enter lyrics", "가사"],
exclude: ["exclude", "negative", "avoid", "제외"],
extend: ["extend", "continue", "추가"],
};
const keywords = keywordsByField[def.key] || def.aliases;
return getAllControls()
.filter((node) => !used.has(node))
.map((node) => ({ node, score: scoreControl(node, keywords) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)[0]?.node || null;
}
function setControlValue(node, value) {
node.focus();
const tag = node.tagName.toLowerCase();
if (tag === "textarea" || tag === "input") {
const prototype = tag === "textarea" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
if (setter) setter.call(node, value);
else node.value = value;
} else {
node.textContent = value;
node.innerText = value;
}
node.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
node.dispatchEvent(new Event("change", { bubbles: true }));
node.blur();
}
function fillSuno(payload) {
const used = new Set();
const results = [];
for (const def of FIELD_DEFS) {
const value = payload[def.key];
if (!value || def.key === "extend") continue;
const control = findBestControl(def, used);
if (!control) {
results.push(`${def.label}: 못 찾음`);
continue;
}
setControlValue(control, value);
used.add(control);
results.push(`${def.label}: 입력`);
}
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 readSunoDataSources() {
return [
...readJsonScripts(),
...capturedSunoResponses.map((item) => item.data),
...((typeof unsafeWindow !== "undefined" && unsafeWindow.__NEXT_DATA__) ? [unsafeWindow.__NEXT_DATA__] : []),
];
}
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 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` : "");
const lyrics = firstValue(object, ["prompt", "lyrics", "lyric", "text"]);
const style = firstValue(object, ["gpt_description_prompt", "style", "tags", "description"]);
const model = firstValue(object, ["model_name", "model", "major_model_version"]);
const createdAt = firstValue(object, ["created_at", "createdAt"]);
return {
id,
title,
lyrics,
style,
model,
createdAt,
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", "lyrics", "style", "model", "createdAt", "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 normalizeMatchText(value) {
return String(value || "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
function getVisibleLibraryRows() {
const rows = [];
const seen = new Set();
const mediaNodes = [...document.querySelectorAll("img, [style*='background-image']")];
for (const node of mediaNodes) {
const rect = node.getBoundingClientRect();
if (rect.width < 30 || rect.height < 30 || rect.bottom < 0 || rect.top > window.innerHeight) continue;
let current = node;
for (let depth = 0; depth < 8 && current; depth += 1) {
const text = (current.innerText || current.textContent || "").trim();
const currentRect = current.getBoundingClientRect();
if (
text.length > 20 &&
text.length < 1800 &&
currentRect.width > 250 &&
currentRect.height > 45 &&
/v\d|BPM|Korean|rock|pop|metal|랩|가사|female|male|vocal|Suno/i.test(text)
) {
const key = normalizeMatchText(text);
if (!seen.has(key)) {
seen.add(key);
rows.push({
text,
normalized: key,
});
}
break;
}
current = current.parentElement;
}
}
return rows;
}
function trackMatchesVisibleRows(track, rows) {
if (!rows.length) return true;
const title = normalizeMatchText(track.title);
const style = normalizeMatchText(track.style || track.lyrics || "");
if (!title || title.length > 160) return false;
return rows.some((row) => {
if (!row.normalized.includes(title)) return false;
if (!style) return true;
const styleNeedle = style.slice(0, Math.min(42, style.length));
return styleNeedle.length < 16 || row.normalized.includes(styleNeedle);
});
}
function scanSunoLibraryTracks() {
const sources = readSunoDataSources();
const objects = sources.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(),
});
}
const visibleRows = getVisibleLibraryRows();
return [...tracks.values()]
.filter((track) => track.id || track.audioUrl || track.imageUrl)
.filter((track) => trackMatchesVisibleRows(track, visibleRows))
.sort((a, b) => String(a.title).localeCompare(String(b.title), "ko"));
}
function visibleSunoRowsHint() {
return getVisibleLibraryRows()
.map((row) => row.text.split("\n").map((line) => line.trim()).filter(Boolean)[0])
.filter((text, index, list) => text && list.indexOf(text) === index)
.slice(0, 5);
}
function findCurrentSunoTrack() {
const pageText = document.body.innerText || "";
const htmlText = document.documentElement.innerHTML || "";
const sources = readSunoDataSources();
const objects = sources.flatMap((item) => flattenObjects(item));
const audioElements = [...document.querySelectorAll("audio, video")].map((node) => node.currentSrc || node.src);
const allText = [htmlText, ...sources.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 ||
"";
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 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에서 확인해줘.`);
}
function exportSunoManifest(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 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`);
}
async function bootSuno() {
const body = createPanel("Suno Prompt Paste");
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>
</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-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-export-library>처리용 manifest 저장</button>
</div>
<div class="cgs-row">
<button class="cgs-btn cgs-wide" type="button" data-cgs-download-library>브라우저 일괄 다운로드</button>
</div>
<div class="cgs-status"></div>
`;
const summary = body.querySelector("[data-cgs-summary]");
const slotSelect = body.querySelector("[data-cgs-slot]");
let libraryTracks = [];
const updateSummary = () => {
const payload = slots[Number(slotSelect.value)];
const track = findCurrentSunoTrack();
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}` : " / 곡 미감지";
summary.textContent = promptText + trackText + libraryText;
};
updateSummary();
slotSelect.addEventListener("change", updateSummary);
body.querySelector("[data-cgs-fill]").addEventListener("click", async () => {
const latest = await readPayload(Number(slotSelect.value));
if (!latest) {
setStatus(body, "선택 슬롯에 저장된 값이 없어.");
return;
}
const results = fillSuno(latest);
setStatus(body, results.join(" / "));
});
for (const button of body.querySelectorAll("[data-cgs-copy]")) {
button.addEventListener("click", async () => {
const latest = await readPayload(Number(slotSelect.value));
const key = button.getAttribute("data-cgs-copy");
const value = latest?.[key] || "";
if (!value) {
setStatus(body, "복사할 값이 없어.");
return;
}
GM_setClipboard(value, "text");
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);
});
body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => {
libraryTracks = scanSunoLibraryTracks();
updateSummary();
const sample = libraryTracks.slice(0, 3).map((track) => track.title || track.id).join(", ");
if (libraryTracks.length) {
const visibleCount = getVisibleLibraryRows().length;
setStatus(body, `${libraryTracks.length}곡 감지: ${sample} / 화면 행 ${visibleCount}개 기준`);
return;
}
const visibleHints = visibleSunoRowsHint();
const networkHint = capturedSunoResponses.length ? `API 응답 ${capturedSunoResponses.length}개는 잡혔는데 clip 구조를 못 찾았어.` : "아직 SUNO API 응답을 못 잡았어.";
setStatus(body, `${networkHint} 목록 새로고침이나 스크롤 후 다시 눌러봐. 화면 텍스트: ${visibleHints.join(", ")}`);
});
body.querySelector("[data-cgs-download-library]").addEventListener("click", async () => {
if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks();
updateSummary();
await downloadSunoBatch(libraryTracks, body);
});
body.querySelector("[data-cgs-export-library]").addEventListener("click", () => {
if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks();
updateSummary();
exportSunoManifest(libraryTracks, body);
});
}
function startApp() {
if (!document.body) {
setTimeout(startApp, 100);
return;
}
try {
if (isChatGPT) bootChatGPT();
if (isSuno) bootSuno();
} catch (error) {
console.error("[ChatGPT to Suno]", error);
createErrorPanel(error);
}
setInterval(() => {
if (!document.querySelector("#cgs-panel")) {
try {
if (isChatGPT) bootChatGPT();
if (isSuno) bootSuno();
} catch (error) {
console.error("[ChatGPT to Suno]", error);
createErrorPanel(error);
}
}
}, 2000);
}
startApp();
})();