// ==UserScript==
// @name ChatGPT to Suno Prompt Copier
// @namespace local.suno-helper
// @version 0.9.5
// @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 *.suno.ai
// @connect cdn1.suno.ai
// @connect cdn2.suno.ai
// @connect *.suno.com
// @connect studio-api-prod.suno.com
// @connect 127.0.0.1
// @connect localhost
// @run-at document-start
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant GM_setValue
// @grant unsafeWindow
// ==/UserScript==
(function () {
"use strict";
const SCRIPT_VERSION = "0.9.6";
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|image_url|song|playlist|metadata|cdn[0-9]*\.suno\.ai|download/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(560px, calc(100vw - 32px));
max-height: min(86vh, 860px);
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-suno {
width: min(620px, calc(100vw - 32px));
}
#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;
max-height: calc(min(86vh, 860px) - 48px);
overflow: auto;
}
.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;
position: sticky;
bottom: 0;
z-index: 2;
padding: 8px 0 2px;
background: rgba(16, 18, 24, 0.96);
}
.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;
}
.cgs-track-list {
margin-top: 8px;
max-height: 300px;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
background: rgba(0, 0, 0, 0.18);
}
.cgs-track-item {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 38px;
padding: 7px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
cursor: default;
user-select: none;
}
.cgs-track-item:last-child { border-bottom: 0; }
.cgs-track-item:hover { background: rgba(255, 255, 255, 0.08); }
.cgs-track-item.cgs-selected {
background: rgba(47, 124, 246, 0.28);
outline: 1px solid rgba(91, 154, 255, 0.55);
outline-offset: -1px;
}
.cgs-track-index { color: #93a4bd; text-align: right; font-variant-numeric: tabular-nums; }
.cgs-track-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #f5f7fb;
font-weight: 650;
}
.cgs-track-meta { color: #94a3b8; font-size: 11px; }
.cgs-track-badge {
color: #b7c9ff;
font-size: 11px;
white-space: nowrap;
}
.cgs-progress-wrap {
margin-top: 8px;
display: grid;
gap: 4px;
position: sticky;
top: 0;
z-index: 3;
padding: 6px 0 8px;
background: rgba(16, 18, 24, 0.98);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.cgs-progress-label {
color: #b7c9ff;
font-size: 12px;
}
.cgs-progress {
height: 10px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.cgs-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #2f7cf6, #5db6ff);
transition: width 180ms ease;
}
`);
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 = `
${escapeHtml(title)} v${SCRIPT_VERSION}
`;
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 = `
스크립트 UI를 만드는 중 오류가 났어.
${escapeHtml(error?.message || String(error))}
`;
}
function setStatus(body, text) {
const status = body.querySelector(".cgs-status");
if (status) status.textContent = text;
}
function updateProgress(body, done, total, label = "진행") {
const progressLabel = body?.querySelector?.("[data-cgs-progress-label]");
const progressFill = body?.querySelector?.("[data-cgs-progress-fill]");
if (!progressLabel || !progressFill) return;
const safeTotal = Math.max(0, Number(total || 0));
const safeDone = Math.max(0, Math.min(Number(done || 0), safeTotal || Number(done || 0)));
const percent = safeTotal ? Math.round((safeDone / safeTotal) * 1000) / 10 : 0;
progressLabel.textContent = `${label} ${safeDone}/${safeTotal}${safeTotal ? ` (${percent}%)` : ""}`;
progressFill.style.width = `${safeTotal ? percent : 0}%`;
}
function formatSyncSummary(data, fallbackRequested = 0) {
const requested = Number(data?.requested ?? data?.received ?? fallbackRequested ?? 0);
const existing = Number(data?.existingRequested ?? data?.existing ?? 0);
const missing = Number(data?.missingRequested ?? data?.missing ?? 0);
const invalid = Number(data?.invalidRequested ?? 0);
return `로컬 확인: 요청 ${requested}곡 / 이미 있음 ${existing}곡 / 새로 필요 ${missing}곡${invalid ? ` / ID 없음 ${invalid}곡` : ""}`;
}
function showSyncSummary(body, data, fallbackRequested = 0) {
const syncSummary = body?.querySelector?.("[data-cgs-sync-summary]");
const text = formatSyncSummary(data, fallbackRequested);
const requested = Number(data?.requested ?? data?.received ?? fallbackRequested ?? 0);
const existing = Number(data?.existingRequested ?? data?.existing ?? 0);
updateProgress(body, existing, requested, "로컬 확인");
if (syncSummary) syncSummary.textContent = text;
return text;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
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 = `
현재 답변에서 Version A/B/C를 찾아서 저장해.
`;
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 = `
${escapeHtml(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} 전체 텍스트를 복사했어.`);
});
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 normalizeSunoMediaUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
if (/^https?:\/\//i.test(raw)) return raw.replace(/\\u002F/g, "/");
if (raw.startsWith("//")) return `https:${raw}`;
if (raw.startsWith("/")) return `https://suno.com${raw}`;
return raw;
}
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 collectStringValues(value, output = []) {
if (value == null) return output;
if (typeof value === "string") {
output.push(value);
return output;
}
if (Array.isArray(value)) {
for (const item of value) collectStringValues(item, output);
return output;
}
if (typeof value === "object") {
for (const item of Object.values(value)) collectStringValues(item, output);
}
return output;
}
function extractAudioUrlsFromValue(value) {
const text = collectStringValues(value).join("\n");
return unique([
...extractUrls(text, /https?:\\?\/\\?\/cdn[0-9]*\.suno\.ai\\?\/[^"' <>)]+?\.(?:mp3|m4a|oga|ogg)(?:\?[^"' <>)]+)?/gi),
...extractUrls(text, /https?:\/\/[^"' <>)]+?\.(?:mp3|m4a|oga|ogg)(?:\?[^"' <>)]+)?/gi),
].map((url) => normalizeSunoMediaUrl(url.replace(/\\\//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", "stream_audio_url", "streamAudioUrl", "play_path", "playPath", "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 audioPath = firstValue(object, ["audio_url", "audioUrl", "stream_audio_url", "streamAudioUrl", "play_url", "playUrl", "play_path", "playPath", "mp3_url", "mp3Url", "video_url", "videoUrl"]);
const audioUrls = extractAudioUrlsFromValue(object);
const audioUrl = normalizeSunoMediaUrl(audioPath) || audioUrls[0] || "";
const wavPath = firstValue(object, ["wav_url", "wavUrl", "download_url", "downloadUrl"]);
const wavUrl = normalizeSunoMediaUrl(wavPath);
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"]);
const owner = firstValue(object, ["username", "user_name", "userName", "handle", "user_handle", "userHandle", "display_name", "displayName"]);
const ownerFromUser = object.user && typeof object.user === "object"
? firstValue(object.user, ["username", "user_name", "userName", "handle", "display_name", "displayName"])
: "";
return {
id,
title,
lyrics,
style,
model,
createdAt,
owner: owner || ownerFromUser,
audioUrl,
detectedAudioUrls: audioUrls,
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.detectedAudioUrls = unique([...(target.detectedAudioUrls || []), ...(source.detectedAudioUrls || [])]);
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 isBadTrackTitle(value) {
const text = String(value || "").trim();
if (!text) return true;
if (/^v\d+(?:\.\d+)*$/i.test(text)) return true;
if (/^\d+:\d{2}$/.test(text)) return true;
if (/^(liked|public|uploads|newest|songs|playlists)$/i.test(text)) return true;
return false;
}
function extractIdsFromText(value) {
return unique([...String(value || "").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)].map((match) => match[0]));
}
function readNodeMediaText(node) {
const values = [];
let current = node;
for (let depth = 0; depth < 5 && current; depth += 1) {
if (current.currentSrc || current.src) values.push(current.currentSrc || current.src);
const style = current.getAttribute?.("style") || "";
if (style) values.push(style);
current = current.parentElement;
}
return values.join("\n");
}
function inferVisibleRowTitle(text) {
const lines = String(text || "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
if (isBadTrackTitle(line)) continue;
if (/^(play|like|liked|public|uploads|newest|filters?)$/i.test(line)) continue;
if (/^\d+$/.test(line)) continue;
if (line.length > 90) continue;
return line;
}
return "";
}
function getCurrentSunoUsername() {
const text = document.body.innerText || "";
const match = /([A-Za-z0-9_.-]{3,40})\s+\d+\s+credits/i.exec(text);
return match?.[1] || "";
}
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);
const mediaText = `${readNodeMediaText(node)}\n${current.innerHTML || ""}`;
rows.push({
text,
normalized: key,
title: inferVisibleRowTitle(text),
ids: extractIdsFromText(mediaText),
});
}
break;
}
current = current.parentElement;
}
}
return rows;
}
function trackMatchesVisibleRows(track, rows) {
return getTrackVisibleIndex(track, rows) >= 0;
}
function getTrackVisibleIndex(track, rows) {
if (!rows.length) return -1;
const title = normalizeMatchText(track.title);
const style = normalizeMatchText(track.style || track.lyrics || "");
const id = String(track.id || "");
const idIndex = rows.findIndex((row) => id && row.ids?.includes(id));
if (idIndex >= 0) return idIndex;
if (!title || title.length > 160 || isBadTrackTitle(track.title)) return -1;
return rows.findIndex((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 trackMatchesCurrentUser(track, username) {
if (!username || !track.owner) return true;
return normalizeMatchText(track.owner) === normalizeMatchText(username);
}
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: "",
imageUrl: "",
pageUrl: location.href,
metadata: {},
capturedAt: new Date().toISOString(),
});
}
const visibleRows = getVisibleLibraryRows();
for (const track of tracks.values()) {
const row = visibleRows.find((item) => track.id && item.ids?.includes(track.id));
if (row?.title && isBadTrackTitle(track.title)) track.title = row.title;
}
const currentUser = getCurrentSunoUsername();
return [...tracks.values()]
.filter((track) => track.id || track.audioUrl || track.imageUrl)
.filter((track) => trackMatchesCurrentUser(track, currentUser))
.filter((track) => trackMatchesVisibleRows(track, visibleRows))
.sort((a, b) => getTrackVisibleIndex(a, visibleRows) - getTrackVisibleIndex(b, visibleRows));
}
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)) || "";
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, onload) {
if (!url) {
onerror?.("URL 없음");
return;
}
GM_download({
url,
name,
saveAs: false,
onload: () => onload?.(),
onerror: (error) => onerror?.(error?.error || String(error)),
});
}
async function callSunoJson(url, options = {}) {
const response = await callSunoText(url, options);
const finalWavUrl = extractWavUrl(response.finalUrl || url);
if (response.ok && finalWavUrl) return finalWavUrl;
const text = response.text || "";
const data = tryParseJson(text) || text;
rememberSunoResponse(url, data);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${String(text).slice(0, 160)}`);
}
return data;
}
function hasHttpStatus(error, statuses) {
const message = String(error?.message || error || "");
return statuses.some((status) => message.startsWith(`${status} `) || message.includes(` ${status} `));
}
function shouldUseTampermonkeyFirst(url) {
return /^https?:\/\/cdn[0-9]*\.suno\.ai\//i.test(url);
}
async function callSunoText(url, options = {}) {
const page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
const headers = {
"accept": "application/json, text/plain, */*",
...(options.method && options.method !== "GET" && options.method !== "HEAD" ? { "content-type": "application/json" } : {}),
...(options.headers || {}),
};
if (typeof GM_xmlhttpRequest === "function" && shouldUseTampermonkeyFirst(url)) {
return callTampermonkeyRequest(url, { ...options, headers });
}
try {
const response = await page.fetch(url, {
credentials: "include",
...options,
headers,
});
const finalUrl = response.url || url;
if (response.ok && extractWavUrl(finalUrl)) {
return {
ok: true,
status: response.status,
statusText: response.statusText,
finalUrl,
text: "",
};
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
finalUrl,
text: await response.text(),
};
} catch (fetchError) {
if (typeof GM_xmlhttpRequest !== "function") throw fetchError;
return callTampermonkeyRequest(url, { ...options, headers });
}
}
function callTampermonkeyRequest(url, options = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || "GET",
url,
headers: options.headers || {},
data: options.body,
anonymous: false,
withCredentials: true,
timeout: 45000,
onload: (response) => {
resolve({
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.statusText || "",
finalUrl: response.finalUrl || url,
text: response.responseText || "",
});
},
onerror: (error) => reject(new Error(error?.error || "GM_xmlhttpRequest failed")),
ontimeout: () => reject(new Error("GM_xmlhttpRequest timeout")),
});
});
}
function callTampermonkeyBytes(url, options = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || "GET",
url,
headers: options.headers || {},
anonymous: false,
withCredentials: true,
timeout: 120000,
responseType: "arraybuffer",
onload: (response) => {
resolve({
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.statusText || "",
finalUrl: response.finalUrl || url,
buffer: response.response,
});
},
onerror: (error) => reject(new Error(error?.error || "GM_xmlhttpRequest bytes failed")),
ontimeout: () => reject(new Error("GM_xmlhttpRequest bytes timeout")),
});
});
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = "";
for (let index = 0; index < bytes.length; index += chunkSize) {
const chunk = bytes.subarray(index, index + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
function extractWavUrl(value) {
if (!value) return "";
if (typeof value === "string") {
const direct = value.match(/https?:\/\/[^"' <>)]+?\.wav(?:\?[^"' <>)]+)?/i)?.[0] || "";
if (direct) return normalizeSunoMediaUrl(direct);
return /\.wav(?:\?|$)/i.test(value) ? normalizeSunoMediaUrl(value) : "";
}
if (Array.isArray(value)) {
for (const item of value) {
const found = extractWavUrl(item);
if (found) return found;
}
return "";
}
if (typeof value === "object") {
const directKeys = [
"url",
"download_url",
"downloadUrl",
"wav_url",
"wavUrl",
"wav_file_url",
"wavFileUrl",
"file_url",
"fileUrl",
"signed_url",
"signedUrl",
"audio_url",
"audioUrl",
"file",
];
for (const key of directKeys) {
const found = extractWavUrl(value[key]);
if (found) return found;
}
for (const item of Object.values(value)) {
const found = extractWavUrl(item);
if (found) return found;
}
}
return "";
}
async function canReadUrl(url) {
if (!url) return false;
const page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
const methods = [
{ method: "HEAD" },
{ method: "GET", headers: { range: "bytes=0-0" } },
];
for (const options of methods) {
try {
const response = await page.fetch(url, {
credentials: "include",
cache: "no-store",
...options,
});
if (response.ok) return true;
} catch (error) {
console.warn("[ChatGPT to Suno] URL probe failed", url, error);
}
if (typeof GM_xmlhttpRequest === "function") {
try {
const response = await callTampermonkeyRequest(url, {
...options,
headers: options.headers || {},
});
if (response.ok || response.status === 206) return true;
} catch (error) {
console.warn("[ChatGPT to Suno] GM URL probe failed", url, error);
}
}
}
return false;
}
async function requestSunoWavUrl(track) {
if (!track.id) return extractWavUrl(track.wavUrl);
const base = `https://studio-api-prod.suno.com/api/gen/${encodeURIComponent(track.id)}`;
const cdnWavUrl = `https://cdn1.suno.ai/${encodeURIComponent(track.id)}.wav`;
try {
await callSunoJson(`${base}/convert_wav/`, { method: "POST" });
} catch (error) {
if (hasHttpStatus(error, [401, 403])) throw new Error("SUNO WAV 변환 권한이 없어. SUNO 페이지를 새로고침한 뒤 다시 시도해줘.");
console.warn("[ChatGPT to Suno] convert_wav failed", error);
}
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const data = await callSunoJson(`${base}/wav_file/`, { method: "GET" });
const direct = extractWavUrl(data);
if (direct) return direct;
if (await canReadUrl(cdnWavUrl)) return cdnWavUrl;
} catch (error) {
if (hasHttpStatus(error, [401, 403])) throw new Error("SUNO WAV URL 권한이 없어. SUNO 페이지를 새로고침한 뒤 다시 시도해줘.");
console.warn("[ChatGPT to Suno] wav_file failed", error);
if (await canReadUrl(cdnWavUrl)) return cdnWavUrl;
}
await new Promise((resolve) => setTimeout(resolve, 1500));
}
return extractWavUrl(track.wavUrl) || ((await canReadUrl(cdnWavUrl)) ? cdnWavUrl : "");
}
async function prepareSunoWavCandidate(track) {
if (!track.id) return extractWavUrl(track.wavUrl);
const base = `https://studio-api-prod.suno.com/api/gen/${encodeURIComponent(track.id)}`;
const cdnWavUrl = `https://cdn1.suno.ai/${encodeURIComponent(track.id)}.wav`;
try {
await callSunoJson(`${base}/convert_wav/`, { method: "POST" });
} catch (error) {
if (hasHttpStatus(error, [401, 403])) throw new Error("SUNO WAV 변환 권한이 없어. SUNO 페이지를 새로고침한 뒤 다시 시도해줘.");
console.warn("[ChatGPT to Suno] convert_wav failed; local retry will continue", error);
}
try {
const data = await callSunoJson(`${base}/wav_file/`, { method: "GET" });
const direct = extractWavUrl(data);
if (direct) return direct;
} catch (error) {
if (hasHttpStatus(error, [401, 403])) throw new Error("SUNO WAV URL 권한이 없어. SUNO 페이지를 새로고침한 뒤 다시 시도해줘.");
console.warn("[ChatGPT to Suno] wav_file probe failed; local retry will continue", error);
}
return extractWavUrl(track.wavUrl) || cdnWavUrl;
}
async function downloadSunoWavBuffer(track, statusTarget) {
if (!track.id) throw new Error("clip id 없음");
const base = `https://studio-api-prod.suno.com/api/gen/${encodeURIComponent(track.id)}`;
const candidates = [];
setStatus(statusTarget, `브라우저에서 WAV 변환 요청 중: ${track.title || track.id}`);
try {
await callSunoJson(`${base}/convert_wav/`, { method: "POST" });
} catch (error) {
if (hasHttpStatus(error, [401, 403])) throw new Error("SUNO WAV 변환 권한이 없어. SUNO 페이지를 새로고침한 뒤 다시 시도해줘.");
console.warn("[ChatGPT to Suno] convert_wav failed before browser download", error);
}
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
setStatus(statusTarget, `SUNO WAV URL 확인 중 ${attempt + 1}/20: ${track.title || track.id}`);
const data = await callSunoJson(`${base}/wav_file/`, { method: "GET" });
const direct = extractWavUrl(data);
if (direct) candidates.push(direct);
} catch (error) {
if (hasHttpStatus(error, [401, 403])) throw new Error("SUNO WAV URL 권한이 없어. SUNO 페이지를 새로고침한 뒤 다시 시도해줘.");
console.warn("[ChatGPT to Suno] wav_file probe failed before browser download", error);
}
candidates.push(`https://cdn1.suno.ai/${encodeURIComponent(track.id)}.wav`);
for (const url of unique(candidates)) {
try {
setStatus(statusTarget, `브라우저에서 WAV 받는 중: ${track.title || track.id}`);
const response = await callTampermonkeyBytes(url, { method: "GET" });
const size = response.buffer?.byteLength || 0;
if (response.ok && size > 1024 * 1024) {
return response.buffer;
}
console.warn("[ChatGPT to Suno] WAV bytes not ready", url, response.status, size);
} catch (error) {
console.warn("[ChatGPT to Suno] WAV bytes download failed", url, error);
}
}
await new Promise((resolve) => setTimeout(resolve, 1500));
}
throw new Error("브라우저도 WAV 다운로드를 못 받았어");
}
function getMp3Candidates(track) {
const candidates = [];
if (track.audioUrl && !/\.wav(?:\?|$)/i.test(track.audioUrl)) candidates.push(track.audioUrl);
for (const url of track.detectedAudioUrls || []) {
if (url && !/\.wav(?:\?|$)/i.test(url)) candidates.push(url);
}
const metadata = track.metadata || {};
for (const url of extractAudioUrlsFromValue(metadata)) {
if (url && !/\.wav(?:\?|$)/i.test(url)) candidates.push(url);
}
for (const value of [
metadata.audio_url,
metadata.audioUrl,
metadata.stream_audio_url,
metadata.streamAudioUrl,
metadata.play_url,
metadata.playUrl,
metadata.play_path,
metadata.playPath,
metadata.mp3_url,
metadata.mp3Url,
]) {
const normalized = normalizeSunoMediaUrl(value);
if (normalized && !/\.wav(?:\?|$)/i.test(normalized)) candidates.push(normalized);
}
if (track.id) {
candidates.push(`https://cdn1.suno.ai/${encodeURIComponent(track.id)}.mp3`);
candidates.push(`https://cdn2.suno.ai/${encodeURIComponent(track.id)}.mp3`);
}
return unique(candidates);
}
async function downloadSunoMp3Buffer(track, statusTarget) {
if (track.id) {
const id = encodeURIComponent(track.id);
setStatus(statusTarget, `SUNO MP3 다운로드 권한 요청 중: ${track.title || track.id}`);
for (const url of [
`https://studio-api-prod.suno.com/api/gen/${id}/increment_action_count/`,
`https://studio-api-prod.suno.com/api/billing/clips/${id}/download/`,
]) {
try {
await callSunoJson(url, { method: "POST" });
} catch (postError) {
try {
await callSunoJson(url, { method: "GET" });
} catch (getError) {
console.warn("[ChatGPT to Suno] MP3 preflight failed", url, postError, getError);
}
}
}
}
const candidates = getMp3Candidates(track);
if (!candidates.length) throw new Error("MP3 후보 URL 없음");
for (const url of candidates) {
try {
setStatus(statusTarget, `브라우저에서 MP3 받는 중: ${track.title || track.id}`);
const response = await callTampermonkeyBytes(url, { method: "GET" });
const size = response.buffer?.byteLength || 0;
if (response.ok && size > 128 * 1024) {
track.audioUrl = url;
return response.buffer;
}
console.warn("[ChatGPT to Suno] MP3 bytes not ready", url, response.status, size);
} catch (error) {
console.warn("[ChatGPT to Suno] MP3 download failed", url, error);
}
}
throw new Error("브라우저도 MP3 다운로드를 못 받았어");
}
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);
}
async 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 요청");
}
const preparedWavUrl = await requestSunoWavUrl(track);
if (preparedWavUrl) {
downloadUrl(preparedWavUrl, `${baseName}.wav`, (reason) => {
fail("wav")(reason);
});
messages.push("wav 요청");
}
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}`);
await downloadSunoPackage(track, statusTarget);
await new Promise((resolve) => setTimeout(resolve, 650));
}
setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`);
}
async function exportSunoManifest(tracks, statusTarget) {
if (!tracks.length) {
setStatus(statusTarget, "내보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘.");
return;
}
const preparedTracks = [];
for (let index = 0; index < tracks.length; index += 1) {
const track = { ...tracks[index] };
setStatus(statusTarget, `${index + 1}/${tracks.length} WAV 준비 중: ${track.title || track.id}`);
track.wavUrl = await requestSunoWavUrl(track);
if (!track.wavUrl) {
setStatus(statusTarget, `${track.title || track.id} WAV URL을 아직 못 받아서 건너뜀`);
continue;
}
preparedTracks.push(track);
}
const manifest = {
pageUrl: location.href,
capturedAt: new Date().toISOString(),
count: preparedTracks.length,
tracks: preparedTracks,
};
downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`);
setStatus(statusTarget, `${preparedTracks.length}곡 manifest 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`);
}
async function postToCompanion(tracks, statusTarget) {
if (!tracks.length) {
setStatus(statusTarget, "보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘.");
return;
}
const syncManifest = {
pageUrl: location.href,
capturedAt: new Date().toISOString(),
count: tracks.length,
tracks,
};
setStatus(statusTarget, `${tracks.length}곡 로컬 폴더와 비교 중...`);
const syncResponse = await fetch("http://127.0.0.1:17873/sync-index", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(syncManifest),
});
const syncText = await syncResponse.text();
if (!syncResponse.ok) {
throw new Error(syncText || `${syncResponse.status} ${syncResponse.statusText}`);
}
const syncData = tryParseJson(syncText) || {};
const syncTextSummary = showSyncSummary(statusTarget, syncData, tracks.length);
const missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : [];
if (!missingTracks.length) {
setStatus(statusTarget, `동기화 완료. ${syncTextSummary}`);
return;
}
let enqueued = 0;
let wavPending = 0;
const syncExisting = Number(syncData.existingRequested ?? syncData.existing ?? 0);
const syncTotal = Number(syncData.requested ?? syncData.received ?? tracks.length);
for (let index = 0; index < missingTracks.length; index += 1) {
const track = { ...missingTracks[index] };
updateProgress(statusTarget, syncExisting + enqueued, syncTotal, "WAV 큐 준비");
setStatus(statusTarget, `${index + 1}/${missingTracks.length} 없는 곡 WAV 변환 요청 중: ${track.title || track.id}`);
track.wavUrl = await prepareSunoWavCandidate(track);
if (!track.wavUrl) {
wavPending += 1;
setStatus(statusTarget, `${track.title || track.id} WAV 후보 URL을 못 만들어서 보류 (${wavPending}곡)`);
continue;
}
const enqueueManifest = {
pageUrl: location.href,
capturedAt: new Date().toISOString(),
count: 1,
tracks: [track],
};
const response = await fetch("http://127.0.0.1:17873/enqueue", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(enqueueManifest),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text || `${response.status} ${response.statusText}`);
}
enqueued += 1;
updateProgress(statusTarget, syncExisting + enqueued, syncTotal, "WAV 큐 준비");
const data = tryParseJson(text) || {};
setStatus(statusTarget, `${enqueued}/${missingTracks.length}곡 큐 추가: ${track.title || track.id} / 대기 ${data.queued ?? "?"}개`);
}
if (!enqueued) {
setStatus(statusTarget, `큐에 넣은 곡 없음. 새 곡 ${missingTracks.length}개 중 ${wavPending}개가 WAV 후보 URL 생성 실패.`);
return;
}
setStatus(statusTarget, `큐 전송 완료: 새 곡 ${enqueued}개, 보류 ${wavPending}개. ${syncTextSummary}`);
}
async function uploadTrackToCompanion(track, statusTarget, format = "mp3") {
const syncManifest = {
pageUrl: location.href,
capturedAt: new Date().toISOString(),
count: 1,
tracks: [track],
};
setStatus(statusTarget, `로컬 보관함 확인 중: ${track.title || track.id}`);
const syncResponse = await fetch("http://127.0.0.1:17873/sync-index", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(syncManifest),
});
const syncText = await syncResponse.text();
if (!syncResponse.ok) throw new Error(syncText || `${syncResponse.status} ${syncResponse.statusText}`);
const syncData = tryParseJson(syncText) || {};
const syncTextSummary = showSyncSummary(statusTarget, syncData, 1);
const missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : [];
if (!missingTracks.length) {
setStatus(statusTarget, `이미 로컬에 있음, SUNO 다운로드 생략: ${track.title || track.id}. ${syncTextSummary}`);
return {
ok: true,
skipped: true,
localExists: true,
queued: 0,
active: "",
total: 0,
done: 0,
percent: 100,
};
}
track = { ...track, ...missingTracks[0] };
const buffer = format === "wav"
? await downloadSunoWavBuffer(track, statusTarget)
: await downloadSunoMp3Buffer(track, statusTarget);
setStatus(statusTarget, `로컬 프로그램으로 ${format.toUpperCase()} 업로드 중: ${track.title || track.id}`);
const payload = {
pageUrl: location.href,
capturedAt: new Date().toISOString(),
track,
audioExt: format,
audioBase64: arrayBufferToBase64(buffer),
};
const response = await fetch("http://127.0.0.1:17873/upload-track", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text || `${response.status} ${response.statusText}`);
}
return tryParseJson(text) || {};
}
async function uploadTracksToCompanion(tracks, statusTarget, format = "mp3") {
if (!tracks.length) {
setStatus(statusTarget, "보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘.");
return;
}
const syncManifest = {
pageUrl: location.href,
capturedAt: new Date().toISOString(),
count: tracks.length,
tracks,
};
setStatus(statusTarget, `${tracks.length}곡 로컬 폴더와 비교 중...`);
const syncResponse = await fetch("http://127.0.0.1:17873/sync-index", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(syncManifest),
});
const syncText = await syncResponse.text();
if (!syncResponse.ok) throw new Error(syncText || `${syncResponse.status} ${syncResponse.statusText}`);
const syncData = tryParseJson(syncText) || {};
const syncTextSummary = showSyncSummary(statusTarget, syncData, tracks.length);
const missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : [];
if (!missingTracks.length) {
setStatus(statusTarget, `동기화 완료. ${syncTextSummary}`);
return;
}
let uploaded = 0;
let failed = 0;
const syncExisting = Number(syncData.existingRequested ?? syncData.existing ?? 0);
const syncTotal = Number(syncData.requested ?? syncData.received ?? tracks.length);
for (let index = 0; index < missingTracks.length; index += 1) {
const track = { ...missingTracks[index] };
try {
updateProgress(statusTarget, syncExisting + uploaded + failed, syncTotal, `${format.toUpperCase()} 다운로드`);
setStatus(statusTarget, `${index + 1}/${missingTracks.length} ${format.toUpperCase()} 처리 중: ${track.title || track.id}`);
await uploadTrackToCompanion(track, statusTarget, format);
uploaded += 1;
} catch (error) {
failed += 1;
console.warn("[ChatGPT to Suno] upload failed", track, error);
setStatus(statusTarget, `${track.title || track.id} 실패: ${error.message || error}`);
}
updateProgress(statusTarget, syncExisting + uploaded + failed, syncTotal, `${format.toUpperCase()} 다운로드`);
await new Promise((resolve) => setTimeout(resolve, 400));
}
let healthText = "";
try {
healthText = ` / ${formatCompanionStatus(await readCompanionHealth())}`;
} catch {}
setStatus(statusTarget, `${format.toUpperCase()} 일괄 업로드 완료: ${uploaded}곡 성공, ${failed}곡 실패. ${syncTextSummary}. 로컬에서 메타데이터 후처리 중${healthText}`);
}
function trackOptionLabel(track, index) {
const title = track?.title || track?.id || "제목 없음";
const shortId = track?.id ? String(track.id).slice(0, 8) : "";
return `${index + 1}. ${title}${shortId ? ` [${shortId}]` : ""}`;
}
async function testCompanion(statusTarget) {
try {
const response = await fetch("http://127.0.0.1:17873/health", {
method: "GET",
cache: "no-store",
});
const text = await response.text();
if (!response.ok) {
setStatus(statusTarget, `연결 실패: ${response.status} ${response.statusText}`);
return;
}
const data = tryParseJson(text) || {};
setStatus(statusTarget, `연결 OK: ${formatCompanionStatus(data)}`);
} catch (error) {
setStatus(statusTarget, `연결 실패: companion을 켜고 새로고침해줘. ${error.message || error}`);
}
}
async function readCompanionHealth() {
const response = await fetch("http://127.0.0.1:17873/health", {
method: "GET",
cache: "no-store",
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 160)}`);
}
return tryParseJson(text) || {};
}
async function stopCompanionWork(statusTarget) {
try {
const response = await fetch("http://127.0.0.1:17873/stop", {
method: "POST",
cache: "no-store",
});
const text = await response.text();
if (!response.ok) {
setStatus(statusTarget, `중단 실패: ${response.status} ${response.statusText}`);
return;
}
const data = tryParseJson(text) || {};
setStatus(statusTarget, `작업 중단: 실행 ${data.stopped || 0}개 종료, 대기 ${data.dropped || 0}개 제거`);
} catch (error) {
setStatus(statusTarget, `중단 실패: companion 연결 확인 필요. ${error.message || error}`);
}
}
function formatCompanionStatus(data) {
if (!data || typeof data !== "object") return "상태 정보 없음";
const active = Number(data.activeCount || 0);
const queued = Number(data.queued || 0);
const done = Number(data.done ?? ((data.completed || 0) + (data.failed || 0)));
const total = Number(data.total || 0);
const failed = Number(data.failed || 0);
const percent = Number.isFinite(Number(data.percent)) ? Number(data.percent) : (total ? Math.round((done / total) * 1000) / 10 : 100);
const workerText = data.maxWorkers ? `${active}/${data.maxWorkers}` : `${active}`;
const title = data.active || "";
const titleText = title ? ` / 처리중: ${title}` : "";
return `작업 ${done}/${total} (${percent}%), 실행 ${workerText}, 대기 ${queued}, 실패 ${failed}${titleText}`;
}
async function bootSuno() {
const body = createPanel("Suno Prompt Paste", "cgs-suno");
const slots = await readSlots();
body.innerHTML = `
${FIELD_DEFS.map((def) => ``).join("")}
`;
const summary = body.querySelector("[data-cgs-summary]");
const selectionSummary = body.querySelector("[data-cgs-selection-summary]");
const slotSelect = body.querySelector("[data-cgs-slot]");
const trackList = body.querySelector("[data-cgs-track-list]");
let libraryTracks = [];
const libraryTrackMap = new Map();
const selectedTrackIds = new Set();
let lastSelectedIndex = -1;
let autoScanTimer = 0;
const getSelectedTracks = () => libraryTracks.filter((track) => selectedTrackIds.has(track.id));
const mergeLibraryTracks = (tracks) => {
for (const track of tracks) {
if (!track.id) continue;
if (libraryTrackMap.has(track.id)) {
libraryTrackMap.set(track.id, mergeTrack(libraryTrackMap.get(track.id), track));
} else {
libraryTrackMap.set(track.id, track);
}
}
libraryTracks = [...libraryTrackMap.values()].filter((track) => track?.id);
};
const renderLibraryTrackList = () => {
try {
for (const id of [...selectedTrackIds]) {
if (!libraryTrackMap.has(id)) selectedTrackIds.delete(id);
}
const selectedCount = selectedTrackIds.size;
selectionSummary.textContent = libraryTracks.length
? `누적 ${libraryTracks.length}곡 / 선택 ${selectedCount}곡`
: "누적된 곡 없음. 스크롤하면서 곡 감지를 눌러줘.";
const nodes = [];
if (!libraryTracks.length) {
const empty = document.createElement("div");
empty.className = "cgs-track-item";
empty.innerHTML = `감지된 곡 없음
`;
trackList.replaceChildren(empty);
return;
}
libraryTracks.forEach((track, index) => {
const row = document.createElement("div");
row.className = `cgs-track-item ${selectedTrackIds.has(track.id) ? "cgs-selected" : ""}`;
row.dataset.cgsTrackIndex = String(index);
row.title = track.id || "";
const number = document.createElement("div");
number.className = "cgs-track-index";
number.textContent = String(index + 1);
const text = document.createElement("div");
const title = document.createElement("div");
title.className = "cgs-track-title";
title.textContent = track.title || track.id || "제목 없음";
const meta = document.createElement("div");
meta.className = "cgs-track-meta";
meta.textContent = String(track.style || track.lyrics || track.id || "").slice(0, 110);
text.append(title, meta);
const badge = document.createElement("div");
badge.className = "cgs-track-badge";
badge.textContent = [
track.audioUrl ? "MP3" : "",
track.wavUrl ? "WAV" : "",
track.imageUrl ? "IMG" : "",
].filter(Boolean).join(" ") || "ID";
row.append(number, text, badge);
nodes.push(row);
});
trackList.replaceChildren(...nodes);
} catch (error) {
console.error("[ChatGPT to Suno] track list render failed", error);
setStatus(body, `곡 목록 표시 실패: ${error.message || error}`);
}
};
const setSelectedRange = (from, to) => {
const start = Math.max(0, Math.min(from, to));
const end = Math.min(libraryTracks.length - 1, Math.max(from, to));
for (let index = start; index <= end; index += 1) {
if (libraryTracks[index]?.id) selectedTrackIds.add(libraryTracks[index].id);
}
};
const scanAndMergeVisibleTracks = () => {
const scannedTracks = scanSunoLibraryTracks();
const before = libraryTracks.length;
mergeLibraryTracks(scannedTracks);
renderLibraryTrackList();
updateSummary();
return { scannedTracks, added: libraryTracks.length - before };
};
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;
};
renderLibraryTrackList();
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-scan-library]").addEventListener("click", () => {
const { scannedTracks, added } = scanAndMergeVisibleTracks();
const sample = scannedTracks.slice(0, 3).map((track) => track.title || track.id).join(", ");
if (scannedTracks.length) {
const visibleCount = getVisibleLibraryRows().length;
setStatus(body, `이번 화면 ${scannedTracks.length}곡 감지, 누적 ${libraryTracks.length}곡 (+${added}): ${sample} / 화면 행 ${visibleCount}개 기준`);
return;
}
const visibleHints = visibleSunoRowsHint();
const networkHint = capturedSunoResponses.length ? `API 응답 ${capturedSunoResponses.length}개는 잡혔는데 clip 구조를 못 찾았어.` : "아직 SUNO API 응답을 못 잡았어.";
setStatus(body, `${networkHint} 목록 새로고침이나 스크롤 후 다시 눌러봐. 화면 텍스트: ${visibleHints.join(", ")}`);
});
const scheduleAutoScan = () => {
clearTimeout(autoScanTimer);
autoScanTimer = setTimeout(() => {
const result = scanAndMergeVisibleTracks();
if (result.added > 0) {
setStatus(body, `스크롤 감지로 ${result.added}곡 추가, 누적 ${libraryTracks.length}곡`);
}
}, 450);
};
window.addEventListener("scroll", scheduleAutoScan, { passive: true });
document.addEventListener("scroll", scheduleAutoScan, { passive: true, capture: true });
trackList.addEventListener("click", (event) => {
const item = event.target.closest("[data-cgs-track-index]");
if (!item) return;
const index = Number(item.getAttribute("data-cgs-track-index"));
const track = libraryTracks[index];
if (!track?.id) return;
if (event.shiftKey && lastSelectedIndex >= 0) {
setSelectedRange(lastSelectedIndex, index);
} else if (event.ctrlKey || event.metaKey) {
if (selectedTrackIds.has(track.id)) selectedTrackIds.delete(track.id);
else selectedTrackIds.add(track.id);
lastSelectedIndex = index;
} else {
selectedTrackIds.clear();
selectedTrackIds.add(track.id);
lastSelectedIndex = index;
}
renderLibraryTrackList();
updateSummary();
});
const processSelectedTrack = async (format) => {
if (!libraryTracks.length) {
mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackList();
}
updateSummary();
const track = getSelectedTracks()[0] || libraryTracks[0];
if (!track) {
setStatus(body, "선택할 감지 곡이 없어. 곡 감지를 먼저 눌러줘.");
return;
}
try {
const data = await uploadTrackToCompanion(track, body, format);
setStatus(body, `선택 1곡 ${format.toUpperCase()} 업로드 완료: ${track.title || track.id} / ${formatCompanionStatus(data)}`);
} catch (error) {
setStatus(body, `선택 1곡 ${format.toUpperCase()} 처리 실패: ${error.message || error}`);
}
};
body.querySelector("[data-cgs-process-one-mp3]").addEventListener("click", async () => {
await processSelectedTrack("mp3");
});
body.querySelector("[data-cgs-process-one-wav]").addEventListener("click", async () => {
await processSelectedTrack("wav");
});
body.querySelector("[data-cgs-process-selected-mp3]").addEventListener("click", async () => {
await processSelectedTracks("mp3");
});
body.querySelector("[data-cgs-process-selected-wav]").addEventListener("click", async () => {
await processSelectedTracks("wav");
});
const processLibraryTracks = async (format) => {
if (!libraryTracks.length) {
mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackList();
}
updateSummary();
try {
await uploadTracksToCompanion(libraryTracks, body, format);
} catch (error) {
setStatus(body, `${format.toUpperCase()} 일괄 처리 실패: ${error.message || error}`);
}
};
const processSelectedTracks = async (format) => {
if (!libraryTracks.length) {
mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackList();
}
const tracks = getSelectedTracks();
if (!tracks.length) {
setStatus(body, "선택된 곡이 없어. 목록에서 Ctrl/Shift로 고른 뒤 눌러줘.");
return;
}
updateSummary();
try {
await uploadTracksToCompanion(tracks, body, format);
} catch (error) {
setStatus(body, `선택항목 ${format.toUpperCase()} 처리 실패: ${error.message || error}`);
}
};
body.querySelector("[data-cgs-process-library-mp3]").addEventListener("click", async () => {
await processLibraryTracks("mp3");
});
body.querySelector("[data-cgs-process-library-wav]").addEventListener("click", async () => {
await processLibraryTracks("wav");
});
body.querySelector("[data-cgs-test-companion]").addEventListener("click", async () => {
await testCompanion(body);
});
body.querySelector("[data-cgs-stop-companion]").addEventListener("click", async () => {
await stopCompanionWork(body);
});
body.querySelector("[data-cgs-copy-diagnostics]").addEventListener("click", async () => {
if (!libraryTracks.length) {
mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackList();
}
const selectedTracks = getSelectedTracks();
const selectedTrack = selectedTracks[0] || libraryTracks[0];
let health = {};
let healthError = "";
try {
health = await readCompanionHealth();
} catch (error) {
healthError = error.message || String(error);
}
const payload = {
scriptVersion: SCRIPT_VERSION,
location: location.href,
detectedTracks: libraryTracks.length,
visibleRows: getVisibleLibraryRows().length,
selectedCount: selectedTracks.length,
selectedIds: selectedTracks.map((track) => track.id),
selectedTrack: selectedTrack ? {
id: selectedTrack.id || "",
title: selectedTrack.title || "",
audioUrl: selectedTrack.audioUrl || "",
hasWavUrl: Boolean(selectedTrack.wavUrl),
hasImageUrl: Boolean(selectedTrack.imageUrl),
hasLyrics: Boolean(selectedTrack.lyrics),
mp3Candidates: getMp3Candidates(selectedTrack).slice(0, 10),
} : null,
capturedSunoResponses: capturedSunoResponses.length,
companion: healthError ? { ok: false, error: healthError } : health,
};
GM_setClipboard(JSON.stringify(payload, null, 2), "text");
setStatus(body, `진단 복사 완료: v${SCRIPT_VERSION}, 감지 ${libraryTracks.length}곡, 로컬 ${healthError ? "실패" : "OK"}`);
});
}
function safeBootSuno() {
try {
bootSuno();
} catch (error) {
console.error("[ChatGPT to Suno]", error);
createErrorPanel(error);
}
}
function safeBootChatGPT() {
try {
const versions = parseVersions(getConversationText());
if (versions.length) bootChatGPT();
} catch (error) {
console.error("[ChatGPT to Suno]", error);
createErrorPanel(error);
}
}
function startApp() {
if (!document.body) {
setTimeout(startApp, 100);
return;
}
if (isSuno) safeBootSuno();
if (isChatGPT) safeBootChatGPT();
setInterval(() => {
if (isSuno && !document.querySelector("#cgs-panel")) {
safeBootSuno();
}
if (isChatGPT) {
const hasPanel = Boolean(document.querySelector("#cgs-panel"));
const hasVersions = parseVersions(getConversationText()).length > 0;
if (hasVersions && !hasPanel) safeBootChatGPT();
if (!hasVersions && hasPanel) document.querySelector("#cgs-panel")?.remove();
}
}, 2000);
}
startApp();
})();