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