// ==UserScript== // @name 外币自动转换人民币助手 // @namespace http://tampermonkey.net/ // @version 0.1.0 // @description 自动检测网页中的外币价格,实时转换为人民币显示。支持替换模式和附加模式。 // @author Currency Converter Team // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect api.exchangerate-api.com // @connect open.er-api.com // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; const SCRIPT_VERSION = '0.1.0'; const PROCESSED_ATTR = 'data-cc-done'; const CONVERTED_CLASS = 'cc-converted'; const DEFAULT_CONFIG = { mode: 'append', enabled: true, decimalPlaces: 2, cacheDuration: 6 * 3600 * 1000 }; 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(); 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 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; } // ═══════════════════════════════════════════════════════════ // DOM 操作引擎 - 纯DOM操作,不使用innerHTML // ═══════════════════════════════════════════════════════════ 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; } } // ═══════════════════════════════════════════════════════════ // UI 组件 // ═══════════════════════════════════════════════════════════ function createStatusIndicator() { 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; `; 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 = `
版本: ${SCRIPT_VERSION}
显示模式
汇率信息
${rateCache.data ? '✅ 汇率已加载' : '❌ 汇率未加载'}
更新时间: ${timeInfo}
${ratesInfo}