您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Get a clear view of global Xbox pricing. PriceLens adds a powerful, customizable dashboard to game pages, showing you what a game costs in different countries—all in your home currency. Pin your favorite stores and let PriceLens help you focus on the best deals.
// ==UserScript== // @name Xbox PriceLens // @namespace https://github.com/sinazadeh/userscripts // @version 1.0.4 // @description Get a clear view of global Xbox pricing. PriceLens adds a powerful, customizable dashboard to game pages, showing you what a game costs in different countries—all in your home currency. Pin your favorite stores and let PriceLens help you focus on the best deals. // @author TheSina // @match *://www.xbox.com/*/games/store/* // @connect cdn.jsdelivr.net // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @license MIT // ==/UserScript== /* jshint esversion: 11 */ (async function () { 'use strict'; // 1) Centralized Configuration const CONFIG = { SELECTORS: { priceText: '.Price-module__boldText___1i2Li', insertionPoint: '.Price-module__priceBaseContainer___j9jGE', buyButton: 'button[data-m*="Buy"]', banner: '.xbox-banner', }, API_BASE_URL: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/', RETRY_DELAY: 750, CACHE: { KEY_RATES_PREFIX: 'xboxCurrencyRates_v4.2_', KEY_TIMESTAMP_PREFIX: 'xboxCurrencyRatesTS_v4.2_', TTL: 12 * 60 * 60 * 1000, }, }; // 2) Style Injection function injectStyles() { const style = document.createElement('style'); style.textContent = ` .xbox-banner, .xbox-row, .xbox-modal, .xbox-modal-section, .xbox-settings-btn { font-family: system-ui, 'Noto Sans', 'Segoe UI', 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-size: 14px; } .xbox-banner { position: relative; background: #fff; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); margin: 16px 0; padding: 12px 16px; font-size: 0.95rem; line-height: 1.4; color: #333; min-height: 50px; } .xbox-rows { display: flex; flex-wrap: wrap; gap: 8px; align-content: flex-start; } .xbox-row { flex: 1 1 calc(50% - 8px); background: #f9f9f9; padding: 8px; border-radius: 4px; direction: ltr; text-align: left; border-left: 3px solid transparent; transition: background-color 0.2s, border-color 0.2s; } .xbox-row.default-store-highlight { background-color: #e6ffed; border-left-color: #4caf50; } .xbox-row.error { color: #d32f2f; } .xbox-row.loading { width: 100%; display: flex; justify-content: center; align-items: center; color: #888; background: none; } .xbox-row .error-text { color: currentColor; } .rtl-text { direction: rtl; unicode-bidi: embed; display: inline-block; } .xbox-settings-btn { position: absolute; bottom: 8px; right: 8px; background: none; border: none; cursor: pointer; color: currentColor; padding: 4px; font-size: 1.2rem; z-index: 1; } .xbox-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s; z-index: 9999; } .xbox-overlay.show { opacity: 1; } .xbox-modal { background: #fff; color: #000; padding: 20px; border-radius: 8px; width: 500px; max-width: 95%; font-size: 1rem; line-height: 1.4; } .xbox-modal h3 { margin: 0 0 16px; } .xbox-modal h4 { margin: 16px 0 8px; } .stores-list { column-count: 2; column-gap: 20px; } .xbox-modal-section { margin-bottom: 16px; } .xbox-modal-section select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; } .xbox-modal-actions { text-align: right; margin-top: 24px; } .xbox-modal-actions-links { margin-bottom: 16px; } .xbox-modal-actions-links a { margin-right: 12px; cursor: pointer; } .xbox-modal-actions button:not(:last-child) { margin-right: 8px; } .switch { display: flex; align-items: center; margin: 6px 0; cursor: pointer; break-inside: avoid-column; } .switch input { opacity: 0; width: 1px; height: 1px; } .switch .slider { width: 36px; height: 18px; background: #ccc; border-radius: 9px; margin-right: 10px; position: relative; transition: background 0.2s; } .switch .slider::after { content: ''; position: absolute; width: 16px; height: 16px; top: 1px; left: 1px; background: #fff; border-radius: 50%; transition: transform 0.2s; } .switch input:checked + .slider { background: #4caf50; } .switch input:checked + .slider::after { transform: translateX(18px); } .switch input:focus-visible + .slider { box-shadow: 0 0 0 2px #0078d4; } @media (max-width: 420px) { .stores-list { column-count: 1; } } @media (prefers-color-scheme: dark) { .xbox-banner { background: #1e1e1e; color: #ddd; } .xbox-row { background: #2a2a2a; } .xbox-row.default-store-highlight { background-color: #1a3d20; border-left-color: #66bb6a; } .xbox-row.loading { color: #666; } .xbox-row.error { color: #ef5350; } .xbox-modal { background: #2a2a2a; color: #ccc; } .xbox-modal-section select { background: #333; color: #ccc; border-color: #555; } .xbox-settings-btn { color: #fff; } } `; document.head.appendChild(style); } // 3) Currency & Tax Definitions const CURRENCIES = [ { code: 'ar', api: 'ars', region: 'es-ar', flag: '🇦🇷', name: 'Argentina Store', link: 'Argentina Store', tax: 0.7, decimal: ',', fmt: x => `ARS${new Intl.NumberFormat('es-AR', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x).replace(/\s/g, '')}`, }, { code: 'au', api: 'aud', region: 'en-au', flag: '🇦🇺', name: 'Australia Store', link: 'Australia Store', tax: 0, decimal: '.', fmt: x => `AU$${new Intl.NumberFormat('en-AU', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`, }, { code: 'br', api: 'brl', region: 'pt-br', flag: '🇧🇷', name: 'Brazil Store', link: 'Brazil Store', tax: 0, decimal: ',', fmt: x => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', }) .format(x) .replace(/\s/g, ''), }, { code: 'ca', api: 'cad', region: 'en-ca', flag: '🇨🇦', name: 'Canada Store', link: 'Canada Store', tax: 0, decimal: '.', fmt: x => `CAD ${new Intl.NumberFormat('en-CA', {style: 'currency', currency: 'CAD'}).format(x)}`, showPlusTax: true, }, { code: 'ch', api: 'chf', region: 'de-ch', flag: '🇨🇭', name: 'Switzerland Store', link: 'Switzerland Store', tax: 0, decimal: '.', fmt: x => `CHF ${new Intl.NumberFormat('de-CH', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`, }, { code: 'cl', api: 'clp', region: 'es-cl', flag: '🇨🇱', name: 'Chile Store', link: 'Chile Store', tax: 0, decimal: ',', fmt: x => `$${new Intl.NumberFormat('es-CL').format(x)}`, showPlusTax: true, }, { code: 'co', api: 'cop', region: 'es-co', flag: '🇨🇴', name: 'Colombia Store', link: 'Colombia Store', tax: 0, decimal: ',', fmt: x => `COP$${new Intl.NumberFormat('es-CO').format(x)}`, showPlusTax: true, }, { code: 'cz', api: 'czk', region: 'cs-cz', flag: '🇨🇿', name: 'Czechia Store', link: 'Czechia Store', tax: 0, decimal: ',', fmt: x => `${new Intl.NumberFormat('cs-CZ', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)} Kč`, }, { code: 'gb', api: 'gbp', region: 'en-gb', flag: '🇬🇧', name: 'UK Store', link: 'UK Store', tax: 0, decimal: '.', fmt: x => new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', }).format(x), }, { code: 'hk', api: 'hkd', region: 'en-hk', flag: '🇭🇰', name: 'Hong Kong Store', link: 'Hong Kong Store', tax: 0, decimal: '.', fmt: x => `HK$${new Intl.NumberFormat('en-HK', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`, }, { code: 'hu', api: 'huf', region: 'hu-hu', flag: '🇭🇺', name: 'Hungary Store', link: 'Hungary Store', tax: 0, decimal: ',', fmt: x => `${new Intl.NumberFormat('hu-HU', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)} HUF`, }, { code: 'in', api: 'inr', region: 'en-in', flag: '🇮🇳', name: 'India Store', link: 'India Store', tax: 0.18, decimal: '.', fmt: x => new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', minimumFractionDigits: 2, maximumFractionDigits: 2, }) .format(x) .replace(/\s/g, ''), }, { code: 'jp', api: 'jpy', region: 'ja-jp', flag: '🇯🇵', name: 'Japan Store', link: 'Japan Store', tax: 0, decimal: '.', fmt: x => new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY', }).format(x), }, { code: 'kr', api: 'krw', region: 'ko-kr', flag: '🇰🇷', name: 'South Korea Store', link: 'South Korea Store', tax: 0, decimal: '.', fmt: x => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW', }) .format(x) .replace(/\s/g, ''), }, { code: 'mx', api: 'mxn', region: 'es-mx', flag: '🇲🇽', name: 'Mexico Store', link: 'Mexico Store', tax: 0, decimal: '.', fmt: x => `MXN$${new Intl.NumberFormat('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`, }, { code: 'no', api: 'nok', region: 'nb-no', flag: '🇳🇴', name: 'Norway Store', link: 'Norway Store', tax: 0, decimal: ',', fmt: x => `kr ${new Intl.NumberFormat('nb-NO', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`, }, { code: 'nz', api: 'nzd', region: 'en-nz', flag: '🇳🇿', name: 'New Zealand Store', link: 'New Zealand Store', tax: 0, decimal: '.', fmt: x => `NZ$${new Intl.NumberFormat('en-NZ', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`, }, { code: 'pl', api: 'pln', region: 'pl-pl', flag: '🇵🇱', name: 'Poland Store', link: 'Poland Store', tax: 0, decimal: ',', fmt: x => `${new Intl.NumberFormat('pl-PL', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)} zł`, }, { code: 'sa', api: 'sar', region: 'ar-sa', flag: '🇸🇦', name: 'Saudi Arabia Store', link: 'Saudi Arabia Store', tax: 0, decimal: '.', fmt: x => new Intl.NumberFormat('ar-SA', { style: 'currency', currency: 'SAR', }).format(x), preParse: str => str.replace(/ر\.س\./g, ''), isRTL: true, }, { code: 'se', api: 'sek', region: 'sv-se', flag: '🇸🇪', name: 'Sweden Store', link: 'Sweden Store', tax: 0, decimal: ',', fmt: x => `${new Intl.NumberFormat('sv-SE', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)} kr`, }, { code: 'sg', api: 'sgd', region: 'en-sg', flag: '🇸🇬', name: 'Singapore Store', link: 'Singapore Store', tax: 0, decimal: '.', fmt: x => `S$${new Intl.NumberFormat('en-SG', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`, }, { code: 'tr', api: 'try', region: 'tr-TR', flag: '🇹🇷', name: 'Turkey Store', link: 'Turkey Store', tax: 0, decimal: ',', fmt: x => new Intl.NumberFormat('tr-TR', { style: 'currency', currency: 'TRY', minimumFractionDigits: 2, maximumFractionDigits: 2, }) .format(x) .replace(/\s/g, ''), }, { code: 'tw', api: 'twd', region: 'zh-tw', flag: '🇹🇼', name: 'Taiwan Store', link: 'Taiwan Store', tax: 0, decimal: '.', fmt: x => `NT$${new Intl.NumberFormat('zh-TW').format(x)}`, }, { code: 'us', api: 'usd', region: 'en-us', flag: '🇺🇸', name: 'US Store', link: 'US Store', tax: 0, decimal: '.', fmt: x => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(x), showPlusTax: true, }, { code: 'za', api: 'zar', region: 'en-za', flag: '🇿🇦', name: 'South Africa Store', link: 'South Africa Store', tax: 0, decimal: ',', fmt: x => `R ${new Intl.NumberFormat('en-ZA', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`, }, ]; // 4) Preference Management Module const Prefs = { visible: {}, sortOrder: 'lowest', defaultStore: 'us', defaults: ['us', 'tr', 'in', 'ar'], async load() { this.visible = await GM_getValue('visibleCurrencies_v4.2', {}); this.sortOrder = await GM_getValue( 'currencySortOrder_v4.2', 'lowest', ); this.defaultStore = await GM_getValue('defaultStore_v4.2', 'us'); let needsSave = false; CURRENCIES.forEach(c => { if (this.visible[c.code] === undefined) { this.visible[c.code] = this.defaults.includes(c.code); needsSave = true; } }); if (needsSave) await this.save(); }, async save() { await GM_setValue('visibleCurrencies_v4.2', this.visible); await GM_setValue('currencySortOrder_v4.2', this.sortOrder); await GM_setValue('defaultStore_v4.2', this.defaultStore); }, }; // 5) Data Fetching, Caching & Parsing async function getRates(baseCurrencyApi) { const now = Date.now(); const cacheKeyRates = `${CONFIG.CACHE.KEY_RATES_PREFIX}${baseCurrencyApi}`; const cacheKeyTs = `${CONFIG.CACHE.KEY_TIMESTAMP_PREFIX}${baseCurrencyApi}`; const ts = await GM_getValue(cacheKeyTs, 0); const cachedRates = await GM_getValue(cacheKeyRates, null); if (cachedRates && now - ts < CONFIG.CACHE.TTL) return cachedRates; return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: `${CONFIG.API_BASE_URL}${baseCurrencyApi}.json`, onload: async r => { if (r.status >= 200 && r.status < 300) { try { const data = JSON.parse(r.responseText)[ baseCurrencyApi ]; await GM_setValue(cacheKeyRates, data); await GM_setValue(cacheKeyTs, now); resolve(data); } catch (e) { console.error('API parse failed:', e); resolve(null); } } else { console.error('API request failed:', r.statusText); resolve(null); } }, onerror: e => { console.error('API network error:', e); resolve(null); }, }); }); } function fetchWithRetry(currency, url, retries = 1) { return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: url, onload: r => { if (r.status >= 500 && retries > 0) { setTimeout( () => resolve( fetchWithRetry(currency, url, retries - 1), ), CONFIG.RETRY_DELAY, ); return; } let priceStr = null, error = null; if (r.status >= 200 && r.status < 300) { const doc = new DOMParser().parseFromString( r.responseText, 'text/html', ); priceStr = doc .querySelector(CONFIG.SELECTORS.priceText) ?.textContent.replace(/\+\s*$/, '') .trim() || null; if (!priceStr) error = 'Price not found'; } else { error = `Request failed (${r.status})`; } resolve({ code: currency.code, priceStr, error, }); }, onerror: () => { if (retries > 0) { setTimeout( () => resolve( fetchWithRetry(currency, url, retries - 1), ), CONFIG.RETRY_DELAY, ); } else { resolve({ code: currency.code, priceStr: null, error: 'Network Error', }); } }, }); }); } function fetchAllPrices(urls, currenciesToFetch) { const promises = currenciesToFetch.map(c => fetchWithRetry(c, urls[c.code]), ); return Promise.all(promises); } function parsePrice(priceString, separator = '.') { const cleanRegex = new RegExp(`[^\\d\\${separator}]`, 'g'); const cleaned = priceString.replace(cleanRegex, ''); const normalized = separator === '.' ? cleaned : cleaned.replace(separator, '.'); return parseFloat(normalized); } // 6) UI & DOM Manipulation function createBanner() { const banner = document.createElement('div'); banner.className = 'xbox-banner'; const rowsContainer = document.createElement('div'); rowsContainer.className = 'xbox-rows'; const settingsBtn = document.createElement('button'); settingsBtn.className = 'xbox-settings-btn'; settingsBtn.textContent = '⚙️'; settingsBtn.title = 'Settings'; settingsBtn.setAttribute('aria-label', 'Price Settings'); banner.append(rowsContainer, settingsBtn); return { banner, rowsContainer, settingsBtn, }; } function updateBannerDisplay(container, lines) { const sorted = [...lines] .filter(item => Prefs.visible[item.code]) .sort((a, b) => { // avoid the “line break before ‘?’” warning by using an if/else if (Prefs.sortOrder === 'alpha') { return a.name.localeCompare(b.name); } return a.convertedPrice - b.convertedPrice; }); container.innerHTML = ''; if (sorted.length === 0) { container.innerHTML = `<div class="xbox-row">No stores selected.</div>`; return; } sorted.forEach(item => { const row = document.createElement('div'); row.className = 'xbox-row'; if (item.error) { row.classList.add('error'); } if (item.code === Prefs.defaultStore) { row.classList.add('default-store-highlight'); } row.innerHTML = item.html; container.appendChild(row); }); } function showSettingsModal(lines, rowsContainer) { let lastFocusedElement = document.activeElement; const overlay = document.createElement('div'); overlay.className = 'xbox-overlay'; document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('show')); const modal = document.createElement('div'); modal.className = 'xbox-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-labelledby', 'xbox-modal-title'); const defaultStoreOptions = CURRENCIES.map( c => `<option value="${c.code}" ${Prefs.defaultStore === c.code ? 'selected' : ''}>${c.flag} ${c.name}</option>`, ).join(''); modal.innerHTML = `<h3 id="xbox-modal-title">Settings</h3> <div class="xbox-modal-section"> <h4>Default Store (for Conversion)</h4> <select id="default-store-select">${defaultStoreOptions}</select> </div> <div class="xbox-modal-section"> <h4>Visible Stores</h4> <div class="stores-list"></div> <div class="xbox-modal-actions-links"> <a href="#" data-action="default">Select Default</a> <a href="#" data-action="all">Select All</a> <a href="#" data-action="none">Select None</a> </div> </div> <div class="xbox-modal-section"> <h4>Sort Order</h4> <select id="sort-order-select"> <option value="lowest" ${Prefs.sortOrder === 'lowest' ? 'selected' : ''}>Lowest Price</option> <option value="alpha" ${Prefs.sortOrder === 'alpha' ? 'selected' : ''}>Alphabetical</option> </select> </div> <div class="xbox-modal-actions"> <button class="cancel">Cancel</button> <button class="save">Save</button> </div>`; const storesList = modal.querySelector('.stores-list'); CURRENCIES.forEach(c => { storesList.insertAdjacentHTML( 'beforeend', `<label class="switch"> <input type="checkbox" value="${c.code}" ${Prefs.visible[c.code] ? 'checked' : ''}> <span class="slider" role="presentation"></span><span>${c.flag} ${c.link}</span></label>`, ); }); overlay.appendChild(modal); const focusableElements = modal.querySelectorAll( 'button, [href], input, select', ); const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; firstFocusable.focus(); const handleKeyDown = e => { if (e.key === 'Escape') { close(); return; } if (e.key === 'Tab') { if (e.shiftKey) { if (document.activeElement === firstFocusable) { e.preventDefault(); lastFocusable.focus(); } } else { if (document.activeElement === lastFocusable) { e.preventDefault(); firstFocusable.focus(); } } } }; const close = () => { overlay.classList.remove('show'); overlay.addEventListener( 'transitionend', () => { overlay.remove(); document.removeEventListener('keydown', handleKeyDown); lastFocusedElement?.focus(); }, { once: true, }, ); }; document.addEventListener('keydown', handleKeyDown); modal.querySelector('.cancel').addEventListener('click', close); overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); modal.querySelector('.save').addEventListener('click', async () => { modal.querySelectorAll('.stores-list input').forEach(cb => { Prefs.visible[cb.value] = cb.checked; }); Prefs.sortOrder = modal.querySelector('#sort-order-select').value; Prefs.defaultStore = modal.querySelector( '#default-store-select', ).value; await Prefs.save(); runScript(true); close(); }); modal .querySelector('.xbox-modal-actions-links') .addEventListener('click', e => { e.preventDefault(); const action = e.target.dataset.action; if (!action) return; modal.querySelectorAll('.stores-list input').forEach(cb => { if (action === 'all') cb.checked = true; else if (action === 'none') cb.checked = false; else if (action === 'default') cb.checked = Prefs.defaults.includes(cb.value); }); }); } // 7) Main Execution Logic async function main(sku) { const anchor = document.querySelector(CONFIG.SELECTORS.insertionPoint); if (!anchor) return; const {banner, rowsContainer, settingsBtn} = createBanner(); banner.dataset.xboxSku = sku; rowsContainer.innerHTML = `<div class="xbox-row loading">Loading prices...</div>`; anchor.parentNode.insertBefore(banner, anchor.nextSibling); const parts = location.pathname.split('/').filter(p => p); const storeIdx = parts.indexOf('store'); const [, slug, prod] = parts.slice(storeIdx); const currenciesToFetch = CURRENCIES.filter(c => Prefs.visible[c.code]); if (currenciesToFetch.length === 0) { updateBannerDisplay(rowsContainer, []); settingsBtn.addEventListener('click', () => showSettingsModal([], rowsContainer), ); return; } const urls = Object.fromEntries( CURRENCIES.map(c => [ c.code, `https://www.xbox.com/${c.region}/games/store/${slug}/${prod}/${sku}`, ]), ); const priceResults = await fetchAllPrices(urls, currenciesToFetch); const rawPrices = Object.fromEntries( priceResults.map(r => [ r.code, { priceStr: r.priceStr, error: r.error, }, ]), ); const parsedValues = {}; currenciesToFetch.forEach(c => { const raw = rawPrices[c.code]; if (raw && raw.priceStr) { try { let strToParse = raw.priceStr; if (c.preParse) { strToParse = c.preParse(strToParse); } parsedValues[c.code] = parsePrice(strToParse, c.decimal); } catch (e) { raw.error = 'Parse failed'; } } }); const defaultCurrency = CURRENCIES.find( c => c.code === Prefs.defaultStore, ); const rates = await getRates(defaultCurrency.api); if (!rates || !defaultCurrency) { rowsContainer.innerHTML = `<div class="xbox-row error">Could not load exchange rates for ${defaultCurrency?.name || 'default store'}.</div>`; return; } const displayLines = CURRENCIES.map(c => { if (!Prefs.visible[c.code]) { return { code: c.code, html: '', }; } const value = parsedValues[c.code]; const raw = rawPrices[c.code]; let html, convertedPrice = Infinity; const linkHtml = `<a href="${urls[c.code]}" target="_blank" rel="noopener noreferrer">${c.name}</a>`; const nameWithFlag = `${c.flag} ${linkHtml}`; if (raw.error) { console.warn(`Could not fetch price for ${c.name}:`, raw.error); html = `${nameWithFlag}: <span class="error-text" title="${raw.error}">⚠️ Couldn’t load</span>`; } else if (value != null) { convertedPrice = value / rates[c.api]; html = `${nameWithFlag}: ${defaultCurrency.fmt(convertedPrice)}`; if (c.code !== defaultCurrency.code) { let formattedLocal = c.fmt(value); if (c.isRTL) { formattedLocal = `<span class="rtl-text">${formattedLocal}</span>`; } html += ` (${formattedLocal})`; } if (c.tax > 0) { const totalConverted = convertedPrice * (1 + c.tax); const totalLocalValue = value * (1 + c.tax); let formattedTaxLocal = c.fmt(totalLocalValue); if (c.isRTL) { formattedTaxLocal = `<span class="rtl-text">${formattedTaxLocal}</span>`; } html += ` + Tax = ${defaultCurrency.fmt(totalConverted)}`; if (c.code !== defaultCurrency.code) { html += ` (${formattedTaxLocal})`; } convertedPrice = totalConverted; } else if (c.showPlusTax) { html += ` + Tax`; } } else { html = `${nameWithFlag}: Not available`; } return { code: c.code, name: c.name, html, convertedPrice, error: raw.error, }; }); settingsBtn.addEventListener('click', () => showSettingsModal(displayLines, rowsContainer), ); updateBannerDisplay(rowsContainer, displayLines); } function getCurrentSku() { try { const buyButton = document.querySelector( CONFIG.SELECTORS.buyButton, ); if (!buyButton) return null; const mData = JSON.parse(buyButton.dataset.m || '{}'); return mData.sku || null; } catch (e) { return null; } } // --- Script Initialization and SPA Navigation Handling --- let debounceTimer; async function runScript(forceRefresh = false) { observer.disconnect(); try { const currentSku = getCurrentSku(); const existingBanner = document.querySelector( CONFIG.SELECTORS.banner, ); if (!currentSku) { existingBanner?.remove(); return; } if ( !forceRefresh && existingBanner && existingBanner.dataset.xboxSku === currentSku ) { // re-observe even if we don't run } else { existingBanner?.remove(); const insertionPoint = document.querySelector( CONFIG.SELECTORS.insertionPoint, ); if (insertionPoint) { await main(currentSku); } } } catch (error) { console.error('Error during script execution:', error); } finally { observer.observe(document.body, { childList: true, subtree: true, }); } } const observer = new MutationObserver(() => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => runScript(false), 300); }); injectStyles(); await Prefs.load(); await runScript(true); })();