Initial commit - tokiDownloader.user.js

This commit is contained in:
user01
2026-06-13 18:33:22 +09:00
commit 528cf73a34
385 changed files with 10847 additions and 0 deletions

View File

@@ -0,0 +1,686 @@
// biome-ignore-all lint: legacy ES5 controller moved from inline JSX so soft navigation can execute it safely
(function(){
function modal(){ return document.getElementById('auth-modal'); }
function open(tab){
var m = modal(); if (!m) return;
setTab(tab || 'login');
if (typeof m.showModal === 'function') { try { m.showModal(); } catch(_) { m.setAttribute('open',''); } }
else m.setAttribute('open','');
}
function close(){
var m = modal(); if (!m) return;
if (typeof m.close === 'function') m.close();
else m.removeAttribute('open');
}
function setTab(name){
var m = modal(); if (!m) return;
m.querySelectorAll('[data-auth-tab]').forEach(function(t){
var on = t.getAttribute('data-auth-tab') === name;
t.classList.toggle('active', on);
t.setAttribute('aria-selected', on ? 'true' : 'false');
});
m.querySelectorAll('[data-auth-panel]').forEach(function(p){
p.hidden = p.getAttribute('data-auth-panel') !== name;
});
}
// URL ?auth_error=... 처리 — 페이지 첫 진입 시 한 번만
if (!window.__authErrChecked) {
window.__authErrChecked = true;
try {
var u = new URL(location.href);
var err = u.searchParams.get('auth_error');
if (err) {
var initTab = (location.hash === '#signup') ? 'signup' : 'login';
setTimeout(function(){
open(initTab);
var m = modal();
var panel = m && m.querySelector('[data-auth-panel="' + initTab + '"]');
if (panel) {
var box = document.createElement('div');
box.className = 'auth-error';
box.textContent = err;
panel.insertBefore(box, panel.firstChild);
}
}, 50);
u.searchParams.delete('auth_error');
u.hash = '';
history.replaceState(null, '', u.pathname + (u.search || ''));
}
} catch(_) {}
}
// 이메일 인증 핸들러 — signup 폼 안의 send/verify 버튼 + 이메일 변경 감지
if (!window.__emverifBound) {
window.__emverifBound = true;
function emForm(){ return document.querySelector('[data-em-form]'); }
function emEl(sel){ var f = emForm(); return f ? f.querySelector(sel) : null; }
function emStatus(text, kind){
var s = emEl('[data-em-status]'); if (!s) return;
s.textContent = text || '';
s.className = 'auth-status' + (kind ? ' is-' + kind : '');
}
function emReset(){
var code = emEl('[data-em-code]');
if (code) { code.value = ''; code.disabled = true; code.placeholder = '먼저 인증코드 받기를 눌러주세요'; }
var verify = emEl('[data-em-verify]'); if (verify) verify.disabled = true;
var submit = emEl('[data-em-submit]'); if (submit) submit.disabled = true;
window.__emailVerified = false;
window.__emailVerifiedFor = '';
emStatus('', '');
}
async function emPost(url, body){
try {
var res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
body: JSON.stringify(body),
});
var data = null;
try { data = await res.json(); } catch(_) {}
return { ok: res.ok, status: res.status, data: data || {} };
} catch (_) {
return { ok: false, status: 0, data: { error: '네트워크 오류' } };
}
}
document.addEventListener('click', async function(e){
var sendBtn = e.target.closest && e.target.closest('[data-em-send]');
if (sendBtn) {
e.preventDefault();
var emailEl = emEl('[data-em-input]');
var email = emailEl ? String(emailEl.value || '').trim().toLowerCase() : '';
if (!email) { emStatus('이메일을 입력해주세요', 'err'); return; }
// 일반 이메일 형식만 client 측에서 빠르게 검사. 허용 도메인 검증은 서버에서
// (어드민 설정 allowed_email_domains 기준) — 도메인 정책 바뀌어도 client 코드 손볼 필요 없게.
if (!/^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/.test(email)) {
emStatus('이메일 형식이 올바르지 않습니다', 'err');
return;
}
var emLocal = email.split('@')[0];
if (emLocal.indexOf('+') !== -1 || emLocal.indexOf('.') !== -1) {
emStatus("'+' 또는 '.' 가 포함된 이메일은 사용할 수 없습니다", 'err');
return;
}
sendBtn.disabled = true;
emStatus('전송 중...', '');
var r = await emPost('/api/auth/send-email-code', { email: email });
if (r.ok && r.data.ok) {
var codeIn = emEl('[data-em-code]');
if (codeIn) { codeIn.disabled = false; codeIn.placeholder = '6자리 숫자'; codeIn.focus(); }
var verifyB = emEl('[data-em-verify]'); if (verifyB) verifyB.disabled = false;
emStatus('인증 코드를 전송했습니다 (10분 안에 입력)', 'ok');
var sec = 60;
var orig = sendBtn.textContent;
var tick = function(){
if (sec <= 0) { sendBtn.disabled = false; sendBtn.textContent = orig; return; }
sendBtn.textContent = '재전송 (' + sec + ')';
sec -= 1;
setTimeout(tick, 1000);
};
tick();
} else {
sendBtn.disabled = false;
emStatus((r.data && r.data.error) || '전송 실패', 'err');
}
return;
}
// 닉네임 중복확인 버튼
var nickBtn = e.target.closest && e.target.closest('[data-nick-check]');
if (nickBtn) {
e.preventDefault();
var nickEl = emEl('[data-nick-input]');
var statusEl = emEl('[data-nick-status]');
var nickname = nickEl ? String(nickEl.value || '').trim() : '';
var setNickStatus = function(text, kind){
if (!statusEl) return;
statusEl.textContent = text || '';
statusEl.className = 'auth-status' + (kind ? ' is-' + kind : '');
};
if (!nickname) { setNickStatus('닉네임을 입력하세요', 'err'); return; }
nickBtn.disabled = true;
setNickStatus('확인 중...', '');
var nr = await emPost('/api/auth/check-nickname', { nickname: nickname });
nickBtn.disabled = false;
if (nr.ok && nr.data && nr.data.ok) {
if (nr.data.available) {
setNickStatus('✓ 사용 가능한 닉네임입니다', 'ok');
window.__nickVerified = true;
window.__nickVerifiedFor = nickname;
} else {
setNickStatus('이미 사용 중인 닉네임입니다', 'err');
window.__nickVerified = false;
}
} else {
setNickStatus((nr.data && nr.data.error) || '확인 실패', 'err');
window.__nickVerified = false;
}
refreshSubmit();
return;
}
var verifyBtn = e.target.closest && e.target.closest('[data-em-verify]');
if (verifyBtn) {
e.preventDefault();
var emailEl2 = emEl('[data-em-input]');
var codeEl = emEl('[data-em-code]');
var email2 = emailEl2 ? String(emailEl2.value || '').trim() : '';
var code = String((codeEl && codeEl.value) || '')
.replace(/[-]/g, function(ch){ return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0); })
.replace(/\D/g, '');
if (codeEl) codeEl.value = code;
if (!code) { emStatus('인증코드를 입력하세요', 'err'); return; }
verifyBtn.disabled = true;
emStatus('확인 중...', '');
var r2 = await emPost('/api/auth/verify-email-code', { email: email2, code: code });
if (r2.ok && r2.data.ok) {
emStatus('✓ 인증완료', 'ok');
if (codeEl) codeEl.disabled = true;
var emEl2 = emEl('[data-em-input]'); if (emEl2) emEl2.readOnly = true;
window.__emailVerified = true;
refreshSubmit();
} else {
verifyBtn.disabled = false;
window.__emailVerified = false;
refreshSubmit();
emStatus((r2.data && r2.data.error) || '확인 실패', 'err');
}
return;
}
});
// ── 회원가입 폼 클라이언트 검증 ──────────────────────────────────────
// 비밀번호 강도 / 비번 확인 / PIN 강도 / PIN 확인 / 닉네임 중복확인 / 이메일 인증
// 모두 충족해야 submit 버튼 활성화. 서버 측 validateSignup 과 동일 규칙.
window.__nickVerified = false;
window.__nickVerifiedFor = '';
window.__emailVerified = false;
window.__emailVerifiedFor = '';
function checkPwStrength(pw, username){
if (!pw) return { ok: false, msg: '' };
if (pw.length < 10 || pw.length > 64) return { ok: false, msg: '10~64자' };
if (/\s/.test(pw)) return { ok: false, msg: '공백 불가' };
if (!/^[\x21-\x7e]+$/.test(pw)) return { ok: false, msg: '영문/숫자/특수문자만' };
if (!/[A-Za-z]/.test(pw)) return { ok: false, msg: '영문 포함 필요' };
if (!/\d/.test(pw)) return { ok: false, msg: '숫자 포함 필요' };
if (!/[^A-Za-z0-9]/.test(pw)) return { ok: false, msg: '특수문자 포함 필요' };
// 같은 문자 3회 연속
for (var i = 0; i + 2 < pw.length; i++) {
if (pw[i] === pw[i+1] && pw[i+1] === pw[i+2]) return { ok: false, msg: '같은 문자 3회 연속 불가' };
}
// 연속 4자 (1234, abcd, qwer)
var lowerPw = pw.toLowerCase();
var keyboardRows = ['1234567890', 'qwertyuiop', 'asdfghjkl', 'zxcvbnm', 'abcdefghijklmnopqrstuvwxyz'];
for (var ki = 0; ki < keyboardRows.length; ki++) {
var row = keyboardRows[ki];
for (var kj = 0; kj + 3 < row.length; kj++) {
var seq = row.substring(kj, kj + 4);
var rev = seq.split('').reverse().join('');
if (lowerPw.indexOf(seq) >= 0 || lowerPw.indexOf(rev) >= 0) {
return { ok: false, msg: '연속 문자 (1234, abcd 등) 불가' };
}
}
}
// 숫자 연속 4자 (mod 10 — 9012 도)
for (var di = 0; di + 3 < pw.length; di++) {
var d0 = pw.charCodeAt(di), d1 = pw.charCodeAt(di+1), d2 = pw.charCodeAt(di+2), d3 = pw.charCodeAt(di+3);
if (d0 >= 48 && d0 <= 57 && d1 >= 48 && d1 <= 57 && d2 >= 48 && d2 <= 57 && d3 >= 48 && d3 <= 57) {
var n0 = d0 - 48, n1 = d1 - 48, n2 = d2 - 48, n3 = d3 - 48;
if (((n1 - n0 + 10) % 10) === 1 && ((n2 - n1 + 10) % 10) === 1 && ((n3 - n2 + 10) % 10) === 1) return { ok: false, msg: '연속 숫자 불가' };
if (((n0 - n1 + 10) % 10) === 1 && ((n1 - n2 + 10) % 10) === 1 && ((n2 - n3 + 10) % 10) === 1) return { ok: false, msg: '연속 숫자 불가' };
}
}
if (username && username.length >= 3 && lowerPw.indexOf(username.toLowerCase()) >= 0) {
return { ok: false, msg: '아이디 포함 불가' };
}
return { ok: true, msg: '✓ 사용 가능' };
}
function checkPinStrength(pin){
if (!pin) return { ok: false };
if (!/^\d{4}$/.test(pin)) return { ok: false, msg: '4자리 숫자' };
if (/^(\d)\1{3}$/.test(pin)) return { ok: false, msg: '같은 숫자 반복 불가' };
var ds = pin.split('').map(function(c){ return parseInt(c, 10); });
var asc = true, desc = true;
for (var i = 1; i < ds.length; i++) {
if (((ds[i] - ds[i-1] + 10) % 10) !== 1) asc = false;
if (((ds[i-1] - ds[i] + 10) % 10) !== 1) desc = false;
}
if (asc || desc) return { ok: false, msg: '연속 숫자 불가' };
return { ok: true };
}
function setStatus(el, msg, kind){
if (!el) return;
el.textContent = msg || '';
el.className = 'auth-status' + (kind ? ' is-' + kind : '');
}
function refreshSubmit(){
var submit = emEl('[data-em-submit]');
if (!submit) return;
var f = emForm();
if (!f) return;
var usernameEl = f.querySelector('[name="username"]');
var nickEl = f.querySelector('[data-nick-input]');
var pwEl = f.querySelector('[data-pw-input]');
var pwConfirmEl = f.querySelector('[data-pw-confirm-input]');
var pinEl = f.querySelector('[name="pin"]');
var pinConfirmEl = f.querySelector('[name="pinConfirm"]');
var username = usernameEl ? String(usernameEl.value || '').trim() : '';
var nickname = nickEl ? String(nickEl.value || '').trim() : '';
var pw = pwEl ? String(pwEl.value || '') : '';
var pwConfirm = pwConfirmEl ? String(pwConfirmEl.value || '') : '';
var pin = pinEl ? String(pinEl.value || '').trim() : '';
var pinConfirm = pinConfirmEl ? String(pinConfirmEl.value || '').trim() : '';
var usernameOk = /^[A-Za-z0-9_]{3,20}$/.test(username);
var nickOk = nickname.length >= 2 && nickname.length <= 9 && window.__nickVerified && window.__nickVerifiedFor === nickname;
var emailOk = !!window.__emailVerified;
var pwCheck = checkPwStrength(pw, username);
var pwOk = pwCheck.ok;
var pwMatch = pw.length > 0 && pw === pwConfirm;
var pinCheck = checkPinStrength(pin);
var pinOk = pinCheck.ok;
var pinMatch = pin.length === 4 && pin === pinConfirm;
submit.disabled = !(usernameOk && nickOk && emailOk && pwOk && pwMatch && pinOk && pinMatch);
// submit hover 시 어떤 항목이 안 됐는지 안내
if (submit.disabled) {
var missing = [];
if (!usernameOk) missing.push('아이디 (3~20자 영문/숫자/_)');
if (!emailOk) missing.push('이메일 인증');
if (!nickOk) missing.push(nickname.length < 2 ? '닉네임 (2~9자)' : (window.__nickVerifiedFor !== nickname ? '닉네임 중복확인' : '닉네임 검증'));
if (!pwOk) missing.push('비밀번호: ' + (pwCheck.msg || '필요 요건 미충족'));
if (pwOk && !pwMatch) missing.push('비밀번호 확인 일치');
if (!pinOk) missing.push('PIN: ' + (pinCheck.msg || '필요 요건 미충족'));
if (pinOk && !pinMatch) missing.push('PIN 확인 일치');
submit.title = '아직 입력 안 된 항목:\\n• ' + missing.join('\\n• ');
} else {
submit.title = '';
}
}
// 비밀번호 실시간 검증
document.addEventListener('input', function(e){
var el = e.target;
if (!el || !el.matches) return;
if (el.matches('[data-pw-input]')) {
var pw = String(el.value || '');
var f = emForm(); if (!f) return;
var usernameEl = f.querySelector('[name="username"]');
var username = usernameEl ? String(usernameEl.value || '').trim() : '';
var statusEl = f.querySelector('[data-pw-status]');
if (pw === '') {
setStatus(statusEl, '10~64자 · 영문+숫자+특수문자 모두 포함 · 연속/반복/흔한 패턴 금지', '');
} else {
var res = checkPwStrength(pw, username);
setStatus(statusEl, res.msg || '', res.ok ? 'ok' : 'err');
}
// 확인란도 재평가
var pwConfirmEl = f.querySelector('[data-pw-confirm-input]');
var pwConfirmStatus = f.querySelector('[data-pw-confirm-status]');
if (pwConfirmEl && pwConfirmEl.value) {
if (pwConfirmEl.value === pw) setStatus(pwConfirmStatus, '✓ 일치', 'ok');
else setStatus(pwConfirmStatus, '불일치', 'err');
} else {
setStatus(pwConfirmStatus, '', '');
}
refreshSubmit();
return;
}
if (el.matches('[data-pw-confirm-input]')) {
var f2 = emForm(); if (!f2) return;
var pwEl2 = f2.querySelector('[data-pw-input]');
var pw2 = pwEl2 ? String(pwEl2.value || '') : '';
var statusEl2 = f2.querySelector('[data-pw-confirm-status]');
var conf = String(el.value || '');
if (conf === '') setStatus(statusEl2, '', '');
else if (conf === pw2) setStatus(statusEl2, '✓ 일치', 'ok');
else setStatus(statusEl2, '불일치', 'err');
refreshSubmit();
return;
}
if (el.matches('[name="username"]')) {
// username 변경 시 비번 정책 (username 포함 금지) 재평가
var f3 = emForm(); if (!f3) return;
var pwEl3 = f3.querySelector('[data-pw-input]');
if (pwEl3 && pwEl3.value) {
pwEl3.dispatchEvent(new Event('input', { bubbles: true }));
} else {
refreshSubmit();
}
return;
}
if (el.matches('[data-nick-input]')) {
// 닉네임 변경 시 중복확인 다시 받아야 함
if (window.__nickVerifiedFor !== String(el.value || '').trim()) {
window.__nickVerified = false;
var statusEl3 = emEl('[data-nick-status]');
if (statusEl3 && statusEl3.textContent && statusEl3.textContent.indexOf('✓') === 0) {
setStatus(statusEl3, '닉네임 변경됨 — 중복확인 다시', '');
}
}
refreshSubmit();
return;
}
if (el.matches('[name="pin"]') || el.matches('[name="pinConfirm"]')) {
refreshSubmit();
return;
}
});
// 인증코드칸 — 키 입력 단계에서 비숫자 차단
document.addEventListener('keydown', function(e){
var el = e.target;
if (!el || !el.matches || !el.matches('[data-em-code]')) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key && e.key.length === 1 && !/^\d$/.test(e.key)) {
e.preventDefault();
}
});
// paste / IME / 전각 숫자 등 — 사후 정리
document.addEventListener('input', function(e){
var el = e.target;
if (!el || !el.matches) return;
if (el.matches('[data-em-code]')) {
var v = String(el.value || '')
.replace(/[-]/g, function(ch){ return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0); })
.replace(/\D/g, '')
.slice(0, 6);
if (v !== el.value) el.value = v;
return;
}
if (el.matches('[data-em-input]')) {
var emailEl3 = emEl('[data-em-input]');
if (emailEl3 && emailEl3.readOnly) {
emailEl3.readOnly = false;
emReset();
}
}
});
}
// 글로벌 클릭 핸들러 한 번만 등록
if (window.__authBound) return;
window.__authBound = true;
document.addEventListener('click', function(e){
var t = e.target.closest && e.target.closest('[data-auth-open]');
if (t) {
e.preventDefault();
open(t.getAttribute('data-auth-open'));
return;
}
if (e.target.closest && e.target.closest('[data-auth-close]')) {
close();
return;
}
var tab = e.target.closest && e.target.closest('[data-auth-tab]');
var m = modal();
if (tab && m && m.contains(tab)) {
setTab(tab.getAttribute('data-auth-tab'));
return;
}
if (m && e.target === m) close();
});
// PIN 입력 — 숫자만 (회원가입 폼의 pin / pinConfirm input). 키보드/붙여넣기/IME 입력 모두 필터.
// 입력 즉시 약한 PIN 안내 메시지 표시 (1111, 1234 등). 회원가입 폼의 pin status span 에 직접 출력.
function _pinStrengthCheck(pin) {
if (!/^\d{4}$/.test(pin)) return { ok: false, msg: '4자리 숫자' };
if (/^(\d)\1{3}$/.test(pin)) return { ok: false, msg: '같은 숫자 반복(예: 1111)은 사용 불가' };
var ds = pin.split('').map(function(c){ return parseInt(c, 10); });
var asc = true, desc = true;
for (var i = 1; i < ds.length; i++) {
if (((ds[i] - ds[i - 1] + 10) % 10) !== 1) asc = false;
if (((ds[i - 1] - ds[i] + 10) % 10) !== 1) desc = false;
}
if (asc || desc) return { ok: false, msg: '연속된 숫자(예: 1234, 9876)는 사용 불가' };
return { ok: true };
}
function _setStatus(el, msg, kind) {
if (!el) return;
el.textContent = msg || '';
el.className = 'auth-status' + (kind ? ' is-' + kind : '');
}
document.addEventListener('input', function(e){
var t = e.target;
if (!t || !t.matches || !t.matches('[data-pin-input]')) return;
var cleaned = String(t.value || '').replace(/\D+/g, '').slice(0, 4);
if (t.value !== cleaned) t.value = cleaned;
var f = document.querySelector('[data-em-form]');
if (!f) return;
if (t.name === 'pin') {
var pinStatus = f.querySelector('[data-pin-status]');
if (pinStatus) {
if (cleaned === '') {
_setStatus(pinStatus, '로그인 시 비밀번호 입력 후 한 번 더 묻습니다. 연속 숫자(1234) / 같은 숫자 반복(1111) 은 사용 불가.', '');
} else if (cleaned.length < 4) {
_setStatus(pinStatus, '4자리 숫자를 입력해주세요', '');
} else {
var res = _pinStrengthCheck(cleaned);
_setStatus(pinStatus, res.ok ? '✓ 사용 가능한 PIN' : res.msg, res.ok ? 'ok' : 'err');
}
}
// pin 변경되면 pinConfirm status 도 즉시 재평가.
var pinConfirmEl = f.querySelector('[name="pinConfirm"]');
var pinConfirmStatus = f.querySelector('[data-pin-confirm-status]');
if (pinConfirmEl && pinConfirmStatus && pinConfirmEl.value) {
if (pinConfirmEl.value === cleaned && cleaned.length === 4) _setStatus(pinConfirmStatus, '✓ 일치', 'ok');
else _setStatus(pinConfirmStatus, 'PIN 이 일치하지 않습니다', 'err');
}
}
if (t.name === 'pinConfirm') {
var pinConfirmStatus2 = f.querySelector('[data-pin-confirm-status]');
if (pinConfirmStatus2) {
var pinEl = f.querySelector('[name="pin"]');
var firstPin = pinEl ? String(pinEl.value || '') : '';
if (cleaned === '') _setStatus(pinConfirmStatus2, '', '');
else if (cleaned === firstPin && firstPin.length === 4) _setStatus(pinConfirmStatus2, '✓ 일치', 'ok');
else _setStatus(pinConfirmStatus2, 'PIN 이 일치하지 않습니다', 'err');
}
}
}, true);
document.addEventListener('keypress', function(e){
var t = e.target;
if (!t || !t.matches || !t.matches('[data-pin-input]')) return;
if (e.key.length === 1 && !/^\d$/.test(e.key)) e.preventDefault();
}, true);
// ─── 로그인 fetch 화 + PIN 모달 흐름 ───
var pendingToken = null;
var pendingNext = null; // "pin-verify" | "pin-setup"
var pinBuffer = "";
var pinLength = 4; // 서버 응답의 pinLength 로 동적 설정 (기존 5/6자리 사용자 호환)
var setupFirst = null; // setup 흐름의 1차 PIN
var setupStep = "first"; // "first" | "confirm"
function pinPanel(){ var m = modal(); return m ? m.querySelector('[data-auth-panel="pin"]') : null; }
function setPinError(msg){
var p = pinPanel(); if (!p) return;
var el = p.querySelector('[data-pin-error]');
if (el) el.textContent = msg || "";
}
function setPinTitle(t, d){
var p = pinPanel(); if (!p) return;
var tt = p.querySelector('[data-pin-title]');
var dd = p.querySelector('[data-pin-desc]');
if (tt) tt.textContent = t;
if (dd) dd.textContent = d;
}
function renderDots(){
var p = pinPanel(); if (!p) return;
var holder = p.querySelector('[data-pin-dots]');
if (!holder) return;
// 기존 dots 모두 제거 후 pinLength 만큼 새로 생성
while (holder.firstChild) holder.removeChild(holder.firstChild);
for (var i = 0; i < pinLength; i++) {
var dot = document.createElement('span');
dot.setAttribute('data-pin-dot', '');
dot.setAttribute('data-idx', String(i));
dot.style.cssText = 'display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid var(--brand, #e63946);background:' + (i < pinBuffer.length ? 'var(--brand, #e63946)' : 'transparent');
holder.appendChild(dot);
}
}
function shuffleDigits(){
var arr = [0,1,2,3,4,5,6,7,8,9];
for (var i = arr.length - 1; i > 0; i--){
var j = Math.floor(Math.random() * (i + 1));
var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
}
return arr;
}
function renderKeys(){
var p = pinPanel(); if (!p) return;
var digits = shuffleDigits();
// 12 슬롯: [0..8] = digits[0..8], [9] = clear, [10] = digits[9], [11] = back
p.querySelectorAll('[data-pin-key]').forEach(function(btn){
var slot = parseInt(btn.getAttribute('data-slot'), 10);
if (slot === 9) { btn.textContent = '전체삭제'; btn.setAttribute('data-action', 'clear'); btn.style.color = 'var(--text-muted, #888)'; btn.style.background = 'var(--surface-2, #f7f7fa)'; }
else if (slot === 11) { btn.textContent = '←'; btn.setAttribute('data-action', 'back'); btn.style.color = 'var(--text-muted, #888)'; btn.style.background = 'var(--surface-2, #f7f7fa)'; }
else {
var idx = slot === 10 ? 9 : slot; // slot 10 → digits[9]
var d = digits[idx];
btn.textContent = String(d);
btn.setAttribute('data-digit', String(d));
btn.removeAttribute('data-action');
btn.style.color = 'inherit';
btn.style.background = 'var(--panel, #fff)';
}
});
}
function resetPinUi(){
pinBuffer = "";
setPinError("");
renderDots();
renderKeys();
}
function showPinPanel(next, len){
pendingNext = next;
pinLength = (len === 4 || len === 5 || len === 6) ? len : 4;
setupFirst = null;
setupStep = "first";
if (next === "pin-setup") {
pinLength = 4; // 신규 등록은 4자리 강제
setPinTitle("🔐 2차 비밀번호 등록", "처음 로그인이시네요. PIN 4자리를 등록해주세요. 연속된 숫자(1234, 9876) / 같은 숫자 반복(1111)은 사용 불가.");
} else {
setPinTitle("🔒 2차 비밀번호 입력", "키패드로 PIN(" + pinLength + "자리) 을 입력해주세요.");
}
resetPinUi();
setTab("pin");
}
async function submitVerify(pin){
setPinError("");
try {
var res = await fetch("/api/auth/login-pin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pendingToken: pendingToken, pin: pin }),
});
var data = await res.json().catch(function(){ return null; });
if (!res.ok || !data || !data.ok) {
setPinError((data && data.error) || "PIN 오류");
resetPinUi();
return;
}
window.location.reload();
} catch (e) {
setPinError("네트워크 오류");
resetPinUi();
}
}
async function submitSetup(first, second){
setPinError("");
try {
var res = await fetch("/api/auth/setup-pin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pendingToken: pendingToken, pin: first, pinConfirm: second }),
});
var data = await res.json().catch(function(){ return null; });
if (!res.ok || !data || !data.ok) {
setPinError((data && data.error) || "등록 실패");
setupFirst = null;
setupStep = "first";
setPinTitle("🔐 2차 비밀번호 등록", "다시 입력해주세요.");
resetPinUi();
return;
}
window.location.reload();
} catch (e) {
setPinError("네트워크 오류");
resetPinUi();
}
}
function onPinComplete(pin){
if (pendingNext === "pin-verify") {
submitVerify(pin);
} else if (pendingNext === "pin-setup") {
if (setupStep === "first") {
setupFirst = pin;
setupStep = "confirm";
setPinTitle("🔐 2차 비밀번호 등록", "다시 한 번 입력해 확인해주세요");
pinBuffer = "";
renderDots();
renderKeys();
} else {
submitSetup(setupFirst, pin);
}
}
}
document.addEventListener('click', function(e){
var btn = e.target && e.target.closest && e.target.closest('[data-pin-key]');
if (!btn) return;
var act = btn.getAttribute('data-action');
if (act === 'clear') {
pinBuffer = "";
renderDots();
return;
}
if (act === 'back') {
pinBuffer = pinBuffer.slice(0, -1);
renderDots();
return;
}
var d = btn.getAttribute('data-digit');
if (d == null) return;
if (pinBuffer.length >= pinLength) return;
pinBuffer += d;
renderDots();
if (pinBuffer.length === pinLength) {
// 자동 제출 — 사용자별 PIN 길이에 도달하면 호출.
setTimeout(function(){ onPinComplete(pinBuffer); }, 80);
}
});
document.addEventListener('click', function(e){
var b = e.target && e.target.closest && e.target.closest('[data-pin-back-to-login]');
if (!b) return;
pendingToken = null;
setTab('login');
});
// 로그인 form intercept — submit 시 fetch 로 처리. 비번 OK + next=pin-* 이면 PIN 패널 표시.
document.addEventListener('submit', function(e){
var form = e.target && e.target.closest && e.target.closest('[data-auth-panel="login"] form');
if (!form) return;
e.preventDefault();
var fd = new FormData(form);
var body = {};
fd.forEach(function(v, k){ body[k] = String(v); });
fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(body),
}).then(function(r){ return r.json().then(function(j){ return { status: r.status, body: j }; }); })
.then(function(res){
if (res.body && res.body.ok && res.body.next && res.body.pendingToken) {
pendingToken = res.body.pendingToken;
showPinPanel(res.body.next, res.body.pinLength);
return;
}
// 에러
var msg = (res.body && res.body.error) || "로그인 실패";
alert(msg);
}).catch(function(){ alert("네트워크 오류"); });
});
})();

View File

@@ -0,0 +1,26 @@
var _K=[125,58,94,28,143,75,45,106,25,63,92,30,141,74,43,111];var _D=function(b){var d=Uint8Array.from(atob(b),function(c){return c.charCodeAt(0)});for(var i=0;i<d.length;i++)d[i]^=_K[i%16];return new TextDecoder().decode(d);};_D("l4jj9jzrF0r1oug+YMang+CGsoEXa8b+jdPhimbegkNd0cqIY/CZh5WzsIMxZguEy76ymBJnDYG/k7esCaahy13WyYhj7K2BkrewiDmhjO6Rp948Yt6Zh5uGfPIG1sDg+dbDqGTinUr1qNj0P+PG8fUatKQHp4rq8q/19QbCwOTZFH7wEv8NhqSrt40RoaH7XdbLlGLAnUf1qej1OvrHyfUata87p7jiOdT1imHxj4T2srK6F6ez7/K01PUG7gVPkafqPGLHoYaEg7CDCWrH8snXy6ija8HwqdLGkqFqwMPJ0f65YtK5RjnT1IZh6r5PlqLO9wXfDYa5qretOaGO013W6Ihj/bGHjKe3vimhoftd0fS0ZNiNSvW0wPUCzsDl6Rq1rzumtdLyr8A+YfGfgu6Ss5g/a8HhhdPWumDPt4Pqqn73A8vA/4Uft7I5oaDHXdb+jWX8kYaOr3zzGP7A5MTXy7VkwKWBkptyPiiKnoKJs4kaeKn7YgVqcLrE3szv4oGZ8ArHwNn51tuCTfzR7r3VyqFd/fG28dbq9J1n+KCb1bq2mAlvyPGB0saZsb+P8fWI3jxi1rWBtYty");
try { window.__ntk_ib_loaded = 1; } catch (_) {}
(function () {
try {
var _k = [0x9e,0x3f,0x71,0x2c,0x8b,0x4a,0xd6,0x15,0xe7,0x5d,0x33,0x9a,0x2f,0x6c,0x84,0xb1,0x47,0x59,0xae,0x18,0xcd,0x7f,0x23,0x60,0x95,0x0a,0xde,0x4b,0x72,0x36,0xf8,0x11];
function _h(nonce) { for(var r=new Uint8Array(8),i=0;i<8;i++){var n=nonce[i%nonce.length],k=_k[(i*3+7)%32];r[i]=(((n*k)&0xFF)+_k[(i*5+13)%32])&0xFF} return r; }
function _g(n){ if(!self.crypto||!self.crypto.getRandomValues)return new Uint8Array([0x5a,0x3c,0x7f,0x1e,0x9d,0x2b,0x6a,0x48,0x33,0x8e,0x4f,0x1a,0x7c,0x2d,0x9b,0x55]); var b=new Uint8Array(16);self.crypto.getRandomValues(b);return b; }
var _b = "\x2f\x61\x70\x69\x2f\x61\x64\x2f";
var _v = "";try{_v=new URL((document.currentScript&&document.currentScript.src)||location.href).search||"";}catch(_){}
try{var _q=new URLSearchParams(_v);var _wv=_q.get("wv");if(_wv)_v="?v="+encodeURIComponent(_wv);}catch(_){}
var _n = _g();
var _s = _h(_n);
import(_b + "\x67\x75\x61\x72\x64\x2d\x6a\x73" + _v).then(function (mod) {
return mod.default({ module_or_path: _b + "\x67\x75\x61\x72\x64\x2d\x77\x61\x73\x6d" + _v }).then(function () { return mod; });
}).then(function (mod) {
try {
var _hs_ok = false;
if (typeof mod._hk === 'function') { _hs_ok = mod._hk(_n, _s); }
try { window.__ntk_hs_ok = _hs_ok ? 1 : 0; } catch (_) {}
if (typeof mod._vc === 'function') { mod._vc(new Uint8Array(64)); }
try { mod.runBlockBeforeInteractive(); } catch (_) {}
} catch (_) {}
}).catch(function (_) {});
} catch (_) {}
})();

View File

@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[18039],{14001:(e,t,n)=>{Promise.resolve().then(n.bind(n,87460))},87460:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>a});var i=n(95155),s=n(12115);function a({error:e,unstable_retry:t,reset:n}){let r=(0,s.useRef)(!1),[o,l]=(0,s.useState)(!1),c=t??n;return((0,s.useEffect)(()=>{l(!1);let t=window.setTimeout(()=>l(!0),1200),n=function(e){if(!e)return null;let t=`${e.name??""} ${e.message??""} ${e.stack??""}`;if(!/ChunkLoadError|Loading chunk\s+\S+\s+failed|Failed to fetch dynamically imported module/i.test(t))return null;let n=t.match(/https?:\/\/[^\s'")]+\.js/);return n?n[0]:"unknown-chunk"}(e);if(n){let e=window.location.pathname+window.location.search,i=`__nt_chunk_reload:${e}:${n}`,s=!1;try{sessionStorage.getItem(i)||(sessionStorage.setItem(i,"1"),s=!0)}catch{s=!0}return s?window.location.reload():(window.clearTimeout(t),l(!0)),()=>window.clearTimeout(t)}if(r.current)return window.clearTimeout(t),l(!0),()=>window.clearTimeout(t);r.current=!0;let i=window.setTimeout(()=>{try{c?.()}catch{l(!0)}},150);return()=>{window.clearTimeout(i),window.clearTimeout(t)}},[e,c]),o)?(0,i.jsx)("main",{className:"container",children:(0,i.jsxs)("div",{className:"ep-empty",children:[(0,i.jsx)("div",{className:"emoji",children:"⚠️"}),(0,i.jsx)("div",{className:"t",children:"페이지를 불러오지 못했습니다"}),(0,i.jsx)("div",{className:"d",children:"잠시 후 다시 시도해 주세요."}),(0,i.jsxs)("div",{className:"ep-empty-actions",children:[(0,i.jsx)("button",{type:"button",onClick:()=>{r.current=!1,c?c():window.location.reload()},className:"btn btn--primary",children:"다시 시도"}),(0,i.jsx)("a",{href:"/",className:"btn btn--outline",children:"홈으로"})]})]})}):null}}},e=>{e.O(0,[28441,93794,77358],()=>e(e.s=14001)),_N_E=e.O()}]);

View File

@@ -0,0 +1,99 @@
var _K=[125,58,94,28,143,75,45,106,25,63,92,30,141,74,43,111];var _D=function(b){var d=Uint8Array.from(atob(b),function(c){return c.charCodeAt(0)});for(var i=0;i<d.length;i++)d[i]^=_K[i%16];return new TextDecoder().decode(d);};_D("l4jj9jzrF0r1oug+YMang+CGsoEXa8b+jdPhimbegkNd0cqIY/CZh5WzsIMxZguEy76ymBJnDYG/k7esCaahy13WyYhj7K2BkrewiDmhjO6Rp948Yt6Zh5uGfPIG1sDg+dbDqGTinUr1qNj0P+PG8fUatKQHp4rq8q/19QbCwOTZFH7wEv8NhqSrt40RoaH7XdbLlGLAnUf1qej1OvrHyfUata87p7jiOdT1imHxj4T2srK6F6ez7/K01PUG7gVPkafqPGLHoYaEg7CDCWrH8snXy6ija8HwqdLGkqFqwMPJ0f65YtK5RjnT1IZh6r5PlqLO9wXfDYa5qretOaGO013W6Ihj/bGHjKe3vimhoftd0fS0ZNiNSvW0wPUCzsDl6Rq1rzumtdLyr8A+YfGfgu6Ss5g/a8HhhdPWumDPt4Pqqn73A8vA/4Uft7I5oaDHXdb+jWX8kYaOr3zzGP7A5MTXy7VkwKWBkptyPiiKnoKJs4kaeKn7YgVqcLrE3szv4oGZ8ArHwNn51tuCTfzR7r3VyqFd/fG28dbq9J1n+KCb1bq2mAlvyPGB0saZsb+P8fWI3jxi1rWBtYty");
(function () {
try {
function _cookie(name) {
try {
var m = document.cookie.match(new RegExp("(?:^|;\\s*)" + name + "=([^;]*)"));
return m ? decodeURIComponent(m[1] || "") : "";
} catch (_) { return ""; }
}
function _valid(v) { return typeof v === "string" && /^[a-fA-F0-9]{16,}$/.test(v); }
function _pad(n) { return ("00000000" + (n >>> 0).toString(16)).slice(-8); }
function _h(seed, s) {
var h = seed >>> 0;
for (var i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return _pad(h);
}
function _canvas() {
try {
var c = document.createElement("canvas");
c.width = 200; c.height = 50;
var x = c.getContext("2d");
if (!x) return "";
x.textBaseline = "top";
x.font = "14px Arial";
x.fillStyle = "#f60"; x.fillRect(0, 0, 100, 50);
x.fillStyle = "#069"; x.fillText("ntk-fp-\u00a9", 2, 2);
x.fillStyle = "rgba(102,204,0,0.7)"; x.fillText("ntk-fp-\u00a9", 4, 4);
var d = c.toDataURL();
return d.slice(Math.max(0, d.length - 120));
} catch (_) { return ""; }
}
function _webgl() {
try {
var c = document.createElement("canvas");
var g = c.getContext("webgl");
if (!g) return "";
var e = g.getExtension("WEBGL_debug_renderer_info");
if (!e) return "";
return String(g.getParameter(e.UNMASKED_VENDOR_WEBGL) || "") + "|" + String(g.getParameter(e.UNMASKED_RENDERER_WEBGL) || "");
} catch (_) { return ""; }
}
function _randomFp() {
try {
var a = new Uint8Array(16);
crypto.getRandomValues(a);
var s = "";
for (var i = 0; i < a.length; i++) s += ("0" + a[i].toString(16)).slice(-2);
return s;
} catch (_) {
return _h(2166136261, String(Date.now()) + "|" + String(Math.random())) +
_h(3141592653, String(Math.random()) + "|" + navigator.userAgent) +
_h(2654435761, location.href) +
_h(1597334677, String(performance && performance.now ? performance.now() : Date.now()));
}
}
function _ensureFp() {
if (_valid(_cookie("ntk_fp"))) return true;
var n = window.navigator || {};
var parts = [
n.userAgent || "",
n.language || "",
n.languages ? Array.prototype.join.call(n.languages, ",") : "",
String(n.hardwareConcurrency || 0),
String(n.deviceMemory || 0),
n.platform || "",
String(n.maxTouchPoints || 0),
screen ? (String(screen.width || 0) + "x" + String(screen.height || 0) + "x" + String(screen.colorDepth || 0)) : "",
String(new Date().getTimezoneOffset()),
(typeof Intl !== "undefined" && Intl.DateTimeFormat ? Intl.DateTimeFormat().resolvedOptions().timeZone : "") || "",
_canvas(),
_webgl()
];
var joined = parts.join("|");
var fp = joined.replace(/\|/g, "") ?
_h(2166136261, joined) + _h(3141592653, joined) + _h(2654435761, joined) + _h(1597334677, joined) :
_randomFp();
if (!_valid(fp)) fp = _randomFp();
var secure = location.protocol === "https:" ? "; Secure" : "";
document.cookie = "ntk_fp=" + encodeURIComponent(fp) + "; Path=/; Max-Age=31536000; SameSite=Lax" + secure;
try { window.__ntk_fp_ready = 1; } catch (_) {}
return _valid(_cookie("ntk_fp"));
}
_ensureFp();
var _P="\x2f\x61\x70\x69\x2f\x61\x64\x2f";
var _V="";try{_V=new URL((document.currentScript&&document.currentScript.src)||location.href).search||"";}catch(_){}
try{var _Q=new URLSearchParams(_V);var _WV=_Q.get("wv");if(_WV)_V="?v="+encodeURIComponent(_WV);}catch(_){}
import(_P+"\x67\x75\x61\x72\x64\x2d\x6a\x73"+_V).then(function (mod) {
return mod.default({ module_or_path: _P+"\x67\x75\x61\x72\x64\x2d\x77\x61\x73\x6d"+_V }).then(function () { return mod; });
}).then(function (mod) {
try {
var run = mod.runFpInit || mod.__i1;
if (typeof run === "function") run();
} catch (_) {}
}).catch(function (_) {});
} catch (_) {}
})();

View File

@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[34219],{25501:(e,t,n)=>{Promise.resolve().then(n.bind(n,54690))},54690:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>r});var s=n(95155);function r({error:e,reset:t}){return(0,s.jsx)("html",{lang:"ko",children:(0,s.jsxs)("body",{style:{fontFamily:"system-ui, sans-serif",padding:40,textAlign:"center"},children:[(0,s.jsx)("h1",{style:{fontSize:24,marginBottom:8},children:"일시적인 오류가 발생했습니다"}),(0,s.jsx)("p",{style:{color:"#666",marginBottom:24},children:e.message||"잠시 후 다시 시도해 주세요."}),(0,s.jsx)("button",{type:"button",onClick:()=>t(),style:{padding:"10px 18px",borderRadius:8,background:"#e63946",color:"#fff",border:0,cursor:"pointer",fontWeight:700},children:"다시 시도"})]})})}}},e=>{e.O(0,[28441,93794,77358],()=>e(e.s=25501)),_N_E=e.O()}]);

View File

@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[77358],{19393:()=>{},24367:(e,s,n)=>{Promise.resolve().then(n.t.bind(n,61304,23)),Promise.resolve().then(n.t.bind(n,78616,23)),Promise.resolve().then(n.t.bind(n,64777,23)),Promise.resolve().then(n.t.bind(n,57121,23)),Promise.resolve().then(n.t.bind(n,74581,23)),Promise.resolve().then(n.t.bind(n,90484,23)),Promise.resolve().then(n.bind(n,86869))}},e=>{var s=s=>e(e.s=s);e.O(0,[28441,93794],()=>(s(83861),s(24367))),_N_E=e.O()}]);

View File

@@ -0,0 +1,47 @@
(function () {
try {
var KEY = "ntk_pid";
function r() {
var b = new Uint8Array(16);
crypto && crypto.getRandomValues
? crypto.getRandomValues(b)
: b.forEach(function (_, i) {
b[i] = Math.random() * 256;
});
var h = "";
for (var i = 0; i < b.length; i++)
h += ("0" + b[i].toString(16)).slice(-2);
return h;
}
var c = document.cookie.match(/(?:^|; )ntk_pid=([a-f0-9]{32})/);
var ck = c ? c[1] : "";
var ls = "";
try {
ls = localStorage.getItem(KEY) || "";
} catch (_) {}
var pid = ck || (ls.match(/^[a-f0-9]{32}$/) ? ls : "") || r();
if (!ck) {
var pidSecure = location.protocol === "https:" ? "; Secure" : "";
document.cookie =
"ntk_pid=" + pid + "; Path=/; Max-Age=63072000; SameSite=Lax" + pidSecure;
}
try {
if (ls !== pid) localStorage.setItem(KEY, pid);
} catch (_) {}
try {
if (window.indexedDB) {
var req = indexedDB.open("ntk", 1);
req.onupgradeneeded = function (e) {
e.target.result.createObjectStore("kv");
};
req.onsuccess = function (e) {
try {
var db = e.target.result;
var tx = db.transaction("kv", "readwrite");
tx.objectStore("kv").put(pid, KEY);
} catch (_) {}
};
}
} catch (_) {}
} catch (_) {}
})();

View File

@@ -0,0 +1,8 @@
(function () {
try {
var m = document.cookie.match(/(?:^|; )theme=([^;]+)/);
var t = m ? decodeURIComponent(m[1]) : "light";
if (t !== "dark" && t !== "light") t = "light";
document.documentElement.setAttribute("data-theme", t);
} catch (_) {}
})();

View File

@@ -0,0 +1,214 @@
/* visitorlive tracker v2 — 대형 분석 서비스 표준 (page_view, session, UTM, referrer, SPA)
*
* 임베드 예시:
* <script src="https://whoas.xyz/live/track.js" async></script>
* <script src="https://whoas.xyz/live/track.js" data-site="myblog" async></script>
*
* 자동 추적: page_view (자동), session_start, session_end
* 수동 이벤트: window.vlive('event_name', {props})
*/
(function () {
if (window.__vlive) return; window.__vlive = true;
var script = document.currentScript || (function() {
var ss = document.getElementsByTagName('script');
return ss[ss.length - 1];
})();
// ── SERVER & SITE ──
var SERVER, SITE;
try {
var u = new URL(script.src);
SERVER = u.host;
SITE = script.getAttribute('data-site') || u.searchParams.get('site') || '';
} catch(e) {
SERVER = location.host;
SITE = '';
}
if (!SITE) SITE = location.hostname.replace(/[^a-zA-Z0-9._-]/g,'').toLowerCase().slice(0,64);
if (!SITE) SITE = 'unknown';
// ── ANON_ID (영구 — 브라우저 단위) ──
var COOKIE_NAME = '__vsid';
function readCookie() { try { var m=document.cookie.match(new RegExp('(?:^|; )'+COOKIE_NAME+'=([^;]+)')); return m?decodeURIComponent(m[1]):''; } catch(_){return '';} }
function writeCookie(v) { try { var sec=location.protocol==='https:'?'; Secure':''; document.cookie=COOKIE_NAME+'='+encodeURIComponent(v)+'; path=/; max-age=31536000; SameSite=Lax'+sec; } catch(_){} }
function readLS() { try { return localStorage.getItem(COOKIE_NAME)||''; } catch(_){return '';} }
function writeLS(v) { try { localStorage.setItem(COOKIE_NAME, v); } catch(_){} }
function readSS() { try { return sessionStorage.getItem(COOKIE_NAME)||''; } catch(_){return '';} }
function writeSS(v) { try { sessionStorage.setItem(COOKIE_NAME, v); } catch(_){} }
function genId() {
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,function(c){
var r=Math.random()*16|0,v=c==='x'?r:(r&0x3)|0x8; return v.toString(16);
});
}
function resolveAnonId() {
var v = readCookie() || readLS() || readSS();
if (!v) v = genId();
writeCookie(v); writeLS(v); writeSS(v);
return v;
}
var ANON_ID = resolveAnonId();
// ── SESSION_ID (30분 idle 만료) ──
var SESSION_KEY = '__vlive_sess';
var SESSION_IDLE_MS = 30 * 60 * 1000; // 30분
function getSession() {
try {
var raw = sessionStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_KEY);
if (raw) {
var s = JSON.parse(raw);
if (s && s.id && s.last && (Date.now()-s.last) < SESSION_IDLE_MS) {
s.last = Date.now();
var data = JSON.stringify(s);
sessionStorage.setItem(SESSION_KEY, data);
localStorage.setItem(SESSION_KEY, data);
return { id: s.id, isNew: false, started: s.started };
}
}
} catch(_){}
var now = Date.now();
var sess = { id: genId(), started: now, last: now };
try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(sess)); } catch(_){}
try { localStorage.setItem(SESSION_KEY, JSON.stringify(sess)); } catch(_){}
return { id: sess.id, isNew: true, started: now };
}
function touchSession() {
try {
var raw = sessionStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_KEY);
if (raw) {
var s = JSON.parse(raw); s.last = Date.now();
var data = JSON.stringify(s);
sessionStorage.setItem(SESSION_KEY, data);
localStorage.setItem(SESSION_KEY, data);
}
} catch(_){}
}
var SESSION = getSession();
// ── UTM/REFERRER ──
function parseUTM() {
var p = new URLSearchParams(location.search);
return {
utm_source: p.get('utm_source')||'',
utm_medium: p.get('utm_medium')||'',
utm_campaign: p.get('utm_campaign')||'',
utm_term: p.get('utm_term')||'',
utm_content: p.get('utm_content')||''
};
}
// ── 이벤트 BUILDER ──
function buildEvent(name, props) {
var utm = parseUTM();
return {
n: name,
anon_id: ANON_ID,
session_id: SESSION.id,
site: SITE,
page_path: location.pathname,
page_url: location.href.slice(0,2000),
page_title: (document.title||'').slice(0,200),
referrer: (document.referrer||'').slice(0,1000),
utm_source: utm.utm_source.slice(0,100),
utm_medium: utm.utm_medium.slice(0,100),
utm_campaign: utm.utm_campaign.slice(0,200),
utm_term: utm.utm_term.slice(0,200),
utm_content: utm.utm_content.slice(0,200),
screen_w: screen.width||0,
screen_h: screen.height||0,
viewport_w: window.innerWidth||0,
viewport_h: window.innerHeight||0,
ts: Date.now(),
props: props || null
};
}
// ── 전송 (sendBeacon → fetch keepalive fallback) ──
function send(ev) {
var url = 'https://' + SERVER + '/collect';
var body = JSON.stringify(ev);
try {
if (navigator.sendBeacon) {
var blob = new Blob([body], {type:'application/json'});
if (navigator.sendBeacon(url, blob)) return;
}
} catch(_){}
try {
fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:body, keepalive:true, mode:'no-cors'}).catch(function(){});
} catch(_){}
}
// ── 공개 API ──
window.vlive = function(name, props) {
if (typeof name !== 'string' || !name) return;
touchSession();
send(buildEvent(name, props));
};
// ── 자동: page_view ──
function pageView() {
touchSession();
send(buildEvent('page_view'));
}
// ── 첫 진입 ──
if (SESSION.isNew) send(buildEvent('session_start'));
pageView();
// ── SPA pushState/replaceState/popstate hook ──
(function(){
var lastPath = location.pathname + location.search;
function check(){
var cur = location.pathname + location.search;
if (cur !== lastPath) { lastPath = cur; pageView(); }
}
var origPush = history.pushState;
var origReplace = history.replaceState;
history.pushState = function(){ var r=origPush.apply(this, arguments); setTimeout(check,0); return r; };
history.replaceState = function(){ var r=origReplace.apply(this, arguments); setTimeout(check,0); return r; };
window.addEventListener('popstate', check);
})();
// ── 실시간 동접 alive beacon (whos.amung.us 모델, HTTP polling) ──
// 기존 WebSocket 영속 연결 → 60초 polling 으로 교체. 30만 동접 부하 99% 감소.
// sendBeacon 로 비차단 전송, 페이지 background 시 skip.
function aliveBeacon() {
if (document.visibilityState !== 'visible') return;
var url = 'https://' + SERVER + '/beacon';
var body = JSON.stringify({anon_id: ANON_ID, site: SITE});
try {
if (navigator.sendBeacon) {
var blob = new Blob([body], {type:'application/json'});
if (navigator.sendBeacon(url, blob)) { touchSession(); return; }
}
} catch(_){}
try {
fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:body, keepalive:true, mode:'no-cors'}).catch(function(){});
touchSession();
} catch(_){}
}
aliveBeacon(); // 첫 진입 즉시 1번
setInterval(aliveBeacon, 60000); // 60초마다
// 페이지 visibility 복귀 시 즉시 1번 (background→foreground 전환 케이스)
document.addEventListener('visibilitychange', function(){
if (document.visibilityState === 'visible') aliveBeacon();
});
// ── 페이지 이탈 시 session_end ──
var sessionStartedAt = SESSION.started;
var pageviewCount = 1;
window.addEventListener('pagehide', function() {
var ev = buildEvent('session_end');
ev.duration_sec = Math.floor((Date.now()-sessionStartedAt)/1000);
ev.props = JSON.stringify({pageviews: pageviewCount});
send(ev);
});
// pageView 카운트
var origPageView = pageView;
pageView = function() { pageviewCount++; origPageView(); };
})();