XBDeals Price Toolkit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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