您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
주석 번호와 설명을 복사할 수 있는 패널을 작고 세련된 UI로 제공하며, 페이지 내용과 URL 변경 시 자동 갱신, 복사 시 선택된 텍스트에 주석 내용 HTML을 보존한 형태로 자동 덧붙임 기능 포함.
// ==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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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); }); })();