OpenMarket

Add quality and bonus percentages to the item market, auction house, and bazaars.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         OpenMarket
// @namespace    https://greasyfork.org/en/scripts/571158-openmarket
// @version      1.0.0
// @description  Add quality and bonus percentages to the item market, auction house, and bazaars.
// @author       https://www.torn.com/forums.php#p=threads&f=67&t=16469095
// @match        https://www.torn.com/page.php?sid=ItemMarket*
// @match        https://www.torn.com/amarket.php*
// @match        https://www.torn.com/bazaar.php*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        none
// @run-at       document-start
// @license      GNU GPLv3
// ==/UserScript==

(function () {
    'use strict';

    // Choose which pages the script is active on.
    // Change "true" to "false" to disable a page.
    const CONFIG = {
        enableItemMarket: true,
        enableAuctionHouse: true,
        enableBazaar: true
    };

    // You can customise the colours however you want. Tiers for quality are lowest to highest.
    const COLOURS = {
        tiers: {
            dark: ['#e4e4e4', '#57efea', '#c286ff', '#ffd700'],
            light: ['#717171', '#009590', '#8e19c1', '#e37100']
        },
        bonus: {
            dark: '#e8d1ff',
            light: '#370b40'
        },
        qualityBoxBg: {
            dark: 'rgba(0,0,0,0.8)',
            light: 'rgba(255,255,255,0.9)'
        }
    };

    // These are the various elements that need to be found/selected within the page source.
    // Changes to the game may break these, so finding the new values may be a necessary part
    // of debugging the script. If an element is broken, you can right click on it and "inspect"
    // to get to the HTML/CSS. This may lead you to the right place, or you might need to dig around.
    const SELECTORS = {
        itemMarket: {
            root: '#item-market-root',
            tile: '.itemTile___cbw7w',
            imageWrapper: '.imageWrapper___RqvUg',
            title: 'div > div.title___bQI0h',
            price: '.priceAndTotal___eEVS7 span',
            statsContainer: '.properties___QCPEP',
            statEntry: '.property___SHm8e',
            statIcon: '.icon___ThfN8',
            statValue: '.value___cwqHv',
            statTypeElement: 'value',
            statTypeAttribute: 'aria-label',
            bonusIcons: '.bonuses___a8gmz i'
        },
        auctionHouse: {
            root: '#auction-house-tabs',
            visibleTab: '#auction-house-tabs .tabContent:not([style*="display: none"])',
            tile: 'ul.items-list li',
            imageWrapper: '.img-wrap',
            title: '.title',
            itemHover: '.item-hover',
            rarityLine: 'p.t-gray-6',
            statsContainer: '.infobonuses',
            statEntry: 'span.bonus-attachment',
            statIcon: 'i',
            statValue: '.label-value',
            statTypeElement: 'icon',
            statTypeAttribute: 'className',
            bonusIcons: '.iconsbonuses .bonus-attachment-icons',
            bonusTitleAttr: 'title',
            bonusDescAttr: null
        },
        bazaar: {
            root: '#bazaarRoot',
            itemsContainer: 'div[class*="itemsContainner"]',
            tile: '.item___GYCYJ.item___khvF6',
            imageWrapper: '.imgBar___Dbu1b',
            image: '.imgContainer___Ec4I5 img',
            title: '.description___Y2Nrl',
            stockElement: '.amount___K8sOQ',
            statsContainer: '.infoBonuses___g8QdG',
            statEntry: '.container___Go0NJ',
            statIcon: 'i',
            statValue: 'span',
            statTypeElement: 'icon',
            statTypeAttribute: 'className',
            bonusIcons: '.iconBonuses____iFjZ i',
            bonusTitleAttr: 'data-bonus-attachment-title',
            bonusDescAttr: 'data-bonus-attachment-description'
        }
    };

    // When new items are added, or if values are updated, these three sections will need to be adjusted.
    // The first number is the item ID. You can find that by searching the item name in the wiki.
    // https://wiki.torn.com/
    // For example, the item with ID 1 is the hammer, and the wiki shows that its base damage is 17
    // and its base accuracy is 55.
    // You can check for new items at tornstats by going to items and selecting the last page.
    // https://tornstats.com/items
    // You can cross reference them with the wiki. Please note, the wiki values are regularly wrong.
    // You may have to figure out the actual base values by looking for items on the market, and/or
    // doing some maths.
    const BASE_DAMAGE_VALUES = {
        1: 17, 2: 16, 3: 20, 4: 11, 5: 21, 6: 25, 7: 28, 8: 34, 9: 40, 10: 61,
        11: 58, 12: 28, 13: 29, 14: 32, 15: 36, 16: 44, 17: 48, 18: 52, 19: 55, 20: 59,
        21: 64, 22: 41, 23: 39, 24: 45, 25: 48, 26: 56, 27: 55, 28: 59, 29: 61, 30: 64,
        31: 67, 63: 72, 76: 52, 98: 59, 99: 33, 100: 64, 108: 65, 109: 77, 110: 27, 111: 39,
        146: 65, 147: 22, 170: 60, 173: 24, 174: 50, 175: 1, 177: 61, 189: 42, 217: 57, 218: 35,
        219: 63, 223: 69, 224: 23, 225: 56, 227: 38, 228: 50, 230: 18, 231: 60, 232: 62, 233: 61,
        234: 31, 235: 22, 236: 35, 237: 62, 238: 29, 240: 78, 241: 50, 243: 30, 244: 15, 245: 13,
        247: 52, 248: 62, 249: 46, 250: 50, 251: 53, 252: 49, 253: 27, 254: 47, 255: 67, 289: 70,
        290: 70, 291: 70, 292: 70, 346: 40, 359: 16, 360: 53, 382: 75, 387: 67, 388: 74, 391: 57,
        393: 14, 395: 61, 397: 71, 398: 69, 399: 68, 400: 63, 401: 26, 402: 51, 438: 18, 439: 19,
        440: 1, 483: 42, 484: 46, 485: 40, 486: 38, 487: 39, 488: 37, 489: 35, 490: 46, 539: 36,
        545: 79, 546: 76, 547: 78, 548: 77, 549: 80, 599: 60, 600: 61, 604: 43, 605: 45, 612: 65,
        613: 47, 614: 60, 615: 64, 632: 48, 790: 5, 792: 17, 805: 18, 830: 95, 831: 54, 832: 21,
        837: 66, 838: 63, 839: 60, 844: 15, 845: 58, 846: 56, 850: 58, 871: 5, 874: 68, 1053: 41,
        1055: 35, 1056: 40, 1152: 76, 1153: 74, 1154: 73, 1155: 70, 1156: 68, 1157: 69, 1158: 62,
        1159: 51, 1173: 37, 1231: 29, 1255: 54, 1257: 1, 1296: 27
    };

    const BASE_ACCURACY_VALUES = {
        1: 55, 2: 57, 3: 52, 4: 62, 5: 45, 6: 55, 7: 60, 8: 52, 9: 58, 10: 23,
        11: 52, 12: 53, 13: 52, 14: 56, 15: 54, 16: 58, 17: 51, 18: 49, 19: 38, 20: 36,
        21: 30, 22: 63, 23: 65, 24: 51, 25: 51, 26: 52, 27: 47, 28: 55, 29: 47, 30: 45,
        31: 41, 63: 28, 76: 24, 98: 24, 99: 57, 100: 24, 108: 43, 109: 39, 110: 52, 111: 51,
        146: 49, 147: 15, 170: 24, 173: 55, 174: 56, 175: 54, 177: 53, 189: 54, 217: 49, 218: 63,
        219: 55, 223: 52, 224: 52, 225: 62, 227: 48, 228: 48, 230: 22, 231: 46, 232: 50, 233: 55,
        234: 52, 235: 59, 236: 55, 237: 56, 238: 52, 240: 25, 241: 57, 243: 57, 244: 39, 245: 55,
        247: 55, 248: 53, 249: 47, 250: 53, 251: 51, 252: 62, 253: 41, 254: 52, 255: 39, 289: 54,
        290: 54, 291: 54, 292: 54, 346: 63, 359: 50, 360: 57, 382: 62, 387: 63, 388: 45, 391: 65,
        393: 54, 395: 60, 397: 28, 398: 50, 399: 57, 400: 35, 401: 33, 402: 60, 438: 42, 439: 43,
        440: 63, 483: 52, 484: 41, 485: 54, 486: 45, 487: 43, 488: 41, 489: 48, 490: 24, 539: 55,
        545: 38, 546: 47, 547: 46, 548: 45, 549: 36, 599: 48, 600: 41, 604: 45, 605: 48, 612: 52,
        613: 63, 614: 62, 615: 52, 632: 48, 790: 29, 792: 57, 805: 55, 830: 45, 831: 53, 832: 54,
        837: 36, 838: 60, 839: 45, 844: 45, 845: 53, 846: 52, 850: 50, 871: 59, 874: 57, 1053: 65,
        1055: 49, 1056: 47, 1152: 42, 1153: 44, 1154: 40, 1155: 45, 1156: 36, 1157: 49, 1158: 39,
        1159: 56, 1173: 67, 1231: 59, 1255: 52, 1257: 59, 1296: 58
    };

    const BASE_ARMOUR_VALUES = {
        32: 20, 33: 32, 34: 34, 49: 31, 50: 36, 176: 23, 178: 30, 332: 38, 333: 40, 334: 42,
        348: 10, 538: 25, 640: 32, 641: 34, 642: 30, 643: 30, 644: 34, 645: 30, 646: 24, 647: 20,
        648: 20, 649: 20, 650: 20, 651: 38, 652: 38, 653: 38, 654: 38, 655: 35, 656: 45, 657: 45,
        658: 45, 659: 45, 660: 44, 661: 44, 662: 44, 663: 44, 664: 44, 665: 46, 666: 46, 667: 46,
        668: 46, 669: 46, 670: 49, 671: 49, 672: 49, 673: 49, 674: 49, 675: 40, 676: 52, 677: 52,
        678: 52, 679: 52, 680: 55, 681: 55, 682: 55, 683: 55, 684: 55, 848: 32, 1164: 38, 1165: 50,
        1166: 50, 1167: 50, 1168: 50, 1174: 39, 1307: 53, 1308: 53, 1309: 53, 1310: 53, 1311: 53,
        1355: 48, 1356: 48, 1357: 48, 1358: 48, 1359: 48
    };

    // The following const is a map matching all item IDs to the damage, accuracy and armour values.
    // Weapons have damage and accuracy, armour just has armour. The script accounts for that by giving 0
    // to the missing values, so it won't have any effect on calculating bonus %. If some kind of wacky item
    // is added in the future which somehow has defensive and offensive stats, it could break this.
    const BASE_STATS_MAP = Object.keys({
        ...BASE_DAMAGE_VALUES,
        ...BASE_ACCURACY_VALUES,
        ...BASE_ARMOUR_VALUES
    }).reduce((map, id) => {
        map[id] = {
            baseDamage: BASE_DAMAGE_VALUES[id] ?? 0,
            baseAccuracy: BASE_ACCURACY_VALUES[id] ?? 0,
            baseArmour: BASE_ARMOUR_VALUES[id] ?? 0
        };
        return map;
    }, {});

    // Some bonuses have a fixed effect without any varying number. These tend to be weapons
    // which were released before ranked war weapons were introduced. 
    // They are currently sledgehammer, tranquilizer gun, and handbag. 
    // Some may be missing, and some new things may need to be added here in future.
    const FIXED_BONUSES = ['Smash', 'Sleep', 'Storage'];

    const NUMBER_REGEX = /(\d+(?:\.\d+)?)/;

    const processedElements = new WeakSet();
    let darkMode = false;
    let currentHandler = null;

    // FUNCTIONS WHICH MAY NEED DEBUGGING ACCORDING TO GAME UDPDATES BELOW,
    // OR FUNCTIONS WHICH CAN BE ADJUSTED TO PREFERENCE

    // This checks the page source to see if it's in dark mode or not. This could changed/renamed.
    function checkDarkMode() {
        darkMode = document.body.classList.contains('dark-mode');
    }

    // These percentages can be adjusted to change when quality is considered a higher "tier".
    function getTierColour(percent) {
        const colours = darkMode ? COLOURS.tiers.dark : COLOURS.tiers.light;
        if (percent <= 25) return colours[0];
        if (percent <= 50) return colours[1];
        if (percent <= 75) return colours[2];
        return colours[3];
    }


    // Helper function for selecting a specific element from the DOM.
    function qs(selector, parent = document) {
        try {
            return parent.querySelector(selector);
        } catch (e) {
            return null;
        }
    }

    // Helper function for selecting all instances of a specific element from the DOM.
    function qsa(selector, parent = document) {
        try {
            return Array.from(parent.querySelectorAll(selector));
        } catch (e) {
            return null;
        }
    }

    // This is a generic helper function for creating DOM elements with children.
    // Search "create(" for examples of its usage.
    function create(tag, attrs = {}, children = []) {
        const el = document.createElement(tag);
        Object.entries(attrs).forEach(([key, value]) => {
            if (key === 'style' && typeof value === 'object') {
                Object.assign(el.style, value);
            } else if (key === 'dataset' && typeof value === 'object') {
                Object.entries(value).forEach(([k, v]) => el.dataset[k] = v);
            } else {
                el[key] = value;
            }
        });
        children.forEach(child => {
            if (typeof child === 'string') {
                el.appendChild(document.createTextNode(child));
            } else if (child) {
                el.appendChild(child);
            }
        });
        return el;
    }

    // processedElements is a set of elements which have been processed, for avoiding
    // duplicates. Necessary now since the script keeps a cache of items.
    function isProcessed(el) {
        return processedElements.has(el);
    }

    function markProcessed(el) {
        processedElements.add(el);
    }

    // This returns the "tier" for the item's quality, which will vary for weapons vs armour.
    // The maxRange is 100% for armour and 300% for weapons, unless they are adjusted.
    // The tier colour can also be adjusted, as well as which percent it falls under.
    function getQualityColour(value, maxRange) {
        const percent = (value / maxRange) * 100;
        return getTierColour(percent);
    }

    function getBonusColour() {
        return darkMode ? COLOURS.bonus.dark : COLOURS.bonus.light;
    }

    // This is a global stat extraction function which works for the item market, bazaar
    // and auction house. This should be fairly future proof if the names of the HTML
    // elements change. You can just fix the SELECTORS at the top of the script.
    function extractStats(el, selectors) {
        let damage = 0, accuracy = 0, armour = 0;

        // Check if the container exists. If you're getting negative values
        // for quality, the general structure of the item tile elements may have changed.
        const container = qs(selectors.statsContainer, el);
        if (!container) return { damage, accuracy, armour };

        // This whole thing has to be a bit messy because there's no consistency across pages.
        qsa(selectors.statEntry, container).forEach(entry => {
            const valueEl = qs(selectors.statValue, entry);
            const iconEl = qs(selectors.statIcon, entry);
            const val = parseFloat(valueEl?.textContent) || 0;

            const typeEl = selectors.statTypeElement === 'value' ? valueEl : iconEl;
            let typeText = '';
            if (selectors.statTypeAttribute === 'className') {
                typeText = (typeEl?.className || '').toLowerCase();
            } else {
                typeText = (typeEl?.getAttribute(selectors.statTypeAttribute) || '').toLowerCase();
            }

            if (typeText.includes('damage')) damage = val;
            else if (typeText.includes('accuracy')) accuracy = val;
            // Sometimes the game calls it "armor" and sometimes it calls it "defence".
            // They are not sure if they're American or British.
            else if (typeText.includes('armor') || typeText.includes('defence')) armour = val;
        });

        return { damage, accuracy, armour };
    }

    // The auction house and bazaar store bonus information in the DOM, so we can
    // extract it with a shared function.
    function extractBonusesFromDOM(el, selectors) {
        const bonuses = [];

        qsa(selectors.bonusIcons, el).forEach(icon => {
            // Auction house gets bonus name + percent from "title", bazaar gets it from
            // title AND description. If one of these pages breaks but not the other,
            // the elements might need to be changed in SELECTORS, or this function adjusted.
            const titleAttr = icon.getAttribute(selectors.bonusTitleAttr) || '';
            const descAttr = icon.getAttribute(selectors.bonusDescAttr) || '';
            if (!titleAttr && !descAttr) return;

            const tmp = create('div');
            tmp.innerHTML = titleAttr;
            const title = qs('b', tmp)?.textContent || tmp.textContent || '';
            const descText = tmp.textContent.replace(title, '').trim() || descAttr;

            const valueMatch = descText.match(NUMBER_REGEX);
            const value = valueMatch ? parseFloat(valueMatch[1]) : null;

            if (title) {
                bonuses.push({ title, value, description: descText });
            }
        });

        return bonuses;
    }

    // Each page tends to have wildly differing ways of storing the info,
    // but there are a few consistent aspects which can be grouped in this
    // function.
    function processItem(el, data) {
        if (isProcessed(el)) return;

        const { itemID, stats, bonuses, selectors } = data;

        // Calculate quality.
        const quality = calcQuality(itemID, stats.damage, stats.accuracy, stats.armour);
        if (quality === null) return;

        // Get title element for bonus insertion.
        const titleEl = qs(selectors.title, el);
        if (!titleEl) return;

        // Remove existing bonus container if present.
        const existing = qs('.openmarket-bonuses', el);
        if (existing) existing.remove();

        // Create bonus container.
        const bonusContainer = create('div', {
            className: 'openmarket-bonuses',
            style: { lineHeight: '1' }
        });

        // Add bonus elements to container.
        bonuses.forEach(bonus => {
            bonusContainer.appendChild(createBonusEl(bonus));
        });

        // Insert bonus container.
        if (bonusContainer.children.length > 0) {
            if (data.insertBefore) {
                titleEl.insertBefore(bonusContainer, data.insertBefore);
            } else {
                titleEl.appendChild(bonusContainer);
            }
        }

        // Hide elements if specified.
        (data.hideElements || []).forEach(hideEl => {
            if (hideEl) hideEl.style.display = 'none';
        });

        // Insert quality box on image wrapper.
        const imageWrapper = qs(selectors.imageWrapper, el);
        const isArmour = stats.armour > 0;
        insertQualityBox(imageWrapper, quality, isArmour ? 100 : 300);

        markProcessed(el);
    }

    function getQualityBoxBg() {
        return darkMode ? COLOURS.qualityBoxBg.dark : COLOURS.qualityBoxBg.light;
    }

    // Quality is not a value provided by the game.
    // So this needs to be calculated by using the damage/accuracy/armour values.
    // It's not perfectly accurate.
    // Perhaps in the future, proper quality values will be given, and this can be removed.
    function calcQuality(itemID, damage, accuracy, armour) {
        const base = BASE_STATS_MAP[itemID];
        if (!base) return null;

        if (armour && armour !== 0) {
            return ((armour - base.baseArmour) * 20).toFixed(1);
        }
        return (((damage - base.baseDamage) + (accuracy - base.baseAccuracy)) * 10).toFixed(1);
    }

    // A simple check to see if the value of the bonus has a percent after it
    // in the description. So it doesn't return something like "Disarm: 5%".
    // This could be expanded to include more info, like ' turns'.
    function getUnitFromDescription(description, value) {
        if (!description || value === undefined || value === null) return '';
        const pattern = new RegExp(String(value) + '\\s*%');
        if (pattern.test(description)) return '%';
        return '';
    }

    // This is a helper function which creates the small element holding the bonus info.
    // If the way item info is received changes, this could break. Also new unusual
    // items may potentially not be able to have their info extracted properly.
    function createBonusEl(bonus) {
        const name = bonus.title || '';
        const value = bonus.value;
        const description = bonus.description || '';

        // If this is a "fixed" bonus, just put the name there.
        if (FIXED_BONUSES.includes(name)) {
            return create('div', {
                textContent: name,
                style: { color: getBonusColour() }
            });
        }

        let text = name;

        // Use the getUnitFromDescription function to see if the description
        // simply has a "%" after the number. That function was destined for
        // bigger things...
        if (value !== undefined && value !== null) {
            const unit = getUnitFromDescription(description, value);
            text = `${name}: ${value}${unit}`;
        }

        return create('div', {
            textContent: text,
            style: { color: getBonusColour() }
        });
    }

    // Helper function for putting the quality element on the item image.
    function insertQualityBox(container, value, maxRange) {
        if (!container || qs('.openmarket-quality-box', container)) return;

        const colour = getQualityColour(parseFloat(value), maxRange);

        // Child element containing the text.
        const span = create('span', {
            className: 'label-value t-overflow',
            textContent: `Q ${value}%`
        });

        // This is the box/background. These numbers can be adjusted
        // to change the styling. This could be removed entirely but
        // it ensures the text is easy to read.
        const box = create('div', {
            className: 'openmarket-quality-box',
            style: {
                position: 'absolute',
                top: '2px',
                left: '2px',
                padding: '1px 3px',
                borderRadius: '3px',
                fontSize: '11px',
                fontWeight: 'bold',
                zIndex: '2',
                background: getQualityBoxBg(),
                color: colour
            }
        }, [span]);

        // I don't remember what problem this solved, perhaps a mobile/PDA thing.
        const style = window.getComputedStyle(container);
        if (style.position === 'static') {
            container.style.position = 'relative';
        }

        container.appendChild(box);
    }

    // This is a simple check if the tab/window is in focus.
    // According to the rules, only a single tab can have its info used by a script.
    // !document.hidden checks if the current TAB is visible.
    // document.hasFocus() checks if the current WINDOW has focus.
    // From what I can tell, these both need to be included to abide by the rules.
    function isPageActive() {
        return !document.hidden && document.hasFocus();
    }

    // This sets up listeners for tab visibility and window focus events,
    // runs processing when appropriate.
    function setupActiveListeners() {
        const onActivate = () => {
            if (currentHandler) {
                currentHandler.processAll();
            }
        };

        document.addEventListener('visibilitychange', onActivate);
        window.addEventListener('focus', onActivate);
    }

    // ITEM MARKET

    const itemMarket = {
        // When items are loaded on the item market, the script intercepts a fetch request
        // which contains the info for the page to display them. It only contains info
        // for newly added items, and since the market redraws everything, old item info
        // needs to be kept in a cache. This was a recent change to Torn,
        // and it could change again.
        // NOTE: The legality of intercepting fetch requests for unviewed tabs was questioned.
        // The Torn admin team deemed storing info in a cache is okay if it is not used in any
        // way until the appropriate tab is viewed.
        // Direct quote: "We agreed that it's fine to go ahead with your plan to keep it in cache,
        // just as long as it's not persisted anywhere/repurposed for anything else"
        // If you modify this script it is your responsibility to ensure this behaviour remains
        // compliant, or there is a potential risk of account deletion.
        cache: [],
        selectors: SELECTORS.itemMarket,

        // Check to see if the stats and price of an item matches what's listed in the cache.
        // It's not impossible that items with identical stats and price with different bonuses
        // could be mixed up. That seems like a very rare occurence, though.
        matchToCache(el) {
            // Convert the price into a plain integer. If processing isn't working on the item market
            // only, the selectors could have changed.
            const price = parseInt(qs(this.selectors.price, el)?.textContent.replace(/[^\d]/g, ''), 10) || 0;
            const stats = extractStats(el, this.selectors);

            return this.cache.find(item => {
                if (item.armor && item.armor > 0) {
                    return item.minPrice === price && item.armor === stats.armour;
                }
                return item.minPrice === price && item.damage === stats.damage && item.accuracy === stats.accuracy;
            });
        },

        processAll() {
            // Per the rules, only process items if the page is currently visible AND in focus.
            if (!isPageActive()) return;
            if (this.cache.length === 0) return;

            // Tile elements could have been changed by an update if item market isn't working.
            qsa(this.selectors.tile).forEach(el => {
                const cached = this.matchToCache(el);
                if (!cached) return;

                processItem(el, {
                    itemID: cached.itemID,
                    stats: {
                        damage: cached.damage || 0,
                        accuracy: cached.accuracy || 0,
                        armour: cached.armor || 0
                    },
                    bonuses: cached.bonuses || [],
                    selectors: this.selectors
                });
            });
        },

        init() {
            const root = qs(this.selectors.root);
            if (!root) return;

            // Scrolling the page loads in new items, so check for DOM changes and re-process.
            const observer = new MutationObserver(() => {
                this.processAll();
            });

            // Observe just the root element of the item market, to avoid excess reprocessing. 
            // This could probably be made more specific, as to not
            // run when unnecessary (eg only reprocessing when the cache is updated).
            observer.observe(root, { childList: true, subtree: true });
            this.processAll();
        }
    };

    // AUCTION HOUSE

    const auctionHouse = {
        selectors: SELECTORS.auctionHouse,

        processAll() {
            if (!isPageActive()) return;

            const visibleTab = qs(this.selectors.visibleTab);
            if (!visibleTab) return;

            qsa(this.selectors.tile, visibleTab).forEach(el => {

                // If the auction house isn't working, it could be a selector in this section.
                const hoverEl = qs(this.selectors.itemHover, el);

                // If a specific item doesn't show up, it could be that the ID isn't in the map yet.
                const itemID = hoverEl?.getAttribute('item');
                if (!itemID || !BASE_STATS_MAP[itemID]) return;

                // Or something went wrong with finding stats, and it returns 0 for all.
                const stats = extractStats(el, this.selectors);
                if (stats.damage === 0 && stats.accuracy === 0 && stats.armour === 0) return;

                const titleEl = qs(this.selectors.title, el);
                const rarityLine = qs(this.selectors.rarityLine, titleEl);

                processItem(el, {
                    itemID,
                    stats,
                    bonuses: extractBonusesFromDOM(el, this.selectors),
                    selectors: this.selectors,
                    hideElements: [rarityLine] // Hide the rarity for tidiness.
                });
            });
        },

        init() {
            // This function is the same as the item market, but instead of watching for vertical scrolls,
            // this checks for going to a new page or switching to a different category. 
            // The observer is set off each time the counter ticks down,
            // but it's only once a second, so whatever.
            const root = qs(this.selectors.root);
            if (!root) return;

            const observer = new MutationObserver(() => {
                this.processAll();
            });

            observer.observe(root, { childList: true, subtree: true });

            this.processAll();
        }
    };

    // BAZAAR

    const bazaar = {
        selectors: SELECTORS.bazaar,

        processAll() {
            if (!isPageActive()) return;

            qsa(this.selectors.tile).forEach(el => {
                // Item ID is extracted from each item's image URL and filtered with regex.
                const img = qs(this.selectors.image, el);
                if (!img) return;

                const match = img.src.match(/\/images\/items\/(\d+)\//);
                if (!match) return;

                const itemID = match[1];
                if (!BASE_STATS_MAP[itemID]) return;

                const stats = extractStats(el, this.selectors);
                if (stats.damage === 0 && stats.accuracy === 0 && stats.armour === 0) return;

                const titleEl = qs(this.selectors.title, el);
                const stockEl = qs(this.selectors.stockElement, titleEl);

                // For the bazaar, insert above the stock element, then hide stock for tidiness.
                // There's no reason for Torn to tell us there's 1 in stock for these items anyway.
                processItem(el, {
                    itemID,
                    stats,
                    bonuses: extractBonusesFromDOM(el, this.selectors),
                    selectors: this.selectors,
                    insertBefore: stockEl,
                    hideElements: [stockEl]
                });
            });
        },

        init() {
            const root = qs(this.selectors.root);
            if (!root) return;

            // The outer observer waits for the bazaar in general to load, the inner observer
            // then checks for changes due to scrolling, similar to the item market.
            // One observer would probably work fine, but I think there was a reason for this
            // at some point. Maybe the extra logic was related to a now fixed bazaar bug with
            // large images.
            const outerObserver = new MutationObserver((_, obs) => {
                const container = qs(this.selectors.itemsContainer, root);
                if (!container) return;

                const innerObserver = new MutationObserver(() => {
                    this.processAll();
                });

                innerObserver.observe(container, { childList: true, subtree: true });
                this.processAll();

                obs.disconnect();
            });

            outerObserver.observe(root, { childList: true, subtree: true });
        }
    };

    // FETCH REQUEST INTERCEPTION

    function interceptFetch() {
        const originalFetch = window.fetch;

        // Override the original fetch function to intercept requests.
        window.fetch = async (...args) => {
            const response = await originalFetch(...args);

            // If the URL in the request shows it's related to the item market,
            // add items to the cache, checking itemID for dupes. 
            // If the item market URL changes to no longer contain 'sid=iMarket',
            // this could break. This could be adjusted to something more generic
            // like 'market' to future-proof it, also could be added to SELECTORS.
            const url = args[0] instanceof Request ? args[0].url : String(args[0]);

            if (url.includes('sid=iMarket')) {
                response.clone().json().then(data => {
                    if (!data?.items?.length) return;

                    const existingIds = new Set(itemMarket.cache.map(i => i.listingID));
                    const newItems = data.items.filter(item => !existingIds.has(item.listingID));

                    if (newItems.length > 0) {
                        itemMarket.cache.push(...newItems);
                        if (isPageActive()) itemMarket.processAll();
                    }
                }).catch(() => { });
            }

            return response;
        };
    }

    // STYLES

    function insertStyles() {
        // A handful of page elements need to have some CSS adjusted for a tidier fit.
        // Things could be adjusted further if changes to the page layouts are made,
        // or if there are conflicts with other scripts. Or just for nicer aesthetics.
        const css = `
            .itemTile___cbw7w {
                padding: 5px 3px 0 !important;
            }
            .itemTile___cbw7w .title___bQI0h {
                padding: 5px 0 0px !important;
            }
            .openmarket-bonuses div {
                font-size: 11px !important;
            }
        `;

        const style = document.createElement('style');
        style.textContent = css;
        (document.head || document.documentElement).appendChild(style);
    }

    // INITIALISATION

    function init() {
        // Insert the style changes and override the fetch function before the page loads.
        insertStyles();
        interceptFetch();

        // Once the DOM is fully loaded, run the script.
        document.addEventListener('DOMContentLoaded', () => {
            checkDarkMode();
            setupActiveListeners();

            const href = window.location.href;

            if (href.includes('sid=ItemMarket') && CONFIG.enableItemMarket) {
                currentHandler = itemMarket;
                itemMarket.init();
            } else if (href.includes('amarket.php') && CONFIG.enableAuctionHouse) {
                currentHandler = auctionHouse;
                auctionHouse.init();
            } else if (href.includes('bazaar.php') && CONFIG.enableBazaar) {
                currentHandler = bazaar;
                bazaar.init();
            }
            // Here, more pages can be added.
            // TODO: Add various pages and simply match with https://www.torn.com/* since 
            // this checks the URL anyway. But URL check should be moved to the very first thing
            // the script does.
        });
    }

    init();
})();