(function () { window.__all = []; // 전체 수집 댓글(분석 기준) window.__cards = []; // 호환용 별칭 window.__videoId = null; // 타임라인 링크용 let mosaicOn = true; // 프로필·아이디 모자이크 기본 ON let roundedOn = false; // 모서리 둥글게 토글(기본: 각진 모서리) let wordMaskOn = true; // 단어 가리기 기본 ON(단어 클릭=가림). 끄면 카드 클릭=복사. const DISPLAY_CAP = 2000; // 카드 DOM 렌더 상한(브라우저 보호). 분석은 전체 기준. const $ = (id) => document.getElementById(id); function esc(s) { const d = document.createElement('div'); d.textContent = String(s == null ? '' : s); return d.innerHTML; } function timeAgo(iso) { if (!iso) return ''; const then = new Date(iso).getTime(); if (isNaN(then)) return ''; const sec = Math.floor((Date.now() - then) / 1000); const units = [['년', 31536000], ['개월', 2592000], ['일', 86400], ['시간', 3600], ['분', 60]]; for (const [label, s] of units) { const v = Math.floor(sec / s); if (v >= 1) return v + label + ' 전'; } return '방금 전'; } // YouTube textDisplay(HTML) → 안전한 평문 (태그 제거,
→줄바꿈) function toPlainText(html) { const tmp = document.createElement('div'); tmp.innerHTML = String(html).replace(//gi, '\n'); return tmp.textContent || ''; } // URL/ID → videoId (백엔드 YoutubeVideoIdParser 미러) function parseVideoId(s) { s = (s || '').trim(); if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s; const pats = [ /[?&]v=([A-Za-z0-9_-]{11})/, /youtu\.be\/([A-Za-z0-9_-]{11})/, /\/shorts\/([A-Za-z0-9_-]{11})/, /\/embed\/([A-Za-z0-9_-]{11})/, ]; for (const p of pats) { const m = s.match(p); if (m) return m[1]; } return null; } // 제외 단어: 쉼표/공백으로 구분. 하나라도 포함된 댓글은 카드·분석 모두에서 제외. function getExcludeTerms() { const raw = ($('ccExclude') && $('ccExclude').value) || ''; return raw.split(/[,\s]+/).map(s => s.trim().toLowerCase()).filter(Boolean); } function excludeComments(list, terms) { if (!terms || !terms.length) return list; return list.filter(c => { const t = toPlainText(c.text).toLowerCase(); return !terms.some(term => t.includes(term)); }); } function debounce(fn, ms) { let h = null; return function () { clearTimeout(h); h = setTimeout(fn, ms); }; } // 댓글 텍스트를 단어 span으로 분해(공백/줄바꿈 보존). 단어 가리기 ON일 때 클릭하면 해당 단어만 블러. function buildWords(el, plain) { el.textContent = ''; for (const p of plain.split(/(\s+)/)) { if (!p) continue; if (/^\s+$/.test(p)) { el.appendChild(document.createTextNode(p)); continue; } const s = document.createElement('span'); s.className = 'cc-word'; s.textContent = p; s.addEventListener('click', (e) => { if (!wordMaskOn) return; // 꺼짐 모드: 무시 → 이벤트 버블 → 카드 클릭=복사 e.stopPropagation(); s.classList.toggle('masked'); }); el.appendChild(s); } } function applyFilterSort(cards) { const sort = $('ccSort').value; const minLikes = parseInt($('ccMinLikes').value, 10) || 0; const repliesOnly = $('ccRepliesOnly').checked; const q = ($('ccSearch').value || '').trim().toLowerCase(); let list = cards.filter(c => (c.likeCount || 0) >= minLikes && (!repliesOnly || (c.replyCount || 0) > 0) && (!q || toPlainText(c.text).toLowerCase().includes(q)) // 내용에만 매칭 ); if (sort === 'likes') list.sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0)); else if (sort === 'replies') list.sort((a, b) => (b.replyCount || 0) - (a.replyCount || 0)); else if (sort === 'latest') list.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); return list; } window.renderCards = function () { const grid = $('cardGrid'); const base = excludeComments(window.__all, getExcludeTerms()); let list = applyFilterSort(base); const total = list.length; const capped = total > DISPLAY_CAP; if (capped) list = list.slice(0, DISPLAY_CAP); $('ccCount').textContent = total.toLocaleString() + '개' + (capped ? ' (상위 ' + DISPLAY_CAP.toLocaleString() + '개 표시)' : ''); grid.classList.toggle('wordmask', wordMaskOn); grid.innerHTML = ''; list.forEach((c, i) => { const card = document.createElement('div'); card.className = 'comment-card' + (mosaicOn ? ' mosaic' : '') + (roundedOn ? ' rounded' : ''); card.dataset.index = i; const avatarSrc = c.profileImageUrl ? '/api/comment-cards/avatar?url=' + encodeURIComponent(c.profileImageUrl) : ''; const head = document.createElement('div'); head.className = 'cc-head'; head.innerHTML = '' + '
' + '
' + '
' + '
👍 ' + (c.likeCount || 0).toLocaleString() + '' + '💬 ' + (c.replyCount || 0).toLocaleString() + '
' + '
'; head.querySelector('.cc-author').textContent = c.authorName || ''; head.querySelector('.cc-time').textContent = timeAgo(c.publishedAt); buildWords(head.querySelector('.cc-text'), toPlainText(c.text)); // 단어 가리기 ON: 카드 클릭 복사 잠금(단어 클릭으로 가림). OFF: 카드 클릭=복사. card.title = '단어 클릭 = 가리기 · 토글을 끄면 카드 클릭 = 복사'; card.addEventListener('click', () => { if (wordMaskOn) return; window.copyCard(card); }); // 저장(다운로드) 버튼 — cc-noexport라 캡처 이미지엔 안 찍힘. 캡컷엔 이 파일을 불러오기. const actions = document.createElement('div'); actions.className = 'cc-actions cc-noexport'; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'cc-iconbtn'; saveBtn.textContent = '⬇ 저장'; saveBtn.title = 'PNG 파일로 저장 (캡컷에서 불러오기)'; saveBtn.addEventListener('click', (e) => { e.stopPropagation(); window.downloadCard(card, c); }); actions.appendChild(saveBtn); card.appendChild(head); card.appendChild(actions); grid.appendChild(card); }); if (window.lucide) window.lucide.createIcons(); }; /* --------------------------------------------------------------------- * 분석 (전부 클라이언트 계산 — 추가 API 쿼터 0) * ------------------------------------------------------------------- */ const KO_STOP = new Set(('그리고 그런데 그래서 하지만 그러나 그냥 정말 진짜 너무 완전 이거 저거 그거 이건 저건 그건 근데 ' + '이렇게 저렇게 그렇게 하는 하고 해서 있는 있고 없는 같아요 같은 같다 되게 무슨 어떤 이런 저런 그런 여기 저기 거기 ' + '우리 저는 제가 요즘 지금 오늘 내가 니가 네가 저도 나도 이제 그리 그럼 아니 아마 많이 조금 그때 이때 대한 위해 ' + '부분 사람 생각 때문 정도 그게 이게 저게 그건데 뭔가 거의 계속 진짜로 그거는').split(/\s+/)); const EN_STOP = new Set(('the a an and or but if is are was were be been to of in on for it this that with you your my ' + 'me we he she they i so just not no yes at as by from about into out up down all can will do does did have has had ' + 'im its lol').split(/\s+/)); // 조사 꼬리 제거(휴리스틱) — 긴 것부터 const JOSA = ['으로써', '으로서', '에서는', '에게서', '이라는', '이라고', '으로', '로써', '로서', '에서', '에게', '한테', '까지', '부터', '조차', '마저', '밖에', '처럼', '만큼', '보다', '이나', '이랑', '라는', '라고', '다는', '에는', '에도', '든지', '이며', '이고', '은', '는', '이', '가', '을', '를', '에', '의', '도', '로', '과', '와', '만', '랑', '님', '들'] .sort((a, b) => b.length - a.length); function stripJosa(w) { for (const j of JOSA) { if (w.length > j.length + 1 && w.endsWith(j)) return w.slice(0, -j.length); } return w; } function tokenize(text) { const t = toPlainText(text).toLowerCase().replace(/https?:\/\/\S+/g, ' '); const raw = t.replace(/[^\p{L}\p{N}\sㄱ-ㅎㅏ-ㅣ]/gu, ' ').split(/\s+/); const out = []; for (let w of raw) { if (!w) continue; if (/^[0-9]+$/.test(w)) continue; // 순수 숫자 제외 if (/[가-힣]/.test(w)) w = stripJosa(w); // 한글 어절이면 조사 제거 if (w.length < 2) continue; if (KO_STOP.has(w) || EN_STOP.has(w)) continue; out.push(w); } return out; } function computeWords(all, weighted) { const map = new Map(); for (const c of all) { const wt = weighted ? (1 + (c.likeCount || 0)) : 1; for (const w of tokenize(c.text)) map.set(w, (map.get(w) || 0) + wt); } return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20); } // 댓글 속 타임스탬프(mm:ss / h:mm:ss) 추출 const TS_RE = /(? b[1] - a[1]).slice(0, 15) .map(([sec, cnt]) => ({ sec, cnt, label: fmtTime(sec) })); } function computeStats(all) { let likes = 0, max = null; const byAuthor = new Map(), byMonth = new Map(); for (const c of all) { likes += c.likeCount || 0; if (!max || (c.likeCount || 0) > (max.likeCount || 0)) max = c; byAuthor.set(c.authorName, (byAuthor.get(c.authorName) || 0) + 1); const mo = (c.publishedAt || '').slice(0, 7); if (mo) byMonth.set(mo, (byMonth.get(mo) || 0) + 1); } const topAuthor = [...byAuthor.entries()].sort((a, b) => b[1] - a[1])[0]; const months = [...byMonth.entries()].sort((a, b) => a[0].localeCompare(b[0])); return { count: all.length, likes, avg: all.length ? likes / all.length : 0, max, authors: byAuthor.size, topAuthor, months, }; } function tile(l, v) { return '
' + esc(v) + '
' + esc(l) + '
'; } function bar(label, val, max, suffix) { const pct = max ? Math.max(4, Math.round(val / max * 100)) : 0; return '
' + '' + esc(label) + '' + '' + '' + esc(val.toLocaleString()) + (suffix || '') + '
'; } function renderAnalytics(all) { const box = $('ccAnalytics'); const exTerms = getExcludeTerms(); all = excludeComments(all || [], exTerms); if (!all.length) { box.style.display = 'none'; box.innerHTML = ''; return; } const weighted = !!($('ccWeighted') && $('ccWeighted').checked); const tlSort = ($('ccTlSort') && $('ccTlSort').value) || 'count'; const stats = computeStats(all); let tl = computeTimeline(all); // 언급 많은 순 top-N 선별 if (tlSort === 'time') tl = tl.slice().sort((a, b) => a.sec - b.sec); // 선별본을 시간순 재정렬 const words = computeWords(all, weighted); const vid = window.__videoId; let html = '
📊 분석 ' + '' + all.length.toLocaleString() + '개 댓글 기준' + (exTerms.length ? ' · 제외어 적용' : '') + '' + '
' + '
'; html += '
' + tile('총 댓글', stats.count.toLocaleString()) + tile('총 좋아요', stats.likes.toLocaleString()) + tile('평균 좋아요', stats.avg.toFixed(1)) + tile('참여자', stats.authors.toLocaleString() + '명') + '
'; // 타임라인 html += '

⏱ 많이 언급된 타임라인

' + '
'; if (tl.length) { const mx = tl[0].cnt; html += '
' + tl.map(t => { const inner = bar(t.label, t.cnt, mx, '회'); return vid ? '' + inner + '' : inner; }).join('') + '
'; } else { html += '

댓글에 타임스탬프 언급이 없습니다.

'; } html += '
'; // 단어 html += '

🔤 자주 나온 단어

' + '
'; if (words.length) { const mx = words[0][1]; html += '
' + words.map(([w, n]) => bar(w, Math.round(n), mx)).join('') + '
'; } else { html += '

분석할 단어가 부족합니다.

'; } html += '
'; // 최다 좋아요 댓글 if (stats.max) { html += '

🏆 최다 좋아요 댓글

' + '
' + esc(stats.max.authorName) + ' · 👍 ' + (stats.max.likeCount || 0).toLocaleString() + '
' + esc(toPlainText(stats.max.text).slice(0, 200)) + '
'; } // 월별 댓글량 if (stats.months.length > 1) { const mx = Math.max(...stats.months.map(m => m[1])); html += '

📅 월별 댓글량

' + stats.months.slice(-18).map(([mo, n]) => '
' + '' + '' + esc(mo.slice(2)) + '
').join('') + '
'; } html += '
'; // #ccAnBody box.innerHTML = html; box.style.display = 'block'; const tgl = $('ccAnToggle'); if (tgl) tgl.addEventListener('click', () => { const b = $('ccAnBody'); const hidden = b.style.display === 'none'; b.style.display = hidden ? '' : 'none'; tgl.textContent = hidden ? '접기' : '펼치기'; }); const wt = $('ccWeighted'); if (wt) wt.addEventListener('change', () => renderAnalytics(window.__all)); const tls = $('ccTlSort'); if (tls) tls.addEventListener('change', () => renderAnalytics(window.__all)); } /* ------------------------- 캡처/복사/저장 ------------------------- */ async function captureBlob(cardEl) { const ms = window.modernScreenshot; if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패'); return await ms.domToBlob(cardEl, { backgroundColor: null, scale: 2, filter: (node) => !(node instanceof Element && node.classList.contains('cc-noexport')), }); } function downloadBlob(blob) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'comment-card.png'; a.click(); URL.revokeObjectURL(a.href); } let toastTimer = null; function showToast(msg) { let t = document.getElementById('ccToast'); if (!t) { t = document.createElement('div'); t.id = 'ccToast'; t.className = 'cc-toast'; document.body.appendChild(t); } t.textContent = msg; t.classList.add('show'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 1500); } function buildFileName(c) { var who = (c && c.authorName) ? c.authorName.replace(/[^\w가-힣]+/g, '').slice(0, 20) : 'card'; return 'comment-' + (who || 'card') + '.png'; } window.downloadCard = async function (cardEl, c) { if (cardEl.dataset.busy === '1') return; cardEl.dataset.busy = '1'; try { const blob = await captureBlob(cardEl); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = buildFileName(c); a.click(); URL.revokeObjectURL(a.href); showToast('저장됨 ⬇ (캡컷에서 불러오기)'); } catch (e) { showToast('저장 실패'); } finally { setTimeout(() => { delete cardEl.dataset.busy; }, 600); } }; window.copyCard = async function (cardEl) { if (cardEl.dataset.copying === '1') return; // 중복 클릭 방지 cardEl.dataset.copying = '1'; try { const blob = await captureBlob(cardEl); if (navigator.clipboard && window.ClipboardItem) { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); showToast('복사됨 ✓'); } else { downloadBlob(blob); showToast('다운로드됨'); } } catch (e) { // 클립보드 권한 거부 등 → 다운로드 폴백 try { const blob = await captureBlob(cardEl); downloadBlob(blob); showToast('다운로드됨'); } catch (e2) { showToast('복사 실패'); } } finally { setTimeout(() => { delete cardEl.dataset.copying; }, 600); } }; async function fetchComments() { const url = $('ccUrl').value.trim(); if (!url) return; window.__videoId = parseVideoId(url); const btn = $('ccFetch'); btn.disabled = true; btn.textContent = '수집 중…'; $('ccEmpty').textContent = '전체 댓글을 모으는 중입니다. 댓글이 많으면 시간이 걸릴 수 있어요…'; try { const res = await fetch('/api/comment-cards/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); const json = await res.json(); if (!json.success) throw new Error(json.message || '가져오기 실패'); window.__all = json.data || []; window.__cards = window.__all; $('ccFilters').style.display = window.__all.length ? 'flex' : 'none'; $('ccEmpty').textContent = window.__all.length ? '' : '댓글이 없습니다.'; renderAnalytics(window.__all); window.renderCards(); } catch (e) { $('ccFilters').style.display = 'none'; $('ccAnalytics').style.display = 'none'; $('cardGrid').innerHTML = ''; $('ccEmpty').textContent = '⚠️ ' + e.message; } finally { btn.disabled = false; btn.textContent = '가져오기'; } } document.addEventListener('DOMContentLoaded', () => { $('ccFetch').addEventListener('click', fetchComments); $('ccUrl').addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchComments(); }); ['ccSearch', 'ccSort', 'ccMinLikes', 'ccRepliesOnly'].forEach(id => $(id).addEventListener('input', () => window.renderCards())); // 제외 단어는 카드+분석 둘 다 갱신. 대량 댓글 대비 디바운스. $('ccExclude').addEventListener('input', debounce(() => { window.renderCards(); renderAnalytics(window.__all); }, 250)); $('ccMosaic').addEventListener('click', () => { mosaicOn = !mosaicOn; $('ccMosaic').textContent = mosaicOn ? '모자이크 해제' : '전체 모자이크'; document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('mosaic', mosaicOn)); }); $('ccRounded').addEventListener('click', () => { roundedOn = !roundedOn; $('ccRounded').textContent = roundedOn ? '모서리 각지게' : '모서리 둥글게'; document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('rounded', roundedOn)); }); $('ccWordMask').addEventListener('click', () => { wordMaskOn = !wordMaskOn; $('ccWordMask').textContent = wordMaskOn ? '🖍 단어 가리기: 켜짐' : '🖍 단어 가리기: 꺼짐(복사)'; $('cardGrid').classList.toggle('wordmask', wordMaskOn); }); }); })();