각 채팅마다 소모된 프로챗을 엄지 버튼 왼쪽에 표시합니다
// ==UserScript==
// @name BabeChat Cost Tracker
// @namespace https://babechat.ai/
// @version 2.4.1
// @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
// @grant GM_listValues
// @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 = 700;
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 pendingEvents = [];
var dropBaseline = null;
var costHistory = Object.create(null);
var observers = [];
var pollId = null;
var bootTimer = null;
var currentRoomId = null;
var busy = false; // 재진입 방지
var lastApplyTime = 0; // applyBadges 쓰로틀 타임스탬프
var _cachedBalBtn = null; // 잔액 버튼 캐시
var _entryCache = Object.create(null);
var ENTRY_PREFIX = 'babeCostEntry:';
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);
}
function hashString(s) {
var h = 2166136261;
for (var i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(36);
}
function initEntryStorage() {
try {
var keys = typeof GM_listValues === 'function' ? GM_listValues() : [];
for (var i = 0; i < keys.length; i++) {
if (keys[i].indexOf(ENTRY_PREFIX) !== 0) continue;
var rec = GM_getValue(keys[i], null);
if (!rec || typeof rec.room !== 'string' || typeof rec.message !== 'string' || typeof rec.cost !== 'number') continue;
if (!_entryCache[rec.room]) _entryCache[rec.room] = Object.create(null);
_entryCache[rec.room][rec.message] = rec.cost;
}
} catch (e) { log('Entry storage init error:', e); }
}
function saveEntry(room, message, cost) {
if (!_entryCache[room]) _entryCache[room] = Object.create(null);
_entryCache[room][message] = cost;
try {
GM_setValue(ENTRY_PREFIX + hashString(room) + ':' + hashString(message), {
room: room, message: message, cost: cost, updatedAt: Date.now()
});
} catch (e) { log('Entry storage save error:', e); }
}
/* ─── 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') || p.get('chatRoomId') || p.get('conversationId') || p.get('chatId');
// roomId가 있으면 pathname + roomId (채팅방별 고유 키)
if (rid != null) return path + '_r' + rid;
// roomId 없으면 pathname만 사용 (최초 입장 시)
if (path.indexOf('/chat') !== -1) return path + '_s' + (p.get('scenario') || '0');
} 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 {
var current = GM_getValue(k, null);
if (current) return current;
var legacyKey = k.replace(/_s[^_]*$/, '');
return GM_getValue(legacyKey, {});
} catch (e) { return {}; }
}
function saveCost(messageKey, cost, eventAt) {
var room = currentRoomId || getRoomId();
if (!room) return;
costHistory[messageKey] = cost;
var recordKey = eventAt ? messageKey + '@' + eventAt + '-' + Math.random().toString(36).slice(2, 7) : messageKey;
saveEntry(room, recordKey, cost);
log('Saved', messageKey, '=', cost, 'total', totalCost());
}
function loadEntryCost(room, messageKey) {
var entries = room && _entryCache[room];
if (!entries) return null;
if (typeof entries[messageKey] === 'number') return entries[messageKey];
var prefix = messageKey + '@';
var latestStamp = -1;
var latestCost = null;
for (var k in entries) {
if (k.indexOf(prefix) !== 0) continue;
var stamp = parseInt(k.slice(prefix.length), 10);
if (!isNaN(stamp) && stamp > latestStamp) {
latestStamp = stamp;
latestCost = entries[k];
}
}
return latestCost;
}
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() {
var room = currentRoomId || getRoomId();
var entries = room && _entryCache[room];
var t = 0;
if (entries) for (var k in entries) if (typeof entries[k] === 'number') t += entries[k];
return t + (gmLoad()._t || 0);
}
/* ─── DOM 헬퍼 ────────────────────────────────────────── */
function parseAmount(text) {
var m = String(text || '').match(/\d[\d,]*(?:\.\d+)?/);
if (!m) return null;
var n = parseFloat(m[0].replace(/,/g, ''));
return isNaN(n) ? null : n;
}
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 = parseAmount(b.textContent);
// 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 = parseAmount(b.textContent);
// 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 msgToolbars() {
var area = document.querySelector('.custom-scroll');
if (!area) return [];
var all = area.querySelectorAll('.justify-between, [class*="justify-between"]');
var out = [];
for (var i = 0; i < all.length; i++) {
var el = all[i];
var cl = el.className || '';
var buttons = el.querySelectorAll('button').length;
var hasVersion = /(\d+)\s*\/\s*(\d+)/.test(el.textContent);
var oldToolbar = cl.indexOf('justify-between') !== -1 && cl.indexOf('mt-4') !== -1;
var actionRow = buttons >= 2 && el.textContent.trim().length < 160;
if (!oldToolbar && !hasVersion && !actionRow) continue;
out.push(el);
}
return out;
}
function getVer(tb) {
if (!tb) return null;
var m = tb.textContent.match(/(\d+)\s*\/\s*(\d+)/);
return m ? parseInt(m[1], 10) : null;
}
function normalizedText(el, toolbar) {
if (!el) return '';
var clone = el.cloneNode(true);
var badges = clone.querySelectorAll('[' + BADGE_ATTR + ']');
for (var i = 0; i < badges.length; i++) badges[i].remove();
var toolbarText = toolbar ? toolbar.textContent.trim() : '';
var text = clone.textContent.replace(/\s+/g, ' ').trim();
if (toolbarText && text.lastIndexOf(toolbarText) === text.length - toolbarText.length) {
text = text.slice(0, -toolbarText.length).trim();
}
return text;
}
function messageKey(tb, idx) {
var area = document.querySelector('.custom-scroll');
var p = tb && tb.parentElement;
var text = '';
for (var depth = 0; p && p !== area && depth < 7; depth++, p = p.parentElement) {
var candidate = normalizedText(p, tb);
if (candidate.length >= 8) { text = candidate; break; }
}
var ver = getVer(tb) || 1;
return text ? 'm' + hashString(text.slice(-6000)) + 'v' + ver : 'fallback-' + idx + 'v' + ver;
}
function toolbarSnapshot() {
var tbs = msgToolbars();
var keys = Object.create(null);
for (var i = 0; i < tbs.length; i++) keys[messageKey(tbs[i], i)] = true;
return keys;
}
function badgePlacement(tb) {
var children = tb ? tb.children : [];
for (var i = children.length - 1; i >= 0; i--) {
var count = children[i].querySelectorAll ? children[i].querySelectorAll('button').length : 0;
if (count >= 2) return { host: children[i], before: children[i].querySelector('button') };
}
var buttons = tb ? tb.querySelectorAll('button') : [];
var before = buttons.length >= 2 ? buttons[buttons.length - 2] : (buttons[0] || null);
return { host: tb, before: before };
}
/* ─── 배지 ────────────────────────────────────────────── */
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 tbs = msgToolbars();
while (pendingEvents.length > 0 && tbs.length > 0) {
var ev = pendingEvents[0];
var idx = -1;
for (var c = tbs.length - 1; c >= 0; c--) {
var candidateKey = messageKey(tbs[c], c);
if (!ev.baseline[candidateKey]) { idx = c; break; }
}
if (idx < 0) idx = tbs.length - 1;
var boundKey = messageKey(tbs[idx], idx);
var old = tbs[idx].querySelector('[' + BADGE_ATTR + ']');
if (old) old.remove();
saveCost(boundKey, ev.cost, ev.at);
log('Stored', ev.cost, 'message', boundKey);
pendingEvents.shift();
}
// 각 툴바에 배지 적용
for (var i = 0; i < tbs.length; i++) {
var tb = tbs[i];
var v2 = getVer(tb);
var key2 = messageKey(tb, i);
var cost = null;
// 세션 데이터
if (typeof costHistory[key2] === 'number') cost = costHistory[key2];
if (cost == null) cost = loadEntryCost(currentRoomId || getRoomId(), key2);
// GM 저장 데이터
if (cost == null) cost = loadCost(i, v2 != null ? v2 : 1);
if (cost == null) continue;
var placement = badgePlacement(tb);
var existing = tb.querySelectorAll('[' + BADGE_ATTR + ']');
var correctlyPlaced = existing.length === 1 &&
existing[0].getAttribute(BADGE_ATTR) === String(cost) &&
existing[0].parentElement === placement.host &&
existing[0].nextElementSibling === placement.before;
if (correctlyPlaced) continue;
for (var e = 0; e < existing.length; e++) existing[e].remove();
placement.host.insertBefore(mkBadge(cost), placement.before);
}
updateTotal();
}
/* ─── tick ─────────────────────────────────────────────── */
function finalise() {
debounceTimer = null;
if (accumulatedCost <= 0) return;
pendingEvents.push({ cost: accumulatedCost, baseline: dropBaseline || Object.create(null), at: Date.now() });
log('Finalised:', accumulatedCost);
accumulatedCost = 0;
dropBaseline = null;
applyBadges();
}
function tick() {
if (busy) return;
busy = true;
var bal = readBal();
if (bal !== null) {
if (prevBalance !== null && bal < prevBalance - 0.000001) {
var d = Math.round((prevBalance - bal) * 1000) / 1000;
if (accumulatedCost === 0) dropBaseline = toolbarSnapshot();
accumulatedCost += d;
log('Drop:', prevBalance, '->', bal, 'd=' + d, 'acc=' + accumulatedCost);
if (debounceTimer !== null) clearTimeout(debounceTimer);
debounceTimer = setTimeout(finalise, DEBOUNCE_MS);
}
prevBalance = bal;
}
busy = false;
applyBadges();
}
/* ─── 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();
_cachedBalBtn = null;
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');
}
// custom-scroll은 applyBadges 전용 (tick 유발 안 함)
var area = document.querySelector('.custom-scroll');
if (area) {
var o2 = new MutationObserver(function () {
applyBadges();
});
o2.observe(area, { childList: true, subtree: true });
observers.push(o2);
log('custom-scroll attached');
}
pollId = setInterval(tick, POLL_MS);
applyBadges();
}
/* ─── SPA 감지 ─────────────────────────────────────────── */
var _lastUrl = '';
function onUrlChange() {
log('URL:', location.href);
if (accumulatedCost > 0) finalise();
applyBadges();
while (pendingEvents.length > 0) {
var orphan = pendingEvents.shift();
saveCost('unassigned-' + orphan.at + '-' + Math.random().toString(36).slice(2, 8), orphan.cost);
}
var oldRoomId = currentRoomId;
var nextRoomId = getRoomId();
if (oldRoomId && nextRoomId && oldRoomId !== nextRoomId &&
oldRoomId.replace(/_s[^_]*$/, '') === nextRoomId.replace(/_r[^_]*$/, '')) {
var oldEntries = _entryCache[oldRoomId];
if (oldEntries) for (var ek in oldEntries) saveEntry(nextRoomId, ek, oldEntries[ek]);
}
prevBalance = null;
accumulatedCost = 0;
dropBaseline = null;
costHistory = Object.create(null);
_cachedBalBtn = null;
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.4.1 userscript loaded');
initEntryStorage();
currentRoomId = getRoomId();
log('Initial room:', currentRoomId);
setTimeout(boot, 1200);
watchNav();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();