// ==UserScript== // @name ChatGPT to Suno Prompt Copier // @namespace local.suno-helper // @version 0.8.7 // @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.8.7"; 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(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 = `
${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 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 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); rows.push({ text, normalized: key, }); } break; } current = current.parentElement; } } return rows; } function trackMatchesVisibleRows(track, rows) { if (!rows.length) return false; 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 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(); 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) => 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)) || ""; 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; } 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" && /^https?:\/\/[^/]*suno\./i.test(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) { 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) { 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 billingDownloadUrl = `https://studio-api-prod.suno.com/api/billing/clips/${encodeURIComponent(track.id)}/download/`; const cdnWavUrl = `https://cdn1.suno.ai/${encodeURIComponent(track.id)}.wav`; try { await callSunoJson(`${base}/convert_wav/`, { method: "POST" }); } catch (error) { 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) { console.warn("[ChatGPT to Suno] wav_file probe failed; local retry will continue", error); } try { const data = await callSunoJson(billingDownloadUrl, { method: "GET" }); const direct = extractWavUrl(data); if (direct) return direct; } catch (error) { console.warn("[ChatGPT to Suno] billing download 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) { console.warn("[ChatGPT to Suno] convert_wav failed before browser download", 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) candidates.push(direct); } catch (error) { console.warn("[ChatGPT to Suno] wav_file probe failed before browser download", error); } candidates.push(`https://studio-api-prod.suno.com/api/billing/clips/${encodeURIComponent(track.id)}/download/`); 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 missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : []; if (!missingTracks.length) { setStatus(statusTarget, `동기화 완료 상태: ${tracks.length}곡 모두 로컬에 있음`); return; } let enqueued = 0; let wavPending = 0; for (let index = 0; index < missingTracks.length; index += 1) { const track = { ...missingTracks[index] }; 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; 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}개. 로컬 프로그램이 WAV 준비를 기다리며 처리 중.`); } async function uploadTrackToCompanion(track, statusTarget, format = "mp3") { 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 missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : []; if (!missingTracks.length) { setStatus(statusTarget, `동기화 완료 상태: ${tracks.length}곡 모두 로컬에 있음`); return; } let uploaded = 0; let failed = 0; for (let index = 0; index < missingTracks.length; index += 1) { const track = { ...missingTracks[index] }; try { 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}`); } await new Promise((resolve) => setTimeout(resolve, 400)); } let healthText = ""; try { healthText = ` / ${formatCompanionStatus(await readCompanionHealth())}`; } catch {} setStatus(statusTarget, `${format.toUpperCase()} 일괄 업로드 완료: ${uploaded}곡 성공, ${failed}곡 실패. 로컬에서 메타데이터 후처리 중${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) || {}; } 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"); const slots = await readSlots(); body.innerHTML = `
${FIELD_DEFS.map((def) => ``).join("")}
`; const summary = body.querySelector("[data-cgs-summary]"); const slotSelect = body.querySelector("[data-cgs-slot]"); const libraryTrackSelect = body.querySelector("[data-cgs-library-track]"); let libraryTracks = []; const renderLibraryTrackSelect = () => { if (!libraryTracks.length) { libraryTrackSelect.innerHTML = ``; return; } libraryTrackSelect.innerHTML = libraryTracks .map((track, index) => ``) .join(""); }; 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; }; renderLibraryTrackSelect(); 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", () => { libraryTracks = scanSunoLibraryTracks(); renderLibraryTrackSelect(); 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(", ")}`); }); const processSelectedTrack = async (format) => { if (!libraryTracks.length) { libraryTracks = scanSunoLibraryTracks(); renderLibraryTrackSelect(); } updateSummary(); const index = Number(libraryTrackSelect.value || 0); const track = libraryTracks[index]; 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"); }); const processLibraryTracks = async (format) => { if (!libraryTracks.length) { libraryTracks = scanSunoLibraryTracks(); renderLibraryTrackSelect(); } updateSummary(); try { await uploadTracksToCompanion(libraryTracks, 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-copy-diagnostics]").addEventListener("click", async () => { if (!libraryTracks.length) { libraryTracks = scanSunoLibraryTracks(); renderLibraryTrackSelect(); } const selectedIndex = Number(libraryTrackSelect.value || 0); const selectedTrack = libraryTracks[selectedIndex]; 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, selectedIndex, 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(); })();