나무위키 주석 추출기

주석 번호와 설명을 복사할 수 있는 패널을 작고 세련된 UI로 제공하며, 페이지 내용과 URL 변경 시 자동 갱신, 복사 시 선택된 텍스트에 주석 내용 자동 덧붙임 기능 포함.

// ==UserScript==
// @name         나무위키 주석 추출기
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  주석 번호와 설명을 복사할 수 있는 패널을 작고 세련된 UI로 제공하며, 페이지 내용과 URL 변경 시 자동 갱신, 복사 시 선택된 텍스트에 주석 내용 자동 덧붙임 기능 포함.
// @match        https://namu.wiki/*
// @grant        GM_setClipboard
// ==/UserScript==

(function () {
    'use strict';

    const green = '#4CAF50';
    const greenTransparent = 'rgba(76, 175, 80, 0.8)';
    let collected = [];
    let panel, copyBtn, pre;
    let fixedContainer, smallButton, expandBtn;

    let isCopyButtonClick = false;

    // 주석 수집 및 갱신
    function collectAndUpdate() {
        const bodyText = document.body.innerText;
        const notes = [...bodyText.matchAll(/\[(\d+)\]/g)];
        const uniqueNotes = [...new Set(notes.map(m => m[0]))];
        const newCollected = [];

        uniqueNotes.forEach(n => {
            const parts = bodyText.split(n);
            if (parts.length >= 3) {
                const afterSecond = parts[2].trim();
                const nextLine = afterSecond.split(/\n/)[0];
                newCollected.push(`${n} ${nextLine}`);
            }
        });

        if (JSON.stringify(newCollected) !== JSON.stringify(collected)) {
            collected = newCollected;
            pre.innerText = collected.length > 0
              ? collected.join('\n')
              : '(복사할 주석이 없습니다)';
        }
    }

    // 텍스트 내 주석 번호를 주석 내용으로 대체 (텍스트 전용)
    function replaceNotesWithContents(text) {
        const noteMap = {};
        collected.forEach(line => {
            const m = line.match(/^\[(\d+)\]\s*(.*)$/);
            if (m) noteMap[m[1]] = m[2];
        });

        return text.replace(/\[(\d+)\]/g, (match, num) =>
            noteMap[num] ? `[${noteMap[num]}]` : match
        );
    }

    // 복사용 DOM 노드 내 주석 대체, 링크 내 주석 텍스트로만 대체하고 링크는 제거
    // 단, 링크 내 주석 없으면 링크 유지
    function replaceNotesInNode(node, noteMap) {
        if (node.nodeType === Node.TEXT_NODE) {
            node.textContent = node.textContent.replace(/\[(\d+)\]/g, (match, num) =>
                noteMap[num] ? `[${noteMap[num]}]` : match
            );
        } else if (node.nodeType === Node.ELEMENT_NODE) {
            if (node.tagName === 'A') {
                const hasNote = /\[(\d+)\]/.test(node.textContent);
                if (hasNote) {
                    const replacedText = node.textContent.replace(/\[(\d+)\]/g, (match, num) =>
                        noteMap[num] ? `[${noteMap[num]}]` : match
                    );
                    const textNode = document.createTextNode(replacedText);
                    node.parentNode.replaceChild(textNode, node);
                } else {
                    node.childNodes.forEach(child => replaceNotesInNode(child, noteMap));
                }
            } else {
                node.childNodes.forEach(child => replaceNotesInNode(child, noteMap));
            }
        }
    }

    // 임시 복사 DOM 내 a[href] 절대경로 변환 (상대경로만 변환)
    function fixLinksToAbsolute(container) {
        const anchors = container.querySelectorAll('a[href]');
        anchors.forEach(a => {
            const href = a.getAttribute('href');
            if (href && !href.match(/^https?:\/\//i) && !href.startsWith('mailto:') && !href.startsWith('#')) {
                try {
                    const absUrl = new URL(href, location.origin);
                    a.setAttribute('href', absUrl.href);
                } catch {}
            }
        });
    }

    // 공통 복사 함수
    function copyNotes() {
        const selection = window.getSelection();
        const textToCopy = selection && selection.toString().trim().length > 0
            ? replaceNotesWithContents(selection.toString())
            : collected.join('\n');

        if (textToCopy) {
            GM_setClipboard(textToCopy);
            return true;
        }
        return false;
    }

    // 복사 버튼 클릭 처리 함수 (버튼 요소 인자로 받음)
    function handleCopyClick(button) {
        isCopyButtonClick = true;
        if (copyNotes()) {
            const originalText = button.innerText;
            button.innerText = '복사됨!';
            setTimeout(() => {
                button.innerText = originalText;
                isCopyButtonClick = false;
            }, 1500);
        } else {
            isCopyButtonClick = false;
        }
    }

    function createUI() {
        panel = document.createElement('div');
        panel.style = `
          position: fixed; bottom: 20px; right: 20px; z-index: 9999;
          background: white; border: 1px solid #ccc; padding: 10px;
          box-shadow: 0 2px 10px rgba(0,0,0,0.2); max-width: 300px;
          max-height: 400px; overflow: auto; font-size: 13px;
          line-height: 1.4; display: none;
        `;

        copyBtn = document.createElement('button');
        copyBtn.innerText = '주석 복사';
        copyBtn.style = `
          background: ${green}; color: white; border: none;
          padding: 6px 12px; font-size: 13px; border-radius: 4px; cursor: pointer;
        `;
        copyBtn.onclick = () => {
            handleCopyClick(copyBtn);
        };

        pre = document.createElement('pre');
        pre.style.whiteSpace = 'pre-wrap';
        pre.innerText = '(복사할 주석이 없습니다)';

        const collapseBtn = document.createElement('div');
        collapseBtn.innerHTML = `
          <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="white" viewBox="0 0 24 24">
            <path d="M19 13H5v-2h14v2z"/>
          </svg>`;
        collapseBtn.style = `
          position: absolute; top: 6px; right: 6px; width: 20px; height: 20px;
          background: rgba(255, 0, 0, 0.8); border-radius: 4px;
          display: flex; align-items: center; justify-content: center; cursor: pointer;
        `;
        collapseBtn.onclick = () => {
            panel.style.display = 'none';
            fixedContainer.style.display = 'flex';
        };

        panel.appendChild(copyBtn);
        panel.appendChild(document.createElement('hr'));
        panel.appendChild(pre);
        panel.appendChild(collapseBtn);

        fixedContainer = document.createElement('div');
        fixedContainer.style = `
          position: fixed; bottom: 20px; right: 20px; z-index: 9998;
          display: flex; align-items: center; gap: 4px;
        `;

        smallButton = document.createElement('button');
        smallButton.innerText = '주석 복사';
        smallButton.style = `
          background: ${green}; color: white; border: none;
          padding: 6px 12px; font-size: 13px; border-radius: 4px;
          cursor: pointer; flex-shrink: 0; height: 28px;
        `;
        smallButton.onclick = () => {
            handleCopyClick(smallButton);
        };

        expandBtn = document.createElement('button');
        expandBtn.title = '펼치기';
        expandBtn.style = `
          background: ${greenTransparent}; border: none; border-radius: 4px;
          height: 28px; width: 28px; padding: 0; display: flex;
          align-items: center; justify-content: center; cursor: pointer;
        `;
        expandBtn.innerHTML = `
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="white" viewBox="0 0 24 24">
            <path d="M4 4h6v2H6v4H4V4zm16 0v6h-2V6h-4V4h6zm0 16h-6v-2h4v-4h2v6zM4 20v-6h2v4h4v2H4z"/>
          </svg>`;
        expandBtn.onclick = () => {
            panel.style.display = 'block';
            fixedContainer.style.display = 'none';
        };

        fixedContainer.appendChild(smallButton);
        fixedContainer.appendChild(expandBtn);
        document.body.appendChild(panel);
        document.body.appendChild(fixedContainer);
    }

    // SPA 대응 URL 변경 감지
    function onUrlChange(callback) {
        window.addEventListener('popstate', callback);
        const pushState = history.pushState;
        history.pushState = function (...args) {
            const result = pushState.apply(this, args);
            callback();
            return result;
        };
        const replaceState = history.replaceState;
        history.replaceState = function (...args) {
            const result = replaceState.apply(this, args);
            callback();
            return result;
        };
    }

    createUI();
    collectAndUpdate();

    const observer = new MutationObserver(() => {
        collectAndUpdate();
    });
    observer.observe(document.body, { childList: true, subtree: true, characterData: true });

    onUrlChange(() => {
        setTimeout(() => {
            collectAndUpdate();
        }, 500);
    });

    // 복사 시 주석 내용 자동 덧붙임 및 하이퍼링크 처리
    document.addEventListener('copy', (e) => {
        if (isCopyButtonClick) {
            // 주석창의 복사 버튼 클릭 시는 가로채지 않고 원본 복사 허용
            return;
        }

        // ✅ 주석창이 열려있으면 원래 복사만 허용
        if (panel && panel.style.display !== 'none') {
            return; // 패널이 열려있을 때는 기본 복사
        }

        // ✅ 주석창이 닫혀있을 때만 주석 변환 가로채기
        const selection = window.getSelection();
        if (!selection || selection.rangeCount === 0) return;

        const range = selection.getRangeAt(0);
        const cloned = range.cloneContents();

        const noteMap = {};
        collected.forEach(line => {
            const m = line.match(/^\[(\d+)\]\s*(.*)$/);
            if (m) noteMap[m[1]] = m[2];
        });

        const div = document.createElement('div');
        div.appendChild(cloned);

        replaceNotesInNode(div, noteMap);
        fixLinksToAbsolute(div);

        const selectedText = selection.toString();
        const replacedText = replaceNotesWithContents(selectedText);

        e.preventDefault();
        e.clipboardData.setData('text/html', div.innerHTML);
        e.clipboardData.setData('text/plain', replacedText);
    });

})();