XBDeals Price Toolkit

The essential toolkit for xbdeals.net. Converts all prices (lists, history, charts) to USD

// ==UserScript==
// @name         XBDeals Price Toolkit
// @namespace    https://github.com/sinazadeh/userscripts
// @version      1.0.2
// @description  The essential toolkit for xbdeals.net. Converts all prices (lists, history, charts) to USD
// @author       TheSina
// @match        *://xbdeals.net/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      cdn.jsdelivr.net
// @license      MIT
// ==/UserScript==
/* jshint esversion: 11 */
(function () {
    'use strict';

    // --- Constants and Global State ---
    const API_URL =
        'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json';
    const hiddenStores = ['ae'];
    const currencyMap = {
        ae: 'aed',
        ar: 'ars',
        at: 'eur',
        au: 'aud',
        be: 'eur',
        br: 'brl',
        ca: 'cad',
        ch: 'chf',
        cl: 'clp',
        co: 'cop',
        cz: 'czk',
        de: 'eur',
        dk: 'dkk',
        es: 'eur',
        fi: 'eur',
        fr: 'eur',
        gb: 'gbp',
        gr: 'eur',
        hk: 'hkd',
        hu: 'huf',
        ie: 'eur',
        il: 'ils',
        in: 'inr',
        it: 'eur',
        jp: 'jpy',
        kr: 'krw',
        mx: 'mxn',
        nl: 'eur',
        no: 'nok',
        nz: 'nzd',
        pl: 'pln',
        pt: 'eur',
        sa: 'sar',
        se: 'sek',
        sg: 'sgd',
        sk: 'eur',
        tr: 'try',
        tw: 'twd',
        us: 'usd',
        za: 'zar',
    };

    let ratesCache = null;

    // --- Utility Functions ---

    GM_addStyle(`
    .usd-price-appendix {
        display: block; font-size: 0.8em; font-weight: normal;
        color: #888; margin-top: 2px;
    }
    `);

    /**
     * Parses a price string into a float. Handles "FREE".
     */
    function parseAmount(text) {
        const cleanText = text.trim().toUpperCase();
        if (cleanText === 'FREE') return 0;

        let cleaned = text.replace(/[^\d.,]/g, '');
        if (cleaned.includes(',') && cleaned.includes('.')) {
            const lastComma = cleaned.lastIndexOf(',');
            const lastDot = cleaned.lastIndexOf('.');
            if (lastDot > lastComma) cleaned = cleaned.replace(/,/g, '');
            else cleaned = cleaned.replace(/\./g, '').replace(',', '.');
        } else if (cleaned.includes(',')) {
            const parts = cleaned.split(',');
            if (parts.length === 2 && parts[1].length <= 2)
                cleaned = cleaned.replace(',', '.');
            else cleaned = cleaned.replace(/,/g, '');
        }
        return parseFloat(cleaned);
    }

    async function fetchRates() {
        if (ratesCache) return ratesCache;
        return new Promise(resolve => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: API_URL,
                onload: res => {
                    try {
                        ratesCache = JSON.parse(res.responseText).usd;
                        resolve(ratesCache);
                    } catch (e) {
                        console.error('Error parsing currency data:', e);
                        resolve(null);
                    }
                },
                onerror: err => {
                    console.error('Error fetching currency data:', err);
                    resolve(null);
                },
            });
        });
    }

    // --- Page-Specific Conversion Functions ---

    async function convertAndSort(container) {
        // Mark as processed immediately to prevent multiple runs on the same mutation event
        container.dataset.usdConverted = 'true';
        console.log('Running conversion for price comparison list...');

        const rates = await fetchRates();
        if (!rates) {
            delete container.dataset.usdConverted; // Allow a retry if API fails
            return;
        }

        container
            .closest('.compare-prices-container')
            ?.style.setProperty('height', 'auto', 'important');
        container.style.display = 'grid';
        container.style.gridTemplateColumns = 'repeat(3, 1fr)';
        container.style.gap = '10px';

        const entries = Array.from(container.querySelectorAll('a'))
            .map(link => {
                const priceEl = link.querySelector('.compare-prices-price');
                if (!priceEl) return null;

                const regionCode = link
                    .getAttribute('href')
                    ?.match(/^\/([a-z]{2})-store/i)?.[1]
                    .toLowerCase();
                if (!regionCode || hiddenStores.includes(regionCode))
                    return null;

                const currencyCode = currencyMap[regionCode];
                const rate = rates[currencyCode];
                if (!currencyCode || !rate) return null;

                const rawText = priceEl.textContent
                    .replace(/\(≈.*?\)/g, '')
                    .trim();
                const localAmount = parseAmount(rawText);
                if (isNaN(localAmount)) return null;

                const usd = localAmount / rate;
                priceEl.textContent = `${rawText} ($${usd.toFixed(2)} USD)`;
                return {link, usdValue: usd};
            })
            .filter(Boolean);

        if (entries.length > 0) {
            entries.sort((a, b) => a.usdValue - b.usdValue);
            container.innerHTML = '';
            entries.forEach(entry => container.appendChild(entry.link));
        }
    }

    async function convertPriceHistory(container) {
        container.dataset.usdConverted = 'true';
        console.log('Running conversion for price history...');

        const rates = await fetchRates();
        if (!rates) {
            delete container.dataset.usdConverted; // Allow retry
            return;
        }

        const breadcrumbLink = document.querySelector(
            '.breadcrumb a[itemprop="item"]',
        );
        const regionCode = breadcrumbLink
            ?.getAttribute('href')
            ?.match(/^\/([a-z]{2})-store/i)?.[1]
            .toLowerCase();
        if (!regionCode) return;

        const currencyCode = currencyMap[regionCode];
        const rate = rates[currencyCode];
        if (!currencyCode || !rate) return;

        container.querySelectorAll('.game-stats-col-number-big').forEach(el => {
            if (el.parentNode.querySelector('.usd-price-appendix')) return; // Already done
            const localAmount = parseAmount(el.textContent);
            if (!isNaN(localAmount)) {
                const usd = localAmount / rate;
                const usdText = document.createElement('small');
                usdText.className = 'usd-price-appendix';
                usdText.textContent = `($${usd.toFixed(2)} USD)`;
                el.parentNode.appendChild(usdText);
            }
        });
    }

    // --- Main Execution Logic ---

    function runConversions() {
        // PATIENCE: Check for a container that is NOT processed AND has content.
        const listContainer = document.querySelector(
            '#compare-prices:not([data-usd-converted])',
        );
        if (
            listContainer &&
            listContainer.querySelector('a .compare-prices-price')
        ) {
            convertAndSort(listContainer);
        }

        // PATIENCE: Check for a container that is NOT processed AND has content.
        const historyContainer = document.querySelector(
            '.game-stats-price-history:not([data-usd-converted])',
        );
        if (
            historyContainer &&
            historyContainer.querySelector('.game-stats-col-number-big')
        ) {
            convertPriceHistory(historyContainer);
        }
    }

    // PERSISTENCE: This observer keeps watching the page for new content.
    const observer = new MutationObserver(runConversions);
    observer.observe(document.body, {
        childList: true,
        subtree: true,
    });
})();