Torn Market Filler

On "Fill" click autofills market item price with lowest market price currently minus $1 (can be customised), fills max quantity for items, marks checkboxes for guns.

// ==UserScript==
// @name         Torn Market Filler
// @namespace    https://github.com/SOLiNARY
// @version      0.4
// @description  On "Fill" click autofills market item price with lowest market price currently minus $1 (can be customised), fills max quantity for items, marks checkboxes for guns.
// @author       Ramin Quluzade, Silmaril [2665762]
// @license      MIT License
// @match        https://www.torn.com/page.php?sid=ItemMarket*
// @match        https://*.torn.com/page.php?sid=ItemMarket*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @run-at       document-idle
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// ==/UserScript==

(async function() {
    'use strict';

    try {
        GM_registerMenuCommand('Set Price Delta', setPriceDelta);
        GM_registerMenuCommand('Set Api Key', function() { checkApiKey(false); });
    } catch (error) {
        console.warn('[TornMarketFiller] Tampermonkey not detected!');
    }

    const itemUrl = "https://api.torn.com/torn/{itemId}?selections=items&key={apiKey}&comment=MarketFiller";
    const marketUrl = "https://api.torn.com/v2/market/{itemId}?selections=itemMarket&key={apiKey}&comment=MarketFiller";
    const marketUrlV2 = "https://api.torn.com/v2/market?id={itemId}&selections=itemMarket&key={apiKey}&comment=MarketFiller";
    let priceDeltaRaw = localStorage.getItem("silmaril-torn-bazaar-filler-price-delta") ?? '-1[0]';
    let apiKey = localStorage.getItem("silmaril-torn-bazaar-filler-apikey") ?? '###PDA-APIKEY###';

    let GM_addStyle = function (s) {
        let style = document.createElement("style");
        style.type = "text/css";
        style.innerHTML = s;
        document.head.appendChild(style);
    };

    GM_addStyle(`#item-market-root [class^=addListingWrapper___] [class^=panels___] [class^=priceInputWrapper___] > .input-money-group > .input-money {border-bottom-left-radius: 0 !important;border-top-left-radius: 0 !important;}#item-market-root [class^=viewListingWrapper___] [class^=priceInputWrapper___] > .input-money-group > .input-money {border-bottom-left-radius: 0 !important;border-top-left-radius: 0 !important;}`);

    const pages = { "AddItems": 10, "ViewItems": 20, "Other": 0};
    let currentPage = pages.Other;
    let holdTimer;
    const CLICK_TO_AUTOFILL = 'Click to autofill the price and quantity if not set already';
    const LOADING_THE_PRICES = 'Loading the prices...';

    const isMobileView = window.innerWidth <= 784;

    const observerTarget = document.querySelector("#item-market-root");
    const observerConfig = { attributes: false, childList: true, characterData: false, subtree: true };
    const observer = new MutationObserver(function(mutations) {
        mutations.forEach(mutationRaw => {
            let mutation = mutationRaw.target;
            currentPage = getCurrentPage();
            if (currentPage == pages.AddItems){
                if (mutation.id.startsWith('headlessui-tabs-panel-')) {
                    mutation.querySelectorAll('[class*=itemRowWrapper___]:not(.silmaril-market-filler-processed) > [class*=itemRow___]:not([class*=grayedOut___]) [class^=priceInputWrapper___]').forEach(x => AddFillButton(x));
                }
                if (mutation.className.indexOf('priceInputWrapper___') > -1){
                    AddFillButton(mutation);
                }
            } else if (currentPage == pages.ViewItems){
                if (mutation.className.startsWith('viewListingWrapper___')) {
                    mutation.querySelectorAll('[class*=itemRowWrapper___]:not(.silmaril-market-filler-processed) > [class*=itemRow___]:not([class*=grayedOut___]) [class^=priceInputWrapper___]').forEach(x => AddFillButton(x));
                }
            }
        });
    });
    observer.observe(observerTarget, observerConfig);

    function AddFillButton(itemPriceElement){
        if (itemPriceElement.querySelector('.silmaril-market-filler-button') != null){
            return;
        }
        const wrapperParent = findParentByCondition(itemPriceElement, (el) => el.className.indexOf('itemRowWrapper___') > -1);
        wrapperParent.classList.add('silmaril-market-filler-processed');
        let itemIdString = wrapperParent.querySelector('[class^=itemRow___] [type=button][class^=viewInfoButton___]').getAttribute('aria-controls');
        let itemImage = wrapperParent.querySelector('[class*=viewInfoButton] img');
        let itemId = currentPage == pages.AddItems ? getItemIdFromString(itemIdString) : getItemIdFromImage(itemImage);
        const span = document.createElement('span');
        span.title = CLICK_TO_AUTOFILL;
        span.className = 'silmaril-market-filler-button input-money-symbol';
        span.setAttribute('data-action-flag', 'fill');
        span.addEventListener('click', async () => {await handleFillClick(itemId)});
        span.addEventListener('mousedown', startHold);
        span.addEventListener('touchstart', startHold);
        span.addEventListener('mouseup', cancelHold);
        span.addEventListener('mouseleave', cancelHold);
        span.addEventListener('touchend', cancelHold);
        span.addEventListener('touchcancel', cancelHold);
        const input = document.createElement('input');
        // input.title = 'Click to autofill the price & quantity if not set already';
        input.type = 'button';
        input.className = 'wai-btn';
        span.appendChild(input);
        itemPriceElement.querySelector('.input-money-group').prepend(span);
    }

    async function GetPrices(itemId){
        let requestUrl = priceDeltaRaw.indexOf('[market]') != -1 ? itemUrl : marketUrlV2;
        requestUrl = requestUrl
            .replace("{itemId}", itemId)
            .replace("{apiKey}", apiKey);
        return fetch(requestUrl)
            .then(response => response.json())
            .then(data => {
            if (data.error != null){
                switch (data.error.code){
                    case 2:
                        apiKey = null;
                        localStorage.setItem("silmaril-torn-bazaar-filler-apikey", null);
                        console.error("[TornMarketFiller] Incorrect Api Key:", data);
                        return {"price": 'Wrong API key!', "amount": 0};
                    case 9:
                        console.warn("[TornMarketFiller] The API is temporarily disabled, please try again later");
                        return {"price": 'API is OFF!', "amount": 0};
                    default:
                        console.error("[TornMarketFiller] Error:", data.error.error);
                        return {"price": data.error.error, "amount": 0};
                }
            }
            if (priceDeltaRaw.indexOf('[market]') != -1){
                return {"price": data.items[itemId].market_value, "amount": 1};
            } else {
                if (data.itemmarket.listings[0].price == null){
                    console.warn("[TornMarketFiller] The API is temporarily disabled, please try again later");
                    return {"price": 'API is OFF!', "amount": 0};
                }
                // temporary hotfix to avoid wrong prices
                if (data.itemmarket.item.id != itemId){
                    return {"price": 'API is BROKEN!', "amount": 0};
                }
                return data.itemmarket.listings;
            }
        })
            .catch(error => {
            console.error("[TornMarketFiller] Error fetching data:", error);
            return 'Failed!';
        });

    }

    function GetPrice(prices){
        if (prices == null){
            return 'No prices loaded';
        }
        if (prices.amount == 0){
            return prices.price;
        }
        if (priceDeltaRaw.indexOf('[market]') != -1) {
            prices = Array(prices);
            let priceDelta = priceDeltaRaw.indexOf('[') == -1 ? priceDeltaRaw : priceDeltaRaw.substring(0, priceDeltaRaw.indexOf('['));
            return Math.round(performOperation(prices[0].price, priceDelta));
        } else if (priceDeltaRaw.indexOf('[median]') != -1) {
            return getMedianPrice(prices);
        } else {
            let marketSlotOffset = priceDeltaRaw.indexOf('[') == -1 ? 0 : parseInt(priceDeltaRaw.substring(priceDeltaRaw.indexOf('[') + 1, priceDeltaRaw.indexOf(']')));
            let priceDeltaWithoutMarketOffset = priceDeltaRaw.indexOf('[') == -1 ? priceDeltaRaw : priceDeltaRaw.substring(0, priceDeltaRaw.indexOf('['));
            return Math.round(performOperation(prices[Math.min(marketSlotOffset, prices.length - 1)].price, priceDeltaWithoutMarketOffset));
        }
    }

    async function handleFillClick(itemId){
        let target = event.target;
        let tooltipId = target.getAttribute('aria-describedby');
        let tooltip = document.getElementById(tooltipId).querySelector('.ui-tooltip-content');
        target.title = LOADING_THE_PRICES;
        if (tooltip != null){
            tooltip.innerHTML = tooltip.innerHTML.replace(CLICK_TO_AUTOFILL, LOADING_THE_PRICES);
        }
        let inputEvent = new Event("input", {bubbles: true});
        let action = target.getAttribute('data-action-flag');
        let prices = await GetPrices(itemId);
        const breakdown = GetPricesBreakdown(prices);
        target.title = breakdown;
        if (tooltip != null){
            tooltip.innerHTML = tooltip.innerHTML.replace(LOADING_THE_PRICES, breakdown);
        }
        let price = action == 'fill' ? GetPrice(prices) : '';

        switchActionFlag(target);
        let parentRow = findParentByCondition(target, (el) => el.className.indexOf('info___') > -1);
        let quantityInputs = parentRow.querySelectorAll('[class^=amountInputWrapper___] .input-money-group > .input-money');
        if (quantityInputs.length > 0){
            if (quantityInputs[0].value.length == 0 || parseInt(quantityInputs[0].value) < 1){
                quantityInputs[0].value = action == 'fill' ? Number.MAX_SAFE_INTEGER : 0;
                quantityInputs[1].value = action == 'fill' ? Number.MAX_SAFE_INTEGER : 0;
            } else {
                quantityInputs[0].value = action == 'clear' ? '' : quantityInputs[0].value;
                quantityInputs[1].value = action == 'clear' ? '' : quantityInputs[1].value;
            }
            quantityInputs[0].dispatchEvent(inputEvent);
        } else {
            let checkbox = parentRow.querySelector('[class^=checkboxWrapper___] > [class^=checkboxContainer___] [type=checkbox]');
            if (action == 'fill' && !checkbox.checked || action == 'clear' && checkbox.checked){
                checkbox.click();
            }
        }

        let priceInputs = target.parentNode.querySelectorAll('input.input-money');
        priceInputs.forEach(x => {x.value = price});
        priceInputs[0].dispatchEvent(inputEvent);
    }

    function getItemIdFromString(string){
        const match = string.match(/-(\d+)-/);
        if (match) {
            const number = match[1];
            return number;
        } else {
            console.error("[TornMarketFiller] ItemId not found!");
            return -1;
        }
    }

    function getItemIdFromImage(image){
        let numberPattern = /\/(\d+)\//;
        let match = image.src.match(numberPattern);
        if (match) {
            return parseInt(match[1], 10);
        } else {
            console.error("[TornMarketFiller] ItemId not found!");
            return -1;
        }
    }

    function switchActionFlag(target){
        switch (target.getAttribute('data-action-flag')){
            case 'fill':
                target.setAttribute('data-action-flag', 'clear');
                break;
            case 'clear':
            default:
                target.setAttribute('data-action-flag', 'fill');
                break;
        }
    }

    function findParentByCondition(element, conditionFn){
        let currentElement = element;
        while (currentElement !== null) {
            if (conditionFn(currentElement)) {
                return currentElement;
            }
            currentElement = currentElement.parentElement;
        }
        return null;
    }

    function setPriceDelta() {
        let userInput = prompt('Enter price delta formula (default: -1[market]):', priceDeltaRaw);
        if (userInput !== null) {
            priceDeltaRaw = userInput;
            localStorage.setItem("silmaril-torn-bazaar-filler-price-delta", userInput);
        } else {
            console.error("[TornMarketFiller] User cancelled the Price Delta input.");
        }
    }

    function GetPricesBreakdown(prices){
        if (prices[0] == undefined){
            prices = Array(prices);
        }
        const sb = new StringBuilder();
        for (let i = 0; i < Math.min(prices.length, 5); i++){
            sb.append(`${prices[i].amount} x ${formatNumberWithCommas(prices[i].price)}`);
            if (i < Math.min(prices.length+1, 5)){
                sb.append('<br>');
            }
        }
        return sb.toString();
    }

    function performOperation(number, operation) {
        // Parse the operation string to extract the operator and value
        const match = operation.match(/^([-+]?)(\d+(?:\.\d+)?)(%)?$/);

        if (!match) {
            throw new Error('Invalid operation string');
        }

        const [, operator, operand, isPercentage] = match;
        const operandValue = parseFloat(operand);

        // Check for percentage and convert if necessary
        const adjustedOperand = isPercentage ? (number * operandValue) / 100 : operandValue;

        // Perform the operation based on the operator
        switch (operator) {
            case '':
            case '+':
                return number + adjustedOperand;
            case '-':
                return number - adjustedOperand;
            default:
                throw new Error('Invalid operator');
        }
    }

    function formatNumberWithCommas(number) {
        return new Intl.NumberFormat('en-US').format(number);
    }

    function checkApiKey(checkExisting = true) {
        if (!checkExisting || apiKey === null || apiKey.indexOf('PDA-APIKEY') > -1 || apiKey.length != 16){
            let userInput = prompt("Please enter a PUBLIC Api Key, it will be used to get current bazaar prices:", apiKey ?? '');
            if (userInput !== null && userInput.length == 16) {
                apiKey = userInput;
                localStorage.setItem("silmaril-torn-bazaar-filler-apikey", userInput);
            } else {
                console.error("[TornMarketFiller] User cancelled the Api Key input.");
            }
        }
    }

    function getMedianPrice(items) {
        // Expand prices according to their quantities
        const prices = items.flatMap(item => Array(item.amount).fill(item.price));

        // Sort prices in ascending order
        prices.sort((a, b) => a - b);

        // Find the median
        const mid = Math.floor(prices.length / 2);

        if (prices.length % 2 === 0) {
            // If even number of prices, take the average of the two middle prices
            return (prices[mid - 1] + prices[mid]) / 2;
        } else {
            // If odd, take the middle price
            return prices[mid];
        }
    }

    function getCurrentPage(){
        if (window.location.href.indexOf('#/addListing') > -1){
            return pages.AddItems;
        } else if (window.location.href.indexOf('#/viewListing') > -1){
            return pages.ViewItems;
        } else {
            return pages.Other;
        }
    }

    const startHold = () => {
        holdTimer = setTimeout(() => {
            setPriceDelta();
            checkApiKey(false);
        }, 2000);
    };

    const cancelHold = () => {
        clearTimeout(holdTimer);
    };

    class StringBuilder {
        constructor() {
            this.parts = [];
        }

        append(str) {
            this.parts.push(str);
            return this;
        }

        toString() {
            return this.parts.join('');
        }
    }
})();