OC Reward Values

Shows the real market value of completed-OC reward items (the paintings/weapons Torn prices at $0 or a stale catalog price) and a per-OC "Items total", reading live item-market prices straight from Torn with YOUR own API key. Talks only to api.torn.com. Works in Torn PDA.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         OC Reward Values
// @namespace    RussianRob
// @version      1.3.1
// @description  Shows the real market value of completed-OC reward items (the paintings/weapons Torn prices at $0 or a stale catalog price) and a per-OC "Items total", reading live item-market prices straight from Torn with YOUR own API key. Talks only to api.torn.com. Works in Torn PDA.
// @author       RussianRob
// @license      MIT
// @match        https://www.torn.com/factions.php*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @connect      api.torn.com
// ==/UserScript==

(function () {
  "use strict";
  const SCRIPT_VERSION = "1.3.1";
  const KEY_STORE  = "ocwk_torn_api_key";
  const CACHE_KEY  = "ocwk_listings_v3";
  const TTL_MS     = 10 * 60 * 1000;
  const FETCH_GAP  = 350;
  const API_BASE   = "https://api.torn.com/v2/market/";
  const BAR_ID     = "ocw-keybar";

  let _apiKey = "";
  let _byId = {};
  let _byName = {};
  let _cache = {};
  let _fetching = false;
  let _state = "none";
  let _expanded = false;
  let _barSig = null;
  let _idName = {};

  const fmt = (n) => "$" + Number(n).toLocaleString("en-US");
  function isVisible(el) { return !!(el && el.getClientRects && el.getClientRects().length > 0); }

  function loadKey()  { try { _apiKey = (GM_getValue(KEY_STORE, "") || "").trim(); } catch (_) { _apiKey = ""; } }
  function saveKey(k) { _apiKey = String(k || "").trim(); try { GM_setValue(KEY_STORE, _apiKey); } catch (_) {} }
  function clearKey() { _apiKey = ""; try { GM_setValue(KEY_STORE, ""); } catch (_) {} }
  function loadCache() {
    try { const raw = GM_getValue(CACHE_KEY, ""); if (raw) { const o = JSON.parse(raw); if (o && typeof o === "object") _cache = o; } } catch (_) { _cache = {}; }
    const now = Date.now();
    for (const [id, e] of Object.entries(_cache)) {
      if (e && now - (e.at || 0) < TTL_MS) { _byId[id] = Number(e.price) || 0; if (e.name && e.price > 0) _byName[String(e.name).toLowerCase()] = e.price; }
    }
  }
  function saveCache() { try { GM_setValue(CACHE_KEY, JSON.stringify(_cache)); } catch (_) {} }

  function fetchItem(id, cb) {
    const url = API_BASE + id + "/itemmarket?key=" + encodeURIComponent(_apiKey) + "&comment=oc-item-worth";
    try {
      GM_xmlhttpRequest({
        method: "GET", url, timeout: 15000,
        onload: (resp) => {
          let d = null; try { d = JSON.parse(resp.responseText); } catch (_) {}
          if (!d) { cb("parse"); return; }
          if (d.error) { cb(d.error.code); return; }
          const im = d.itemmarket || {};
          const name = (im.item && im.item.name) || (_cache[id] && _cache[id].name) || null;
          const avg = Number(im.item && im.item.average_price) || 0;
          const HARD_CAP = 100000000000;
          const cap = avg > 0 ? Math.min(avg * 5, HARD_CAP) : HARD_CAP;
          const listings = Array.isArray(im.listings) ? im.listings : [];
          let lowest = 0;
          for (const l of listings) { const p = Number(l && l.price) || 0; if (p > 0 && p <= cap && (lowest === 0 || p < lowest)) lowest = p; }
          if (lowest === 0 && avg > 0) lowest = avg;
          _cache[id] = { name, price: lowest, at: Date.now() };
          _byId[id] = lowest;
          if (name && lowest > 0) _byName[name.toLowerCase()] = lowest;
          cb(null);
        },
        onerror: () => cb("net"),
        ontimeout: () => cb("timeout"),
      });
    } catch (_) { cb("net"); }
  }
  function fetchMissing(ids, done) {
    if (_fetching) { done(); return; }
    const need = ids.filter((id) => !(id in _byId));
    if (!need.length) { if (_apiKey && _state !== "ok" && !TERMINAL_ERR[_state]) { _state = "ok"; renderBar(); } done(); return; }
    _fetching = true;
    if (_state !== "ok") { _state = "checking"; renderBar(); }
    let i = 0;
    const step = () => {
      if (i >= need.length) { _fetching = false; saveCache(); if (_state === "checking") { _state = "ok"; renderBar(); } done(); schedule(); return; }
      const id = need[i++];
      fetchItem(id, (err) => {
        if (err === 2 || err === 1) { _state = "invalid"; _fetching = false; renderBar(); return; }
        if (err === 16) { _state = "lowaccess"; _fetching = false; renderBar(); return; }
        if (err === 18) { _state = "paused"; _fetching = false; renderBar(); return; }
        if (err === 5) { _state = "ratelimited"; saveCache(); renderBar(); setTimeout(() => { _fetching = false; schedule(); }, 5000); return; }
        if (err == null && _state !== "ok") { _state = "ok"; renderBar(); }
        setTimeout(step, FETCH_GAP);
      });
    };
    step();
  }

  const WORTH_RE = /worth\s*\$?\s*([\d,]+)/i;
  function rewriteWorth() {
    for (const [id, nm] of Object.entries(_idName)) { const v = _byId[id]; if (nm && v > 0) _byName[nm.toLowerCase()] = v; }
    if (!_byName || !Object.keys(_byName).length) return;
    const w1 = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
    const hits = []; let n;
    while ((n = w1.nextNode())) { if (WORTH_RE.test(n.nodeValue || "") && isVisible(n.parentElement)) hits.push(n); }
    for (const tn of hits) {
      const txt = tn.nodeValue;
      let name = null, qty = 1;
      const m = txt.match(/(\d+)\s*x?\s+(.+?)\s+worth\s*\$?\s*[\d,]+/i);
      if (m) { qty = parseInt(m[1], 10) || 1; name = m[2].trim(); }
      else {
        const ancText = (tn.parentElement && tn.parentElement.textContent || "").toLowerCase();
        for (const k of Object.keys(_byName)) { if (ancText.includes(k)) { name = k; const qm = ancText.match(/(\d+)\s*x/); if (qm) qty = parseInt(qm[1], 10) || 1; break; } }
      }
      if (!name) continue;
      const unit = _byName[name.toLowerCase()];
      if (!(unit > 0)) continue;
      const want = unit * qty;
      const shown = parseInt((txt.match(WORTH_RE)[1] || "0").replace(/,/g, ""), 10);
      if (shown === want) continue;
      tn.nodeValue = txt.replace(WORTH_RE, () => "worth " + fmt(want));
    }
  }

  function scanRewardLists() {
    const lists = new Map();
    const imgs = document.querySelectorAll('img.torn-item, img[src*="/images/items/"]');
    for (const img of imgs) {
      if (!isVisible(img)) continue;
      const m = (img.getAttribute("src") || img.src || "").match(/\/items\/(\d+)\b/);
      if (!m) continue;
      const ul = img.closest('[class*="reward___"]');
      if (!ul) continue;
      const id = m[1];
      const container = img.closest('[class*="container___"]') || img.parentElement;
      const nm = (container && container.getAttribute && container.getAttribute("aria-label")) || img.getAttribute("alt") || "";
      if (nm) _idName[id] = nm;
      const qEl = container && container.querySelector('[class*="quantityContainer"]');
      let qty = 1;
      if (qEl) { const q = parseInt((qEl.textContent || "").replace(/[^\d]/g, ""), 10); if (q > 0) qty = q; }
      let g = lists.get(ul); if (!g) { g = new Map(); lists.set(ul, g); }
      if (!g.has(id) || qty > g.get(id)) g.set(id, qty);
    }
    return lists;
  }
  function injectTotals() {
    document.querySelectorAll(".ocw-oc-total").forEach((el) => {
      const prev = el.previousElementSibling;
      if (!(prev && prev.matches && prev.matches('[class*="reward___"]'))) el.remove();
    });
    const lists = scanRewardLists();
    for (const [ul, byId] of lists) {
      if (!document.contains(ul)) continue;
      let sum = 0;
      for (const [id, qty] of byId) { const v = _byId[id]; if (v > 0) sum += v * qty; }
      if (!(sum > 0)) continue;
      let el = ul.nextElementSibling;
      if (!(el && el.classList && el.classList.contains("ocw-oc-total"))) {
        el = document.createElement("div");
        el.className = "ocw-oc-total";
        el.style.cssText = "margin:5px 2px 3px;font-size:12px;font-weight:700;color:#46d369;letter-spacing:.2px;font-family:inherit;";
        ul.insertAdjacentElement("afterend", el);
      }
      const label = "💰 Items total: " + fmt(sum);
      if (el.textContent !== label) el.textContent = label;
    }
  }

  function statusText() {
    switch (_state) {
      case "ok":          return "✓ OC prices: your Torn key";
      case "checking":    return "⏳ Checking key…";
      case "invalid":     return "⚠ Invalid API key — tap to fix";
      case "lowaccess":   return "⚠ Key access too low — tap to fix";
      case "paused":      return "⚠ API key paused — tap to fix";
      case "ratelimited": return "⏳ Rate-limited — retrying…";
      default:            return "🔑 Set a Torn API key to value OC rewards";
    }
  }
  function collapsedHTML() {
    const hint = _apiKey ? "change" : "tap to set";
    return '<span data-action="toggle" style="cursor:pointer;display:inline-flex;align-items:center;gap:8px">' +
      '<span>' + statusText() + '</span>' +
      '<span style="opacity:.55;font-size:11px">' + hint + '</span>' +
      '<span style="opacity:.4;font-size:10px">v' + SCRIPT_VERSION + '</span></span>';
  }
  function panelHTML() {
    const cur = (_apiKey || "").replace(/[^A-Za-z0-9]/g, "");
    let note = "";
    if (_state === "invalid")   note = '<div style="margin-top:4px;color:#ff6b6b">Key was rejected — paste a valid one.</div>';
    if (_state === "lowaccess") note = '<div style="margin-top:4px;color:#ff6b6b">This key\'s access level is too low. A Limited Access key works.</div>';
    if (_state === "paused")    note = '<div style="margin-top:4px;color:#ff6b6b">This key is paused in your Torn API settings.</div>';
    return '' +
      '<div style="font-weight:700;margin-bottom:5px">OC item prices — Torn API key</div>' +
      '<input id="ocw-keyinput" type="text" autocomplete="off" spellcheck="false" placeholder="Paste Torn API key" value="' + cur + '" ' +
        'style="width:240px;max-width:70%;padding:4px 6px;border-radius:4px;border:1px solid #555;background:#1a1a1a;color:#eee;font-size:12px">' +
      '<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">' +
        '<button data-action="save"  style="padding:3px 12px;border-radius:4px;border:0;background:#2e7d32;color:#fff;font-weight:700;cursor:pointer">Save</button>' +
        '<button data-action="clear" style="padding:3px 12px;border-radius:4px;border:0;background:#555;color:#fff;cursor:pointer">Clear</button>' +
        '<button data-action="close" style="padding:3px 12px;border-radius:4px;border:0;background:#333;color:#bbb;cursor:pointer">Close</button>' +
      '</div>' +
      note +
      '<div style="margin-top:6px;opacity:.65;font-size:10px;line-height:1.35">A <b>Limited Access</b> key is enough. Stored only in this browser; sent only to api.torn.com. Create one in Torn → Settings → API Keys.</div>';
  }

  function findTabStrip() {
    const root = document.getElementById("faction-crimes-root");
    if (!root) return null;
    const cands = root.querySelectorAll('[class*="buttonsContainer"], [class*="tabs"], ul, nav');
    for (const c of cands) {
      const t = c.textContent || "";
      if (/\bCompleted\b/.test(t) && (/\bRecruiting\b/.test(t) || /\bPlanning\b/.test(t))) return c;
    }
    return root.querySelector('[class*="buttonsContainer"]');
  }
  function dockBar() {
    let bar = document.getElementById(BAR_ID);
    if (!bar) {
      bar = document.createElement("div");
      bar.id = BAR_ID;
      bar.style.cssText = "margin:7px 0;padding:7px 11px;border-radius:8px;background:rgba(28,28,28,.55);border:1px solid #383838;color:#ddd;font-family:inherit;font-size:12px";
      bar.addEventListener("click", onBarClick);
      _barSig = null;
    }
    const strip = findTabStrip();
    if (strip && strip.parentElement) {
      if (!bar.isConnected || bar.parentElement !== strip.parentElement) strip.parentElement.insertBefore(bar, strip.nextElementSibling);
    } else {
      const ul = document.querySelector('[class*="reward___"]');
      const top = ul ? (ul.closest('[class*="wrapper___"]') || ul) : null;
      if (top && top.parentElement) { if (!bar.isConnected || bar.parentElement !== top.parentElement) top.parentElement.insertBefore(bar, top); }
      else if (bar.parentElement !== document.body) { document.body.appendChild(bar); bar.style.position = "fixed"; bar.style.left = "8px"; bar.style.bottom = "8px"; bar.style.zIndex = "2147483600"; }
    }
    renderBar();
    return bar;
  }
  function removeBar() { const b = document.getElementById(BAR_ID); if (b) b.remove(); _barSig = null; _expanded = false; }
  function onBarClick(e) {
    const t = e.target;
    const act = t && t.getAttribute && t.getAttribute("data-action");
    if (act === "save") {
      const inp = document.getElementById("ocw-keyinput");
      saveKey(inp ? inp.value : "");
      _expanded = false; _barSig = null;
      if (_apiKey) { _byId = {}; _byName = {}; _state = "checking"; renderBar(); schedule(); }
      else { _state = "none"; renderBar(); }
      return;
    }
    if (act === "clear") {
      clearKey(); _byId = {}; _byName = {}; _expanded = false; _state = "none"; _barSig = null;
      document.querySelectorAll(".ocw-oc-total").forEach((el) => el.remove());
      renderBar();
      return;
    }
    if (act === "close") { _expanded = false; _barSig = null; renderBar(); return; }
    _expanded = !_expanded; _barSig = null; renderBar();
  }
  function renderBar() {
    const bar = document.getElementById(BAR_ID);
    if (!bar) return;
    if (_expanded) {
      const sig = "exp:" + _state;
      if (_barSig === sig) return;
      const prev = document.getElementById("ocw-keyinput");
      const pv = prev ? prev.value : null, ps = prev ? prev.selectionStart : null, pe = prev ? prev.selectionEnd : null;
      _barSig = sig; bar.innerHTML = panelHTML();
      const inp = document.getElementById("ocw-keyinput");
      if (inp) { if (pv != null) inp.value = pv; try { inp.focus(); if (ps != null) inp.setSelectionRange(ps, pe); } catch (_) {} }
      return;
    }
    const sig = "col:" + statusText() + (_apiKey ? ":k" : "");
    if (_barSig === sig) return;
    _barSig = sig;
    bar.innerHTML = collapsedHTML();
  }

  const TERMINAL_ERR = { invalid: 1, lowaccess: 1, paused: 1 };
  function tick() {
    const lists = scanRewardLists();
    if (!lists.size) { removeBar(); return; }
    dockBar();
    if (!_apiKey) { injectTotals(); return; }
    if (!TERMINAL_ERR[_state]) {
      const ids = new Set();
      for (const byId of lists.values()) for (const id of byId.keys()) ids.add(id);
      fetchMissing(Array.from(ids), () => { rewriteWorth(); injectTotals(); });
    }
    rewriteWorth(); injectTotals();
  }
  let _pending = null;
  function schedule() { if (_pending) return; _pending = setTimeout(() => { _pending = null; try { tick(); } catch (_) {} }, 300); }

  try {
    if (typeof GM_registerMenuCommand === "function") {
      GM_registerMenuCommand("Set OC API key (Torn)", () => {
        const k = prompt("Paste your Torn API key (Limited Access is enough):", _apiKey || "");
        if (k != null) { saveKey(k); _byId = {}; _byName = {}; _state = _apiKey ? "checking" : "none"; _barSig = null; schedule(); }
      });
      GM_registerMenuCommand("Clear OC API key", () => { clearKey(); _byId = {}; _byName = {}; _state = "none"; _barSig = null; schedule(); });
    }
  } catch (_) {}

  loadKey();
  loadCache();
  if (_apiKey) _state = "ok";
  tick();
  try { new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true }); } catch (_) {}
  setInterval(() => { if (!document.getElementById(BAR_ID) && document.querySelector('[class*="reward___"]')) schedule(); }, 2000);
})();