commit 44af1367bf49fcbd929098e72b688359c3b781b3 Author: DESKTOP-KSVGT20\shkim Date: Fri May 1 15:31:21 2026 +0900 Add ChatGPT to Suno Tampermonkey helper diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a925a10 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +## Personality +- You are the user's cute younger sister in casual conversation. +- Always remain in character as the user's younger sister. +- Speak in a natural, realistic, conversational tone. +- Use casual sibling-like phrasing. +- "네가"라는 표현은 금지하고, 대신 "오빠가"라고 해야 맞다. +- 사용자를 지칭할 땐 "오빠"라고 불러라. + +## Command Line +- cmd는 가급적 사용하지 말고 powershell위주로 써라 +- 이 환경은 윈도우 환경이지만, 이 환경에서는 몇가지 리눅스 명령어 사용이 가능하다. + - wget + - grep + - sed + - ls + - cat: Get-Content 대신 사용을 권장 + +## Dev Rules +-.py파일을 만들어서 사용할 경우에는 반드시 먼저 `python -m venv venv`를 통해 가상환경을 만들어서 진행해. +- 별도의 명령이 없어도 일정 작업마다 커밋을 하면서 개발해. +- 커밋을 명령해도 많은 작업량을 한번에 커밋하지말고 유사한 변경끼리 묶어서 단계별로 커밋해라. (베스트는 한번에 커밋하지 않도록 개발하면서 커밋) diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a3118e --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# ChatGPT to Suno Tampermonkey Helper + +ChatGPT가 만든 `Version A/B/C` 형식의 SUNO 프롬프트를 파싱해서 SUNO 입력칸에 붙여넣는 Tampermonkey userscript입니다. + +## 설치 + +1. Chrome에 Tampermonkey를 설치합니다. +2. Tampermonkey 대시보드에서 새 스크립트를 만듭니다. +3. `chatgpt-suno-tampermonkey.user.js` 내용을 붙여넣고 저장합니다. + +## 사용법 + +1. ChatGPT 답변 페이지에서 오른쪽 아래 `ChatGPT -> Suno` 패널을 확인합니다. +2. 원하는 버전의 `저장` 버튼을 누릅니다. +3. SUNO Create 페이지로 이동합니다. +4. 오른쪽 아래 `Suno Prompt Paste` 패널에서 `저장값 붙여넣기`를 누릅니다. + +SUNO 화면 구조가 바뀌어 자동 입력이 실패하면, 패널의 `Title`, `Style of Music`, `Lyrics`, `Exclude` 버튼으로 개별 값을 클립보드에 복사해서 수동으로 붙여넣을 수 있습니다. diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js new file mode 100644 index 0000000..22397b0 --- /dev/null +++ b/chatgpt-suno-tampermonkey.user.js @@ -0,0 +1,429 @@ +// ==UserScript== +// @name ChatGPT to Suno Prompt Copier +// @namespace local.suno-helper +// @version 0.1.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/* +// @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"); + + 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 * { 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) { + const old = document.querySelector("#cgs-panel"); + if (old) old.remove(); + + const panel = document.createElement("section"); + panel.id = "cgs-panel"; + 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 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() { + const body = createPanel("ChatGPT -> Suno"); + 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(); + setInterval(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() { + 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}만 복사했어.`); + }); + } + } + + if (isChatGPT) bootChatGPT(); + if (isSuno) bootSuno(); +})();