Add ChatGPT to Suno Tampermonkey helper
This commit is contained in:
@@ -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`를 통해 가상환경을 만들어서 진행해.
|
||||
- 별도의 명령이 없어도 일정 작업마다 커밋을 하면서 개발해.
|
||||
- 커밋을 명령해도 많은 작업량을 한번에 커밋하지말고 유사한 변경끼리 묶어서 단계별로 커밋해라. (베스트는 한번에 커밋하지 않도록 개발하면서 커밋)
|
||||
@@ -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` 버튼으로 개별 값을 클립보드에 복사해서 수동으로 붙여넣을 수 있습니다.
|
||||
@@ -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 = `
|
||||
<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();
|
||||
})();
|
||||
Reference in New Issue
Block a user