Greasy Fork

来自缓存

Greasy Fork is available in English.

SOOP 하이라이트 댓글 복사기

하이라이트 댓글을 쉽게 복사하게 하기 위해 작성하였습니다. 각 댓글 옆에 링크 아이콘을 추가하여 클릭 한 번으로 하이라이트 링크를 복사할 수 있습니다.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();