// ==UserScript== // @name 外币自动转换人民币助手 // @namespace http://tampermonkey.net/ // @version 0.2.2 // @description 自动检测网页中的外币价格,实时转换为人民币显示。支持替换模式和附加模式,支持白名单/黑名单,支持汇率自动刷新。 // @author Currency Converter Team // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @connect api.exchangerate-api.com // @connect open.er-api.com // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; const SCRIPT_VERSION = '0.2.1'; const PROCESSED_ATTR = 'data-cc-done'; const CONVERTED_CLASS = 'cc-converted'; const DEFAULT_CONFIG = { mode: 'append', enabled: true, decimalPlaces: 2, cacheDuration: 6 * 3600 * 1000, showStatusIndicator: false, listMode: 'none', whitelist: [], blacklist: [], autoRefreshOnModeChange: true, autoRefreshRates: true, autoRefreshInterval: 120 }; const CACHE_DURATION = 30 * 60 * 1000; let rateCache = { data: null, timestamp: null }; const MATCHERS = [ { re: /HK\$\s?([\d,]+(?:\.\d+)?)/gi, code: 'HKD' }, { re: /NT\$\s?([\d,]+(?:\.\d+)?)/gi, code: 'TWD' }, { re: /US\$\s?([\d,]+(?:\.\d+)?)/gi, code: 'USD' }, { re: /A\$\s?([\d,]+(?:\.\d+)?)/gi, code: 'AUD' }, { re: /C\$\s?([\d,]+(?:\.\d+)?)/gi, code: 'CAD' }, { re: /S\$\s?([\d,]+(?:\.\d+)?)/gi, code: 'SGD' }, { re: /R\$\s?([\d,]+(?:\.\d+)?)/gi, code: 'BRL' }, { re: /USD\s?([\d,]+(?:\.\d+)?)/gi, code: 'USD' }, { re: /EUR\s?([\d,]+(?:\.\d+)?)/gi, code: 'EUR' }, { re: /GBP\s?([\d,]+(?:\.\d+)?)/gi, code: 'GBP' }, { re: /JPY\s?([\d,]+(?:\.\d+)?)/gi, code: 'JPY' }, { re: /HKD\s?([\d,]+(?:\.\d+)?)/gi, code: 'HKD' }, { re: /KRW\s?([\d,]+(?:\.\d+)?)/gi, code: 'KRW' }, { re: /AUD\s?([\d,]+(?:\.\d+)?)/gi, code: 'AUD' }, { re: /CAD\s?([\d,]+(?:\.\d+)?)/gi, code: 'CAD' }, { re: /TWD\s?([\d,]+(?:\.\d+)?)/gi, code: 'TWD' }, { re: /SGD\s?([\d,]+(?:\.\d+)?)/gi, code: 'SGD' }, { re: /CHF\s?([\d,]+(?:\.\d+)?)/gi, code: 'CHF' }, ]; const SYMBOL_MATCHERS = [ { re: /€\s?([\d,]+(?:\.\d+)?)/g, code: 'EUR' }, { re: /£\s?([\d,]+(?:\.\d+)?)/g, code: 'GBP' }, { re: /₩\s?([\d,]+(?:\.\d+)?)/g, code: 'KRW' }, ]; const DOLLAR_RE = /\$\s?([\d,]+(?:\.\d+)?)/g; const DOLLAR_PREFIX_RE = /[A-Za-z]$/; const SKIP_TAGS = new Set([ 'SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'SELECT', 'OPTION', 'CODE', 'PRE', 'KBD', 'SAMP', 'NOSCRIPT', 'IFRAME', 'SVG', 'MATH', 'CANVAS', 'VIDEO', 'AUDIO', 'IMG', 'BR', 'HR', 'META', 'LINK', 'HEAD', 'TITLE', ]); let statusIndicator = null; let isProcessing = false; let isModifying = false; let observer = null; let processedNodes = new WeakSet(); let autoRefreshTimer = null; function getConfig() { const stored = GM_getValue('currencyConverterConfig', null); if (stored) { return { ...DEFAULT_CONFIG, ...stored }; } return { ...DEFAULT_CONFIG }; } function saveConfig(config) { GM_setValue('currencyConverterConfig', config); } function parseAmount(s) { const n = parseFloat(s.replace(/,/g, '')); return (isNaN(n) || n <= 0) ? null : n; } function formatPrice(amt) { const d = getConfig().decimalPlaces || 2; if (amt >= 100000) return '≈¥' + (amt / 10000).toFixed(1) + '万'; if (amt >= 10000) return '≈¥' + (amt / 10000).toFixed(d) + '万'; return '≈¥' + amt.toFixed(d); } function quickCheck(text) { if (/≈¥/.test(text)) return false; if (/[\$€£₩]/.test(text)) return true; if (/\b(?:USD|EUR|GBP|JPY|HKD|KRW|AUD|CAD|TWD|SGD|CHF)\b/i.test(text)) return true; return false; } function findAllMatches(text) { const hits = []; for (const m of MATCHERS) { m.re.lastIndex = 0; let match; while ((match = m.re.exec(text)) !== null) { const amt = parseAmount(match[1]); if (amt !== null) { hits.push({ index: match.index, length: match[0].length, code: m.code, amount: amt, fullMatch: match[0] }); } } } for (const m of SYMBOL_MATCHERS) { m.re.lastIndex = 0; let match; while ((match = m.re.exec(text)) !== null) { const amt = parseAmount(match[1]); if (amt !== null) { hits.push({ index: match.index, length: match[0].length, code: m.code, amount: amt, fullMatch: match[0] }); } } } DOLLAR_RE.lastIndex = 0; let dm; while ((dm = DOLLAR_RE.exec(text)) !== null) { const idx = dm.index; if (idx > 0 && DOLLAR_PREFIX_RE.test(text[idx - 1])) continue; const amt = parseAmount(dm[1]); if (amt !== null) { hits.push({ index: idx, length: dm[0].length, code: 'USD', amount: amt, fullMatch: dm[0] }); } } if (hits.length === 0) return []; hits.sort((a, b) => a.index - b.index || b.length - a.length); const result = []; let lastEnd = -1; for (const h of hits) { if (h.index >= lastEnd) { result.push(h); lastEnd = h.index + h.length; } } return result; } const API_ENDPOINTS = [ 'https://open.er-api.com/v6/latest/USD', 'https://api.exchangerate-api.com/v4/latest/USD' ]; async function fetchExchangeRates() { console.log('[CC] fetchExchangeRates - checking cache...'); if (rateCache.data && rateCache.timestamp && (Date.now() - rateCache.timestamp) < CACHE_DURATION) { console.log('[CC] using cached rates'); return rateCache.data; } for (const apiUrl of API_ENDPOINTS) { try { console.log('[CC] fetching from:', apiUrl); updateStatus('正在获取汇率...', 'loading'); const response = await fetchWithGM(apiUrl); console.log('[CC] response:', response ? 'got data' : 'null'); if (response && response.rates) { rateCache.data = response.rates; rateCache.timestamp = Date.now(); updateStatus('汇率已更新', 'success'); console.log('[CC] rates loaded, CNY:', response.rates.CNY); return response.rates; } } catch (error) { console.error('[CurrencyConverter] API请求失败:', error); continue; } } updateStatus('汇率获取失败', 'error'); return null; } function fetchWithGM(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 10000, onload: function(response) { try { const data = JSON.parse(response.responseText); resolve(data); } catch (e) { reject(e); } }, onerror: function(error) { reject(error); }, ontimeout: function() { reject(new Error('请求超时')); } }); }); } function convertCurrency(amount, fromCurrency) { if (!rateCache.data) return null; const rates = rateCache.data; if (fromCurrency === 'CNY') return amount; if (rates[fromCurrency] && rates['CNY']) { const inUSD = amount / rates[fromCurrency]; return inUSD * rates['CNY']; } return null; } function skip(node) { if (!node) return true; if (processedNodes.has(node)) return true; const el = node.nodeType === 3 ? node.parentElement : node; if (!el) return true; if (SKIP_TAGS.has(el.tagName)) return true; if (el.isContentEditable) return true; if (el.classList && el.classList.contains(CONVERTED_CLASS)) return true; if (el.getAttribute && el.getAttribute(PROCESSED_ATTR)) return true; return false; } function walkDOM(root) { if (!root) return; const walker = document.createTreeWalker( root, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { if (SKIP_TAGS.has(node.tagName)) return NodeFilter.FILTER_REJECT; if (node.isContentEditable) return NodeFilter.FILTER_REJECT; if (node.classList && node.classList.contains(CONVERTED_CLASS)) return NodeFilter.FILTER_REJECT; if (node.getAttribute && node.getAttribute(PROCESSED_ATTR)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } } ); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); console.log('[CC] walkDOM found', nodes.length, 'element nodes'); for (const n of nodes) { try { processElement(n); } catch (e) { console.error('[CC] processElement error:', e); } } } function processElement(el) { if (!el || !el.textContent) return; if (el.getAttribute(PROCESSED_ATTR)) return; const text = el.textContent; if (text.length < 2) return; if (!quickCheck(text)) return; const matches = findAllMatches(text); if (matches.length === 0) return; if (el.children && el.children.length > 0) { for (const child of el.children) { if (child.nodeType !== 1) continue; if (SKIP_TAGS.has(child.tagName)) continue; if (child.classList && child.classList.contains(CONVERTED_CLASS)) continue; if (child.getAttribute && child.getAttribute(PROCESSED_ATTR)) continue; const childText = child.textContent; if (childText && quickCheck(childText) && findAllMatches(childText).length > 0) { return; } } } console.log('[CC] TARGET:', JSON.stringify(text.substring(0, 100)), 'matches:', matches.length); const config = getConfig(); if (config.mode === 'replace') { processElementReplace(el, text, matches); } else { processElementAppend(el, text, matches); } el.setAttribute(PROCESSED_ATTR, '1'); } function processElementReplace(el, text, matches) { let newText = text; let offset = 0; for (const m of matches) { const cny = convertCurrency(m.amount, m.code); if (cny === null) continue; const cnyText = formatPrice(cny); const start = m.index + offset; newText = newText.substring(0, start) + cnyText + newText.substring(start + m.length); offset += cnyText.length - m.length; } if (newText !== text) { isModifying = true; el.textContent = newText; isModifying = false; } } function processElementAppend(el, text, matches) { isModifying = true; for (const m of matches) { const cny = convertCurrency(m.amount, m.code); if (cny === null) continue; const cnyText = formatPrice(cny); const span = document.createElement('span'); span.className = CONVERTED_CLASS; span.setAttribute(PROCESSED_ATTR, '1'); span.style.cssText = 'color:#e74c3c;font-weight:500;margin-left:2px;font-size:0.9em;'; span.textContent = cnyText; el.appendChild(span); } isModifying = false; } function processPage() { if (isProcessing) return; const config = getConfig(); console.log('[CC] processPage - enabled:', config.enabled, 'rates:', !!rateCache.data); if (!config.enabled || !rateCache.data) { console.log('[CC] processPage skipped - enabled:', config.enabled, 'rates:', !!rateCache.data); return; } isProcessing = true; try { walkDOM(document.body); } finally { isProcessing = false; } } function getCurrentHostname() { try { return window.location.hostname; } catch (e) { return ''; } } function isUrlInList(hostname, list) { if (!list || list.length === 0) return false; for (const item of list) { if (!item) continue; const pattern = item.replace(/\./g, '\\.').replace(/\*/g, '.*'); const regex = new RegExp('^' + pattern + '$', 'i'); if (regex.test(hostname)) return true; } return false; } function shouldRunOnCurrentSite() { const config = getConfig(); const hostname = getCurrentHostname(); if (config.listMode === 'whitelist') { return isUrlInList(hostname, config.whitelist || []); } else if (config.listMode === 'blacklist') { return !isUrlInList(hostname, config.blacklist || []); } return true; } function createStatusIndicator() { const config = getConfig(); if (!config.showStatusIndicator) { return null; } const indicator = document.createElement('div'); indicator.id = 'currency-converter-status'; indicator.style.cssText = ` position: fixed !important; bottom: 20px !important; right: 20px !important; background: white !important; border: 1px solid #ddd !important; border-radius: 8px !important; padding: 10px 15px !important; font-size: 12px !important; box-shadow: 0 2px 10px rgba(0,0,0,0.1) !important; z-index: 2147483647 !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; cursor: pointer !important; user-select: none !important; `; document.body.appendChild(indicator); indicator.addEventListener('click', showConfigPanel); return indicator; } function updateStatus(message, type = 'info') { if (!statusIndicator) return; const colors = { loading: '#007bff', success: '#28a745', error: '#dc3545', info: '#6c757d' }; statusIndicator.innerHTML = `
${message}
`; } function showConfigPanel() { const config = getConfig(); const panel = document.createElement('div'); panel.id = 'currency-converter-panel'; panel.style.cssText = ` position: fixed !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%) !important; background: white !important; border-radius: 12px !important; padding: 25px !important; min-width: 400px !important; max-width: 500px !important; max-height: 80vh !important; overflow-y: auto !important; box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important; z-index: 2147483647 !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; `; const overlay = document.createElement('div'); overlay.id = 'currency-converter-overlay'; overlay.style.cssText = ` position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; background: rgba(0,0,0,0.5) !important; z-index: 2147483646 !important; `; overlay.addEventListener('click', () => { panel.remove(); overlay.remove(); }); const ratesInfo = rateCache.data ? JSON.stringify(rateCache.data, null, 2) : '未加载'; const timeInfo = rateCache.timestamp ? new Date(rateCache.timestamp).toLocaleString() : '无'; const currentHost = getCurrentHostname(); const whitelistStr = (config.whitelist || []).join('\n'); const blacklistStr = (config.blacklist || []).join('\n'); panel.innerHTML = `

外币转换助手设置

版本: ${SCRIPT_VERSION} | 当前网站: ${currentHost}

分钟刷新一次(10-1440分钟)

显示模式

网站过滤模式

白名单(每行一个域名,支持 * 通配符):

黑名单(每行一个域名,支持 * 通配符):

汇率信息

${rateCache.data ? '✅ 汇率已加载' : '❌ 汇率未加载'}

更新时间: ${timeInfo}

查看汇率详情
${ratesInfo}
`; document.body.appendChild(overlay); document.body.appendChild(panel); function updateListVisibility() { const mode = document.querySelector('input[name="cc-listmode"]:checked').value; document.getElementById('cc-whitelist-container').style.display = mode === 'whitelist' ? 'block' : 'none'; document.getElementById('cc-blacklist-container').style.display = mode === 'blacklist' ? 'block' : 'none'; } document.querySelectorAll('input[name="cc-listmode"]').forEach(radio => { radio.addEventListener('change', updateListVisibility); }); document.getElementById('cc-enabled').addEventListener('change', (e) => { config.enabled = e.target.checked; saveConfig(config); if (config.enabled) { processPage(); } }); document.getElementById('cc-show-indicator').addEventListener('change', (e) => { config.showStatusIndicator = e.target.checked; saveConfig(config); if (statusIndicator) { statusIndicator.style.display = config.showStatusIndicator ? 'block' : 'none'; } }); document.getElementById('cc-auto-refresh').addEventListener('change', (e) => { config.autoRefreshOnModeChange = e.target.checked; saveConfig(config); }); const autoRefreshRatesCheckbox = document.getElementById('cc-auto-refresh-rates'); const refreshIntervalInput = document.getElementById('cc-refresh-interval'); autoRefreshRatesCheckbox.addEventListener('change', (e) => { config.autoRefreshRates = e.target.checked; saveConfig(config); refreshIntervalInput.disabled = !config.autoRefreshRates; setupAutoRefreshTimer(); }); refreshIntervalInput.addEventListener('change', (e) => { let val = parseInt(e.target.value, 10); if (isNaN(val) || val < 10) val = 10; if (val > 1440) val = 1440; e.target.value = val; config.autoRefreshInterval = val; saveConfig(config); setupAutoRefreshTimer(); }); document.querySelectorAll('input[name="cc-mode"]').forEach(radio => { radio.addEventListener('change', (e) => { const oldMode = config.mode; config.mode = e.target.value; saveConfig(config); if (config.autoRefreshOnModeChange && oldMode !== config.mode) { panel.remove(); overlay.remove(); location.reload(); } else { processPage(); } }); }); document.querySelectorAll('input[name="cc-listmode"]').forEach(radio => { radio.addEventListener('change', (e) => { config.listMode = e.target.value; saveConfig(config); }); }); document.getElementById('cc-whitelist').addEventListener('change', (e) => { const text = e.target.value.trim(); config.whitelist = text ? text.split('\n').map(s => s.trim()).filter(s => s) : []; saveConfig(config); }); document.getElementById('cc-blacklist').addEventListener('change', (e) => { const text = e.target.value.trim(); config.blacklist = text ? text.split('\n').map(s => s.trim()).filter(s => s) : []; saveConfig(config); }); document.getElementById('cc-refresh').addEventListener('click', async () => { rateCache.data = null; rateCache.timestamp = null; await fetchExchangeRates(); if (getConfig().enabled) { processPage(); } panel.remove(); overlay.remove(); }); document.getElementById('cc-close').addEventListener('click', () => { panel.remove(); overlay.remove(); }); } function setupAutoRefreshTimer() { if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; } const config = getConfig(); if (!config.autoRefreshRates || !config.enabled) { console.log('[CC] 自动刷新汇率已禁用'); return; } const intervalMinutes = config.autoRefreshInterval || 120; const intervalMs = intervalMinutes * 60 * 1000; console.log(`[CC] 设置汇率自动刷新: 每 ${intervalMinutes} 分钟`); autoRefreshTimer = setInterval(async () => { console.log('[CC] 自动刷新汇率...'); rateCache.data = null; rateCache.timestamp = null; await fetchExchangeRates(); if (getConfig().enabled) { processPage(); } }, intervalMs); } function setupObserver() { if (observer) observer.disconnect(); let pending = []; let timer = null; function flush() { if (!getConfig().enabled || isProcessing || isModifying) return; isProcessing = true; const nodes = pending.splice(0); for (const n of nodes) { try { if (n.nodeType === 1) { processElement(n); walkDOM(n); } } catch (e) { /* skip */ } } isProcessing = false; } observer = new MutationObserver((muts) => { if (!getConfig().enabled || isModifying) return; for (const mut of muts) { if (mut.type !== 'childList') continue; for (const n of mut.addedNodes) { if (n.nodeType === 1 && !skip(n)) { if (n.id === 'currency-converter-status' || n.id === 'currency-converter-panel' || n.id === 'currency-converter-overlay') continue; pending.push(n); } } } if (pending.length > 0) { clearTimeout(timer); timer = setTimeout(flush, 300); } }); observer.observe(document.body, { childList: true, subtree: true }); return observer; } async function init() { console.log('========== 外币转换助手初始化 =========='); console.log(`版本: ${SCRIPT_VERSION}`); const config = getConfig(); if (!shouldRunOnCurrentSite()) { console.log('[CC] 当前网站不在运行列表中,跳过'); return; } statusIndicator = createStatusIndicator(); if (statusIndicator) { updateStatus('正在获取汇率...', 'loading'); } await fetchExchangeRates(); if (config.enabled) { setTimeout(() => processPage(), 500); } setupObserver(); setupAutoRefreshTimer(); GM_registerMenuCommand('🎯 打开外币转换设置', showConfigPanel); GM_registerMenuCommand(config.showStatusIndicator ? '🔴 隐藏状态指示器' : '🟢 显示状态指示器', () => { config.showStatusIndicator = !config.showStatusIndicator; saveConfig(config); if (statusIndicator) { statusIndicator.remove(); statusIndicator = null; } if (config.showStatusIndicator) { statusIndicator = createStatusIndicator(); if (statusIndicator && rateCache.data) { updateStatus('汇率已更新', 'success'); } } }); console.log('========== 初始化完成 =========='); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();