BabeChat Cost Tracker

각 채팅마다 소모된 프로챗을 엄지 버튼 왼쪽에 표시합니다

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
  }
})();