Market Resale Helper TornW3B

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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

})();