BabeChat Cost Tracker

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
  }
})();