feat(comment-cards): 전체 댓글 수집 + 인사이트 분석 패널 추가

- 수집 상한 1,000→20,000개(안전 상한)로 상향해 전체 댓글 기준 분석
- 분석 패널: 많이 언급된 타임라인(클릭 시 해당 시점 유튜브 이동), 자주 나온 단어(조사 제거+불용어, 좋아요 가중 토글), 요약 통계(총/평균 좋아요·참여자·최다 좋아요 댓글·월별 댓글량)
- 모자이크 기본 ON, '모서리 둥글게' 토글 추가(기본 각진 모서리)
- 카드 렌더는 상위 2,000개로 제한(브라우저 보호), 분석은 전체 기준

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-07-08 12:37:15 +09:00
parent b1b23a45f2
commit 92726b1c08
3 changed files with 283 additions and 19 deletions

View File

@ -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();

View File

@ -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 = /(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)/g;
function fmtTime(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(ss).padStart(2, '0');
}
function computeTimeline(all) {
const map = new Map();
for (const c of all) {
const txt = toPlainText(c.text);
let m; TS_RE.lastIndex = 0;
const seen = new Set(); // 한 댓글 내 같은 시각 중복 제거
while ((m = TS_RE.exec(txt))) {
const sec = m[3] !== undefined
? (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3])
: (+m[1]) * 60 + (+m[2]);
if (seen.has(sec)) continue; seen.add(sec);
map.set(sec, (map.get(sec) || 0) + 1);
}
}
return [...map.entries()]
.sort((a, b) => 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 '<div class="cc-tile"><div class="cc-tile-v">' + esc(v) + '</div><div class="cc-tile-l">' + esc(l) + '</div></div>';
}
function bar(label, val, max, suffix) {
const pct = max ? Math.max(4, Math.round(val / max * 100)) : 0;
return '<div class="cc-bar-row">'
+ '<span class="cc-bar-label">' + esc(label) + '</span>'
+ '<span class="cc-bar-track"><span class="cc-bar-fill" style="width:' + pct + '%"></span></span>'
+ '<span class="cc-bar-val">' + esc(val.toLocaleString()) + (suffix || '') + '</span></div>';
}
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 = '<div class="cc-an-head"><b>📊 분석</b> '
+ '<span class="cc-an-sub">전체 ' + all.length.toLocaleString() + '개 댓글 기준</span>'
+ '<button type="button" class="cc-iconbtn" id="ccAnToggle">접기</button></div>'
+ '<div id="ccAnBody">';
html += '<div class="cc-tiles">'
+ tile('총 댓글', stats.count.toLocaleString())
+ tile('총 좋아요', stats.likes.toLocaleString())
+ tile('평균 좋아요', stats.avg.toFixed(1))
+ tile('참여자', stats.authors.toLocaleString() + '명')
+ '</div>';
// 타임라인
html += '<div class="cc-an-sec"><h4>⏱ 많이 언급된 타임라인</h4>';
if (tl.length) {
const mx = tl[0].cnt;
html += '<div class="cc-bars">' + tl.map(t => {
const inner = bar(t.label, t.cnt, mx, '회');
return vid
? '<a class="cc-tl-link" href="https://www.youtube.com/watch?v=' + encodeURIComponent(vid) + '&t=' + t.sec + 's" target="_blank" rel="noopener">' + inner + '</a>'
: inner;
}).join('') + '</div>';
} else {
html += '<p class="cc-an-empty">댓글에 타임스탬프 언급이 없습니다.</p>';
}
html += '</div>';
// 단어
html += '<div class="cc-an-sec"><div class="cc-an-sechead"><h4>🔤 자주 나온 단어</h4>'
+ '<label class="cc-wlabel"><input type="checkbox" id="ccWeighted"' + (weighted ? ' checked' : '') + '> 좋아요 가중</label></div>';
if (words.length) {
const mx = words[0][1];
html += '<div class="cc-bars">' + words.map(([w, n]) => bar(w, Math.round(n), mx)).join('') + '</div>';
} else {
html += '<p class="cc-an-empty">분석할 단어가 부족합니다.</p>';
}
html += '</div>';
// 최다 좋아요 댓글
if (stats.max) {
html += '<div class="cc-an-sec"><h4>🏆 최다 좋아요 댓글</h4>'
+ '<div class="cc-top-comment"><b>' + esc(stats.max.authorName) + '</b> · 👍 '
+ (stats.max.likeCount || 0).toLocaleString()
+ '<div class="cc-top-text">' + esc(toPlainText(stats.max.text).slice(0, 200)) + '</div></div></div>';
}
// 월별 댓글량
if (stats.months.length > 1) {
const mx = Math.max(...stats.months.map(m => m[1]));
html += '<div class="cc-an-sec"><h4>📅 월별 댓글량</h4><div class="cc-months">'
+ stats.months.slice(-18).map(([mo, n]) =>
'<div class="cc-mo" title="' + esc(mo) + ': ' + n + '">'
+ '<span class="cc-mo-bar" style="height:' + Math.max(6, Math.round(n / mx * 70)) + 'px"></span>'
+ '<span class="cc-mo-lb">' + esc(mo.slice(2)) + '</span></div>').join('')
+ '</div></div>';
}
html += '</div>'; // #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));
});
});
})();

View File

@ -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; }
</style>
</head>
<body>
<div layout:fragment="content">
<h1 style="font-size:1.5rem;font-weight:700;margin-bottom:.3rem;">댓글 카드</h1>
<p style="color:var(--text-3);margin-bottom:1.2rem;font-size:14px;">
유튜브 링크의 댓글을 가져와 카드로 만들고, 프로필·아이디를 모자이크하세요. <b>카드 클릭=이미지 복사</b>, <b>⬇ 저장=PNG 파일 다운로드</b>(캡컷은 클립보드 붙여넣기가 안 되니 저장한 파일을 "불러오기" 하세요).
유튜브 링크의 <b>전체 댓글</b>을 가져와 분석(많이 언급된 타임라인·자주 나온 단어·요약 통계)하고 카드로 만듭니다.
모자이크는 기본 켜짐, <b>모서리 둥글게</b>로 각진/라운드 전환. <b>카드 클릭=이미지 복사</b>, <b>⬇ 저장=PNG 파일 다운로드</b>(캡컷은 붙여넣기가 안 되니 저장한 파일을 "불러오기" 하세요).
</p>
<div class="cc-toolbar">
@ -66,10 +100,13 @@
<input type="number" id="ccMinLikes" value="0" min="0" style="width:90px;" /> 이상</label>
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:.3rem;">
<input type="checkbox" id="ccRepliesOnly" /> 답글 있는 것만</label>
<button class="cc-btn secondary" id="ccMosaic">전체 모자이크</button>
<button class="cc-btn secondary" id="ccMosaic">모자이크 해제</button>
<button class="cc-btn secondary" id="ccRounded">모서리 둥글게</button>
<span id="ccCount" style="font-size:12px;color:var(--text-3);"></span>
</div>
<div class="cc-analytics" id="ccAnalytics" style="display:none;"></div>
<div class="cc-grid" id="cardGrid"></div>
<div class="cc-empty" id="ccEmpty"></div>
</div>
@ -77,7 +114,7 @@
<th:block layout:fragment="script">
<!-- DOM→이미지 캡처 (Task 5에서 사용). blur 필터 지원 위해 modern-screenshot 사용. -->
<script src="https://cdn.jsdelivr.net/npm/modern-screenshot@4/dist/index.js"></script>
<script th:src="@{/js/comment-cards.js(v=20260702)}"></script>
<script th:src="@{/js/comment-cards.js(v=20260708)}"></script>
</th:block>
</body>
</html>