// ==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();
})();