1002 lines
36 KiB
JavaScript
1002 lines
36 KiB
JavaScript
// ==UserScript==
|
|
// @name ChatGPT to Suno Prompt Copier
|
|
// @namespace local.suno-helper
|
|
// @version 0.4.1
|
|
// @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
|
|
// @connect cdn*.suno.ai
|
|
// @connect *.suno.com
|
|
// @run-at document-start
|
|
// @grant GM_addStyle
|
|
// @grant GM_getValue
|
|
// @grant GM_download
|
|
// @grant GM_setClipboard
|
|
// @grant GM_setValue
|
|
// @grant unsafeWindow
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
const STORAGE_KEY = "chatgptSunoPromptSlots";
|
|
const SLOT_COUNT = 2;
|
|
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 capturedSunoResponses = [];
|
|
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", "추가 프롬프트"] },
|
|
];
|
|
|
|
function rememberSunoResponse(url, data) {
|
|
if (!isSuno || !data) return;
|
|
const text = typeof data === "string" ? data : JSON.stringify(data);
|
|
if (!/clip|audio_url|image_url|song|playlist|metadata|cdn[0-9]*\.suno\.ai/i.test(text)) return;
|
|
capturedSunoResponses.push({
|
|
url: String(url || location.href),
|
|
data,
|
|
capturedAt: new Date().toISOString(),
|
|
});
|
|
if (capturedSunoResponses.length > 60) capturedSunoResponses.shift();
|
|
}
|
|
|
|
function tryParseJson(text) {
|
|
if (!text || typeof text !== "string") return null;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function installSunoNetworkCapture() {
|
|
if (!isSuno) return;
|
|
const page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
|
|
if (page.__cgsNetworkCaptureInstalled) return;
|
|
page.__cgsNetworkCaptureInstalled = true;
|
|
|
|
const originalFetch = page.fetch;
|
|
if (typeof originalFetch === "function") {
|
|
page.fetch = async function (...args) {
|
|
const response = await originalFetch.apply(this, args);
|
|
const url = args[0]?.url || args[0] || response.url || "";
|
|
if (/suno/i.test(String(url))) {
|
|
response.clone().text().then((text) => {
|
|
rememberSunoResponse(url, tryParseJson(text) || text);
|
|
}).catch(() => {});
|
|
}
|
|
return response;
|
|
};
|
|
}
|
|
|
|
const OriginalXHR = page.XMLHttpRequest;
|
|
if (typeof OriginalXHR === "function") {
|
|
page.XMLHttpRequest = function () {
|
|
const xhr = new OriginalXHR();
|
|
let requestUrl = "";
|
|
const open = xhr.open;
|
|
xhr.open = function (method, url, ...rest) {
|
|
requestUrl = String(url || "");
|
|
return open.call(xhr, method, url, ...rest);
|
|
};
|
|
xhr.addEventListener("load", () => {
|
|
if (!/suno/i.test(requestUrl || xhr.responseURL || "")) return;
|
|
const responseText = typeof xhr.responseText === "string" ? xhr.responseText : "";
|
|
rememberSunoResponse(requestUrl || xhr.responseURL, tryParseJson(responseText) || responseText);
|
|
});
|
|
return xhr;
|
|
};
|
|
}
|
|
}
|
|
|
|
installSunoNetworkCapture();
|
|
|
|
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-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;
|
|
}
|
|
.cgs-select {
|
|
width: 100%;
|
|
min-height: 30px;
|
|
margin-top: 8px;
|
|
color: #f5f7fb;
|
|
background: rgba(255, 255, 255, 0.09);
|
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
border-radius: 7px;
|
|
padding: 4px 7px;
|
|
font: inherit;
|
|
}
|
|
`);
|
|
|
|
function createPanel(title, mode = "") {
|
|
document.querySelector("#cgs-launcher")?.remove();
|
|
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 = `
|
|
<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 createErrorPanel(error) {
|
|
if (!document.body) return;
|
|
const body = createPanel("Suno Helper Error", "cgs-error");
|
|
body.innerHTML = `
|
|
<div class="cgs-muted">스크립트 UI를 만드는 중 오류가 났어.</div>
|
|
<div class="cgs-status">${escapeHtml(error?.message || String(error))}</div>
|
|
`;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
function emptySlots() {
|
|
return Array.from({ length: SLOT_COUNT }, () => null);
|
|
}
|
|
|
|
async function readSlots() {
|
|
const raw = await GM_getValue(STORAGE_KEY, "");
|
|
if (!raw) return emptySlots();
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) return emptySlots().map((_, index) => parsed[index] || null);
|
|
if (parsed && typeof parsed === "object") return [parsed, ...emptySlots()].slice(0, SLOT_COUNT);
|
|
} catch {
|
|
return emptySlots();
|
|
}
|
|
return emptySlots();
|
|
}
|
|
|
|
async function saveSlots(slots) {
|
|
await GM_setValue(STORAGE_KEY, JSON.stringify(emptySlots().map((_, index) => slots[index] || null)));
|
|
}
|
|
|
|
async function savePayload(payload, slotIndex) {
|
|
const slots = await readSlots();
|
|
slots[slotIndex] = { ...payload, savedAt: new Date().toISOString() };
|
|
await saveSlots(slots);
|
|
GM_setClipboard(formatPayload(payload), "text");
|
|
}
|
|
|
|
async function readPayload(slotIndex = 0) {
|
|
const slots = await readSlots();
|
|
return slots[slotIndex] || null;
|
|
}
|
|
|
|
function slotLabel(payload, index) {
|
|
if (!payload) return `Slot ${index + 1} - 비어있음`;
|
|
return `Slot ${index + 1} - ${payload.title || payload.version || "저장됨"}`;
|
|
}
|
|
|
|
function bootChatGPT() {
|
|
clearInterval(chatRenderTimer);
|
|
const body = createPanel("ChatGPT -> Suno", "cgs-chatgpt");
|
|
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 = async () => {
|
|
const list = body.querySelector("[data-cgs-list]");
|
|
const versions = parseVersions(getConversationText());
|
|
list.innerHTML = "";
|
|
|
|
if (!versions.length) {
|
|
document.querySelector("#cgs-panel")?.remove();
|
|
return;
|
|
}
|
|
|
|
const slots = await readSlots();
|
|
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" data-slot="0">S1</button>
|
|
<button class="cgs-btn cgs-primary" type="button" data-slot="1">S2</button>
|
|
<button class="cgs-btn" type="button">복사</button>
|
|
`;
|
|
const copyButton = row.querySelector("button:not([data-slot])");
|
|
for (const saveButton of row.querySelectorAll("[data-slot]")) {
|
|
saveButton.title = slotLabel(slots[Number(saveButton.dataset.slot)], Number(saveButton.dataset.slot));
|
|
saveButton.addEventListener("click", async () => {
|
|
const slotIndex = Number(saveButton.dataset.slot);
|
|
await savePayload(payload, slotIndex);
|
|
setStatus(body, `${payload.version} -> Slot ${slotIndex + 1} 저장하고 복사했어.`);
|
|
});
|
|
}
|
|
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;
|
|
}
|
|
|
|
function safeFilename(value, fallback = "suno-track") {
|
|
return String(value || fallback)
|
|
.replace(/[\\/:*?"<>|]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 120) || fallback;
|
|
}
|
|
|
|
function extensionFromUrl(url, fallback) {
|
|
const clean = String(url || "").split("?")[0].split("#")[0];
|
|
const match = /\.([a-z0-9]{2,5})$/i.exec(clean);
|
|
return (match?.[1] || fallback).toLowerCase();
|
|
}
|
|
|
|
function unique(values) {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
function extractUrls(text, pattern) {
|
|
return unique([...String(text || "").matchAll(pattern)].map((match) => match[0].replace(/\\u002F/g, "/")));
|
|
}
|
|
|
|
function readJsonScripts() {
|
|
const results = [];
|
|
for (const script of document.querySelectorAll("script")) {
|
|
const text = script.textContent || "";
|
|
if (!text.includes("suno") && !text.includes("audio") && !text.includes("image")) continue;
|
|
try {
|
|
results.push(JSON.parse(text));
|
|
} catch {
|
|
results.push(text);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function readSunoDataSources() {
|
|
return [
|
|
...readJsonScripts(),
|
|
...capturedSunoResponses.map((item) => item.data),
|
|
...((typeof unsafeWindow !== "undefined" && unsafeWindow.__NEXT_DATA__) ? [unsafeWindow.__NEXT_DATA__] : []),
|
|
];
|
|
}
|
|
|
|
function flattenObjects(value, output = []) {
|
|
if (!value || typeof value !== "object") return output;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) flattenObjects(item, output);
|
|
return output;
|
|
}
|
|
output.push(value);
|
|
for (const item of Object.values(value)) flattenObjects(item, output);
|
|
return output;
|
|
}
|
|
|
|
function firstValue(object, keys) {
|
|
for (const key of keys) {
|
|
if (object?.[key]) return object[key];
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function isLikelyTrackObject(object) {
|
|
if (!object || typeof object !== "object" || Array.isArray(object)) return false;
|
|
const id = firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]);
|
|
const hasTrackText = firstValue(object, ["title", "name", "prompt", "gpt_description_prompt", "metadata"]);
|
|
const hasMedia = firstValue(object, ["audio_url", "audioUrl", "image_url", "imageUrl", "image_large_url", "wav_url", "wavUrl"]);
|
|
return Boolean(id && (hasTrackText || hasMedia));
|
|
}
|
|
|
|
function normalizeTrackObject(object) {
|
|
const id = String(firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]) || "").trim();
|
|
const title = String(firstValue(object, ["title", "name"]) || id || "suno-track").trim();
|
|
const imageUrl = firstValue(object, ["image_url", "imageUrl", "image_large_url", "imageLargeUrl", "cover_url", "coverUrl"]);
|
|
const audioUrl = firstValue(object, ["audio_url", "audioUrl", "play_url", "playUrl", "mp3_url", "mp3Url"]);
|
|
const wavUrl = firstValue(object, ["wav_url", "wavUrl"]) || (id ? `https://cdn1.suno.ai/${id}.wav` : "");
|
|
const lyrics = firstValue(object, ["prompt", "lyrics", "lyric", "text"]);
|
|
const style = firstValue(object, ["gpt_description_prompt", "style", "tags", "description"]);
|
|
const model = firstValue(object, ["model_name", "model", "major_model_version"]);
|
|
const createdAt = firstValue(object, ["created_at", "createdAt"]);
|
|
return {
|
|
id,
|
|
title,
|
|
lyrics,
|
|
style,
|
|
model,
|
|
createdAt,
|
|
audioUrl,
|
|
wavUrl,
|
|
imageUrl,
|
|
pageUrl: location.href,
|
|
metadata: object,
|
|
capturedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function mergeTrack(target, source) {
|
|
if (!target) return source;
|
|
for (const key of ["title", "lyrics", "style", "model", "createdAt", "audioUrl", "wavUrl", "imageUrl", "pageUrl"]) {
|
|
if (!target[key] && source[key]) target[key] = source[key];
|
|
}
|
|
target.metadata = target.metadata && Object.keys(target.metadata).length ? target.metadata : source.metadata;
|
|
return target;
|
|
}
|
|
|
|
function normalizeMatchText(value) {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function getVisibleLibraryRows() {
|
|
const rows = [];
|
|
const seen = new Set();
|
|
const mediaNodes = [...document.querySelectorAll("img, [style*='background-image']")];
|
|
for (const node of mediaNodes) {
|
|
const rect = node.getBoundingClientRect();
|
|
if (rect.width < 30 || rect.height < 30 || rect.bottom < 0 || rect.top > window.innerHeight) continue;
|
|
let current = node;
|
|
for (let depth = 0; depth < 8 && current; depth += 1) {
|
|
const text = (current.innerText || current.textContent || "").trim();
|
|
const currentRect = current.getBoundingClientRect();
|
|
if (
|
|
text.length > 20 &&
|
|
text.length < 1800 &&
|
|
currentRect.width > 250 &&
|
|
currentRect.height > 45 &&
|
|
/v\d|BPM|Korean|rock|pop|metal|랩|가사|female|male|vocal|Suno/i.test(text)
|
|
) {
|
|
const key = normalizeMatchText(text);
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
rows.push({
|
|
text,
|
|
normalized: key,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
current = current.parentElement;
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function trackMatchesVisibleRows(track, rows) {
|
|
if (!rows.length) return true;
|
|
const title = normalizeMatchText(track.title);
|
|
const style = normalizeMatchText(track.style || track.lyrics || "");
|
|
if (!title || title.length > 160) return false;
|
|
return rows.some((row) => {
|
|
if (!row.normalized.includes(title)) return false;
|
|
if (!style) return true;
|
|
const styleNeedle = style.slice(0, Math.min(42, style.length));
|
|
return styleNeedle.length < 16 || row.normalized.includes(styleNeedle);
|
|
});
|
|
}
|
|
|
|
function scanSunoLibraryTracks() {
|
|
const sources = readSunoDataSources();
|
|
const objects = sources.flatMap((item) => flattenObjects(item));
|
|
const tracks = new Map();
|
|
|
|
for (const object of objects) {
|
|
if (!isLikelyTrackObject(object)) continue;
|
|
const track = normalizeTrackObject(object);
|
|
if (!track.id) continue;
|
|
tracks.set(track.id, mergeTrack(tracks.get(track.id), track));
|
|
}
|
|
|
|
const htmlText = document.documentElement.innerHTML || "";
|
|
const idMatches = [...htmlText.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi)];
|
|
const fallbackIds = unique(idMatches
|
|
.filter((match) => /clip|audio|image|title|cdn[0-9]*\.suno\.ai/i.test(htmlText.slice(Math.max(0, match.index - 500), match.index + 500)))
|
|
.map((match) => match[0]));
|
|
for (const id of fallbackIds) {
|
|
if (tracks.has(id)) continue;
|
|
tracks.set(id, {
|
|
id,
|
|
title: id,
|
|
audioUrl: "",
|
|
wavUrl: `https://cdn1.suno.ai/${id}.wav`,
|
|
imageUrl: "",
|
|
pageUrl: location.href,
|
|
metadata: {},
|
|
capturedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const visibleRows = getVisibleLibraryRows();
|
|
return [...tracks.values()]
|
|
.filter((track) => track.id || track.audioUrl || track.imageUrl)
|
|
.filter((track) => trackMatchesVisibleRows(track, visibleRows))
|
|
.sort((a, b) => String(a.title).localeCompare(String(b.title), "ko"));
|
|
}
|
|
|
|
function visibleSunoRowsHint() {
|
|
return getVisibleLibraryRows()
|
|
.map((row) => row.text.split("\n").map((line) => line.trim()).filter(Boolean)[0])
|
|
.filter((text, index, list) => text && list.indexOf(text) === index)
|
|
.slice(0, 5);
|
|
}
|
|
|
|
function findCurrentSunoTrack() {
|
|
const pageText = document.body.innerText || "";
|
|
const htmlText = document.documentElement.innerHTML || "";
|
|
const sources = readSunoDataSources();
|
|
const objects = sources.flatMap((item) => flattenObjects(item));
|
|
const audioElements = [...document.querySelectorAll("audio, video")].map((node) => node.currentSrc || node.src);
|
|
const allText = [htmlText, ...sources.map((item) => typeof item === "string" ? item : JSON.stringify(item))].join("\n");
|
|
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
|
|
const ids = unique([...allText.matchAll(uuidPattern)].map((match) => match[0]));
|
|
const cdnAudioUrls = extractUrls(allText, /https:\/\/cdn[0-9]*\.suno\.ai\/[^"' <>)]+?\.(?:mp3|wav|m4a|oga|ogg)(?:\?[^"' <>)]+)?/gi);
|
|
const cdnImageUrls = extractUrls(allText, /https:\/\/cdn[0-9]*\.suno\.ai\/[^"' <>)]+?\.(?:png|jpg|jpeg|webp)(?:\?[^"' <>)]+)?/gi);
|
|
const audioUrls = unique([...audioElements, ...cdnAudioUrls]);
|
|
const imageUrls = unique([
|
|
...cdnImageUrls,
|
|
...[...document.images].map((img) => img.currentSrc || img.src).filter((url) => /suno|cdn/i.test(url)),
|
|
]);
|
|
|
|
const metadataObjects = objects.filter((item) => {
|
|
const text = JSON.stringify(item);
|
|
return /audio_url|image_url|clip|prompt|metadata|title|gpt_description_prompt/i.test(text)
|
|
&& (ids.some((id) => text.includes(id)) || /cdn[0-9]*\.suno\.ai/i.test(text));
|
|
});
|
|
const metadata = metadataObjects[0] || {};
|
|
const title =
|
|
metadata.title ||
|
|
metadata.name ||
|
|
document.querySelector("h1")?.innerText ||
|
|
document.querySelector("[data-testid*='title' i]")?.innerText ||
|
|
"";
|
|
const id = metadata.id || metadata.clip_id || metadata.clipId || ids[0] || "";
|
|
const imageUrl = metadata.image_url || metadata.imageUrl || metadata.image_large_url || imageUrls[0] || "";
|
|
const audioUrl = metadata.audio_url || metadata.audioUrl || audioUrls.find((url) => /\.wav(?:\?|$)/i.test(url)) || audioUrls[0] || "";
|
|
const wavUrl = metadata.wav_url || metadata.wavUrl || audioUrls.find((url) => /\.wav(?:\?|$)/i.test(url)) || (id ? `https://cdn1.suno.ai/${id}.wav` : "");
|
|
|
|
return {
|
|
id,
|
|
title: String(title || "suno-track").trim(),
|
|
audioUrl,
|
|
wavUrl,
|
|
imageUrl,
|
|
pageUrl: location.href,
|
|
detectedIds: ids,
|
|
detectedAudioUrls: audioUrls,
|
|
detectedImageUrls: imageUrls,
|
|
metadata,
|
|
pageTextPreview: pageText.slice(0, 3000),
|
|
capturedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function downloadUrl(url, name, onerror) {
|
|
if (!url) {
|
|
onerror?.("URL 없음");
|
|
return;
|
|
}
|
|
GM_download({
|
|
url,
|
|
name,
|
|
saveAs: false,
|
|
onerror: (error) => onerror?.(error?.error || String(error)),
|
|
});
|
|
}
|
|
|
|
function downloadTextFile(text, name) {
|
|
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = name;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
}
|
|
|
|
function downloadSunoPackage(track, statusTarget) {
|
|
const baseName = safeFilename(track.title || track.id);
|
|
const messages = [];
|
|
const fail = (label) => (reason) => {
|
|
messages.push(`${label} 실패: ${reason}`);
|
|
setStatus(statusTarget, messages.join(" / "));
|
|
};
|
|
|
|
downloadTextFile(JSON.stringify(track, null, 2), `${baseName}.metadata.json`);
|
|
messages.push("metadata 저장");
|
|
|
|
if (track.imageUrl) {
|
|
downloadUrl(track.imageUrl, `${baseName}.cover.${extensionFromUrl(track.imageUrl, "jpg")}`, fail("cover"));
|
|
messages.push("cover 요청");
|
|
}
|
|
if (track.wavUrl) {
|
|
downloadUrl(track.wavUrl, `${baseName}.wav`, fail("wav"));
|
|
messages.push("wav 요청");
|
|
} else if (track.audioUrl) {
|
|
downloadUrl(track.audioUrl, `${baseName}.${extensionFromUrl(track.audioUrl, "audio")}`, fail("audio"));
|
|
messages.push("audio 요청");
|
|
}
|
|
setStatus(statusTarget, messages.join(" / "));
|
|
}
|
|
|
|
async function downloadSunoBatch(tracks, statusTarget) {
|
|
if (!tracks.length) {
|
|
setStatus(statusTarget, "다운로드할 곡을 못 찾았어. 라이브러리에서 스크롤을 좀 내려서 곡을 로드한 뒤 다시 눌러봐.");
|
|
return;
|
|
}
|
|
|
|
const manifest = {
|
|
pageUrl: location.href,
|
|
capturedAt: new Date().toISOString(),
|
|
count: tracks.length,
|
|
tracks,
|
|
};
|
|
downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`);
|
|
setStatus(statusTarget, `${tracks.length}곡 감지. manifest 저장 후 순차 다운로드 시작.`);
|
|
|
|
for (let index = 0; index < tracks.length; index += 1) {
|
|
const track = tracks[index];
|
|
setStatus(statusTarget, `${index + 1}/${tracks.length} 요청 중: ${track.title || track.id}`);
|
|
downloadSunoPackage(track, statusTarget);
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
}
|
|
setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`);
|
|
}
|
|
|
|
function exportSunoManifest(tracks, statusTarget) {
|
|
if (!tracks.length) {
|
|
setStatus(statusTarget, "내보낼 곡을 못 찾았어. 라이브러리 곡 감지를 먼저 해줘.");
|
|
return;
|
|
}
|
|
const manifest = {
|
|
pageUrl: location.href,
|
|
capturedAt: new Date().toISOString(),
|
|
count: tracks.length,
|
|
tracks,
|
|
};
|
|
downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`);
|
|
setStatus(statusTarget, `${tracks.length}곡 manifest 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`);
|
|
}
|
|
|
|
async function bootSuno() {
|
|
const body = createPanel("Suno Prompt Paste");
|
|
const slots = await readSlots();
|
|
body.innerHTML = `
|
|
<div class="cgs-muted" data-cgs-summary></div>
|
|
<select class="cgs-select" data-cgs-slot>
|
|
${slots.map((payload, index) => `<option value="${index}">${escapeHtml(slotLabel(payload, index))}</option>`).join("")}
|
|
</select>
|
|
<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-row">
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-refresh-track>곡 감지</button>
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-download-track>WAV + 커버 + 메타 다운로드</button>
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-scan-library>라이브러리 곡 감지</button>
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-export-library>처리용 manifest 저장</button>
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-download-library>브라우저 일괄 다운로드</button>
|
|
</div>
|
|
<div class="cgs-status"></div>
|
|
`;
|
|
|
|
const summary = body.querySelector("[data-cgs-summary]");
|
|
const slotSelect = body.querySelector("[data-cgs-slot]");
|
|
let libraryTracks = [];
|
|
const updateSummary = () => {
|
|
const payload = slots[Number(slotSelect.value)];
|
|
const track = findCurrentSunoTrack();
|
|
const promptText = payload ? `${payload.version || "저장된 프롬프트"}: ${payload.title || "제목 없음"}` : "선택 슬롯 비어있음";
|
|
const libraryText = libraryTracks.length ? ` / 라이브러리 ${libraryTracks.length}곡` : "";
|
|
const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지";
|
|
summary.textContent = promptText + trackText + libraryText;
|
|
};
|
|
updateSummary();
|
|
slotSelect.addEventListener("change", updateSummary);
|
|
|
|
body.querySelector("[data-cgs-fill]").addEventListener("click", async () => {
|
|
const latest = await readPayload(Number(slotSelect.value));
|
|
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(Number(slotSelect.value));
|
|
const key = button.getAttribute("data-cgs-copy");
|
|
const value = latest?.[key] || "";
|
|
if (!value) {
|
|
setStatus(body, "복사할 값이 없어.");
|
|
return;
|
|
}
|
|
GM_setClipboard(value, "text");
|
|
setStatus(body, `${button.textContent}만 복사했어.`);
|
|
});
|
|
}
|
|
|
|
body.querySelector("[data-cgs-refresh-track]").addEventListener("click", () => {
|
|
updateSummary();
|
|
const track = findCurrentSunoTrack();
|
|
setStatus(body, track.id || track.audioUrl ? `감지됨: ${track.title || track.id}` : "현재 화면에서 곡 URL을 못 찾았어.");
|
|
});
|
|
|
|
body.querySelector("[data-cgs-download-track]").addEventListener("click", () => {
|
|
const track = findCurrentSunoTrack();
|
|
if (!track.id && !track.audioUrl && !track.imageUrl) {
|
|
setStatus(body, "다운로드할 곡 정보를 못 찾았어. 곡 상세/플레이어 화면에서 다시 눌러봐.");
|
|
return;
|
|
}
|
|
downloadSunoPackage(track, body);
|
|
});
|
|
|
|
body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => {
|
|
libraryTracks = scanSunoLibraryTracks();
|
|
updateSummary();
|
|
const sample = libraryTracks.slice(0, 3).map((track) => track.title || track.id).join(", ");
|
|
if (libraryTracks.length) {
|
|
const visibleCount = getVisibleLibraryRows().length;
|
|
setStatus(body, `${libraryTracks.length}곡 감지: ${sample} / 화면 행 ${visibleCount}개 기준`);
|
|
return;
|
|
}
|
|
const visibleHints = visibleSunoRowsHint();
|
|
const networkHint = capturedSunoResponses.length ? `API 응답 ${capturedSunoResponses.length}개는 잡혔는데 clip 구조를 못 찾았어.` : "아직 SUNO API 응답을 못 잡았어.";
|
|
setStatus(body, `${networkHint} 목록 새로고침이나 스크롤 후 다시 눌러봐. 화면 텍스트: ${visibleHints.join(", ")}`);
|
|
});
|
|
|
|
body.querySelector("[data-cgs-download-library]").addEventListener("click", async () => {
|
|
if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks();
|
|
updateSummary();
|
|
await downloadSunoBatch(libraryTracks, body);
|
|
});
|
|
|
|
body.querySelector("[data-cgs-export-library]").addEventListener("click", () => {
|
|
if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks();
|
|
updateSummary();
|
|
exportSunoManifest(libraryTracks, body);
|
|
});
|
|
}
|
|
|
|
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();
|
|
})();
|