Torn Inventory Exporter

Export Torn inventory items to clipboard (TSV) or download as CSV. script captures items while you scroll (no autoscroll).

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

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.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Torn Inventory Exporter 
// @namespace    https://torn.com/
// @version      0.4.2
// @description  Export Torn inventory items to clipboard (TSV) or download as CSV. script captures items while you scroll (no autoscroll).
// @match        https://www.torn.com/item.php*
// @match        https://www.torn.com/bazaar.php*
// @run-at       document-idle
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// ==/UserScript==

(function () {
  "use strict";

  const FLOAT_ID = "ttItemExporterFloatBtn";
  const STYLE_ID = "ttItemExporterStyle";

  // -------------------- utils --------------------
  function normInt(s) {
    if (!s) return 0;
    const m = String(s).replace(/[^\d]/g, "");
    return m ? parseInt(m, 10) : 0;
  }

 function normMoney(s) {
  if (!s) return 0;
  const text = String(s);

  // Match a real integer amount (comma-grouped correctly or plain digits)
  const AMT = /(\d{1,3}(?:,\d{3})+|\d+)/;

  // Prefer first $-prefixed amount
  const m = text.match(new RegExp("\\$\\s*" + AMT.source));
  if (m && m[1]) return parseInt(m[1].replace(/,/g, ""), 10);

  // Fallback: first valid integer anywhere
  const m2 = text.match(AMT);
  if (m2 && m2[1]) return parseInt(m2[1].replace(/,/g, ""), 10);

  return 0;
}


  function toTSV(rows) {
    return rows.map(r => r.map(v => String(v ?? "")).join("\t")).join("\n");
  }

  function toCSV(rows) {
    const esc = (v) => {
      const s = String(v ?? "");
      if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
      return s;
    };
    return rows.map(r => r.map(esc).join(",")).join("\n");
  }

  async function copyText(text) {
    try {
      if (typeof GM_setClipboard === "function") {
        GM_setClipboard(text, { type: "text", mimetype: "text/plain" });
        return true;
      }
    } catch {}
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch {}
    try {
      const ta = document.createElement("textarea");
      ta.value = text;
      ta.style.position = "fixed";
      ta.style.left = "-9999px";
      document.body.appendChild(ta);
      ta.select();
      document.execCommand("copy");
      ta.remove();
      return true;
    } catch {}
    return false;
  }

  function downloadFile(filename, content, mime = "text/csv;charset=utf-8") {
    const blob = new Blob([content], { type: mime });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  }

  function nowStamp() {
    const d = new Date();
    const pad = (n) => String(n).padStart(2, "0");
    return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
  }

  function toast(msg) {
    const el = document.createElement("div");
    el.className = "ttItemExporterToast";
    el.textContent = msg;
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 2600);
  }

  function injectStyle() {
    if (document.getElementById(STYLE_ID)) return;
    const style = document.createElement("style");
    style.id = STYLE_ID;
    style.textContent = `
      #${FLOAT_ID}{
  position: fixed;
  right: max(8px, env(safe-area-inset-right));
  top: clamp(8px, 10vh, 140px);
  z-index: 2147483647;
  padding: 9px 10px;
  border-radius: 10px;
  border: 1px solid rgba(255,255,255,0.25);
  background: rgba(0,0,0,0.72);
  color: #fff;
  cursor: pointer;
  font: 13px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
  user-select: none;
}

      #${FLOAT_ID}:hover{ background: rgba(0,0,0,0.86); }
      .ttItemExporterToast{
        position: fixed;
        right: 16px;
        top: 165px;
        z-index: 2147483647;
        padding: 10px 12px;
        border-radius: 10px;
        background: rgba(0,0,0,0.85);
        color: white;
        font: 13px/1.3 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
        box-shadow: 0 6px 20px rgba(0,0,0,0.35);
      }
    `;
    document.documentElement.appendChild(style);
  }

  function currentMode() {
    if (location.pathname.includes("/item.php")) return "inventory";
    if (location.pathname.includes("/bazaar.php")) return "bazaar";
    return "unknown";
  }

  // -------------------- INVENTORY --------------------
  function safeText(el) {
    return (el?.textContent || "").trim();
  }

  function isBorrowedFromFaction(itemLi) {
    return Boolean(
      itemLi.querySelector('button.option-return-to-faction, li[data-action="return"][data-type="armoury"]')
    );
  }

  function scrapeInventoryRows() {
    const lis = Array.from(document.querySelectorAll("ul.itemsList > li[data-item]"));
    const rows = [];
    rows.push(["source", "item_id", "name", "quantity"]);

    const seen = new Set();

    for (const li of lis) {
      if (li.closest(".view-item-info")) continue;

      const item_id = li.getAttribute("data-item") || "";
      if (!item_id) continue;

      if (isBorrowedFromFaction(li)) continue;

      const name =
        safeText(li.querySelector(".title-wrap .name")) ||
        safeText(li.querySelector(".name")) ||
        "";

      if (!name) continue; // ghost nodes

      const qtyAttr = li.getAttribute("data-qty") || "";
      const qtyVisible = safeText(li.querySelector(".item-amount.qty"));
      const qty = normInt(qtyAttr || qtyVisible);

      if (seen.has(item_id)) continue;
      seen.add(item_id);

      rows.push(["inventory", item_id, name, qty]);
    }

    return rows;
  }

  // -------------------- BAZAAR (capture while user scrolls; no autoscroll) --------------------
  // Goal: one row per item_id, total qty correct for non-stack items.
  // We dedupe *per listing slot*, not per (item_id, price). Bazaar rows have translateY positioning; use that + column index.

  const bazaarListingCache = new Map(); // listingKey -> { item_id, name, priceEach, qty }
  const bazaarAgg = new Map();          // item_id -> { name, totalQty, totalPriceQty }
  let bazaarObserverStarted = false;

  function guessItemIdFromImg(imgEl) {
    if (!imgEl) return "";
    const srcset = imgEl.getAttribute("srcset") || "";
    const src = imgEl.getAttribute("src") || "";
    const blob = srcset || src;
    const m = blob.match(/\/images\/items\/(\d+)\//);
    return m ? m[1] : "";
  }

  function getRowTranslateY(rowEl) {
    if (!rowEl) return "";
    const t = rowEl.style?.transform || "";
    const m = t.match(/translateY\(([-\d.]+)px\)/);
    return m ? m[1] : "";
  }

  function getListingKey(cardEl) {
    // Prefer stable identity based on virtualization slot:
    // row translateY + index within row (so multiple same-item cards at same price count separately)
    const row = cardEl.closest('[data-testid="bazaar-items-row"]');
    const y = getRowTranslateY(row);
    const rowItems = row ? row.querySelector('[data-testid="row-items"]') : null;

    if (rowItems) {
      const children = Array.from(rowItems.querySelectorAll('[data-testid="item"]'));
      const idx = Math.max(0, children.indexOf(cardEl));
      if (y !== "") return `y:${y}::i:${idx}`;
    }

    // Fallback (less ideal, but keeps script functioning if DOM changes)
    const img = cardEl.querySelector('img[alt]');
    const item_id = guessItemIdFromImg(img);
    const name = (img?.getAttribute("alt") || "").trim();
    const priceEach = normMoney(cardEl.querySelector('[data-testid="price"]')?.textContent || "");
    return item_id ? `fallback:${item_id}::p:${priceEach}` : `fallbackname:${name}::p:${priceEach}`;
  }

  function applyListingToAgg(listingKey, listing) {
    const prev = bazaarListingCache.get(listingKey);

    // First time seeing this listing slot
    if (!prev) {
      bazaarListingCache.set(listingKey, { ...listing });

      const id = listing.item_id;
      if (!bazaarAgg.has(id)) {
        bazaarAgg.set(id, { name: listing.name || "", totalQty: 0, totalPriceQty: 0 });
      }
      const agg = bazaarAgg.get(id);
      if (listing.name && !agg.name) agg.name = listing.name;

      agg.totalQty += listing.qty;
      agg.totalPriceQty += listing.priceEach * listing.qty;
      return true;
    }

    // Slot seen before: update if something changed (qty/price)
    let changed = false;

    if (listing.name && !prev.name) { prev.name = listing.name; changed = true; }

    // If price changes for a listing slot (rare), adjust aggregates
    if (listing.priceEach !== prev.priceEach || listing.qty !== prev.qty) {
      const agg = bazaarAgg.get(prev.item_id);
      if (agg) {
        // remove old contribution
        agg.totalQty -= prev.qty;
        agg.totalPriceQty -= prev.priceEach * prev.qty;

        // add new contribution
        agg.totalQty += listing.qty;
        agg.totalPriceQty += listing.priceEach * listing.qty;

        if (listing.name && !agg.name) agg.name = listing.name;
      }

      prev.priceEach = listing.priceEach;
      prev.qty = listing.qty;
      prev.item_id = listing.item_id || prev.item_id;

      changed = true;
    }

    return changed;
  }

  function captureBazaarVisible() {
    let cards = [];
    const root = document.querySelector('[data-testid="bazaar-items"]');
    if (root) cards = Array.from(root.querySelectorAll('[data-testid="item"]'));
    if (!cards.length) cards = Array.from(document.querySelectorAll('[data-testid="item"]'));

    let changed = false;

    for (const card of cards) {
      const img = card.querySelector('img[alt]');
      const name = (img?.getAttribute("alt") || "").trim();
      const item_id = guessItemIdFromImg(img);
      if (!item_id) continue; // required for one-row-per-item_id export

      const priceEach = normMoney(card.querySelector('[data-testid="price"]')?.textContent || "");
      const qty = normInt(card.querySelector('[data-testid="amount-value"]')?.textContent || "");

      // NOTE: for non-stack items, qty is often "1" per listing card. :contentReference[oaicite:2]{index=2}
      // The fix is counting multiple listing slots, not trusting "price point" buckets.
      const listingKey = getListingKey(card);

      const didChange = applyListingToAgg(listingKey, { item_id, name, priceEach, qty: qty || 0 });
      if (didChange) changed = true;
    }

    return changed;
  }

  function startBazaarObserver(onUpdate) {
    if (bazaarObserverStarted) return;
    bazaarObserverStarted = true;

    captureBazaarVisible();
    if (typeof onUpdate === "function") onUpdate();

    const obs = new MutationObserver(() => {
      const changed = captureBazaarVisible();
      if (changed && typeof onUpdate === "function") onUpdate();
    });

    obs.observe(document.body, { childList: true, subtree: true });
  }

  function bazaarCapturedCount() {
    return bazaarAgg.size;
  }

  function scrapeBazaarRowsFromCache() {
    const rows = [];
    rows.push(["source", "item_id", "name", "quantity", "avg_price_each"]);

    const items = Array.from(bazaarAgg.entries()).map(([item_id, agg]) => {
      const avg = (agg.totalQty > 0) ? Math.round(agg.totalPriceQty / agg.totalQty) : 0;
      return { item_id, name: agg.name || "", qty: Math.max(0, agg.totalQty || 0), avg_price_each: avg };
    });

    items.sort((a, b) => (a.name || "").localeCompare(b.name || ""));

    for (const it of items) {
      rows.push(["bazaar", it.item_id, it.name, it.qty, it.avg_price_each]);
    }

    return rows;
  }

  // -------------------- UI + export --------------------
  function updateButtonLabel(btn) {
    if (!btn) return;
    const mode = currentMode();
    if (mode === "bazaar") {
      btn.textContent = `Export items (captured: ${bazaarCapturedCount()})`;
      btn.title = "Scroll through your bazaar list first so items are captured, then click to copy TSV (Sheets-friendly).";
    } else if (mode === "inventory") {
      btn.textContent = "Export items";
      btn.title = "Scroll to load everything, then click to copy TSV (Sheets-friendly).";
    } else {
      btn.textContent = "Export items";
      btn.title = "Unsupported page";
    }
  }

  async function exportNow({ copy = true, download = false } = {}) {
    const mode = currentMode();

    try {
      let rows;

      if (mode === "inventory") {
        rows = scrapeInventoryRows();
      } else if (mode === "bazaar") {
        // Capture current viewport too, then export cache.
        captureBazaarVisible();
        rows = scrapeBazaarRowsFromCache();
      } else {
        toast("Unsupported page.");
        return;
      }

      const count = (rows?.length || 0) - 1;
      if (!rows || rows.length <= 1) {
        if (mode === "bazaar") toast("0 bazaar items captured yet — scroll your bazaar list first.");
        else toast("0 items found. Scroll to load everything, then try again.");
        return;
      }

      const tsv = toTSV(rows);
      const csv = toCSV(rows);

      if (copy) {
        const ok = await copyText(tsv);
        toast(ok ? `Copied ${count} rows (paste into Sheets)` : "Clipboard copy failed");
      }
      if (download) {
        downloadFile(`torn-${mode}-${nowStamp()}.csv`, csv);
        toast(`Downloaded CSV (${count} rows)`);
      }

      console.log(`[Torn Export] ${mode} rows:`, rows);
    } catch (err) {
      console.error("[Torn Export] Error:", err);
      toast("Export failed — check console (F12) for error.");
    }
  }

  function addFloatingButton() {
    if (document.getElementById(FLOAT_ID)) return;

    injectStyle();

    const btn = document.createElement("button");
    btn.id = FLOAT_ID;
    btn.type = "button";
    btn.addEventListener("click", () => exportNow({ copy: true, download: false }));

    document.body.appendChild(btn);
    updateButtonLabel(btn);

    if (currentMode() === "bazaar") {
      startBazaarObserver(() => updateButtonLabel(btn));
      // keep label fresh if bazaar route changes
      setInterval(() => updateButtonLabel(btn), 1500);
    }
  }

  // Menu commands
  if (typeof GM_registerMenuCommand === "function") {
    GM_registerMenuCommand("Export (copy TSV to clipboard)", () => exportNow({ copy: true, download: false }));
    GM_registerMenuCommand("Export (download CSV)", () => exportNow({ copy: false, download: true }));
    GM_registerMenuCommand("Export (copy TSV + download CSV)", () => exportNow({ copy: true, download: true }));
  }

  addFloatingButton();
})();