BabeChat Cost Tracker

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

You will need to install an extension such as Tampermonkey to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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();
  }
})();