430 lines
14 KiB
JavaScript
430 lines
14 KiB
JavaScript
// ==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 = `
|
|
<div class="cgs-head">
|
|
<span>${escapeHtml(title)}</span>
|
|
<button class="cgs-btn" type="button" data-cgs-toggle>_</button>
|
|
</div>
|
|
<div class="cgs-body"></div>
|
|
`;
|
|
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 = `
|
|
<div class="cgs-muted">현재 답변에서 Version A/B/C를 찾아서 저장해.</div>
|
|
<div data-cgs-list></div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-rescan>다시 찾기</button>
|
|
</div>
|
|
<div class="cgs-status"></div>
|
|
`;
|
|
|
|
const render = () => {
|
|
const list = body.querySelector("[data-cgs-list]");
|
|
const versions = parseVersions(getConversationText());
|
|
list.innerHTML = "";
|
|
|
|
if (!versions.length) {
|
|
list.innerHTML = `<div class="cgs-status">아직 Version 형식을 못 찾았어.</div>`;
|
|
return;
|
|
}
|
|
|
|
for (const payload of versions) {
|
|
const row = document.createElement("div");
|
|
row.className = "cgs-row";
|
|
row.innerHTML = `
|
|
<div class="cgs-version" title="${escapeHtml(payload.version)}">${escapeHtml(payload.version)}</div>
|
|
<button class="cgs-btn cgs-primary" type="button">저장</button>
|
|
<button class="cgs-btn" type="button">복사</button>
|
|
`;
|
|
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 = `
|
|
<div class="cgs-muted" data-cgs-summary></div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-fill>저장값 붙여넣기</button>
|
|
</div>
|
|
<div class="cgs-field-list">
|
|
${FIELD_DEFS.map((def) => `<button class="cgs-btn" type="button" data-cgs-copy="${def.key}">${escapeHtml(def.label)}</button>`).join("")}
|
|
</div>
|
|
<div class="cgs-status"></div>
|
|
`;
|
|
|
|
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();
|
|
})();
|