각 채팅마다 소모된 프로챗을 엄지 버튼 왼쪽에 표시합니다
// ==UserScript==
// @name BabeChat Cost Tracker
// @namespace https://babechat.ai/
// @version 2.2.0
// @description 각 채팅마다 소모된 프로챗을 엄지 버튼 왼쪽에 표시합니다
// @author romh
// @license MIT
// @match https://babechat.ai/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=babechat.ai
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* ─── 상수 ────────────────────────────────────────────── */
var BADGE_ATTR = 'data-babecost';
var TOTAL_BADGE_ID = 'babecost-total';
var POLL_MS = 500;
var DEBOUNCE_MS = 1500;
var THROTTLE_MS = 200; // applyBadges 쓰로틀
var DEBUG = false;
var KRW_MIN = 0.889;
var KRW_MAX = 0.98;
/* ─── 상태 ────────────────────────────────────────────── */
var prevBalance = null;
var accumulatedCost = 0;
var debounceTimer = null;
var pendingCost = null;
var costHistory = []; // 세션 내 Map<version, cost>[]
var observers = [];
var pollId = null;
var bootTimer = null;
var currentRoomId = null;
var busy = false; // 재진입 방지
var lastApplyTime = 0; // applyBadges 쓰로틀 타임스탬프
var _cachedBalBtn = null; // 잔액 버튼 캐시
function log() {
if (!DEBUG) return;
var a = ['[BabeCost]'];
for (var i = 0; i < arguments.length; i++) a.push(arguments[i]);
console.log.apply(console, a);
}
/* ─── roomId ──────────────────────────────────────────── */
function getRoomId() {
try {
var path = location.pathname; // 예: /ko/character/u/abc123/chat
var p = new URL(location.href).searchParams;
var rid = p.get('roomId');
// roomId가 있으면 pathname + roomId (채팅방별 고유 키)
if (rid != null) return path + '_r' + rid;
// roomId 없으면 pathname만 사용 (최초 입장 시)
if (path.indexOf('/chat') !== -1) return path;
} catch (e) { }
return null;
}
/* ─── GM 저장 ─────────────────────────────────────────── */
function sKey() {
var rid = currentRoomId || getRoomId();
if (rid && !currentRoomId) currentRoomId = rid;
return rid ? 'c_' + rid : null;
}
function gmLoad() {
var k = sKey();
if (!k) return {};
try { return GM_getValue(k, {}); } catch (e) { return {}; }
}
function gmSave(d) {
var k = sKey();
if (!k) return;
try { GM_setValue(k, d); } catch (e) { }
}
function saveCost(idx, ver, cost) {
var d = gmLoad();
var key = ver > 1 ? 'i' + idx + 'v' + ver : 'i' + idx;
d[key] = cost;
// 합계 계산
var t = 0;
for (var k in d) { if (k !== '_t' && typeof d[k] === 'number') t += d[k]; }
d._t = t;
gmSave(d);
log('Saved', key, '=', cost, 'total', t);
}
function loadCost(idx, ver) {
var d = gmLoad();
var key = ver > 1 ? 'i' + idx + 'v' + ver : 'i' + idx;
if (key in d) return d[key];
// fallback: 해당 인덱스의 모든 버전 중 마지막 값 반환
var prefix = 'i' + idx;
var last = null;
for (var k in d) {
// 'i1'이 'i10'을 매칭하지 않도록: 정확히 prefix이거나 prefix+'v'로 시작
if (k === prefix || (k.length > prefix.length && k.indexOf(prefix + 'v') === 0)) {
last = d[k];
}
}
return last;
}
function totalCost() {
return gmLoad()._t || 0;
}
/* ─── DOM 헬퍼 ────────────────────────────────────────── */
function balBtn() {
// 캐시된 버튼이 아직 DOM에 있으면 재사용
if (_cachedBalBtn && _cachedBalBtn.isConnected) return _cachedBalBtn;
var btns = document.querySelectorAll('button');
for (var i = 0; i < btns.length; i++) {
var b = btns[i];
if (b.classList.contains('rounded-full') && b.classList.contains('ml-auto')) {
var n = parseInt(b.textContent.replace(/[^\d]/g, ''), 10);
// n >= 0: 잔액이 0이 되어도 버튼을 찾아야 마지막 차감분을 놓치지 않음
if (!isNaN(n) && n >= 0) {
_cachedBalBtn = b;
return b;
}
}
}
return null;
}
function readBal() {
var b = balBtn();
if (!b) return null;
var n = parseInt(b.textContent.replace(/[^\d]/g, ''), 10);
// 0은 유효한 잔액 (null과 구분)
return isNaN(n) ? null : n;
}
function cloneIcon(sz) {
var b = balBtn();
if (!b) return null;
var el = b.querySelector('img') || b.querySelector('svg');
if (!el) return null;
var c = el.cloneNode(true);
c.style.cssText = 'width:' + (sz || '14px') + ';height:' + (sz || '14px') + ';flex-shrink:0';
return c;
}
function thumbGroups() {
var area = document.querySelector('#messages-area');
if (!area) return [];
var out = [];
var imgs = area.querySelectorAll('img[src*="thumbsup"]');
for (var i = 0; i < imgs.length; i++) {
var btn = imgs[i].closest('button');
if (!btn) continue;
var g = btn.parentElement;
if (g && out.indexOf(g) === -1) out.push(g);
}
return out;
}
function toolbar(g) { return g ? g.parentElement : null; }
function getVer(tb) {
if (!tb) return null;
var m = tb.textContent.match(/(\d+)\s*\/\s*(\d+)/);
return m ? parseInt(m[1], 10) : null;
}
/* ─── 배지 ────────────────────────────────────────────── */
function fmtK(n) { return n % 1 === 0 ? String(n) : n.toFixed(1); }
function mkBadge(cost) {
var w = document.createElement('div');
w.setAttribute(BADGE_ATTR, String(cost));
w.style.cssText = 'display:inline-flex;align-items:center;gap:3px;border-radius:9999px;' +
'border:1px solid rgba(255,255,255,0.15);padding:1px 8px;font-size:12px;font-weight:600;' +
'color:rgba(255,107,138,0.85);white-space:nowrap;user-select:none;flex-shrink:0;' +
'margin-right:4px;position:relative;cursor:default';
var ic = cloneIcon();
if (ic) { ic.style.opacity = '0.85'; w.appendChild(ic); }
else { var s = document.createElement('span'); s.textContent = '💎'; s.style.cssText = 'font-size:11px;opacity:0.85'; w.appendChild(s); }
var num = document.createElement('span');
num.textContent = '-' + cost;
w.appendChild(num);
// 툴팁 (XSS 방지: innerHTML 대신 textContent 조합)
var tt = document.createElement('div');
var ttLine1 = document.createElement('span');
ttLine1.style.fontWeight = '700';
ttLine1.textContent = '₩' + fmtK(cost * KRW_MIN) + ' ~ ₩' + fmtK(cost * KRW_MAX);
var ttLine2 = document.createElement('span');
ttLine2.style.cssText = 'font-size:10px;opacity:0.7';
ttLine2.textContent = 'ⓘ 구매 프로챗 환산 기준';
tt.appendChild(ttLine1);
tt.appendChild(document.createElement('br'));
tt.appendChild(ttLine2);
tt.style.cssText = 'position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);' +
'background:rgba(30,30,30,0.25);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);' +
'border:1px solid rgba(255,255,255,0.15);color:#e0e0e0;padding:6px 10px;border-radius:8px;' +
'font-size:12px;line-height:1.5;white-space:nowrap;pointer-events:none;opacity:0;' +
'transition:opacity 0.15s;z-index:9999;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,0.5)';
w.appendChild(tt);
w.onmouseenter = function () { tt.style.opacity = '1'; };
w.onmouseleave = function () { tt.style.opacity = '0'; };
return w;
}
/* ─── 누적 배지 ───────────────────────────────────────── */
function updateTotal() {
var t = totalCost();
if (t <= 0) return;
var bb = balBtn();
if (!bb) return;
var el = document.getElementById(TOTAL_BADGE_ID);
if (!el) {
el = document.createElement('div');
el.id = TOTAL_BADGE_ID;
el.style.cssText = 'display:inline-flex;align-items:center;gap:4px;border-radius:99px;' +
'background:rgba(30,30,30,0.85);' +
'border:1px solid rgba(255,255,255,0.08);padding:3px 12px;font-size:14px;font-weight:700;' +
'color:rgba(255,107,138,0.9);white-space:nowrap;user-select:none;flex-shrink:0;' +
'cursor:default;position:relative;margin-right:8px;box-shadow:0 2px 8px rgba(0,0,0,0.15)';
var ic = cloneIcon('12px');
if (ic) { ic.style.opacity = '0.85'; el.appendChild(ic); }
var lbl = document.createElement('span');
lbl.className = 'bc-lbl';
el.appendChild(lbl);
// 툴팁
var tt = document.createElement('div');
tt.className = 'bc-tt';
tt.style.cssText = 'position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);' +
'background:rgba(20,20,20,0.85);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);' +
'border:1px solid rgba(255,255,255,0.1);color:#ececec;padding:8px 12px;border-radius:10px;' +
'font-size:12px;line-height:1.6;white-space:nowrap;pointer-events:none;opacity:0;' +
'transition:opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1);z-index:99999;text-align:center;' +
'box-shadow:0 8px 32px rgba(0,0,0,0.6);font-weight:400';
el.appendChild(tt);
el.onmouseenter = function () { tt.style.opacity = '1'; };
el.onmouseleave = function () { tt.style.opacity = '0'; };
bb.parentElement.insertBefore(el, bb);
}
var lbl2 = el.querySelector('.bc-lbl');
if (lbl2) lbl2.textContent = '-' + t;
var tt2 = el.querySelector('.bc-tt');
if (tt2) {
// XSS 방지: innerHTML 대신 textContent 조합
tt2.textContent = '';
var h1 = document.createElement('span'); h1.style.fontWeight = '700'; h1.textContent = '이 채팅방 누적';
var h2 = document.createElement('span'); h2.style.fontWeight = '700'; h2.textContent = '₩' + fmtK(t * KRW_MIN) + ' ~ ₩' + fmtK(t * KRW_MAX);
var h3 = document.createElement('span'); h3.style.cssText = 'font-size:10px;opacity:0.7'; h3.textContent = 'ⓘ 구매 프로챗 환산 기준';
tt2.appendChild(h1); tt2.appendChild(document.createElement('br'));
tt2.appendChild(h2); tt2.appendChild(document.createElement('br'));
tt2.appendChild(h3);
}
}
/* ─── 핵심: 배지 적용 ─────────────────────────────────── */
function applyBadges() {
// 쓰로틀: 짧은 시간 내 중복 호출 방지
var now = Date.now();
if (now - lastApplyTime < THROTTLE_MS) return;
lastApplyTime = now;
var groups = thumbGroups();
// pendingCost 확정
if (pendingCost !== null && groups.length > 0) {
var idx = groups.length - 1;
while (costHistory.length < idx) costHistory.push(null);
if (!(costHistory[idx] instanceof Map)) costHistory[idx] = new Map();
var tb = toolbar(groups[idx]);
var v = getVer(tb);
if (v == null) v = costHistory[idx].size + 1;
if (tb) { var old = tb.querySelector('[' + BADGE_ATTR + ']'); if (old) old.remove(); }
costHistory[idx].set(v, pendingCost);
saveCost(idx, v, pendingCost);
log('Stored', pendingCost, 'idx', idx, 'ver', v);
pendingCost = null;
}
// 각 툴바에 배지 적용
for (var i = 0; i < groups.length; i++) {
var tb2 = toolbar(groups[i]);
if (!tb2) continue;
var v2 = getVer(tb2);
var cost = null;
// 세션 데이터
var m = costHistory[i];
if (m instanceof Map && m.size > 0) {
cost = (v2 != null && m.has(v2)) ? m.get(v2) : Array.from(m.values()).pop();
}
// GM 저장 데이터
if (cost == null) cost = loadCost(i, v2 != null ? v2 : 1);
if (cost == null) continue;
var ex = tb2.querySelector('[' + BADGE_ATTR + ']');
if (ex && ex.getAttribute(BADGE_ATTR) === String(cost)) continue;
if (ex) ex.remove();
tb2.insertBefore(mkBadge(cost), groups[i]);
}
updateTotal();
}
/* ─── tick ─────────────────────────────────────────────── */
function finalise() {
debounceTimer = null;
if (accumulatedCost <= 0) return;
pendingCost = accumulatedCost;
log('Finalised:', pendingCost);
accumulatedCost = 0;
applyBadges();
}
function tick() {
if (busy) return;
busy = true;
var bal = readBal();
if (bal !== null) {
if (prevBalance !== null && bal < prevBalance) {
var d = prevBalance - bal;
accumulatedCost += d;
log('Drop:', prevBalance, '->', bal, 'd=' + d, 'acc=' + accumulatedCost);
if (debounceTimer !== null) clearTimeout(debounceTimer);
debounceTimer = setTimeout(finalise, DEBOUNCE_MS);
}
prevBalance = bal;
}
busy = false;
}
/* ─── Toast ───────────────────────────────────────────── */
function toast(text, icon) {
var old = document.querySelectorAll('.babecost-toast');
for (var i = 0; i < old.length; i++) old[i].remove();
var el = document.createElement('div');
el.className = 'babecost-toast';
if (icon) el.appendChild(icon);
var sp = document.createElement('span');
sp.textContent = text;
el.appendChild(sp);
el.style.cssText = 'display:inline-flex;align-items:center;gap:6px;position:fixed;' +
'top:40px;left:50%;transform:translateX(-50%) translateY(-30px);' +
'background:rgba(30,30,30,0.85);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);' +
'border:1px solid rgba(255,255,255,0.1);color:#e0e0e0;padding:8px 16px;' +
'border-radius:9999px;font-size:13px;font-weight:600;z-index:999999;opacity:0;' +
'transition:all 0.3s cubic-bezier(0.175,0.885,0.32,1.275);' +
'box-shadow:0 4px 16px rgba(0,0,0,0.4);pointer-events:none';
document.body.appendChild(el);
requestAnimationFrame(function () {
el.style.opacity = '1';
el.style.transform = 'translateX(-50%) translateY(0)';
});
setTimeout(function () {
el.style.opacity = '0';
el.style.transform = 'translateX(-50%) translateY(-30px)';
setTimeout(function () { el.remove(); }, 300);
}, 2500);
}
/* ─── Observer ─────────────────────────────────────────── */
function cleanup() {
for (var i = 0; i < observers.length; i++) observers[i].disconnect();
observers = [];
if (pollId !== null) { clearInterval(pollId); pollId = null; }
}
function boot() {
cleanup();
currentRoomId = getRoomId();
log('Room:', currentRoomId);
var bb = balBtn();
if (bb) {
prevBalance = readBal();
log('Balance:', prevBalance);
toast('프로챗 트래커 작동 중', cloneIcon());
var o1 = new MutationObserver(tick);
o1.observe(bb, { childList: true, subtree: true, characterData: true });
observers.push(o1);
} else {
log('Balance button not found');
}
// messages-area는 applyBadges 전용 (tick 유발 안 함)
var area = document.querySelector('#messages-area');
if (area) {
var o2 = new MutationObserver(function () {
// 뱃지 삽입이 mutation을 유발하므로 throttle로 제어
applyBadges();
});
o2.observe(area, { childList: true, subtree: true });
observers.push(o2);
log('messages-area attached');
}
pollId = setInterval(tick, POLL_MS);
applyBadges();
}
/* ─── SPA 감지 ─────────────────────────────────────────── */
var _lastUrl = '';
function onUrlChange() {
log('URL:', location.href);
prevBalance = null;
accumulatedCost = 0;
pendingCost = null;
costHistory.length = 0;
var tb = document.getElementById(TOTAL_BADGE_ID);
if (tb) tb.remove();
if (debounceTimer !== null) clearTimeout(debounceTimer);
debounceTimer = null;
if (bootTimer !== null) clearTimeout(bootTimer);
bootTimer = setTimeout(boot, 1200);
}
function watchNav() {
_lastUrl = location.href;
// MutationObserver (DOM 변경 시 URL 체크)
new MutationObserver(function () {
if (location.href !== _lastUrl) {
_lastUrl = location.href;
onUrlChange();
}
}).observe(document.body, { childList: true, subtree: true });
// URL 폴링 (pushState/replaceState 감지용 fallback)
setInterval(function () {
if (location.href !== _lastUrl) {
_lastUrl = location.href;
onUrlChange();
}
}, 500);
}
/* ─── 시작 ─────────────────────────────────────────────── */
function init() {
log('v2.2.0 loaded');
currentRoomId = getRoomId();
log('Initial room:', currentRoomId);
setTimeout(boot, 1200);
watchNav();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();