// ==UserScript== // @name SGLV Currency Library (SGLV-Suite) // @name:en SGLV Currency Library (SGLV-Suite) // @namespace https://greasyfork.org/scripts/588485 // @version 1.1.1 // @description SGLV 货币函数库(v1.1.1):跨脚本复用的货币识别/解析/折算/汇总工具集。39 币种注册表(ISO id + steamId 原始 Steam 码)、normalizeCode 别名归一(MexP→MXN / COL→COP)、symbolOf 符号展示、detectFromText 文本币种识别(¥ 歧义 CNY/JPY 消解)、parseAmountText 千分位/小数逗号解析、format 格式化、convert USD 枢纽折算、aggregate 单遍 O(n) 整数分累加汇总(按币种分桶降级)、sgisRatesToUsdRates/cnyBaseRatesToUsdRates 汇率语义适配器。v1.1.1 修复:JP¥ 前缀无小数误判 CNY、US$/USD 前缀与 ₽ 前缀识别缺口、注册表条目只读化、aggregate unsupported 去重 O(n)、format 非有限值返回 —。零依赖纯函数库,供主脚本 @require 引用,不可单独使用。Greasy Fork 发布 id:591126。 // @description:en SGLV Currency Library v1.1.1 — Cross-script currency toolkit: 39-currency registry (ISO id + original Steam steamId), normalizeCode alias mapping (MexP→MXN / COL→COP), symbolOf display, detectFromText detection (¥ CNY/JPY disambiguation), parseAmountText thousands/decimal-comma parsing, format, convert USD-hub conversion, aggregate single-pass O(n) integer-cent summation with per-currency bucketing fallback, sgisRatesToUsdRates/cnyBaseRatesToUsdRates rate-semantics adapters. v1.1.1 fixes: JP¥ prefix without decimals misdetected as CNY, US$/USD prefix and ₽ prefix detection gaps, frozen registry entries, O(n) unsupported dedup, format non-finite → —. Zero-dependency pure functions. For host script @require only. Greasy Fork publish id: 591126. // @author hshow // @license MIT // @match *://*/* // @noframes // ==/UserScript== /* * SGLV Currency Library v1.1.1 * * Greasy Fork 发布 id:591126(主脚本 @require 固定 revision 引用) * * 设计目标:从 Steam-Spending-History-Classifier(分类器 2.1.26) 中提取"零业务依赖"的 * 货币识别 / 解析 / 折算 / 汇总逻辑,供所有 SGLV 系列脚本 * (steam-game-library-viewer / wishlist / badges / economy / family ...) * 通过 @require 共享,避免每个脚本各自维护一份货币表与解析函数。 * * API 暴露方式:window.SGLVCurrency (IIFE 直接挂全局,无依赖注入) * * 设计原则: * 1. 零依赖 — 不引用 GM_* / document / window 业务对象;Node 测试环境可直接加载 * 2. 纯函数 — 全部显式传参,禁止模块级可变全局状态(注册表为只读常量) * 3. 归一优先 — 对外统一使用 ISO 4217 码(MXN/COP);分类器原始 Steam 码 * (MexP/COL)保留在注册表条目的 steamId 字段,经 normalizeCode 归一 * 4. 符号展示 — symbolOf 输出主脚本(ISO 体系)符号;注册表 match 正则与 * steamSymbol 保留分类器原始形态用于文本识别 * 5. 渐进式破坏 — 输入非法一律返回 null / NaN,不抛异常 * * 提取源对照(Steam-Spending-History-Classifier-2.1.26.js): * L51-92 CURRENCIES / CURRENCY_MAP / CUR_BY_SYMBOL * L266 normText(\u00a0 → 空格归一化) * L350 resolveYen(金额含小数 → CNY,否则 → JPY) * L373-380 resolveCurrencyId 算法内核 * L423-431 parseNumber(千分位/小数逗号消歧) * L1044-1053 extractCurrency(原嵌于 RegionCD IIFE,连同 normText 移出,函数体保持原样) * L1466 CUR_API_MAP({ MexP:'mxn', COL:'cop' }) * L1496-1497 USD 枢纽折算公式 amount × (targetPerUsd / sourcePerUsd) * L582-592 单遍汇总 + 按币种分桶的降级行为 * * API: * version: '1.1.0' * apiVersion: 1 * CURRENCIES: 39 币种注册表(只读数组) { id, steamId, label, match, symbol, steamSymbol, dc, decimals } * get(id): 归一后按 id 查注册表条目,未知返回 null * bySymbol(sym): 按分类器原始符号查注册表(即 CUR_BY_SYMBOL),未命中返回 null * normalizeCode(code): trim + 大写 + 别名表归一(MEXP→MXN / COL→COP),未知码原样大写返回 * apiCode(id): 汇率 API 用的小写码(原 CUR_API_MAP 语义,MexP→mxn / COL→cop) * symbolOf(code): 归一后查注册表 symbol(ISO 展示值),fallback '¥' * detectFromText(text): 文本币种识别,返回归一 ISO id 或 null * parseNumber(text, decimalComma): 分类器原函数(返回 0 当无数字) * parseAmountText(text, code): 按币种 dc 标志解析金额,失败返回 NaN * format(value, code): symbolOf + toFixed(decimals),负数符号前置 * convert(amount, from, to, usdRates): USD 枢纽折算,rates 缺项返回 NaN * **v1.1.0 NEW** sgisRatesToUsdRates(rC): SGIS 风格汇率表(「1 外币 = X CNY」)→ USD 枢纽表,无效返回 null * **v1.1.0 NEW** cnyBaseRatesToUsdRates(rB): app-detail 风格汇率表(「1 CNY = X 外币」)→ USD 枢纽表,无效返回 null * aggregate(items, targetCode, usdRates, options): 单遍 O(n) 整数分累加汇总, * 返回 { total, byCurrency, unsupported } */ (function (root) { 'use strict'; // ==================== 1. 货币注册表 ==================== // 提取自分类器 2.1.26 L51-92。id 统一为 ISO 码;steamId 保留原始 Steam 码; // match 保留分类器原始形态用于文本识别;symbol 采用主脚本(ISO 体系)展示值, // steamSymbol 保留分类器原始符号供 bySymbol / extractCurrency 匹配。 // dc = decimal comma(小数逗号货币);decimals = format 小数位(零小数币种为 0)。 const CURRENCIES = Object.freeze([ { id: 'HKD', steamId: 'HKD', label: '港币', match: /HK\$\s*[\d,. ]+/, symbol: 'HK$', steamSymbol: 'HK$', decimals: 2 }, { id: 'TWD', steamId: 'TWD', label: '新台币', match: /NT\$\s*[\d,. ]+/, symbol: 'NT$', steamSymbol: 'NT$', decimals: 2 }, { id: 'AUD', steamId: 'AUD', label: '澳元', match: /A\$\s*[\d,. ]+/, symbol: 'A$', steamSymbol: 'A$', decimals: 2 }, { id: 'CAD', steamId: 'CAD', label: '加元', match: /CDN\$\s*[\d,. ]+/, symbol: 'C$', steamSymbol: 'CDN$', decimals: 2 }, { id: 'NZD', steamId: 'NZD', label: '新西兰元', match: /NZ\$\s*[\d,. ]+/, symbol: 'NZ$', steamSymbol: 'NZ$', decimals: 2 }, { id: 'ARS', steamId: 'ARS', label: '阿根廷比索', match: /ARS\$\s*[\d,. ]+/, symbol: 'ARS$', steamSymbol: 'ARS$', dc: true, decimals: 2 }, { id: 'SGD', steamId: 'SGD', label: '新加坡元', match: /(? Object.freeze(c)); const CURRENCY_MAP = new Map(CURRENCIES.map(c => [c.id, c])); const CUR_BY_SYMBOL = CURRENCIES.reduce((m, c) => { if (!m.has(c.steamSymbol)) m.set(c.steamSymbol, c); return m; }, new Map()); // ==================== 2. 代码归一化 ==================== // 别名表(大写键):分类器原始 Steam 码 → ISO 码。 // 提取自分类器 L1466 CUR_API_MAP { MexP:'mxn', COL:'cop' }。 const ALIASES = { MEXP: 'MXN', COL: 'COP' }; function normalizeCode(code) { if (code === null || code === undefined) return ''; const up = String(code).trim().toUpperCase(); if (!up) return ''; return ALIASES[up] || up; } // 汇率 API 用的小写码。归一后 MXN/COP 的 toLowerCase() 天然等于 'mxn'/'cop', // 与分类器 CUR_API_MAP[rawId] 语义一致(MexP→mxn / COL→cop)。 function apiCode(id) { const n = normalizeCode(id); return n ? n.toLowerCase() : ''; } function get(id) { return CURRENCY_MAP.get(normalizeCode(id)) || null; } // 按分类器原始符号查表(extractCurrency 提取出的符号串是分类器形态,如 CDN$/TL/S/.) function bySymbol(sym) { if (!sym) return null; return CUR_BY_SYMBOL.get(sym) || null; } // symbolOf:归一后查注册表 symbol(ISO 展示值),fallback '¥' // 与主脚本 sglvCurrencySymbol(v2.9.85+)未知码回退 '¥' 行为一致 function symbolOf(code) { const cur = get(code); return cur ? cur.symbol : '¥'; } // ==================== 3. 文本工具 ==================== // 提取自分类器 L266 const normText = text => (text || '').replace(/\u00a0/g, ' ').trim(); // 提取自分类器 L1044-1053(原嵌于 RegionCD IIFE,函数体保持原样) function extractCurrency(text) { for (const line of normText(text).split(/\r?\n/).map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean)) { if (!/\d/.test(line)) continue; const pre = line.match(/^[+\-]?\s*([^\d\s.,+\-]+(?:\s+[^\d\s.,+\-]+)?)\s*(?=\d)/); if (pre) return pre[1].trim(); const suf = line.match(/\d[\d\s.,]*\s*([^\d\s.,+\-]+(?:\s+[^\d\s.,+\-]+)?)\s*$/); if (suf) return suf[1].trim(); } return null; } // ¥ 歧义消解,提取自分类器 L350:金额含小数 → CNY,否则 → JPY const resolveYen = text => /\.\d+/.test(text) ? 'CNY' : 'JPY'; // ==================== 4. 币种识别 ==================== // detectFromText:normText → ¥ 歧义特判(分类器 detectPrimaryCurrency L365 语义) // → extractCurrency 提取符号串 → CUR_BY_SYMBOL 精确匹配 → 注册表 match 正则 // (resolveCurrencyId L373-380 算法内核)。返回归一 ISO id 或 null。 // v1.1.1 修复:¥ 特判只排除显式 "JPY" 后缀(不再排除 JP¥ 前缀)——JP¥ 前缀文本同样 // 走 resolveYen(含小数→CNY / 无小数→JPY),修复旧实现中 JP¥ 前缀无小数被注册表 // CNY 正则(先于 JPY 条目)抢先命中而误判为 CNY 的缺陷。 function detectFromText(text) { const t = normText(text); if (!t) return null; if (/¥/.test(t) && !/JPY/i.test(t)) return resolveYen(t); const sym = extractCurrency(t); if (sym) { const bySym = bySymbol(sym); if (bySym) return bySym.id; } for (const cur of CURRENCIES) if (cur.match.test(t)) return cur.id; return null; } // ==================== 5. 金额解析 ==================== // 提取自分类器 L423-431(千分位/小数逗号消歧),函数体保持原样。 // 注意:无数字时返回 0(分类器行为);parseAmountText 将其转译为 NaN。 function parseNumber(text, decimalComma = false) { const m = text.match(/[\d]+(?:[ ,.]\d+)*/); if (!m) return 0; let s = m[0].replace(/ /g, ''); const li = s.lastIndexOf('.'), ci = s.lastIndexOf(','); if (decimalComma) { s = ci >= 0 ? s.replace(/\./g, '').replace(',', '.') : s.replace(/\./g, ''); } else if (li > 0 && ci > 0) { s = li > ci ? s.replace(/,/g, '') : s.replace(/\./g, '').replace(',', '.'); } else { s = s.replace(/,/g, ''); } return parseFloat(s) || 0; } // parseAmountText:归一 code 后读注册表 dc 标志;未知 code 保守默认 false(与分类器一致) // 返回 number,解析失败(无数字 / 0 结果)返回 NaN function parseAmountText(text, code) { if (typeof text !== 'string') return NaN; const cur = get(code); const dc = cur ? !!cur.dc : false; const v = parseNumber(normText(text), dc); return v || NaN; } // ==================== 6. 格式化 ==================== // format:symbolOf + toFixed(注册表 decimals,零小数币种 JPY/KRW/VND/CLP 为 0); // 负数符号前置,与分类器 fmtAmt L269 一致 function format(value, code) { const cur = get(code); const sym = symbolOf(code); const decimals = cur ? (cur.decimals ?? 2) : 2; const v = Number(value); if (!Number.isFinite(v)) return '—'; // v1.1.1: 非有限值返回占位符,避免拼接出 "¥NaN" return v < 0 ? `-${sym}${Math.abs(v).toFixed(decimals)}` : `${sym}${v.toFixed(decimals)}`; } // ==================== 7. 汇率折算 ==================== // convert:USD 枢纽折算,公式 amount × (targetPerUsd / sourcePerUsd) // 提取自分类器 L1496-1497。usdRates 为「1 USD = X 币种」对象,键为小写 apiCode。 // from/to 经 normalizeCode→apiCode 归一;rates 缺项返回 NaN。 function convert(amount, from, to, usdRates) { if (!usdRates || typeof usdRates !== 'object') return NaN; const sourcePerUsd = usdRates[apiCode(from)]; const targetPerUsd = usdRates[apiCode(to)]; if (!sourcePerUsd || !targetPerUsd) return NaN; return amount * (targetPerUsd / sourcePerUsd); } // ---- v1.1.0 汇率语义适配器(只增不改,零风险,供未来调用) ---- // 背景:仓库内存在三种汇率表示语义,数据源已是单一事实来源,差异仅在表示格式: // ① SGIS(主脚本 refreshRates):「1 外币 = X CNY」,toCNY = amount * rC[code] // ② app-detail(SGLVAppDetail.getRates):「1 CNY = X 外币」,toCNY = amount / rB[code] // ③ 本 lib convert/aggregate:「1 USD = X 币种」(USD 枢纽,键小写 apiCode) // 两适配器将 ①/② 转为 ③,产物可直接喂给 convert/aggregate。 // sgisRatesToUsdRates:SGIS 风格 {CNY:1, USD:7.2, EUR:0.13, ...}(「1 外币 = X CNY」) // 换算公式:U[code] = rC['USD'] / rC[code] (推导:1 USD = rC['USD'] CNY;1 code = rC[code] CNY // → 1 USD = rC['USD']/rC[code] code)。CNY 项结果为 rC['USD'],USD 项结果为 1。 // 键统一小写(与 apiCode 语义一致);遍历防御非数字/非正数值;rC 无效或缺 USD/非正数 → null。 function sgisRatesToUsdRates(rC) { if (!rC || typeof rC !== 'object') return null; const usdInCny = Number(rC.USD ?? rC.usd); if (!Number.isFinite(usdInCny) || usdInCny <= 0) return null; const out = {}; for (const key of Object.keys(rC)) { const v = Number(rC[key]); if (!Number.isFinite(v) || v <= 0) continue; out[String(key).toLowerCase()] = usdInCny / v; } return out; } // cnyBaseRatesToUsdRates:app-detail 风格 {USD:7.2, EUR:0.936, ...}(「1 CNY = X 外币」) // 换算公式:U[code] = rB[code] / rB['USD'] (推导:1 CNY = rB[code] code;1 CNY = rB['USD'] USD // → 1 USD = rB[code]/rB['USD'] code)。CNY 自身项(若为 1)结果为 1/rB['USD']。 // 键统一小写;遍历防御非数字/非正数值;rB 无效或缺 USD/非正数 → null。 function cnyBaseRatesToUsdRates(rB) { if (!rB || typeof rB !== 'object') return null; const usdPerCny = Number(rB.USD ?? rB.usd); if (!Number.isFinite(usdPerCny) || usdPerCny <= 0) return null; const out = {}; for (const key of Object.keys(rB)) { const v = Number(rB[key]); if (!Number.isFinite(v) || v <= 0) continue; out[String(key).toLowerCase()] = v / usdPerCny; } return out; } // ==================== 8. 单遍汇总 ==================== // aggregate:单遍 O(n),内部 Math.round(x*100) 整数分累加消除浮点误差。 // 行为参照分类器 accumulateRow L582-592:主币种直接累加,其余币种无汇率时 // 降级进入 byCurrency 分桶(unsupported 列表记录)。 // item 形如 { amount, code },可用 options.amountKey/codeKey 自定义字段名。 // 返回 { total(折算成功的目标币金额,出口处 /100), byCurrency: {code: amount}, unsupported: [code...] } function aggregate(items, targetCode, usdRates, options) { const opts = options || {}; const amountKey = opts.amountKey || 'amount'; const codeKey = opts.codeKey || 'code'; const target = normalizeCode(targetCode); const rates = (usdRates && typeof usdRates === 'object') ? usdRates : {}; let totalCents = 0; const bucketCents = new Map(); const unsupportedSet = new Set(); // v1.1.1: Set 去重(原 includes O(n²)),保持插入序 if (Array.isArray(items)) { for (const it of items) { if (!it || typeof it !== 'object') continue; const amount = Number(it[amountKey]); if (!Number.isFinite(amount)) continue; const code = normalizeCode(it[codeKey]); if (!code) continue; if (code === target) { // 目标币种直接累加(无需汇率) totalCents += Math.round(amount * 100); continue; } const sourcePerUsd = rates[apiCode(code)]; const targetPerUsd = rates[apiCode(target)]; if (sourcePerUsd && targetPerUsd) { // USD 枢纽折算后累加 totalCents += Math.round(amount * (targetPerUsd / sourcePerUsd) * 100); } else { // 无汇率 → 降级分桶(分类器 amtByCur 行为) bucketCents.set(code, (bucketCents.get(code) || 0) + Math.round(amount * 100)); unsupportedSet.add(code); } } } const byCurrency = {}; for (const [code, cents] of bucketCents) byCurrency[code] = cents / 100; return { total: totalCents / 100, byCurrency: byCurrency, unsupported: [...unsupportedSet] }; } // ==================== 导出 ==================== root.SGLVCurrency = { version: '1.1.1', apiVersion: 1, CURRENCIES: CURRENCIES, get: get, bySymbol: bySymbol, normalizeCode: normalizeCode, apiCode: apiCode, symbolOf: symbolOf, detectFromText: detectFromText, parseNumber: parseNumber, parseAmountText: parseAmountText, format: format, convert: convert, sgisRatesToUsdRates: sgisRatesToUsdRates, cnyBaseRatesToUsdRates: cnyBaseRatesToUsdRates, aggregate: aggregate, }; })(typeof window !== 'undefined' ? window : globalThis);