나무위키 주석 추출기

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==  
// @name         나무위키 주석 추출기  
// @namespace    http://tampermonkey.net/  
// @version      5.3
// @description  주석 번호와 설명을 복사할 수 있는 패널을 작고 세련된 UI로 제공하며, 페이지 내용과 URL 변경 시 자동 갱신, 복사 시 선택된 텍스트에 주석 내용 HTML을 보존한 형태로 자동 덧붙임 기능 포함.  
// @match        https://namu.wiki/*  
// @grant        GM_setClipboard  
// ==/UserScript==   
  
(function () {  
    'use strict';  
  
    const green = '#4CAF50';  
    const greenTransparent = 'rgba(76, 175, 80, 0.8)';  
    let collected = []; // {num, title, el}  
    let collectedHtmlMap = new Map(); // Map(el → html)  
    let convertTimer = null;  
  
    let panel, copyBtn, previewDiv, fixedContainer, smallButton, expandBtn;  
    let isCopyButtonClick = false;  
  
    // ---------------- 수집 ----------------  
    function collectAndUpdate() {  
        // href에 fn- 포함 + title 있는 <a>만 선택  
        const fnNotes = Array.from(document.querySelectorAll('a[href*="fn-"]'))  
            .filter(a => a.getAttribute('title'));  
  
        const newCollected = fnNotes.map(fn => {  
            const numMatch = fn.href.match(/fn-(\d+)/);  
            if (!numMatch) return null;  
            const num = numMatch[1];  
            const title = fn.getAttribute('title') || '';  
            return { num, title, el: fn };  
        }).filter(Boolean);  
  
        if (newCollected.length !== collected.length) {  
            collected = newCollected;  
            updatePanel();  
            if (convertTimer) clearTimeout(convertTimer);  
            convertTimer = setTimeout(() => convertCollectedToHtml(), 200);  
        }  
    }  
  
    // ---------------- UI 생성 ----------------  
    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); width: 360px;  
            max-height: 440px; 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);  
  
        const headerRow = document.createElement('div');  
        headerRow.style = 'display:flex; gap:8px; align-items:center; margin-bottom:8px;';  
        headerRow.appendChild(copyBtn);  
  
        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 = `  
            width: 28px; height: 28px; background: ${greenTransparent}; border-radius: 6px;  
            display:flex; align-items:center; justify-content:center; cursor:pointer;  
        `;  
        collapseBtn.title = '최소화';  
        collapseBtn.onclick = () => {  
            panel.style.display = 'none';  
            fixedContainer.style.display = 'flex';  
        };  
        headerRow.appendChild(collapseBtn);  
        panel.appendChild(headerRow);  
  
        previewDiv = document.createElement('div');  
        previewDiv.style = 'margin-top:8px;padding:6px;background:#fff;border-radius:4px;border:1px dashed #eee;max-height:240px;overflow:auto;';  
        previewDiv.innerHTML = '<small style="color:#666">주석 HTML 미리보기</small>';  
        panel.appendChild(previewDiv);  
  
        fixedContainer = document.createElement('div');  
        fixedContainer.style = `  
            position: fixed; bottom: 20px; right: 20px; z-index: 9998;  
            display: flex; align-items: center; gap: 6px;  
        `;  
  
        smallButton = document.createElement('button');  
        smallButton.innerText = '주석 복사';  
        smallButton.style = `  
            background: ${green}; color: white; border: none;  
            padding: 6px 10px; font-size: 13px; border-radius: 6px;  
            cursor: pointer; height: 34px;  
        `;  
        smallButton.onclick = () => handleCopyClick(smallButton);  
  
        expandBtn = document.createElement('button');  
        expandBtn.title = '펼치기';  
        expandBtn.style = `  
            background: ${greenTransparent}; border: none; border-radius: 6px;  
            height: 34px; width: 34px; 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);  
    }  
  
    function escapeHtml(s) {  
        return (s + '').replace(/[&<>"']/g, c =>  
            ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])  
        );  
    }  
  
    // ---------------- collected → HTML 변환 ----------------  
    function updatePanel() {  
        const htmlParts = [];  
        for (const item of collected) {  
            const html = collectedHtmlMap.get(item.el);  
            if (html) htmlParts.push(`<div style="margin-bottom:8px;"><strong>[${item.num}]</strong> ${html}</div>`);  
            else htmlParts.push(`<div style="margin-bottom:8px;"><strong>[${item.num}]</strong> ${escapeHtml(item.title)}</div>`);  
        }  
        previewDiv.innerHTML = htmlParts.length > 0 ? htmlParts.join('') : '<small style="color:#666">주석 HTML 미리보기 없음</small>';  
    }  
  
    function findRangeByText(root, text) {  
        if (!text) return null;  
        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);  
        let node, offset = 0, startNode = null, startOffset = 0, endNode = null, endOffset = 0;  
  
        while (node = walker.nextNode()) {  
            const value = node.nodeValue;  
            for (let i = 0; i < value.length; i++) {  
                if (text[offset] === value[i]) {  
                    if (offset === 0) {  
                        startNode = node;  
                        startOffset = i;  
                    }  
                    offset++;  
                    if (offset === text.length) {  
                        endNode = node;  
                        endOffset = i + 1;  
                        const range = document.createRange();  
                        range.setStart(startNode, startOffset);  
                        range.setEnd(endNode, endOffset);  
                        return range;  
                    }  
                } else offset = 0;  
            }  
        }  
        return null;  
    }  
  
    function convertCollectedToHtml() {  
        const map = new Map();  
        for (const item of collected) {  
            const { el, title, num } = item;  
            let foundHtml = null;  
  
            const range = findRangeByText(document.body, title);  
            if (range) {  
                const div = document.createElement('div');  
                div.appendChild(range.cloneContents());  
                foundHtml = div.innerHTML.trim();  
            }  
  
            if (!foundHtml) {  
                const ids = [`fn-${num}`, `rfn-${num}`, `fn${num}`, `ref-${num}`];  
                for (const id of ids) {  
                    const elem = document.getElementById(id);  
                    if (elem && elem.innerHTML.trim()) {  
                        const clone = elem.cloneNode(true);  
                        clone.querySelectorAll('.wiki-edit,.footnote-backref,.editsection').forEach(x => x.remove());  
                        foundHtml = clone.innerHTML.trim();  
                        break;  
                    }  
                }  
            }  
  
            if (!foundHtml) foundHtml = escapeHtml(title);  
            map.set(el, foundHtml);  
        }  
        collectedHtmlMap = map;  
        updatePanel();  
    }  
  
    // ---------------- 앵커 언랩(복사 전) ----------------  
    function cleanNoteAnchors(root) {  
        const anchors = root.querySelectorAll('a[href]');  
        anchors.forEach(a => {  
            try {  
                const href = a.getAttribute('href') || '';  
                if (!href.match(/^#([a-zA-Z0-9]*-)?fn-\d+$/)) return;  
  
                const title = a.getAttribute('title') || '';  
                const numMatch = href.match(/([a-zA-Z0-9]*-)?fn-(\d+)/);  
                const span = document.createElement('span');  
                span.textContent = a.textContent;  
  
                if (numMatch) span.setAttribute('data-fn-num', numMatch[2]);  
                if (title) span.setAttribute('data-fn-title', title);  
  
                a.parentNode.replaceChild(span, a);  
            } catch (e) { /* 안전하게 무시 */ }  
        });  
    }  
  
    // ---------------- 선택 영역 기준 치환 ----------------  
    function replaceNotesInNodeHtml(node, containerRoot) {  
        if (!node) return;  
      
        // 텍스트 노드인 경우  
        if (node.nodeType === Node.TEXT_NODE) {  
            const parts = node.nodeValue.split(/(\[\d+\])/g);  
            if (parts.length === 1) return; // 치환할 패턴 없음  
      
            const frag = document.createDocumentFragment();  
      
            for (const part of parts) {  
                const m = part.match(/^\[(\d+)\]$/);  
                if (!m) {  
                    // 일반 텍스트 조각  
                    frag.appendChild(document.createTextNode(part));  
                    continue;  
                }  
      
                const num = m[1];  
      
                // --- (1) 이 텍스트 노드가 rfn-숫자 앵커 내부에 있는지 검사 ---  
                // 텍스트 노드의 조상 중 a[href]가 있고, 그 href에 해당 rfn-num이 포함되어 있으면 원본 유지  
                let inRfnAnchor = false;  
                try {  
                    const ancA = node.parentElement && node.parentElement.closest ? node.parentElement.closest('a[href]') : null;  
                    const ancHref = ancA ? ancA.getAttribute('href') || '' : '';  
                    if (ancHref && ancHref.includes(`rfn-${num}`)) {  
                        inRfnAnchor = true;  
                    }  
                } catch (e) { /* ignore */ }  
      
                if (inRfnAnchor) {  
                    frag.appendChild(document.createTextNode(part)); // 원본 [num] 유지  
                    continue;  
                }  
      
                // --- (2) fn 요소 탐색 (containerRoot 우선, rfn 제외) ---  
                let el = null;  
                try {  
                    el = containerRoot.querySelector(`[data-fn-num="${num}"]`);  
                    if (!el) el = containerRoot.querySelector(`a[href*="fn-${num}"]:not([href*="rfn-${num}"])`);  
                    if (!el) el = containerRoot.querySelector(`[data-fn-title*="${num}"], a[title*="${num}"]`);  
                } catch (e) { el = null; }  
      
                // containerRoot에서 못 찾았으면 document 수준에서 한 번 더 안전 검색 (rfn 제외)  
                if (!el) {  
                    try {  
                        el = document.querySelector(`a[href*="fn-${num}"]:not([href*="rfn-"])`) || document.querySelector(`[data-fn-num="${num}"]`);  
                    } catch (e) { el = null; }  
                }  
      
                // --- (3) replacement HTML 찾기 (title 범위 추출 또는 collectedHtmlMap 사용 등) ---  
                let html = null;  
                let titleForFind = null;  
      
                if (el) titleForFind = el.getAttribute('data-fn-title') || el.getAttribute('title') || null;  
      
                if (titleForFind) {  
                    const range = findRangeByText(document.body, titleForFind);  
                    if (range) {  
                        const div = document.createElement('div');  
                        div.appendChild(range.cloneContents());  
                        html = div.innerHTML.trim();  
                    }  
                }  
      
                // collectedHtmlMap에 이미 있는 경우 사용  
                if (!html) {  
                    try {  
                        const foundItem = [...collected].find(it => it.num === num && collectedHtmlMap.has(it.el));  
                        if (foundItem) html = collectedHtmlMap.get(foundItem.el);  
                    } catch (e) { /* ignore */ }  
                }  
      
                // el 자체가 있고 아직 html 못 찾았으면 그 요소의 innerHTML을 사용  
                if (!html && el) {  
                    try {  
                        const clone = el.cloneNode(true);  
                        clone.querySelectorAll('.wiki-edit,.footnote-backref,.editsection').forEach(x => x.remove());  
                        html = clone.innerHTML.trim();  
                    } catch (e) { /* ignore */ }  
                }  
      
                // --- (4) 최종 처리: html 있으면 치환, 없으면 원본 유지 ---  
                if (!html) {  
                    frag.appendChild(document.createTextNode(part)); // 못 찾았으면 원본 [n]  
                } else {  
                    const span = document.createElement('span');  
                    span.innerHTML = '[' + html + ']';  
                    frag.appendChild(span);  
                }  
            }  
      
            // 원래 텍스트 노드를 치환  
            node.parentNode.replaceChild(frag, node);  
            return;  
        }  
      
        // 요소 노드인 경우  
        if (node.nodeType === Node.ELEMENT_NODE) {  
            // --- 만약 이 요소가 rfn 링크(a[href*="rfn-숫자"])이면 내부 건드리지 않음 ---  
            if (node.tagName === 'A') {  
                try {  
                    const href = node.getAttribute('href') || '';  
                    if (href.includes('rfn-')) return; // rfn 앵커는 건너뛴다 (원본 유지)  
                } catch (e) { /* ignore */ }  
            }  
      
            // 그 외 요소는 자식들 재귀 처리 (live NodeList 문제 방지로 배열화)  
            Array.from(node.childNodes).forEach(child => replaceNotesInNodeHtml(child, containerRoot));  
        }  
    }  
      
    function fixLinksToAbsolute(container) {
        // 기존 링크 절대경로 처리
        const anchors = container.querySelectorAll('a[href]');
        anchors.forEach(a => {
            const href = a.getAttribute('href');
            if (!href) return;
    
            if (href.startsWith('#') && !href.match(/^#([a-zA-Z0-9]*-)?fn-\d+$/)) {
                try { a.setAttribute('href', new URL(href, location.origin).href); } catch (e) {}
            } else if (!href.match(/^https?:\/\//i) && !href.startsWith('mailto:')) {
                try { a.setAttribute('href', new URL(href, location.origin).href); } catch (e) {}
            }
        });
    
        // 🔽 추가: 이미지 src 절대경로 처리
        const imgs = container.querySelectorAll('img[src]');
        imgs.forEach(img => {
            const src = img.getAttribute('src');
            if (!src) return;
            if (!src.match(/^https?:\/\//i) && !src.startsWith('data:')) {
                try { img.setAttribute('src', new URL(src, location.origin).href); } catch (e) {}
            }
        });
    }
        
    async function writeClipboard(plain, html) {  
        try {  
            if (navigator.clipboard && window.ClipboardItem) {  
                await navigator.clipboard.write([  
                    new ClipboardItem({  
                        'text/plain': new Blob([plain]),  
                        'text/html': new Blob([html || plain])  
                    })  
                ]);  
                return true;  
            }  
        } catch (e) {}  
  
        try {  
            if (typeof GM_setClipboard === 'function') { GM_setClipboard(plain); return true; }  
        } catch (e) {}  
  
        try { return !!document.execCommand && document.execCommand('copy'); } catch (e) { return false; }  
    }  
  
    async function copyNotesAsync() {  
        const sel = window.getSelection();  
        let plain = '';  
        let html = '';  
  
        if (sel && sel.toString().trim().length > 0) {  
            plain = sel.toString();  
            const range = sel.getRangeAt(0);  
            const cloned = range.cloneContents();  
            const container = document.createElement('div');  
            container.appendChild(cloned);  
  
            cleanNoteAnchors(container);  
            replaceNotesInNodeHtml(container, container);  
            fixLinksToAbsolute(container);  
  
            html = container.innerHTML;  
        } else {  
            plain = collected.map(c => `[${c.num}] ${c.title}`).join('\n');  
            html = '';  
            collected.forEach(c => {  
                const h = collectedHtmlMap.get(c.el);  
                html += `<div><strong>[${c.num}]</strong> ${h || escapeHtml(c.title)}</div>`;  
            });  
        }  
  
        if (!plain) return false;  
        return await writeClipboard(plain, html);  
    }  
  
    function handleCopyClick(button) {  
        isCopyButtonClick = true;  
        copyNotesAsync().then(success => {  
            if (success) {  
                const original = button.innerText;  
                button.innerText = '복사됨!';  
                setTimeout(() => { button.innerText = original; isCopyButtonClick = false; }, 1300);  
            } else {  
                isCopyButtonClick = false;  
                alert('복사 실패: 브라우저 권한 또는 환경 문제일 수 있습니다.');  
            }  
        });  
    }  
  
    document.addEventListener('copy', (e) => {  
        try {  
            if (isCopyButtonClick) return;  
            if (panel && panel.style.display !== 'none') return;  
  
            const sel = window.getSelection();  
            if (!sel || sel.rangeCount === 0) return;  
  
            const range = sel.getRangeAt(0);  
            const cloned = range.cloneContents();  
            const container = document.createElement('div');  
            container.appendChild(cloned);  
  
            cleanNoteAnchors(container);  
            replaceNotesInNodeHtml(container, container);  
            fixLinksToAbsolute(container);  
  
            e.preventDefault();  
            e.clipboardData.setData('text/html', container.innerHTML);  
            e.clipboardData.setData('text/plain', sel.toString());  
        } catch (err) { console.warn('copy handler error', err); }  
    });  
  
    function onUrlChange(callback) {  
        window.addEventListener('popstate', callback);  
        const pushState = history.pushState;  
        history.pushState = function (...args) { const res = pushState.apply(this, args); callback(); return res; };  
        const replaceState = history.replaceState;  
        history.replaceState = function (...args) { const res = replaceState.apply(this, args); callback(); return res; };  
    }  
  
    createUI();  
    collectAndUpdate();  
    const observer = new MutationObserver(() => { collectAndUpdate(); });  
    observer.observe(document.body, { childList: true, subtree: true });  
    onUrlChange(() => { setTimeout(() => collectAndUpdate(), 500); });  
  
})();