Torn RW Scanner Widget

Floating Torn Ranked War scanner widget with local price history, ROI estimates, and page scanning.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn RW Scanner Widget
// @namespace    https://openai.com/
// @version      1.1.0
// @description  Floating Torn Ranked War scanner widget with local price history, ROI estimates, and page scanning.
// @match        https://www.torn.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_notification
// ==/UserScript==

(() => {
  'use strict';

  const STORE_KEYS = {
    db: 'rwScanner.db.v1',
    cfg: 'rwScanner.cfg.v1',
    pos: 'rwScanner.pos.v1',
    collapsed: 'rwScanner.collapsed.v1',
  };

  const DEFAULT_CFG = {
    refreshMinMs: 60000,
    refreshMaxMs: 90000,
    notifyRoi: 25,
    notifyProfit: 10000000,
    enabled: true,
  };

  const state = {
    db: loadJSON(STORE_KEYS.db, {}),
    cfg: { ...DEFAULT_CFG, ...loadJSON(STORE_KEYS.cfg, {}) },
    results: [],
    lastScanAt: 0,
    scanTimer: null,
    noticeCooldown: new Map(),
    collapsed: !!loadJSON(STORE_KEYS.collapsed, false),
  };

  const style = `
    #rwScannerWidget {
      position: fixed;
      z-index: 999999;
      left: 12px;
      top: 12px;
      width: 320px;
      max-width: calc(100vw - 12px);
      max-height: calc(100vh - 12px);
      background: rgba(24, 24, 24, 0.96);
      color: #e9e9e9;
      border: 1px solid rgba(255,255,255,0.12);
      border-radius: 16px;
      box-shadow: 0 12px 30px rgba(0,0,0,0.45);
      font: 13px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
      overflow: hidden;
      backdrop-filter: blur(10px);
      -webkit-backdrop-filter: blur(10px);
      touch-action: none;
    }
    #rwScannerWidget * { box-sizing: border-box; }
    #rwScannerWidget.collapsed {
      width: 120px !important;
      max-height: none;
      border-radius: 14px;
    }
    #rwScannerWidget.collapsed .rw-body { display: none; }
    #rwScannerWidget.collapsed .rw-head {
      padding: 8px 8px;
    }
    #rwScannerWidget.collapsed .rw-title {
      font-size: 13px;
    }
    #rwScannerWidget.collapsed .rw-count {
      display: inline-flex;
    }
    .rw-head {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
      padding: 10px 12px;
      cursor: move;
      background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.03));
      border-bottom: 1px solid rgba(255,255,255,0.08);
      user-select: none;
      -webkit-user-select: none;
      -webkit-touch-callout: none;
    }
    .rw-head-left {
      display: flex;
      flex-direction: column;
      min-width: 0;
      flex: 1;
      gap: 2px;
    }
    .rw-head-right {
      display: flex;
      align-items: center;
      gap: 6px;
      flex: 0 0 auto;
    }
    .rw-title {
      font-weight: 800;
      font-size: 14px;
      line-height: 1.1;
      white-space: nowrap;
    }
    .rw-pill {
      font-size: 11px;
      padding: 3px 8px;
      border-radius: 999px;
      background: rgba(255,255,255,0.08);
      color: #dfe7f5;
      white-space: nowrap;
    }
    .rw-count {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      min-width: 40px;
    }
    .rw-body {
      padding: 10px 12px 12px;
      overflow: auto;
      max-height: calc(100vh - 58px);
    }
    .rw-row {
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
      margin-bottom: 8px;
    }
    .rw-btn {
      appearance: none;
      border: 0;
      border-radius: 12px;
      padding: 8px 10px;
      background: #2d7ef7;
      color: #fff;
      font-weight: 700;
      cursor: pointer;
      font-size: 12px;
    }
    .rw-btn.secondary { background: rgba(255,255,255,0.08); }
    .rw-btn.danger { background: #c94242; }
    .rw-btn:disabled { opacity: .6; cursor: not-allowed; }
    .rw-status {
      color: #aab4c3;
      margin-bottom: 8px;
      font-size: 12px;
      word-break: break-word;
    }
    .rw-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
      margin: 8px 0;
    }
    .rw-card {
      padding: 8px 10px;
      border-radius: 12px;
      background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.06);
    }
    .rw-card .label { font-size: 11px; color: #9ca9bb; }
    .rw-card .value {
      font-size: 15px;
      font-weight: 800;
      margin-top: 2px;
      word-break: break-word;
    }
    .rw-list {
      margin-top: 8px;
      display: grid;
      gap: 8px;
    }
    .rw-item {
      border-radius: 12px;
      padding: 8px 10px;
      background: rgba(255,255,255,0.04);
      border: 1px solid rgba(255,255,255,0.06);
    }
    .rw-item.top { outline: 1px solid rgba(45,126,247,0.45); }
    .rw-item .name {
      font-weight: 800;
      margin-bottom: 2px;
      word-break: break-word;
    }
    .rw-item .meta {
      color: #aab4c3;
      font-size: 12px;
      display: flex;
      justify-content: space-between;
      gap: 10px;
    }
    .rw-mini {
      font-size: 11px;
      color: #9ca9bb;
      margin-top: 4px;
    }
    .rw-foot {
      margin-top: 8px;
      color: #7f8b9d;
      font-size: 11px;
    }
    .rw-badge {
      color: #68d391;
      font-weight: 800;
    }
    #rwScannerWidget.rw-alert-good {
      box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.35), 0 0 18px rgba(245, 158, 11, 0.15), 0 12px 30px rgba(0,0,0,0.45);
    }
    #rwScannerWidget.rw-alert-great {
      box-shadow: 0 0 0 1px rgba(249, 115, 22, 0.45), 0 0 22px rgba(249, 115, 22, 0.2), 0 12px 30px rgba(0,0,0,0.45);
    }
    #rwScannerWidget.rw-alert-snipe {
      animation: rwPulse 1s infinite;
      box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.5), 0 0 26px rgba(239, 68, 68, 0.25), 0 12px 30px rgba(0,0,0,0.45);
    }
    @keyframes rwPulse {
      0%   { transform: scale(1); }
      50%  { transform: scale(1.02); }
      100% { transform: scale(1); }
    }
  `;

  GM_addStyle(style);

  const widget = document.createElement('div');
  widget.id = 'rwScannerWidget';
  widget.className = state.collapsed ? 'collapsed' : '';
  widget.innerHTML = `
    <div class="rw-head" id="rwDragHandle">
      <div class="rw-head-left">
        <div class="rw-title">RW Scanner</div>
        <div class="rw-pill" id="rwScanState">Idle</div>
      </div>
      <div class="rw-head-right">
        <div class="rw-pill rw-count" id="rwCountPill">0</div>
        <button class="rw-btn secondary" id="rwToggle" title="Collapse/expand">−</button>
      </div>
    </div>
    <div class="rw-body">
      <div class="rw-row">
        <button class="rw-btn" id="rwScanNow">Scan Now</button>
        <button class="rw-btn secondary" id="rwClear">Clear DB</button>
      </div>
      <div class="rw-status" id="rwStatus">Waiting for scan...</div>
      <div class="rw-grid">
        <div class="rw-card"><div class="label">Best ROI</div><div class="value" id="rwBestRoi">—</div></div>
        <div class="rw-card"><div class="label">Best Profit</div><div class="value" id="rwBestProfit">—</div></div>
      </div>
      <div class="rw-list" id="rwList"></div>
      <div class="rw-foot">Scans the current Torn page every 60–90s and learns from what it sees.</div>
    </div>
  `;
  document.documentElement.appendChild(widget);

  const els = {
    scanState: widget.querySelector('#rwScanState'),
    countPill: widget.querySelector('#rwCountPill'),
    scanNow: widget.querySelector('#rwScanNow'),
    toggle: widget.querySelector('#rwToggle'),
    clear: widget.querySelector('#rwClear'),
    status: widget.querySelector('#rwStatus'),
    bestRoi: widget.querySelector('#rwBestRoi'),
    bestProfit: widget.querySelector('#rwBestProfit'),
    list: widget.querySelector('#rwList'),
    head: widget.querySelector('#rwDragHandle'),
  };

  let drag = {
    pointerId: null,
    startX: 0,
    startY: 0,
    offsetX: 0,
    offsetY: 0,
    moved: false,
    active: false,
  };

  applySavedPosition();
  syncCollapsedUI();

  els.scanNow.addEventListener('click', (e) => {
    e.preventDefault();
    e.stopPropagation();
    runScan(true);
  });

  els.toggle.addEventListener('click', (e) => {
    e.preventDefault();
    e.stopPropagation();
    toggleWidget();
  });

  els.clear.addEventListener('click', () => {
    if (!confirm('Clear local RW price history?')) return;
    state.db = {};
    saveJSON(STORE_KEYS.db, state.db);
    render([]);
    setStatus('Local DB cleared.');
  });

  makeDraggable(widget, els.head);

  if ('Notification' in window && Notification.permission === 'default') {
    // Optional request only when browser supports it; ignored if blocked.
    // This keeps the script quiet unless the user already allowed notifications.
  }

  scheduleNextScan();
  runScan(false);

  function toggleWidget() {
    state.collapsed = !state.collapsed;
    widget.classList.toggle('collapsed', state.collapsed);
    saveJSON(STORE_KEYS.collapsed, state.collapsed);
    syncCollapsedUI();
  }

  function syncCollapsedUI() {
    els.toggle.textContent = state.collapsed ? '+' : '−';
    els.toggle.title = state.collapsed ? 'Expand' : 'Collapse';
    els.countPill.textContent = String(state.results.length || 0);
    widget.classList.toggle('collapsed', state.collapsed);
  }

  function setStatus(msg) {
    els.status.textContent = msg;
    els.scanState.textContent = msg.length > 30 ? msg.slice(0, 30) + '…' : msg;
  }

  function scheduleNextScan() {
    if (state.scanTimer) clearTimeout(state.scanTimer);
    const min = Math.max(10000, Number(state.cfg.refreshMinMs) || DEFAULT_CFG.refreshMinMs);
    const max = Math.max(min, Number(state.cfg.refreshMaxMs) || DEFAULT_CFG.refreshMaxMs);
    const delay = Math.floor(min + Math.random() * (max - min));
    state.scanTimer = setTimeout(() => {
      runScan(false);
      scheduleNextScan();
    }, delay);
  }

  async function runScan(manual) {
    if (!state.cfg.enabled) return;
    setStatus(manual ? 'Manual scan…' : 'Scanning…');

    try {
      const findings = [];
      const url = location.href;
      const text = document.body ? document.body.innerText : '';

      const detail = parseDetailPage(text);
      if (detail) {
        const key = makeKey(detail.name, detail.bonus);
        const observation = {
          key,
          name: detail.name,
          bonus: detail.bonus,
          quality: detail.quality,
          source: detail.source,
          price: detail.value || detail.buy || detail.sell || detail.listedPrice || null,
          buy: detail.buy || null,
          sell: detail.sell || null,
          value: detail.value || null,
          seenAt: Date.now(),
        };
        if (observation.price) {
          recordObservation(observation);
          findings.push(buildFinding(observation, detail));
        }
      } else {
        const cards = extractVisibleCards();
        for (const card of cards) {
          const key = makeKey(card.name, card.bonus);
          const observation = {
            key,
            name: card.name,
            bonus: card.bonus,
            quality: card.quality,
            source: inferSource(url, text),
            price: card.price,
            buy: null,
            sell: null,
            value: null,
            seenAt: Date.now(),
          };
          recordObservation(observation);
          findings.push(buildFinding(observation, null));
        }
      }

      state.lastScanAt = Date.now();
      state.results = compactAndSort(findings);
      render(state.results);
      maybeNotify(state.results);
      setStatus(`Scanned ${state.results.length} item${state.results.length === 1 ? '' : 's'} at ${new Date().toLocaleTimeString()}`);
    } catch (err) {
      console.error('[RW Scanner]', err);
      setStatus(`Scan failed: ${String(err.message || err).slice(0, 36)}`);
    }
  }

  function inferSource(url, text) {
    const u = url.toLowerCase();
    if (u.includes('item') && u.includes('market')) return 'market';
    if (u.includes('auction')) return 'auction';
    if (/buy:\s*\$[\d,]+/i.test(text) && /bonus:/i.test(text)) return 'detail';
    return 'page';
  }

  function parseDetailPage(text) {
    const hasKnownFields = /\bBuy:\s*\$[\d,]+/i.test(text) || /\bSell:\s*\$[\d,]+/i.test(text) || /\bBonus:\s*/i.test(text);
    if (!hasKnownFields) return null;

    const name =
      firstMatch(text, /^\s*([A-Za-z0-9 '\-\.]{3,})\s*$/m) ||
      firstMatch(text, /^(?:Buy|Sell|Value|Circ|Damage|Accuracy|Stealth|Bonus|Quality)\s*$/m) ||
      guessTitle(text);

    const buy = money(firstMatch(text, /\bBuy:\s*\$([\d,]+)/i));
    const sell = money(firstMatch(text, /\bSell:\s*\$([\d,]+)/i));
    const value = money(firstMatch(text, /\bValue:\s*\$([\d,]+)/i));
    const quality = firstMatch(text, /\bQuality:\s*([\d.]+)%\s*([A-Za-z]+)?/i);
    const bonus = firstMatch(text, /\bBonus:\s*(?:[\u2605\u2726\u2730\u2747\u26ab\u2694\ufe0f\uD83D\uDD2B\uD83D\uDCA5\uD83D\uDCAA\uD83D\uDCA8\uD83D\uDD2A\uD83E\uDDE1\uD83D\uDC80]?\s*)?(.+?)(?:\n|$)/i);
    const rarity = firstMatch(text, /\((?:Very\s+)?(?:Common|Uncommon|Rare|Extremely\s+Rare|Mythical|Legendary|Freakish|Oddball)\s+\d+\)/i);

    if (!name) return null;

    const qMatch = quality ? quality.match(/([\d.]+)%\s*([A-Za-z]+)?/i) : null;
    const qValue = qMatch ? Number(qMatch[1]) : null;
    const qColor = qMatch ? (qMatch[2] || '').trim() : null;

    return {
      source: 'detail',
      name: cleanName(name),
      buy,
      sell,
      value,
      bonus: cleanBonus(bonus),
      quality: qValue ? { value: qValue, label: qColor } : null,
      rarity,
      listedPrice: value || sell || buy || null,
    };
  }

  function extractVisibleCards() {
    const candidates = [];
    const elements = Array.from(document.querySelectorAll('a, button, div, li, article, section, span')).slice(0, 6000);

    for (const el of elements) {
      if (!isVisible(el)) continue;
      const rect = el.getBoundingClientRect();
      if (rect.width < 120 || rect.height < 70 || rect.width > window.innerWidth * 0.98) continue;

      const txt = normalizeText(el.innerText || el.textContent || '');
      if (!txt || txt.length < 8 || txt.length > 320) continue;
      if (!/\$[\d,]+/.test(txt)) continue;
      if (!/[A-Za-z]/.test(txt)) continue;

      const lines = txt.split('\n').map(s => s.trim()).filter(Boolean);
      if (lines.length < 2) continue;

      const name = cleanName(lines.find(line => /[A-Za-z]/.test(line) && !/^\$/.test(line)) || lines[0]);
      const price = parseFirstMoney(txt);
      if (!name || !price) continue;

      const bonus = findBonusHint(lines);
      const quality = findQualityHint(lines);
      candidates.push({ name, price, bonus, quality, text: txt, rect });
    }

    const seen = new Set();
    return candidates.filter(c => {
      const key = `${c.name}|${c.price}|${Math.round(c.rect.top / 50)}`;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    }).slice(0, 100);
  }

  function findBonusHint(lines) {
    const line = lines.find(l => /\b(?:Grace|Parry|Deadeye|Warlord|Overclocked|Steel\s+Fist|Finesse|Anarchy|Berserk|Serrated|Precision|Ruthless|Outlaw|Puncture|Hawkeye|Juggernaut|Tactician|Rampage)\b/i.test(l));
    return line ? cleanBonus(line) : null;
  }

  function findQualityHint(lines) {
    const line = lines.find(l => /\b\d{2,3}(?:\.\d+)?%\b/i.test(l) || /\bYellow\b/i.test(l));
    return line || null;
  }

  function buildFinding(observation, detail) {
    const key = observation.key;
    const hist = state.db[key] || [];
    const estimated = estimateValue(key, observation.price, detail);
    const profit = estimated != null && observation.price != null ? estimated - observation.price : null;
    const roi = estimated != null && observation.price > 0 ? (profit / observation.price) * 100 : null;
    const qualityText = observation.quality
      ? `${observation.quality.value.toFixed(2)}%${observation.quality.label ? ' ' + observation.quality.label : ''}`
      : (detail?.quality ? `${detail.quality.value.toFixed(2)}%${detail.quality.label ? ' ' + detail.quality.label : ''}` : '—');

    return {
      ...observation,
      estimated,
      profit,
      roi,
      samples: hist.length,
      qualityText,
      rating: roi == null ? 'Unknown' : roi >= 60 ? 'Snipe' : roi >= 40 ? 'Great' : roi >= 25 ? 'Good' : roi >= 10 ? 'Watch' : 'Skip',
    };
  }

  function estimateValue(key, currentPrice, detail) {
    const hist = state.db[key] || [];
    const prices = hist.map(x => x.price).filter(n => Number.isFinite(n) && n > 0);
    if (prices.length) return median(prices);
    if (detail?.value) return detail.value;
    if (detail?.sell) return detail.sell;
    if (detail?.buy) return Math.round(detail.buy * 0.62);
    return currentPrice || null;
  }

  function recordObservation(observation) {
    const { key, price, name, bonus, quality, source, seenAt } = observation;
    if (!key || !Number.isFinite(price)) return;
    if (!state.db[key]) state.db[key] = [];

    state.db[key].push({
      price: Math.round(price),
      name,
      bonus: bonus || null,
      quality: quality || null,
      source: source || 'page',
      seenAt: seenAt || Date.now(),
    });

    if (state.db[key].length > 120) state.db[key] = state.db[key].slice(-120);
    saveJSON(STORE_KEYS.db, state.db);
  }

  function maybeNotify(findings) {
    const top = findings.find(x => Number.isFinite(x.roi) && x.roi >= state.cfg.notifyRoi && Number.isFinite(x.profit) && x.profit >= state.cfg.notifyProfit);
    if (!top) return;

    const cooldownKey = top.key;
    const last = state.noticeCooldown.get(cooldownKey) || 0;
    if (Date.now() - last < 10 * 60 * 1000) return;
    state.noticeCooldown.set(cooldownKey, Date.now());

    const msg = `${top.name} | ROI ${fmtPct(top.roi)} | +${fmtMoney(top.profit)}`;
    if (typeof GM_notification === 'function') {
      GM_notification({ title: 'RW deal found', text: msg, timeout: 7000 });
    } else if ('Notification' in window && Notification.permission === 'granted') {
      new Notification('RW deal found', { body: msg });
    }
  }

  function render(findings) {
    const list = [...findings]
      .sort((a, b) => (b.roi ?? -Infinity) - (a.roi ?? -Infinity) || (b.profit ?? -Infinity) - (a.profit ?? -Infinity))
      .slice(0, 5);

    state.results = list;
    syncCollapsedUI();
    els.countPill.textContent = String(list.length);

    els.bestRoi.textContent = list[0]?.roi != null ? fmtPct(list[0].roi) : '—';
    els.bestProfit.textContent = list[0]?.profit != null ? `+${fmtMoney(list[0].profit)}` : '—';

    widget.classList.remove('rw-alert-good', 'rw-alert-great', 'rw-alert-snipe');
    if (list[0] && Number.isFinite(list[0].roi)) {
      if (list[0].roi >= 60) widget.classList.add('rw-alert-snipe');
      else if (list[0].roi >= 40) widget.classList.add('rw-alert-great');
      else if (list[0].roi >= 25) widget.classList.add('rw-alert-good');
    }

    if (!list.length) {
      els.list.innerHTML = `<div class="rw-item"><div class="name">No RW items found yet</div><div class="meta"><span>Open a market, auction, or detail page</span><span>then press Scan Now</span></div></div>`;
      return;
    }

    els.list.innerHTML = list.map((x, idx) => `
      <div class="rw-item ${idx === 0 ? 'top' : ''}">
        <div class="name">${escapeHTML(x.name)}${x.bonus ? ` <span class="rw-badge">• ${escapeHTML(x.bonus)}</span>` : ''}</div>
        <div class="meta"><span>${escapeHTML(x.source)}${x.samples != null ? ` • ${x.samples} hist` : ''}</span><span>${escapeHTML(x.rating || '—')}</span></div>
        <div class="meta"><span>Price: ${x.price != null ? fmtMoney(x.price) : '—'}</span><span>Est: ${x.estimated != null ? fmtMoney(x.estimated) : '—'}</span></div>
        <div class="meta"><span>Profit: ${x.profit != null ? (x.profit >= 0 ? '+' : '') + fmtMoney(x.profit) : '—'}</span><span>ROI: ${x.roi != null ? fmtPct(x.roi) : '—'}</span></div>
        <div class="rw-mini">${escapeHTML(x.qualityText || '—')}</div>
      </div>
    `).join('');
  }

  function compactAndSort(findings) {
    const map = new Map();
    for (const f of findings) {
      if (!f.key) continue;
      const prev = map.get(f.key);
      if (!prev || compareFinding(f, prev) < 0) map.set(f.key, f);
    }
    return [...map.values()].sort((a, b) => compareFinding(b, a));
  }

  function compareFinding(a, b) {
    const ar = Number.isFinite(a.roi) ? a.roi : -9999;
    const br = Number.isFinite(b.roi) ? b.roi : -9999;
    if (ar !== br) return ar - br;
    const ap = Number.isFinite(a.profit) ? a.profit : -999999999;
    const bp = Number.isFinite(b.profit) ? b.profit : -999999999;
    return ap - bp;
  }

  function makeKey(name, bonus) {
    return `${slug(name)}|${slug(bonus || 'no-bonus')}`;
  }

  function slug(s) {
    return String(s || '')
      .toLowerCase()
      .replace(/&/g, ' and ')
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/^-+|-+$/g, '') || 'unknown';
  }

  function cleanName(s) {
    return String(s || '')
      .replace(/\s+/g, ' ')
      .replace(/^[•·\-\s]+|[•·\-\s]+$/g, '')
      .trim();
  }

  function cleanBonus(s) {
    return String(s || '')
      .replace(/\s+/g, ' ')
      .replace(/^Bonus:\s*/i, '')
      .replace(/[\u{1F300}-\u{1FAFF}]/gu, '')
      .trim();
  }

  function parseFirstMoney(text) {
    const m = String(text || '').match(/\$([\d,]+(?:\.\d+)?)/);
    return m ? money(m[1]) : null;
  }

  function money(s) {
    if (s == null) return null;
    const n = Number(String(s).replace(/[^\d.]/g, ''));
    return Number.isFinite(n) ? Math.round(n) : null;
  }

  function fmtMoney(n) {
    if (!Number.isFinite(n)) return '—';
    const abs = Math.abs(n);
    const sign = n < 0 ? '-' : '';
    if (abs >= 1e9) return sign + '$' + (abs / 1e9).toFixed(abs >= 10e9 ? 1 : 2) + 'b';
    if (abs >= 1e6) return sign + '$' + (abs / 1e6).toFixed(abs >= 10e6 ? 1 : 2) + 'm';
    if (abs >= 1e3) return sign + '$' + Math.round(abs / 1e3) + 'k';
    return sign + '$' + Math.round(abs).toLocaleString();
  }

  function fmtPct(n) {
    if (!Number.isFinite(n)) return '—';
    const sign = n >= 0 ? '+' : '';
    return `${sign}${n.toFixed(1)}%`;
  }

  function median(arr) {
    const nums = arr.filter(Number.isFinite).slice().sort((a, b) => a - b);
    const mid = Math.floor(nums.length / 2);
    if (!nums.length) return null;
    return nums.length % 2 ? nums[mid] : Math.round((nums[mid - 1] + nums[mid]) / 2);
  }

  function firstMatch(text, regex) {
    const m = String(text || '').match(regex);
    return m ? (m[1] ?? m[0]) : null;
  }

  function guessTitle(text) {
    const lines = String(text || '').split('\n').map(s => s.trim()).filter(Boolean);
    return cleanName(lines.find(line => line.length > 2 && line.length < 60 && !/^\$/.test(line)) || 'Unknown RW');
  }

  function normalizeText(text) {
    return String(text || '')
      .replace(/\u00a0/g, ' ')
      .replace(/[\t\r]+/g, '\n')
      .replace(/\n{3,}/g, '\n\n')
      .trim();
  }

  function escapeHTML(s) {
    return String(s || '').replace(/[&<>'"]/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[ch]));
  }

  function isVisible(el) {
    if (!el || !el.isConnected) return false;
    const st = getComputedStyle(el);
    if (st.display === 'none' || st.visibility === 'hidden' || st.opacity === '0') return false;
    const rect = el.getBoundingClientRect();
    return rect.bottom > 0 && rect.right > 0 && rect.top < (window.innerHeight || document.documentElement.clientHeight) && rect.left < (window.innerWidth || document.documentElement.clientWidth);
  }

  function applySavedPosition() {
    const pos = loadJSON(STORE_KEYS.pos, null);
    if (!pos) return;
    if (Number.isFinite(pos.left)) widget.style.left = `${pos.left}px`;
    if (Number.isFinite(pos.top)) widget.style.top = `${pos.top}px`;
  }

  function makeDraggable(root, handle) {
    handle.addEventListener('pointerdown', (e) => {
      if (e.target.closest('button')) return;

      const rect = root.getBoundingClientRect();
      drag.pointerId = e.pointerId;
      drag.startX = e.clientX;
      drag.startY = e.clientY;
      drag.offsetX = e.clientX - rect.left;
      drag.offsetY = e.clientY - rect.top;
      drag.moved = false;
      drag.active = true;

      handle.setPointerCapture(e.pointerId);
      root.style.transition = 'none';
      e.preventDefault();
    });

    window.addEventListener('pointermove', (e) => {
      if (!drag.active || e.pointerId !== drag.pointerId) return;
      const dx = Math.abs(e.clientX - drag.startX);
      const dy = Math.abs(e.clientY - drag.startY);
      if (dx > 5 || dy > 5) drag.moved = true;

      const left = e.clientX - drag.offsetX;
      const top = e.clientY - drag.offsetY;

      const maxLeft = Math.max(0, window.innerWidth - root.offsetWidth);
      const maxTop = Math.max(0, window.innerHeight - root.offsetHeight);

      root.style.left = `${clamp(left, 0, maxLeft)}px`;
      root.style.top = `${clamp(top, 0, maxTop)}px`;
    });

    window.addEventListener('pointerup', (e) => {
      if (e.pointerId !== drag.pointerId) return;
      if (!drag.active) return;
      drag.active = false;
      root.style.transition = '';

      if (!drag.moved) {
        toggleWidget();
        return;
      }

      snapToCorner(root);
      saveJSON(STORE_KEYS.pos, {
        left: Math.round(parseFloat(root.style.left) || 0),
        top: Math.round(parseFloat(root.style.top) || 0),
      });
    });

    window.addEventListener('pointercancel', () => {
      drag.active = false;
      root.style.transition = '';
    });
  }

  function snapToCorner(root) {
    const rect = root.getBoundingClientRect();
    const w = rect.width || root.offsetWidth || 320;
    const h = rect.height || root.offsetHeight || 160;
    const margin = 6;

    const centerX = rect.left + w / 2;
    const centerY = rect.top + h / 2;
    const targetLeft = centerX < window.innerWidth / 2 ? margin : Math.max(margin, window.innerWidth - w - margin);
    const targetTop = centerY < window.innerHeight / 2 ? margin : Math.max(margin, window.innerHeight - h - margin);

    root.style.left = `${clamp(targetLeft, margin, Math.max(margin, window.innerWidth - w - margin))}px`;
    root.style.top = `${clamp(targetTop, margin, Math.max(margin, window.innerHeight - h - margin))}px`;
  }

  function clamp(n, min, max) {
    return Math.max(min, Math.min(max, n));
  }

  function loadJSON(key, fallback) {
    try {
      const raw = typeof GM_getValue === 'function' ? GM_getValue(key, null) : localStorage.getItem(key);
      if (!raw) return fallback;
      const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
      return parsed ?? fallback;
    } catch {
      return fallback;
    }
  }

  function saveJSON(key, value) {
    try {
      const raw = JSON.stringify(value);
      if (typeof GM_setValue === 'function') GM_setValue(key, raw);
      else localStorage.setItem(key, raw);
    } catch (err) {
      console.warn('[RW Scanner] save failed', err);
    }
  }
})();