// ==UserScript== // @name ChatGPT to Suno Prompt Copier // @namespace local.suno-helper // @version 0.3.0 // @description Copy structured ChatGPT song prompt versions into Suno fields. // @author local // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @match https://suno.com/* // @match https://*.suno.com/* // @updateURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js // @downloadURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js // @connect cdn*.suno.ai // @connect *.suno.com // @run-at document-idle // @grant GM_addStyle // @grant GM_getValue // @grant GM_download // @grant GM_setClipboard // @grant GM_setValue // ==/UserScript== (function () { "use strict"; const STORAGE_KEY = "chatgptSunoPromptSlots"; const SLOT_COUNT = 2; const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com"); const isSuno = location.hostname === "suno.com" || location.hostname.endsWith(".suno.com"); 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", "추가 프롬프트"] }, ]; 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)}
`; 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 unique(values) { return [...new Set(values.filter(Boolean))]; } function extractUrls(text, pattern) { return unique([...String(text || "").matchAll(pattern)].map((match) => match[0].replace(/\\u002F/g, "/"))); } function readJsonScripts() { const results = []; for (const script of document.querySelectorAll("script")) { const text = script.textContent || ""; if (!text.includes("suno") && !text.includes("audio") && !text.includes("image")) continue; try { results.push(JSON.parse(text)); } catch { results.push(text); } } return results; } function flattenObjects(value, output = []) { if (!value || typeof value !== "object") return output; if (Array.isArray(value)) { for (const item of value) flattenObjects(item, output); return output; } output.push(value); for (const item of Object.values(value)) flattenObjects(item, output); return output; } function firstValue(object, keys) { for (const key of keys) { if (object?.[key]) return object[key]; } return ""; } function isLikelyTrackObject(object) { if (!object || typeof object !== "object" || Array.isArray(object)) return false; const id = firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]); const hasTrackText = firstValue(object, ["title", "name", "prompt", "gpt_description_prompt", "metadata"]); const hasMedia = firstValue(object, ["audio_url", "audioUrl", "image_url", "imageUrl", "image_large_url", "wav_url", "wavUrl"]); return Boolean(id && (hasTrackText || hasMedia)); } function normalizeTrackObject(object) { const id = String(firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]) || "").trim(); const title = String(firstValue(object, ["title", "name"]) || id || "suno-track").trim(); const imageUrl = firstValue(object, ["image_url", "imageUrl", "image_large_url", "imageLargeUrl", "cover_url", "coverUrl"]); const audioUrl = firstValue(object, ["audio_url", "audioUrl", "play_url", "playUrl", "mp3_url", "mp3Url"]); const wavUrl = firstValue(object, ["wav_url", "wavUrl"]) || (id ? `https://cdn1.suno.ai/${id}.wav` : ""); return { id, title, audioUrl, wavUrl, imageUrl, pageUrl: location.href, metadata: object, capturedAt: new Date().toISOString(), }; } function mergeTrack(target, source) { if (!target) return source; for (const key of ["title", "audioUrl", "wavUrl", "imageUrl", "pageUrl"]) { if (!target[key] && source[key]) target[key] = source[key]; } target.metadata = target.metadata && Object.keys(target.metadata).length ? target.metadata : source.metadata; return target; } function scanSunoLibraryTracks() { const scripts = readJsonScripts(); const objects = scripts.flatMap((item) => flattenObjects(item)); const tracks = new Map(); for (const object of objects) { if (!isLikelyTrackObject(object)) continue; const track = normalizeTrackObject(object); if (!track.id) continue; tracks.set(track.id, mergeTrack(tracks.get(track.id), track)); } const htmlText = document.documentElement.innerHTML || ""; const idMatches = [...htmlText.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi)]; const fallbackIds = unique(idMatches .filter((match) => /clip|audio|image|title|cdn[0-9]*\.suno\.ai/i.test(htmlText.slice(Math.max(0, match.index - 500), match.index + 500))) .map((match) => match[0])); for (const id of fallbackIds) { if (tracks.has(id)) continue; tracks.set(id, { id, title: id, audioUrl: "", wavUrl: `https://cdn1.suno.ai/${id}.wav`, imageUrl: "", pageUrl: location.href, metadata: {}, capturedAt: new Date().toISOString(), }); } return [...tracks.values()] .filter((track) => track.id || track.audioUrl || track.imageUrl) .sort((a, b) => String(a.title).localeCompare(String(b.title), "ko")); } function findCurrentSunoTrack() { const pageText = document.body.innerText || ""; const htmlText = document.documentElement.innerHTML || ""; const scripts = readJsonScripts(); const objects = scripts.flatMap((item) => flattenObjects(item)); const audioElements = [...document.querySelectorAll("audio, video")].map((node) => node.currentSrc || node.src); const allText = [htmlText, ...scripts.map((item) => typeof item === "string" ? item : JSON.stringify(item))].join("\n"); const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; const ids = unique([...allText.matchAll(uuidPattern)].map((match) => match[0])); const cdnAudioUrls = extractUrls(allText, /https:\/\/cdn[0-9]*\.suno\.ai\/[^"' <>)]+?\.(?:mp3|wav|m4a|oga|ogg)(?:\?[^"' <>)]+)?/gi); const cdnImageUrls = extractUrls(allText, /https:\/\/cdn[0-9]*\.suno\.ai\/[^"' <>)]+?\.(?:png|jpg|jpeg|webp)(?:\?[^"' <>)]+)?/gi); const audioUrls = unique([...audioElements, ...cdnAudioUrls]); const imageUrls = unique([ ...cdnImageUrls, ...[...document.images].map((img) => img.currentSrc || img.src).filter((url) => /suno|cdn/i.test(url)), ]); const metadataObjects = objects.filter((item) => { const text = JSON.stringify(item); return /audio_url|image_url|clip|prompt|metadata|title|gpt_description_prompt/i.test(text) && (ids.some((id) => text.includes(id)) || /cdn[0-9]*\.suno\.ai/i.test(text)); }); const metadata = metadataObjects[0] || {}; const title = metadata.title || metadata.name || document.querySelector("h1")?.innerText || document.querySelector("[data-testid*='title' i]")?.innerText || document.title.replace(/\s*\|\s*Suno.*$/i, ""); const id = metadata.id || metadata.clip_id || metadata.clipId || ids[0] || ""; const imageUrl = metadata.image_url || metadata.imageUrl || metadata.image_large_url || imageUrls[0] || ""; const audioUrl = metadata.audio_url || metadata.audioUrl || audioUrls.find((url) => /\.wav(?:\?|$)/i.test(url)) || audioUrls[0] || ""; const wavUrl = metadata.wav_url || metadata.wavUrl || audioUrls.find((url) => /\.wav(?:\?|$)/i.test(url)) || (id ? `https://cdn1.suno.ai/${id}.wav` : ""); return { id, title: String(title || "suno-track").trim(), audioUrl, wavUrl, imageUrl, pageUrl: location.href, detectedIds: ids, detectedAudioUrls: audioUrls, detectedImageUrls: imageUrls, metadata, pageTextPreview: pageText.slice(0, 3000), capturedAt: new Date().toISOString(), }; } function downloadUrl(url, name, onerror) { if (!url) { onerror?.("URL 없음"); return; } GM_download({ url, name, saveAs: false, onerror: (error) => onerror?.(error?.error || String(error)), }); } function downloadTextFile(text, name) { const blob = new Blob([text], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = name; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function downloadSunoPackage(track, statusTarget) { const baseName = safeFilename(track.title || track.id); const messages = []; const fail = (label) => (reason) => { messages.push(`${label} 실패: ${reason}`); setStatus(statusTarget, messages.join(" / ")); }; downloadTextFile(JSON.stringify(track, null, 2), `${baseName}.metadata.json`); messages.push("metadata 저장"); if (track.imageUrl) { downloadUrl(track.imageUrl, `${baseName}.cover.${extensionFromUrl(track.imageUrl, "jpg")}`, fail("cover")); messages.push("cover 요청"); } if (track.wavUrl) { downloadUrl(track.wavUrl, `${baseName}.wav`, fail("wav")); messages.push("wav 요청"); } else if (track.audioUrl) { downloadUrl(track.audioUrl, `${baseName}.${extensionFromUrl(track.audioUrl, "audio")}`, fail("audio")); messages.push("audio 요청"); } setStatus(statusTarget, messages.join(" / ")); } async function downloadSunoBatch(tracks, statusTarget) { if (!tracks.length) { setStatus(statusTarget, "다운로드할 곡을 못 찾았어. 라이브러리에서 스크롤을 좀 내려서 곡을 로드한 뒤 다시 눌러봐."); return; } const manifest = { pageUrl: location.href, capturedAt: new Date().toISOString(), count: tracks.length, tracks, }; downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`); setStatus(statusTarget, `${tracks.length}곡 감지. manifest 저장 후 순차 다운로드 시작.`); for (let index = 0; index < tracks.length; index += 1) { const track = tracks[index]; setStatus(statusTarget, `${index + 1}/${tracks.length} 요청 중: ${track.title || track.id}`); downloadSunoPackage(track, statusTarget); await new Promise((resolve) => setTimeout(resolve, 650)); } setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`); } 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]"); let libraryTracks = []; const updateSummary = () => { const payload = slots[Number(slotSelect.value)]; const track = findCurrentSunoTrack(); const promptText = payload ? `${payload.version || "저장된 프롬프트"}: ${payload.title || "제목 없음"}` : "선택 슬롯 비어있음"; const libraryText = libraryTracks.length ? ` / 라이브러리 ${libraryTracks.length}곡` : ""; const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지"; summary.textContent = promptText + trackText + libraryText; }; updateSummary(); slotSelect.addEventListener("change", updateSummary); body.querySelector("[data-cgs-fill]").addEventListener("click", async () => { const latest = await readPayload(Number(slotSelect.value)); if (!latest) { setStatus(body, "선택 슬롯에 저장된 값이 없어."); return; } const results = fillSuno(latest); setStatus(body, results.join(" / ")); }); for (const button of body.querySelectorAll("[data-cgs-copy]")) { button.addEventListener("click", async () => { const latest = await readPayload(Number(slotSelect.value)); const key = button.getAttribute("data-cgs-copy"); const value = latest?.[key] || ""; if (!value) { setStatus(body, "복사할 값이 없어."); return; } GM_setClipboard(value, "text"); setStatus(body, `${button.textContent}만 복사했어.`); }); } body.querySelector("[data-cgs-refresh-track]").addEventListener("click", () => { updateSummary(); const track = findCurrentSunoTrack(); setStatus(body, track.id || track.audioUrl ? `감지됨: ${track.title || track.id}` : "현재 화면에서 곡 URL을 못 찾았어."); }); body.querySelector("[data-cgs-download-track]").addEventListener("click", () => { const track = findCurrentSunoTrack(); if (!track.id && !track.audioUrl && !track.imageUrl) { setStatus(body, "다운로드할 곡 정보를 못 찾았어. 곡 상세/플레이어 화면에서 다시 눌러봐."); return; } downloadSunoPackage(track, body); }); body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => { libraryTracks = scanSunoLibraryTracks(); updateSummary(); const sample = libraryTracks.slice(0, 3).map((track) => track.title || track.id).join(", "); setStatus(body, libraryTracks.length ? `${libraryTracks.length}곡 감지: ${sample}` : "라이브러리 곡을 못 찾았어. 곡 목록을 스크롤해서 로드한 뒤 다시 눌러봐."); }); body.querySelector("[data-cgs-download-library]").addEventListener("click", async () => { if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks(); updateSummary(); await downloadSunoBatch(libraryTracks, body); }); } function startApp() { if (!document.body) { setTimeout(startApp, 100); return; } try { if (isChatGPT) bootChatGPT(); if (isSuno) bootSuno(); } catch (error) { console.error("[ChatGPT to Suno]", error); createErrorPanel(error); } setInterval(() => { if (!document.querySelector("#cgs-panel")) { try { if (isChatGPT) bootChatGPT(); if (isSuno) bootSuno(); } catch (error) { console.error("[ChatGPT to Suno]", error); createErrorPanel(error); } } }, 2000); } startApp(); })();