// ==UserScript== // @name Wargaming商店外区货币转换器 // @namespace http://tampermonkey.net/ // @version 3.1 // @description Wargaming商店外区货币转换,右侧悬浮显示汇率 // @author SundayRX // @match https://wargaming.net/shop/* // @grant GM_xmlhttpRequest // @connect api.exchangerate-api.com // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; const CONFIG = { discount: 0.87, highlightStyle: ` .currency-conversion { background-color: #ffffcc; border-radius: 3px; padding: 2px 4px; margin-left: 5px; font-weight: bold; font-size: 0.9em; color: #d32f2f; display: inline-block; } .currency-processed { display: inline-flex; align-items: center; } #currency-float-panel { position: fixed !important; left: 20px !important; /* 改为左侧距离 */ bottom: 20px !important; /* 改为底部距离(左下角核心) */ /* 移除top和transform,避免垂直居中 */ background-color: rgba(30, 30, 30, 0.98) !important; color: #fff !important; padding: 15px !important; border-radius: 8px !important; box-shadow: 0 4px 15px rgba(0,0,0,0.5) !important; z-index: 9999999 !important; width: 220px !important; font-size: 14px !important; line-height: 1.6 !important; border: 1px solid #555 !important; margin: 0 !important; opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; overflow: visible !important; } #currency-float-panel.hidden { opacity: 0 !important; visibility: hidden !important; pointer-events: none !important; } #currency-float-panel .panel-title { font-weight: bold !important; margin-bottom: 8px !important; padding-bottom: 5px !important; border-bottom: 1px dashed #666 !important; color: #fff !important; font-size: 15px !important; } #currency-float-panel .rate-source { display: inline-block !important; margin-top: 5px !important; padding: 2px 6px !important; border-radius: 3px !important; font-size: 12px !important; background-color: #2196F3 !important; color: #fff !important; } #currency-float-panel .rate-source.fallback { background-color: #FF9800 !important; } #currency-float-panel .loading { color: #ccc !important; font-style: italic !important; } `, }; class Currency { constructor(Type, ExchangeRateAPI, ExchangeRateFallBack, MatchRegex, Symbol = null) { this.Type = Type; this.ExchangeRateAPI = ExchangeRateAPI; this.ExchangeRateRemote = null; this.ExchangeRateFallBack = this.validateRate(ExchangeRateFallBack, false); this.MatchRegex = MatchRegex; this.Symbol = Symbol; this.requestStatus = 'idle'; // idle/pending/done this.pendingElements = []; console.log(`[Currency初始化] ${this.Type} 备用汇率: ${this.ExchangeRateFallBack}`); } validateRate(rate, isRemote = false) { if (typeof rate === 'number' && !isNaN(rate) && rate > 0) { return rate; } if (isRemote) { console.warn(`[${this.Type}] 远程汇率无效(值: ${rate}),将使用备用汇率`); return null; } else { console.warn(`[${this.Type}] 备用汇率无效(值: ${rate}),兜底为1`); return 1; } } fetchExchangeRate() { if (this.requestStatus !== 'idle') { console.log(`[${this.Type}] 跳过重复请求(当前状态: ${this.requestStatus})`); return; } this.requestStatus = 'pending'; console.log(`[${this.Type}] 开始请求汇率 API: ${this.ExchangeRateAPI}`); updateFloatPanel(this); // 超时保护:5秒未响应则强制结束请求 const timeoutId = setTimeout(() => { console.error(`[${this.Type}] 汇率请求超时(5秒)`); this.requestStatus = 'done'; this.triggerPendingElements(); updateFloatPanel(this); }, 5000); GM_xmlhttpRequest({ method: 'GET', url: this.ExchangeRateAPI, onload: (response) => { clearTimeout(timeoutId); // 清除超时 console.log(`[${this.Type}] API响应状态: ${response.status}`); try { // 关键:检查API响应结构是否正确(exchangerate-api的正确结构) if (response.status !== 200) { console.error(`[${this.Type}] API返回失败: ${data.result || '未知错误'}`); this.requestStatus = 'done'; this.triggerPendingElements(); updateFloatPanel(this); return; } const data = JSON.parse(response.responseText); const remoteRate = this.validateRate(data.rates?.CNY, true); if (remoteRate) { this.ExchangeRateRemote = remoteRate; console.log(`[${this.Type}] 远程汇率有效: ${remoteRate}`); } } catch (e) { console.error(`[${this.Type}] 解析响应失败:`, e); } finally { this.requestStatus = 'done'; this.triggerPendingElements(); updateFloatPanel(this); } }, onerror: (error) => { clearTimeout(timeoutId); // 清除超时 console.error(`[${this.Type}] 请求失败:`, error); this.requestStatus = 'done'; this.triggerPendingElements(); updateFloatPanel(this); } }); } getFinalRate() { const rate = this.ExchangeRateRemote ?? this.ExchangeRateFallBack; console.log(`[${this.Type}] 最终使用汇率: ${rate}`); return rate; } getRateSource() { return this.ExchangeRateRemote ? '实时汇率' : '备用汇率'; } addPendingElement(element) { if (!this.pendingElements.includes(element) && !element.classList.contains('currency-processed')) { this.pendingElements.push(element); console.log(`[${this.Type}] 暂存元素(累计: ${this.pendingElements.length})`); } } triggerPendingElements() { console.log(`[${this.Type}] 开始处理暂存元素(数量: ${this.pendingElements.length})`); this.pendingElements.forEach(element => { if (!element.isConnected) { console.log(`[${this.Type}] 元素已被移除,跳过`); return; } if (element.classList.contains('currency-processed')) { console.log(`[${this.Type}] 元素已处理,跳过`); return; } ProcessPriceElement(element, this.Type); }); this.pendingElements = []; } } let CurrencyDict = [ new Currency('ARS', 'https://api.exchangerate-api.com/v4/latest/ARS', 0.005, /([\d,]+(?:\.\d+)?)\s*(ARS)/i), new Currency('SGD', 'https://api.exchangerate-api.com/v4/latest/SGD', 5.500, /([\d,]+(?:\.\d+)?)\s*(SGD)/i), new Currency('HKD', 'https://api.exchangerate-api.com/v4/latest/HKD', 0.916, /([\d,]+(?:\.\d+)?)\s*(HKD)/i), new Currency('TWD', 'https://api.exchangerate-api.com/v4/latest/TWD', 0.233, /([\d,]+(?:\.\d+)?)\s*(TWD)/i), new Currency('MOP', 'https://api.exchangerate-api.com/v4/latest/MOP', 0.891, /([\d,]+(?:\.\d+)?)\s*(MOP)/i), new Currency('EUR', 'https://api.exchangerate-api.com/v4/latest/EUR', 8.260, null, 'EUR'), new Currency('USD', 'https://api.exchangerate-api.com/v4/latest/USD', 7.200, null, 'USD'), new Currency('CAD', 'https://api.exchangerate-api.com/v4/latest/CAD', 5.000, null, 'CAD'), new Currency('GBP', 'https://api.exchangerate-api.com/v4/latest/GBP', 9.500, null, 'GBP'), new Currency('JPY', 'https://api.exchangerate-api.com/v4/latest/JPY', 0.050, null, 'JPY'), ]; let currentActiveCurrency = null; let isProcessing = false; let observer = null; let processedElements = new Set(); let floatPanel = null; function createFloatPanel() { if (floatPanel) return; floatPanel = document.createElement('div'); floatPanel.id = 'currency-float-panel'; floatPanel.className = 'hidden'; floatPanel.innerHTML = `
WG商店货币转换器(SundayRX)
加载中...
`; document.body.appendChild(floatPanel); console.log('[悬浮窗] 已创建右侧面板'); // 防移除监听 const panelObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.removedNodes.forEach(node => { if (node.id === 'currency-float-panel') { console.warn('[悬浮窗] 被移除,重建中...'); floatPanel = null; createFloatPanel(); if (currentActiveCurrency) updateFloatPanel(currentActiveCurrency); } }); }); }); panelObserver.observe(document.body, { childList: true, subtree: true }); // 样式保护 setInterval(() => { if (!floatPanel) return; floatPanel.style.position = 'fixed'; floatPanel.style.left = '20px'; floatPanel.style.bottom = '20px'; floatPanel.style.zIndex = '9999999'; }, 300); } function updateFloatPanel(currency) { if (!floatPanel) createFloatPanel(); // 关键修复1:即使无新元素,只要存在活跃货币,就显示悬浮窗 if (!currency && currentActiveCurrency) { currency = currentActiveCurrency; // 用全局活跃货币覆盖空值 } if (!currency) { floatPanel.classList.add('hidden'); floatPanel.querySelector('.panel-content').innerHTML = '无目标货币'; return; } // 始终显示悬浮窗(只要有活跃货币) floatPanel.classList.remove('hidden'); const contentEl = floatPanel.querySelector('.panel-content'); const finalRate = currency.getFinalRate(); const rateSource = currency.getRateSource(); const sourceClass = rateSource === '实时汇率' ? 'rate-source' : 'rate-source fallback'; if (currency.requestStatus === 'pending') { contentEl.innerHTML = `
当前货币:${currency.Type}
汇率获取中...
`; } else { contentEl.innerHTML = `
当前货币:${currency.Type}
转换汇率:1 ${currency.Type} = ${finalRate.toFixed(4)} CNY
${rateSource}
`; } } // ============================================================ function init() { console.log('=== 初始化开始 ==='); AddStyles(); createFloatPanel(); ConvertPageCurrencyValues(); ObserveDOMChanges(); console.log('=== 初始化完成 ==='); } function AddStyles() { const style = document.createElement('style'); style.textContent = CONFIG.highlightStyle; document.head.insertBefore(style, document.head.firstChild); console.log('=== 样式已添加 ==='); } function ExtractCurrencyInfo(element) { if (processedElements.has(element) || element.closest('#currency-float-panel')) { return [null, null, null]; } const text = element.textContent.trim(); console.log(`[提取信息] 元素文本: ${text.substring(0, 50)}...`); // 限制长度,避免日志过长 // 1. 处理Symbol类型(如EUR/USD) const currencyCodeEl = element.querySelector('.currency-code'); if (currencyCodeEl) { const title = currencyCodeEl.getAttribute('title')?.trim(); console.log(`[提取信息] 找到.currency-code,title: ${title}`); if (title) { const targetCurrency = CurrencyDict.find(c => c.Symbol === title); if (targetCurrency) { const priceMatch = text.match(/(\d{1,3}(?:,\d{3})*(?:\.\d+)?)/); console.log(`[提取信息] 价格匹配: ${priceMatch?.[1] || '无'}`); if (priceMatch) { const numericValue = parseFloat(priceMatch[1].replace(/,/g, '')); if (!isNaN(numericValue)) { console.log(`[提取成功] ${title} 数值: ${numericValue}`); currentActiveCurrency = targetCurrency; return [numericValue, title, targetCurrency]; } } } } } // 2. 处理正则匹配类型(如ARS) for (let currency of CurrencyDict) { if (!currency.MatchRegex) continue; const match = text.match(currency.MatchRegex); if (match && match[1]) { console.log(`[提取信息] 正则匹配 ${currency.Type}: ${match[1]}`); const numericValue = parseFloat(match[1].replace(/,/g, '')); if (!isNaN(numericValue)) { console.log(`[提取成功] ${currency.Type} 数值: ${numericValue}`); currentActiveCurrency = currency; return [numericValue, currency.Type, currency]; } } } console.log(`[提取失败] 元素不含目标货币`); return [null, null, null]; } function FindPriceElements() { const validElements = []; const allPriceEls = document.querySelectorAll('.product-price:not(.currency-processed)'); console.log(`[查找元素] 找到${allPriceEls.length}个未处理.product-price`); allPriceEls.forEach(el => { const [_, __, targetCurrency] = ExtractCurrencyInfo(el); if (targetCurrency && !processedElements.has(el)) { validElements.push(el); console.log(`[查找元素] 加入有效元素列表`); } }); console.log(`[查找元素] 有效元素总数: ${validElements.length}`); // 关键修复2:移除“有效元素为0时重置活跃货币”的逻辑,保留当前活跃货币 // 原错误代码:if (validElements.length === 0) currentActiveCurrency = null; return validElements; } function FormatCurrency(value, currencyType) { const targetCurrency = CurrencyDict.find(c => c.Type === currencyType); if (!targetCurrency) { console.warn(`[格式化] 未知货币类型: ${currencyType}`); return `${value.toFixed(2)} (未知货币)`; } const finalRate = targetCurrency.getFinalRate(); const originalCNY = (value * finalRate).toFixed(2); const discountedCNY = (originalCNY * CONFIG.discount).toFixed(2); console.log(`[格式化] ${value} ${currencyType} → 原始: ${originalCNY} CNY, 折扣后: ${discountedCNY} CNY`); return `${originalCNY} (${discountedCNY}) CNY`; } function ProcessPriceElement(element, currencyType) { if (element.classList.contains('currency-processed') || processedElements.has(element)) { console.log(`[处理元素] 已处理,跳过`); return; } const [priceValue, _, targetCurrency] = ExtractCurrencyInfo(element); if (!priceValue || !targetCurrency) { console.log(`[处理元素] 无效价格或货币,跳过`); return; } try { const formattedCNY = FormatCurrency(priceValue, currencyType); const conversionEl = document.createElement('span'); conversionEl.className = 'currency-conversion'; conversionEl.textContent = `≈${formattedCNY}`; console.log(`[处理元素] 生成转换标签: ${conversionEl.textContent}`); // 兼容多种插入位置,确保能插入 let insertPoint = element.querySelector('.product-price_wrap') || element.querySelector('.price-wrap') || element.querySelector('.price') || element; // 最后 fallback 到元素自身 insertPoint.appendChild(conversionEl); console.log(`[处理元素] 标签已插入到:`, insertPoint); element.classList.add('currency-processed'); processedElements.add(element); } catch (e) { console.error(`[处理元素] 插入失败:`, e); } } function ConvertPageCurrencyValues() { if (isProcessing) { console.log(`[转换流程] 正在处理中,跳过`); return; } isProcessing = true; console.log(`=== 开始转换页面货币 ===`); if (observer) observer.disconnect(); const validElements = FindPriceElements(); // 关键修复3:即使无新元素,只要有活跃货币,就更新悬浮窗(不隐藏) if (validElements.length === 0) { console.log(`[转换流程] 无新元素,但保留活跃货币`); updateFloatPanel(currentActiveCurrency); // 用当前活跃货币更新,而非null isProcessing = false; if (observer) observer.observe(document.body, { childList: true, subtree: true }); return; } // (货币分组处理逻辑保持不变) const currencyGroups = {}; validElements.forEach(el => { const [_, currencyType, targetCurrency] = ExtractCurrencyInfo(el); if (targetCurrency) { if (!currencyGroups[currencyType]) { currencyGroups[currencyType] = { currency: targetCurrency, elements: [] }; } currencyGroups[currencyType].elements.push(el); } }); Object.values(currencyGroups).forEach(group => { const { currency, elements } = group; switch (currency.requestStatus) { case 'idle': elements.forEach(el => currency.addPendingElement(el)); currency.fetchExchangeRate(); break; case 'pending': elements.forEach(el => currency.addPendingElement(el)); updateFloatPanel(currency); break; case 'done': elements.forEach(el => ProcessPriceElement(el, currency.Type)); updateFloatPanel(currency); break; } }); if (observer) { observer.observe(document.body, { childList: true, subtree: true }); } isProcessing = false; console.log(`=== 转换流程结束 ===`); } function ObserveDOMChanges() { observer = new MutationObserver(mutations => { let hasValidNewElement = false; mutations.forEach(mutation => { if (mutation.type !== 'childList') return; mutation.addedNodes.forEach(node => { if (node.nodeType !== Node.ELEMENT_NODE || node.id === 'currency-float-panel') return; const [_, __, targetCurrency] = ExtractCurrencyInfo(node); const hasChildValidEl = !!node.querySelector('.product-price:not(.currency-processed)') && ExtractCurrencyInfo(node.querySelector('.product-price:not(.currency-processed)'))[2]; if (targetCurrency || hasChildValidEl) { hasValidNewElement = true; if (targetCurrency) currentActiveCurrency = targetCurrency; console.log(`[DOM监听] 检测到新增有效元素`); } }); }); if (hasValidNewElement && !isProcessing) { console.log(`[DOM监听] 触发延迟转换`); clearTimeout(window.currencyConversionTimeout); window.currencyConversionTimeout = setTimeout(ConvertPageCurrencyValues, 500); } }); observer.observe(document.body, { childList: true, subtree: true }); console.log(`[DOM监听] 已开启`); } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();