// ==UserScript== // @name SOOP 하이라이트 댓글 복사기 // @name:en SOOP Highlight Comment Copier // @namespace https://greasyfork.org/ // @version 1.0.0 // @description 하이라이트 댓글을 쉽게 복사하게 하기 위해 작성하였습니다. 각 댓글 옆에 링크 아이콘을 추가하여 클릭 한 번으로 하이라이트 링크를 복사할 수 있습니다. // @description:en Adds a link icon next to each comment on SOOP (sooplive.com) station posts. Click to copy the highlight link for that comment. // @author Anonymous // @license MIT // @match https://www.sooplive.com/station/* // @match https://sooplive.com/station/* // @grant GM_setClipboard // @run-at document-start // @icon https://res.sooplive.com/favicon.ico // @compatible chrome Tampermonkey, Violentmonkey // @compatible firefox Greasemonkey, Tampermonkey // @compatible edge Tampermonkey // @downloadURL none // ==/UserScript== /** * SOOP 하이라이트 댓글 복사기 * * [기능] * - 각 댓글의 ⋮ 버튼 옆에 🔗 링크 아이콘 버튼을 추가합니다. * - 클릭하면 해당 댓글의 하이라이트 링크가 클립보드에 복사됩니다. * - 복사 성공 시 아이콘이 초록색 ✓ 체크로 변합니다. * * [동작 방식] * 1. 댓글 API 응답을 인터셉트하여 댓글 ID를 캐싱합니다. * 2. SPA 네비게이션 시 댓글 API를 선제 호출하여 데이터를 확보합니다. * 3. DOM MutationObserver로 새로 렌더링되는 댓글에 자동으로 버튼을 주입합니다. */ (function () { 'use strict'; // ═══════════════════════════════════════ // 댓글 데이터 캐싱 // ═══════════════════════════════════════ const commentStore = []; function storeComments(json) { const list = json?.data; if (!Array.isArray(list)) return; for (const item of list) { if (!item.pCommentNo) continue; const id = String(item.pCommentNo); if (commentStore.some(c => c.id === id)) continue; commentStore.push({ id, nick: item.userNick || '', userId: item.userId || '', content: (item.comment || '').trim() }); } } // ═══════════════════════════════════════ // 네트워크 인터셉트 (fetch + XHR) // ═══════════════════════════════════════ const COMMENT_API_PATTERN = /api-channel\.sooplive\.com.*\/comment/i; const origFetch = window.fetch; window.fetch = async function (...args) { const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; const response = await origFetch.apply(this, args); if (COMMENT_API_PATTERN.test(url)) { try { response.clone().json().then(storeComments).catch(() => {}); } catch {} } return response; }; const origXhrOpen = XMLHttpRequest.prototype.open; const origXhrSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url, ...rest) { this._hlUrl = url; return origXhrOpen.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.send = function (...args) { this.addEventListener('load', function () { if (this._hlUrl && COMMENT_API_PATTERN.test(this._hlUrl)) { try { storeComments(JSON.parse(this.responseText)); } catch {} } }); return origXhrSend.apply(this, args); }; // ═══════════════════════════════════════ // URL 파싱 & 댓글 선제 로드 // ═══════════════════════════════════════ function getPostInfo() { const m = location.pathname.match(/\/station\/([^/]+)\/post\/(\d+)/); return m ? { stationId: m[1], postId: m[2] } : null; } let lastPrefetchedUrl = ''; async function prefetchComments() { const info = getPostInfo(); if (!info) return; const currentUrl = location.href; if (lastPrefetchedUrl === currentUrl && commentStore.length > 0) return; lastPrefetchedUrl = currentUrl; const MAX_PAGES = 10; let page = 1; while (page <= MAX_PAGES) { try { const apiUrl = `https://api-channel.sooplive.com/v1.1/channel/${info.stationId}/post/${info.postId}/comment?page=${page}&orderBy=reg_date&commentNo=0`; const res = await origFetch(apiUrl); const json = await res.json(); const prevCount = commentStore.length; storeComments(json); if (commentStore.length === prevCount) break; page++; } catch { break; } } } // SPA 네비게이션 감지 const origPushState = history.pushState; history.pushState = function (...args) { origPushState.apply(this, args); setTimeout(prefetchComments, 1000); }; window.addEventListener('popstate', () => setTimeout(prefetchComments, 1000)); // ═══════════════════════════════════════ // 댓글 요소 탐색 & ID 매칭 // ═══════════════════════════════════════ function findCommentContainer(el) { let cur = el; while (cur && cur !== document.body) { const cls = (cur.className || '').toString(); if (/CommentItem_comment__/.test(cls) && !/Content|Wrapper|Input|dot|Button/i.test(cls)) { return cur; } cur = cur.parentElement; } return null; } function findCommentId(commentEl) { if (!commentEl) return null; const img = commentEl.querySelector('img[alt]'); const alt = img?.alt; if (!alt) return null; // userId 또는 nick으로 매칭 let matches = commentStore.filter(c => c.userId === alt); if (matches.length === 0) { matches = commentStore.filter(c => c.nick === alt); } if (matches.length === 1) return matches[0].id; if (matches.length > 1) { const domText = (commentEl.textContent || '').trim(); for (const c of matches) { const snippet = c.content.substring(0, 15); if (snippet && domText.includes(snippet)) { return c.id; } } return matches[0].id; } return null; } // ═══════════════════════════════════════ // 유틸리티 // ═══════════════════════════════════════ function copyText(text) { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); return; } navigator.clipboard.writeText(text).catch(() => { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;opacity:0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }); } function showToast(msg) { const el = document.createElement('div'); el.textContent = msg; Object.assign(el.style, { position: 'fixed', bottom: '40px', left: '50%', transform: 'translateX(-50%)', background: '#333', color: '#fff', padding: '12px 24px', borderRadius: '8px', fontSize: '14px', zIndex: '999999', boxShadow: '0 4px 12px rgba(0,0,0,.3)', transition: 'opacity .3s', pointerEvents: 'none' }); document.body.appendChild(el); setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 2000); } // ═══════════════════════════════════════ // SVG 아이콘 // ═══════════════════════════════════════ const ICON_LINK = ``; const ICON_CHECK = ``; // ═══════════════════════════════════════ // 스타일 주입 // ═══════════════════════════════════════ let styleInjected = false; function injectStyles() { if (styleInjected) return; styleInjected = true; const style = document.createElement('style'); style.id = 'soop-highlight-copier-styles'; style.textContent = ` .tm-hl-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; border-radius: 50%; background: transparent; cursor: pointer; padding: 0; margin-right: 2px; transition: background-color 0.15s ease, transform 0.1s ease; color: #888; flex-shrink: 0; } .tm-hl-btn:hover { background-color: rgba(0, 0, 0, 0.06); color: #555; } .tm-hl-btn:active { background-color: rgba(0, 0, 0, 0.1); transform: scale(0.92); } .tm-hl-btn svg { width: 16px; height: 16px; } .tm-hl-btn.tm-copied { color: #22c55e !important; background-color: rgba(34, 197, 94, 0.1) !important; } `; document.head.appendChild(style); } // ═══════════════════════════════════════ // 버튼 주입 (⋮ 버튼 옆 링크 아이콘) // ═══════════════════════════════════════ function injectLinkButton(dotButton) { if (dotButton.parentElement?.querySelector('.tm-hl-btn')) return; const commentContainer = findCommentContainer(dotButton); if (!commentContainer) return; injectStyles(); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tm-hl-btn'; btn.title = '하이라이트 링크 복사'; btn.innerHTML = ICON_LINK; btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const postInfo = getPostInfo(); if (!postInfo) { showToast('게시글 정보를 찾을 수 없습니다.'); return; } let commentId = findCommentId(commentContainer); // 댓글 데이터가 아직 없으면 선제 로드 후 재시도 if (!commentId && commentStore.length === 0) { await prefetchComments(); commentId = findCommentId(commentContainer); } if (!commentId) { showToast('댓글 ID를 찾을 수 없습니다. 새로고침 후 다시 시도해주세요.'); return; } const link = `https://www.sooplive.com/station/${postInfo.stationId}/post/${postInfo.postId}#comment_noti${commentId}`; copyText(link); // 복사 성공 피드백: 체크 아이콘으로 전환 btn.classList.add('tm-copied'); btn.innerHTML = ICON_CHECK; showToast('하이라이트 링크가 복사되었습니다!'); setTimeout(() => { btn.classList.remove('tm-copied'); btn.innerHTML = ICON_LINK; }, 1500); }); dotButton.parentElement.insertBefore(btn, dotButton); } // ═══════════════════════════════════════ // DOM 감시 & 초기화 // ═══════════════════════════════════════ function scanForDotButtons() { const selectors = '[class*="dotButton"], [class*="dot_button"], [class*="moreButton"]'; document.querySelectorAll(selectors).forEach(el => { const btn = el.closest('button') || el; if (findCommentContainer(btn)) { injectLinkButton(btn); } }); } // MutationObserver 성능 최적화: debounce let scanTimer = null; function debouncedScan() { if (scanTimer) return; scanTimer = setTimeout(() => { scanTimer = null; scanForDotButtons(); }, 300); } const waitForBody = () => { if (!document.body) { requestAnimationFrame(waitForBody); return; } const observer = new MutationObserver(debouncedScan); observer.observe(document.body, { childList: true, subtree: true }); // 초기 스캔 (페이지 로드 후) setTimeout(scanForDotButtons, 1000); setTimeout(scanForDotButtons, 3000); // 댓글 선제 로드 setTimeout(prefetchComments, 1500); }; waitForBody(); })();