OpenMarket

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

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

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

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

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

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

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

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

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

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

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

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

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

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