Torn RW Scanner Widget

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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