// ==UserScript== // @name ChatGPT to Suno Prompt Copier // @namespace local.suno-helper // @version 0.1.3 // @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 // @run-at document-idle // @grant GM_addStyle // @grant GM_getValue // @grant GM_setClipboard // @grant GM_setValue // ==/UserScript== (function () { "use strict"; const STORAGE_KEY = "chatgptSunoPromptPayload"; 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-launcher { position: fixed; right: 18px; top: 18px; z-index: 2147483647; min-width: 76px; min-height: 34px; padding: 7px 10px; color: #ffffff; background: #ef4444; border: 2px solid #ffffff; border-radius: 999px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); cursor: pointer; font: 700 13px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } #cgs-launcher:hover { background: #dc2626; } #cgs-panel * { box-sizing: border-box; } #cgs-panel.cgs-minimized .cgs-body { display: none; } .cgs-head { 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; } `); function createPanel(title, mode = "") { 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 ensureLauncher(label, onClick) { let launcher = document.querySelector("#cgs-launcher"); if (!launcher) { launcher = document.createElement("button"); launcher.id = "cgs-launcher"; launcher.type = "button"; document.body.appendChild(launcher); } launcher.textContent = label; launcher.onclick = onClick; } function createErrorPanel(error) { if (!document.body) return; const body = createPanel("Suno Helper Error", "cgs-error"); 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"); } async function savePayload(payload) { await GM_setValue(STORAGE_KEY, JSON.stringify(payload)); GM_setClipboard(formatPayload(payload), "text"); } async function readPayload() { const raw = await GM_getValue(STORAGE_KEY, ""); if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } function bootChatGPT() { clearInterval(chatRenderTimer); ensureLauncher("Suno", () => bootChatGPT()); const body = createPanel("ChatGPT -> Suno", "cgs-chatgpt"); body.innerHTML = `
현재 답변에서 Version A/B/C를 찾아서 저장해.
`; const render = () => { const list = body.querySelector("[data-cgs-list]"); const versions = parseVersions(getConversationText()); list.innerHTML = ""; if (!versions.length) { list.innerHTML = `
아직 Version 형식을 못 찾았어.
`; return; } for (const payload of versions) { const row = document.createElement("div"); row.className = "cgs-row"; row.innerHTML = `
${escapeHtml(payload.version)}
`; const [saveButton, copyButton] = row.querySelectorAll("button"); saveButton.addEventListener("click", async () => { await savePayload(payload); setStatus(body, `${payload.version} 저장하고 클립보드에도 복사했어.`); }); 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; } async function bootSuno() { ensureLauncher("Suno", () => bootSuno()); const body = createPanel("Suno Prompt Paste"); const payload = await readPayload(); body.innerHTML = `
${FIELD_DEFS.map((def) => ``).join("")}
`; const summary = body.querySelector("[data-cgs-summary]"); summary.textContent = payload ? `${payload.version || "저장된 프롬프트"} 준비됨: ${payload.title || "제목 없음"}` : "ChatGPT 쪽에서 먼저 Version을 저장해야 해."; body.querySelector("[data-cgs-fill]").addEventListener("click", async () => { const latest = await readPayload(); 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(); const key = button.getAttribute("data-cgs-copy"); const value = latest?.[key] || ""; if (!value) { setStatus(body, "복사할 값이 없어."); return; } GM_setClipboard(value, "text"); setStatus(body, `${button.textContent}만 복사했어.`); }); } } 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(); })();