Greasy Fork is available in English.
하이라이트 댓글을 쉽게 복사하게 하기 위해 작성하였습니다. 각 댓글 옆에 링크 아이콘을 추가하여 클릭 한 번으로 하이라이트 링크를 복사할 수 있습니다.
// ==UserScript==
// @name SOOP 하이라이트 댓글 복사기
// @name:en SOOP Highlight Comment Copier
// @namespace http://greasyfork.icu/
// @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
// ==/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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
const ICON_CHECK = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
// ═══════════════════════════════════════
// 스타일 주입
// ═══════════════════════════════════════
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();
})();