';
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 '