// ==UserScript== // @name Steam 购物车增强 // @name:en Steam Cart Enhanced // @namespace https://github.com/steam-card-view // @version 1.1.1 // @description 外币价格看不懂、折扣到期易错过、跨区比价太麻烦:在购物车内直接换算人民币、提醒优惠倒计时、对比跨区价与 ITAD 史低,还支持暂存商品。 // @description:en Stop guessing foreign prices, missing sale deadlines, or comparing regions manually: get CNY estimates, discount countdowns, regional prices and ITAD lows right in your cart, plus a shared item shelf. // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsbGVkYnk9InRpdGxlIGRlc2MiPgogIDx0aXRsZSBpZD0idGl0bGUiPlN0ZWFtIOi0reeJqei9puWinuW8ujwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2MiPue0q+iJsua4kOWPmOiDjOaZr+S4iueahOeZveiJsui0reeJqei9puS4jumdkuiJsuaKmOaJo+agh+etvjwvZGVzYz4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iYmciIHgxPSI3IiB5MT0iNSIgeDI9IjU3IiB5Mj0iNjAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iIzdjM2FlZCIvPgogICAgICA8c3RvcCBvZmZzZXQ9Ii40OCIgc3RvcC1jb2xvcj0iIzRmNDZlNSIvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMwODkxYjIiLz4KICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICA8cmFkaWFsR3JhZGllbnQgaWQ9Imdsb3ciIGN4PSIwIiBjeT0iMCIgcj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJ0cmFuc2xhdGUoNDkgMTIpIHJvdGF0ZSgxMjgpIHNjYWxlKDQzKSI+CiAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiM2N2U4ZjkiIHN0b3Atb3BhY2l0eT0iLjcyIi8+CiAgICAgIDxzdG9wIG9mZnNldD0iLjU4IiBzdG9wLWNvbG9yPSIjODE4Y2Y4IiBzdG9wLW9wYWNpdHk9Ii4xMiIvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMTJlODEiIHN0b3Atb3BhY2l0eT0iMCIvPgogICAgPC9yYWRpYWxHcmFkaWVudD4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0idGFnIiB4MT0iMyIgeTE9IjEiIHgyPSIyMSIgeTI9IjIwIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiMyMmQzZWUiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMTRiOGE2Ii8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPGZpbHRlciBpZD0ic2hhZG93IiB4PSItMzAlIiB5PSItMzAlIiB3aWR0aD0iMTYwJSIgaGVpZ2h0PSIxNzAlIj4KICAgICAgPGZlRHJvcFNoYWRvdyBkeD0iMCIgZHk9IjIiIHN0ZERldmlhdGlvbj0iMiIgZmxvb2QtY29sb3I9IiMwZjE3MmEiIGZsb29kLW9wYWNpdHk9Ii4zOCIvPgogICAgPC9maWx0ZXI+CiAgPC9kZWZzPgoKICA8cmVjdCB4PSIyIiB5PSIyIiB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHJ4PSIxNiIgZmlsbD0idXJsKCNiZykiLz4KICA8cmVjdCB4PSIyIiB5PSIyIiB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHJ4PSIxNiIgZmlsbD0idXJsKCNnbG93KSIvPgogIDxyZWN0IHg9IjIuNzUiIHk9IjIuNzUiIHdpZHRoPSI1OC41IiBoZWlnaHQ9IjU4LjUiIHJ4PSIxNS4yNSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utb3BhY2l0eT0iLjIiIHN0cm9rZS13aWR0aD0iMS41Ii8+CiAgPHBhdGggZD0iTTggMjJDMjAgNyA0MiA1IDU2IDE3IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS1vcGFjaXR5PSIuMSIgc3Ryb2tlLXdpZHRoPSI1IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KCiAgPGcgZmlsdGVyPSJ1cmwoI3NoYWRvdykiPgogICAgPHBhdGggZD0iTTEyIDE4aDUuMmwzIDE4LjRjLjMgMS41IDEuNSAyLjYgMy4xIDIuNmgxOS4yYzEuNCAwIDIuNi0uOSAzLTIuM2wyLjctMTEuMkgyMC4xIiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMy4yIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICAgIDxwYXRoIGQ9Ik0yMiAyOWgyMyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZGRkNmZlIiBzdHJva2Utb3BhY2l0eT0iLjQ4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogICAgPGNpcmNsZSBjeD0iMjYiIGN5PSI0NS41IiByPSIzLjQiIGZpbGw9IiNmZmYiLz4KICAgIDxjaXJjbGUgY3g9IjQxIiBjeT0iNDUuNSIgcj0iMy40IiBmaWxsPSIjZmZmIi8+CiAgICA8Y2lyY2xlIGN4PSIyNiIgY3k9IjQ1LjUiIHI9IjEuNCIgZmlsbD0iIzYzNjZmMSIvPgogICAgPGNpcmNsZSBjeD0iNDEiIGN5PSI0NS41IiByPSIxLjQiIGZpbGw9IiMwODkxYjIiLz4KICA8L2c+CgogIDxnIGZpbHRlcj0idXJsKCNzaGFkb3cpIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgzNyA5KSByb3RhdGUoOCAxMSAxMCkiPgogICAgPHBhdGggZD0iTTMgMWgxMS41TDIxIDcuNVYyMEgzVjFaIiBmaWxsPSJ1cmwoI3RhZykiIHN0cm9rZT0iI2NmZmFmZSIgc3Ryb2tlLW9wYWNpdHk9Ii41NSIgc3Ryb2tlLXdpZHRoPSIxLjIiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICAgIDxjaXJjbGUgY3g9IjgiIGN5PSI2LjUiIHI9IjEuOCIgZmlsbD0iIzE1NWU3NSIvPgogICAgPHBhdGggZD0ibTkuNSAxNiA1LTVNMTAgMTAuNWguMDFNMTUgMTUuNWguMDEiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogIDwvZz4KCiAgPHBhdGggZD0iTTEyIDh2Nk05IDExaDYiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2E1ZjNmYyIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+Cg== // @match https://store.steampowered.com/cart* // @include /^https:\/\/store\.steampowered\.com\/cart(\/.*)?$/ // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @connect api.steampowered.com // @connect api.augmentedsteam.com // @connect api.frankfurter.dev // @connect api.frankfurter.app // @connect cdn.jsdelivr.net // @connect open.er-api.com // @connect api.isthereanydeal.com // @run-at document-idle // @license MIT // @noframes // ==/UserScript== /* ===== INLINE-BEGIN: shared/fx-core.lib.js ===== */ /* * fx-core.lib.js —— 双锚点汇率引擎(Steam 充值报价参考的取数地基) * =========================================================================== * canonical 位置:shared/fx-core.lib.js(本仓库) * 消费方式:① Node/测试:require('./fx-core.lib.js') * ② 油猴脚本:原样内联(见 steam-price-converter 的 inline 标记块) * * 为什么不是「一个接口一张表」: * 充值报价评估需要两个口径分开的锚点 —— * steam:Steam 参考结算价(AugmentedSteam 交叉表,跟随 Steam 建议汇率) * spot :即期市场中间价(Frankfurter=ECB → jsDelivr currency-api → er-api) * 两个锚点各自独立取数、独立缓存、独立标注来源与日期;一个挂了不污染另一个。 * * 血的教训(2026-11-05 实测): * open.er-api.com 免费档会返回 result:"success" + HTTP 200,但 time_last_update * 停在 2022-05-09。只看「有没有 rates 字段」就会把 4 年前的牌价当实时汇率。 * → 所有源必须做 payload 级新鲜度校验,陈旧即弃并记录 stale 来源。 * * 表的形状(冻结语义,方向不许含糊): * USD 枢纽表 rates:rates[CODE] = 「1 USD 能换多少 CODE」,rates.USD === 1。 * CODE→CNY 的单价 = rates.CNY / rates.CODE(convert 内部走 USD 枢纽)。 * * 缓存键: * fxcore_spot_v1 / fxcore_steam_v1 带 1h TTL 的锚点缓存 * fxcore_snap_v1 无 TTL 快照 {spot,steam,ts},接口全挂时兜底 * 只读救援键(不写):swe_fx_cache / swe_fx_snap_v1 / spc_fx_snap_v1 / spc_fx_v1 * —— 与愿望单侧边栏、个人日历两个插件的既有 FX 链互救,快照时间如实保留。 * * 宽表模式(2026-11-06 新增,opt-in,默认关): * create({ wideTable: true }) → spot 锚把多个源按优先级**逐币种合并**, * 而不是「取第一个成功的源」。原因:ECB(frankfurter) 只有 ~30 个币种, * **RUB/TRY/UAH/VND/PHP/NOK/SEK/PLN 全都没有**(ECB 2022 年后不公布卢布), * 只取第一个成功的源 → 这些币种一个都拿不到。 * 合并后 rateDate 取各源里最旧的那个(保守),source 标记 merged:<源列表>。 * 默认关 → 其余三个插件(个人日历 / 价格换算 / 市场增强的老调用方)行为完全不变。 * =========================================================================== */ (function () { 'use strict'; var BASE = 'USD'; var TTL_MS = 60 * 60 * 1000; // 锚点缓存 1 小时 var REQ_TIMEOUT = 6000; // 单请求超时 var ANCHOR_BUDGET = 7000; // 单锚点所有源合计预算 var SPOT_MAX_AGE_MS = 5 * 24 * 3600 * 1000; // 即期牌价最多容忍 5 天(ECB 周末/长假不更新) var ERAPI_MAX_AGE_MS = 72 * 3600 * 1000; var K_SPOT = 'fxcore_spot_v1'; var K_STEAM = 'fxcore_steam_v1'; var K_SNAP = 'fxcore_snap_v1'; // 他脚本的缓存/快照:只读,用于接口全挂时的最后一层救援 var RESCUE_KEYS = ['swe_fx_cache', 'swe_fx_snap_v1', 'spc_fx_snap_v1', 'spc_fx_v1']; function isValidTable(rates) { return !!rates && typeof rates === 'object' && Number(rates[BASE]) > 0; } /** * 交叉表 {USD:{CNY:6.67}, INR:{CNY:0.070}, ...}(语义:1 单位 X = ? CNY) * 折成 USD 枢纽表。真实返回里没有查询币种自己,必须补 rates[quote]。 * 缺 USD 基准 → null(不硬凑一张比例错的表)。 */ function ratesFromCross(data, quoteId) { if (!data || typeof data !== 'object') return null; var quote = quoteId || 'CNY'; var baseRow = data[BASE]; var usdToQuote = baseRow ? Number(baseRow[quote]) : 0; if (!usdToQuote || usdToQuote <= 0) return null; var out = {}; Object.keys(data).forEach(function (id) { var row = data[id]; var perQuote = row ? Number(row[quote]) : 0; if (!perQuote || perQuote <= 0) return; out[id] = usdToQuote / perQuote; }); if (!out[quote]) out[quote] = usdToQuote; return Object.keys(out).length ? out : null; } /** 'YYYY-MM-DD'(UTC)→ ms;非法返回 0 */ function dateOnlyToMs(s) { if (typeof s !== 'string') return 0; var m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!m) return 0; return Date.UTC(+m[1], +m[2] - 1, +m[3]); } function ageOk(dateMs, maxAgeMs, now) { return dateMs > 0 && (now - dateMs) <= maxAgeMs && dateMs <= now + 24 * 3600 * 1000; } /** 小写键表 → 大写键表(currency-api 的 usd 对象键是小写) */ function lowerTableToUpper(lower) { var out = {}; Object.keys(lower).forEach(function (k) { var v = Number(lower[k]); if (Number.isFinite(v) && v > 0) out[k.toUpperCase()] = v; }); out[BASE] = 1; return out; } // ---- 源定义:parse(data, ctx) 返回 {rates, dateMs} | {stale:true, dateMs} | null ---- var STEAM_SOURCES = [ { id: 'augmented', label: 'api.augmentedsteam.com', url: 'https://api.augmentedsteam.com/rates/v1?to=CNY', parse: function (data) { var rates = ratesFromCross(data, 'CNY'); if (!rates) return null; // AS 交叉表不带日期,dateMs 留空(UI 上显示「接口未提供日期」) return { rates: rates, dateMs: 0 }; }, }, ]; var SPOT_SOURCES = [ { id: 'frankfurter', label: 'frankfurter.dev (ECB)', // 2026-11-06 实测:api.frankfurter.app 已 301 迁移到 api.frankfurter.dev/v1/latest // (路径也多了一层 /v1)。直接用新域名,不再靠重定向活着。 url: 'https://api.frankfurter.dev/v1/latest?from=USD', parse: function (data, ctx) { var r = data && data.rates; if (!r) return null; var rates = Object.assign({}, r); rates[BASE] = 1; if (!isValidTable(rates)) return null; var d = dateOnlyToMs(data.date); if (!ageOk(d, SPOT_MAX_AGE_MS, ctx.now)) { return d > 0 ? { stale: true, dateMs: d } : null; } return { rates: rates, dateMs: d }; }, }, { id: 'frankfurter-legacy', label: 'frankfurter.app (ECB, 旧域名)', url: 'https://api.frankfurter.app/latest?from=USD', parse: function (data, ctx) { var r = data && data.rates; if (!r) return null; var rates = Object.assign({}, r); rates[BASE] = 1; if (!isValidTable(rates)) return null; var d = dateOnlyToMs(data.date); if (!ageOk(d, SPOT_MAX_AGE_MS, ctx.now)) { return d > 0 ? { stale: true, dateMs: d } : null; } return { rates: rates, dateMs: d }; }, }, { id: 'jsdelivr', label: 'cdn.jsdelivr.net currency-api', url: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json', parse: function (data, ctx) { var u = data && data.usd; if (!u) return null; var d = dateOnlyToMs(data.date); if (!ageOk(d, SPOT_MAX_AGE_MS, ctx.now)) { return d > 0 ? { stale: true, dateMs: d } : null; } var rates = lowerTableToUpper(u); return isValidTable(rates) ? { rates: rates, dateMs: d } : null; }, }, { id: 'erapi', label: 'open.er-api.com', url: 'https://open.er-api.com/v6/latest/USD', parse: function (data, ctx) { var rates = data && data.rates; if (!rates) return null; var d = Number(data.time_last_update_unix) * 1000; // 免费档实测会返回 2022 年的陈旧数据却自称 success —— 这里是专门的闸门 if (!ageOk(d, ERAPI_MAX_AGE_MS, ctx.now)) { return d > 0 ? { stale: true, dateMs: d } : null; } return isValidTable(rates) ? { rates: rates, dateMs: d } : null; }, }, ]; function defaultDeps() { return { now: function () { return Date.now(); }, gmGet: function (k, d) { try { return (typeof GM_getValue === 'function') ? GM_getValue(k, d) : d; } catch (e) { return d; } }, gmSet: function (k, v) { try { if (typeof GM_setValue === 'function') GM_setValue(k, v); } catch (e) { /* ignore */ } }, fetchJson: function (url, opts) { return new Promise(function (resolve, reject) { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('no GM_xmlhttpRequest')); return; } GM_xmlhttpRequest({ method: 'GET', url: url, timeout: (opts && opts.timeout) || REQ_TIMEOUT, onload: function (res) { if (res.status >= 200 && res.status < 300) { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('bad json: ' + url)); } } else reject(new Error('http ' + res.status)); }, onerror: function () { reject(new Error('network error: ' + url)); }, ontimeout: function () { reject(new Error('timeout: ' + url)); }, }); }); }, log: function () {}, // 宽表模式(默认关):spot 锚把多个源按优先级逐币种合并, // 用来补上 ECB 不公布的币种(RUB/TRY/UAH/VND/PHP/NOK/SEK/PLN…)。 // 开了它一次刷新会打多个源,且 rateDate 取各源里最旧的那个。 wideTable: false, }; } // ---- 无状态纯函数(实例与顶层 API 共用,方向语义冻结,见文件头) ---- /** amount 单位 fromCur → toCur,USD 枢纽:from→USD→to;缺项返回 null */ function convert(amount, fromCur, toCur, rates) { var v = Number(amount); if (!Number.isFinite(v)) return null; if (!fromCur || !toCur || fromCur === toCur) return v; if (!isValidTable(rates)) return null; var fr = Number(rates[fromCur]); var tr = Number(rates[toCur]); if (!(fr > 0) || !(tr > 0)) return null; return (v / fr) * tr; } /** * 手动汇率覆盖(购汇成本锚点用),不修改原表。 * per='usd':value = 「1 USD = value 个 currency」 * per='cur':value = 「1 currency = value USD」 * 非法值 → {ok:false},调用方必须回退,绝不拿错的数去算报价。 */ function applyManualRate(rates, manual) { if (!isValidTable(rates)) return { ok: false, reason: 'noTable' }; if (!manual) return { ok: false, reason: 'noManual' }; var currency = manual.currency; var value = Number(manual.value); if (!currency || !Number.isFinite(value) || value <= 0) return { ok: false, reason: 'badValue' }; var next = Object.assign({}, rates); next[currency] = manual.per === 'cur' ? 1 / value : value; return { ok: true, rates: next }; } /** 1 单位 code 合多少 CNY(方向冻结:CNY per unit);CNY/RMB→1;缺项 null */ function cnyPerUnit(code, rates) { if (!code) return null; code = String(code).toUpperCase(); if (code === 'CNY' || code === 'RMB') return 1; if (!isValidTable(rates)) return null; var cny = Number(rates.CNY); var v = Number(rates[code]); if (!(cny > 0) || !(v > 0)) return null; return cny / v; } function create(injected) { var deps = Object.assign(defaultDeps(), injected || {}); var wideTable = !!deps.wideTable; // mem:{ spot:entry|null, steam:entry|null, ts, warnings:[] } // entry = { rates, source, label, rateDate, ts, stale? } var mem = { spot: null, steam: null, ts: 0, warnings: [] }; var inflight = null; // 单飞的 in-flight Promise function readCache(key) { try { var c = deps.gmGet(key, null); if (c && isValidTable(c.rates) && c.ts && (deps.now() - c.ts) < TTL_MS) return c; } catch (e) { /* ignore */ } return null; } function writeCache(key, entry) { try { deps.gmSet(key, { rates: entry.rates, ts: entry.ts, source: entry.source, rateDate: entry.rateDate || 0 }); } catch (e) { /* ignore */ } } function readSnapshotBuckets() { var out = { spot: null, steam: null }; try { var snap = deps.gmGet(K_SNAP, null); if (snap) { if (snap.spot && isValidTable(snap.spot.rates)) out.spot = snap.spot; if (snap.steam && isValidTable(snap.steam.rates)) out.steam = snap.steam; } } catch (e) { /* ignore */ } return out; } function writeSnapshot(spotEntry, steamEntry) { var cur = { spot: null, steam: null, ts: deps.now() }; try { var old = deps.gmGet(K_SNAP, null); if (old) cur = old; } catch (e) { /* ignore */ } if (spotEntry) cur.spot = spotEntry; if (steamEntry) cur.steam = steamEntry; cur.ts = deps.now(); try { deps.gmSet(K_SNAP, cur); } catch (e) { /* ignore */ } } /** 救援:他脚本的通用 USD 表快照(来源无法区分锚点,标记 snapshot) */ function genericRescue(anchorId) { for (var i = 0; i < RESCUE_KEYS.length; i++) { try { var c = deps.gmGet(RESCUE_KEYS[i], null); if (c && isValidTable(c.rates)) { return { rates: c.rates, source: 'snapshot:' + RESCUE_KEYS[i], label: RESCUE_KEYS[i], rateDate: c.rateDate || 0, ts: c.ts || deps.now(), rescued: true, anchor: anchorId, }; } } catch (e) { /* next */ } } return null; } function fetchAnchor(anchorId, sources) { var warnings = []; var deadline = deps.now() + ANCHOR_BUDGET; function next(i) { if (i >= sources.length) return Promise.resolve(null); var src = sources[i]; var left = deadline - deps.now(); if (left <= 0) return Promise.resolve(null); return deps.fetchJson(src.url, { timeout: Math.min(REQ_TIMEOUT, left) }) .then(function (data) { var parsed; try { parsed = src.parse(data, { now: deps.now() }); } catch (e) { parsed = null; } if (parsed && parsed.stale) { warnings.push({ anchor: anchorId, source: src.id, reason: 'stale', dateMs: parsed.dateMs }); return next(i + 1); } if (parsed && parsed.rates) { return { rates: parsed.rates, source: src.id, label: src.label, rateDate: parsed.dateMs || 0, ts: deps.now(), }; } return next(i + 1); }) .catch(function () { return next(i + 1); }); } return next(0).then(function (entry) { return { entry: entry, warnings: warnings }; }); } /** * 宽表模式(opt-in,默认关):不取「第一个成功的源」,而是把**所有**源按优先级 * 逐币种合并 —— 先到的源优先,后来���源只补前面缺的币种。 * * 为什么需要:ECB(frankfurter) 只有 ~30 个币种,**RUB/TRY/UAH/VND/PHP/NOK/SEK/PLN * 全都没有**(ECB 2022 年后不公布卢布);而 currency-api 有 339 个、er-api 有 166 个。 * 只取第一个成功的源 → 这些币种全部拿不到汇率。 * * 语义变化(只影响开了 wideTable 的调用方): * · 一次刷新会打多个源(受 ANCHOR_BUDGET 约束,打不完就只用已到的那些) * · rateDate 取各源里**最旧**的那个(保守:合并表里最不鲜的那个币种决定新鲜度) * · source 标记为 merged:<源1>+<源2>,UI 上要如实显示是宽表 */ function fetchAnchorMerged(anchorId, sources) { var warnings = []; var deadline = deps.now() + ANCHOR_BUDGET; var merged = null; var primary = null; var usedSources = []; var oldestDate = 0; var seenDate = false; function step(i) { if (i >= sources.length) return Promise.resolve(null); var src = sources[i]; var left = deadline - deps.now(); if (left <= 0) return Promise.resolve(null); return deps.fetchJson(src.url, { timeout: Math.min(REQ_TIMEOUT, left) }) .then(function (data) { var parsed; try { parsed = src.parse(data, { now: deps.now() }); } catch (e) { parsed = null; } if (parsed && parsed.stale) { warnings.push({ anchor: anchorId, source: src.id, reason: 'stale', dateMs: parsed.dateMs }); return step(i + 1); } if (parsed && parsed.rates) { if (!merged) { merged = {}; primary = src; } var codes = Object.keys(parsed.rates); for (var k = 0; k < codes.length; k++) { if (merged[codes[k]] === undefined) merged[codes[k]] = parsed.rates[codes[k]]; } usedSources.push(src.id); var d = parsed.dateMs || 0; if (!seenDate || d < oldestDate) { oldestDate = d; seenDate = true; } } return step(i + 1); }) .catch(function () { return step(i + 1); }); } return step(0).then(function () { if (!merged || !Object.keys(merged).length) return { entry: null, warnings: warnings }; return { entry: { rates: merged, source: 'merged:' + usedSources.join('+'), label: (primary ? primary.label : 'merged') + ' + 宽表', rateDate: oldestDate, ts: deps.now(), merged: true, mergedSources: usedSources.slice(), }, warnings: warnings, }; }); } /** * 取双锚点汇率。force=true 无视 TTL 强刷(F3)。 * 返回 { spot, steam, ts, warnings };任一锚点可能为 null(unknown≠0,绝不伪造)。 */ function load(force) { var now = deps.now(); if (!force && mem.ts && (now - mem.ts) < TTL_MS && (mem.spot || mem.steam)) { return Promise.resolve(mem); } // 单飞:并发调用共享同一次刷新(多个页面同时要汇率时不会打出一串重复请求) if (!inflight) { inflight = doLoad(force).then(function (r) { inflight = null; return r; }, function (e) { inflight = null; throw e; }); } return inflight; } function doLoad(force) { var now = deps.now(); var warnings = []; var startSpot = null; var startSteam = null; if (!force) { var cs = readCache(K_SPOT); var ce = readCache(K_STEAM); if (cs) startSpot = cacheToEntry(cs, K_SPOT); if (ce) startSteam = cacheToEntry(ce, K_STEAM); } if (!force && startSpot && startSteam) { mem = { spot: startSpot, steam: startSteam, ts: now, warnings: [] }; return Promise.resolve(mem); } return Promise.all([ startSpot ? Promise.resolve({ entry: startSpot, warnings: [] }) : (wideTable ? fetchAnchorMerged('spot', SPOT_SOURCES) : fetchAnchor('spot', SPOT_SOURCES)), startSteam ? Promise.resolve({ entry: startSteam, warnings: [] }) : fetchAnchor('steam', STEAM_SOURCES), ]).then(function (results) { var spot = results[0].entry; var steam = results[1].entry; warnings = warnings.concat(results[0].warnings, results[1].warnings); // 网络成功 → 写锚点缓存 + 合并写快照 if (spot) writeCache(K_SPOT, spot); if (steam) writeCache(K_STEAM, steam); if (spot || steam) writeSnapshot(spot, steam); // 网络全挂的锚点:先找自己的无 TTL 快照,再找他脚本快照救援 var buckets = readSnapshotBuckets(); if (!spot) spot = buckets.spot || genericRescue('spot'); if (!steam) steam = buckets.steam || genericRescue('steam'); if (spot && spot.rescued) warnings.push({ anchor: 'spot', source: spot.source, reason: 'rescue' }); if (steam && steam.rescued) warnings.push({ anchor: 'steam', source: steam.source, reason: 'rescue' }); if (!spot) warnings.push({ anchor: 'spot', reason: 'unavailable' }); if (!steam) warnings.push({ anchor: 'steam', reason: 'unavailable' }); mem = { spot: spot, steam: steam, ts: now, warnings: warnings }; return mem; }); } function cacheToEntry(c, key) { return { rates: c.rates, source: c.source || 'cache', label: c.source || key, rateDate: c.rateDate || 0, ts: c.ts, cached: true, }; } return { BASE: BASE, load: load, convert: convert, applyManualRate: applyManualRate, cnyPerUnit: cnyPerUnit, ratesFromCross: ratesFromCross, isValidTable: isValidTable, wideTable: wideTable, getMem: function () { return mem; }, /** 同步取汇率(只读内存里的 spot 锚,不发请求);取不到返回 null(unknown≠0) */ getRate: function (fromCur, toCur) { var m = mem.spot; if (!m || !m.rates) return null; var v = convert(1, fromCur, toCur, m.rates); return (v === null || !Number.isFinite(v)) ? null : v; }, // 暴露给测试 _internals: { dateOnlyToMs: dateOnlyToMs, ageOk: ageOk, lowerTableToUpper: lowerTableToUpper, TTL_MS: TTL_MS }, }; } var api = { BASE: BASE, create: create, ratesFromCross: ratesFromCross, isValidTable: isValidTable, convert: convert, applyManualRate: applyManualRate, cnyPerUnit: cnyPerUnit, }; // UMD-lite:Node 测试走 require;油猴内联时挂 window 并由 var 声明直接可见 if (typeof module === 'object' && module.exports) module.exports = api; if (typeof window !== 'undefined') window.FxCore = api; if (typeof self !== 'undefined' && !self.FxCore) self.FxCore = api; })(); /* ===== INLINE-END: shared/fx-core.lib.js ===== */ /* ===== INLINE-BEGIN: sce-core.lib.js ===== */ /* * sce-core.lib.js —— Steam 购物车增强的纯逻辑层 * =========================================================================== * canonical 位置:steam-cart-enhanced/libs/sce-core.lib.js(本仓库) * 消费方式:① Node/测试:require('./libs/sce-core.lib.js') * ② 油猴脚本:原样内联(见 steam-cart-enhanced.user.js 的 INLINE 标记块) * * 这个库**不碰 DOM、不发网络请求**,只回答「算得对不对」: * ① 价格文本 → 金额(三种千分位口径) parseAmount * ② 价格文本 → 币种(¥ 歧义留给上层消解) curOfText * ③ 金额格式化(零小数位货币取整) fmtMoney * ④ 折扣到期时间 / 倒计时分档 discountEndOf / severityOf * ⑤ 暂存箱记录的归一化、过滤、去重、合并写回 shelf.* * * 口径纪律(沿用本仓既有哲学): * unknown ≠ 0 —— 缺任何一腿都返回 null,绝不拿 0 或另一个口径的数充数; * 方向不许含糊 —— 「1 外币 = ? 目标币」与「? 外币 = 1 目标币」不混用; * 字段名不许含糊 —— 暂存箱字段名是对外契约(见 docs/CONTRACT.md),改一个字 * 就与 SGLV 静默失联。 * =========================================================================== */ (function () { 'use strict'; var VERSION = '1.0.0'; /* ----------------------------------------------------------------- * §1 价格文本 → 金额 * ----------------------------------------------------------------- * 三种千分位口径都要认: * 1,234.56(美式) / 1.234,56(欧式) / 1,23,456.78(印度式) / 12,99(逗号当小数) * 认错就是 1000 倍的错 —— 宁可返回 null 让人看见「解析失败」,也不硬凑一个数。 * ----------------------------------------------------------------- */ function parseAmount(text) { if (text == null) return null; var s = String(text).replace(/[^\d.,]/g, '').trim(); if (!s) return null; var hasDot = s.indexOf('.') >= 0; var hasComma = s.indexOf(',') >= 0; if (hasDot && hasComma) { var lastDot = s.lastIndexOf('.'); var lastComma = s.lastIndexOf(','); if (lastComma > lastDot) { // 欧式:1.234,56 → 1234.56 s = s.replace(/\./g, '').replace(',', '.').replace(/,/g, ''); } else { // 美式:1,234.56 → 1234.56 s = s.replace(/,/g, ''); } } else if (hasComma) { var parts = s.split(','); var tail = parts[parts.length - 1]; var indian = parts.length > 2 && parts.slice(1).every(function (p) { return p.length === 2; }); if (indian) s = s.replace(/,/g, ''); // 1,23,456 else if (tail.length === 3 && parts.length > 1) s = s.replace(/,/g, ''); // 1,234 else s = s.replace(',', '.'); // 12,99 } var n = Number(s); return isFinite(n) ? n : null; } /* ----------------------------------------------------------------- * §2 币种 * ----------------------------------------------------------------- */ // 符号 → 币种码。顺序有意义:长符号在前,避免 $ 吃掉 HK$。 var CUR_BY_SYMBOL = [ ['JP¥', 'JPY'], ['CN¥', 'CNY'], ['HK$', 'HKD'], ['NT$', 'TWD'], ['CDN$', 'CAD'], ['C$', 'CAD'], ['ARS$', 'ARS'], ['Mex$', 'MXN'], ['NZ$', 'NZD'], ['US$', 'USD'], ['R$', 'BRL'], ['A$', 'AUD'], ['S$', 'SGD'], ['CHF', 'CHF'], ['AED', 'AED'], ['SAR', 'SAR'], ['₹', 'INR'], ['₩', 'KRW'], ['₽', 'RUB'], ['₺', 'TRY'], ['₫', 'VND'], ['₱', 'PHP'], ['฿', 'THB'], ['₴', 'UAH'], ['₸', 'KZT'], ['zł', 'PLN'], ['RM', 'MYR'], ['Rp', 'IDR'], ['€', 'EUR'], ['£', 'GBP'], ['¥', 'CNY'], ['$', 'USD'], ].sort(function (a, b) { return b[0].length - a[0].length; }); var CUR_SYMBOL = { CNY: '¥', USD: '$', EUR: '€', GBP: '£', JPY: '¥', KRW: '₩', TWD: 'NT$', HKD: 'HK$', BRL: 'R$', RUB: '₽', TRY: '₺', INR: '₹', VND: '₫', IDR: 'Rp', THB: '฿', MYR: 'RM', PHP: '₱', UAH: '₴', KZT: '₸', PLN: 'zł', CAD: 'CDN$', AUD: 'A$', SGD: 'S$', NZD: 'NZ$', ARS: 'ARS$', MXN: 'Mex$', CHF: 'CHF', }; var ZERO_DEC = ['JPY', 'KRW', 'VND', 'IDR', 'HUF', 'KZT', 'CLP', 'COP', 'PYG']; var CUR_TO_CC = { CNY: 'CN', INR: 'IN', USD: 'US', JPY: 'JP', EUR: 'DE', GBP: 'GB', KRW: 'KR', TWD: 'TW', HKD: 'HK', SGD: 'SG', RUB: 'RU', BRL: 'BR', TRY: 'TR', UAH: 'UA', KZT: 'KZ', IDR: 'ID', PHP: 'PH', VND: 'VN', THB: 'TH', MYR: 'MY', MXN: 'MX', CAD: 'CA', AUD: 'AU', NZD: 'NZ', PLN: 'PL', ARS: 'AR', CHF: 'CH', }; var REGION_INFO = { CN: { cur: 'CNY', zh: '中国', en: 'China', flag: '🇨🇳' }, TW: { cur: 'TWD', zh: '台湾', en: 'Taiwan', flag: '🇹🇼' }, HK: { cur: 'HKD', zh: '香港', en: 'Hong Kong', flag: '🇭🇰' }, JP: { cur: 'JPY', zh: '日本', en: 'Japan', flag: '🇯🇵' }, SG: { cur: 'SGD', zh: '新加坡', en: 'Singapore', flag: '🇸🇬' }, US: { cur: 'USD', zh: '美国', en: 'United States', flag: '🇺🇸' }, IN: { cur: 'INR', zh: '印度', en: 'India', flag: '🇮🇳' }, ID: { cur: 'IDR', zh: '印尼', en: 'Indonesia', flag: '🇮🇩' }, PH: { cur: 'PHP', zh: '菲律宾', en: 'Philippines', flag: '🇵🇭' }, VN: { cur: 'VND', zh: '越南', en: 'Vietnam', flag: '🇻🇳' }, TR: { cur: 'TRY', zh: '土耳其', en: 'Turkey', flag: '🇹🇷' }, UA: { cur: 'UAH', zh: '乌克兰', en: 'Ukraine', flag: '🇺🇦' }, KZ: { cur: 'KZT', zh: '哈萨克斯坦', en: 'Kazakhstan', flag: '🇰🇿' }, RU: { cur: 'RUB', zh: '俄罗斯', en: 'Russia', flag: '🇷🇺' }, BR: { cur: 'BRL', zh: '巴西', en: 'Brazil', flag: '🇧🇷' }, AR: { cur: 'ARS', zh: '阿根廷', en: 'Argentina', flag: '🇦🇷' }, TH: { cur: 'THB', zh: '泰国', en: 'Thailand', flag: '🇹🇭' }, MY: { cur: 'MYR', zh: '马来西亚', en: 'Malaysia', flag: '🇲🇾' }, MX: { cur: 'MXN', zh: '墨西哥', en: 'Mexico', flag: '🇲🇽' }, CA: { cur: 'CAD', zh: '加拿大', en: 'Canada', flag: '🇨🇦' }, AU: { cur: 'AUD', zh: '澳大利亚', en: 'Australia', flag: '🇦🇺' }, NZ: { cur: 'NZD', zh: '新西兰', en: 'New Zealand', flag: '🇳🇿' }, PL: { cur: 'PLN', zh: '波兰', en: 'Poland', flag: '🇵🇱' }, }; function symOf(code) { return CUR_SYMBOL[code] || (code ? code + ' ' : ''); } function isZeroDec(code) { return ZERO_DEC.indexOf(code) >= 0; } /** 币种码 → 区域码 */ function curToCc(code) { return CUR_TO_CC[code] || ''; } /** 价格文本里的币种符号 → 币种码(¥ ���歧义,交给上层用地区消解) */ function curOfText(text) { var s = String(text == null ? '' : text); for (var i = 0; i < CUR_BY_SYMBOL.length; i++) { if (s.indexOf(CUR_BY_SYMBOL[i][0]) >= 0) return CUR_BY_SYMBOL[i][1]; } var m = s.match(/\b([A-Z]{3})\b/); if (m && CUR_SYMBOL[m[1]]) return m[1]; return null; } /** 金额格式化:零小数位货币取整,其余两位 */ function fmtMoney(v, code) { var n = Number(v); if (!isFinite(n)) return '—'; return symOf(code) + n.toFixed(isZeroDec(code) ? 0 : 2); } /* ----------------------------------------------------------------- * §3 折扣到期与倒计时 * ----------------------------------------------------------------- */ /** * 折扣到期时间(毫秒)。 * 唯一可信字段是 active_discounts[].discount_end_date(Unix 秒)—— * discount_expiration 实测恒为 null。 */ function discountEndOf(opt) { var ds = (opt && opt.active_discounts) || []; var end = 0; for (var i = 0; i < ds.length; i++) { var t = Number(ds[i] && ds[i].discount_end_date) || 0; if (t > end) end = t; } return end ? end * 1000 : 0; } /** 分档:0 充裕 / 1 一周内 / 2 三天内 / 3 24 小时内 / 4 已结束 */ function severityOf(endTs, now) { if (!endTs) return 0; var left = endTs - (now || Date.now()); if (left <= 0) return 4; var h = left / 3600000; if (h <= 24) return 3; if (h <= 72) return 2; if (h <= 168) return 1; return 0; } /** 倒计时拆解(文案在脚本侧拼,库里只给数) */ function remainParts(endTs, now) { if (!endTs) return null; var left = endTs - (now || Date.now()); if (left <= 0) return { expired: true, d: 0, h: 0, m: 0 }; return { expired: false, d: Math.floor(left / 86400000), h: Math.floor((left % 86400000) / 3600000), m: Math.floor((left % 3600000) / 60000), }; } /* ----------------------------------------------------------------- * §4 暂存箱(对外契约,见 docs/CONTRACT.md) * ----------------------------------------------------------------- */ var INTEROP_KINDS = ['app', 'bundle', 'package']; /** * SCE 口径的可接受性 —— 必须与 SGLV 的 sceAcceptable() 镜像。 * 不满足的记录会被 SGLV 过滤掉;反过来 SCE 写出去的记录必须全部满足, * 否则 SGLV 那边会静默丢弃(这是「互通失败但没有报错」的头号来源)。 */ function shelfAcceptable(rec) { if (!rec || !rec.id) return false; if (rec.appid || rec.bundleid || rec.packageid) return true; return !!(Array.isArray(rec.appids) && rec.appids.length); } /** kind ↔ id 三件套互斥(C5) */ function normalizeShelfRecord(rec) { var r = Object.assign({}, rec); var kind = r.kind; if (INTEROP_KINDS.indexOf(kind) < 0) { kind = r.bundleid ? 'bundle' : (r.packageid ? 'package' : 'app'); } r.kind = kind; if (kind === 'bundle') { r.packageid = null; r.appid = null; } else if (kind === 'package') { r.bundleid = null; r.appid = null; } else { r.bundleid = null; r.packageid = null; } if (!Array.isArray(r.appids)) r.appids = r.appid ? [r.appid] : []; if (!r.names || typeof r.names !== 'object') r.names = {}; if (!r.id) r.id = newShelfId(Date.now(), Math.random()); if (!r.addedAt) r.addedAt = r.removedAt || Date.now(); if (!r.removedAt) r.removedAt = r.addedAt; if (!r.endTs) r.endTs = 0; if (!r.endTsMap || typeof r.endTsMap !== 'object') r.endTsMap = {}; if (!r.pct) r.pct = 0; if (!r.count) r.count = r.appids.length || 1; if (r.lastError === undefined) r.lastError = null; if (r.source === undefined) r.source = 'cart'; if (r.origin === undefined) r.origin = 'sce'; if (!Array.isArray(r.tags)) r.tags = []; if (r.note === undefined) r.note = ''; if (r.url === undefined) r.url = ''; if (r.currency === undefined) r.currency = ''; if (r.price === undefined) r.price = 0; if (!r.displayName) r.displayName = r.targetName || ''; return r; } /** 同目标判定(用于去重:同 kind + 同 id 视为同一条) */ function sameTarget(a, b) { if (!a || !b) return false; if (a.kind && b.kind && a.kind !== b.kind) return false; if (a.bundleid && b.bundleid) return Number(a.bundleid) === Number(b.bundleid); if (a.packageid && b.packageid) return Number(a.packageid) === Number(b.packageid); if (a.appid && b.appid) return Number(a.appid) === Number(b.appid); return false; } function newShelfId(now, rnd) { var t = (now || Date.now()).toString(36); var r = String(rnd === undefined ? Math.random() : rnd).slice(2, 7); return 'sh_' + t + '_' + r; } /** 从桶里取出可接受的记录(SCE 口径) */ function shelfItems(raw) { var items = (raw && Array.isArray(raw.items)) ? raw.items : []; return items.filter(shelfAcceptable); } /** * 合并写回(C6/C7/C8): * · 顶层键原样保留(days/tab 不能丢) * · 只替换 items * · 扩展字段只挂记录级 */ function mergeShelf(raw, items) { var payload = Object.assign({}, (raw && typeof raw === 'object') ? raw : {}); payload.items = (items || []).slice(); return payload; } /** 写入前按目标去重:新记录排在最前,同目标的旧记录被替换 */ function dedupeShelf(items, incoming) { var kept = items.filter(function (it) { return !sameTarget(it, incoming); }); kept.unshift(incoming); return kept; } /* ----------------------------------------------------------------- * §5 跨区比价 * ----------------------------------------------------------------- */ /** * 选出最便宜的一区。**不能直接比数字** —— USD 9.99 和 ARS 999 没有可比性, * 必须先折成同一个目标货币再比。perCur 是「1 该区货币 = ? 目标货币」的函数。 */ function cheapestRegion(regions, perCur, targetCur) { var best = null; (regions || []).forEach(function (x) { if (!x || x.amount == null) return; var per = perCur(x.currency, targetCur); if (!per || !isFinite(per) || per <= 0) return; var val = x.amount * per; if (best === null || val < best.val) best = { cc: x.cc, val: val, currency: x.currency }; }); return best; } var api = { VERSION: VERSION, parseAmount: parseAmount, curOfText: curOfText, symOf: symOf, fmtMoney: fmtMoney, isZeroDec: isZeroDec, curToCc: curToCc, discountEndOf: discountEndOf, severityOf: severityOf, remainParts: remainParts, cheapestRegion: cheapestRegion, REGION_INFO: REGION_INFO, CUR_SYMBOL: CUR_SYMBOL, INTEROP_KINDS: INTEROP_KINDS, shelf: { acceptable: shelfAcceptable, normalize: normalizeShelfRecord, sameTarget: sameTarget, newId: newShelfId, items: shelfItems, merge: mergeShelf, dedupe: dedupeShelf, }, }; // UMD-lite:Node 测试走 require;油猴内联时挂 window 并由 var 声明直接可见 if (typeof module === 'object' && module.exports) module.exports = api; if (typeof window !== 'undefined') window.SceCore = api; if (typeof self !== 'undefined' && !self.SceCore) self.SceCore = api; })(); /* ===== INLINE-END: sce-core.lib.js ===== */ (function () { 'use strict'; if (typeof window !== 'undefined' && window.top && window.top !== window.self) return; /* ================================================================= * §0 常量与命名空间 * ================================================================= */ const NS = 'sce'; const VERSION = '1.1.1'; const CFG = { // 暂存箱:与「游戏库查看器」(SGLV) 共用同一份 localStorage,双向互通。 // 契约见 docs/CONTRACT.md(键名、记录字段、按钮 class、DOM 标记)。 SHELF_KEY: 'sce_cart_shelf_v1', SETTINGS_KEY: 'sce_settings_v1', PANEL_KEY: 'sce_panel_state_v1', REGION_CACHE_KEY: 'sce_region_cache_v1', ITAD_CACHE_KEY: 'sce_itad_cache_v1', GET_ITEMS: 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1/', ADD_TO_CART: 'https://store.steampowered.com/cart/addtocart', ITAD_LOOKUP: 'https://api.isthereanydeal.com/v01/id/lookup/v1/', ITAD_LOWEST: 'https://api.isthereanydeal.com/v01/game/lowest/', // 愿望单侧栏的跨区比价缓存:只读复用,命中就不重复请求 SWE_REGION_CACHE: 'swe_region_cmp_cache_v2', DEFAULT_REGIONS: ['CN', 'US'], REGION_TTL: 6 * 60 * 60 * 1000, ITAD_TTL: 6 * 60 * 60 * 1000, STEP_MS: 700, MAX_ITEMS: 300, PANEL_W: 360, }; /* ================================================================= * §1 小工具 * ================================================================= */ const log = (...a) => { if (settings.debug) console.log('%c[SCE]', 'color:#8b5cf6', ...a); }; function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function clamp(n, min, max) { return Math.min(max, Math.max(min, n)); } function fmtNum(v, digits) { const n = Number(v); if (!isFinite(n)) return '—'; return n.toLocaleString('zh-CN', { minimumFractionDigits: digits || 0, maximumFractionDigits: digits || 0 }); } function nowMs() { return Date.now(); } /** 本地存储读写(localStorage 优先,失败时静默) */ function lsGet(key, fallback) { try { const raw = localStorage.getItem(key); if (raw == null) return fallback; return JSON.parse(raw); } catch (e) { return fallback; } } function lsSet(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); return true; } catch (e) { return false; } } /** GM 存储读写(用于汇率缓存等与其它插件共享的数据) */ function gmGet(key, fallback) { try { return (typeof GM_getValue === 'function') ? GM_getValue(key, fallback) : fallback; } catch (e) { return fallback; } } function gmSet(key, val) { try { if (typeof GM_setValue === 'function') GM_setValue(key, val); } catch (e) { /* ignore */ } } /** GM_xmlhttpRequest → JSON(跨域取数统一走这里) */ function gmJson(url, timeout) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('no GM_xmlhttpRequest')); return; } GM_xmlhttpRequest({ method: 'GET', url: url, timeout: timeout || 15000, onload(res) { if (res.status >= 200 && res.status < 300) { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('bad json')); } } else { reject(new Error('http ' + res.status)); } }, onerror() { reject(new Error('network error')); }, ontimeout() { reject(new Error('timeout')); }, }); }); } /* ================================================================= * §2 设置 * ================================================================= */ const SETTINGS_DEF = { targetCur: 'CNY', // 换算目标货币 anchor: 'auto', // auto | steam | spot —— 汇率锚点口径 showFinal: true, // 现价旁的人民币换算 showOriginal: true, // 原价旁的人民币换算 showCountdown: true, // 折扣到期倒计时 showRegion: true, // 跨区比价 regions: ['CN', 'US'], // 参与比价的区域 autoRegion: true, // 进页面自动跑一次比价 showItad: true, // ITAD 史低(需要填 key) itadKey: '', // ITAD API Key(免费) manualRate: 0, // 手动汇率:1 外币 = ? 目标货币(0 = 不用) panelOpen: true, debug: false, }; let settings = Object.assign({}, SETTINGS_DEF); function loadSettings() { const raw = lsGet(CFG.SETTINGS_KEY, null); const src = (raw && typeof raw === 'object') ? raw : {}; const out = Object.assign({}, SETTINGS_DEF); Object.keys(SETTINGS_DEF).forEach(k => { if (src[k] === undefined || src[k] === null) return; if (k === 'regions') { if (Array.isArray(src[k]) && src[k].length) out[k] = src[k].filter(x => typeof x === 'string'); } else if (k === 'itadKey' || k === 'targetCur' || k === 'anchor') { out[k] = String(src[k]); } else if (k === 'manualRate') { const n = Number(src[k]); out[k] = isFinite(n) && n > 0 ? n : 0; } else { out[k] = !!src[k]; } }); settings = out; return out; } function saveSettings(patch) { settings = Object.assign({}, settings, patch || {}); lsSet(CFG.SETTINGS_KEY, settings); return settings; } /* ================================================================= * §3 双语与货币 * ================================================================= */ const IS_ZH = (navigator.language || document.documentElement.lang || 'zh').toLowerCase().indexOf('zh') === 0; function t(zh, en) { return IS_ZH ? zh : en; } // 纯逻辑(价格解析 / 币种 / 倒计时分档 / 暂存箱契约)都在 sce-core.lib.js 里。 // 那份库能被 Node 测试直接 require,所以这一层只管 DOM 与网络。 const Core = (typeof window !== 'undefined' && window.SceCore) ? window.SceCore : null; if (!Core) { console.error('[SCE] 核心库 sce-core.lib.js 缺失'); return; } const parseAmount = Core.parseAmount; const curOfText = Core.curOfText; const symOf = Core.symOf; const fmtMoney = Core.fmtMoney; const REGION_INFO = Core.REGION_INFO; const curToCc = Core.curToCc; /* ================================================================= * §4 汇率层(复用 shared/fx-core.lib.js,双锚点) * ================================================================= */ const fx = (typeof window !== 'undefined' && window.FxCore) ? window.FxCore : null; const fxState = { mem: null, loading: false, error: '', manual: null }; function fxCore() { if (!fx) return null; if (!fxCore._inst) { fxCore._inst = fx.create({ gmGet: gmGet, gmSet: gmSet, fetchJson: (url, opts) => gmJson(url, opts && opts.timeout), wideTable: true, }); } return fxCore._inst; } /** * 取汇率。口径按 settings.anchor 选: * auto → steam 锚优先(Steam 自己的区域定价参考价),缺失退 spot * steam → 只认 steam 锚 * spot → 只认 spot 锚(即期市场中间价) * 返回 { per: 1 外币 = ? 目标币, source, date, anchor } 或 null(unknown≠0)。 */ function fxRate(fromCur, toCur) { const mem = fxState.mem; if (!mem) return null; const entry = settings.anchor === 'spot' ? mem.spot : (settings.anchor === 'steam' ? mem.steam : (mem.steam || mem.spot)); if (!entry || !entry.rates) return null; let per = null; if (settings.manualRate > 0 && fromCur && toCur) { // 手动汇率口径:1 外币 = manualRate 个目标币(只对 from→target 生效) if (fromCur === cartState.cur) per = settings.manualRate; } if (per == null) { const inst = fxCore(); if (!inst) return null; per = inst.convert(1, fromCur, toCur, entry.rates); } if (per == null || !isFinite(per) || per <= 0) return null; return { per: per, source: entry.label || entry.source || '', sourceId: entry.source || '', date: entry.rateDate || 0, anchor: entry === mem.steam ? 'steam' : 'spot', manual: !!(settings.manualRate > 0 && fromCur === cartState.cur), stale: !!entry.stale, }; } async function ensureFx(force) { const inst = fxCore(); if (!inst) { fxState.error = 'fx-core missing'; return null; } if (fxState.loading) return fxState.mem; fxState.loading = true; try { fxState.mem = await inst.load(!!force); fxState.error = ''; const w = (fxState.mem && fxState.mem.warnings) || []; if (w.length) log('fx warnings', w); } catch (e) { fxState.error = (e && e.message) || String(e); } finally { fxState.loading = false; } return fxState.mem; } /** 供面板显示:双锚点对照 + 分歧度 */ function fxSnapshot() { const mem = fxState.mem; if (!mem) return null; const from = cartState.cur; const to = settings.targetCur; const inst = fxCore(); const perOf = (entry) => { if (!entry || !entry.rates || !inst) return null; const v = inst.convert(1, from, to, entry.rates); return (v && isFinite(v) && v > 0) ? v : null; }; const steam = perOf(mem.steam); const spot = perOf(mem.spot); let div = null; if (steam && spot && steam > 0) div = Math.abs(steam - spot) / steam; return { steam: steam, spot: spot, divergence: div, steamEntry: mem.steam || null, spotEntry: mem.spot || null, warnings: mem.warnings || [], }; } /* ================================================================= * §5 购物车 DOM 解析 * * 现代 Steam 商店是 React 应用,class 名基本是构建期哈希(会随发版变), * 只有少数语义 class 是稳定的: * .StoreOriginalPrice 原价 * .StoreSaleDiscountBox 折扣百分比 * .Panel 通用块 * 行容器没有稳定 class —— 靠「含商品链接 + 含价格/移除控件」反查。 * ================================================================= */ const cartState = { cur: null, // 购物车币种(如 INR) curSymbol: '', regionText: '', regionCc: '', rows: [], sig: '', // 购物车内容指纹(防自激) totals: { regular: 0, final: 0, cny: 0, mixed: false }, }; const REMOVE_WORDS = ['移除', '删除', '移出购物车', 'Remove', 'Delete', 'Entfernen', 'Supprimer', 'Eliminar']; const ADD_WORDS = ['添加', '加入购物车', 'Add to cart', 'Add to Cart', 'Hinzufügen']; function textOf(el) { return el ? String(el.textContent || '').trim() : ''; } /** 找到文本恰好等于某个词的元素(叶子优先) */ function findByExactText(root, words) { if (!root) return null; const list = root.querySelectorAll('button, a, div, span, li'); for (const el of list) { if (el.children.length) continue; const txt = textOf(el); if (!txt || txt.length > 12) continue; if (words.indexOf(txt) >= 0) return el; } // 退化:文本完全等于但带子元素(例如带 数字) for (const el of list) { const txt = textOf(el); if (txt && txt.length <= 12 && words.indexOf(txt) >= 0 && !el.querySelector('button, a')) return el; } return null; } /** 商品链接 → {kind, id} */ function productLinkOf(rowEl) { const a = rowEl.querySelector('a[href*="/app/"],a[href*="/sub/"],a[href*="/bundle/"]'); if (!a) return null; const href = a.getAttribute('href') || ''; const m = href.match(/\/(app|sub|bundle)\/(\d+)/); if (!m) return null; return { kind: m[1] === 'sub' ? 'package' : m[1], id: Number(m[2]), href: href, el: a }; } /** 行内所有商品 id */ function productIdsOf(rowEl) { const out = { appids: [], bundleid: null, packageid: null }; rowEl.querySelectorAll('a[href*="/app/"],a[href*="/sub/"],a[href*="/bundle/"]').forEach(a => { const href = a.getAttribute('href') || ''; const m = href.match(/\/(app|sub|bundle)\/(\d+)/); if (!m) return; if (m[1] === 'app') { if (out.appids.indexOf(Number(m[2])) < 0) out.appids.push(Number(m[2])); } else if (m[1] === 'bundle') out.bundleid = Number(m[2]); else out.packageid = Number(m[2]); }); return out; } /** 找到行内的现价元素 */ function finalPriceElOf(rowEl) { const orig = rowEl.querySelector('.StoreOriginalPrice'); if (orig) { // 重绘恢复时,原价后面可能已经插着我们的换算旁注;跳过它,不能把它当成现价。 let next = orig.nextElementSibling; while (next && next.hasAttribute('data-sce-owned')) next = next.nextElementSibling; if (next) return next; } // 没有原价标签时:在价格容器里找唯一一个「像价格」的叶子元素 const candidates = []; rowEl.querySelectorAll('div, span').forEach(el => { if (el.children.length || el.hasAttribute('data-sce-owned') || el.closest('[data-sce-owned]')) return; const txt = textOf(el); if (!txt || txt.length > 20) return; if (!/[0-9]/.test(txt)) return; if (!curOfText(txt)) return; if (orig && el === orig) return; candidates.push(el); }); return candidates.length ? candidates[candidates.length - 1] : null; } /** 从折扣盒里取折扣百分比(外层那个,嵌套的子百分比忽略) */ function discountPctOf(rowEl) { const box = rowEl.querySelector('.StoreSaleDiscountBox'); if (!box) return 0; const m = textOf(box).match(/(\d+)\s*%/); if (!m) return 0; return Number(m[1]) || 0; } /** 行标题:优先封面图 alt,其次行内最像名字的文本 */ function titleOf(rowEl) { // 封面图的 alt 是**属性**不是 textContent —— 取 textContent 会拿到空串, // 于是掉进「用 URL slug 当标题」的坑(Tavern_Master 而不是 Tavern Master)。 const img = rowEl.querySelector('img[alt]'); if (img) { const alt = String(img.getAttribute('alt') || '').trim(); if (alt && alt.length <= 60) return alt; } const link = productLinkOf(rowEl); if (link) { const a = link.el; const label = textOf(a); if (label && label.length <= 60) return label; const tail = link.href.split('/').pop().replace(/[?#].*$/, ''); if (tail) return decodeURIComponent(tail); } return t('(未命名商品)', '(unnamed item)'); } /** * 扫描购物车行。 * 两条互补的识别路径: * A) 含「移除」控件的祖先 —— 购物车行独有,推荐区没有 * B) 含 .StoreOriginalPrice 且含商品链接的祖先 —— 兜底 */ function scanCartRows() { const found = []; const push = (el) => { if (el && el !== document.body && found.indexOf(el) < 0) found.push(el); }; const rm = Array.from(document.querySelectorAll('button, a, div, span')); rm.forEach(el => { if (el.children.length) return; const txt = textOf(el); if (!txt || REMOVE_WORDS.indexOf(txt) < 0) return; let n = el; for (let i = 0; i < 14 && n && n !== document.body; i++) { const hasLink = !!n.querySelector('a[href*="/app/"],a[href*="/sub/"],a[href*="/bundle/"]'); const hasPrice = !!n.querySelector('.StoreOriginalPrice,.StoreSaleDiscountBox'); if (hasLink && hasPrice) { push(n); return; } n = n.parentElement; } }); document.querySelectorAll('.StoreOriginalPrice').forEach(op => { let n = op; for (let i = 0; i < 14 && n && n !== document.body; i++) { if (n.querySelector('a[href*="/app/"],a[href*="/sub/"],a[href*="/bundle/"]')) { push(n); return; } n = n.parentElement; } }); return found.map(el => parseRow(el)).filter(Boolean); } /** 把一个行 DOM 解析成结构化数据 */ function parseRow(rowEl) { const origEl = rowEl.querySelector('.StoreOriginalPrice'); const finalEl = finalPriceElOf(rowEl); if (!finalEl && !origEl) return null; const link = productLinkOf(rowEl); const ids = productIdsOf(rowEl); const origTxt = textOf(origEl); const finalTxt = textOf(finalEl); return { rowEl: rowEl, origEl: origEl, finalEl: finalEl, origText: origTxt, finalText: finalTxt, origAmount: parseAmount(origTxt), finalAmount: parseAmount(finalTxt), pct: discountPctOf(rowEl), title: titleOf(rowEl), link: link, appid: ids.appids[0] || (link && link.kind === 'app' ? link.id : null), appids: ids.appids, bundleid: ids.bundleid, packageid: ids.packageid, removeEl: findByExactText(rowEl, REMOVE_WORDS), addEl: findByExactText(rowEl, ADD_WORDS), selEl: rowEl.querySelector('[role="combobox"]'), enrich: null, // GetItems / 跨区 / ITAD 结果 }; } /** 地区下拉(写着「印度(₹)」的那个 combobox) */ function detectRegion() { const combos = Array.from(document.querySelectorAll('[role="combobox"]')); for (const el of combos) { const txt = textOf(el); if (!txt) continue; if (/[₹¥$€£₩₽₺₫₱฿₴₸]/.test(txt) || /([^)]{1,12})|\([^)]{1,12}\)/.test(txt)) { return txt; } } return ''; } /** 由地区文本 + 价格文本推断购物车币种 */ function detectCartCurrency(rows) { const regionText = detectRegion(); cartState.regionText = regionText; const syms = regionText.match(/[₹¥$€£₩₽₺₫₱฿₴₸]|[A-Za-z]{0,3}\$/); let code = syms && syms.length ? curOfText(syms[0]) : null; // ¥ 歧义:地区文本里有「日 / Japan / JP」判日元,否则人民币 if (code === 'CNY' && /[¥¥]/.test(regionText) && /(日本|Japan|\bJP\b|日\s*\)|JP¥)/i.test(regionText)) code = 'JPY'; if (!code) { for (const r of rows) { const c = curOfText(r.finalText) || curOfText(r.origText); if (c) { code = c; break; } } } if (code === 'CNY' && /JP¥|JPY/i.test(rows.map(r => r.finalText).join(' '))) code = 'JPY'; cartState.cur = code; cartState.curSymbol = code ? symOf(code) : ''; cartState.regionCc = curToCc(code) || 'US'; return code; } /* ================================================================= * §6 Steam 官方取数 * ================================================================= */ const STEAM_LANG = { 'zh-CN': 'schinese', 'zh-TW': 'tchinese', en: 'english' }; function steamLang() { const l = (document.documentElement.lang || navigator.language || 'zh-CN').toLowerCase(); if (l.indexOf('zh') === 0) return l.indexOf('tw') >= 0 || l.indexOf('hk') >= 0 ? 'tchinese' : 'schinese'; return STEAM_LANG[l] || 'english'; } /** * IStoreBrowseService/GetItems —— 匿名可用(实测无需登录态)。 * 换区靠 context.country_code,不是 ?cc= 参数。 */ async function getItems(appids, cc) { const ids = (appids || []).filter(n => Number.isFinite(n) && n > 0); if (!ids.length) return {}; const out = {}; for (let i = 0; i < ids.length; i += 80) { const chunk = ids.slice(i, i + 80); const input = { ids: chunk.map(appid => ({ appid: Number(appid) })), context: { language: steamLang(), country_code: cc || 'US', steam_realm: 1 }, data_request: { include_basic_info: true, include_all_purchase_options: true }, }; const url = CFG.GET_ITEMS + '?input_json=' + encodeURIComponent(JSON.stringify(input)); let data; try { data = await gmJson(url, 15000); } catch (e) { log('GetItems failed', cc, e); continue; } const items = (data && data.response && data.response.store_items) || []; items.forEach(it => { const opt = it.best_purchase_option || (it.purchase_options || [])[0] || {}; out[it.appid] = { appid: it.appid, name: it.name, itemType: it.item_type, packageid: opt.packageid || null, bundleid: opt.bundleid || null, final: Number(opt.final_price_in_cents) / 100, regular: Number(opt.original_price_in_cents) / 100, pct: Number(opt.discount_pct) || 0, formatted: opt.formatted_final_price || '', free: !!it.is_free, included: it.included_appids || null, endTs: discountEndOf(opt), }; }); } return out; } /** 折扣到期:active_discounts[].discount_end_date(Unix 秒)—— 逻辑在核心库里 */ function discountEndOf(opt) { return Core.discountEndOf(opt); } /** * 加购:现代商店自己的同源端点(FormData),不需要 webapi token。 * 证据:store.akamai.steamstatic.com/public/javascript/applications/store/main.js * —— POST ${STORE_BASE_URL}cart/addtocart,字段 action/subid|bundleid */ async function addToCart(item) { const w = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; try { const fd = new w.FormData(); if (item.kind === 'bundle') fd.set('bundleid', String(item.id)); else fd.set('subid', String(item.id)); fd.set('action', 'add_to_cart'); const r = await w.fetch(CFG.ADD_TO_CART, { method: 'post', body: fd, credentials: 'include' }); const j = await r.json(); return { ok: !!(j && j.success), data: j || null }; } catch (e) { return { ok: false, err: (e && e.message) || String(e) }; } } /* ================================================================= * §7 跨区比价 + ITAD 史低 * ================================================================= */ function regionCache() { const c = gmGet(CFG.REGION_CACHE_KEY, null); return (c && c.buckets) ? c : { buckets: {} }; } function saveRegionCache(c) { gmSet(CFG.REGION_CACHE_KEY, c); } /** 愿望单侧栏的跨区缓存(只读复用) */ function sweRegionCache() { const c = gmGet(CFG.SWE_REGION_CACHE, null); return (c && c.buckets) ? c.buckets : null; } function regionHitFresh(hit) { if (!hit || !hit.ts) return false; if (hit.bad) return false; return (nowMs() - hit.ts) < CFG.REGION_TTL; } /** * 拉某个区的价格。三级来源,先查后打: * ① 自家缓存(sce_region_cache_v1) * ② 愿望单侧栏的跨区缓存(swe_region_cmp_cache_v2,只读复用 —— 那边跑过就不用再打) * ③ 实打实发请求 */ async function fetchRegion(cc, appids) { const cache = regionCache(); if (!cache.buckets[cc]) cache.buckets[cc] = {}; const bucket = cache.buckets[cc]; const missing = []; appids.forEach(id => { if (regionHitFresh(bucket[id])) return; const shared = sweRegionHit(cc, id); if (shared) { bucket[id] = Object.assign({}, shared, { ts: nowMs() }); return; } missing.push(id); }); if (missing.length) { const got = await getItems(missing, cc); missing.forEach(id => { const it = got[id]; if (!it) { bucket[id] = { ts: nowMs(), bad: 'error' }; return; } bucket[id] = { ts: nowMs(), amount: it.final, regular: it.regular, currency: curOfText(it.formatted) || (REGION_INFO[cc] ? REGION_INFO[cc].cur : ''), cut: it.pct, free: it.free, endTs: it.endTs, packageid: it.packageid, }; }); saveRegionCache(cache); } return bucket; } /** ITAD:appid → 史低(需要用户自己的免费 API key) */ async function itadLows(appids) { const key = settings.itadKey; if (!key || !settings.showItad) return {}; const cache = gmGet(CFG.ITAD_CACHE_KEY, null) || {}; const out = {}; const need = []; appids.forEach(id => { const hit = cache[id]; if (hit && (nowMs() - hit.ts) < CFG.ITAD_TTL) out[id] = hit; else need.push(id); }); for (const id of need) { try { const lookUrl = CFG.ITAD_LOOKUP + '?key=' + encodeURIComponent(key) + '&shop=steam&gameid=' + encodeURIComponent(String(id)); const look = await gmJson(lookUrl, 15000); const firstKey = look && look.data && Object.keys(look.data)[0]; if (!firstKey) { cache[id] = { ts: nowMs(), none: true }; continue; } const plain = (look.data[firstKey] && look.data[firstKey].plain) || firstKey; const lowUrl = CFG.ITAD_LOWEST + '?key=' + encodeURIComponent(key) + '&plains=' + encodeURIComponent(plain) + '®ion=us&country=US'; const low = await gmJson(lowUrl, 15000); const rec = low && low.data && low.data[plain]; const price = Number(rec && rec.price); if (!isFinite(price) || price <= 0) { cache[id] = { ts: nowMs(), none: true }; continue; } const cur = String((rec && rec.currency) || 'USD').toUpperCase(); cache[id] = { ts: nowMs(), price: price, currency: cur, plain: plain }; out[id] = cache[id]; } catch (e) { log('itad failed', id, e); cache[id] = { ts: nowMs(), none: true, err: String(e && e.message || e) }; } } gmSet(CFG.ITAD_CACHE_KEY, cache); return out; } /* ================================================================= * §8 行内渲染 * ================================================================= */ const CSS = ` .sce-cny { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 600; color: #b39dff; letter-spacing: .2px; white-space: nowrap; vertical-align: baseline; font-variant-numeric: tabular-nums; cursor: help; } .sce-cny.is-orig { color: #7d8394; font-weight: 500; } .sce-cny.is-pending { color: #6b7280; } .sce-tags { display: block; flex: 0 0 100%; width: 100%; max-width: 100%; box-sizing: border-box; margin-top: 4px; font-size: 11px; line-height: 1.6; } .sce-tag { display: inline-block; margin-right: 6px; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; letter-spacing: .2px; white-space: nowrap; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.05); color: #c6d4df; } .sce-tag.is-dl { border-color: rgba(251,191,36,.35); background: rgba(251,191,36,.12); color: #fbbf24; } .sce-tag.is-dl.is-urgent { border-color: rgba(248,113,113,.45); background: rgba(248,113,113,.14); color: #f87171; } .sce-tag.is-dl.is-soon { border-color: rgba(251,146,60,.4); background: rgba(251,146,60,.13); color: #fb923c; } .sce-tag.is-low { border-color: rgba(74,222,128,.4); background: rgba(74,222,128,.12); color: #4ade80; } .sce-tag.is-near { border-color: rgba(251,191,36,.35); background: rgba(251,191,36,.12); color: #fbbf24; } .sce-tag.is-reg { border-color: rgba(139,92,246,.3); background: rgba(139,92,246,.12); color: #c4b5fd; } .sce-tag.is-own { border-color: rgba(102,192,244,.4); background: rgba(102,192,244,.12); color: #66c0f4; } .sce-tag.is-best { border-color: rgba(74,222,128,.45); background: rgba(74,222,128,.14); color: #4ade80; } .sce-shelve-btn { margin: 0 6px; padding: 2px 9px; font-size: 10px; font-weight: 700; letter-spacing: .5px; cursor: pointer; border-radius: 2px; border: 1px solid rgba(102,192,244,.35); background: rgba(102,192,244,.08); color: #66c0f4; font-family: inherit; transition: background 150ms ease, color 150ms ease, border-color 150ms ease; } .sce-shelve-btn:hover { background: rgba(102,192,244,.16); border-color: #66c0f4; color: #66c0f4; } .sce-shelve-btn[disabled] { opacity: .45; cursor: not-allowed; } /* ---- 浮动面板 ---- */ .sce-panel { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; width: ${CFG.PANEL_W}px; max-width: calc(100vw - 24px); max-height: 74vh; display: flex; flex-direction: column; overflow: hidden; color: #e2e8f0; background: rgba(13,18,35,.96); border: 1px solid rgba(139,92,246,.3); border-radius: 12px; box-shadow: 0 16px 44px rgba(0,0,0,.55); backdrop-filter: blur(16px) saturate(150%); -webkit-backdrop-filter: blur(16px) saturate(150%); font: 12px/1.5 "Motiva Sans", system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; } .sce-panel.is-collapsed { max-height: none; } .sce-panel.is-collapsed .sce-panel-body { display: none; } .sce-head { display: flex; align-items: center; gap: 8px; padding: 9px 11px; background: linear-gradient(135deg, rgba(139,92,246,.2), rgba(59,130,246,.1)); border-bottom: 1px solid rgba(139,92,246,.18); } .sce-head-title { font-size: 13px; font-weight: 700; color: #f8fafc; flex: 1; } .sce-head-sub { font-size: 10px; color: #94a3b8; font-weight: 500; } .sce-head-btn { border: 0; background: transparent; color: #94a3b8; cursor: pointer; font: inherit; font-size: 14px; line-height: 1; padding: 2px 6px; border-radius: 6px; } .sce-head-btn:hover { color: #f8fafc; background: rgba(139,92,246,.18); } .sce-tabs { display: flex; gap: 2px; padding: 6px 8px 0; border-bottom: 1px solid rgba(139,92,246,.14); } .sce-tab { border: 0; background: transparent; color: #94a3b8; cursor: pointer; font: inherit; font-size: 11px; font-weight: 600; padding: 5px 10px; border-radius: 6px 6px 0 0; } .sce-tab:hover { color: #c4b5fd; } .sce-tab.is-active { color: #fff; background: rgba(139,92,246,.2); } .sce-tab .sce-badge { display: inline-block; min-width: 16px; height: 15px; padding: 0 4px; margin-left: 4px; border-radius: 8px; font-size: 10px; font-weight: 700; line-height: 15px; text-align: center; background: rgba(251,191,36,.16); color: #fbbf24; font-variant-numeric: tabular-nums; } .sce-panel-body { overflow-y: auto; overflow-x: hidden; padding: 10px 11px 12px; } .sce-panel-body::-webkit-scrollbar { width: 8px; } .sce-panel-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,.25); border-radius: 4px; } .sce-sec { margin: 0 0 10px; } .sce-sec-title { margin: 0 0 6px; font-size: 10px; font-weight: 700; letter-spacing: .8px; text-transform: uppercase; color: #64748b; } .sce-kpi-row { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 6px; } .sce-kpi { background: rgba(20,27,50,.8); border: 1px solid rgba(139,92,246,.16); border-radius: 8px; padding: 7px 9px; min-width: 0; } .sce-kpi-label { font-size: 10px; color: #94a3b8; } .sce-kpi-value { font-size: 15px; font-weight: 750; color: #f8fafc; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sce-kpi-value.is-accent { color: #c4b5fd; } .sce-kpi-value.is-green { color: #4ade80; } .sce-kpi-value.is-amber { color: #fbbf24; } .sce-list { display: flex; flex-direction: column; gap: 4px; } .sce-row { display: flex; gap: 7px; align-items: flex-start; padding: 6px 8px; border-radius: 6px; background: rgba(255,255,255,.03); border-left: 2px solid transparent; } .sce-row:nth-child(odd) { background: rgba(255,255,255,.05); } .sce-row-main { flex: 1; min-width: 0; } .sce-row-name { font-size: 11.5px; font-weight: 600; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sce-row-meta { font-size: 10px; color: #8f98a0; margin-top: 2px; } .sce-row-side { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } .sce-row-price { font-size: 12px; font-weight: 700; color: #f8fafc; } .sce-row-cny { font-size: 10px; color: #b39dff; } .sce-row-act { border: 0; background: transparent; color: #64748b; cursor: pointer; font: inherit; font-size: 11px; padding: 2px 5px; border-radius: 4px; } .sce-row-act:hover { color: #f8fafc; background: rgba(139,92,246,.18); } .sce-row-act.is-danger:hover { color: #f87171; background: rgba(248,113,113,.14); } .sce-note { font-size: 10.5px; color: #94a3b8; line-height: 1.6; } .sce-note b { color: #c4b5fd; font-weight: 650; } .sce-btn { border: 1px solid rgba(139,92,246,.24); background: rgba(139,92,246,.1); color: #e2e8f0; border-radius: 6px; padding: 4px 10px; font: inherit; font-size: 11px; font-weight: 600; cursor: pointer; transition: background 150ms ease, border-color 150ms ease; } .sce-btn:hover { background: rgba(139,92,246,.2); border-color: rgba(139,92,246,.45); } .sce-btn[disabled] { opacity: .4; cursor: not-allowed; } .sce-btn.is-primary { background: linear-gradient(135deg,#8b5cf6,#3b82f6); border-color: transparent; color: #fff; } .sce-btn.is-primary:hover { background: linear-gradient(135deg,#a78bfa,#60a5fa); } .sce-btn.is-danger { border-color: rgba(248,113,113,.35); background: rgba(248,113,113,.12); color: #f87171; } .sce-btn.is-danger:hover { background: rgba(248,113,113,.2); } .sce-btn-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } .sce-field { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 6px 0; } .sce-field label { color: #94a3b8; font-size: 11px; } .sce-field input[type=text], .sce-field input[type=number], .sce-field select { box-sizing: border-box; max-width: 100%; background: #141a2e; color: #f8fafc; border: 1px solid rgba(139,92,246,.28); border-radius: 6px; padding: 3px 6px; font: inherit; } .sce-field input[type=text] { width: 170px; } .sce-field input[type=number] { width: 110px; } .sce-field select { width: 130px; } .sce-check { display: flex; align-items: center; gap: 6px; margin: 5px 0; color: #c6d4df; font-size: 11px; } .sce-empty { padding: 26px 12px; text-align: center; color: #5c6b7a; font-size: 12px; } .sce-rate-line { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; margin: 3px 0; } .sce-rate-line span:first-child { color: #94a3b8; } .sce-rate-line b { color: #f8fafc; font-variant-numeric: tabular-nums; } .sce-src { font-size: 10px; color: #64748b; margin-top: 6px; line-height: 1.5; } .sce-src.is-warn { color: #fbbf24; } /* ---- 模态 ---- */ .sce-mask { position: fixed; inset: 0; z-index: 2147483600; background: rgba(5,8,18,.72); display: flex; align-items: center; justify-content: center; padding: 20px; } .sce-modal { width: 460px; max-width: 100%; max-height: 84vh; overflow: hidden; display: flex; flex-direction: column; background: #0d1223; border: 1px solid rgba(139,92,246,.34); border-radius: 12px; box-shadow: 0 24px 64px rgba(0,0,0,.6); color: #e2e8f0; font: 12px/1.55 "Motiva Sans", system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; } .sce-modal-head { display: flex; align-items: center; justify-content: space-between; flex: none; padding: 13px 18px; border-bottom: 1px solid rgba(139,92,246,.2); background: linear-gradient(180deg, rgba(139,92,246,.08), transparent); } .sce-modal-head strong { font-size: 15px; color: #f8fafc; } .sce-modal-body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 18px; } .sce-modal-body::-webkit-scrollbar { width: 6px; } .sce-modal-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,.28); border-radius: 4px; } .sce-modal-foot { padding: 12px 18px; border-top: 1px solid rgba(139,92,246,.2); display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex: none; background: rgba(2,6,23,.3); } .sce-modal-foot-hint { margin-right: auto; color: #64748b; font-size: 10.5px; line-height: 1.5; } .sce-modal-lead { color: #94a3b8; margin: 0 0 10px; } .sce-modal textarea { width: 100%; box-sizing: border-box; min-height: 130px; resize: vertical; background: #141a2e; color: #f8fafc; border: 1px solid rgba(139,92,246,.28); border-radius: 6px; padding: 6px 8px; font: 11px/1.5 ui-monospace, Menlo, Consolas, monospace; } .sce-modal code { color: #c4b5fd; } /* ---- 设置中心:参考 SGLV 卡片分组 + AI 助手表单说明 ---- */ .sce-settings-modal { width: 780px; height: 720px; max-height: 88vh; } .sce-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; align-items: start; } .sce-set-intro { grid-column: 1 / -1; padding: 12px 14px; border: 1px solid rgba(102,192,244,.2); border-radius: 10px; background: rgba(102,192,244,.06); color: #aebdd0; font-size: 12px; line-height: 1.7; } .sce-set-intro strong { display: block; color: #dbeafe; margin-bottom: 2px; font-size: 12.5px; } .sce-set-card { min-width: 0; padding: 15px; border: 1px solid rgba(148,163,184,.14); border-radius: 12px; background: rgba(15,23,42,.58); } .sce-set-card.is-wide { grid-column: 1 / -1; } .sce-set-card-title { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; color: #e2e8f0; font-size: 13px; font-weight: 700; } .sce-set-card-title::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #8b5cf6; flex: none; } .sce-set-badge { margin-left: auto; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 600; color: #94a3b8; background: rgba(148,163,184,.1); } .sce-set-badge.is-ready { color: #4ade80; background: rgba(74,222,128,.12); } .sce-set-desc { margin: 0 0 13px; color: #94a3b8; font-size: 11.5px; line-height: 1.65; } .sce-set-toggle { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; border-top: 1px solid rgba(148,163,184,.08); cursor: pointer; } .sce-set-toggle:first-of-type { border-top: 0; } .sce-set-toggle-copy { min-width: 0; } .sce-set-toggle-copy strong { display: block; color: #cbd5e1; font-size: 12px; font-weight: 600; } .sce-set-toggle-copy small { display: block; margin-top: 2px; color: #64748b; font-size: 10.5px; line-height: 1.5; } .sce-set-switch { position: relative; display: inline-block; width: 36px; height: 20px; flex: none; } .sce-set-switch input { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; } .sce-set-slider { position: absolute; inset: 0; border-radius: 20px; background: rgba(148,163,184,.18); border: 1px solid rgba(148,163,184,.12); transition: .18s; pointer-events: none; } .sce-set-slider::before { content: ''; position: absolute; width: 14px; height: 14px; left: 2px; top: 2px; border-radius: 50%; background: #8a9ba8; transition: .18s; } .sce-set-switch input:checked + .sce-set-slider { background: rgba(139,92,246,.3); border-color: rgba(167,139,250,.5); } .sce-set-switch input:checked + .sce-set-slider::before { transform: translateX(16px); background: #c4b5fd; } .sce-set-switch input:focus-visible + .sce-set-slider { outline: 2px solid #66c0f4; outline-offset: 2px; } .sce-set-field { margin-top: 12px; } .sce-set-field > label { display: block; margin-bottom: 5px; color: #cbd5e1; font-size: 12px; font-weight: 600; } .sce-set-input { width: 100%; box-sizing: border-box; padding: 9px 11px; border-radius: 8px; border: 1px solid rgba(148,163,184,.22); background: rgba(2,6,23,.65); color: #e2e8f0; font-family: inherit; font-size: 12px; line-height: 1.4; outline: none; color-scheme: dark; } .sce-set-input:focus { border-color: #66c0f4; box-shadow: 0 0 0 3px rgba(102,192,244,.12); } .sce-set-input::placeholder { color: #475569; } .sce-set-hint { margin-top: 5px; color: #64748b; font-size: 10.5px; line-height: 1.6; } .sce-set-keyline { display: flex; gap: 8px; align-items: stretch; } .sce-set-keyline .sce-set-input { flex: 1; min-width: 0; font-family: ui-monospace, Menlo, Consolas, monospace; } .sce-set-keyline .sce-btn { display: inline-flex; align-items: center; justify-content: center; white-space: nowrap; text-decoration: none; } .sce-set-guide { margin-top: 12px; padding: 11px 13px; border-left: 3px solid #66c0f4; border-radius: 7px; background: rgba(2,6,23,.35); } .sce-set-guide strong { color: #93c5fd; font-size: 11px; } .sce-set-guide ol { margin: 6px 0 0; padding-left: 20px; color: #94a3b8; font-size: 11px; line-height: 1.75; } .sce-set-guide a { color: #66c0f4; text-decoration: none; } .sce-set-guide a:hover { text-decoration: underline; } .sce-set-privacy { margin-top: 9px; color: #64748b; font-size: 10.5px; line-height: 1.65; } .sce-set-diagnostics { display: none; margin-top: 10px; max-height: 150px; overflow: auto; white-space: pre-wrap; word-break: break-all; color: #a5b4fc; font: 10px/1.5 ui-monospace, Menlo, Consolas, monospace; } .sce-settings-modal .sce-modal-foot .sce-btn { padding: 8px 20px; font-size: 12px; } @media (max-width: 640px) { .sce-mask { padding: 10px; } .sce-settings-grid { grid-template-columns: 1fr; } .sce-set-card.is-wide, .sce-set-intro { grid-column: auto; } .sce-modal-foot-hint { display: none; } .sce-set-keyline { flex-wrap: wrap; } .sce-set-keyline .sce-set-input { flex-basis: 100%; } } /* ---- Toast ---- */ .sce-toast { position: fixed; bottom: 50px; left: 50%; transform: translateX(-50%) translateY(20px); background: rgba(15,23,42,.96); color: #fff; border: 1px solid rgba(167,139,250,.5); padding: 9px 22px; border-radius: 20px; font-size: 13px; font-weight: 500; box-shadow: 0 4px 18px rgba(0,0,0,.35); z-index: 2147483700; opacity: 0; pointer-events: none; transition: opacity .25s, transform .25s; font-family: "Motiva Sans", system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; } .sce-toast.is-show { opacity: 1; transform: translateX(-50%) translateY(0); } .sce-toast.is-success { border-color: #2ed573; } .sce-toast.is-error { border-color: #ff6b6b; } .sce-toast.is-warning { border-color: #f59e0b; } .sce-toast.is-info { border-color: #54a0ff; } `; let toastTimer = 0; function toast(msg, type, ms) { let el = document.getElementById('sce-toast'); if (!el) { el = document.createElement('div'); el.id = 'sce-toast'; el.className = 'sce-toast'; el.setAttribute('data-sce-plugin-ui', '1'); (document.body || document.documentElement).appendChild(el); } el.className = 'sce-toast is-show' + (type ? ' is-' + type : ''); el.textContent = msg; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.className = 'sce-toast' + (type ? ' is-' + type : ''); }, ms || 2600); } /* ================================================================= * §9 模态 / 确认 * ================================================================= */ function showModal(opts) { const mask = document.createElement('div'); mask.className = 'sce-mask'; mask.setAttribute('data-sce-plugin-ui', '1'); mask.setAttribute('data-sglv-plugin-ui', '1'); const buttons = (opts.buttons || []).map((b, i) => `` ).join(''); mask.innerHTML = `
${esc(opts.title || '')}
${opts.body || ''}
${buttons ? `
${opts.footerHint ? `${esc(opts.footerHint)}` : ''}${buttons}
` : ''}
`; (document.body || document.documentElement).appendChild(mask); const close = () => { try { mask.remove(); } catch (e) { /* ignore */ } if (opts.onClose) opts.onClose(); }; mask.addEventListener('click', (e) => { if (e.target === mask && opts.maskClose !== false) close(); const bi = e.target.getAttribute && e.target.getAttribute('data-sce-bi'); if (bi != null) { const b = (opts.buttons || [])[Number(bi)]; // 先执行回调再关闭:保存回调需要读取仍在文档中的表单。 // 返回 false 时保留弹窗,供输入校验失败后继续修改。 const result = b && b.act ? b.act() : undefined; if (result !== false) close(); } if (e.target.getAttribute && e.target.getAttribute('data-sce-close')) close(); }); return { close: close, root: mask }; } function showConfirm(title, lead, okLabel, act) { return new Promise((resolve) => { let done = false; const m = showModal({ title: title, body: `

${lead}

`, buttons: [ { label: t('取消', 'Cancel') }, { label: okLabel || t('确认', 'OK'), primary: true, act: () => { done = true; resolve(true); if (act) act(); } }, ], onClose: () => { if (!done) resolve(false); }, }); return m; }); } /* ================================================================= * §10 暂存箱(与 SGLV 共用 sce_cart_shelf_v1) * * 契约见 docs/CONTRACT.md: * C1 key 必须是 sce_cart_shelf_v1 * C2 每条记录必须有 id * C3 必须有 appid/bundleid/packageid 之一,或 appids 非空 * C4 kind 只能是 app/bundle/package * C5 三个 id 字段互斥 * C6 不写 items/days/tab 之外的顶层键 * C7 写回时保留 days/tab * C8 扩展字段只能挂记录级 * C9 行内按钮 class 必须是 sce-shelve-btn(SGLV 见它就跳过,不会出现双按钮) * C10 注入的 DOM 打 data-sglv-plugin-ui 标记 * ================================================================= */ function readShelfRaw() { const raw = lsGet(CFG.SHELF_KEY, null); return (raw && typeof raw === 'object') ? raw : {}; } /** 读互通桶(SCE 口径:只要有 id 且能认出购买项 —— 与 SGLV 的 sceAcceptable 镜像) */ function readShelf() { return Core.shelf.items(readShelfRaw()); } /** 写互通桶:保留顶层键(days/tab),只替换 items */ function writeShelf(items) { const payload = Core.shelf.merge(readShelfRaw(), items.slice(0, CFG.MAX_ITEMS)); return lsSet(CFG.SHELF_KEY, payload); } function sameTarget(a, b) { return Core.shelf.sameTarget(a, b); } function newShelfId() { return Core.shelf.newId(nowMs(), Math.random()); } /** 从购物车行构造一条暂存箱记录 */ function buildShelfRecord(row) { const ids = { appid: null, bundleid: null, packageid: null }; let kind = 'app'; if (row.bundleid) { kind = 'bundle'; ids.bundleid = row.bundleid; } else if (row.packageid) { kind = 'package'; ids.packageid = row.packageid; } else { kind = 'app'; ids.appid = row.appid; } const names = {}; (row.appids || []).forEach(id => { names[id] = row.title; }); const endTsMap = {}; if (row.enrich && row.enrich.endTs) { (row.appids || []).forEach(id => { endTsMap[id] = row.enrich.endTs; }); } const rec = { id: newShelfId(), kind: kind, appid: ids.appid, bundleid: ids.bundleid, packageid: ids.packageid, appids: (row.appids || []).slice(), names: names, displayName: row.title, targetName: row.title, targetHow: kind === 'bundle' ? t('整包 bundle,内容与购物车里那一项一致', 'Bundle, same contents as the cart line') : kind === 'package' ? t('购买包 package,内容与购物车里那一项一致', 'Package, same contents as the cart line') : t('单个商品,恢复时按商店当前购买项', 'Single app; restored with the current purchase option'), lineItemId: null, endTsMap: endTsMap, endTs: (row.enrich && row.enrich.endTs) || 0, pct: row.pct || 0, count: (row.appids || []).length || 1, price: row.finalAmount != null ? row.finalAmount : 0, currency: cartState.cur || '', addedAt: nowMs(), removedAt: nowMs(), restoredAt: 0, lastError: null, steamid: currentSteamId(), source: 'cart', origin: 'sce', legacy: false, tags: [], note: '', url: '', }; // 归一化在核心库里做:保证三件套互斥、字段齐全(SGLV 那边会照单全收) return Core.shelf.normalize(rec); } /** 当前账号 steamid(拿不到就留空,跨账号保护随之失效) */ function currentSteamId() { try { const w = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; const p = w.g_rgProfileParams || w.g_rgAccountInfo; if (p && (p.steamid || p.steamID)) return String(p.steamid || p.steamID); } catch (e) { /* ignore */ } try { const m = String(document.cookie || '').match(/steamID\s?=\s?("?)([0-9]+)/); if (m) return m[2]; } catch (e) { /* ignore */ } return ''; } /** 暂存一行(点 Steam 自己的「移除」按钮 —— 没有官方移除 API) */ async function shelveRow(row) { if (!row.removeEl) { toast(t('这一行找不到「移除」按钮,无法暂存', 'Cannot find the remove control on this line'), 'warning'); return false; } const rec = buildShelfRecord(row); const items = readShelf(); // 同目标去重:SGLV 也写过的话,以信息更全的这条为准 const kept = items.filter(it => !sameTarget(it, rec)); kept.unshift(rec); row.removeEl.click(); writeShelf(kept); return true; } /** 批量暂存整车 */ async function shelveAll() { const rows = cartState.rows; if (!rows.length) { toast(t('购物车是空的', 'The cart is empty'), 'warning'); return; } const ok = await showConfirm( t('暂存整台购物车', 'Stash the whole cart'), t('将把购物车里的 ', 'Moves ') + `${rows.length} ` + t('项移出购物车并存入暂存箱(会点 Steam 自己的「移除」按钮)。', 'items out of the cart into the shelf (by clicking Steam’s own remove buttons).'), t('确认暂存', 'Stash'), async () => { let done = 0; for (const row of rows) { if (await shelveRow(row)) done++; await sleep(CFG.STEP_MS); } toast(done ? t('已暂存 ', 'Stashed ') + done + t(' 项', ' items') : t('没有暂存成功', 'Nothing was stashed'), done ? 'success' : 'warning'); setTimeout(() => location.reload(), 600); }); return ok; } /** 恢复到购物车(同源 addtocart,不需要 token) */ async function restoreItem(rec) { let kind = rec.kind; let id = null; if (kind === 'bundle') { id = rec.bundleid; } else if (kind === 'package') { id = rec.packageid; } else { // 单品:先查它当前的购买项 packageid(Steam 的加购要 subid,不是 appid) const appid = rec.appid || (rec.appids && rec.appids[0]); if (!appid) return { ok: false, err: t('记录里没有 appid', 'Record has no appid') }; const got = await getItems([appid], cartState.regionCc || 'US'); const it = got[appid]; if (!it || !it.packageid) return { ok: false, err: t('Steam 没有返回购买项', 'Steam returned no purchase option') }; id = it.packageid; kind = 'package'; } if (!id) return { ok: false, err: t('记录里没有购买项 id', 'Record has no purchase id') }; const r = await addToCart({ kind: kind, id: id }); return { ok: r.ok, err: r.err || (r.data && r.data.message) || '' }; } /** 暂存箱:删除一条 */ function removeShelfItem(id) { const items = readShelf().filter(it => it.id !== id); writeShelf(items); renderPanel(); } /* ================================================================= * §11 渲染:行内旁注 + 徽章 * ================================================================= */ function severityOf(endTs) { return Core.severityOf(endTs, nowMs()); } function remainText(endTs) { const p = Core.remainParts(endTs, nowMs()); if (!p) return ''; if (p.expired) return t('已结束', 'ended'); if (p.d > 0) return t('剩 ', 'ends in ') + p.d + t(' 天', 'd ') + (p.h ? ' ' + p.h + t(' 小时', 'h ') : ''); if (p.h > 0) return t('剩 ', 'ends in ') + p.h + t(' 小时', 'h ') + (p.m ? ' ' + p.m + t(' 分', 'm ') : ''); return t('剩 ', 'ends in ') + p.m + t(' 分钟', 'm'); } function cnyNote(amount, fromCur, toCur, extraClass) { const rate = fxRate(fromCur, toCur); if (!rate) { const el = document.createElement('span'); el.className = 'sce-cny is-pending' + (extraClass ? ' ' + extraClass : ''); el.textContent = '≈ —'; el.title = t('汇率还没取到,稍后会自动补上', 'FX not loaded yet; it will fill in shortly'); return el; } const val = amount * rate.per; const el = document.createElement('span'); el.className = 'sce-cny' + (extraClass ? ' ' + extraClass : ''); el.textContent = '≈ ' + fmtMoney(val, toCur); const dateTxt = rate.date ? new Date(rate.date).toLocaleDateString('zh-CN') : t('接口未提供日期', 'no date from source'); el.title = [ t('汇率口径:', 'Rate: ') + (rate.manual ? t('手动设定', 'manual') : (rate.anchor === 'steam' ? t('Steam 参考价', 'Steam reference') : t('即期牌价', 'market spot'))), t('来源:', 'Source: ') + rate.source, t('牌价日期:', 'Date: ') + dateTxt, t('原始金额:', 'Original: ') + fmtMoney(amount, fromCur), t('精确汇率:', 'Rate: ') + '1 ' + fromCur + ' = ' + rate.per.toFixed(6) + ' ' + toCur, ].join('\n'); return el; } /** 跨区比价:返回 { cc, cur, amount, cheapest } */ function regionPricesOf(row) { if (!row.enrich || !row.enrich.regions) return []; const out = []; row.enrich.regions.forEach(r => { if (!r || r.amount == null) return; out.push(r); }); return out; } /** * 找放标签行的宿主容器。 * * ⚠️ 踩过的坑:把标签直接塞进**价格对容器**(原价 + 现价那个 flex 行)—— * 它就变成第三个 flex 项,把同级的折扣徽标挤变形 * (实测 `-87%` 被拉成 84px 高的绿条)。 * * 正确做法:往上找第一个**可换行**的 flex 容器(Steam 的价格 Panel 是 * `flex-wrap: wrap`),标签以 `flex-basis:100%` 放进去 → 自动换到独立一行, * 完全不碰折扣徽标。找不到可换行容器时,退回「插进块级容器、价格块之后」。 */ function tagsHostOf(row) { const anchor = row.finalEl || row.origEl; if (!anchor) return null; let n = anchor; let flexHost = null; while (n && n !== row.rowEl) { const p = n.parentElement; if (!p) break; const cs = getComputedStyle(p); const d = cs.display; if (d === 'flex' || d === 'inline-flex') { if (cs.flexWrap === 'wrap') return { host: p, mode: 'flex' }; if (!flexHost) flexHost = p; } else if (d === 'block' || d === 'flow-root') { return { host: p, mode: 'block', ref: n }; } n = p; } return flexHost ? { host: flexHost, mode: 'flex' } : null; } /** 渲染一行的所有增强 */ function renderRow(row) { // 清掉旧标注(重绘时) row.rowEl.querySelectorAll('.sce-cny[data-sce-owned],.sce-tags[data-sce-owned]').forEach(el => el.remove()); const cur = cartState.cur; const target = settings.targetCur; const notes = []; if (settings.showFinal && row.finalEl && row.finalAmount != null && cur && cur !== target) { const n = cnyNote(row.finalAmount, cur, target); n.setAttribute('data-sce-owned', '1'); row.finalEl.parentElement.insertBefore(n, row.finalEl.nextSibling); notes.push(n); } if (settings.showOriginal && row.origEl && row.origAmount != null && cur && cur !== target) { const n = cnyNote(row.origAmount, cur, target, 'is-orig'); n.setAttribute('data-sce-owned', '1'); row.origEl.parentElement.insertBefore(n, row.origEl.nextSibling); notes.push(n); } // 徽章行:倒计时 / 跨区 / 史低 const tags = []; if (settings.showCountdown && row.enrich && row.enrich.endTs) { const sev = severityOf(row.enrich.endTs); if (sev < 4) { const cls = sev === 3 ? ' is-urgent' : (sev === 2 ? ' is-soon' : ''); tags.push(`⏳ ${esc(remainText(row.enrich.endTs))}`); } } if (settings.showRegion) { const regs = regionPricesOf(row); if (regs.length) { regs.forEach(r => { const cls = (r.own ? ' is-own' : ' is-reg') + (r.cheapest ? ' is-best' : ''); const label = (r.own && r.cheapest ? '★ ' : '') + (REGION_INFO[r.cc] ? (REGION_INFO[r.cc].flag + ' ' + (IS_ZH ? REGION_INFO[r.cc].zh : REGION_INFO[r.cc].en)) : r.cc) + ' ' + fmtMoney(r.amount, r.currency); let tip = r.title || ''; if (r.delta != null && !r.own) { tip += (IS_ZH ? '(比你现在贵 ' : ' (+') + (r.delta > 0 ? r.delta + '%' : (IS_ZH ? '便宜 ' : '-') + Math.abs(r.delta) + '%') + (IS_ZH ? ')' : ')'); } tags.push(`${esc(label)}`); }); } } if (settings.showItad && settings.itadKey && row.enrich && row.enrich.itad) { const low = row.enrich.itad; if (!low.none && low.price) { const curAmt = row.finalAmount; const lowCny = fxRate(low.currency, target); const ownCny = fxRate(cur, target); if (lowCny && ownCny) { const lowVal = low.price * lowCny.per; const ownVal = curAmt * ownCny.per; const at = ownVal <= lowVal * 1.001; tags.push(`${at ? '🏆 ' : ''}${esc(t('史低 ', 'low '))}${esc(fmtMoney(low.price, low.currency))}`); } } } if (tags.length) { let box = row.rowEl.querySelector('.sce-tags'); if (!box) { box = document.createElement('div'); box.className = 'sce-tags'; box.setAttribute('data-sce-owned', '1'); box.setAttribute('data-sce-plugin-ui', '1'); box.setAttribute('data-sglv-plugin-ui', '1'); const spot = tagsHostOf(row); if (spot && spot.mode === 'flex') { // 可换行 flex 容器:靠 flex-basis:100% 自动换到独立一行 spot.host.appendChild(box); } else if (spot && spot.mode === 'block' && spot.ref) { spot.host.insertBefore(box, spot.ref.nextSibling); } else { const anchor = row.finalEl || row.origEl; if (anchor && anchor.parentElement) anchor.parentElement.appendChild(box); } } box.innerHTML = tags.join(''); } } /** 行内「暂存」按钮:只在没有任何暂存按钮时才注入(避免与 SGLV 撞车) */ function ensureShelveButton(row) { if (!row.removeEl) return; const rowEl = row.rowEl; if (rowEl.querySelector('.sce-shelve-btn') || rowEl.querySelector('.sgsh-shelve-btn')) return; const btn = document.createElement('button'); btn.className = 'sce-shelve-btn'; btn.type = 'button'; btn.textContent = t('暂存', 'Stash'); btn.title = t('加入暂存箱(会从购物车移除)', 'Move to shelf (removes from cart)'); btn.setAttribute('data-sce-plugin-ui', '1'); btn.setAttribute('data-sglv-plugin-ui', '1'); btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); btn.disabled = true; await shelveRow(row); setTimeout(() => location.reload(), 500); }); if (row.removeEl.parentElement) row.removeEl.parentElement.insertBefore(btn, row.removeEl); else rowEl.appendChild(btn); } /* ================================================================= * §12 右下角浮动面板 * ================================================================= */ const panelState = { open: true, tab: 'cart', collapsed: false, root: null }; function ensurePanel() { if (panelState.root && panelState.root.isConnected) return panelState.root; const root = document.createElement('div'); root.className = 'sce-panel' + (panelState.collapsed ? ' is-collapsed' : ''); root.id = 'sce-panel'; root.setAttribute('data-sce-plugin-ui', '1'); root.setAttribute('data-sglv-plugin-ui', '1'); root.innerHTML = `
${esc(t('购物车增强', 'Cart Enhanced'))}
`; (document.body || document.documentElement).appendChild(root); panelState.root = root; root.addEventListener('click', (e) => { const tabBtn = e.target.closest && e.target.closest('[data-sce-tab]'); if (tabBtn) { panelState.tab = tabBtn.getAttribute('data-sce-tab'); renderPanel(); return; } const act = e.target.closest && e.target.closest('[data-sce-act]'); if (act) { handlePanelAction(act.getAttribute('data-sce-act'), act.getAttribute('data-sce-id')); return; } if (e.target.id === 'sce-collapse') { panelState.collapsed = !panelState.collapsed; root.classList.toggle('is-collapsed', panelState.collapsed); lsSet(CFG.PANEL_KEY, { collapsed: panelState.collapsed, tab: panelState.tab }); return; } if (e.target.id === 'sce-settings') { openSettings(); return; } }); return root; } async function handlePanelAction(act, id) { if (act === 'refresh-fx') { toast(t('正在刷新汇率…', 'Refreshing FX…'), 'info'); await ensureFx(true); renderPanel(); return; } if (act === 'refresh-region') { toast(t('正在跨区比价…', 'Comparing regions…'), 'info'); await enrichRows(true); renderPanel(); return; } if (act === 'shelve-all') { await shelveAll(); return; } if (act === 'shelve-row') { const row = cartState.rows.filter(r => String(r.appid) === String(id))[0]; if (row) { await shelveRow(row); setTimeout(() => location.reload(), 500); } return; } if (act === 'restore') { const rec = readShelf().filter(r => r.id === id)[0]; if (!rec) return; toast(t('正在恢复…', 'Restoring…'), 'info'); const r = await restoreItem(rec); if (r.ok) { removeShelfItem(id); toast(t('已恢复到购物车', 'Restored to the cart'), 'success'); setTimeout(() => location.reload(), 700); } else { toast(t('恢复失败:', 'Restore failed: ') + (r.err || t('未知错误', 'unknown error')), 'error'); } return; } if (act === 'del-shelf') { removeShelfItem(id); return; } if (act === 'open-settings') { openSettings(); return; } } function panelBodyHtml() { if (panelState.tab === 'cart') return cartTabHtml(); if (panelState.tab === 'shelf') return shelfTabHtml(); return fxTabHtml(); } function cartTabHtml() { const rows = cartState.rows; const cur = cartState.cur; const tot = cartState.totals; const rate = fxRate(cur, settings.targetCur); const cnyTotal = rate ? tot.final * rate.per : null; const kpis = [ `
${esc(t('现价合计', 'Current total'))}
${esc(fmtMoney(tot.final, cur))}
`, `
${esc(t('原价合计', 'List total'))}
${esc(fmtMoney(tot.regular, cur))}
`, `
${esc(t('折合人民币', 'In CNY'))}
${esc(cnyTotal == null ? '—' : fmtMoney(cnyTotal, settings.targetCur))}
`, `
${esc(t('折扣力度', 'Discount'))}
${esc(tot.regular > 0 ? Math.round((1 - tot.final / tot.regular) * 100) + '%' : '—')}
`, ].join(''); let list = ''; if (!rows.length) { list = `
${esc(t('购物车是空的', 'Your cart is empty'))}
`; } else { list = rows.map(r => { const r2 = r.enrich && r.enrich.rate; const cny = r2 && r.finalAmount != null ? r.finalAmount * r2.per : null; const end = r.enrich && r.enrich.endTs; const meta = []; if (r.pct) meta.push('-' + r.pct + '%'); if (end) meta.push(remainText(end)); if (r.enrich && r.enrich.cheapest) { const cc = r.enrich.cheapest.cc; meta.push(t('最便宜 ', 'cheapest ') + (REGION_INFO[cc] ? (IS_ZH ? REGION_INFO[cc].zh : REGION_INFO[cc].en) : cc)); } if (r.enrich && r.enrich.regions) { r.enrich.regions.filter(x => !x.own && x.delta != null).forEach(x => { const nm = REGION_INFO[x.cc] ? (IS_ZH ? REGION_INFO[x.cc].zh : REGION_INFO[x.cc].en) : x.cc; meta.push(nm + ' ' + (x.delta > 0 ? '+' : '') + x.delta + '%'); }); } return `
${esc(r.title)}
${esc(meta.join(' · ') || (r.finalText || ''))}
${esc(r.finalText || '—')}
${esc(cny == null ? '≈ —' : '≈ ' + fmtMoney(cny, settings.targetCur))}
`; }).join(''); } return `
${kpis}
${esc(t('明细', 'Items'))}
${list}
`; } function shelfTabHtml() { const items = readShelf(); const foreign = items.filter(it => it.steamid && currentSteamId() && it.steamid !== currentSteamId()); let list = ''; if (!items.length) { list = `
${esc(t('暂存箱是空的', 'The shelf is empty'))}
`; } else { list = items.map(it => { const meta = []; if (it.pct) meta.push('-' + it.pct + '%'); if (it.endTs) meta.push(remainText(it.endTs)); if (it.price) meta.push(fmtMoney(it.price, it.currency || '')); const isForeign = foreign.indexOf(it) >= 0; return `
${esc(it.displayName || it.targetName || t('(未命名)', '(unnamed)'))}
${esc(meta.join(' · ') || (it.kind || ''))}${isForeign ? ' · ' + esc(t('其他账号', 'other account')) : ''}
`; }).join(''); } return `
${esc(t('暂存箱', 'Shelf'))} · ${items.length}
${list}
${esc(t('与「游戏库查看器」(SGLV) 共用同一份暂存数据,两边互写互读。', 'Shared with the Steam Game Library Viewer (SGLV) — both sides read and write the same records.'))}
`; } function fxTabHtml() { const cur = cartState.cur; const target = settings.targetCur; const snap = fxSnapshot(); const rate = fxRate(cur, target); const lines = []; if (rate) { lines.push(`
${esc(t('当前口径', 'Current rate'))}1 ${esc(cur || '?')} = ${esc(rate.per.toFixed(6))} ${esc(target)}
`); lines.push(`
${esc(t('来源', 'Source'))}${esc(rate.source)}
`); lines.push(`
${esc(t('牌价日期', 'Rate date'))}${esc(rate.date ? new Date(rate.date).toLocaleDateString('zh-CN') : t('接口未提供', 'not provided'))}
`); } else { lines.push(`
${esc(t('当前口径', 'Current rate'))}—
`); } if (snap) { if (snap.steam) lines.push(`
${esc(t('Steam 参考价', 'Steam reference'))}${esc(snap.steam.toFixed(6))}
`); if (snap.spot) lines.push(`
${esc(t('即期牌价', 'Market spot'))}${esc(snap.spot.toFixed(6))}
`); if (snap.divergence != null) { const cls = snap.divergence > 0.03 ? ' is-warn' : ''; lines.push(`
${esc(t('两个口径相差 ', 'Anchor divergence: ') + (snap.divergence * 100).toFixed(2) + '%')}
`); } } if (fxState.error) lines.push(`
${esc(t('汇率加载出错:', 'FX error: ') + fxState.error)}
`); const conv = (function () { if (!rate || !cur) return ''; const n = Number(document.getElementById('sce-calc-amt') ? document.getElementById('sce-calc-amt').value : 0); const v = n * rate.per; return isFinite(v) ? `
${esc(t('换算', 'Convert'))}${esc(fmtMoney(n, cur))} ≈ ${esc(fmtMoney(v, target))}
` : ''; })(); return `
${esc(t('汇率', 'Exchange rate'))}
${lines.join('')}
${esc(t('换算器', 'Calculator'))}
${conv}
${esc(t('换算器输入后按回车即可。', 'Press Enter after typing.'))}
`; } function renderPanel() { const root = ensurePanel(); const body = root.querySelector('#sce-panel-body'); if (body) body.innerHTML = panelBodyHtml(); root.querySelectorAll('.sce-tab').forEach(b => { b.classList.toggle('is-active', b.getAttribute('data-sce-tab') === panelState.tab); }); const cnt = root.querySelector('#sce-shelf-count'); if (cnt) cnt.textContent = String(readShelf().length); const sub = root.querySelector('#sce-head-sub'); if (sub) { const cur = cartState.cur; sub.textContent = cur ? (cartState.rows.length + ' ' + t('项', 'items') + ' · ' + symOf(cur)) : ''; } // 换算器输入联动 const calc = root.querySelector('#sce-calc-amt'); if (calc && !calc.__bound) { calc.__bound = true; calc.addEventListener('keydown', (e) => { if (e.key === 'Enter') renderPanel(); }); calc.addEventListener('change', () => renderPanel()); } } /* ================================================================= * §13 设置弹窗 * ================================================================= */ function openSettings() { const cur = cartState.cur || ''; const toggle = (id, checked, title, desc) => ` `; const body = `
${esc(t('第一次使用?保持默认设置即可。', 'First time here? The defaults are ready to use.'))} ${esc(t('换算、倒计时和跨区比价无需密钥。只有想查看“历史最低价”时,才需要填写下方 ITAD API Key。所有修改点击底部“保存设置”后生效。', 'Conversion, countdowns and region prices need no key. Only ITAD all-time lows require the API key below. Click “Save settings” to apply your changes.'))}
${esc(t('显示内容', 'Display'))}

${esc(t('选择购物车商品下方要显示的信息。关闭某项只会隐藏标注,不影响 Steam 购物车本身。', 'Choose which annotations appear below cart items. Turning one off never changes the Steam cart itself.'))}

${toggle('sce-s-showFinal', settings.showFinal, t('现价换算', 'Current-price conversion'), t('在折后价旁显示目标货币估算值。', 'Show an estimate next to the discounted price.'))} ${toggle('sce-s-showOriginal', settings.showOriginal, t('原价换算', 'List-price conversion'), t('换算划线原价,方便比较折扣前后的金额。', 'Convert the crossed-out list price for comparison.'))} ${toggle('sce-s-showCountdown', settings.showCountdown, t('折扣到期倒计时', 'Discount countdown'), t('显示优惠还剩多久结束,临近到期会改变颜色。', 'Show time left; the color changes near expiry.'))} ${toggle('sce-s-showRegion', settings.showRegion, t('跨区比价', 'Cross-region prices'), t('比较所选地区的 Steam 当前售价,绿色表示最低。', 'Compare current Steam prices; green marks the cheapest.'))} ${toggle('sce-s-showItad', settings.showItad, t('ITAD 历史最低价', 'ITAD all-time low'), t('显示 ITAD 收录的历史低价,需要先在下方填写 Key。', 'Show ITAD historical lows; configure the key below first.'))}
${esc(t('货币与汇率', 'Currency & FX'))}

${esc(t('这里只改变页面上的换算结果,不会修改 Steam 实际支付币种或订单金额。悬停换算值可查看汇率来源与日期。', 'These options only change estimates on the page, never the Steam payment currency or order total. Hover an estimate for its source and date.'))}

${esc(t('默认 CNY(人民币)。例如印度区 ₹59 会同时显示约 ¥4.18。', 'Default: CNY. For example, ₹59 can appear with an approximate CNY value.'))}
${esc(t('“自动”优先采用 Steam 参考价,取不到时使用市场汇率,适合大多数用户。', 'Auto prefers Steam reference rates and falls back to market rates. Recommended for most users.'))}
${esc(t('含义:1 ' + (cur || t('外币', 'foreign currency')) + ' 等于多少 ' + settings.targetCur + '。例如填 0.08,表示 1 单位外币约等于 0.08 ' + settings.targetCur + ';填写后优先于在线汇率。', 'How much ' + settings.targetCur + ' equals 1 ' + (cur || 'foreign currency') + '. A value such as 0.08 overrides online rates.'))}
${esc(t('跨区比价', 'Cross-region comparison'))}

${esc(t('用两位国家/地区代码指定比较范围,多个代码用英文逗号分隔。建议保留 2–4 个,地区越多查询越慢。', 'Enter two-letter country codes separated by commas. Two to four regions are recommended; more regions take longer.'))}

${esc(t('常用:CN 中国、US 美国、JP 日本、IN 印度、KZ 哈萨克斯坦、UA 乌克兰、BR 巴西、VN 越南、ID 印度尼西亚、PH 菲律宾。留空会恢复为 CN, US。', 'Common codes: CN, US, JP, IN, KZ, UA, BR, VN, ID, PH. Blank restores CN, US.'))}
${esc(t('比价结果仅供参考,不代表可以跨区购买;实际购买请遵守 Steam 账户地区与支付规则。', 'Prices are informational only and do not imply cross-region purchases are available. Follow Steam region and payment rules.'))}
${esc(t('诊断与日志', 'Diagnostics'))}

${esc(t('通常无需修改。只有页面标注异常或向作者反馈问题时,才需要开启日志或导出检测报告。', 'Usually no changes are needed. Use these tools only when annotations misbehave or when reporting a problem.'))}

${toggle('sce-s-debug', settings.debug, t('输出调试日志', 'Verbose console logs'), t('在浏览器开发者工具的控制台中记录运行细节。', 'Write runtime details to the browser developer console.'))}
${esc(t('报告包含商品 ID、价格和 DOM 类名,不包含 ITAD Key。可复制给作者排查 Steam 页面改版问题。', 'The report includes item IDs, prices and DOM class names, never your ITAD key. Share it to help diagnose Steam layout changes.'))}
${esc(t('ITAD 历史最低价(可选)', 'ITAD all-time lows (optional)'))} ${esc(settings.itadKey ? t('已填写 Key', 'Key configured') : t('未填写,史低不显示', 'No key; lows hidden'))}

${esc(t('ITAD(IsThereAnyDeal)是第三方游戏价格数据库。API Key 是调用其价格接口的凭证,不是你的 ITAD 或 Steam 密码。不填写也能正常使用其他全部功能。', 'ITAD (IsThereAnyDeal) is a third-party game price database. An API key identifies API requests; it is not your ITAD or Steam password. Every other feature works without it.'))}

${esc(t('打开申请页面', 'Get a key'))}
${esc(t('请只粘贴 Key 本身,不要粘贴网址或“API Key:”前缀。清空输入框并保存,即可停止查询史低。', 'Paste only the key, not a URL or an “API Key:” prefix. Clear this field and save to stop low-price requests.'))}
${esc(t('第一次申请 Key,按这 4 步操作', 'Getting a key for the first time: 4 steps'))}
  1. ${t('注册并登录 IsThereAnyDeal,按网站提示验证邮箱。', 'Register and sign in to IsThereAnyDeal, then verify your email.')}
  2. ${t('打开 isthereanydeal.com/apps,点击 Register App,按页面要求填写应用名称等信息并提交。', 'Open isthereanydeal.com/apps, choose Register App and submit the requested app details.')}
  3. ${esc(t('在应用的 API Keys 区域复制 32 位密钥,粘贴到上方输入框。', 'Copy the 32-character key from the app’s API Keys area and paste it above.'))}
  4. ${esc(t('确认“ITAD 历史最低价”开关已打开,再点击底部“保存设置”。', 'Keep “ITAD all-time low” enabled, then click “Save settings” below.'))}
${esc(t('Key 仅保存在当前浏览器的 localStorage 中,脚本只把它发往 ITAD 官方接口查询价格,不会发送到其他服务器。史低数据来自 ITAD,更新可能有延迟。', 'The key is stored only in this browser’s localStorage and sent only to ITAD’s official price API, never to another server. ITAD low-price data may be delayed.'))}
`; const m = showModal({ title: t('购物车增强 · 设置', 'Cart Enhanced · Settings'), className: 'sce-settings-modal', footerHint: t('保存后立即生效 · Key 仅保存在本机浏览器', 'Changes apply on save · Your key stays in this browser'), body: body, buttons: [ { label: t('取消', 'Cancel') }, { label: t('保存设置', 'Save settings'), primary: true, act: () => { const root = m.root; const itadKey = (root.querySelector('#sce-s-itad').value || '').trim(); if (itadKey && !/^[0-9a-f]{32}$/i.test(itadKey)) { toast(t('Key 格式不正确:应为 32 位十六进制字符(0–9、a–f),请检查后重试。', 'Invalid key: enter the 32 hexadecimal characters (0–9, a–f).'), 'warning', 4200); root.querySelector('#sce-s-itad').focus(); return false; } const regions = (root.querySelector('#sce-s-regions').value || '') .split(/[,\s]+/).map(s => s.trim().toUpperCase()).filter(Boolean); saveSettings({ showFinal: root.querySelector('#sce-s-showFinal').checked, showOriginal: root.querySelector('#sce-s-showOriginal').checked, showCountdown: root.querySelector('#sce-s-showCountdown').checked, showRegion: root.querySelector('#sce-s-showRegion').checked, showItad: root.querySelector('#sce-s-showItad').checked, targetCur: root.querySelector('#sce-s-target').value, anchor: root.querySelector('#sce-s-anchor').value, manualRate: Number(root.querySelector('#sce-s-manual').value) || 0, regions: regions.length ? regions : CFG.DEFAULT_REGIONS.slice(), itadKey: itadKey, debug: root.querySelector('#sce-s-debug').checked, }); toast(t('设置已保存,正在刷新购物车标注。', 'Settings saved. Refreshing cart annotations.'), 'success'); refreshAll(true); return true; } }, ], }); m.root.addEventListener('click', (e) => { const target = e.target && e.target.closest ? e.target.closest('button') : null; if (!target) return; if (target.id === 'sce-s-diag') { const out = m.root.querySelector('#sce-s-diag-out'); out.style.display = 'block'; out.textContent = diagnosticsText(); } else if (target.id === 'sce-s-itad-toggle') { const input = m.root.querySelector('#sce-s-itad'); const show = input.type === 'password'; input.type = show ? 'text' : 'password'; target.textContent = show ? t('隐藏', 'Hide') : t('显示', 'Show'); } }); } /** 诊断报告:Steam 改版后靠它定位选择器 */ function diagnosticsText() { const rows = cartState.rows.map(r => ({ title: r.title, appid: r.appid, final: r.finalText, orig: r.origText, pct: r.pct, hasRemove: !!r.removeEl, origElClass: r.origEl ? r.origEl.className : null, finalElClass: r.finalEl ? r.finalEl.className : null, rowElClass: r.rowEl.className, })); return JSON.stringify({ version: VERSION, url: location.href, currency: cartState.cur, regionText: cartState.regionText, rowCount: cartState.rows.length, shelfCount: readShelf().length, rows: rows, }, null, 1); } /* ================================================================= * §14 跨脚本联动 * ================================================================= */ /** 跨标签页同步:SGLV 改了互通桶 → 这里立刻重画(补上 SGLV 的不对称) */ function watchShelf() { window.addEventListener('storage', (e) => { if (!e) return; if (e.key && e.key !== CFG.SHELF_KEY) return; const cnt = document.getElementById('sce-shelf-count'); if (cnt) cnt.textContent = String(readShelf().length); if (panelState.tab === 'shelf') renderPanel(); }); } /** 愿望单侧栏的跨区缓存:命中就直接用,不重复请求 */ function sweRegionHit(cc, appid) { try { const buckets = sweRegionCache(); if (!buckets || !buckets[cc]) return null; const hit = buckets[cc][String(appid)]; if (!hit || !regionHitFresh(hit)) return null; return hit; } catch (e) { return null; } } /* ================================================================= * §15 主流程 * ================================================================= */ /** 给每一行补齐折扣到期 / 跨区价 / 史低 */ async function enrichRows(force) { const rows = cartState.rows; if (!rows.length) return; const appids = []; rows.forEach(r => { if (r.appid && appids.indexOf(r.appid) < 0) appids.push(r.appid); (r.appids || []).forEach(id => { if (appids.indexOf(id) < 0) appids.push(id); }); }); if (!appids.length) return; // 1) 基础数据(packageid / 折扣到期 / 本区价) const base = await getItems(appids, cartState.regionCc || 'CN'); rows.forEach(r => { const it = base[r.appid]; if (it) r.enrich = { endTs: it.endTs, packageid: it.packageid, regions: [] }; else if (!r.enrich) r.enrich = { endTs: 0, packageid: null, regions: [] }; }); // 2) 跨区比价 if (settings.showRegion) { const regions = (settings.regions && settings.regions.length) ? settings.regions : CFG.DEFAULT_REGIONS; for (const cc of regions) { const bucket = await fetchRegion(cc, appids); rows.forEach(r => { const hit = bucket[r.appid]; if (!hit || hit.bad || hit.amount == null) return; r.enrich.regions.push({ cc: cc, amount: hit.amount, currency: hit.currency || (REGION_INFO[cc] ? REGION_INFO[cc].cur : ''), cheapest: false, title: (REGION_INFO[cc] ? REGION_INFO[cc].zh : cc) + ' ' + hit.amount, }); }); } // 把自己所在的区也算进比较集。 // 不然「最便宜」只在几个候选区里挑,结论会误导: // 例:印度 ₹59 ≈ ¥4.18,国区实价 ¥6.24 —— 只比 CN/US 会说「国区最便宜」,其实你手里更便宜。 rows.forEach(r => { if (!cartState.cur || r.finalAmount == null) return; const ownCc = cartState.regionCc; const own = { cc: ownCc, amount: r.finalAmount, currency: cartState.cur, own: true, title: (REGION_INFO[ownCc] ? REGION_INFO[ownCc].zh : ownCc) + ' ' + r.finalText, }; const at = r.enrich.regions.findIndex(x => x.cc === ownCc); if (at >= 0) r.enrich.regions[at] = own; else r.enrich.regions.unshift(own); }); // 标出最便宜的一区(统一折成目标货币再比,不能直接比数字) rows.forEach(r => { const best = Core.cheapestRegion(r.enrich.regions, (curCode, toCode) => { const f = fxRate(curCode, toCode); return f ? f.per : null; }, settings.targetCur); if (best) { r.enrich.cheapest = { cc: best.cc, own: !!(r.enrich.regions.find(x => x.cc === best.cc) || {}).own }; r.enrich.regions.forEach(x => { if (x.cc === best.cc) x.cheapest = true; }); } // 各区相对「你现在这个价」的贵/便宜百分比 const ownPer = fxRate(cartState.cur, settings.targetCur); r.enrich.regions.forEach(x => { if (x.own) { x.delta = 0; return; } const per = fxRate(x.currency, settings.targetCur); if (!per || !ownPer || !r.finalAmount) { x.delta = null; return; } const mine = r.finalAmount * ownPer.per; const theirs = x.amount * per.per; x.delta = mine > 0 ? Math.round((theirs / mine - 1) * 100) : null; }); }); } // 3) 汇率快照挂到每行(面板里显示「≈ ¥xx」用) const homeRate = fxRate(cartState.cur, settings.targetCur); rows.forEach(r => { r.enrich.rate = homeRate; }); // 3) ITAD 史低 if (settings.showItad && settings.itadKey) { const lows = await itadLows(appids); rows.forEach(r => { if (lows[r.appid]) r.enrich.itad = lows[r.appid]; }); } log('enrich done', rows.map(r => r.enrich)); } /** 统计合计 */ function computeTotals() { const tot = { regular: 0, final: 0, cny: 0, mixed: false }; const curSet = new Set(); cartState.rows.forEach(r => { if (r.finalAmount != null) { tot.final += r.finalAmount; curSet.add(cartState.cur); } if (r.origAmount != null) tot.regular += r.origAmount; }); tot.mixed = curSet.size > 1; cartState.totals = tot; } /** * 购物车内容指纹。React 重绘会让 DOM 变动,但真正影响展示的只有 * 「有哪些行、每行价格是多少」。用它做闸门,注入自己的标注不会自激。 */ function cartSignature(rows) { const list = rows || cartState.rows; return list.map(r => [r.appid || '', r.finalText || '', r.origText || '', r.pct || 0].join('~')).join('||'); } /** 全流程刷新 */ let refreshing = false; let refreshQueued = false; let refreshQueuedForce = false; async function refreshAll(force) { if (refreshing) { // Steam 在异步取数期间也可能重绘;不能丢掉这次恢复请求。 refreshQueued = true; refreshQueuedForce = refreshQueuedForce || !!force; return; } refreshing = true; try { const rows = scanCartRows(); const sig = cartSignature(rows); if (!force && sig === cartState.sig) { // 内容没变不代表增强 DOM 还在:React 会单独移除我们插入的节点。 // 保留旧行的 enrich 数据,只补回缺失的标注,避免重复打网络请求。 const previousRows = cartState.rows; rows.forEach((r, i) => { const old = previousRows[i]; if (!old) { r.__restoreEnhancements = true; return; } r.enrich = old.enrich; r.__restoreEnhancements = old.rowEl !== r.rowEl; }); cartState.rows = rows; rows.forEach(r => { const needFinalNote = settings.showFinal && r.finalEl && r.finalAmount != null && cartState.cur && cartState.cur !== settings.targetCur; const needOriginalNote = settings.showOriginal && r.origEl && r.origAmount != null && cartState.cur && cartState.cur !== settings.targetCur; const needTags = !!r.enrich && ( (settings.showCountdown && r.enrich.endTs && severityOf(r.enrich.endTs) < 4) || (settings.showRegion && regionPricesOf(r).length > 0) || (settings.showItad && settings.itadKey && r.enrich.itad) ); const finalNoteMissing = needFinalNote && !(r.finalEl.nextElementSibling && r.finalEl.nextElementSibling.classList.contains('sce-cny')); const originalNoteMissing = needOriginalNote && !(r.origEl.nextElementSibling && r.origEl.nextElementSibling.classList.contains('sce-cny')); const tagsMissing = needTags && !r.rowEl.querySelector('.sce-tags[data-sce-owned]'); if (r.__restoreEnhancements || finalNoteMissing || originalNoteMissing || tagsMissing) renderRow(r); ensureShelveButton(r); delete r.__restoreEnhancements; }); renderPanel(); return; } cartState.rows = rows; cartState.sig = sig; detectCartCurrency(cartState.rows); computeTotals(); if (settings.showRegion && settings.autoRegion) await enrichRows(!!force); else if (!cartState.rows.every(r => r.enrich)) await enrichRows(!!force); cartState.rows.forEach(renderRow); cartState.rows.forEach(ensureShelveButton); renderPanel(); } catch (e) { log('refresh failed', e); } finally { refreshing = false; if (refreshQueued) { const nextForce = refreshQueuedForce; refreshQueued = false; refreshQueuedForce = false; refreshAll(nextForce); } } } /** 倒计时每秒刷新(只更新文字,不重排) */ function tick() { const now = nowMs(); cartState.rows.forEach(r => { if (!r.enrich || !r.enrich.endTs) return; const box = r.rowEl.querySelector('.sce-tags'); if (!box) return; const tag = box.querySelector('.is-dl'); if (!tag) return; const sev = severityOf(r.enrich.endTs); if (sev >= 4) { tag.remove(); return; } tag.textContent = '⏳ ' + remainText(r.enrich.endTs); tag.className = 'sce-tag is-dl' + (sev === 3 ? ' is-urgent' : (sev === 2 ? ' is-soon' : '')); }); } /** * 监听 DOM 变化(React 重绘)。 * 两道闸门,防自激: * ① 变异过滤 —— 只认「节点是我们自己注入的」就跳过 * ② 指纹闸门 —— 购物车内容没变就不重跑 */ function observe() { let timer = 0; const isOurs = (node) => { if (!node || node.nodeType !== 1) return false; if (node.hasAttribute && (node.hasAttribute('data-sce-owned') || node.hasAttribute('data-sce-plugin-ui'))) return true; return !!(node.closest && node.closest('[data-sce-owned],[data-sce-plugin-ui]')); }; const mo = new MutationObserver((mutations) => { // 自己主动替换旧节点也会留下 removedNodes;随后同指纹检查会确认新节点是否齐全。 // 若 Steam 单独删掉 .sce-tags,这里必须唤醒恢复,不能再把「我们的节点被删」一概忽略。 const ownedNodeRemoved = mutations.some(m => Array.from(m.removedNodes || []).some(node => node && node.nodeType === 1 && node.hasAttribute('data-sce-owned') && !node.isConnected )); const relevant = ownedNodeRemoved || mutations.some(m => { const nodes = Array.from(m.addedNodes || []).concat(Array.from(m.removedNodes || [])); if (!nodes.length) return false; if (nodes.every(isOurs)) return false; const t = m.target; if (t && t.nodeType === 1 && t.closest && t.closest('[data-sce-owned],[data-sce-plugin-ui]')) return false; return true; }); if (!relevant) return; if (timer) return; timer = setTimeout(() => { timer = 0; refreshAll(); }, 600); }); mo.observe(document.body, { childList: true, subtree: true }); } async function boot() { loadSettings(); const pstate = lsGet(CFG.PANEL_KEY, null); if (pstate) { panelState.collapsed = !!pstate.collapsed; panelState.tab = pstate.tab || 'cart'; } if (typeof GM_addStyle === 'function') GM_addStyle(CSS); else { const s = document.createElement('style'); s.id = 'sce-css'; s.textContent = CSS; (document.head || document.documentElement).appendChild(s); } watchShelf(); ensurePanel(); renderPanel(); await ensureFx(false); await refreshAll(false); observe(); setInterval(tick, 1000); setInterval(() => { if (!document.hidden) ensureFx(true); }, 3600000); log('SCE booted', VERSION); } if (document.body) boot(); else document.addEventListener('DOMContentLoaded', boot, { once: true }); })();