From 92726b1c08bf84fb2404c18473ce5ec5ae57675f Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Wed, 8 Jul 2026 12:37:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(comment-cards):=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=88=98=EC=A7=91=20+=20=EC=9D=B8?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=B6=84=EC=84=9D=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수집 상한 1,000→20,000개(안전 상한)로 상향해 전체 댓글 기준 분석 - 분석 패널: 많이 언급된 타임라인(클릭 시 해당 시점 유튜브 이동), 자주 나온 단어(조사 제거+불용어, 좋아요 가중 토글), 요약 통계(총/평균 좋아요·참여자·최다 좋아요 댓글·월별 댓글량) - 모자이크 기본 ON, '모서리 둥글게' 토글 추가(기본 각진 모서리) - 카드 렌더는 상위 2,000개로 제한(브라우저 보호), 분석은 전체 기준 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/YoutubeCommentService.java | 7 +- src/main/resources/static/js/comment-cards.js | 250 +++++++++++++++++- .../resources/templates/comment-cards.html | 45 +++- 3 files changed, 283 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java b/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java index 3a27318..9e8afd5 100644 --- a/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java +++ b/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java @@ -28,8 +28,11 @@ public class YoutubeCommentService { @Value("${youtube.api.key}") private String youtubeApiKey; - /** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개 → 최대 1,000개). */ - private static final int MAX_PAGES = 10; + /** + * 전체 댓글을 수집하되, 폭주(초대형 영상)·쿼터 보호용 안전 상한. + * 페이지당 최대 100개 → 최대 20,000개. 대부분 영상은 이 이전에 nextPageToken이 끝나 전체 수집됨. + */ + private static final int MAX_PAGES = 200; private static final ObjectMapper MAPPER = new ObjectMapper(); diff --git a/src/main/resources/static/js/comment-cards.js b/src/main/resources/static/js/comment-cards.js index 5b9f7e9..fbaeb8a 100644 --- a/src/main/resources/static/js/comment-cards.js +++ b/src/main/resources/static/js/comment-cards.js @@ -1,9 +1,20 @@ (function () { - window.__cards = []; - let mosaicOn = false; + window.__all = []; // 전체 수집 댓글(분석 기준) + window.__cards = []; // 호환용 별칭 + window.__videoId = null; // 타임라인 링크용 + let mosaicOn = true; // 모자이크 기본 ON + let roundedOn = false; // 모서리 둥글게 토글(기본: 각진 모서리) + + 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(); @@ -24,6 +35,20 @@ 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 applyFilterSort(cards) { const sort = $('ccSort').value; const minLikes = parseInt($('ccMinLikes').value, 10) || 0; @@ -42,12 +67,18 @@ window.renderCards = function () { const grid = $('cardGrid'); - const list = applyFilterSort(window.__cards); - $('ccCount').textContent = list.length + '개'; + let list = applyFilterSort(window.__all); + 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.innerHTML = ''; list.forEach((c, i) => { const card = document.createElement('div'); - card.className = 'comment-card' + (mosaicOn ? ' mosaic' : ''); + card.className = 'comment-card' + + (mosaicOn ? ' mosaic' : '') + + (roundedOn ? ' rounded' : ''); card.dataset.index = i; const avatarSrc = c.profileImageUrl @@ -90,12 +121,196 @@ 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'); + if (!all || !all.length) { box.style.display = 'none'; box.innerHTML = ''; return; } + + const weighted = !!($('ccWeighted') && $('ccWeighted').checked); + const stats = computeStats(all); + const tl = computeTimeline(all); + const words = computeWords(all, weighted); + const vid = window.__videoId; + + let html = '
📊 분석 ' + + '전체 ' + all.length.toLocaleString() + '개 댓글 기준' + + '
' + + '
'; + + 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)); + } + + /* ------------------------- 캡처/복사/저장 ------------------------- */ + async function captureBlob(cardEl) { - // modern-screenshot UMD 전역: window.modernScreenshot const ms = window.modernScreenshot; if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패'); - // backgroundColor:null → 카드 바깥 투명. scale 2 → 또렷하게. - // filter: .cc-noexport(저장/액션 버튼)는 캡처 이미지에서 제외 return await ms.domToBlob(cardEl, { backgroundColor: null, scale: 2, @@ -178,9 +393,10 @@ 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 = ''; + btn.disabled = true; btn.textContent = '수집 중…'; + $('ccEmpty').textContent = '전체 댓글을 모으는 중입니다. 댓글이 많으면 시간이 걸릴 수 있어요…'; try { const res = await fetch('/api/comment-cards/fetch', { method: 'POST', @@ -189,12 +405,15 @@ }); const json = await res.json(); if (!json.success) throw new Error(json.message || '가져오기 실패'); - window.__cards = json.data || []; - $('ccFilters').style.display = window.__cards.length ? 'flex' : 'none'; - $('ccEmpty').textContent = window.__cards.length ? '' : '댓글이 없습니다.'; + 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 { @@ -212,5 +431,10 @@ $('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)); + }); }); })(); diff --git a/src/main/resources/templates/comment-cards.html b/src/main/resources/templates/comment-cards.html index 5441663..9226d2f 100644 --- a/src/main/resources/templates/comment-cards.html +++ b/src/main/resources/templates/comment-cards.html @@ -16,9 +16,11 @@ /* 메이슨리(다단): 카드가 내용 높이에 딱 맞게 쌓여 짧은 글 아래 흰 여백이 안 생김 */ .cc-grid { column-width:320px; column-gap:1rem; } /* 카드: 영상 위에 얹을 소스 → 투명 캡처 기준. 미리보기는 surface 배경. */ - .comment-card { background:var(--surface); border:1px solid var(--border); border-radius:12px; + /* 기본은 각진 모서리, '모서리 둥글게' 토글 시 .rounded 로 살짝 라운드 */ + .comment-card { background:var(--surface); border:1px solid var(--border); border-radius:4px; padding:1rem; position:relative; break-inside:avoid; -webkit-column-break-inside:avoid; margin-bottom:1rem; } + .comment-card.rounded { border-radius:16px; } .cc-head { display:flex; gap:.6rem; align-items:flex-start; } .cc-avatar { width:40px; height:40px; border-radius:50%; flex-shrink:0; object-fit:cover; background:var(--surface-2); } .cc-meta { flex:1; min-width:0; } @@ -41,13 +43,45 @@ .comment-card.mosaic .cc-avatar { filter: blur(6px); } .comment-card.mosaic .cc-author { filter: blur(5px); } .cc-empty { color:var(--text-3); padding:2rem 0; } + + /* ---- 분석 패널 ---- */ + .cc-analytics { background:var(--surface); border:1px solid var(--border); border-radius:12px; + padding:1rem 1.1rem; margin-bottom:1.2rem; } + .cc-an-head { display:flex; align-items:center; gap:.6rem; margin-bottom:.9rem; font-size:15px; } + .cc-an-sub { color:var(--text-3); font-size:12.5px; font-weight:400; } + .cc-an-head .cc-iconbtn { margin-left:auto; } + .cc-tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:.6rem; margin-bottom:1rem; } + .cc-tile { background:var(--surface-2); border:1px solid var(--border); border-radius:10px; padding:.6rem .8rem; } + .cc-tile-v { font-size:1.25rem; font-weight:700; color:var(--text); line-height:1.2; } + .cc-tile-l { font-size:11.5px; color:var(--text-3); margin-top:.15rem; } + .cc-an-sec { margin-top:1rem; } + .cc-an-sec h4 { font-size:13.5px; font-weight:600; color:var(--text-2); margin:0 0 .5rem; } + .cc-an-sechead { display:flex; align-items:center; justify-content:space-between; gap:.6rem; } + .cc-wlabel { font-size:12px; color:var(--text-2); display:flex; align-items:center; gap:.3rem; cursor:pointer; } + .cc-an-empty { color:var(--text-3); font-size:13px; margin:.2rem 0; } + .cc-bars { display:flex; flex-direction:column; gap:.35rem; } + .cc-bar-row { display:grid; grid-template-columns:76px 1fr 52px; align-items:center; gap:.5rem; font-size:12.5px; } + .cc-bar-label { color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .cc-bar-track { background:var(--surface-2); border-radius:999px; height:9px; overflow:hidden; } + .cc-bar-fill { display:block; height:100%; background:var(--primary-gradient); border-radius:999px; } + .cc-bar-val { color:var(--text-3); text-align:right; } + a.cc-tl-link { text-decoration:none; } + a.cc-tl-link:hover .cc-bar-label { text-decoration:underline; color:var(--primary,#6366f1); } + .cc-top-comment { background:var(--surface-2); border:1px solid var(--border); border-radius:10px; + padding:.6rem .8rem; font-size:12.5px; color:var(--text-2); } + .cc-top-text { color:var(--text); margin-top:.3rem; white-space:pre-wrap; word-break:break-word; } + .cc-months { display:flex; align-items:flex-end; gap:.35rem; height:92px; overflow-x:auto; padding-top:.3rem; } + .cc-mo { display:flex; flex-direction:column; align-items:center; justify-content:flex-end; gap:.25rem; min-width:26px; } + .cc-mo-bar { display:block; width:16px; background:var(--primary-gradient); border-radius:4px 4px 0 0; } + .cc-mo-lb { font-size:10px; color:var(--text-3); white-space:nowrap; }

댓글 카드

- 유튜브 링크의 댓글을 가져와 카드로 만들고, 프로필·아이디를 모자이크하세요. 카드 클릭=이미지 복사, ⬇ 저장=PNG 파일 다운로드(캡컷은 클립보드 붙여넣기가 안 되니 저장한 파일을 "불러오기" 하세요). + 유튜브 링크의 전체 댓글을 가져와 분석(많이 언급된 타임라인·자주 나온 단어·요약 통계)하고 카드로 만듭니다. + 모자이크는 기본 켜짐, 모서리 둥글게로 각진/라운드 전환. 카드 클릭=이미지 복사, ⬇ 저장=PNG 파일 다운로드(캡컷은 붙여넣기가 안 되니 저장한 파일을 "불러오기" 하세요).

@@ -66,10 +100,13 @@ 이상 - + +
+ +
@@ -77,7 +114,7 @@ - +