// ==UserScript== // @name dmm 换算人民币 // @namespace http://94cat.com/ // @version 1.2 // @description 精确识别 FANZA 商品价格,将日元换算为人民币,保留折扣和原价样式 // @author Melody // @match https://video.dmm.co.jp/* // @match https://www.dmm.co.jp/digital/* // @match https://www.dmm.co.jp/search/* // @match https://cashier.dmm.co.jp/choice/* // @run-at document-idle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect open.er-api.com // @license MIT // ==/UserScript== (function () { 'use strict'; const API_URL = 'https://open.er-api.com/v6/latest/JPY'; const CACHE_KEY = 'dmm_cny_rate_v1'; const RETRY_KEY = 'dmm_cny_rate_retry_after'; const DAY = 24 * 60 * 60 * 1000; const MAX_CACHE_AGE = 7 * DAY; const IS_VIDEO = location.hostname === 'video.dmm.co.jp'; // 列表有稳定的语义标记;首页目前没有,使用实际价格样式并校验商品卡片。 // 不把通用红色文字、折扣、标题或所有含“円”的文字当作价格。 const DETAIL_OPTION_SELECTOR = 'label .font-bold.text-right'; const DETAIL_SPLIT_SELECTOR = 'span.text-red-900.items-baseline'; const VIDEO_SELECTORS = [ '[data-e2eid="content-price"]', 'div.font-bold.text-red-900', DETAIL_OPTION_SELECTOR, DETAIL_SPLIT_SELECTOR ].join(','); const LEGACY_SELECTORS = [ '.price', '.tx-price', '.tx-lt', '#side-rank-tab .red', '.package-item_plice', '#tr_bmm_price', '.choice-totalAmount__txt' ].join(','); const SELECTORS = IS_VIDEO ? VIDEO_SELECTORS : LEGACY_SELECTORS; const EXCLUDED = [ 'script', 'style', 'noscript', 'template', 'textarea', 'input', 'select', 'option', '[contenteditable]:not([contenteditable="false"])', '.salecount', '.tx-sale', '.tx-discount' ].join(','); const NUMBER = '(?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)(?:\\.[0-9]{1,2})?'; // 一组相邻文本节点必须是一个金额;拒绝“300円以下”、销量、日期、百分比等。 const PRICE_PATTERN = new RegExp( '^(\\s*(?:(?:通常価格|販売価格|税込価格|価格|合計)\\s*[::]\\s*)?)' + '(?:[¥¥]\\s*(' + NUMBER + ')(?:\\s*円)?|(' + NUMBER + ')\\s*円)' + '(\\s*(?:(?:[〜~~]|から|より|税込|税抜|[((](?:税込|税抜)[))])\\s*)*)$' ); // 保存每个文本节点的原始日元和输出,支持 React 复用节点、价格更新和汇率刷新。 const converted = new WeakMap(); const originalTitles = new WeakMap(); let quote = readCache(); let timer = null; let observer; function parsePrice(text) { const normalized = text .replace(/[0-9]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0)) .replace(/,/g, ',').replace(/./g, '.'); const match = PRICE_PATTERN.exec(normalized); if (!match) return null; const amount = Number((match[2] || match[3]).replace(/,/g, '')); if (!Number.isFinite(amount) || amount < 0) return null; return { amount, prefix: match[1], suffix: match[4] }; } function isPositiveRate(rate) { return typeof rate === 'number' && Number.isFinite(rate) && rate > 0; } function readCache() { const cached = GM_getValue(CACHE_KEY, null); if (!cached || cached.base !== 'JPY' || !isPositiveRate(cached.rate) || !Number.isFinite(cached.fetchedAt) || !Number.isFinite(cached.updatedAt)) return null; const age = Date.now() - cached.fetchedAt; return age >= 0 && age <= MAX_CACHE_AGE ? cached : null; } function isProductLink(link) { try { const url = new URL(link.getAttribute('href'), location.href); return url.hostname === 'video.dmm.co.jp' && /^\/[^/]+\/content\/$/.test(url.pathname); } catch (_) { return false; } } function isPriceContainer(element) { if (element.closest(EXCLUDED)) return false; if (!IS_VIDEO) return true; if (element.getAttribute('data-e2eid') === 'content-price') return true; if (/^\/[^/]+\/content\/$/.test(location.pathname)) { if (element.matches(DETAIL_OPTION_SELECTOR)) { const label = element.closest('label'); return !!label.querySelector('input[type="radio"]') && hasPurchaseContext(element); } if (element.matches(DETAIL_SPLIT_SELECTOR)) return hasPurchaseContext(element); } // 购买区候选不能走商品卡片的备用规则。 if (!element.matches('div.font-bold.text-red-900')) return false; // 首页价格必须位于附近有商品详情链接的卡片里;不能仅凭红色+粗体判断。 let card = element; for (let depth = 0; card && depth <= 4; depth++, card = card.parentElement) { if (card === document.body || card.matches('main, section, ul, nav, aside')) break; const links = card.querySelectorAll('a[href*="/content/"]'); if (Array.from(links).some(isProductLink)) return true; } return false; } function hasPurchaseContext(element) { let panel = element; for (let depth = 0; panel && depth <= 7; depth++, panel = panel.parentElement) { if (panel === document.body || panel.matches('main, nav')) break; if (panel.querySelector('[data-e2eid="purchase-button"]')) return true; } return false; } function originalText(nodes) { return nodes.map(node => { const previous = converted.get(node); return previous && node.data === previous.output ? previous.source : node.data; }); } function writeText(nodes, sources, output) { nodes.forEach((node, index) => { const part = index === 0 ? output : ''; converted.set(node, { source: sources[index], output: part }); if (node.data !== part) node.data = part; }); } function convertGroup(nodes) { if (!nodes.length || nodes[0].parentElement.closest(EXCLUDED)) return; const sources = originalText(nodes); const source = sources.join(''); const price = parsePrice(source); if (!price) return; const value = price.amount * quote.rate; // API 基准是 JPY:1 日元对应的人民币。 if (!Number.isFinite(value)) return; const output = price.prefix + value.toFixed(2) + '元' + price.suffix; // React 会把“1,386”和“円”拆成相邻节点(有时中间有注释)。 // 把显示文本放在第一个节点,其余节点置空但保留身份和原文记录。 // 后续只更新数字节点时,仍可用原单位重新识别完整日元金额。 writeText(nodes, sources, output); setPriceTitle(nodes[0].parentElement, source); } function convertSplitPrice(element) { // 优惠券金额的数字与单位使用不同字号的两个 span;仅接受这个明确结构。 const children = Array.from(element.children); if (children.length !== 2 || children.some(child => child.tagName !== 'SPAN' || child.childElementCount !== 0 || child.closest(EXCLUDED))) return; const amountNodes = Array.from(children[0].childNodes).filter(node => node.nodeType === 3); const unitNodes = Array.from(children[1].childNodes).filter(node => node.nodeType === 3); if (!amountNodes.length || !unitNodes.length) return; const amountSources = originalText(amountNodes); const unitSources = originalText(unitNodes); const unitSource = unitSources.join(''); if (unitSource.trim() !== '円') return; const source = amountSources.join('') + unitSource; const price = parsePrice(source); if (!price) return; const value = price.amount * quote.rate; if (!Number.isFinite(value)) return; writeText(amountNodes, amountSources, price.prefix + value.toFixed(2)); writeText(unitNodes, unitSources, unitSource.replace('円', '元')); setPriceTitle(element, source); } function setPriceTitle(parent, source) { if (!originalTitles.has(parent)) originalTitles.set(parent, parent.getAttribute('title') || ''); const originalTitle = originalTitles.get(parent); const cached = Date.now() - quote.fetchedAt >= DAY ? '(缓存汇率)' : ''; const date = new Date(quote.updatedAt).toISOString().slice(0, 10); parent.title = (originalTitle ? originalTitle + '\n' : '') + '原价:' + source.trim() + '\n1 JPY ≈ ' + quote.rate + ' CNY' + cached + '\n汇率日期:' + date + ';来源:ExchangeRate-API;仅供参考'; } function showAttribution() { // 此免费接口要求显示来源链接:https://www.exchangerate-api.com/docs/free let link = document.getElementById('dmm-cny-rate-source'); if (!link) { link = document.createElement('a'); link.id = 'dmm-cny-rate-source'; link.href = 'https://www.exchangerate-api.com'; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.style.cssText = 'position:fixed;right:8px;bottom:4px;font:10px/1.4 sans-serif;' + 'color:#666;background:rgba(255,255,255,.9);padding:2px 4px;z-index:10;'; document.body.appendChild(link); } const stale = Date.now() - quote.fetchedAt >= DAY; link.textContent = '汇率:ExchangeRate-API' + (stale ? '(缓存)' : ''); link.title = 'Rates By Exchange Rate API;1 JPY ≈ ' + quote.rate + ' CNY;仅供参考'; } function scan() { timer = null; if (!quote || !document.body) return; observer.disconnect(); try { const elements = new Set(); const splitPrices = new Set(); for (const container of document.querySelectorAll(SELECTORS)) { if (!isPriceContainer(container)) continue; if (IS_VIDEO && container.matches(DETAIL_SPLIT_SELECTOR)) { splitPrices.add(container); continue; } elements.add(container); for (const child of container.querySelectorAll('*')) elements.add(child); } for (const element of elements) { if (element.closest(EXCLUDED)) continue; let group = []; for (const child of element.childNodes) { if (child.nodeType === 3) group.push(child); else if (child.nodeType !== 8) { // 跨元素不拼接,避免把划线原价和折后价串成一个数字。 convertGroup(group); group = []; } } convertGroup(group); } for (const element of splitPrices) convertSplitPrice(element); showAttribution(); } finally { // 新站没有 #mu;body 同时覆盖旧站、结算页和动态加载列表。 observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'data-e2eid'] }); } } function scheduleScan() { // 页面连续更新时合并扫描;自身写入时暂停 observer,避免无限循环。 if (timer === null) timer = setTimeout(scan, 150); } function refreshRate() { if (quote && Date.now() - quote.fetchedAt < DAY) return; const retryAfter = GM_getValue(RETRY_KEY, 0); if (Number.isFinite(retryAfter) && Date.now() < retryAfter) return; function failed(reason) { GM_setValue(RETRY_KEY, Date.now() + 60 * 60 * 1000); console.warn('[DMM 人民币] 汇率获取失败,' + (quote ? '暂用最近缓存。' : '保留日元原价。'), reason); } try { GM_xmlhttpRequest({ method: 'GET', url: API_URL, responseType: 'json', timeout: 12000, onload(response) { try { if (response.status !== 200) throw new Error('HTTP ' + response.status); const data = response.response && typeof response.response === 'object' ? response.response : JSON.parse(response.responseText); if (data.result !== 'success' || data.base_code !== 'JPY' || !data.rates || !isPositiveRate(data.rates.CNY) || !Number.isFinite(data.time_last_update_unix) || data.time_last_update_unix <= 0) { throw new Error('无效的 JPY/CNY 汇率响应'); } quote = { base: 'JPY', rate: data.rates.CNY, fetchedAt: Date.now(), updatedAt: data.time_last_update_unix * 1000 }; GM_setValue(CACHE_KEY, quote); GM_setValue(RETRY_KEY, 0); scheduleScan(); } catch (error) { failed(error.message); } }, onerror() { failed('网络错误'); }, ontimeout() { failed('请求超时'); }, onabort() { failed('请求中止'); } }); } catch (error) { failed(error.message); } } function start() { observer = new MutationObserver(scheduleScan); // 即使暂时没有汇率,也监听页面;首次获取成功后会开始转换。 observer.observe(document.body, { childList: true, subtree: true, characterData: true }); scan(); refreshRate(); } if (document.body) start(); else document.addEventListener('DOMContentLoaded', start, { once: true }); })();