// ==UserScript== // @name 外币自动转换人民币助手 // @namespace http://tampermonkey.net/ // @version 0.4.0 // @description 自动检测网页中的外币价格,实时转换为人民币显示。支持替换模式和附加模式,支持白名单/黑名单,支持汇率自动刷新。 // @author Currency Converter Team // @match *://*/* // @match file:///* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_addStyle // @connect api.exchangerate-api.com // @connect open.er-api.com // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; if (typeof window !== 'undefined' && window.__CC_RUNTIME_ACTIVE__) { return; } if (typeof window !== 'undefined') { window.__CC_RUNTIME_ACTIVE__ = true; } const SCRIPT_VERSION = '0.4.0'; const PROCESSED_ATTR = 'data-cc-done'; const CONVERTED_CLASS = 'cc-converted'; const CONVERTED_GROUP_CLASS = 'cc-converted-group'; 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, debugMode: false }; const CACHE_DURATION = 30 * 60 * 1000; const PERSISTED_RATE_CACHE_KEY = 'currencyConverterRateCache'; const MIN_MUTATION_SCAN_INTERVAL = 1200; const DORMANT_PROBE_MIN_INTERVAL = 1200; const DORMANT_PROBE_NODE_LIMIT = 120; const DORMANT_PROBE_CHAR_LIMIT = 4000; const DORMANT_PROBE_TEXT_LIMIT = 220; let rateCache = { data: null, timestamp: null }; const AMOUNT_CAPTURE_PATTERN = '([\\d,]+(?:\\.\\d+(?:[\\s\\u00A0]+\\d+)*)?)'; const MATCHERS = [ { re: new RegExp('HK\\$\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'HKD' }, { re: new RegExp('NT\\$\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'TWD' }, { re: new RegExp('US\\$\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'USD' }, { re: new RegExp('A\\$\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'AUD' }, { re: new RegExp('C\\$\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'CAD' }, { re: new RegExp('S\\$\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'SGD' }, { re: new RegExp('R\\$\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'BRL' }, { re: new RegExp('USD\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'USD' }, { re: new RegExp('EUR\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'EUR' }, { re: new RegExp('GBP\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'GBP' }, { re: new RegExp('JPY\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'JPY' }, { re: new RegExp('HKD\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'HKD' }, { re: new RegExp('KRW\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'KRW' }, { re: new RegExp('AUD\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'AUD' }, { re: new RegExp('CAD\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'CAD' }, { re: new RegExp('TWD\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'TWD' }, { re: new RegExp('SGD\\s?' + AMOUNT_CAPTURE_PATTERN, 'gi'), code: 'SGD' }, { re: new RegExp('CHF\\s?' + AMOUNT_CAPTURE_PATTERN, '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 = new RegExp('\\$\\s?' + AMOUNT_CAPTURE_PATTERN, 'g'); const DOLLAR_PREFIX_RE = /[A-Za-z]$/; const NORMALIZED_SYMBOL_MATCHERS = [ { re: new RegExp('\\u20AC\\s?' + AMOUNT_CAPTURE_PATTERN, 'g'), code: 'EUR' }, { re: new RegExp('\\u00A3\\s?' + AMOUNT_CAPTURE_PATTERN, 'g'), code: 'GBP' }, { re: new RegExp('\\u20A9\\s?' + AMOUNT_CAPTURE_PATTERN, 'g'), code: 'KRW' }, ]; 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', ]); const INTERACTIVE_TAGS = new Set(['BUTTON']); const INTERACTIVE_ROLES = new Set(['button', 'textbox', 'dialog', 'menu', 'listbox', 'combobox']); const FALLBACK_CONTAINER_TAGS = new Set(['SPAN', 'A', 'STRONG', 'B', 'EM', 'SMALL', 'DIV', 'P']); let statusIndicator = null; let isProcessing = false; let isModifying = false; let observer = null; let autoRefreshTimer = null; let processPageTimer = null; let scheduledProcessAt = 0; let interactionRescanTimers = []; let debugScanState = null; let lastProcessAt = 0; let pendingProcessReason = null; let dormantProbeTimer = null; let lastDormantProbeAt = 0; let isScannerAwake = false; let wakePromise = null; let hoverProbeWindowUntil = 0; let lastHoverProbeAt = 0; let lastHoverProbeTarget = null; let menuCommandIds = []; 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 loadPersistedRateCache() { const stored = GM_getValue(PERSISTED_RATE_CACHE_KEY, null); if (!stored || !stored.data || !stored.timestamp) return null; return stored; } function persistRateCache() { if (!rateCache.data || !rateCache.timestamp) return; GM_setValue(PERSISTED_RATE_CACHE_KEY, { data: rateCache.data, timestamp: rateCache.timestamp }); } function isDebugEnabled() { return !!getConfig().debugMode; } function debugLog(...args) { if (!isDebugEnabled()) return; console.log('[CC DEBUG]', ...args); } function previewText(text, maxLen = 80) { if (!text) return ''; return text.replace(/\s+/g, ' ').trim().slice(0, maxLen); } function hasPotentialCurrencyText(text, maxLen = 160) { if (!text) return false; const compact = text.replace(/\s+/g, ' ').trim(); if (!compact || compact.length > maxLen) return false; return quickCheck(compact); } function hasCurrencyClueText(text, maxLen = 220) { if (!text) return false; const compact = text.replace(/\s+/g, ' ').trim(); if (!compact || compact.length > maxLen) return false; if (!quickCheck(compact)) return false; return findAllMatches(compact).length > 0; } function shouldScheduleForMutationNode(node) { if (!node) return false; if (node.nodeType === 3) { if (hasPotentialCurrencyText(node.nodeValue, 120)) return true; return shouldScheduleForMutationNode(node.parentElement); } if (node.nodeType !== 1) return false; const el = node; if (skip(el)) return false; if (hasPotentialCurrencyText(el.textContent || '', 160)) return true; const parent = el.parentElement; if (parent && !skip(parent) && hasPotentialCurrencyText(parent.textContent || '', 160)) { return true; } return false; } function runWhenIdle(callback, timeout = 500) { if (typeof window.requestIdleCallback === 'function') { return window.requestIdleCallback(callback, { timeout }); } return window.setTimeout(callback, Math.min(timeout, 120)); } function isInteractiveRole(el) { if (!el || !el.getAttribute) return false; const role = (el.getAttribute('role') || '').trim().toLowerCase(); return !!role && INTERACTIVE_ROLES.has(role); } function isLiveRegion(el) { if (!el || !el.getAttribute) return false; if (el.hasAttribute('aria-live')) return true; const busy = (el.getAttribute('aria-busy') || '').trim().toLowerCase(); return !!busy && busy !== 'false'; } function isSafeDisplayTooltip(el, maxLen = 220) { if (!el || !el.getAttribute) return false; const role = (el.getAttribute('role') || '').trim().toLowerCase(); const className = typeof el.className === 'string' ? el.className.toLowerCase() : ''; const hasTooltipClass = className.includes('tooltip'); const hasTooltipRole = role === 'tooltip'; const hasLiveStatus = role === 'status' && isLiveRegion(el); if (!hasTooltipClass && !hasTooltipRole && !hasLiveStatus) return false; if (el.isContentEditable) return false; if (el.querySelector && el.querySelector('button,input,textarea,select,option,[role="button"],[role="textbox"],[role="dialog"],[role="menu"],[role="listbox"],[role="combobox"],[contenteditable=""],[contenteditable="true"]')) { return false; } const text = (el.textContent || '').replace(/\s+/g, ' ').trim(); if (!text || text.length > maxLen) return false; return true; } function isProtectedContext(el) { let current = el; while (current) { if (isSafeDisplayTooltip(current)) { current = current.parentElement; continue; } if (INTERACTIVE_TAGS.has(current.tagName)) return true; if (isInteractiveRole(current)) return true; if (isLiveRegion(current)) return true; current = current.parentElement; } return false; } function hasRiskyInteractiveDescendant(el) { if (!el || !el.querySelector) return false; return !!el.querySelector( 'button,input,textarea,select,option,[role="button"],[role="textbox"],[role="dialog"],[role="menu"],[role="listbox"],[role="combobox"],[aria-live],[aria-busy="true"],[contenteditable=""],[contenteditable="true"]' ); } function getHoverProbeHost(target) { if (!target || target.nodeType !== 1) return null; const hoverSelectors = [ '.recharts-tooltip-wrapper', '.recharts-default-tooltip', '[role="tooltip"]', '[role="status"]', '[class*="tooltip"]', '[class*="Tooltip"]', '.recharts-rectangle', '.recharts-bar-rectangle', '.recharts-layer', 'svg' ].join(','); if (target.closest) { const host = target.closest(hoverSelectors); if (host) return host; } return target; } function isLikelyHoverCurrencySurface(el) { if (!el || el.nodeType !== 1) return false; if (isSafeDisplayTooltip(el, 260)) return true; if (shouldScheduleForMutationNode(el)) return true; const className = typeof el.className === 'string' ? el.className.toLowerCase() : ''; if (className.includes('tooltip') || className.includes('chart') || className.includes('recharts') || className.includes('bar') || className.includes('graph')) { return true; } const tagName = (el.tagName || '').toUpperCase(); if (tagName === 'PATH' || tagName === 'RECT' || tagName === 'SVG' || tagName === 'G') { return true; } if (el.closest && el.closest('.recharts-wrapper, [class*="chart"], [class*="Chart"], svg')) { return true; } return false; } function getHoverProbeTarget(event) { const rawTarget = event && event.target && event.target.nodeType === 1 ? event.target : null; if (!rawTarget) return null; const host = getHoverProbeHost(rawTarget); if (!host || !isLikelyHoverCurrencySurface(host)) return null; const relatedTarget = event.relatedTarget && event.relatedTarget.nodeType === 1 ? event.relatedTarget : null; if (relatedTarget) { const relatedHost = getHoverProbeHost(relatedTarget); if (relatedHost && relatedHost === host) { return null; } } const now = Date.now(); if (lastHoverProbeTarget === host && (now - lastHoverProbeAt) < 180) { return null; } lastHoverProbeTarget = host; lastHoverProbeAt = now; return host; } function probeCurrencyClueInSubtree(root, nodeLimit = DORMANT_PROBE_NODE_LIMIT, charLimit = DORMANT_PROBE_CHAR_LIMIT) { if (!root) return false; const seenParents = new Set(); const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { if (skip(node)) return NodeFilter.FILTER_REJECT; if (!node.nodeValue || node.nodeValue.trim().length === 0) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } } ); let visited = 0; let consumedChars = 0; while (walker.nextNode()) { const textNode = walker.currentNode; const text = textNode.nodeValue || ''; visited += 1; consumedChars += Math.min(text.length, DORMANT_PROBE_TEXT_LIMIT); if (hasCurrencyClueText(text, DORMANT_PROBE_TEXT_LIMIT)) { return true; } const parent = textNode.parentElement; if (parent && !seenParents.has(parent)) { seenParents.add(parent); if (parent.children && parent.children.length === 0) { const parentText = parent.textContent || ''; if (hasCurrencyClueText(parentText, DORMANT_PROBE_TEXT_LIMIT)) { return true; } } } if (visited >= nodeLimit || consumedChars >= charLimit) { break; } } return false; } function documentHasCurrencyClue() { if (hasCurrencyClueText(document.title || '', 120)) return true; if (!document.body) return false; return probeCurrencyClueInSubtree(document.body); } function hasCurrencyClueNearNode(node) { if (!node) return false; if (shouldScheduleForMutationNode(node)) return true; const root = node.nodeType === 3 ? node.parentElement : node; if (!root) return false; const rootsToProbe = []; if (root.nodeType === 1) rootsToProbe.push(root); if (root.parentElement) rootsToProbe.push(root.parentElement); for (const candidate of rootsToProbe) { if (!candidate || skip(candidate)) continue; const directText = candidate.textContent || ''; if (hasCurrencyClueText(directText, 260)) { return true; } if (probeCurrencyClueInSubtree(candidate, 40, 1600)) { return true; } } return false; } function cancelDormantProbe() { if (!dormantProbeTimer) return; clearTimeout(dormantProbeTimer); dormantProbeTimer = null; } function scheduleDormantProbe(reason = 'dormant-probe', targetNode = null, delay = 120) { if (isScannerAwake) return; if (!getConfig().enabled || isModifying) return; const now = Date.now(); const cooldown = Math.max(0, DORMANT_PROBE_MIN_INTERVAL - (now - lastDormantProbeAt)); const actualDelay = Math.max(delay, cooldown); cancelDormantProbe(); dormantProbeTimer = setTimeout(() => { dormantProbeTimer = null; lastDormantProbeAt = Date.now(); runWhenIdle(() => { if (isScannerAwake || !getConfig().enabled || isModifying) return; const hasClue = targetNode && targetNode !== document.body ? hasCurrencyClueNearNode(targetNode) : documentHasCurrencyClue(); debugLog('dormant probe', reason, 'hasClue:', hasClue); if (hasClue) { void activateScanner('wake-' + reason, { scan: true }); } }); }, actualDelay); } async function activateScanner(reason = 'manual-wake', options = {}) { const { scan = true, force = false } = options; const config = getConfig(); if (!config.enabled && !force) return false; if (wakePromise) { if (scan) pendingProcessReason = reason; return wakePromise; } if (isScannerAwake) { if (scan) { if (!rateCache.data) { pendingProcessReason = reason; wakePromise = (async () => { await fetchExchangeRates(); setupAutoRefreshTimer(); const nextReason = pendingProcessReason || reason; pendingProcessReason = null; if (getConfig().enabled && rateCache.data) { scheduleProcessPage(0, nextReason); } return true; })().finally(() => { wakePromise = null; }); return wakePromise; } setupAutoRefreshTimer(); scheduleProcessPage(0, reason); } return true; } isScannerAwake = true; pendingProcessReason = scan ? reason : pendingProcessReason; cancelDormantProbe(); debugLog('scanner wake requested:', reason); if (statusIndicator) { updateStatus('检测到货币,正在唤醒...', 'loading'); } wakePromise = (async () => { await fetchExchangeRates(); setupAutoRefreshTimer(); const nextReason = pendingProcessReason || reason; pendingProcessReason = null; if (getConfig().enabled && rateCache.data && scan) { scheduleProcessPage(0, nextReason); } return true; })().finally(() => { wakePromise = null; }); return wakePromise; } function startDebugScan(reason = 'manual') { if (!isDebugEnabled()) { debugScanState = null; return; } debugScanState = { reason, startedAt: Date.now(), visited: 0, skipped: 0, quickRejected: 0, matched: 0, converted: 0, restoredGroups: 0, samples: { skipped: [], quickRejected: [], matched: [], converted: [], currencyCandidates: [], combinedElements: [], parentFallbacks: [], ancestorFallbacks: [], renderedElements: [] } }; debugLog('start scan', reason); } function pushDebugSample(bucket, value, limit = 8) { if (!debugScanState) return; const list = debugScanState.samples[bucket]; if (!list || list.length >= limit) return; list.push(value); } function endDebugScan() { if (!debugScanState) return; const summary = { reason: debugScanState.reason, durationMs: Date.now() - debugScanState.startedAt, visited: debugScanState.visited, skipped: debugScanState.skipped, quickRejected: debugScanState.quickRejected, matched: debugScanState.matched, converted: debugScanState.converted, restoredGroups: debugScanState.restoredGroups }; console.groupCollapsed('[CC DEBUG] scan summary'); console.log(summary); console.log('currency candidate samples:', debugScanState.samples.currencyCandidates); console.log('combined element samples:', debugScanState.samples.combinedElements); console.log('parent fallback samples:', debugScanState.samples.parentFallbacks); console.log('ancestor fallback samples:', debugScanState.samples.ancestorFallbacks); console.log('rendered element samples:', debugScanState.samples.renderedElements); console.log('matched samples:', debugScanState.samples.matched); console.log('converted samples:', debugScanState.samples.converted); console.log('skipped samples:', debugScanState.samples.skipped); console.log('quick rejected samples:', debugScanState.samples.quickRejected); console.groupEnd(); debugScanState = null; } function parseAmount(s) { const n = parseFloat(s.replace(/[,\s\u00A0]+/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 NORMALIZED_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; } const persistedCache = loadPersistedRateCache(); if (persistedCache && persistedCache.data && persistedCache.timestamp && (Date.now() - persistedCache.timestamp) < CACHE_DURATION) { rateCache.data = persistedCache.data; rateCache.timestamp = persistedCache.timestamp; console.log('[CC] using persisted cached rates'); updateStatus('使用缓存汇率', 'success'); 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(); persistRateCache(); 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 isSkippedElement(el) { if (!el) return true; if (SKIP_TAGS.has(el.tagName)) return true; if (el.isContentEditable) return true; if (isProtectedContext(el)) return true; if (el.classList && el.classList.contains(CONVERTED_CLASS)) return true; if (el.classList && el.classList.contains(CONVERTED_GROUP_CLASS)) return true; if (el.closest && (el.closest('.' + CONVERTED_CLASS) || el.closest('.' + CONVERTED_GROUP_CLASS))) return true; return false; } function skip(node) { if (!node) return true; const el = node.nodeType === 3 ? node.parentElement : node; return isSkippedElement(el); } function getConvertedGroupBaseText(group) { let text = ''; for (const child of group.childNodes) { if (child.nodeType === 3) { text += child.nodeValue; continue; } if (child.nodeType === 1) { const childEl = child; if (childEl.classList && childEl.classList.contains(CONVERTED_CLASS)) { continue; } text += childEl.textContent || ''; } } return text; } function unwrapConvertedGroup(group) { if (!group || !group.parentNode) return null; const baseText = getConvertedGroupBaseText(group); const textNode = document.createTextNode(baseText); group.parentNode.replaceChild(textNode, group); return textNode; } function cleanupConvertedGroupsInSubtree(root) { if (!root || root.nodeType !== 1) return; const groups = []; if (root.classList && root.classList.contains(CONVERTED_GROUP_CLASS)) { groups.push(root); } if (root.querySelectorAll) { root.querySelectorAll('.' + CONVERTED_GROUP_CLASS).forEach((group) => groups.push(group)); } for (const group of groups) { unwrapConvertedGroup(group); } const inlineConvertedSpans = []; if (root.classList && root.classList.contains(CONVERTED_CLASS) && root.hasAttribute('data-cc-inline-append')) { inlineConvertedSpans.push(root); } if (root.querySelectorAll) { root.querySelectorAll('.' + CONVERTED_CLASS + '[data-cc-inline-append]').forEach((span) => inlineConvertedSpans.push(span)); } for (const span of inlineConvertedSpans) { if (span && span.parentNode) { span.parentNode.removeChild(span); } } } function restoreConvertedContent(root = document.body) { if (!root || !root.querySelectorAll) return; const groups = Array.from(root.querySelectorAll('.' + CONVERTED_GROUP_CLASS)); const inlineConvertedSpans = Array.from(root.querySelectorAll('.' + CONVERTED_CLASS + '[data-cc-inline-append]')); if (groups.length === 0 && inlineConvertedSpans.length === 0) return; isModifying = true; try { for (const span of inlineConvertedSpans) { if (span.parentNode) { span.parentNode.removeChild(span); } } for (const group of groups) { unwrapConvertedGroup(group); } if (debugScanState) { debugScanState.restoredGroups += groups.length + inlineConvertedSpans.length; } } finally { isModifying = false; } } function recoverNodeForProcessing(node) { if (!node) return node; if (node.nodeType === 3) { const parent = node.parentElement; const group = parent && parent.closest ? parent.closest('.' + CONVERTED_GROUP_CLASS) : null; return group ? unwrapConvertedGroup(group) : node; } if (node.nodeType === 1) { const group = node.closest ? node.closest('.' + CONVERTED_GROUP_CLASS) : null; if (group) { const recoveredTextNode = unwrapConvertedGroup(group); return recoveredTextNode || group.parentNode; } cleanupConvertedGroupsInSubtree(node); } return node; } function walkDOM(root) { if (!root) return; const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { if (skip(node)) return NodeFilter.FILTER_REJECT; if (!node.nodeValue || node.nodeValue.trim().length === 0) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } } ); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); debugLog('walkDOM text nodes:', nodes.length); for (const n of nodes) { try { processTextNode(n); } catch (e) { console.error('[CC] processTextNode error:', e); } } processCombinedTextElements(root); } function hasConvertedDescendant(el) { if (!el || !el.querySelector) return false; return !!el.querySelector('.' + CONVERTED_CLASS + ', .' + CONVERTED_GROUP_CLASS); } function isVisibleElement(el) { if (!el || !el.isConnected) return false; if (el.hidden) return false; const style = window.getComputedStyle(el); if (!style || style.display === 'none' || style.visibility === 'hidden') return false; return el.getClientRects().length > 0; } function getRenderedText(el) { if (!el) return ''; return (el.innerText || '').replace(/\s+/g, ' ').trim(); } function normalizeRenderedTextForMatching(text) { if (!text) return ''; return text.replace(/(\d)\s*([.,])\s*(\d)/g, '$1$2$3'); } function getRenderedMatchText(el) { return normalizeRenderedTextForMatching(getRenderedText(el)); } function hasStructuredInlinePriceDescendants(el, maxDescendants = 8) { if (!el || !el.querySelectorAll || !el.children || el.children.length === 0) return false; const descendants = Array.from(el.querySelectorAll('*')); if (descendants.length === 0 || descendants.length > maxDescendants) return false; const inlineTags = new Set(['SPAN', 'SUP', 'SUB', 'SMALL', 'STRONG', 'B', 'EM', 'I']); let hasSplitHint = false; for (const node of descendants) { if (!inlineTags.has(node.tagName)) return false; const className = typeof node.className === 'string' ? node.className.toLowerCase() : ''; if (className.includes('fraction') || className.includes('decimal') || className.includes('whole')) { hasSplitHint = true; } if ((node.tagName === 'SUP' || node.tagName === 'SUB' || node.tagName === 'SMALL') && /\d/.test(node.textContent || '')) { hasSplitHint = true; } } return hasSplitHint; } function findStructuredPriceAnchor(el) { if (!el || !el.querySelectorAll) return null; const selectors = [ '.a-price-fraction', '[class*="fraction"]', '[class*="Fraction"]', 'sup', 'sub', 'small', '.a-price-decimal', '[class*="decimal"]', '[class*="Decimal"]', '.a-price-whole', '[class*="whole"]', '[class*="Whole"]' ]; for (const selector of selectors) { const matches = Array.from(el.querySelectorAll(selector)).filter((node) => isVisibleElement(node)); if (matches.length > 0) { return matches[matches.length - 1]; } } const descendants = Array.from(el.querySelectorAll('*')).filter((node) => isVisibleElement(node) && (node.textContent || '').trim().length > 0); return descendants.length > 0 ? descendants[descendants.length - 1] : null; } function buildConvertedSpanNode(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 = formatPrice(cny); return span; } function tryAppendStructuredRenderedMatch(el, matchText, matches, source = 'rendered-structured') { if (!el || hasConvertedDescendant(el)) return false; if (getConfig().mode !== 'append') return false; if (!hasStructuredInlinePriceDescendants(el, 8)) return false; if (!matches || matches.length !== 1) return false; const match = matches[0]; if (match.index !== 0 || match.length !== matchText.length) return false; const anchor = findStructuredPriceAnchor(el); if (!anchor || !anchor.parentNode) return false; const cny = convertCurrency(match.amount, match.code); if (cny === null) return false; const span = buildConvertedSpanNode(cny); span.setAttribute('data-cc-inline-append', '1'); isModifying = true; try { anchor.parentNode.insertBefore(span, anchor.nextSibling); if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(matchText), mode: 'append', source }); } return true; } finally { isModifying = false; } } function hasSmallerMatchingChild(el) { if (!el || !el.children || el.children.length === 0) return false; for (const child of el.children) { if (!isVisibleElement(child)) continue; if (skip(child)) continue; const text = getRenderedMatchText(child); if (!text || text.length < 2 || text.length > 120) continue; if (!quickCheck(text)) continue; if (findAllMatches(text).length > 0) return true; } return false; } function rewriteTextOnlyElement(el, sourceText, matches, source) { if (!el || !el.childNodes) return false; const textNodes = Array.from(el.childNodes).filter((node) => node.nodeType === 3); if (textNodes.length === 0) return false; const firstTextNode = textNodes[0]; const remainingTextNodes = textNodes.slice(1); const config = getConfig(); if (config.mode === 'replace') { const newText = buildReplaceText(sourceText, matches); if (newText === null) return false; firstTextNode.nodeValue = newText; for (const node of remainingTextNodes) { if (node.parentNode) node.parentNode.removeChild(node); } if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(sourceText), to: previewText(newText), source }); } return true; } const fragment = buildAppendFragment(sourceText, matches); if (!fragment || !firstTextNode.parentNode) return false; firstTextNode.parentNode.replaceChild(fragment, firstTextNode); for (const node of remainingTextNodes) { if (node.parentNode) node.parentNode.removeChild(node); } if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(sourceText), mode: 'append', source }); } return true; } function isSafeFallbackLeafElement(el, maxLen = 120) { if (!el || skip(el)) return false; if (!FALLBACK_CONTAINER_TAGS.has(el.tagName)) return false; if (hasConvertedDescendant(el)) return false; if (el.children && el.children.length > 0) return false; if (hasRiskyInteractiveDescendant(el)) return false; const text = el.textContent || ''; if (!text || text.length < 2 || text.length > maxLen) return false; return true; } function isSafeRenderedCandidate(el, maxLen = 120) { if (!el || skip(el)) return false; if (!FALLBACK_CONTAINER_TAGS.has(el.tagName)) return false; if (hasConvertedDescendant(el)) return false; if (hasRiskyInteractiveDescendant(el)) return false; const text = el.textContent || ''; if (!text || text.length < 2 || text.length > maxLen) return false; if (isSafeDisplayTooltip(el, maxLen)) return true; const children = el.children ? Array.from(el.children) : []; if (children.length === 0) return true; if (children.length > 3) return false; for (const child of children) { if (!FALLBACK_CONTAINER_TAGS.has(child.tagName)) return false; if (hasRiskyInteractiveDescendant(child)) return false; } return true; } function processRenderedTextElements(root) { if (!root) return; const candidates = []; const walker = document.createTreeWalker( root, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { if (skip(node)) return NodeFilter.FILTER_REJECT; if (!isVisibleElement(node)) return NodeFilter.FILTER_SKIP; if (!isSafeRenderedCandidate(node, 120)) return NodeFilter.FILTER_SKIP; const text = getRenderedMatchText(node); if (!text || text.length < 2 || text.length > 120) return NodeFilter.FILTER_SKIP; if (!quickCheck(text)) return NodeFilter.FILTER_SKIP; if (findAllMatches(text).length === 0) return NodeFilter.FILTER_SKIP; if (hasSmallerMatchingChild(node)) return NodeFilter.FILTER_SKIP; return NodeFilter.FILTER_ACCEPT; } } ); while (walker.nextNode()) { candidates.push(walker.currentNode); } debugLog('rendered element candidates:', candidates.length); for (const el of candidates) { try { processRenderedTextElement(el); } catch (e) { console.error('[CC] processRenderedTextElement error:', e); } } } function processRenderedTextElement(el) { if (!el || !isVisibleElement(el) || !isSafeRenderedCandidate(el, 120)) return; const rawText = getRenderedText(el); if (!rawText || rawText.length < 2 || rawText.length > 120) return; const text = getRenderedMatchText(el); if (!text || text.length < 2 || text.length > 120) return; if (!quickCheck(text)) return; const matches = findAllMatches(text); if (matches.length === 0) return; const hasStructuredSplitMatch = rawText !== text && hasStructuredInlinePriceDescendants(el, 8); if (hasStructuredSplitMatch) { if (tryAppendStructuredRenderedMatch(el, text, matches, 'rendered-structured')) { return; } return; } if (debugScanState) { pushDebugSample('renderedElements', { text: previewText(text), tag: el.tagName, matches: matches.map((m) => m.fullMatch) }, 20); debugScanState.matched += 1; pushDebugSample('matched', { text: previewText(text), tag: el.tagName, matches: matches.map((m) => m.fullMatch), source: 'rendered-element' }); } const config = getConfig(); const canRewriteTextOnly = !isSafeDisplayTooltip(el, 120) && (!el.children || el.children.length === 0); isModifying = true; try { if (canRewriteTextOnly && rewriteTextOnlyElement(el, text, matches, 'rendered-element-text-only')) { return; } if (config.mode === 'replace') { const newText = buildReplaceText(text, matches); if (newText !== null) { el.textContent = newText; if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(text), to: previewText(newText), source: 'rendered-element' }); } } return; } const fragment = buildAppendFragment(text, matches); if (fragment) { el.replaceChildren(fragment); if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(text), mode: 'append', source: 'rendered-element' }); } } } finally { isModifying = false; } } function tryProcessContainerFallback(el, source = 'parent-fallback') { if (!isSafeFallbackLeafElement(el, 80)) return false; const text = el.textContent || ''; if (!text || text.length < 2 || text.length > 80) return false; if (!quickCheck(text)) return false; const matches = findAllMatches(text); if (matches.length === 0) return false; if (debugScanState) { if (source === 'parent-fallback') { pushDebugSample('parentFallbacks', { text: previewText(text), tag: el.tagName, matches: matches.map((m) => m.fullMatch) }, 20); } else { pushDebugSample('ancestorFallbacks', { text: previewText(text), tag: el.tagName, matches: matches.map((m) => m.fullMatch) }, 20); } debugScanState.matched += 1; pushDebugSample('matched', { text: previewText(text), tag: el.tagName, matches: matches.map((m) => m.fullMatch), source }); } const config = getConfig(); isModifying = true; try { if (rewriteTextOnlyElement(el, text, matches, source + '-text-only')) { return true; } if (config.mode === 'replace') { const newText = buildReplaceText(text, matches); if (newText !== null) { el.textContent = newText; if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(text), to: previewText(newText), source }); } return true; } return false; } const fragment = buildAppendFragment(text, matches); if (fragment) { el.replaceChildren(fragment); if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(text), mode: 'append', source }); } return true; } } finally { isModifying = false; } return false; } function tryProcessAncestorFallback(startEl) { let current = startEl; let depth = 0; while (current && depth < 4) { if (tryProcessContainerFallback(current, depth === 0 ? 'parent-fallback' : 'ancestor-fallback')) { return true; } current = current.parentElement; depth += 1; } return false; } function processCombinedTextElements(root) { if (!root) return; const candidates = []; const walker = document.createTreeWalker( root, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { if (skip(node)) return NodeFilter.FILTER_REJECT; if (!node.childNodes || node.childNodes.length < 2) return NodeFilter.FILTER_REJECT; if (node.children && node.children.length > 0) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } } ); while (walker.nextNode()) { candidates.push(walker.currentNode); } debugLog('combined element candidates:', candidates.length); for (const el of candidates) { try { processCombinedTextElement(el); } catch (e) { console.error('[CC] processCombinedTextElement error:', e); } } } function processCombinedTextElement(el) { if (!el || skip(el)) return; if (hasConvertedDescendant(el)) return; const textNodes = Array.from(el.childNodes).filter((node) => node.nodeType === 3 && node.nodeValue && node.nodeValue.trim().length > 0); if (textNodes.length < 2) return; const combinedText = textNodes.map((node) => node.nodeValue).join(''); if (!combinedText || !quickCheck(combinedText)) return; const matches = findAllMatches(combinedText); if (matches.length === 0) return; if (debugScanState) { pushDebugSample('combinedElements', { text: previewText(combinedText), tag: el.tagName, childTextCount: textNodes.length, matches: matches.map((m) => m.fullMatch) }, 20); debugScanState.matched += 1; pushDebugSample('matched', { text: previewText(combinedText), matches: matches.map((m) => m.fullMatch), source: 'combined-element' }); } const config = getConfig(); isModifying = true; try { if (rewriteTextOnlyElement(el, combinedText, matches, 'combined-element-text-only')) { return; } if (config.mode === 'replace') { const newText = buildReplaceText(combinedText, matches); if (newText !== null) { el.textContent = newText; if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(combinedText), to: previewText(newText), source: 'combined-element' }); } } return; } const fragment = buildAppendFragment(combinedText, matches); if (fragment) { el.replaceChildren(fragment); if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(combinedText), mode: 'append', source: 'combined-element' }); } } } finally { isModifying = false; } } function buildAppendFragment(text, matches) { const fragment = document.createDocumentFragment(); let cursor = 0; let changed = false; for (const m of matches) { const cny = convertCurrency(m.amount, m.code); if (cny === null) continue; changed = true; const end = m.index + m.length; if (m.index > cursor) { fragment.appendChild(document.createTextNode(text.slice(cursor, m.index))); } const group = document.createElement('span'); group.className = CONVERTED_GROUP_CLASS; group.setAttribute(PROCESSED_ATTR, '1'); group.style.cssText = 'display:inline;'; group.appendChild(document.createTextNode(text.slice(m.index, end))); 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 = formatPrice(cny); group.appendChild(span); fragment.appendChild(group); cursor = end; } if (!changed) return null; if (cursor < text.length) { fragment.appendChild(document.createTextNode(text.slice(cursor))); } return fragment; } function buildReplaceText(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; } return newText === text ? null : newText; } function processTextNode(node) { if (!node || node.nodeType !== 3) return; const text = node.nodeValue; if (debugScanState && text && /[$€£₩]|\b(?:USD|EUR|GBP|JPY|HKD|KRW|AUD|CAD|TWD|SGD|CHF)\b/i.test(text)) { pushDebugSample('currencyCandidates', { text: previewText(text), parent: node.parentElement ? node.parentElement.tagName + (node.parentElement.className ? '.' + String(node.parentElement.className).trim().replace(/\s+/g, '.') : '') : 'NO_PARENT' }, 20); } if (debugScanState) { debugScanState.visited += 1; } if (skip(node)) { if (debugScanState) { debugScanState.skipped += 1; pushDebugSample('skipped', { text: previewText(text || ''), reason: 'skip(node)', parent: node.parentElement ? node.parentElement.tagName : 'NO_PARENT' }); } return; } if (!text || text.length < 2) return; if (!quickCheck(text)) { if (debugScanState) { debugScanState.quickRejected += 1; pushDebugSample('quickRejected', { text: previewText(text), reason: 'quickCheck=false' }); } return; } const matches = findAllMatches(text); if (matches.length === 0) { if (/[$€£₩]|\b(?:USD|EUR|GBP|JPY|HKD|KRW|AUD|CAD|TWD|SGD|CHF)\b/i.test(text)) { if (tryProcessAncestorFallback(node.parentElement)) { return; } } if (debugScanState) { pushDebugSample('quickRejected', { text: previewText(text), reason: 'findAllMatches=0' }); } return; } if (debugScanState) { debugScanState.matched += 1; pushDebugSample('matched', { text: previewText(text), matches: matches.map((m) => m.fullMatch) }); } debugLog('TEXT TARGET:', JSON.stringify(text.substring(0, 100)), 'matches:', matches.length); const config = getConfig(); isModifying = true; try { if (config.mode === 'replace') { const newText = buildReplaceText(text, matches); if (newText !== null) { node.nodeValue = newText; if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(text), to: previewText(newText) }); } } return; } const fragment = buildAppendFragment(text, matches); if (fragment && node.parentNode) { node.parentNode.replaceChild(fragment, node); if (debugScanState) { debugScanState.converted += 1; pushDebugSample('converted', { from: previewText(text), mode: 'append' }); } } } finally { isModifying = false; } } function processPage(reason = 'manual') { if (!isScannerAwake) { pendingProcessReason = reason; return; } if (isProcessing) { pendingProcessReason = reason; return; } const config = getConfig(); debugLog('processPage - enabled:', config.enabled, 'rates:', !!rateCache.data, 'reason:', reason); if (!config.enabled) { debugLog('processPage skipped - enabled:', config.enabled, 'rates:', !!rateCache.data, 'reason:', reason); return; } if (!rateCache.data) { pendingProcessReason = reason; debugLog('processPage waiting for rates, reason:', reason); if (!wakePromise) { void activateScanner(reason, { scan: false, force: true }); } return; } isProcessing = true; lastProcessAt = Date.now(); startDebugScan(reason); try { restoreConvertedContent(document.body); processRenderedTextElements(document.body); walkDOM(document.body); } finally { isProcessing = false; endDebugScan(); if (pendingProcessReason) { const retryReason = pendingProcessReason; pendingProcessReason = null; scheduleProcessPage(250, retryReason); } } } function scheduleProcessPage(delay = 300, reason = 'scheduled') { if (!isScannerAwake || (wakePromise && !rateCache.data)) { pendingProcessReason = reason; return; } const now = Date.now(); let actualDelay = delay; if (reason.startsWith('mutation-')) { const waitForCooldown = Math.max(0, MIN_MUTATION_SCAN_INTERVAL - (now - lastProcessAt)); actualDelay = Math.max(actualDelay, waitForCooldown); } const targetAt = now + actualDelay; if (processPageTimer && targetAt >= scheduledProcessAt) { return; } clearTimeout(processPageTimer); scheduledProcessAt = targetAt; processPageTimer = setTimeout(() => { processPageTimer = null; scheduledProcessAt = 0; processPage(reason); }, actualDelay); } function scheduleInteractionRescans() { for (const timer of interactionRescanTimers) { clearTimeout(timer); } interactionRescanTimers = []; const delays = [120, 400, 900, 1600]; for (const delay of delays) { const timer = setTimeout(() => { scheduleProcessPage(0, `interaction-${delay}ms`); }, delay); interactionRescanTimers.push(timer); } } 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 = `
版本: ${SCRIPT_VERSION} | 当前网站: ${currentHost}
显示模式
网站过滤模式
白名单(每行一个域名,支持 * 通配符):
黑名单(每行一个域名,支持 * 通配符):
汇率信息
${rateCache.data ? '✅ 汇率已加载' : '❌ 汇率未加载'}
更新时间: ${timeInfo}
${ratesInfo}