Market Resale Helper TornW3B

Calculate suggested resale prices (quantile, median, cluster) on TornW3B

// ==UserScript==
// @name         Market Resale Helper TornW3B
// @namespace    http://tampermonkey.net/
// @version      2025-08-26 v2
// @description  Calculate suggested resale prices (quantile, median, cluster) on TornW3B
// @match        *://weav3r.dev/item/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=weav3r.dev
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    console.log("✅ Market Helper v6 started");

    // --- Config defaults ---
    let BULK_QTY_MIN = 20;
    let BULK_PCT_MIN = 0.10;

    // --- Utils ---
    const fmt = (v) => (v !== null && v !== undefined) ? v.toLocaleString() : "—";
    const diffPct = (p, marketMin) =>
        (p && marketMin) ? ((p - marketMin) / marketMin * 100).toFixed(1) + "%" : "—";

    // --- Pricing methods ---
    function calcQuantile(offers, fraction) {
        offers.sort((a,b) => a.price - b.price);
        const total = offers.reduce((s,o) => s + o.qty, 0);
        let cumulative = 0;
        const threshold = total * fraction;
        for (let o of offers) {
            cumulative += o.qty;
            if (cumulative >= threshold) return o.price - 1;
        }
        return offers[offers.length-1].price;
    }

    function calcMedian(offers) {
        offers.sort((a,b) => a.price - b.price);
        const total = offers.reduce((s,o) => s + o.qty, 0);
        const half = Math.floor(total / 2);
        let cumulative = 0;
        for (let o of offers) {
            cumulative += o.qty;
            if (cumulative >= half) return o.price;
        }
        return offers[Math.floor(offers.length/2)].price;
    }

    function calcCluster(offers) {
        offers.sort((a,b) => a.price - b.price);
        let bestPrice = offers[0].price;
        let bestCount = 0;
        for (let i = 0; i < offers.length; i++) {
            const p = offers[i].price;
            const upper = p * 1.05;
            let count = 0;
            for (let j = i; j < offers.length; j++) {
                if (offers[j].price <= upper) count += offers[j].qty;
                else break;
            }
            if (count > bestCount) {
                bestCount = count;
                bestPrice = p;
            }
        }
        return bestPrice - 1;
    }

    function calcClusterCapped(offers, marketMin) {
        let cluster = calcCluster(offers);
        let cap = Math.round(marketMin * 1.5);
        return Math.min(cluster, cap);
    }

    // --- Bulk detection ---
    function detectBulkAnchor(offers, marketMin) {
        const totalQty = offers.reduce((s,o) => s + o.qty, 0);
        offers.sort((a,b) => a.price - b.price);

        for (let i = 0; i < offers.length; i++) {
            let bandQty = 0;
            const p = offers[i].price;
            const upper = p * 1.05;
            for (let j = i; j < offers.length; j++) {
                if (offers[j].price <= upper) bandQty += offers[j].qty;
                else break;
            }
            if (bandQty >= BULK_QTY_MIN || bandQty >= totalQty * BULK_PCT_MIN) {
                if (p <= marketMin * 1.5) {
                    return {price: p, qty: bandQty};
                }
            }
        }
        return null;
    }

    // --- Parse offers ---
    function parseOffers() {
        let offers = [];
        let inferredMarkets = [];

        // grab all grid rows
        let rows = document.querySelectorAll("div.grid");

        rows.forEach((row, idx) => {
            let cols = row.querySelectorAll(":scope > div");
            if (cols.length === 5) {
                // skip header row (first one with "Player/Qty/Price")
                if (idx === 0) return;

                // qty
                let qty = parseInt(cols[1].innerText.replace(/[^0-9]/g, ""), 10);

                // price
                let priceSpan = cols[2].querySelector("span");
                let priceText = priceSpan ? priceSpan.textContent.trim() : "0";
                let price = parseInt(priceText.replace(/[^0-9]/g, ""), 10);

                // vsMarket
                let vsSpan = cols[3].querySelector("span");
                let vsText = vsSpan ? vsSpan.textContent.trim() : "0"; // "-12%" or "+25%"
                let vsMarket = parseFloat(vsText.replace("%","").replace("+","")) || 0;

                if (!isNaN(price) && !isNaN(qty)) {
                    let inferred = price / (1 + vsMarket/100);
                    inferredMarkets.push(inferred);
                    console.log("Row parsed:", {
                        qty, price, rawVs: vsText, vsMarket, inferredMarket: inferred.toFixed(2)
                    });
                    offers.push({price, qty, vsMarket});
                }
            }
        });

        // median inferred market
        inferredMarkets.sort((a,b) => a-b);
        let marketMin = inferredMarkets.length > 0
            ? Math.round(inferredMarkets[Math.floor(inferredMarkets.length/2)])
            : null;

        console.log("✅ Parsed offers:", offers.length, "| MarketMin:", marketMin);
        return {offers, marketMin};
    }

    // --- Trim dataset ---
    function trimOffers(offers) {
        offers.sort((a,b) => a.price - b.price);
        return offers.filter(o => o.vsMarket <= 200); // remove >+100% vs market
    }

    // --- Auto-scroll ---
    function autoScrollTable(callback) {
        let container = document.querySelector("div.overflow-y-auto");
        if (!container) { callback(); return; }
        let lastCount = 0, tries = 0;
        const interval = setInterval(() => {
            container.scrollBy(0, 800);
            let rows = document.querySelectorAll("div.grid");
            let dataRows = Array.from(rows).filter(r => r.querySelectorAll(":scope > div").length === 5);
            if (dataRows.length > lastCount) { lastCount = dataRows.length; tries = 0; }
            else tries++;
            if (tries > 5) {
                clearInterval(interval);
                container.scrollTo(0, 0);
                console.log("✅ Auto-scroll finished with", dataRows.length, "rows");
                callback();
            }
        }, 500);
    }

    // --- Show results ---
    function injectResult() {
        let parsed = parseOffers();
        let offers = parsed.offers;
        let marketMin = parsed.marketMin;
        if (offers.length === 0 || !marketMin) return;

        let relevant = trimOffers(offers);
        console.log("📊 Relevant offers:", relevant.length, "of", offers.length);

        let fast    = calcQuantile(relevant, 0.1);
        let medium  = calcQuantile(relevant, 0.3);
        let slow    = calcQuantile(relevant, 0.6);
        let median  = calcMedian(relevant);
        let cluster = calcCluster(relevant);
        let capped  = calcClusterCapped(relevant, marketMin);
        let bulk    = detectBulkAnchor(relevant, marketMin);

        let final   = bulk ? bulk.price : capped;

        let avgPrice = Math.round(
            relevant.reduce((s,o) => s + o.price * o.qty, 0) /
            relevant.reduce((s,o) => s + o.qty, 0)
        );

        let box = document.getElementById("market-helper-box");
        if (!box) {
            box = document.createElement("div");
            box.id = "market-helper-box";
            box.style.cssText = `
                padding:10px;margin:10px 0;
                border:2px solid #4caf50;background:#e8f5e9;
                font-size:14px;border-radius:8px;
            `;
            let container = document.querySelector("div.overflow-x-auto");
            if (container) container.prepend(box);
            else document.body.prepend(box);

            // controls
            let controls = document.createElement("div");
            controls.style.margin = "5px 0";
            controls.innerHTML = `
                Bulk min qty: <input id="bulkQtyInput" type="number" value="${BULK_QTY_MIN}" style="width:60px">
                Bulk min %: <input id="bulkPctInput" type="number" value="${BULK_PCT_MIN*100}" style="width:60px">%
            `;
            box.before(controls);

            document.getElementById("bulkQtyInput").addEventListener("change", e=>{
                BULK_QTY_MIN = parseInt(e.target.value,10) || 20;
                injectResult();
            });
            document.getElementById("bulkPctInput").addEventListener("change", e=>{
                BULK_PCT_MIN = (parseFloat(e.target.value)||10)/100;
                injectResult();
            });
        }

        box.innerHTML = `
            <b>💡 Smart Resale Analysis (${relevant.length}/${offers.length} offers):</b><br>
            ⭐ Final Recommended: <b style="color:darkgreen;font-size:16px">${fmt(final)}</b>
              (${diffPct(final, marketMin)} vs market)<br>
            Bulk Anchor: <b>${bulk ? fmt(bulk.price)+" ("+bulk.qty+" items)" : "—"}</b><br>
            <hr>
            Fast (10%): <b>${fmt(fast)}</b> (${diffPct(fast, marketMin)})<br>
            Medium (30%): <b>${fmt(medium)}</b> (${diffPct(medium, marketMin)})<br>
            Slow (60%): <b>${fmt(slow)}</b> (${diffPct(slow, marketMin)})<br>
            Median: <b>${fmt(median)}</b> (${diffPct(median, marketMin)})<br>
            Cluster: <b>${fmt(cluster)}</b> (${diffPct(cluster, marketMin)})<br>
            Cluster+Cap: <b>${fmt(capped)}</b> (${diffPct(capped, marketMin)})<br>
            <hr>
            Market min (from vsMarket): <b>${fmt(marketMin)}</b><br>
            Average (trimmed): <b>${fmt(avgPrice)}</b>
        `;
    }

    // --- Runner ---
    function runFullCycle() {
        autoScrollTable(() => injectResult());
    }

    setTimeout(runFullCycle, 20000);
    setInterval(runFullCycle, 300000);

})();