diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js index c51028d..00b1aaf 100644 --- a/chatgpt-suno-tampermonkey.user.js +++ b/chatgpt-suno-tampermonkey.user.js @@ -30,7 +30,7 @@ (function () { "use strict"; - const SCRIPT_VERSION = "0.9.6"; + const SCRIPT_VERSION = "0.9.7"; const STORAGE_KEY = "chatgptSunoPromptSlots"; const SLOT_COUNT = 2; const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com"); @@ -861,6 +861,7 @@ normalized: key, title: inferVisibleRowTitle(text), ids: extractIdsFromText(mediaText), + element: current, }); } break; @@ -940,6 +941,89 @@ .sort((a, b) => getTrackVisibleIndex(a, visibleRows) - getTrackVisibleIndex(b, visibleRows)); } + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function clickLikeUser(node) { + if (!node) return false; + node.scrollIntoView?.({ block: "center", inline: "center" }); + for (const type of ["pointerover", "mouseover", "mousemove", "pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + node.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); + } + return true; + } + + function visibleClickableText(node) { + return String(node?.getAttribute?.("aria-label") || node?.innerText || node?.textContent || "").replace(/\s+/g, " ").trim(); + } + + function findVisibleButtonByText(labels, root = document) { + const needles = labels.map((label) => normalizeMatchText(label)); + const buttons = [...root.querySelectorAll("button, [role='button'], [data-context-menu-trigger='true']")]; + return buttons.find((button) => { + const rect = button.getBoundingClientRect(); + if (rect.width < 8 || rect.height < 8 || rect.bottom < 0 || rect.top > window.innerHeight) return false; + const text = normalizeMatchText(visibleClickableText(button)); + return needles.some((needle) => text === needle || text.includes(needle)); + }) || null; + } + + function findTrackRowElement(track) { + const rows = getVisibleLibraryRows(); + const index = getTrackVisibleIndex(track, rows); + if (index >= 0) return rows[index].element; + const title = normalizeMatchText(track.title); + if (!title) return null; + return rows.find((row) => row.normalized.includes(title))?.element || null; + } + + function findMoreButtonInRow(row) { + if (!row) return null; + const buttons = [...row.querySelectorAll("button, [role='button']")]; + const labeled = buttons.find((button) => /more|options|menu|ellipsis|actions|더보기|옵션/i.test(visibleClickableText(button))); + if (labeled) return labeled; + const compact = buttons + .map((button) => ({ button, rect: button.getBoundingClientRect(), text: visibleClickableText(button) })) + .filter((item) => item.rect.width >= 18 && item.rect.width <= 70 && item.rect.height >= 18 && item.rect.height <= 70) + .sort((a, b) => b.rect.left - a.rect.left); + return compact[0]?.button || null; + } + + async function waitForMenuButton(labels, timeoutMs = 2500) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const button = findVisibleButtonByText(labels); + if (button) return button; + await sleep(100); + } + return null; + } + + async function clickSunoUiDownload(track, format, statusTarget) { + const row = findTrackRowElement(track); + if (!row) throw new Error("현재 화면에서 곡 행을 못 찾았어. SUNO 목록에서 그 곡이 보이게 스크롤한 뒤 다시 눌러줘."); + const moreButton = findMoreButtonInRow(row); + if (!moreButton) throw new Error("곡의 ... 메뉴 버튼을 못 찾았어."); + + setStatus(statusTarget, `SUNO UI 메뉴 여는 중: ${track.title || track.id}`); + clickLikeUser(moreButton); + + const downloadButton = await waitForMenuButton(["Download", "다운로드"]); + if (!downloadButton) throw new Error("SUNO 메뉴에서 Download 항목을 못 찾았어."); + setStatus(statusTarget, `SUNO UI Download 여는 중: ${track.title || track.id}`); + clickLikeUser(downloadButton); + + const labels = format === "wav" + ? ["WAV Audio", "WAV", "Download WAV", "wav audio"] + : ["MP3 Audio", "MP3", "Download MP3", "mp3 audio"]; + const formatButton = await waitForMenuButton(labels, 3000); + if (!formatButton) throw new Error(`SUNO Download 하위 메뉴에서 ${format.toUpperCase()} 항목을 못 찾았어.`); + setStatus(statusTarget, `SUNO UI ${format.toUpperCase()} 클릭: ${track.title || track.id}`); + clickLikeUser(formatButton); + return true; + } + function visibleSunoRowsHint() { return getVisibleLibraryRows() .map((row) => row.text.split("\n").map((line) => line.trim()).filter(Boolean)[0]) @@ -1737,6 +1821,10 @@ +