Torn Inventory Exporter

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

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

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

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

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

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

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

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

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

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

이 스타일을 설치하려면 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();
})();