// ==UserScript== // @name Steam 个人日历增强 // @name:en Steam Personal Calendar // @namespace https://github.com/steam-card-view // @version 1.8.3 // @description Steam 个人日历把外币标价摊在你面前,心算人民币很容易看花;它也只给价格,不告诉你这游戏是不是抢先体验、这个月是不是扎堆、别处是不是更便宜。「日历换算」面板把全页标价换成本币,再给出跨区最低价、试玩版与抢先体验预警、同月扎堆提示、愿望单预算和折扣雷达 —— 标价比价一条龙。汇率走 Steam 参考价与即期牌价双锚点,陈旧数据自动弃用,取不到就保持原货币,绝不拿错的数改你的标价。 // @description:en Steam's Personal Calendar hands you foreign prices to squint at, and only prices — it never says whether a game is really Early Access, whether the month is crowded, or whether another region sells it cheaper. The Calendar Convert panel converts every price on the page to your currency, then adds cross-region lowest prices, demo and early-access warnings, crowded-month alerts, a wishlist budget and a discount radar — price, comparison and buying advice in one panel. Rates use dual anchors (Steam reference + market spot): stale quotes are rejected, snapshots take over when every source is down, and if nothing can be fetched the price stays in its original currency rather than being silently rewritten wrong. // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%238b5cf6%22%2F%3E%3Cstop%20offset%3D%220.55%22%20stop-color%3D%22%233b82f6%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2306b6d4%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22url(%23g)%22%2F%3E%3Crect%20x%3D%2211%22%20y%3D%2216%22%20width%3D%2232%22%20height%3D%2234%22%20rx%3D%226%22%20fill%3D%22%23ffffff%22%2F%3E%3Cpath%20d%3D%22M11%2022h32v4H11z%22%20fill%3D%22%231e1b4b%22%2F%3E%3Cpath%20d%3D%22M11%2022a6%206%200%200%201%206-6h20a6%206%200%200%201%206%206v4H11z%22%20fill%3D%22%231e1b4b%22%2F%3E%3Crect%20x%3D%2219%22%20y%3D%2211%22%20width%3D%223.2%22%20height%3D%229%22%20rx%3D%221.6%22%20fill%3D%22%23ffffff%22%2F%3E%3Crect%20x%3D%2231%22%20y%3D%2211%22%20width%3D%223.2%22%20height%3D%229%22%20rx%3D%221.6%22%20fill%3D%22%23ffffff%22%2F%3E%3Ccircle%20cx%3D%2219%22%20cy%3D%2233%22%20r%3D%222.15%22%20fill%3D%22%238b5cf6%22%2F%3E%3Ccircle%20cx%3D%2227%22%20cy%3D%2233%22%20r%3D%222.15%22%20fill%3D%22%236366f1%22%2F%3E%3Ccircle%20cx%3D%2219%22%20cy%3D%2240%22%20r%3D%222.15%22%20fill%3D%22%233b82f6%22%2F%3E%3Ccircle%20cx%3D%2227%22%20cy%3D%2240%22%20r%3D%222.15%22%20fill%3D%22%2306b6d4%22%2F%3E%3Ccircle%20cx%3D%2219%22%20cy%3D%2247%22%20r%3D%222.15%22%20fill%3D%22%2322d3ee%22%2F%3E%3Ccircle%20cx%3D%2247%22%20cy%3D%2246%22%20r%3D%2213%22%20fill%3D%22%23d97706%22%2F%3E%3Ccircle%20cx%3D%2247%22%20cy%3D%2246%22%20r%3D%2211.2%22%20fill%3D%22%23fbbf24%22%2F%3E%3Cpath%20d%3D%22M41.2%2040.6%20L47%2046.2%20L52.8%2040.6%20M47%2045.4%20V53.4%20M42.4%2048.4%20H51.6%20M42.4%2051.3%20H51.6%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%222.15%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E // @match https://store.steampowered.com/personalcalendar // @match https://store.steampowered.com/personalcalendar/ // @match https://store.steampowered.com/personalcalendar/* // @include /^https:\/\/store\.steampowered\.com\/personalcalendar([/?#].*)?$/ // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_setClipboard // @grant GM_notification // @grant unsafeWindow // @require https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/libs/sglv-plugin-host.lib.js // @require https://update.greasyfork.org/scripts/590084/1909121/SGLV%20App%20Detail%20Library.js // @connect open.er-api.com // @connect api.frankfurter.app // @connect api.frankfurter.dev // @connect store.steampowered.com // @connect api.augmentedsteam.com // @connect cdn.jsdelivr.net // @connect api.isthereanydeal.com // @run-at document-idle // @license MIT // @noframes // ==/UserScript== (function () { 'use strict'; if (typeof window !== 'undefined' && window.top && window.top !== window.self) return; try { var __PH = (typeof unsafeWindow !== 'undefined' && unsafeWindow.__PLUGIN_HOST__) || (typeof window !== 'undefined' && window.__PLUGIN_HOST__); if (__PH && typeof __PH.registerPlugin === 'function') { __PH.registerPlugin({ id: 'spc', name: 'Steam 个人日历增强', family: 'calendar' }); } } catch (e) { /* 注册失败不影响日历本身 */ } /* ===== 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: shared/deal-radar.lib.js ===== */ /* * deal-radar.lib.js —— 愿望单折扣雷达(纯函数库) * =========================================================================== * canonical 位置:shared/deal-radar.lib.js(本仓库) * 消费方式:① Node/测试:require('./deal-radar.lib.js') * ② 油猴脚本:原样内联(见 steam-personal-calendar 的 INLINE 标记块) * * 思想借鉴 AetherSwap 的「折扣雷达」:愿望单游戏按折扣力度 / 好评率动态排序。 * 本库只做纯数据变换 —— 无网络、无 DOM、无 GM 存储;取数与 canvas 绘制在脚本侧。 * * 数据形状(冻结语义,别改字段名): * card = 日历卡片(collectCards 形状):{ appId, name, section, flags.wishlist, release:{ts} } * target = { appId, name, releaseTs } ← pickWishlistTargets 输出 * deal = { discountPct, priceFinal(分), ccy } ← parseAppdetails 输出 * 无价/未打折统一为 { discountPct: 0, priceFinal: null, ccy: '' } * review = { scoreDesc, totalReviews } ← parseAppreviews 输出 * row = { appId, name, discountPct, priceFinal, ccy, scoreDesc, totalReviews, discounted } * ← buildRow 输出 * layout = { title, dateText, rows[≤8], footer } ← cardLayout 输出(canvas 文本数据) * * 已知边界(有意为之): * · appdetails 不支持批量(appids=1,2 → HTTP 400),所以一次只解析一个 appid 的响应, * 取 json 里第一个键(单 appid 请求的返回只有一个键)。 * · price 排序按 priceFinal 的原始数值(分)升序 —— ccy 不同时不可比 * (USD 9.99 与 ARS 999 没有可比性),仅同币种内有序;rates 参数仅预留, * 将来接入换算后也不改行形状。 * · scoreDesc 透传 Steam 英文原文(Overwhelmingly Positive…),双语映射放在 * 脚本侧 i18n(cardLayout 的 opts.scoreDescText 钩子)。 * · 史低数据(ITAD)需要自备 API key,本期不做 —— 数据全部来自 Steam 官方接口。 * =========================================================================== */ (function () { 'use strict'; var TARGET_LIMIT = 30; // 目标上限:超出时保留 release 最近(发售日最早)的 30 款 var TOP_N = 8; // 分享卡片行数 var DEFAULT_MAX_NAME = 24; // 游戏名单行最大字符数(按码点),超出截断加省略号 // 零小数位货币:appdetails 的 final 同样按「分」上报(JPY 147200 = ¥1472), // 展示时这些币种取整,其它币种保留两位小数。 var ZERO_DEC = { JPY: 1, KRW: 1, VND: 1, IDR: 1, HUF: 1, KZT: 1, CLP: 1, COP: 1, PYG: 1 }; // 常见币种符号;没映射到的币种用「CODE 12.34」写法兜底,绝不猜错符号 var SYMBOLS = { 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$', }; // 好评描述 → 权重:好评 > 多半好评 > 褒贬不一 > 差评 > 无评测(权重 0 垫底) var REVIEW_WEIGHT = { 'Overwhelmingly Positive': 4, 'Very Positive': 4, 'Positive': 4, 'Mostly Positive': 3, 'Mixed': 2, 'Mostly Negative': 1, 'Negative': 1, 'Very Negative': 1, 'Overwhelmingly Negative': 1, }; function num(value) { if (value == null || value === '') return null; // Number(null) 是 0,先挡掉 var n = Number(value); return Number.isFinite(n) ? n : null; } /** 按码点截断(中文/emoji 都按 1 个字算),超出加 … */ function clampText(text, max) { var chars = Array.from(String(text)); if (chars.length <= max) return chars.join(''); return chars.slice(0, max).join('') + '…'; } /** 千分位:1234567 → '1,234,567'(评测数用,中文场景同样适用) */ function thousands(value) { var n = Math.round(num(value) || 0); return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } /** * 从日历卡片里挑折扣雷达目标:section==='upcoming' 且 flags.wishlist。 * 同 appId 在多月重复出现只留一条、保留最早 release;超过 30 款时保留 * 发售日最近(releaseTs 最小)的 30 款 —— 没读到日期的排最后,先被裁掉。 */ function pickWishlistTargets(cards) { var picked = {}; (cards || []).forEach(function (card) { if (!card || card.section !== 'upcoming') return; if (!(card.flags && card.flags.wishlist)) return; var appId = String(card.appId || ''); if (!appId) return; var ts = card.release && card.release.ts ? num(card.release.ts) : null; var prev = picked[appId]; if (!prev) { picked[appId] = { appId: appId, name: card.name || appId, releaseTs: ts }; } else { if (ts != null && (prev.releaseTs == null || ts < prev.releaseTs)) prev.releaseTs = ts; if (!prev.name) prev.name = card.name || appId; } }); var out = Object.keys(picked).map(function (key) { return picked[key]; }); if (out.length > TARGET_LIMIT) { out.sort(function (a, b) { var ta = a.releaseTs == null ? Infinity : a.releaseTs; var tb = b.releaseTs == null ? Infinity : b.releaseTs; return ta - tb; }); out = out.slice(0, TARGET_LIMIT); } return out; } /** * 解析 /api/appdetails?appids={id}&filters=price_overview 的返回(单 appid)。 * price_overview.final 是「分」(JPY 147200 = ¥1472),discount_percent 是整数百分比。 * success=false 或无 price_overview(未打折/未上架/未开卖)→ * { discountPct: 0, priceFinal: null, ccy: '' };json 非法 → null。 */ function parseAppdetails(json) { if (!json || typeof json !== 'object') return null; var keys = Object.keys(json); if (!keys.length) return null; var entry = json[keys[0]]; var data = entry && entry.success ? entry.data : null; var price = data && data.price_overview ? data.price_overview : null; if (!price) return { discountPct: 0, priceFinal: null, ccy: '' }; var final = num(price.final); if (final == null) return { discountPct: 0, priceFinal: null, ccy: String(price.currency || '') }; var pct = Math.max(0, Math.round(num(price.discount_percent) || 0)); return { discountPct: pct, priceFinal: Math.round(final), ccy: String(price.currency || '') }; } /** * 解析 /appreviews/{appid}?json=1&num_per_page=0 的返回摘要。 * → { scoreDesc, totalReviews };非法返回 null。 */ function parseAppreviews(json) { if (!json || typeof json !== 'object') return null; var qs = json.query_summary; if (!qs || typeof qs !== 'object') return null; var total = num(qs.total_reviews); return { scoreDesc: String(qs.review_score_desc || ''), totalReviews: total == null || total < 0 ? 0 : total, }; } /** target + deal + review 合并成一行;discountPct=0 或 priceFinal=null 记 discounted=false */ function buildRow(target, deal, review) { target = target || {}; deal = deal || { discountPct: 0, priceFinal: null, ccy: '' }; review = review || { scoreDesc: '', totalReviews: 0 }; var pct = Math.max(0, Math.round(num(deal.discountPct) || 0)); var priceFinal = num(deal.priceFinal); return { appId: String(target.appId || ''), name: target.name || String(target.appId || ''), discountPct: pct, priceFinal: priceFinal == null ? null : Math.round(priceFinal), ccy: String(deal.ccy || ''), scoreDesc: String(review.scoreDesc || ''), totalReviews: Math.max(0, Math.round(num(review.totalReviews) || 0)), discounted: !!(pct > 0 && priceFinal != null), }; } function rowWeight(row) { var w = REVIEW_WEIGHT[row && row.scoreDesc]; return w == null ? 0 : w; } /** * 排序(返回新数组,不改原数组): * 'discount'(默认):折扣力度降序,未打折垫底、组内按名字排; * 'review':先按「是否在折扣内」分组,组内按好评权重(好评>多半好评>褒贬不一> * 差评>无评测)降序,再按折扣力度、名字稳定下来; * 'price':按 priceFinal(分)数值升序 —— 局限:ccy 不同时不可比, * 仅同币种内有序(rates 参数仅预留,接入换算前不读)。 */ function rankRows(rows, sortBy, rates) { void rates; // 仅预留:接入多币种换算后用于价格排序,当前不读 var list = (rows || []).slice(); var cmpName = function (a, b) { return String(a.name || '').localeCompare(String(b.name || '')); }; if (sortBy === 'review') { list.sort(function (a, b) { var group = (b.discounted ? 1 : 0) - (a.discounted ? 1 : 0); if (group) return group; var w = rowWeight(b) - rowWeight(a); if (w) return w; var d = (b.discountPct || 0) - (a.discountPct || 0); if (d) return d; return cmpName(a, b); }); return list; } if (sortBy === 'price') { list.sort(function (a, b) { var pa = a.priceFinal == null ? Infinity : a.priceFinal; var pb = b.priceFinal == null ? Infinity : b.priceFinal; if (pa !== pb) return pa - pb; return cmpName(a, b); }); return list; } list.sort(function (a, b) { var ga = a.discounted ? 0 : 1; var gb = b.discounted ? 0 : 1; if (ga !== gb) return ga - gb; if (ga === 1) return cmpName(a, b); // 都未打折:按名字排 var d = (b.discountPct || 0) - (a.discountPct || 0); if (d) return d; return cmpName(a, b); }); return list; } /** 分 → 带符号金额文本:12345/CNY → '¥123.45';147200/JPY → '¥1,472' */ function formatAmount(priceFinal, ccy) { var cents = num(priceFinal); if (cents == null) return '—'; var code = String(ccy || ''); var digits = ZERO_DEC[code] ? 0 : 2; var body = (cents / 100).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits, }); var symbol = SYMBOLS[code]; if (symbol) return symbol + body; return code ? code + ' ' + body : body; } /** * 分享卡片布局数据(纯数据,不碰 canvas):取前 8 行,格式化折扣徽章、 * 现价、好评摘要文本,并截断超长游戏名。title/dateText/footer 由调用方传入; * opts.scoreDescText(desc) 是好评描述的本地化钩子(不传就透传英文原文), * opts.maxNameChars 覆盖默认的单行最大字符数。 */ function cardLayout(rows, opts) { var o = opts || {}; var scoreText = typeof o.scoreDescText === 'function' ? o.scoreDescText : function (desc) { return desc; }; var maxName = num(o.maxNameChars); var limit = maxName != null && maxName > 0 ? Math.floor(maxName) : DEFAULT_MAX_NAME; var out = { title: String(o.title || ''), dateText: String(o.dateText || ''), rows: [], footer: String(o.footer || ''), }; (rows || []).slice(0, TOP_N).forEach(function (row) { var priced = row && row.priceFinal != null; out.rows.push({ name: clampText(String((row && row.name) || ''), limit), discountText: row && row.discounted && row.discountPct > 0 ? '-' + Math.round(row.discountPct) + '%' : '', priceText: priced ? formatAmount(row.priceFinal, row.ccy) : '—', scoreText: row && row.scoreDesc ? scoreText(row.scoreDesc) + '(' + thousands(row.totalReviews) + ')' : '—', }); }); return out; } var api = { TARGET_LIMIT: TARGET_LIMIT, TOP_N: TOP_N, pickWishlistTargets: pickWishlistTargets, parseAppdetails: parseAppdetails, parseAppreviews: parseAppreviews, buildRow: buildRow, rankRows: rankRows, cardLayout: cardLayout, }; // UMD-lite:Node 测试走 require;油猴内联时挂 window 并由 var 声明直接可见 if (typeof module === 'object' && module.exports) module.exports = api; if (typeof window !== 'undefined') window.DealRadar = api; if (typeof self !== 'undefined' && !self.DealRadar) self.DealRadar = api; })(); /* ===== INLINE-END: shared/deal-radar.lib.js ===== */ const VERSION = '1.8.3'; const RELEASE_BRIDGE_KEY = '__saa_bridge_spc__'; const SETTINGS_KEY = 'spc_settings_v1'; // 标题栏图标用 SVG,不用字符。 // 之前「重置位置」用的是 ⟲,跟刷新长得一模一样,点的人一堆 —— 现在: // 刷新 = 环形箭头(用户对刷新的默认预期) // 重置位置 = 指向右下角的箭头 + 角标(明显不是刷新,语义是"归位") const ICON_REFRESH = ''; const ICON_RESET = ''; // 品牌图标:标题前面的日历标记。 // 折叠成小胶囊以后,光靠「日历换算」四个字在 Steam 深色背景上根本认不出来, // 所以给它一个紫色渐变方块 + 柔光,折叠时再放大一圈并轻微呼吸。 const ICON_CALENDAR = ''; // 旧汇率键:1.6.0 起汇率取数交给内联的 FxCore(它写自己的 fxcore_* 键), // 这两个键只作为「写给没升级的愿望单侧边栏」的互救快照继续写入。 const FX_KEY = 'spc_fx_v1'; const FX_SNAPSHOT_KEY = 'spc_fx_snap_v1'; // 发售浏览器通知:去重记录 + 前瞻窗口 const NOTIFY_KEY = 'spc_release_notify_v1'; const NOTIFY_WINDOW_DAYS = 7; // 面板拖动后的位置 const PANEL_POS_KEY = 'spc_panel_pos_v1'; const DAY = 86400000; // 零小数位货币:标价里出现的点号全是千分位,剥掉就完事。 // 判断错了会差 1000 倍("Rp 92 999" 读成 92),所以按 Steam 实际写法逐个核过。 const ZERO_DEC = new Set(['JPY', 'KRW', 'VND', 'IDR', 'HUF', 'KZT', 'CLP', 'COP', 'PYG']); // ── 跨区域比价(复用 SGLV App Detail Library 的多区报价)──────────────── // 库里内置 9 个常见比价区,这里默认比 5 个,避免一次开面板就上百个请求。 // 注意:Steam 已把土耳其 / 阿根廷改成美元结算(2026-10 实测 cc=tr / cc=ar // 都返回 USD),库里的币种校验会把它们整条丢掉 —— 所以默认列表里没有它们, // 面板上仍可手动勾选,只是多半白跑。 const REGION_META = { CN: { name: '中国', flag: '🇨🇳', currency: 'CNY' }, US: { name: '美国', flag: '🇺🇸', currency: 'USD' }, TR: { name: '土耳其', flag: '🇹🇷', currency: 'TRY' }, AR: { name: '阿根廷', flag: '🇦🇷', currency: 'ARS' }, RU: { name: '俄罗斯', flag: '🇷🇺', currency: 'RUB' }, IN: { name: '印度', flag: '🇮🇳', currency: 'INR' }, BR: { name: '巴西', flag: '🇧🇷', currency: 'BRL' }, UA: { name: '乌克兰', flag: '🇺🇦', currency: 'UAH' }, KZ: { name: '哈萨克斯坦', flag: '🇰🇿', currency: 'KZT' }, }; const ITAD_BASE = 'https://api.isthereanydeal.com'; const ITAD_SHOP_STEAM = 61; // 与愿望单侧边栏、sglv 同款内置 key:用户没自备 key 也能查 ITAD const ITAD_FALLBACK_KEY = '58eb2271a08c1cf60bd812d701d09d5c694f502e'; const ITAD_KEY_STORE = 'spc_itad_key'; const CROSS_KEY = 'spc_cross_v1'; const REVIEW_KEY = 'spc_reviews_v1'; const CROSS_TTL = 12 * 60 * 60 * 1000; const REVIEW_TTL = 12 * 60 * 60 * 1000; const CROSS_CONCURRENCY = 3; const REVIEW_CONCURRENCY = 4; // ── 风险信息(试玩版 / 抢先体验 / 正式发售日)────────────────────────── // appdetails 不能批量(appids=1,2 → HTTP 400),只能一 appid 一请求, // 所以风险扫描走手动触发 + 12 小时缓存,跟跨区比价一样不自动跑。 const RISK_KEY = 'spc_risk_v1'; const RISK_TTL = 12 * 60 * 60 * 1000; const RISK_CONCURRENCY = 5; // ── 折扣雷达(即将推出 × 愿望单的当前折扣快照)────────────────────────── // 价格走 api/appdetails?filters=price_overview(不支持批量,一 appid 一请求), // 口碑走 api/appreviews,两条请求都过 steamStoreChannel 限流 + Promise.allSettled, // 一边挂了另一边照样能出数。结果只存内存 Map(关页即失,不写 GM 缓存), // 失败款不进缓存,下次扫描自然重试。 const DEAL_CONCURRENCY = 3; const CLUSTER_MIN = 3; // 同月多少款算「扎堆」的硬下限 const CLUSTER_FACTOR = 1.5; // 还要明显高于其它月份(≥ 平均的 1.5 倍)才算扎堆 const EA_GAP_DAYS = 30; // 日历日期比首次发行日晚多少天,才判为疑似抢先体验 // 识别顺序:长符号在前,避免 $ 吃掉 HK$。¥ 在中文页当人民币,日文页当日元。 // langs 只给「一个符号对应多个币」的条目用(kr = SEK/NOK/DKK),按页面语言消歧; // 拿不到语言就不认,宁可不换算也不猜错。 const CURRENCIES = [ { id: 'HKD', cc: 'HK', label: '港币', symbol: 'HK$', tokens: ['HK$'] }, { id: 'TWD', cc: 'TW', label: '新台币', symbol: 'NT$', tokens: ['NT$'] }, { id: 'ARS', cc: 'AR', label: '阿根廷比索', symbol: 'ARS$', tokens: ['ARS$'] }, { id: 'CAD', cc: 'CA', label: '加元', symbol: 'CDN$', tokens: ['CDN$'] }, { id: 'AUD', cc: 'AU', label: '澳元', symbol: 'A$', tokens: ['A$'] }, { id: 'NZD', cc: 'NZ', label: '新西兰元', symbol: 'NZ$', tokens: ['NZ$'] }, { id: 'SGD', cc: 'SG', label: '新加坡元', symbol: 'S$', tokens: ['S$'] }, { id: 'BRL', cc: 'BR', label: '巴西雷亚尔', symbol: 'R$', tokens: ['R$'] }, { id: 'MXN', cc: 'MX', label: '墨西哥比索', symbol: 'Mex$', tokens: ['Mex$'] }, { id: 'INR', cc: 'IN', label: '印度卢比', symbol: '₹', tokens: ['₹'] }, { id: 'KRW', cc: 'KR', label: '韩元', symbol: '₩', tokens: ['₩'] }, { id: 'RUB', cc: 'RU', label: '俄罗斯卢布', symbol: '₽', tokens: ['₽', 'руб'] }, { id: 'TRY', cc: 'TR', label: '土耳其里拉', symbol: '₺', tokens: ['₺', 'TL'] }, { id: 'VND', cc: 'VN', label: '越南盾', symbol: '₫', tokens: ['₫'] }, { id: 'IDR', cc: 'ID', label: '印尼盾', symbol: 'Rp', tokens: ['Rp'] }, { id: 'PHP', cc: 'PH', label: '菲律宾比索', symbol: '₱', tokens: ['₱'] }, { id: 'THB', cc: 'TH', label: '泰铢', symbol: '฿', tokens: ['฿'] }, { id: 'MYR', cc: 'MY', label: '林吉特', symbol: 'RM', tokens: ['RM'] }, { id: 'UAH', cc: 'UA', label: '格里夫纳', symbol: '₴', tokens: ['₴'] }, { id: 'KZT', cc: 'KZ', label: '坚戈', symbol: '₸', tokens: ['₸'] }, { id: 'PLN', cc: 'PL', label: '波兰兹罗提', symbol: 'zł', tokens: ['zł'] }, { id: 'CZK', cc: 'CZ', label: '捷克克朗', symbol: 'Kč', tokens: ['Kč'] }, { id: 'HUF', cc: 'HU', label: '匈牙利福林', symbol: 'Ft', tokens: ['Ft'] }, { id: 'RON', cc: 'RO', label: '罗马尼亚列伊', symbol: 'lei', tokens: ['lei'] }, { id: 'CHF', cc: 'CH', label: '瑞士法郎', symbol: 'CHF', tokens: ['CHF'] }, { id: 'NOK', cc: 'NO', label: '挪威克朗', symbol: 'kr', tokens: ['kr'], langs: ['nb', 'nn', 'no'] }, { id: 'SEK', cc: 'SE', label: '瑞典克朗', symbol: 'kr', tokens: ['kr'], langs: ['sv'] }, { id: 'DKK', cc: 'DK', label: '丹麦克朗', symbol: 'kr', tokens: ['kr'], langs: ['da'] }, { id: 'ILS', cc: 'IL', label: '以色列新谢克尔', symbol: '₪', tokens: ['₪'] }, { id: 'AED', cc: 'AE', label: '阿联酋迪拉姆', symbol: 'AED', tokens: ['AED'] }, { id: 'SAR', cc: 'SA', label: '沙特里亚尔', symbol: 'SAR', tokens: ['SAR'] }, { id: 'EUR', cc: 'EU', label: '欧元', symbol: '€', tokens: ['€'] }, { id: 'GBP', cc: 'GB', label: '英镑', symbol: '£', tokens: ['£'] }, { id: 'JPY', cc: 'JP', label: '日元', symbol: 'JP¥', tokens: ['JP¥', 'JPY', '¥', '¥'] }, { id: 'CNY', cc: 'CN', label: '人民币', symbol: '¥', tokens: ['CN¥', 'CNY', '¥'] }, { id: 'USD', cc: 'US', label: '美元', symbol: '$', tokens: ['USD', '$'] }, ]; const CURRENCY_MAP = new Map(CURRENCIES.map(item => [item.id, item])); const FLAGS = { HKD: '', TWD: '', AUD: '', CAD: '', ARS: '', SGD: '', MXN: '', BRL: '', USD: '', CNY: '', JPY: '', EUR: '', GBP: '', KRW: '', RUB: '', TRY: '', UAH: '', INR: '', VND: '', IDR: '', PHP: '', KZT: '', THB: '', MYR: '', }; const curFlagHtml = (id, w = 18, h = 13, mr = 0) => { const svg = FLAGS[id] || ''; if (!svg) return ''; const gap = mr ? ';margin-right:' + mr + 'px' : ''; return '' + svg + ''; }; const PRESETS = [ { from: 'INR', to: 'CNY', label: '卢比 → 人民币' }, { from: 'USD', to: 'CNY', label: '美元 → 人民币' }, { from: 'TRY', to: 'CNY', label: '里拉 → 人民币' }, { from: 'ARS', to: 'CNY', label: '比索 → 人民币' }, { from: 'RUB', to: 'CNY', label: '卢布 → 人民币' }, ]; const STR = { zh: { title: '日历换算', collapse: '收起', expand: '展开', summary: '本页 {n} 款 · 愿望单 {w} · 有标价 {p}', summaryEmpty: '还没有读到日历卡片', fxDown: '汇率暂不可用,标价保持原货币', showFx: '显示本币换算', fxSource: '汇率来源', fxAuto: '自动', fxManualMode: '手动', fxRefresh: '刷新汇率', fxRefreshing: '正在刷新汇率…', fxRefreshOk: '汇率已刷新', fxRefreshFail: '汇率刷新失败,继续用旧汇率', fxManualCurrency: '币种', fxManualPer: '写法', fxManualUsd: '1 美元 = ? 币种', fxManualCur: '1 币种 = ? 美元', fxManualValue: '汇率', fxApply: '应用', fxRestoreAuto: '恢复自动', fxBadValue: '汇率得填个正数', fxApplied: '已按手动汇率换算', fxAutoRestored: '已恢复自动汇率', fxStatusAuto: '自动 · {source} · {age}', fxStatusSnapshot: '自动 · 接口不通,先用本地快照({age})', fxStatusManual: '手动 · 1 {from} = {value} {cur}(覆盖自动汇率)', fxStatusManualBad: '手动汇率还没填好,先用自动汇率', fxStatusDown: '汇率接口没通,也没有可用快照,标价保持原货币', fxStatusUsing: '当前用:{anchor}(商店标价口径)', fxAnchorSteam: 'Steam参考', fxAnchorSpot: '即期牌价', fxOk: ' ✓ ', fxUnavail: ' ✕ 不可用', fxRescued: ' ✓ 快照救援·', fxDateUnknown: '日期未知', fxStaleNote: '(陈旧数据已跳过)', fxRefreshSnap: '接口不通,已用快照兜底', notifyRelease: '愿望单近 7 天发售弹浏览器通知', notifyTitleToday: '{n} 款愿望单游戏今天发售', notifyTitleSoon: '{n} 款愿望单游戏将在 7 天内发售', notifyTitleMix: '愿望单发售提醒:今天 {a} 款,7 天内 {b} 款', notifyBodyMore: '…等 {n} 款', panelResetPos: '重置位置', refreshTab: '刷新本页数据', refreshTabFailed: '刷新失败,详情看控制台', home: '本币', wishOnly: '只看愿望单', wishMark: '标出愿望单', horizon: '即将推出只看近 {n} 天', tabBudget: '预算', tabAnalysis: '分析', tabDeals: '折扣', tabFx: '换算', budget: '愿望单预算', budgetCount: '{n} 款近期发售', budgetScope: '近期发售 · 有标价 · 不含已入库', budgetEmpty: '近期发售里,愿望单还没有带标价的游戏', budgetDirect: '标价已是{home},直接相加', budgetConverted: '{from}已换算成{home}', budgetPartial: '还有 {n} 款暂时换不出,其余已相加', budgetNeedFx: '汇率还没准备好,先按原货币分开加总', calc: '价格计算器', amount: '金额', from: '从', to: '到', rate: '1 {from} ≈ {value}', reverseRate: '1 {to} ≈ {value}', calcSource: '口径:{source} · {age}', calcCopy: '复制结果', calcCopied: '换算结果已复制', calcCopyFail: '复制失败,可以手动选中结果', calcSwap: '⇄ 反向', calcAmountPresets: '常用金额', advancedMore: '更多设置', fxAdvanced: '高级', fxAdvancedOpen: '汇率来源 / 手动汇率', fxBrief: '当前:{anchor} · {source} · {age}{extra}', fxBriefDown: '汇率不可用,标价保持原货币', fxStaleSkipped: '陈旧源已跳过 {n} 个', fxRescuedShort: '快照', fxFillCurrent: '填入当前汇率', fxFillFailed: '当前汇率没准备好,不能填入', fxCompare: '三锚点对照', fxCompareSteam: 'Steam 参考', fxCompareSpot: '即期牌价', fxCompareActive: '当前生效', fxCompareNone: '—', upcomingNote: '即将推出的格子上通常没有标价,换算出现在「过去一个月发行」。', approx: '≈', histLow: '史低', cross: '跨区比价', crossStart: '开始比价', crossRunning: '正在比价({done}/{total})', crossHint: '只统计近期发售、愿望单里且有标价的游戏', crossManual: '比价默认手动触发:点「开始比价」,或在设置里打开自动比价', crossNeedFx: '汇率还没准备好,跨区合计先不按本币显示', crossNoHome: '{n} 款在{home}区没取到报价,「省多少」只按取到的算', autoCross: '打开分析页时自动比价', autoReview: '打开分析页时自动读口碑', autoDeals: '打开折扣页时自动扫描折扣', regions: '比价区域', regionNote: '土耳其、阿根廷已改用美元结算,勾了也比不出真实差价', refreshData: '刷新数据', ageNow: '刚刚更新', ageMinutes: '{n} 分钟前更新', ageHours: '{n} 小时前更新', ageDays: '{n} 天前更新', crossNone: '这些区暂时都没取到报价', crossHome: '本区', crossSave: '省 {n}%', crossCheaper: '{n} 款比本区便宜', crossTotal: '都按最低价区买 ≈ {value}(比本区省 {save})', crossFailed: '{n} 款没取到价', review: '首发口碑', reviewStart: '读取口碑', reviewManual: '口碑默认手动读取:点「读取口碑」,或在设置里打开自动', reviewLoading: '正在读取好评率…', reviewNone: '还没有评测', reviewSummary: '平均 {avg}% 好评 · 好评(≥80%) {good}/{n} 款', reviewRecent: '近 30 天 {score}%', reviewCount: '{n} 条评测', lowAt: '已达史低', lowGap: '距史低 {n}%', cluster: '同月扎堆', clusterBadge: '扎堆', clusterHint: '同一个月扎堆发售,预算容易超', clusterNone: '暂时没有扎堆的月份', clusterCount: '{n} 款(愿望单 {w})', exportMd: '复制 Markdown', exportCsv: '下载 CSV', exportJson: '下载 JSON', exportDone: 'Markdown 已复制', exportCsvDone: 'CSV 已下载', exportJsonDone: 'JSON 已下载', exportEmpty: '清单是空的,没什么可导出', risk: '风险信息', riskScan: '扫描风险信息', riskRunning: '正在扫描({done}/{total})', riskHint: '读取试玩版、抢先体验与正式发售日', riskEmpty: '还没扫描风险信息', riskDemo: '有试玩版', riskEa: '疑似抢先体验', riskSoon: '未正式发售', riskUnreleased: '未发售', riskSummary: '已扫 {n} 款 · 有试玩版 {d} · 疑似抢先体验 {e} · 未正式发售 {s}', riskFailed: '{n} 款没取到,点「扫描风险信息」重试可补', riskFilterAll: '全部', riskFilterLabel: '按标签筛选', riskFilterEmpty: '这个标签下暂时没有条目', deals: '折扣雷达', dealsStart: '扫描折扣', dealsRunning: '正在扫描折扣({done}/{total})', dealsHint: '目标是「即将推出 × 愿望单」的游戏。点「扫描折扣」开始,设置里可开自动', dealsEmpty: '这批游戏暂时没有折扣', dealsSummary: '已扫 {n} 款 · 在折扣 {d} 款 · 平均 -{avg}%', dealsFailed: '{n} 款没取到,点「扫描折扣」重试可补', dealsScanFailed: '折扣没扫成:{msg}', dealsSortDiscount: '按折扣', dealsSortScore: '按好评', dealsSortPrice: '按价格', dealsShare: '分享卡片', dealsShared: '分享卡片已下载', dealsShareEmpty: '没有可分享的折扣行', dealsNoPrice: '暂无标价', // 评测摘要的中文叫法照 Steam 官方商店;没见过的描述原样透传 scoreOver: '好评如潮', scoreVery: '特别好评', scorePos: '好评', scoreMostly: '多半好评', scoreMixed: '褒贬不一', scoreMostlyNeg: '多半差评', scoreNeg: '差评', scoreVeryNeg: '特别差评', scoreOverNeg: '差评如潮', exportHeading: 'Steam 个人日历 · 愿望单预算清单', exportTime: '导出时间', exportHome: '本币', exportTotal: '合计', exportScope: '口径', exportColName: '游戏', exportColAppId: 'appId', exportColPrice: '标价', exportColHome: '本币价', exportColRegion: '最低区', exportColLowest: '最低价', exportColReview: '好评率', exportColReviews: '评测数', exportColDemo: '有试玩版', exportColEa: '疑似抢先体验', exportColSoon: '未正式发售', exportColRelease: '发售日', exportColLink: '商店链接', }, en: { title: 'Calendar FX', collapse: 'Hide', expand: 'Show', summary: '{n} on this page · {w} wishlisted · {p} priced', summaryEmpty: 'No calendar cards yet', fxDown: 'Exchange rates unavailable. Prices stay in the store currency.', showFx: 'Show converted prices', fxSource: 'Exchange rate source', fxAuto: 'Auto', fxManualMode: 'Manual', fxRefresh: 'Refresh rates', fxRefreshing: 'Refreshing rates…', fxRefreshOk: 'Rates refreshed', fxRefreshFail: 'Refresh failed, keeping the old rates', fxManualCurrency: 'Currency', fxManualPer: 'Format', fxManualUsd: '1 USD = ? CUR', fxManualCur: '1 CUR = ? USD', fxManualValue: 'Rate', fxApply: 'Apply', fxRestoreAuto: 'Back to auto', fxBadValue: 'Enter a positive rate', fxApplied: 'Manual rate applied', fxAutoRestored: 'Automatic rates restored', fxStatusAuto: 'Auto · {source} · {age}', fxStatusSnapshot: 'Auto · API unreachable, using the local snapshot ({age})', fxStatusManual: 'Manual · 1 {from} = {value} {cur} (overrides auto)', fxStatusManualBad: 'Manual rate is incomplete, using auto rates', fxStatusDown: 'No exchange rates and no snapshot, prices stay in the store currency', fxStatusUsing: 'Using: {anchor} (store price basis)', fxAnchorSteam: 'Steam ref', fxAnchorSpot: 'Spot', fxOk: ' ✓ ', fxUnavail: ' ✕ unavailable', fxRescued: ' ✓ snapshot·', fxDateUnknown: 'date n/a', fxStaleNote: ' (stale data skipped)', fxRefreshSnap: 'APIs unreachable, snapshot used', notifyRelease: 'Browser notification for wishlist releases within 7 days', notifyTitleToday: '{n} wishlist game(s) release today', notifyTitleSoon: '{n} wishlist game(s) release within 7 days', notifyTitleMix: 'Wishlist releases: {a} today, {b} within 7 days', notifyBodyMore: '…{n} games in total', panelResetPos: 'Reset position', refreshTab: 'Refresh this tab', refreshTabFailed: 'Refresh failed, see the console', home: 'Currency', wishOnly: 'Wishlist only', wishMark: 'Mark wishlist', horizon: 'Upcoming: next {n} days', tabBudget: 'Budget', tabAnalysis: 'Analysis', tabDeals: 'Deals', tabFx: 'Convert', budget: 'Wishlist budget', budgetCount: '{n} recent releases', budgetScope: 'Recent releases · priced · not owned', budgetEmpty: 'No priced wishlist games among recent releases', budgetDirect: 'Prices are already in {home}, summed directly', budgetConverted: '{from} converted into {home}', budgetPartial: '{n} could not be converted; the rest are included', budgetNeedFx: 'Exchange rates are not ready. Totals stay in each original currency', calc: 'Price calculator', amount: 'Amount', from: 'From', to: 'To', rate: '1 {from} ≈ {value}', reverseRate: '1 {to} ≈ {value}', calcSource: 'Basis: {source} · {age}', calcCopy: 'Copy result', calcCopied: 'Result copied', calcCopyFail: 'Copy failed; select the result manually', calcSwap: '⇄ Swap', calcAmountPresets: 'Quick amounts', advancedMore: 'More settings', fxAdvanced: 'Advanced', fxAdvancedOpen: 'Rate source / manual rate', fxBrief: 'Using: {anchor} · {source} · {age}{extra}', fxBriefDown: 'Rates unavailable; prices stay in the store currency', fxStaleSkipped: '{n} stale sources skipped', fxRescuedShort: 'snapshot', fxFillCurrent: 'Use current rate', fxFillFailed: 'No current rate to prefill', fxCompare: 'Rate anchors', fxCompareSteam: 'Steam ref', fxCompareSpot: 'Spot', fxCompareActive: 'In use', fxCompareNone: '—', upcomingNote: 'Upcoming tiles usually have no price. Conversion shows on last month’s releases.', approx: '≈', histLow: 'Low', cross: 'Region prices', crossStart: 'Compare', crossRunning: 'Comparing ({done}/{total})', crossHint: 'Only recent releases on your wishlist with a price are compared', crossManual: 'Region comparison is manual: click Compare, or enable it in settings', crossNeedFx: 'Exchange rates are not ready; totals stay in CNY for now', crossNoHome: '{n} games had no quote in {home}; savings cover the rest', autoCross: 'Compare regions automatically', autoReview: 'Read review scores automatically', autoDeals: 'Scan deals automatically on the Deals tab', regions: 'Regions', regionNote: 'Turkey and Argentina now bill in USD, so their quotes no longer show real savings', refreshData: 'Refresh data', ageNow: 'updated just now', ageMinutes: 'updated {n} min ago', ageHours: 'updated {n} h ago', ageDays: 'updated {n} d ago', crossNone: 'No quotes from these regions yet', crossHome: 'Home', crossSave: 'save {n}%', crossCheaper: '{n} games cheaper than home', crossTotal: 'Buying each at its lowest ≈ {value} (save {save} vs home)', crossFailed: '{n} games had no quote', review: 'First impressions', reviewStart: 'Read scores', reviewManual: 'Review scores are manual: click Read scores, or enable it in settings', reviewLoading: 'Reading review scores…', reviewNone: 'No reviews yet', reviewSummary: 'Average {avg}% positive · {good}/{n} at 80%+', reviewRecent: 'Last 30 days {score}%', reviewCount: '{n} reviews', lowAt: 'At low', lowGap: '{n}% above low', cluster: 'Same-month pileup', clusterBadge: 'Crowded', clusterHint: 'Many releases in one month can overrun your budget', clusterNone: 'No crowded months right now', clusterCount: '{n} games ({w} wishlisted)', exportMd: 'Copy Markdown', exportCsv: 'Download CSV', exportJson: 'Download JSON', exportDone: 'Markdown copied', exportCsvDone: 'CSV downloaded', exportJsonDone: 'JSON downloaded', exportEmpty: 'The list is empty, nothing to export', risk: 'Risk info', riskScan: 'Scan risk info', riskRunning: 'Scanning ({done}/{total})', riskHint: 'Reads demo availability, early access and the final release date', riskEmpty: 'Risk info not scanned yet', riskDemo: 'Demo available', riskEa: 'Likely Early Access', riskSoon: 'Not fully released', riskUnreleased: 'Unreleased', riskSummary: 'Scanned {n} · demos {d} · likely early access {e} · not fully released {s}', riskFailed: '{n} not fetched, click "Scan risk info" to retry', riskFilterAll: 'All', riskFilterLabel: 'Filter by tag', riskFilterEmpty: 'No entries under this tag', deals: 'Deal Radar', dealsStart: 'Scan deals', dealsRunning: 'Scanning deals ({done}/{total})', dealsHint: 'Target: coming soon × wishlist. Hit "Scan deals" to start, or enable auto-scan in settings', dealsEmpty: 'No discounts among these games right now', dealsSummary: '{n} scanned · {d} on discount · avg -{avg}%', dealsFailed: '{n} not fetched, click "Scan deals" to retry', dealsScanFailed: 'Deal scan failed: {msg}', dealsSortDiscount: 'By discount', dealsSortScore: 'By reviews', dealsSortPrice: 'By price', dealsShare: 'Share card', dealsShared: 'Share card downloaded', dealsShareEmpty: 'No deal rows to share', dealsNoPrice: 'No price', scoreOver: 'Overwhelmingly Positive', scoreVery: 'Very Positive', scorePos: 'Positive', scoreMostly: 'Mostly Positive', scoreMixed: 'Mixed', scoreMostlyNeg: 'Mostly Negative', scoreNeg: 'Negative', scoreVeryNeg: 'Very Negative', scoreOverNeg: 'Overwhelmingly Negative', exportHeading: 'Steam Personal Calendar · Wishlist budget', exportTime: 'Exported at', exportHome: 'Currency', exportTotal: 'Total', exportScope: 'Scope', exportColName: 'Game', exportColAppId: 'appId', exportColPrice: 'Price', exportColHome: 'In home currency', exportColRegion: 'Lowest region', exportColLowest: 'Lowest price', exportColReview: 'Positive', exportColReviews: 'Reviews', exportColDemo: 'Demo', exportColEa: 'Early Access', exportColSoon: 'Not released', exportColRelease: 'Release date', exportColLink: 'Store link', }, }; function localePack() { const lang = (document.documentElement.lang || '').toLowerCase(); return lang.startsWith('en') ? STR.en : STR.zh; } function t(key, vars) { const raw = localePack()[key] || STR.zh[key] || key; return String(raw).replace(/\{(\w+)\}/g, (_, name) => (vars && vars[name] != null ? vars[name] : '')); } const SETTINGS_VERSION = 3; // 页签顺序就是面板顶栏顺序。1.8.0 把「换算」放回第一个,也是默认首屏。 const TAB_IDS = ['fx', 'budget', 'analysis', 'deals']; const DEFAULTS = { v: SETTINGS_VERSION, home: 'CNY', showFx: true, highlightWishlist: true, wishlistOnly: false, horizonOn: false, horizonDays: 30, calcAmount: '1000', calcFrom: 'INR', calcTo: 'CNY', collapsed: false, // 1.8.0 起换算回到首屏。预算/分析/折扣仍各自成页,只是一打开先看到换算。 panelTab: 'fx', // 比价和口碑一开页就是几十个请求,1.4.0 起默认关,由用户显式打开 crossOn: false, crossRegions: ['CN', 'US', 'RU', 'IN', 'BR'], reviewOn: false, // 折扣雷达同理:一开就扫几十个官方接口,默认关,由用户显式打开 dealsOn: false, // 汇率来源:auto = 走公开接口;manual = 用户自己钉一个汇率(接口挂了就靠它) fxMode: 'auto', fxManual: { currency: 'CNY', per: 'usd', value: '' }, // 愿望单近 7 天发售的浏览器通知(每天最多一条汇总,可关) notifyRelease: true, // 风险信息按标签筛选:all / demo(有试玩版)/ ea(疑似抢先体验)/ soon(未正式发售) riskFilter: 'all', }; // 风险筛选只有这四个合法值;旧档或手改出来的垃圾值一律回落「全部」 const RISK_FILTERS = ['all', 'demo', 'ea', 'soon']; function normalizeRegions(list) { const codes = Object.keys(REGION_META); const picked = Array.isArray(list) ? list.filter(code => codes.indexOf(code) >= 0) : []; return picked.length ? picked : DEFAULTS.crossRegions.slice(); } // settings 里的嵌套对象不能直接共享 DEFAULTS 的引用,否则改手动汇率会连默认值一起改掉。 function normalizeSettings(next) { const merged = Object.assign({}, DEFAULTS, next || {}); merged.crossRegions = normalizeRegions(merged.crossRegions); merged.fxManual = Object.assign({}, DEFAULTS.fxManual, (next && next.fxManual) || {}); if (merged.fxManual.per !== 'cur') merged.fxManual.per = 'usd'; if (merged.fxMode !== 'manual') merged.fxMode = 'auto'; if (RISK_FILTERS.indexOf(merged.riskFilter) < 0) merged.riskFilter = 'all'; merged.notifyRelease = merged.notifyRelease !== false; return merged; } // 纯函数:旧档(v<3)一次性迁回「换算」首屏;v3+ 完全尊重用户自己选的页签。 // 非法页签一律兜底 fx,不让面板落到「四个页签全不亮」的状态。 function defaultPanelTab(saved) { const raw = saved && saved.panelTab; if (Number((saved && saved.v) || 1) < 3) return 'fx'; return TAB_IDS.indexOf(raw) >= 0 ? raw : 'fx'; } function loadSettings() { let raw = null; try { raw = GM_getValue(SETTINGS_KEY, null); } catch (e) { raw = null; } const saved = raw && typeof raw === 'object' ? raw : {}; const merged = normalizeSettings(saved); // 1.3.0 的默认值是「自动比价 + 自动口碑」,升级上来会把一次开页变成 // 上百个请求。旧档只迁移一次,之后就听用户自己的设置。 if (Number(saved.v || 1) < SETTINGS_VERSION) { merged.crossOn = DEFAULTS.crossOn; merged.reviewOn = DEFAULTS.reviewOn; merged.crossRegions = DEFAULTS.crossRegions.slice(); merged.v = SETTINGS_VERSION; } // 升级到 1.8.0 时把首屏从「预算」迁回「换算」,只迁这一次; // 之后用户切到哪一页就记住哪一页,不再被版本升级覆盖。 merged.panelTab = defaultPanelTab(saved); if (Number(saved.v || 1) < SETTINGS_VERSION) { try { GM_setValue(SETTINGS_KEY, merged); } catch (e) { /* ignore */ } } return merged; } function saveSettings(next) { const merged = normalizeSettings(Object.assign({}, next, { v: SETTINGS_VERSION })); try { GM_setValue(SETTINGS_KEY, merged); } catch (e) { /* ignore */ } return merged; } function gmFetch(url, timeout) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: timeout || 15000, anonymous: true, responseType: 'json', onload(resp) { if (resp.status !== 200) { reject(new Error('HTTP ' + resp.status)); return; } let data = resp.response; if (data == null && resp.responseText) data = resp.responseText; if (typeof data === 'string') { try { data = JSON.parse(data); } catch (e) { reject(e); return; } } resolve(data); }, onerror: () => reject(new Error('network')), ontimeout: () => reject(new Error('timeout')), }); }); } // ── 请求限流通道(借鉴 steam-market-enhancer 的 SmeRateChannel,精简版)────────── // 口碑/风险扫描一开就是几十个 store.steampowered.com 请求。worker 可以并发抢任务, // 但真正出网必须经过通道:同域请求间隔 ≥ gap;任何请求吃到 429,全通道冷却 cooldownMs, // 冷却结束才发下一个,避免连续撞限流把后面的请求全送掉。 function createRateChannel(opts) { const gap = (opts && opts.gap) || 220; const cooldownMs = (opts && opts.cooldown) || 30000; let lastSentAt = 0; let cooldownUntil = 0; let chain = Promise.resolve(); function waitTurn() { const wait = Math.max(0, lastSentAt + gap - Date.now(), cooldownUntil - Date.now()); return wait > 0 ? new Promise(resolve => setTimeout(resolve, wait)) : null; } function run(task) { const current = chain.then(async () => { const w = waitTurn(); if (w) await w; lastSentAt = Date.now(); try { return await task(); } catch (e) { // gmFetch 非 200 抛 'HTTP 429';命中就给整个通道降温 if (e && /429/.test(String(e.message || e))) cooldownUntil = Date.now() + cooldownMs; throw e; } }); // 链尾接住失败:一个任务 reject 不能把后面排队的任务全毒死 chain = current.catch(() => {}); return current; } return { run }; } // 口碑 appreviews 和风险 appdetails 同域,共用一条通道(两边也可能同时在跑)。 const steamStoreChannel = createRateChannel({ gap: 220, cooldown: 30000 }); // ==================== 汇率引擎接线(内联 FxCore:Steam 参考 + 即期牌价双锚点) ==================== // fxMem:引擎返回的双锚点原始状态;fx:面板/卡片换算读的视图模型。 // 商店标价对齐 Steam 参考汇率(AugmentedSteam 交叉表),该锚点缺失才退即期牌价; // 手动汇率叠在选中锚点的表上。陈旧牌价由 FxCore 在 payload 层拒绝(er-api 会给 2022 年数据)。 const fxEngine = (typeof window !== 'undefined' && window.FxCore) ? window.FxCore.create({ gmGet: (k, d) => GM_getValue(k, d), gmSet: (k, v) => GM_setValue(k, v), log: (...a) => console.warn('[SPC][FX]', ...a), }) : null; let fxMem = null; const fx = { table: null, rates: null, loadedAt: 0, error: '', source: '', manualError: '', anchor: '', steam: null, spot: null, warnings: [], }; // 纯函数:选当前生效锚点 —— Steam 参考优先,缺失才退即期。两个都没有就是 null(绝不伪造)。 function pickActiveEntry(mem) { if (!mem) return null; return mem.steam || mem.spot || null; } // 纯函数:双锚点状态模型,给状态行和测试用。 // row = {id, ok, rescued, source, rateDate, stale};stale = 该锚点有源返回了陈旧数据被跳过。 function buildFxStatusModel(mem, warnings) { const staleSet = {}; (warnings || []).forEach(w => { if (w && w.reason === 'stale' && w.anchor) staleSet[w.anchor] = true; }); const line = (id, entry) => { if (!entry) return { id, ok: false, stale: !!staleSet[id] }; return { id, ok: true, rescued: !!entry.rescued, source: entry.source || '', rateDate: entry.rateDate || 0, stale: !!staleSet[id], }; }; return { steam: line('steam', mem && mem.steam), spot: line('spot', mem && mem.spot) }; } // 旧键互救桥:FxCore 自己写 fxcore_* 键,但没升级的愿望单侧边栏还读 // spc_fx_v1 / spc_fx_snap_v1 / swe_fx_snap_v1。网络拿到新表后照老格式写三份,两边互救不断档。 function writeLegacyFxBridge(entry) { if (!entry || !entry.rates) return; const payload = { rates: entry.rates, ts: entry.ts || Date.now(), base: 'USD' }; try { GM_setValue(FX_KEY, payload); } catch (e) { /* ignore */ } try { GM_setValue(FX_SNAPSHOT_KEY, payload); } catch (e) { /* ignore */ } try { GM_setValue('swe_fx_snap_v1', payload); } catch (e) { /* ignore */ } } // 手动覆盖叠在选中锚点的表上。切模式时只重算这一层,不打接口。 function applyFxOverride() { const entry = pickActiveEntry(fxMem); fx.manualError = ''; if (!entry) { fx.table = null; fx.rates = null; return null; } fx.table = entry.rates; if (settings.fxMode === 'manual' && window.FxCore) { const applied = window.FxCore.applyManualRate(entry.rates, settings.fxManual); if (applied.ok) { fx.rates = applied.rates; return fx.rates; } // 手动值不合法就先用自动汇率,别拿错的数去改标价。 fx.manualError = applied.reason || 'bad'; fx.rates = entry.rates; return fx.rates; } fx.rates = entry.rates; return fx.rates; } async function ensureFx(force) { if (!fxEngine) { fx.error = 'fx'; fx.rates = null; return null; } const mem = await fxEngine.load(!!force); fxMem = mem; fx.steam = mem.steam; fx.spot = mem.spot; fx.warnings = Array.isArray(mem.warnings) ? mem.warnings : []; const entry = pickActiveEntry(mem); if (entry) { fx.anchor = mem.steam ? 'steam' : 'spot'; fx.source = entry.source || ''; fx.loadedAt = entry.ts || mem.ts || Date.now(); fx.error = ''; writeLegacyFxBridge(entry); } else { fx.anchor = ''; fx.source = ''; fx.error = 'fx'; } applyFxOverride(); return fx.rates; } // 旧调用名兼容(启动流程、手动应用入口都叫 loadRates)。 function loadRates(force) { return ensureFx(force); } const FX_SOURCE_LABELS = { cache: '本地缓存', augmented: 'Augmented Steam', frankfurter: 'Frankfurter (ECB)', jsdelivr: 'jsDelivr currency-api', erapi: 'open.er-api.com', }; function fxSourceLabel(source) { const id = source || fx.source; if (!id) return ''; if (id.indexOf('snapshot:') === 0) return id.slice('snapshot:'.length); return FX_SOURCE_LABELS[id] || id; } function fxDateShort(ms) { if (!ms) return t('fxDateUnknown'); const d = new Date(ms); return (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0'); } function anchorLineText(name, row) { if (!row.ok) return name + t('fxUnavail') + (row.stale ? t('fxStaleNote') : ''); const tag = row.rescued ? t('fxRescued') : t('fxOk'); return name + tag + fxSourceLabel(row.source) + ' · ' + fxDateShort(row.rateDate) + (row.stale ? t('fxStaleNote') : ''); } function fxStatusText() { if (!fx.table) return t('fxStatusDown'); if (settings.fxMode === 'manual') { if (fx.manualError) return t('fxStatusManualBad'); const cur = currencyById(settings.fxManual.currency); const perCur = settings.fxManual.per === 'cur'; const value = Number(settings.fxManual.value); const shown = Number.isFinite(value) ? value.toFixed(4).replace(/\.?0+$/, '') : ''; const zh = (document.documentElement.lang || '').toLowerCase().startsWith('zh'); return t('fxStatusManual', { from: perCur ? cur.label : (zh ? '美元' : 'USD'), value: shown, cur: perCur ? 'USD' : cur.label, }); } const model = buildFxStatusModel(fxMem, fx.warnings); const activeName = fx.anchor === 'spot' ? t('fxAnchorSpot') : t('fxAnchorSteam'); return t('fxStatusUsing', { anchor: activeName }) + '\n' + anchorLineText(t('fxAnchorSteam'), model.steam) + '\n' + anchorLineText(t('fxAnchorSpot'), model.spot); } // 提示条:借鉴 TM-JB 汇率脚本的 showNotification,只借用形态。 let toastTimer = 0; function toast(message) { if (!message) return; let el = document.querySelector('.spc-toast'); if (!el) { el = document.createElement('div'); el.className = 'spc-toast'; el.setAttribute('data-spc-root', '1'); document.documentElement.appendChild(el); } el.textContent = message; el.classList.add('is-on'); clearTimeout(toastTimer); toastTimer = setTimeout(() => { if (el) el.classList.remove('is-on'); }, 2200); } async function refreshRates() { toast(t('fxRefreshing')); const rates = await loadRates(true); const entry = pickActiveEntry(fxMem); if (rates && entry && !entry.rescued) toast(t('fxRefreshOk')); else if (entry) toast(t('fxRefreshSnap')); else toast(t('fxRefreshFail')); refresh(); return !!rates; } // 分析页的数据刷新:只重取已经跑过的那几块,不当场给没打开过的口碑/风险 // 也来一轮几十个请求。分析页自己的「刷新数据」按钮和标题栏的刷新按钮共用这里。 function refreshAnalysisData() { if (crossState.at || settings.crossOn || crossState.rows.size) startCrossCompare({ force: true }); if (reviewState.at || settings.reviewOn) startReviews(true); if (riskState.at) startRiskScan(true); } // 刷新「当前页签」的数据:每个页签刷的东西不一样,别一股脑全刷。 // 换算 / 预算 → 强刷汇率(本币列全靠它) // 分析 → 重跑比价 / 口碑 / 风险 // 折扣 → 整批重扫折扣雷达 // 四个页签第一步都重收一遍卡片:这页是 Steam 动态渲染的,DOM 变了才收得到新条目。 async function refreshActiveTab() { const tab = TAB_IDS.indexOf(settings.panelTab) >= 0 ? settings.panelTab : 'fx'; refresh(); if (tab === 'fx' || tab === 'budget') { await refreshRates(); return; } if (tab === 'analysis') { refreshAnalysisData(); return; } if (tab === 'deals') { startDealScan(true); return; } } // 标题栏刷新按钮:转圈 + 出错兜底,按钮本身别卡在 loading 状态。 async function runRefreshActiveTab(btn) { if (btn && btn.classList.contains('is-busy')) return; if (btn) btn.classList.add('is-busy'); try { await refreshActiveTab(); } catch (error) { console.warn('[SPC] 刷新当前页签失败', error); toast(t('refreshTabFailed')); } finally { if (btn) btn.classList.remove('is-busy'); } } async function applyManualRateSetting() { const value = Number(settings.fxManual.value); if (!settings.fxManual.currency || !Number.isFinite(value) || value <= 0) { toast(t('fxBadValue')); syncPanelInputs(); return false; } settings.fxMode = 'manual'; settings = saveSettings(settings); // 表还没拿到(比如刚开页接口就挂了)就取一次;取到就直接在表上覆盖,不用再打接口。 if (!fx.table) await loadRates(false); applyFxOverride(); syncPanelInputs(); refresh(); toast(t('fxApplied')); return true; } function restoreAutoRate() { settings.fxMode = 'auto'; settings = saveSettings(settings); applyFxOverride(); syncPanelInputs(); refresh(); toast(t('fxAutoRestored')); } // 借鉴 STEAM多国汇率双向换算 的「用当前汇率预填」:先按当前生效表算出同口径数值, // 再走既有的手动汇率应用链路。绝不把手动值写进自动缓存键(老脚本的坑), // 手动汇率的唯一真源仍是 settings.fxManual。 function fillCurrentManualRate() { if (!fx.table || !window.FxCore) { toast(t('fxFillFailed')); return false; } const cur = settings.fxManual.currency; const from = settings.fxManual.per === 'cur' ? cur : 'USD'; const to = settings.fxManual.per === 'cur' ? 'USD' : cur; const value = convertCurrency(1, from, to, fx.table); if (!(value > 0)) { toast(t('fxFillFailed')); return false; } settings.fxManual = Object.assign({}, settings.fxManual, { value: String(Number(value.toPrecision(6))) }); settings = saveSettings(settings); syncPanelInputs(); return applyManualRateSetting(); } // 换算统一走 FxCore(USD 枢纽表,方向语义在共享库里冻结)。 function convertCurrency(amount, fromId, toId, rates) { return window.FxCore ? window.FxCore.convert(amount, fromId, toId, rates) : null; } function currencyById(id) { return CURRENCY_MAP.get(id) || CURRENCY_MAP.get('CNY'); } // SGLV App Detail 库的 cny 字段恒为人民币等值。展示/导出前必须再换成用户选的本币, // 否则选美元当本币的人会看到「省 $88」其实省的是 88 元。 function cnyToHome(cny) { if (cny == null || !Number.isFinite(Number(cny))) return null; if (settings.home === 'CNY') return Number(cny); return fx.rates ? convertCurrency(Number(cny), 'CNY', settings.home, fx.rates) : null; } /** 换成本币后按本币精度取整;换不出来就吐 null,别把人民币数值当本币写出去 */ function homeMoney(cny) { const value = cnyToHome(cny); return value == null ? null : roundMoney(value, settings.home); } function formatMoney(amount, id) { const cur = currencyById(id); const digits = ZERO_DEC.has(id) ? 0 : 2; const body = Number(amount).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits, }); return cur.symbol + body; } function roundMoney(amount, id) { const digits = ZERO_DEC.has(id) ? 0 : 2; const factor = 10 ** digits; return Math.round(Number(amount) * factor) / factor; } // 两种写法都要吃:符号在前(₹ 509、$7.19)和符号在后(140.000₫、445 руб.、40,49 zł)。 // 数字体允许空格千分位(Rp 92 999、2 880₸),但只认普通空格 / NBSP / 窄 NBSP, // 不吃换行 —— 否则跨行会把两个价格粘成一个。 const MONEY_RE = /(?:NZ\$|NT\$|HK\$|ARS\$|CDN\$|Mex\$|A\$|S\$|R\$|JP¥|CN¥|Rp|RM|TL|CHF|AED|SAR|₹|₺|₫|₱|฿|₩|₴|₸|₪|€|£|¥|¥|₽|zł|Kč|Ft|lei|\$)\s*\d(?:[\d., \u00a0\u202f]*\d)?|\d(?:[\d., \u00a0\u202f]*\d)?\s*(?:руб\.?|zł|Kč|Ft|lei|kr|TL|CHF|₺|₫|₸|₴|₪|€|£|₽|Rp)/gi; function tokenCurrency(token) { const text = String(token || ''); const lang = (document.documentElement.lang || '').toLowerCase(); for (const cur of CURRENCIES) { if (cur.id === 'CNY' && lang.startsWith('ja')) continue; if (cur.id === 'JPY' && !lang.startsWith('ja') && text.indexOf('¥') >= 0 && text.indexOf('JP') < 0 && text.indexOf('JPY') < 0) continue; // 同一符号多币种(kr):页面语言对不上就跳过,猜错比不认更糟 if (cur.langs && !cur.langs.some(code => lang.startsWith(code))) continue; if (cur.tokens.some(item => text.indexOf(item) >= 0)) return cur.id; } return null; } function tokenAmount(token, id) { const raw = String(token).replace(/[\s\u00a0\u202f]/g, ''); if (ZERO_DEC.has(id)) { // 零小数位:点号、逗号一律是千分位 const digits = raw.replace(/[^\d]/g, ''); const whole = Number(digits); return digits && Number.isFinite(whole) ? whole : null; } let num = raw.replace(/[^\d.,]/g, ''); if (!num) return null; const lastComma = num.lastIndexOf(','); const lastDot = num.lastIndexOf('.'); if (lastComma >= 0 && lastDot >= 0) { // 两种分隔符都有:靠后的那个是小数点(1,234.56 或 1.234,56) if (lastComma > lastDot) num = num.replace(/\./g, '').replace(',', '.'); else num = num.replace(/,/g, ''); } else if (lastComma >= 0) { // 只有逗号:后接 1~2 位当小数点(49,90),否则当千分位(1,299) const tail = num.length - lastComma - 1; num = (tail === 1 || tail === 2) ? num.replace(',', '.') : num.replace(/,/g, ''); } else if (lastDot >= 0) { // 只有点号:后接 3 位就是千分位(ARS 的 1.999 = 1999), // 否则当小数点(9.99)。Steam 没有三位小数的标价。 const tail = num.length - lastDot - 1; if (tail === 3) num = num.replace(/\./g, ''); } const value = Number(num); return Number.isFinite(value) ? value : null; } // Steam 商店页偶有只给数字不给符号的写法。全局变量里若挂着三位字母币种码就用, // 挂的是数字 ID 一律不认 —— 映射表不稳,猜错会把金额整体算歪。 function pageCurrencyHint() { try { const host = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const info = host && host.g_rgWalletInfo; const code = info && (info.wallet_currency || info.currency); const text = typeof code === 'string' ? code.trim().toUpperCase() : ''; return /^[A-Z]{3}$/.test(text) && CURRENCY_MAP.has(text) ? text : ''; } catch (e) { return ''; } } // hint:整段文本认不出符号时的兜底币种(来自页面钱包信息) function parseMoneyTokens(text, hint) { const out = []; const source = String(text || ''); for (const match of source.matchAll(MONEY_RE)) { const currency = tokenCurrency(match[0]) || hint || null; const amount = tokenAmount(match[0], currency); if (currency && amount != null) out.push({ currency, amount }); } return out; } function priceSource(widget) { const label = widget.getAttribute('aria-label') || ''; if (label.trim()) return label; const clone = widget.cloneNode(true); clone.querySelectorAll('[data-spc-fx]').forEach(node => node.remove()); return clone.textContent || ''; } function parsePriceWidget(widget) { if (!widget) return null; const source = priceSource(widget); const tokens = parseMoneyTokens(source, pageCurrencyHint()); if (!tokens.length) return null; const discounted = widget.classList.contains('Discounted') || /%/.test(source); const pctMatch = source.match(/(\d+(?:\.\d+)?)\s*%/); const discountPct = pctMatch ? Number(pctMatch[1]) : 0; if (discounted && tokens.length >= 2) { return { currency: tokens[1].currency || tokens[0].currency, original: tokens[0].amount, final: tokens[1].amount, discountPct, }; } const own = tokens.find(token => token.currency) || tokens[0]; return { currency: own.currency, original: null, final: own.amount, discountPct: 0 }; } function monthIndex(name) { const key = String(name || '').toLowerCase().slice(0, 3); return { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }[key] || 0; } // 整段文本必须就是日期。中文「10月26日」,英文「Oct 26」或「26 Oct」,可带年份。 // loose=true 时额外接受「2027」「Q1 2027」「2027年第一季度」—— 只有接口的 // release_date 用得上;日历 DOM 里出现一个「2027」不该被当成日期格子。 function parseReleaseLabel(label, now, loose) { const text = String(label || '').trim(); if (loose) { const yearOnly = text.match(/^(\d{4})\s*年?$/); if (yearOnly) { const year = Number(yearOnly[1]); return { label: text, month: 1, day: 1, ts: new Date(year, 0, 1).getTime(), approx: 'year' }; } let q = 0; let qYear = 0; const qEn = text.match(/^Q([1-4])\s*,?\s*(\d{4})$/i) || text.match(/^(\d{4})\s*Q([1-4])$/i); if (qEn) { const qFirst = /^Q/i.test(text); q = Number(qFirst ? qEn[1] : qEn[2]); qYear = Number(qFirst ? qEn[2] : qEn[1]); } else { const qZh = text.match(/^(\d{4})\s*年\s*第?([一二三四1-4])季度$/); if (qZh) { qYear = Number(qZh[1]); q = { '一': 1, '二': 2, '三': 3, '四': 4 }[qZh[2]] || Number(qZh[2]); } } if (q >= 1 && q <= 4) { const quarter = (q - 1) * 3 + 1; return { label: text, month: quarter, day: 1, ts: new Date(qYear, quarter - 1, 1).getTime(), approx: 'quarter' }; } } let month = 0; let day = 0; let year = 0; let match = text.match(/^(\d{1,2})月(\d{1,2})日$/); if (match) { month = Number(match[1]); day = Number(match[2]); } else { match = text.match(/^([A-Za-z]+)\.?\s+(\d{1,2})(?:,?\s*(\d{4}))?$/); if (match && monthIndex(match[1])) { month = monthIndex(match[1]); day = Number(match[2]); year = match[3] ? Number(match[3]) : 0; } else { match = text.match(/^(\d{1,2})\s+([A-Za-z]+)\.?(?:,?\s*(\d{4}))?$/); if (match && monthIndex(match[2])) { day = Number(match[1]); month = monthIndex(match[2]); year = match[3] ? Number(match[3]) : 0; } } } if (!month || !day || month > 12 || day > 31) return null; const date = new Date(year || now.getFullYear(), month - 1, day); if (date.getMonth() !== month - 1) return null; date.setHours(0, 0, 0, 0); if (!year && date.getTime() < now.getTime() - 200 * DAY) date.setFullYear(date.getFullYear() + 1); return { label: text, month, day, ts: date.getTime() }; } function startOfDay(date) { const copy = new Date(date.getTime()); copy.setHours(0, 0, 0, 0); return copy.getTime(); } function appIdFromHref(href) { const match = String(href || '').match(/\/app\/(\d+)/); return match ? match[1] : ''; } function sectionFromHref(href) { const text = String(href || ''); if (text.indexOf('__lastmonth') >= 0) return 'lastmonth'; if (text.indexOf('__lastweek') >= 0) return 'lastweek'; if (text.indexOf('__calendar') >= 0) return 'upcoming'; return 'unknown'; } function isPureDate(el, now) { return !!(el && el.nodeType === 1 && el.childElementCount === 0 && parseReleaseLabel(el.textContent, now)); } // 日期标签只可能落在容器往下 2 层内(实测:天容器 > 日期 div), // 限制深度避免整页扫 div 变成 O(n²)。 function countDateLabels(el, now, depth) { if (!el || !el.children || depth > 2) return 0; let count = 0; for (const child of el.children) { if (isPureDate(child, now)) count += 1; else count += countDateLabels(child, now, depth + 1); } return count; } function dateLabelCount(el, now) { if (!el || !el.querySelectorAll) return 0; return countDateLabels(el, now, 0); } function isDayColumn(el, now) { if (!el || !el.children) return false; let dates = 0; Array.from(el.children).forEach(child => { if (isPureDate(child, now)) dates += 1; }); return dates === 1; } // 卡片归属哪一格:优先认「带日期的那一格」(实测天容器里直接子节点就是日期 div), // 认不到再退回卡片容器。统一成天容器,隐藏粒度才稳定。 function tileOf(anchor, now) { let node = anchor; let fallback = null; for (let i = 0; i < 12 && node && node.parentElement; i++) { if (node !== anchor && isDayColumn(node, now)) return node; const parent = node.parentElement; if (parent === document.body || parent === document.documentElement) break; const parentDates = dateLabelCount(parent, now); const nodeDates = dateLabelCount(node, now); if (parentDates > 1 && nodeDates <= 1) break; if (!fallback && parent.querySelectorAll('a[href*="/app/"]').length > 1 && nodeDates <= 1) { fallback = node; } node = parent; } return fallback || node || anchor; } function releaseNear(anchor, now) { let node = anchor; // 实测从卡片到日期要上溯 6~7 层(a → 卡片 → 列容器 → 天容器), // 留到 12 层抗 Steam 改结构。 for (let depth = 0; depth < 12 && node; depth++) { let prev = node.previousElementSibling; while (prev) { if (prev.childElementCount === 0) { const parsed = parseReleaseLabel(prev.textContent, now); if (parsed) return parsed; } prev = prev.previousElementSibling; } node = node.parentElement; } return null; } // 卡片是后续功能的统一入口。区域价、史低写进 extra,不要再改这个形状。 // // 实测 DOM(2026-10-25 个人日历页,zh-cn / INR 区): // · 卡片 = a[href*="/app/"][href*="snr=1_personalcalendar"](该页 114 张,唯一游戏 70) // href 尾段就是分区:__calendar 88 / __lastweek 10 / __lastmonth 16 // · 分区标题文案是「即将推出」「过去 7 天发行」「过去一个月发行」——别拿文案当判据 // · 价格在 .StoreSalePriceWidgetContainer:普通卡只有一个价格文本; // 打折卡带 aria-label「立省 20%。原价为 ₹1,034.00,折后为 ₹827.00。」→ 原价在前 // · 「已在愿望单中」是 svg + 文本;「在库中」是 在库中 + 文本 // · 即将推出一天的容器里,直接子节点就是「10月26日」这种日期 div // · 同一游戏在「即将推出」可能有两张卡(抢先体验 + 正式发行),按 appId 去重 function collectCards(now) { const anchors = document.querySelectorAll('a[href*="/app/"][href*="snr=1_personalcalendar"]'); const cards = []; anchors.forEach(anchor => { if (anchor.closest('[data-spc-root]')) return; const href = anchor.getAttribute('href') || ''; const appId = appIdFromHref(href); if (!appId) return; const section = sectionFromHref(href); const tile = tileOf(anchor, now); const priceRoot = (anchor.querySelector('.StoreSalePriceWidgetContainer') || tile.querySelector('.StoreSalePriceWidgetContainer')); const text = anchor.textContent || ''; const statusAlt = /^(已在愿望单中|在库中|On Wishlist|In Library)$/; const alts = Array.from(anchor.querySelectorAll('img')).map(img => (img.getAttribute('alt') || '').trim()); const name = alts.find(alt => alt && !statusAlt.test(alt)) || ''; cards.push({ appId, name, image: cardArt(anchor), href, section, release: section === 'upcoming' ? releaseNear(anchor, now) : null, flags: { wishlist: text.indexOf('已在愿望单中') >= 0 || text.indexOf('On Wishlist') >= 0, owned: text.indexOf('在库中') >= 0 || text.indexOf('In Library') >= 0, }, price: parsePriceWidget(priceRoot), anchor, tile, priceRoot, extra: {}, }); }); shareRelease(cards); shareFlags(cards); shareIdentity(cards); return cards; } function cardArt(anchor) { const imgs = Array.from(anchor.querySelectorAll('img')); let best = ''; let bestArea = 0; imgs.forEach(img => { const alt = (img.getAttribute('alt') || '').trim(); if (/^(已在愿望单中|在库中|On Wishlist|In Library)$/.test(alt)) return; const src = img.currentSrc || img.src || img.getAttribute('src') || ''; if (!src || src.indexOf('data:') === 0) return; const area = (img.naturalWidth || img.width || 1) * (img.naturalHeight || img.height || 1); if (!best || area > bestArea) { best = src; bestArea = area; } }); return best; } function shareIdentity(cards) { const best = new Map(); cards.forEach(card => { const prev = best.get(card.appId) || { name: '', image: '' }; if (card.name && card.name.length > prev.name.length) prev.name = card.name; if (!prev.image && card.image) prev.image = card.image; best.set(card.appId, prev); }); cards.forEach(card => { const meta = best.get(card.appId); if (!meta) return; if (meta.name) card.name = meta.name; if (meta.image) card.image = meta.image; }); } function shareFlags(cards) { const wish = new Set(); const owned = new Set(); cards.forEach(card => { if (card.flags.wishlist) wish.add(card.appId); if (card.flags.owned) owned.add(card.appId); }); cards.forEach(card => { if (wish.has(card.appId)) card.flags.wishlist = true; if (owned.has(card.appId)) card.flags.owned = true; }); } function shareRelease(cards) { const found = new Map(); cards.forEach(card => { if (card.section === 'upcoming' && card.release) found.set(card.appId, card.release); }); cards.forEach(card => { if (card.section === 'upcoming' && !card.release && found.has(card.appId)) card.release = found.get(card.appId); }); } function dayColumns(now) { const columns = []; const nodes = document.querySelectorAll('div'); nodes.forEach(el => { if (el.closest('[data-spc-root]')) return; if (el.childElementCount !== 0) return; const text = el.textContent || ''; if (text.length > 12) return; // 日期文本很短,先挡掉大部分 div const parsed = parseReleaseLabel(text, now); if (!parsed || !el.parentElement) return; columns.push({ el, column: el.parentElement, release: parsed }); }); return columns; } function setFlag(el, name, on) { if (!el) return; if (on) { if (el.getAttribute(name) !== '1') el.setAttribute(name, '1'); } else if (el.hasAttribute(name)) { el.removeAttribute(name); } } function applyView(cards, settings, now) { const today = startOfDay(now); const horizonEnd = today + Number(settings.horizonDays || 30) * DAY; const shouldHide = card => { if (settings.wishlistOnly && !card.flags.wishlist) return true; if (settings.horizonOn && card.section === 'upcoming' && card.release && card.release.ts > horizonEnd) return true; return false; }; // 一个格子(tile)可能装着当天多款游戏。整格隐藏会连坐别的游戏, // 所以只有这一格所有卡片都要藏时才藏整格,否则只藏具体那张卡。 const tileStats = new Map(); cards.forEach(card => { if (!card.tile) return; let stat = tileStats.get(card.tile); if (!stat) { stat = { total: 0, hidden: 0 }; tileStats.set(card.tile, stat); } stat.total += 1; if (shouldHide(card)) stat.hidden += 1; }); const hideNodes = new Set(); cards.forEach(card => { if (card.anchor.getAttribute('data-spc-app') !== card.appId) card.anchor.setAttribute('data-spc-app', card.appId); if (card.anchor.getAttribute('data-spc-section') !== card.section) card.anchor.setAttribute('data-spc-section', card.section); setFlag(card.anchor, 'data-spc-wish', !!(settings.highlightWishlist && card.flags.wishlist)); if (!shouldHide(card)) return; const stat = card.tile ? tileStats.get(card.tile) : null; if (stat && stat.hidden >= stat.total) hideNodes.add(card.tile); else hideNodes.add(card.anchor); }); if (settings.horizonOn) { dayColumns(now).forEach(day => { if (day.release.ts <= horizonEnd) return; const target = dateLabelCount(day.column, now) > 1 ? day.el : day.column; hideNodes.add(target); }); } document.querySelectorAll('[data-spc-hide]').forEach(el => { if (!hideNodes.has(el)) el.removeAttribute('data-spc-hide'); }); hideNodes.forEach(el => setFlag(el, 'data-spc-hide', true)); } function shownPrice(card) { // 卡片只认页面自己解析出来的标价。区域价 / 史低不进这里 —— // 它们放在 crossState / lowState,改一个币种不该动到预算口径。 return card.price || null; } function fxLabel(price, home, rates) { if (!price || price.final == null) return null; const final = convertCurrency(price.final, price.currency, home, rates); if (final == null) return null; const approx = t('approx'); let strike = ''; if (price.original != null && price.currency) { const original = convertCurrency(price.original, price.currency, home, rates); if (original != null && Math.abs(original - final) > 0.004) strike = formatMoney(original, home); } return { text: approx + ' ' + formatMoney(final, home), strike }; } function lowLine(card, settings) { // 1.3.0 这里读的 card.extra.low 从来没被写过,所以卡片上的「史低」 // 一次都没显示过。现在由 lowState 按 appid 供数(卡片每轮都会重建)。 const low = lowState.get(String(card.appId)) || (card.extra && card.extra.low); if (!low || !(Number(low.amount) > 0)) return ''; const price = shownPrice(card); const cur = low.currency || (price && price.currency) || settings.home; let text = t('histLow') + ' ' + formatMoney(low.amount, cur); if (settings.showFx && fx.rates && cur !== settings.home) { const converted = convertCurrency(low.amount, cur, settings.home, fx.rates); if (converted != null) text += ' ' + t('approx') + ' ' + formatMoney(converted, settings.home); } return text; } function annotationFor(card, settings) { const price = shownPrice(card); const label = settings.showFx && fx.rates ? fxLabel(price, settings.home, fx.rates) : null; const lowText = lowLine(card, settings); if ((!label || !label.text) && !lowText) return null; return { text: (label && label.text) || '', strike: (label && label.strike) || '', lowText }; } function applyFx(cards, settings) { cards.forEach(card => { const host = card.priceRoot || card.tile; if (!host) return; const note = annotationFor(card, settings); let node = Array.from(host.children).find(el => el.hasAttribute && el.hasAttribute('data-spc-fx')) || null; if (!note) { if (node) node.remove(); return; } const sig = note.strike + '|' + note.text + '|' + note.lowText; if (!node) { node = document.createElement('span'); node.className = 'spc-fx'; node.setAttribute('data-spc-fx', '1'); host.appendChild(node); } if (node.getAttribute('data-sig') === sig) return; node.setAttribute('data-sig', sig); node.replaceChildren(); if (note.strike) { const struck = document.createElement('s'); struck.textContent = note.strike; node.appendChild(struck); node.appendChild(document.createTextNode(' ')); } if (note.text) node.appendChild(document.createTextNode(note.text)); if (note.lowText) { const low = document.createElement('span'); low.className = 'spc-low'; low.textContent = note.lowText; node.appendChild(low); } }); } // 风险徽章画在卡片上:即将推出这一段才是「盲买」决策期, // 试玩版 / 疑似抢先体验 / 未正式发售都在这里才有用。 // 徽章挂在卡片锚点上(不是价格容器),因为即将推出没有价格容器, // 挂到日期格上会把同一天的所有卡片串成一条。 function riskBadges(card, now) { const out = []; const risk = riskState.rows.get(card.appId); if (risk) { if (risk.demos > 0) out.push({ text: t('riskDemo'), tone: 'good' }); if (risk.earlyAccess === true) out.push({ text: t('riskEa'), tone: 'mid' }); if (risk.comingSoon === true) out.push({ text: t('riskSoon'), tone: 'bad' }); } if (card.section === 'upcoming' && card.release && card.release.ts > now) { out.push({ text: t('riskUnreleased'), tone: 'mute' }); } return out; } function applyRisk(cards, now) { cards.forEach(card => { const host = card.anchor; if (!host) return; const badges = riskBadges(card, now); let node = Array.from(host.children).find(el => el.hasAttribute && el.hasAttribute('data-spc-risk')) || null; if (!badges.length) { if (node) node.remove(); return; } const sig = badges.map(item => item.text + ':' + item.tone).join('|'); if (!node) { node = document.createElement('span'); node.className = 'spc-risk'; node.setAttribute('data-spc-risk', '1'); host.appendChild(node); } if (node.getAttribute('data-sig') === sig) return; node.setAttribute('data-sig', sig); node.replaceChildren(); badges.forEach(item => { const badge = document.createElement('span'); badge.className = 'spc-badge spc-badge-' + item.tone; badge.textContent = item.text; node.appendChild(badge); }); }); } // 步骤按 order 执行。view=20,fx=80。扩展数据放在 crossState / lowState, // 不要塞进 card.extra —— 卡片每轮 refresh 都会重建,写进去会丢。 const steps = []; function ordered(list) { return list.slice().sort((a, b) => (a.order ?? 100) - (b.order ?? 100)); } function registerStep(step) { if (!step || !step.id) return; const at = steps.findIndex(item => item.id === step.id); if (at >= 0) steps.splice(at, 1, step); else steps.push(step); } registerStep({ id: 'view', order: 20, run: ctx => applyView(ctx.cards, ctx.settings, ctx.now) }); registerStep({ id: 'fx', order: 80, run: ctx => applyFx(ctx.cards, ctx.settings) }); registerStep({ id: 'risk', order: 85, run: ctx => applyRisk(ctx.cards, ctx.now) }); // 面板槽按 order 排列。summary=10,view=20,calc=40。后注册的区块会插进已打开的面板。 const slots = []; function mountSlots(replacedId) { if (!panelRoot) return; const body = panelRoot.querySelector('.spc-body'); if (!body) return; if (replacedId) { const old = body.querySelector(`[data-spc-slot="${replacedId}"]`); if (old) old.remove(); } ordered(slots).forEach(slot => { let section = body.querySelector(`[data-spc-slot="${slot.id}"]`); if (!section) { section = document.createElement('section'); section.setAttribute('data-spc-slot', slot.id); section.innerHTML = slot.mount(); if (slot.bind) slot.bind(section); } body.appendChild(section); }); } function registerSlot(slot) { if (!slot || !slot.id) return; const at = slots.findIndex(item => item.id === slot.id); if (at >= 0) slots.splice(at, 1, slot); else slots.push(slot); mountSlots(at >= 0 ? slot.id : ''); if (panelRoot) { const ctx = { cards: lastCards, settings, now: new Date(), fx, extra: {} }; if (slot.update) slot.update(ctx); } } let settings = loadSettings(); let panelRoot = null; let lastCards = []; const PICK_CARET = ''; function currencyPick(attr, selected) { const cur = currencyById(selected); return `
`; } function paintPick(el, id) { if (!el) return; const cur = currencyById(id); el.setAttribute('data-spc-value', cur.id); const btn = el.querySelector('.spc-pick-btn'); if (!btn) return; btn.innerHTML = curFlagHtml(cur.id, 18, 13, 6) + '' + cur.label + '' + PICK_CARET; } // 比价区域芯片:区域表是常量,勾选状态每次从 settings 重新同步, // 避免「改完设置面板还显示旧勾选」这种不一致。 function regionLabel(code) { const meta = REGION_META[code]; if (!meta) return code; const zh = (document.documentElement.lang || '').toLowerCase().startsWith('zh'); return meta.flag + ' ' + (zh ? meta.name : code); } function regionChipsHtml() { return Object.keys(REGION_META).map(code => { const on = (settings.crossRegions || []).indexOf(code) >= 0; return ``; }).join(''); } function syncRegionChips() { if (!panelRoot) return; const on = new Set(settings.crossRegions || []); panelRoot.querySelectorAll('[data-spc-region]').forEach(chip => { const active = on.has(chip.getAttribute('data-spc-region')); chip.classList.toggle('is-on', active); chip.setAttribute('aria-pressed', active ? 'true' : 'false'); }); } function summaryText(cards) { const games = new Map(); cards.forEach(card => { const prev = games.get(card.appId) || { wishlist: false, priced: false }; prev.wishlist = prev.wishlist || card.flags.wishlist; prev.priced = prev.priced || !!card.price; games.set(card.appId, prev); }); if (!games.size) return t('summaryEmpty'); let wish = 0; let priced = 0; games.forEach(item => { if (item.wishlist) wish += 1; if (item.priced) priced += 1; }); return t('summary', { n: games.size, w: wish, p: priced }); } // 计算器金额:允许直接粘贴商店价格文本(₹ 1,299 / 1.234,56 / 1,234.56 / 12,99 / 1 234)。 // 分隔符判据借鉴 steam-price-converter 的 extractAmount,但不照抄它「抓第一个数字」的写法: // 这里整段清理后再判,避免把价格旁边的其它数字当金额;解析不出来就返回 null,绝不硬凑 0。 function parseCalcAmount(raw) { let s = String(raw == null ? '' : raw).trim(); if (!s) return null; s = s.replace(/[^\d.,-]/g, ''); if (!s || !/\d/.test(s) || s.indexOf('-') >= 0) return null; const dots = (s.match(/\./g) || []).length; const commas = (s.match(/,/g) || []).length; if (dots && commas) { // 最后出现的那个分隔符是小数点:1.234,56 → 1234.56;1,234.56 → 1234.56 const decimal = s.lastIndexOf('.') > s.lastIndexOf(',') ? '.' : ','; const thousands = decimal === ',' ? '.' : ','; s = s.split(thousands).join('').replace(decimal, '.'); } else if (commas) { const parts = s.split(','); // 尾段 3 位或多个分组 → 千分位;1~2 位 → 小数 const grouped = parts.length > 2 || parts[parts.length - 1].length === 3; s = grouped ? parts.join('') : parts[0] + '.' + parts.slice(1).join(''); } else if (dots > 1) { const parts = s.split('.'); const grouped = parts[parts.length - 1].length === 3; s = grouped ? parts.join('') : parts.slice(0, -1).join('') + '.' + parts[parts.length - 1]; } const value = Number(s); return Number.isFinite(value) && value >= 0 ? value : null; } // 纯函数:交换换算方向,不动金额(金额仍是新方向的输入)。 function swapCalcPair(from, to) { return { from: to, to: from }; } // 纯函数:汇率摘要的数据模型,不碰 DOM,便于单测。 function fxBriefModel(state, now) { const s = state || {}; const warnings = Array.isArray(s.warnings) ? s.warnings : []; const loadedAt = Number(s.loadedAt) || 0; return { ok: !!s.table, anchor: s.anchor === 'spot' ? 'spot' : 'steam', source: s.source || '', loadedAt: loadedAt, ageMs: Math.max(0, (Number(now) || Date.now()) - loadedAt), stale: warnings.filter(w => w && w.reason === 'stale').length, rescued: !!((s.steam && s.steam.rescued) || (s.spot && s.spot.rescued)), manual: s.mode === 'manual' && !s.manualError, }; } function fxBriefState() { return fxBriefModel({ table: fx.table, anchor: fx.anchor, source: fx.source, loadedAt: fx.loadedAt, warnings: fx.warnings, steam: fx.steam, spot: fx.spot, mode: settings.fxMode, manualError: fx.manualError, }, Date.now()); } // 换算页首屏只需要一行摘要:锚点 / 来源 / 多久前更新 / 快照救援 / 陈旧跳过数。 // 双锚点明细仍在高级区的 fxStatus 里,一字未删。 function fxBriefText() { const m = fxBriefState(); if (!m.ok) return t('fxBriefDown'); const anchor = m.manual ? t('fxManualMode') : (m.anchor === 'spot' ? t('fxAnchorSpot') : t('fxAnchorSteam')); const source = m.manual ? t('fxManualMode') : fxSourceLabel(m.source); const extra = (m.rescued ? ' · ' + t('fxRescuedShort') : '') + (m.stale ? ' · ' + t('fxStaleSkipped', { n: m.stale }) : ''); return t('fxBrief', { anchor: anchor, source: source, age: ageText(m.loadedAt), extra: extra }); } function calcSourceText() { const m = fxBriefState(); if (!m.ok) return t('fxBriefDown'); const source = m.manual ? t('fxManualMode') : (m.anchor === 'spot' ? t('fxAnchorSpot') : t('fxAnchorSteam')); return t('calcSource', { source: source, age: ageText(m.loadedAt) }); } // 反向汇率单独一行:1 到币 ≈ ? 从币。走 USD 枢纽表反算,不拿正向结果取倒数硬凑。 function reverseRateText() { if (!fx.rates) return t('fxDown'); const value = convertCurrency(1, settings.calcTo, settings.calcFrom, fx.rates); if (value == null) return t('fxDown'); return t('reverseRate', { to: settings.calcTo, value: currencyById(settings.calcFrom).symbol + value.toFixed(value >= 1 ? 4 : 6), }); } // 三锚点对照(借鉴 steam-price-converter 的报价面板):Steam 参考 / 即期牌价 / 当前生效。 // 缺哪个就显示「—」,unknown≠0;手动汇率只影响「当前生效」这一行,不污染另外两张表。 function fxCompareRows(from, to) { const tables = [ { label: t('fxCompareSteam'), rates: fx.steam && fx.steam.rates }, { label: t('fxCompareSpot'), rates: fx.spot && fx.spot.rates }, { label: t('fxCompareActive'), rates: fx.rates }, ]; return tables.map(row => { const value = row.rates ? convertCurrency(1, from, to, row.rates) : null; return { label: row.label, text: value == null ? t('fxCompareNone') : currencyById(to).symbol + value.toFixed(value >= 1 ? 4 : 6), }; }); } function paintFxCompare() { if (!panelRoot) return; const head = panelRoot.querySelector('[data-spc-bind="fxCompareHead"]'); if (head) { const headText = '1 ' + settings.calcFrom + ' ≈ ? ' + settings.calcTo; if (head.textContent !== headText) head.textContent = headText; } const host = panelRoot.querySelector('[data-spc-bind="fxCompare"]'); if (!host) return; const rows = fxCompareRows(settings.calcFrom, settings.calcTo); const sig = rows.map(row => row.label + ':' + row.text).join('|'); if (host.getAttribute('data-sig') === sig) return; host.setAttribute('data-sig', sig); host.replaceChildren(); rows.forEach(row => { const line = document.createElement('div'); line.className = 'spc-fx-compare-row'; const name = document.createElement('span'); name.textContent = row.label; const value = document.createElement('b'); value.textContent = row.text; line.append(name, value); host.appendChild(line); }); } function rateText() { if (!fx.rates) return t('fxDown'); const value = convertCurrency(1, settings.calcFrom, settings.calcTo, fx.rates); if (value == null) return t('fxDown'); const digits = value >= 1 ? 4 : 6; const shown = currencyById(settings.calcTo).symbol + value.toFixed(digits); return t('rate', { from: settings.calcFrom, value: shown }); } function calcResult() { const amount = parseCalcAmount(settings.calcAmount); if (amount == null) return '—'; const value = convertCurrency(amount, settings.calcFrom, settings.calcTo, fx.rates); if (value == null) return t('fxDown'); return formatMoney(value, settings.calcTo); } // ── 跨区域比价 / 首发口碑:数据层 ──────────────────────────────────────── // 只处理预算清单(近期发售 + 愿望单 + 有标价 + 未入库)这一批。 // 即将推出的条目既没有价格也没有评测,比价和口碑都落不到它们身上。 const crossState = { rows: new Map(), loading: false, done: 0, total: 0, error: '', at: 0, regions: [] }; const reviewState = { rows: new Map(), loading: false, done: 0, total: 0, error: '', at: 0 }; // 折扣雷达的结果只放内存 Map(dealState.rows):不写 GM 缓存,避免半条 // appdetails/appreviews 的残缺数据落盘;sort 是展示层的排序口径(rankRows 用)。 const dealState = { rows: new Map(), loading: false, done: 0, total: 0, error: '', at: 0, sort: 'discount' }; // ITAD 史低按 appid 存这里:卡片对象每次 refresh 都会重建, // 写进 card.extra 下一轮就被冲掉,所以状态必须活在卡片外面。 const lowState = new Map(); function syncLowState(rows) { lowState.clear(); rows.forEach((rec, appid) => { if (!rec || !rec.lowest || !rec.lows) return; const low = rec.lows[rec.lowest.region]; if (!low || !(Number(low.amount) > 0)) return; lowState.set(String(appid), { amount: Number(low.amount), currency: low.currency || rec.lowest.currency, region: rec.lowest.region, }); }); } // 读缓存时把写入时间一起带出来:直接命中缓存时,面板上的「多久前更新」 // 要显示缓存的实际年龄,不能假装是刚刚取回来的。 function readCacheEntry(key, ttl) { try { const raw = GM_getValue(key, null); if (raw && typeof raw === 'object' && raw.at && raw.data && (Date.now() - raw.at) < ttl) { return { data: raw.data, at: raw.at }; } } catch (e) { /* ignore */ } return null; } function writeCache(key, data) { try { GM_setValue(key, { at: Date.now(), data }); } catch (e) { /* ignore */ } } // 缓存按 appid 对账:新进预算的游戏只补它自己,不用等 12 小时整批过期。 // cached 里的 null 是「取过但没报价」,也算有记录;retry 时才重取。 function reconcileCache(cached, ids, retry) { const base = new Map(); if (cached && typeof cached === 'object' && !Array.isArray(cached)) { Object.keys(cached).forEach(id => base.set(String(id), cached[id])); } const missing = ids .map(String) .filter(id => !base.has(id) || (retry && base.get(id) == null)); return { base, missing }; } function ageText(stamp) { if (!stamp) return ''; const minutes = Math.max(0, Math.round((Date.now() - stamp) / 60000)); if (minutes < 2) return t('ageNow'); if (minutes < 60) return t('ageMinutes', { n: minutes }); const hours = Math.round(minutes / 60); if (hours < 24) return t('ageHours', { n: hours }); return t('ageDays', { n: Math.round(hours / 24) }); } function getItadKey() { try { return String(GM_getValue(ITAD_KEY_STORE, '') || '').trim(); } catch (e) { return ''; } } function itadRequest(method, url, body) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, data: body == null ? undefined : JSON.stringify(body), headers: body == null ? {} : { 'Content-Type': 'application/json' }, timeout: 15000, onload(resp) { if (resp.status !== 200) { reject(new Error('HTTP ' + resp.status)); return; } let data = resp.response; if (data == null && resp.responseText) data = resp.responseText; if (typeof data === 'string') { try { data = JSON.parse(data); } catch (e) { reject(e); return; } } resolve(data); }, onerror: () => reject(new Error('network')), ontimeout: () => reject(new Error('timeout')), }); }); } function itadParseStoreLow(data) { const byUuid = {}; const list = Array.isArray(data) ? data : []; for (const row of list) { if (!row || !row.id) continue; const lows = Array.isArray(row.lows) ? row.lows : (row.low ? [row.low] : []); const pick = lows.find(item => item && item.shop && Number(item.shop.id) === ITAD_SHOP_STEAM) || lows[0]; const amount = Number(pick && pick.price && pick.price.amount); const currency = pick && pick.price && pick.price.currency; if (!pick || !Number.isFinite(amount) || amount <= 0 || !currency) { byUuid[row.id] = { none: true }; continue; } byUuid[row.id] = { amount, currency, cut: Number(pick.cut) || 0, date: pick.timestamp || '' }; } return byUuid; } // ITAD 各区 Steam 商店史低:appid → itad_id 查一次,再按国家批量取 storelow。 // 失败不阻塞比价(跨区现价照常显示),史低只是加分项。 async function fetchItadLows(appids, countries) { const key = getItadKey() || ITAD_FALLBACK_KEY; const list = Array.from(appids).map(String); if (!key || !list.length || !countries.length) return null; const uuids = {}; for (let i = 0; i < list.length; i += 200) { const chunk = list.slice(i, i + 200); const data = await itadRequest('POST', ITAD_BASE + '/lookup/id/shop/' + ITAD_SHOP_STEAM + '/v1?key=' + encodeURIComponent(key), chunk.map(id => 'app/' + id)); for (const id of chunk) { const uuid = data && data['app/' + id]; if (typeof uuid === 'string' && uuid) uuids[id] = uuid; } } const ids = Object.keys(uuids); if (!ids.length) return null; const out = {}; for (const cc of countries) { for (let i = 0; i < ids.length; i += 200) { const chunk = ids.slice(i, i + 200).map(id => uuids[id]); const data = await itadRequest('POST', ITAD_BASE + '/games/storelow/v2?key=' + encodeURIComponent(key) + '&country=' + encodeURIComponent(cc) + '&shops=' + ITAD_SHOP_STEAM, chunk); const parsed = itadParseStoreLow(data); for (const id of ids) { const rec = parsed[uuids[id]]; if (!rec || rec.none) continue; if (!out[id]) out[id] = {}; out[id][cc] = rec; } } } return out; } function crossRegions() { const list = (Array.isArray(settings.crossRegions) && settings.crossRegions.length ? settings.crossRegions : DEFAULTS.crossRegions).slice(); // 本区是「省多少」的基准,没勾也必须查,否则比价列表只能干瞪眼 const home = homeRegionCode(); if (home && list.indexOf(home) < 0) list.push(home); return list.map(code => String(code).toUpperCase()).filter(code => REGION_META[code]); } // 多区报价走 SGLV App Detail Library:它已经处理了 cc= 被 Steam 忽略的币种校验、 // 并发限流和缓存,这里只做结果归一。 // force:库的缓存键只有 appid,不含区域集合 —— 区域变了必须绕过它, // 否则读到的是别的区的旧报价。 async function loadCrossFor(appids, regions, onProgress, force) { const lib = (typeof window !== 'undefined' ? window.SGLVAppDetail : null); if (!lib || typeof lib.loadPrices !== 'function') throw new Error('SGLV App Detail 库未加载'); const out = new Map(); const list = Array.from(appids); let cursor = 0; const worker = async () => { for (;;) { const i = cursor++; if (i >= list.length) return; const appid = list[i]; try { const res = await lib.loadPrices(String(appid), { regions, useCache: !force, force: !!force, }); out.set(appid, normalizeCross(res, homeRegionCode())); } catch (e) { out.set(appid, null); } if (onProgress) onProgress(out.size, list.length); } }; await Promise.all(Array.from({ length: Math.min(CROSS_CONCURRENCY, list.length) }, worker)); return out; } // 「本区」= 用户选的本币对应的区,而不是 cookie 里的账号区。 // 两者经常不一致(账号在印度、本币设成人民币),用 cookie 会把省多少算反。 function homeRegionCode() { const cur = currencyById(settings.home); const codes = Object.keys(REGION_META); const hit = codes.find(code => REGION_META[code].currency === cur.id); return hit || ''; } function normalizeCross(res, homeCc) { if (!res) return null; const prices = (res.prices || []) .filter(p => p && !p.free && Number(p.price) > 0 && p.cny != null && Number.isFinite(Number(p.cny))) .map(p => ({ region: p.region, name: (REGION_META[p.region] || {}).name || p.region, flag: (REGION_META[p.region] || {}).flag || '', currency: p.currency, price: Number(p.price), cny: Number(p.cny), discount: Number(p.discount) || 0, })) .sort((a, b) => a.cny - b.cny); if (!prices.length) return null; const userRegion = res.userRegion || homeCc || 'CN'; // 优先用用户选的本币区;那边没报价才退回账号区 const home = prices.find(p => p.region === homeCc) || prices.find(p => p.region === userRegion) || null; const lowest = prices[0]; const savedCny = home ? home.cny - lowest.cny : null; const savedPct = home && home.cny > 0 ? Math.round((1 - lowest.cny / home.cny) * 100) : null; return { prices, home, lowest, savedCny, savedPct, userRegion }; } // 改本币只影响「哪个区算本区」,原始价都在 rec.prices 里,本地重算即可, // 不要为了换本币再去打一遍接口(库缓存键不含本币,force 还会击穿 24h 磁盘缓存)。 function rehomeCross() { if (!crossState.rows.size) return; const homeCc = homeRegionCode(); const next = new Map(); crossState.rows.forEach((rec, appid) => { if (!rec || !Array.isArray(rec.prices) || !rec.prices.length) { next.set(appid, rec); return; } const rebuilt = normalizeCross({ userRegion: rec.userRegion, prices: rec.prices }, homeCc); if (rebuilt) { rebuilt.lows = rec.lows; next.set(appid, rebuilt); } else { next.set(appid, rec); } }); crossState.rows = next; syncLowState(next); const regions = crossState.regions && crossState.regions.length ? crossState.regions : crossRegions(); writeCache(CROSS_KEY, { regions: regions.slice(), rows: Object.fromEntries(next) }); } // 好评率:Steam 官方 appreviews 摘要接口。未上市游戏返回 404,按「没评测」处理。 async function loadReviewsFor(appids, onProgress) { const out = new Map(); const list = Array.from(appids); let cursor = 0; const worker = async () => { for (;;) { const i = cursor++; if (i >= list.length) return; const appid = list[i]; try { const base = 'https://store.steampowered.com/appreviews/' + appid + '?json=1&purchase_type=all&num_per_page=0&language=all'; const settled = await Promise.allSettled([ steamStoreChannel.run(() => gmFetch(base)), steamStoreChannel.run(() => gmFetch(base + '&filter=recent')), ]); const allData = settled[0].status === 'fulfilled' ? settled[0].value : null; const recentData = settled[1].status === 'fulfilled' ? settled[1].value : null; const qs = allData && allData.query_summary; if (!qs || !qs.total_reviews) { out.set(appid, { empty: true }); } else { const rqs = recentData && recentData.query_summary; out.set(appid, { score: Math.round((Number(qs.total_positive) / Number(qs.total_reviews)) * 100), total: Number(qs.total_reviews), recentScore: rqs && rqs.total_reviews ? Math.round((Number(rqs.total_positive) / Number(rqs.total_reviews)) * 100) : null, recentTotal: rqs ? Number(rqs.total_reviews) || 0 : 0, desc: qs.review_score_desc || '', }); } } catch (e) { out.set(appid, { empty: true }); } if (onProgress) onProgress(out.size, list.length); } }; await Promise.all(Array.from({ length: Math.min(REVIEW_CONCURRENCY, list.length) }, worker)); return out; } // 口碑:手动(按钮)或自动(reviewOn)都走这一个入口。 // 缓存按 appid 对账,只补没取过的款;force 时整批重取。 function startReviews(force) { if (reviewState.loading) return; const rows = budgetRows(lastCards); if (!rows.length) return; const ids = rows.map(row => row.appId); const cached = force ? null : readCacheEntry(REVIEW_KEY, REVIEW_TTL); const { base, missing } = reconcileCache(cached && cached.data, ids, false); if (!missing.length) { reviewState.rows = base; reviewState.at = cached ? cached.at : Date.now(); reviewState.error = ''; repaint(); return; } reviewState.loading = true; reviewState.total = missing.length; reviewState.done = 0; reviewState.error = ''; repaint(); loadReviewsFor(missing, (done, total) => { reviewState.done = done; reviewState.total = total; repaint(); }).then(map => { map.forEach((rec, appid) => base.set(String(appid), rec)); reviewState.rows = base; reviewState.loading = false; reviewState.at = Date.now(); writeCache(REVIEW_KEY, Object.fromEntries(base)); repaint(); }).catch(() => { reviewState.loading = false; reviewState.error = 'review'; repaint(); }); } function ensureReviews(cards) { if (!settings.reviewOn) return; if (reviewState.loading || reviewState.error) return; const rows = budgetRows(cards); if (!rows.length) return; // 已经有结果、且没有新款 → 什么都不做(否则每次 DOM 变动都重算) if (reviewState.at && !rows.some(row => !reviewState.rows.has(String(row.appId)))) return; startReviews(false); } // 跨区比价:自动(crossOn)只在有新款时才补;按钮触发不看开关。 function ensureCross(rows) { if (!settings.crossOn) return; if (!rows || !rows.length) return; if (crossState.loading || crossState.error) return; if (crossState.at && !rows.some(row => !crossState.rows.has(String(row.appId)))) return; startCrossCompare(); } function startCrossCompare(options) { const opts = options || {}; if (crossState.loading) return; const rows = budgetRows(lastCards); if (!rows.length) return; const regions = crossRegions(); if (!regions.length) return; const ids = rows.map(row => String(row.appId)); const cached = opts.force ? null : readCacheEntry(CROSS_KEY, CROSS_TTL); const cachedRows = cached ? cached.data : null; const sameRegions = !!(cachedRows && Array.isArray(cachedRows.regions) && cachedRows.regions.join(',') === regions.join(',')); const { base, missing } = reconcileCache(sameRegions ? cachedRows.rows : null, ids, !!opts.retry); if (!missing.length) { crossState.rows = base; crossState.at = cached ? cached.at : Date.now(); crossState.error = ''; // 记住这批结果实际用了哪些区,改本币本地重算时不能把没查过的区写进缓存 crossState.regions = Array.isArray(cachedRows && cachedRows.regions) ? cachedRows.regions.slice() : regions.slice(); syncLowState(base); repaint(); return; } crossState.loading = true; crossState.done = 0; crossState.total = missing.length; crossState.error = ''; repaint(); // 区域集合变了就不能用库里的缓存(键不含区域),必须整批重取一次 loadCrossFor(missing, regions, (done, total) => { crossState.done = done; crossState.total = total; repaint(); }, opts.force || !sameRegions).then(map => { map.forEach((rec, appid) => base.set(String(appid), rec)); crossState.rows = base; crossState.loading = false; crossState.at = Date.now(); crossState.regions = regions.slice(); syncLowState(base); writeCache(CROSS_KEY, { regions, rows: Object.fromEntries(base) }); repaint(); // 史低是加分项:失败只记一笔,不影响上面的比价结果 fetchItadLows(ids, regions).then(lows => { if (!lows) return; base.forEach((rec, appid) => { if (!rec || !lows[appid]) return; rec.lows = lows[appid]; }); syncLowState(base); writeCache(CROSS_KEY, { regions, rows: Object.fromEntries(base) }); repaint(); }).catch(() => { /* 史低取不到就算了 */ }); }).catch(error => { crossState.loading = false; crossState.error = String((error && error.message) || error); repaint(); }); } // 在 slot.update 里被调用时不能同步 refresh(会递归),所以统一走这个闸门 function repaint() { if (!applying) refresh(); } // ── 风险信息:试玩版 / 疑似抢先体验 / 未正式发售 ──────────────────────── // 数据源是 Steam 官方 api/appdetails(免鉴权)。实测: // · 不支持批量:appids=1,2,3 → HTTP 400;重复 appids 参数只认最后一个 // → 只能一 appid 一请求,70 款并发 5 大约 5 秒,所以走手动触发 + 12h 缓存 // · filters 参数会把 demos 过滤掉,用全量请求 // · demos 覆盖不全(70 款里 38 款有),所以只能标「有试玩版」,不能标「没有」 // · 没有 is_early_access 字段,categories 里也没有抢先体验分类 // (category 36 是 Online PvP,不是抢先体验,别踩) // // 抢先体验只能推断:日历「即将推出」给的是未来某天,而 appdetails 记录的首次 // 发行日更早,说明这是抢先体验首测之后的又一次发售。 // 实测 44 个即将推出条目里命中 8 个,与商店页的抢先体验标签交叉验证 8/8 一致; // 漏报的是「一上来就以抢先体验首发」的游戏(appdetails 首发日也在未来)。 // 所以措辞统一用「疑似」。 const riskState = { rows: new Map(), loading: false, done: 0, total: 0, error: '', at: 0, failed: 0 }; function uniqueAppIds(cards) { const seen = new Set(); const out = []; cards.forEach(card => { if (!card.appId || seen.has(card.appId)) return; seen.add(card.appId); out.push(card.appId); }); return out; } // appdetails 的 release_date.date 形如 "Dec 1, 2026",也可能只有年份 // ("2027")或季度("Q1 2027")。宽松解析只用在接口数据上, // 日历 DOM 那边仍然只认完整日期,免得把「2027」当成日期格子。 function detailTs(dateText, now) { const parsed = parseReleaseLabel(dateText, now, true); return parsed ? { ts: parsed.ts, approx: parsed.approx || 'day' } : { ts: null, approx: 'day' }; } async function loadRiskFor(appids, onProgress) { const out = new Map(); const list = Array.from(appids); let cursor = 0; const worker = async () => { for (;;) { const i = cursor++; if (i >= list.length) return; const appid = list[i]; try { const url = 'https://store.steampowered.com/api/appdetails?appids=' + encodeURIComponent(appid) + '&l=english&cc=us'; const json = await steamStoreChannel.run(() => gmFetch(url)); const entry = json && json[appid]; const data = entry && entry.success ? entry.data : null; if (!data) { out.set(appid, { empty: true }); } else { const demos = Array.isArray(data.demos) ? data.demos : []; const rel = data.release_date || {}; const stamp = detailTs(rel.date, new Date()); out.set(appid, { name: data.name || '', demos: demos.length, demoIds: demos.map(item => item && item.appid).filter(Boolean), releaseDate: rel.date || '', comingSoon: !!rel.coming_soon, releaseTs: stamp.ts, releaseApprox: stamp.approx, }); } } catch (e) { // 429/超时/断网是非权威失败:标 failed 不进缓存,下次扫描重试; // empty 只留给「接口正常返回但没有数据」,两种含义不能混。 out.set(appid, { failed: true }); } if (onProgress) onProgress(out.size, list.length); } }; await Promise.all(Array.from({ length: Math.min(RISK_CONCURRENCY, list.length) }, worker)); return out; } // 每个 appId 在「即将推出」里出现的最早日期 function upcomingEarliest(cards) { const map = new Map(); cards.forEach(card => { if (card.section !== 'upcoming' || !card.release) return; const prev = map.get(card.appId); if (prev == null || card.release.ts < prev) map.set(card.appId, card.release.ts); }); return map; } // 疑似抢先体验:日历日期比 appdetails 记录的首次发行日晚 EA_GAP_DAYS 天以上。 // 发行日只有年份/季度时不做判断 —— 拿 1 月 1 日当发行日会把正常游戏误标成抢先体验。 function judgeEarlyAccess(detail, calendarTs) { if (!detail || detail.empty || detail.releaseTs == null || calendarTs == null) return null; if (detail.releaseApprox && detail.releaseApprox !== 'day') return null; return Math.round((calendarTs - detail.releaseTs) / DAY) >= EA_GAP_DAYS; } function riskRecord(detail, calendarTs) { const base = detail && !detail.empty ? detail : null; return { name: base ? base.name : '', demos: base ? base.demos : 0, demoIds: base ? base.demoIds : [], releaseDate: base ? base.releaseDate : '', comingSoon: base ? base.comingSoon : false, releaseTs: base ? base.releaseTs : null, releaseApprox: base ? (base.releaseApprox || 'day') : 'day', earlyAccess: judgeEarlyAccess(base, calendarTs), }; } // 风险扫描手动触发:请求量比口碑大,不自动跑。 // 缓存按 appid 对账(1.3.0 的命中判断把 rows 当数组比,永远不命中, // 结果每次点都是全量重扫 —— 这里一并修掉)。 function startRiskScan(force) { if (riskState.loading) return; const ids = uniqueAppIds(lastCards).map(String); if (!ids.length) return; const cached = force ? null : readCacheEntry(RISK_KEY, RISK_TTL); const cacheRows = cached && cached.data && cached.data.rows && typeof cached.data.rows === 'object' ? cached.data.rows : null; const { base, missing } = reconcileCache(cacheRows, ids, false); if (!missing.length) { riskState.rows = base; riskState.failed = 0; riskState.at = cached ? cached.at : Date.now(); riskState.error = ''; repaint(); return; } riskState.loading = true; riskState.done = 0; riskState.total = missing.length; riskState.error = ''; riskState.failed = 0; repaint(); const calendar = upcomingEarliest(lastCards); loadRiskFor(missing, (done, total) => { riskState.done = done; riskState.total = total; repaint(); }).then(map => { let failed = 0; map.forEach((detail, appid) => { // 失败款不进 rows 也不进缓存:下次扫描按 appid 对账时会自然重试 if (detail && detail.failed) { failed += 1; return; } base.set(String(appid), riskRecord(detail, calendar.get(appid))); }); const rows = Object.fromEntries(base); const cacheIds = Object.keys(rows); riskState.rows = base; riskState.failed = failed; riskState.loading = false; riskState.at = Date.now(); writeCache(RISK_KEY, { ids: cacheIds, rows }); repaint(); }).catch(error => { riskState.loading = false; riskState.error = String((error && error.message) || error); repaint(); }); } // ── 折扣雷达:即将推出 × 愿望单 ───────────────────────────────────────── // 目标口径来自 deal-radar 的 pickWishlistTargets(upcoming × wishlist,同 appId // 去重、最多 30 款)。取数一 appid 两条并联(appdetails 不支持批量,一个一条): // · appdetails?filters=price_overview —— 官方现价与折扣 // · appreviews?json=1 —— 好评摘要 // Promise.allSettled 让一边挂了另一边照样出数;两条全挂才算失败,失败款不进 // 内存缓存(dealState.rows),下次扫描自然重试。不写 GM 缓存:半条价格/口碑的 // 残缺数据不该落盘,关页重扫更干净。请求全走 steamStoreChannel 限流。 const DealRadar = (typeof window !== 'undefined' && window.DealRadar) ? window.DealRadar : null; function dealTargets(cards) { return DealRadar && cards ? DealRadar.pickWishlistTargets(cards) : []; } // 一次 appdetails + appreviews 的响应合并成一条缓存记录(deal-radar 行形状)。 // 两条响应都解析不出东西才返回 null —— 调用方按失败处理,不进缓存。 function dealRecord(target, detailJson, reviewJson) { const deal = DealRadar.parseAppdetails(detailJson); const review = DealRadar.parseAppreviews(reviewJson); if (!deal && !review) return null; return DealRadar.buildRow(target, deal || undefined, review || undefined); } // Steam 英文评测摘要 → 本币文案,叫法照官方商店;没见过的描述原样透传, // 空描述按「还没有评测」处理。双语映射放脚本侧是 deal-radar 的既定分工。 const SCORE_DESC_KEYS = { 'Overwhelmingly Positive': 'scoreOver', 'Very Positive': 'scoreVery', 'Positive': 'scorePos', 'Mostly Positive': 'scoreMostly', 'Mixed': 'scoreMixed', 'Mostly Negative': 'scoreMostlyNeg', 'Negative': 'scoreNeg', 'Very Negative': 'scoreVeryNeg', 'Overwhelmingly Negative': 'scoreOverNeg', }; function scoreDescText(desc) { const text = String(desc || ''); const key = SCORE_DESC_KEYS[text]; return key ? t(key) : (text || t('reviewNone')); } // 汇总视图:items 是「已取到记录」的行,挂上换算好的本币价 homeAmount // (priceFinal 是「分」,先除 100 再换汇;换不出汇率时留 null,界面退回原币) // 与好评标签。missing = 没有记录的款(没扫过或两条请求全挂)。 function dealView(targets) { const items = []; let missing = 0; let discounted = 0; let discountSum = 0; (targets || []).forEach(target => { const rec = dealState.rows.get(String(target.appId)); if (!rec) { missing += 1; return; } const amount = rec.priceFinal == null ? null : rec.priceFinal / 100; const homeAmount = amount == null ? null : convertCurrency(amount, rec.ccy, settings.home, fx.rates); if (rec.discounted) { discounted += 1; discountSum += rec.discountPct || 0; } items.push(Object.assign({}, rec, { homeAmount, reviewLabel: scoreDescText(rec.scoreDesc), })); }); return { items, discounted, missing, avgDiscount: discounted ? Math.round(discountSum / discounted) : 0, }; } // 排序走 deal-radar 的 rankRows(返回新数组,不改原数组); // 展示口径记在 dealState.sort,面板里三个按钮切。 function dealSortItems(items) { return DealRadar.rankRows(items || [], dealState.sort); } // 一批目标并发扫(DEAL_CONCURRENCY 个 worker 消费同一张单子), // 每个 appid 的两条请求都从 steamStoreChannel 走,天然限流。 async function loadDealsFor(targets, onProgress) { const out = new Map(); const list = targets.slice(); let cursor = 0; const worker = async () => { while (cursor < list.length) { const target = list[cursor]; cursor += 1; const appid = String(target.appId); const priceUrl = 'https://store.steampowered.com/api/appdetails?appids=' + encodeURIComponent(appid) + '&filters=price_overview'; const reviewUrl = 'https://store.steampowered.com/appreviews/' + encodeURIComponent(appid) + '?json=1&purchase_type=all&num_per_page=0&language=all'; const [priceRes, reviewRes] = await Promise.allSettled([ steamStoreChannel.run(() => gmFetch(priceUrl)), steamStoreChannel.run(() => gmFetch(reviewUrl)), ]); const rec = dealRecord( target, priceRes.status === 'fulfilled' ? priceRes.value : null, reviewRes.status === 'fulfilled' ? reviewRes.value : null, ); out.set(appid, rec || { failed: true }); if (onProgress) onProgress(out.size, list.length); } }; await Promise.all(Array.from({ length: Math.min(DEAL_CONCURRENCY, list.length) }, () => worker())); return out; } function startDealScan(force) { if (dealState.loading) return; const targets = dealTargets(lastCards); if (!targets.length) return; const missing = force ? targets : targets.filter(item => !dealState.rows.has(String(item.appId))); if (!missing.length) { dealState.error = ''; repaint(); return; } dealState.loading = true; dealState.done = 0; dealState.total = missing.length; dealState.error = ''; repaint(); loadDealsFor(missing, (done, total) => { dealState.done = done; dealState.total = total; repaint(); }).then(map => { map.forEach((rec, appid) => { if (!rec || rec.failed) return; dealState.rows.set(String(appid), rec); }); dealState.loading = false; dealState.at = Date.now(); dealState.error = ''; repaint(); }).catch(error => { dealState.loading = false; dealState.error = String((error && error.message) || error); repaint(); }); } // 自动扫描(「预算」页 dealsOn)与手动按钮共用的入口:已有结果且没有新款就 // 什么都不做;整批重扫只由「扫描折扣」按钮触发(价格随时在变,点了就要新的)。 function ensureDeals(cards) { if (!settings.dealsOn || dealState.loading) return; const targets = dealTargets(cards); if (!targets.length) return; if (dealState.at && !targets.some(item => !dealState.rows.has(String(item.appId)))) return; startDealScan(false); } function paintDeals(host, cards) { if (!host) return; host.replaceChildren(); const note = (text) => { const p = document.createElement('p'); p.className = 'spc-cross-summary'; p.textContent = text; host.appendChild(p); }; if (dealState.loading) { note(t('dealsRunning', { done: dealState.done, total: dealState.total })); return; } if (dealState.error) { note(t('dealsScanFailed', { msg: dealState.error })); return; } if (!dealState.at) { note(t('dealsHint')); return; } const view = dealView(dealTargets(cards)); const stamp = ageText(dealState.at); if (!view.items.length || !view.discounted) { note(t('dealsEmpty') + ' · ' + stamp); } else { note(t('dealsSummary', { n: view.items.length, d: view.discounted, avg: view.avgDiscount, }) + ' · ' + stamp); } if (view.missing > 0) note(t('dealsFailed', { n: view.missing })); dealSortItems(view.items).forEach(item => { const line = document.createElement('div'); line.className = 'spc-cross-row'; const name = document.createElement('span'); name.className = 'spc-cross-name'; name.textContent = item.name || item.appId; line.appendChild(name); if (item.discounted) { const badge = document.createElement('span'); badge.className = 'spc-badge spc-badge-good'; badge.textContent = '-' + item.discountPct + '%'; line.appendChild(badge); } const price = document.createElement('span'); price.className = 'spc-cross-region'; if (item.homeAmount != null) { price.textContent = formatMoney(roundMoney(item.homeAmount, settings.home), settings.home); } else if (item.priceFinal != null) { price.textContent = formatMoney(item.priceFinal / 100, item.ccy); } else { price.textContent = t('dealsNoPrice'); } line.appendChild(price); const review = document.createElement('span'); review.className = 'spc-cross-save'; review.textContent = item.reviewLabel + (item.totalReviews > 0 ? ' · ' + t('reviewCount', { n: item.totalReviews }) : ''); line.appendChild(review); host.appendChild(line); }); } // 分享卡片:1920×1080 高清 PNG(#1b2838 → #2a475e 渐变)。文本布局走 deal-radar // 的 cardLayout(纯数据),现价换成换算好的本币价;这里只负责画到 canvas 并下载。 function shareDealCard(items) { const rows = (items || []).map(item => Object.assign({}, item, { priceFinal: item.homeAmount == null ? item.priceFinal : Math.round(item.homeAmount * 100), ccy: item.homeAmount == null ? item.ccy : settings.home, })); const discounted = rows.filter(item => item.discounted); const avg = discounted.length ? Math.round(discounted.reduce((sum, item) => sum + (item.discountPct || 0), 0) / discounted.length) : 0; const layout = DealRadar.cardLayout(rows, { title: t('deals'), dateText: exportStamp(), footer: discounted.length ? t('dealsSummary', { n: rows.length, d: discounted.length, avg }) : t('dealsEmpty'), scoreDescText, }); const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080; const ctx2d = canvas.getContext && canvas.getContext('2d'); if (!ctx2d) return; const bg = ctx2d.createLinearGradient(0, 0, 1920, 1080); bg.addColorStop(0, '#1b2838'); bg.addColorStop(1, '#2a475e'); ctx2d.fillStyle = bg; ctx2d.fillRect(0, 0, 1920, 1080); ctx2d.fillStyle = '#ffffff'; ctx2d.font = 'bold 72px sans-serif'; ctx2d.fillText(layout.title, 120, 160); ctx2d.fillStyle = '#b8c6db'; ctx2d.font = '36px sans-serif'; ctx2d.fillText(layout.dateText, 120, 232); let y = 380; layout.rows.forEach(row => { ctx2d.fillStyle = '#ffffff'; ctx2d.font = '44px sans-serif'; ctx2d.fillText(row.name, 120, y); if (row.discountText) { ctx2d.fillStyle = '#a4d037'; ctx2d.font = 'bold 44px sans-serif'; ctx2d.fillText(row.discountText, 920, y); } ctx2d.fillStyle = '#ffffff'; ctx2d.font = '44px sans-serif'; ctx2d.fillText(row.priceText, 1140, y); ctx2d.fillStyle = '#b8c6db'; ctx2d.font = '36px sans-serif'; ctx2d.fillText(row.scoreText, 1500, y); y += 84; }); ctx2d.fillStyle = '#9fb3c8'; ctx2d.font = '32px sans-serif'; ctx2d.fillText(layout.footer, 120, 1010); const link = document.createElement('a'); link.href = canvas.toDataURL('image/png'); link.download = 'steam-deals-' + exportStamp() + '.png'; link.click(); } // ── 同月扎堆 ──────────────────────────────────────────────────────────── // 日历页同一款游戏会出现两张卡(日视图 + 周视图),所以必须按 appId 去重, // 否则款数会整整翻倍。 function clusterRows(cards) { const groups = new Map(); cards.forEach(card => { if (card.section !== 'upcoming' || !card.release) return; const date = new Date(card.release.ts); const key = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0'); let group = groups.get(key); if (!group) { group = { key, year: date.getFullYear(), month: date.getMonth() + 1, apps: new Map() }; groups.set(key, group); } let item = group.apps.get(card.appId); if (!item) { item = { appId: card.appId, name: card.name || card.appId, ts: card.release.ts, wishlist: false }; group.apps.set(card.appId, item); } if (card.release.ts < item.ts) item.ts = card.release.ts; if (card.flags.wishlist) item.wishlist = true; if ((card.name || '').length > (item.name || '').length) item.name = card.name || item.name; }); const list = Array.from(groups.values()); list.forEach(group => { group.total = group.apps.size; group.wish = 0; group.names = []; group.apps.forEach(item => { if (item.wishlist) group.wish += 1; group.names.push(item.name); }); group.label = monthLabel(group.year, group.month); }); // 扎堆判据:既过硬下限(3 款),又明显高于其它月份。 // 只看绝对值没用 —— 实测该页 44 款落在 10/23/11 三个月, // 固定阈值会让每个月都报警,等于没报警。 const avg = list.length ? list.reduce((acc, group) => acc + group.total, 0) / list.length : 0; list.forEach(group => { group.crowded = group.total >= CLUSTER_MIN && group.total >= avg * CLUSTER_FACTOR; }); return list.sort((a, b) => a.key.localeCompare(b.key)); } function monthLabel(year, month) { const lang = (document.documentElement.lang || '').toLowerCase(); if (lang.startsWith('en')) { const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return names[month - 1] + ' ' + year; } return year + '年' + month + '月'; } // ── 清单导出(Markdown / CSV)─────────────────────────────────────────── function csvCell(value) { const text = value == null ? '' : String(value); if (/[",\n\r]/.test(text)) return '"' + text.replace(/"/g, '""') + '"'; return text; } function exportColumns() { return [ t('exportColName'), t('exportColAppId'), t('exportColPrice'), t('exportColHome'), t('exportColRegion'), t('exportColLowest'), t('exportColReview'), t('exportColReviews'), t('exportColDemo'), t('exportColEa'), t('exportColSoon'), t('exportColRelease'), ]; } function exportCells(row) { const cross = crossState.rows.get(row.appId); const rev = reviewState.rows.get(row.appId); const risk = riskState.rows.get(row.appId); return { name: row.label || '', appId: row.appId, price: row.amount == null ? '' : formatMoney(row.amount, row.currency), home: row.homeAmount == null ? '' : formatMoney(row.homeAmount, settings.home), region: cross && cross.lowest ? cross.lowest.name : '', lowest: cross && cross.lowest ? formatMoney(cross.lowest.price, cross.lowest.currency) : '', review: rev && !rev.empty && rev.total ? rev.score + '%' : '', reviews: rev && !rev.empty ? rev.total : '', demo: risk && risk.demos ? 'yes' : '', ea: risk && risk.earlyAccess ? 'yes' : '', soon: risk && risk.comingSoon ? 'yes' : '', release: risk ? (risk.releaseDate || '') : '', link: 'https://store.steampowered.com/app/' + row.appId, }; } function buildExportCsv(rows) { const lines = [exportColumns().concat([t('exportColLink')]).map(csvCell).join(',')]; rows.forEach(row => { const cell = exportCells(row); lines.push([ cell.name, cell.appId, cell.price, cell.home, cell.region, cell.lowest, cell.review, cell.reviews, cell.demo, cell.ea, cell.soon, cell.release, cell.link, ].map(csvCell).join(',')); }); // 带 BOM,Excel 打开中文才不会乱码 return '' + lines.join('\r\n'); } function buildExportMarkdown(rows, view) { const lines = []; lines.push('# ' + t('exportHeading')); lines.push(''); lines.push('- ' + t('exportTime') + ':' + new Date().toLocaleString()); lines.push('- ' + t('exportHome') + ':' + currencyById(settings.home).label + ' (' + settings.home + ')'); if (view && view.totalText) lines.push('- ' + t('exportTotal') + ':' + view.totalText); lines.push('- ' + t('exportScope') + ':' + t('budgetScope')); lines.push(''); const head = exportColumns(); lines.push('| ' + head.join(' | ') + ' |'); lines.push('| ' + head.map(() => '---').join(' | ') + ' |'); rows.forEach(row => { const cell = exportCells(row); const cells = [ cell.name, cell.appId, cell.price, cell.home, cell.region, cell.lowest, cell.review, cell.reviews, cell.demo, cell.ea, cell.soon, cell.release, ].map(value => String(value).replace(/\|/g, '\\|')); lines.push('| ' + cells.join(' | ') + ' |'); }); lines.push(''); return lines.join('\n'); } // JSON 导出给程序化分析用:保留原始数值 + 发售信息(发售日 / 抢先体验 / 试玩), // 文本列只做展示冗余,方便直接看。 function buildExportJson(rows, view) { const games = rows.map(row => { const cross = crossState.rows.get(row.appId); const rev = reviewState.rows.get(row.appId); const risk = riskState.rows.get(row.appId); return { appId: Number(row.appId) || row.appId, name: row.label || '', link: 'https://store.steampowered.com/app/' + row.appId, currency: row.currency, price: row.amount, priceText: formatMoney(row.amount, row.currency), homeCurrency: settings.home, homeAmount: row.homeAmount == null ? null : roundMoney(row.homeAmount, settings.home), homeAmountText: row.homeAmount == null ? null : formatMoney(row.homeAmount, settings.home), lowestRegion: cross && cross.lowest ? { region: cross.lowest.region, name: cross.lowest.name, currency: cross.lowest.currency, price: cross.lowest.price, homeAmount: homeMoney(cross.lowest.cny), } : null, savedHomeAmount: cross && cross.savedCny != null ? homeMoney(cross.savedCny) : null, savedPct: cross ? cross.savedPct : null, review: rev && !rev.empty && rev.total ? { score: rev.score, total: rev.total, recentScore: rev.recentScore != null ? rev.recentScore : null, desc: rev.desc || '', } : null, release: { date: risk ? risk.releaseDate : '', ts: risk && risk.releaseTs != null ? risk.releaseTs : null, earlyAccess: !!(risk && risk.earlyAccess === true), comingSoon: !!(risk && risk.comingSoon === true), demos: risk ? risk.demos : 0, demoIds: risk && Array.isArray(risk.demoIds) ? risk.demoIds : [], }, }; }); return { v: 1, exportedAt: new Date().toISOString(), home: settings.home, count: games.length, total: view && view.total != null ? view.total : null, totalText: view && view.totalText ? view.totalText : '', games, }; } function copyText(text) { try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); return Promise.resolve(true); } } catch (e) { /* 换下一种 */ } try { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text).then(() => true).catch(() => false); } } catch (e) { /* 只能回落到手动复制 */ } return Promise.resolve(false); } function downloadText(filename, text, mime) { try { const blob = new Blob([text], { type: mime }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.style.display = 'none'; document.body.appendChild(link); link.click(); setTimeout(() => { link.remove(); URL.revokeObjectURL(url); }, 1000); } catch (e) { /* 下载不了就算了 */ } } function exportStamp() { const date = new Date(); const pad = value => String(value).padStart(2, '0'); return date.getFullYear() + pad(date.getMonth() + 1) + pad(date.getDate()) + '-' + pad(date.getHours()) + pad(date.getMinutes()); } // 导出用的行必须来自 budgetView:homeAmount / shown 是它写上去的, // 自己再调一次 budgetRows 拿到的是没有这些字段的新对象,导出会少一列。 function exportRows() { const view = budgetView(lastCards, settings); return { view, rows: view.rows || [] }; } function exportList(kind) { const { view, rows } = exportRows(); const note = panelRoot && panelRoot.querySelector('[data-spc-bind="exportNote"]'); const say = text => { if (note) note.textContent = text; }; if (!rows.length) { say(t('exportEmpty')); return; } if (kind === 'csv') { downloadText('spc-budget-' + exportStamp() + '.csv', buildExportCsv(rows), 'text/csv;charset=utf-8'); say(t('exportCsvDone')); return; } if (kind === 'json') { downloadText('spc-budget-' + exportStamp() + '.json', JSON.stringify(buildExportJson(rows, view), null, 2), 'application/json;charset=utf-8'); say(t('exportJsonDone')); return; } const markdown = buildExportMarkdown(rows, view); copyText(markdown).then(ok => { say(ok ? t('exportDone') : markdown); }); } function budgetRows(cards) { const games = new Map(); cards.forEach(card => { if (card.section !== 'lastweek' && card.section !== 'lastmonth') return; if (!card.flags.wishlist || card.flags.owned) return; const price = shownPrice(card); if (!price || price.final == null || !price.currency) return; const prev = games.get(card.appId); if (prev) { if (card.name && card.name.length > prev.name.length) prev.name = card.name; if (!prev.image && card.image) prev.image = card.image; return; } games.set(card.appId, { appId: card.appId, name: card.name || '', image: card.image || '', currency: price.currency, amount: price.final, }); }); return Array.from(games.values()); } function budgetView(cards, settings) { const home = settings.home; const homeLabel = currencyById(home).label; const rows = budgetRows(cards); if (!rows.length) { return { rows: [], totalText: '', note: '', count: 0, sig: 'empty', empty: true }; } const foreignIds = []; let missing = 0; let total = 0; let counted = 0; rows.forEach(row => { if (row.currency === home) { row.homeAmount = row.amount; } else { if (foreignIds.indexOf(row.currency) < 0) foreignIds.push(row.currency); row.homeAmount = fx.rates ? convertCurrency(row.amount, row.currency, home, fx.rates) : null; if (row.homeAmount == null) missing += 1; } if (row.homeAmount != null) { total += row.homeAmount; counted += 1; } }); const direct = foreignIds.length === 0; rows.sort((a, b) => (b.homeAmount || 0) - (a.homeAmount || 0)); rows.forEach(row => { row.shown = row.homeAmount != null && !direct ? formatMoney(row.homeAmount, home) : formatMoney(row.amount, row.currency); row.label = row.name || row.appId; }); let note = ''; let totalText = ''; if (direct) { note = t('budgetDirect', { home: homeLabel }); totalText = formatMoney(total, home); } else if (!fx.rates || counted === 0) { const groups = new Map(); rows.forEach(row => groups.set(row.currency, (groups.get(row.currency) || 0) + row.amount)); totalText = Array.from(groups.entries()).map(([id, sum]) => formatMoney(sum, id)).join(' + '); note = t('budgetNeedFx'); } else { const fromLabel = foreignIds.map(id => currencyById(id).label).join('、'); note = t('budgetConverted', { from: fromLabel, home: homeLabel }); if (missing) note += ' ' + t('budgetPartial', { n: missing }); totalText = t('approx') + ' ' + formatMoney(total, home); } const ready = direct || (!!fx.rates && counted > 0); const sig = totalText + '|' + note + '|' + rows.map(row => row.appId + ':' + row.label + ':' + row.image + ':' + row.shown).join(','); return { rows, totalText, note, count: rows.length, sig, empty: false, home, direct, converted: !direct && ready, missing, total: ready ? roundMoney(total, home) : null, }; } function paintBudget(host, view) { host.replaceChildren(); if (view.empty) { const empty = document.createElement('p'); empty.className = 'spc-budget-empty'; empty.textContent = t('budgetEmpty'); host.appendChild(empty); return; } const hero = document.createElement('div'); hero.className = 'spc-hero'; const kicker = document.createElement('p'); kicker.className = 'spc-hero-kicker'; kicker.textContent = t('budgetCount', { n: view.count }); const total = document.createElement('div'); total.className = 'spc-hero-total'; total.textContent = view.totalText; const note = document.createElement('p'); note.className = 'spc-hero-note'; note.textContent = view.note; hero.append(kicker, total, note); const list = document.createElement('div'); list.className = 'spc-budget-list'; view.rows.forEach(row => { const link = document.createElement('a'); link.className = 'spc-game'; link.href = 'https://store.steampowered.com/app/' + row.appId; link.target = '_blank'; link.rel = 'noopener'; const img = document.createElement('img'); img.className = 'spc-cover'; img.alt = ''; img.referrerPolicy = 'no-referrer'; img.src = row.image || ('https://cdn.cloudflare.steamstatic.com/steam/apps/' + row.appId + '/capsule_231x87.jpg'); img.addEventListener('error', () => { if (img.getAttribute('data-fallback') !== '1') { img.setAttribute('data-fallback', '1'); img.src = 'https://cdn.cloudflare.steamstatic.com/steam/apps/' + row.appId + '/capsule_231x87.jpg'; return; } const ph = document.createElement('span'); ph.className = 'spc-cover spc-cover-fallback'; ph.textContent = (row.label || '?').slice(0, 1); img.replaceWith(ph); }); const name = document.createElement('span'); name.className = 'spc-budget-name'; name.textContent = row.label; const amount = document.createElement('span'); amount.className = 'spc-budget-amt'; amount.textContent = row.shown; link.append(img, name, amount); list.appendChild(link); }); host.append(hero, list); } function crossView(rows) { const items = []; let totalHome = 0; let totalLow = 0; let cheaper = 0; let compared = 0; let missing = 0; let noHome = 0; rows.forEach(row => { const rec = crossState.rows.get(String(row.appId)); if (!rec || !rec.lowest) { missing += 1; return; } compared += 1; if (rec.home) { totalHome += rec.home.cny; totalLow += rec.lowest.cny; if (rec.lowest.cny < rec.home.cny) cheaper += 1; } else { noHome += 1; } items.push({ row, rec, lowest: rec.lowest, home: rec.home, low: (rec.lows && rec.lows[rec.lowest.region]) || null }); }); return { items, compared, missing, noHome, cheaper, totalHome, totalLow, saved: totalHome - totalLow }; } function paintCross(host, rows) { if (!host) return; host.replaceChildren(); const note = (text) => { const p = document.createElement('p'); p.className = 'spc-cross-summary'; p.textContent = text; host.appendChild(p); }; if (crossState.loading) { note(t('crossRunning', { done: crossState.done, total: crossState.total })); return; } if (crossState.error) { note(crossState.error); return; } if (!crossState.at) { note(settings.crossOn ? t('crossHint') : t('crossManual')); return; } const view = crossView(rows); if (!view.items.length) { note(t('crossNone')); return; } const parts = []; if (view.cheaper) parts.push(t('crossCheaper', { n: view.cheaper })); if (view.saved > 0) { // totalLow / saved 都是人民币等值,展示前必须换成用户选的本币 const lowHome = cnyToHome(view.totalLow); const saveHome = cnyToHome(view.saved); if (lowHome != null && saveHome != null) { parts.push(t('crossTotal', { value: formatMoney(lowHome, settings.home), save: formatMoney(saveHome, settings.home), })); } else { parts.push(t('crossNeedFx')); } } if (view.missing) parts.push(t('crossFailed', { n: view.missing })); if (view.noHome) parts.push(t('crossNoHome', { n: view.noHome, home: currencyById(settings.home).label })); parts.push(ageText(crossState.at)); note(parts.filter(Boolean).join(' · ') || t('crossNone')); view.items.forEach(item => { const line = document.createElement('div'); line.className = 'spc-cross-row'; const name = document.createElement('span'); name.className = 'spc-cross-name'; name.textContent = item.row.name || item.row.appId; const region = document.createElement('span'); region.className = 'spc-cross-region'; region.textContent = item.lowest.flag + ' ' + item.lowest.name + ' ' + formatMoney(item.lowest.price, item.lowest.currency); const save = document.createElement('span'); save.className = 'spc-cross-save'; save.textContent = item.rec.savedPct != null && item.rec.savedPct > 0 ? t('crossSave', { n: item.rec.savedPct }) : ''; line.append(name, region, save); if (item.low && Number(item.low.amount) > 0) { const gap = Math.round((1 - item.lowest.price / Number(item.low.amount)) * 100); const badge = document.createElement('span'); badge.className = 'spc-badge ' + (gap >= 0 ? 'spc-badge-good' : 'spc-badge-mid'); badge.textContent = gap >= 0 ? t('lowAt') : t('lowGap', { n: Math.abs(gap) }); line.appendChild(badge); } host.appendChild(line); }); } function reviewView(rows) { const items = []; let sum = 0; let good = 0; rows.forEach(row => { const rec = reviewState.rows.get(row.appId); if (!rec || rec.empty || !rec.total) return; sum += rec.score; if (rec.score >= 80) good += 1; items.push({ row, rec }); }); return { items, valid: items.length, good, avg: items.length ? Math.round(sum / items.length) : null }; } function paintReviews(host, rows) { if (!host) return; host.replaceChildren(); const note = (text) => { const p = document.createElement('p'); p.className = 'spc-cross-summary'; p.textContent = text; host.appendChild(p); }; if (reviewState.loading) { note(t('reviewLoading')); return; } if (!reviewState.at) { note(settings.reviewOn ? t('reviewNone') : t('reviewManual')); return; } const view = reviewView(rows); if (!view.items.length) { note(t('reviewNone') + ' · ' + ageText(reviewState.at)); return; } note(t('reviewSummary', { avg: view.avg, good: view.good, n: view.valid }) + ' · ' + ageText(reviewState.at)); view.items.forEach(item => { const line = document.createElement('div'); line.className = 'spc-cross-row'; const name = document.createElement('span'); name.className = 'spc-cross-name'; name.textContent = item.row.name || item.row.appId; const badge = document.createElement('span'); badge.className = 'spc-badge ' + (item.rec.score >= 80 ? 'spc-badge-good' : item.rec.score >= 50 ? 'spc-badge-mid' : 'spc-badge-bad'); badge.textContent = item.rec.score + '%'; const count = document.createElement('span'); count.className = 'spc-cross-region'; count.textContent = t('reviewCount', { n: item.rec.total }); line.append(name, badge, count); if (item.rec.recentScore != null) { const recent = document.createElement('span'); recent.className = 'spc-cross-save'; recent.textContent = t('reviewRecent', { score: item.rec.recentScore }); line.appendChild(recent); } host.appendChild(line); }); } // 风险行 = 「即将推出」里被 appdetails 命中的条目。 // 标签与行是多对多:一个游戏可以同时命中「有试玩版 + 未正式发售」, // 所以筛选按「行是否含这个标签」判断,行内也只回显被选中的那个标签。 function riskBadges(rec) { const badges = []; if (!rec) return badges; if (rec.demos > 0) badges.push({ key: 'demo', text: t('riskDemo'), tone: 'good' }); if (rec.earlyAccess === true) badges.push({ key: 'ea', text: t('riskEa'), tone: 'mid' }); if (rec.comingSoon === true) badges.push({ key: 'soon', text: t('riskSoon'), tone: 'bad' }); return badges; } // 当前筛选要留哪些标签:all = 全留 function riskFilterKeys() { const key = RISK_FILTERS.indexOf(settings.riskFilter) >= 0 ? settings.riskFilter : 'all'; return key === 'all' ? ['demo', 'ea', 'soon'] : [key]; } // 待展示的风险行(已按 appId 去重,跟原来一样只看「即将推出」段) function riskDisplayRows(cards) { const rows = []; const seen = new Set(); (cards || []).forEach(card => { if (!card || card.section !== 'upcoming') return; if (seen.has(card.appId)) return; seen.add(card.appId); const rec = riskState.rows.get(card.appId); if (!rec) return; const badges = riskBadges(rec); if (!badges.length) return; rows.push({ card, rec, badges }); }); return rows; } // 筛选芯片:选中态 + 每个标签命中多少行 function syncRiskFilterChips(counts, total) { if (!panelRoot) return; const current = RISK_FILTERS.indexOf(settings.riskFilter) >= 0 ? settings.riskFilter : 'all'; panelRoot.querySelectorAll('[data-spc-risk-filter]').forEach(chip => { const key = chip.getAttribute('data-spc-risk-filter'); const on = key === current; chip.classList.toggle('is-on', on); chip.setAttribute('aria-pressed', on ? 'true' : 'false'); const badge = chip.querySelector('[data-spc-risk-count]'); if (!badge) return; const next = String(key === 'all' ? total : (counts[key] || 0)); if (badge.textContent !== next) badge.textContent = next; }); } function paintRisk(host, cards) { if (!host) return; host.replaceChildren(); const note = (text) => { const p = document.createElement('p'); p.className = 'spc-cross-summary'; p.textContent = text; host.appendChild(p); }; // 先算行与各标签命中数:哪怕还没扫完,芯片上的计数也不该空着 const rows = riskDisplayRows(cards); const counts = { demo: 0, ea: 0, soon: 0 }; rows.forEach(row => row.badges.forEach(item => { counts[item.key] += 1; })); syncRiskFilterChips(counts, rows.length); if (riskState.loading) { note(t('riskRunning', { done: riskState.done, total: riskState.total })); return; } if (riskState.error) { note(riskState.error); return; } if (!riskState.at) { note(t('riskHint')); return; } let demos = 0; let ea = 0; let soon = 0; riskState.rows.forEach(rec => { if (!rec) return; if (rec.demos > 0) demos += 1; if (rec.earlyAccess === true) ea += 1; if (rec.comingSoon === true) soon += 1; }); note(t('riskSummary', { n: riskState.rows.size, d: demos, e: ea, s: soon }) + ' · ' + ageText(riskState.at)); if (riskState.failed) note(t('riskFailed', { n: riskState.failed })); // 即将推出里命中的单独列出来,盲买决策就在这一段 const keys = riskFilterKeys(); const shown = rows.filter(row => row.badges.some(item => keys.indexOf(item.key) >= 0)); if (!shown.length) { note(t('riskFilterEmpty')); return; } shown.forEach(row => { const line = document.createElement('div'); line.className = 'spc-cross-row'; const name = document.createElement('span'); name.className = 'spc-cross-name'; name.textContent = row.card.name || row.card.appId; line.appendChild(name); row.badges.filter(item => keys.indexOf(item.key) >= 0).forEach(item => { const badge = document.createElement('span'); badge.className = 'spc-badge spc-badge-' + item.tone; badge.textContent = item.text; line.appendChild(badge); }); host.appendChild(line); }); } function paintCluster(host, cards) { if (!host) return; host.replaceChildren(); const groups = clusterRows(cards); if (!groups.length) { const empty = document.createElement('p'); empty.className = 'spc-cross-summary'; empty.textContent = t('clusterNone'); host.appendChild(empty); return; } const max = groups.reduce((acc, group) => Math.max(acc, group.total), 0) || 1; groups.forEach(group => { const line = document.createElement('div'); line.className = 'spc-cluster-row' + (group.crowded ? ' is-crowded' : ''); const label = document.createElement('span'); label.className = 'spc-cluster-label'; label.textContent = group.label; const bar = document.createElement('span'); bar.className = 'spc-cluster-bar'; const fill = document.createElement('i'); fill.style.width = Math.round((group.total / max) * 100) + '%'; bar.appendChild(fill); const count = document.createElement('span'); count.className = 'spc-cluster-count'; count.textContent = t('clusterCount', { n: group.total, w: group.wish }); line.append(label, bar, count); if (group.crowded) { const badge = document.createElement('span'); badge.className = 'spc-badge spc-badge-mid'; badge.textContent = t('clusterBadge'); line.appendChild(badge); } host.appendChild(line); }); } registerSlot({ id: 'summary', order: 10, mount: () => `

${t('upcomingNote')}

`, bind() {}, update(ctx) { const el = panelRoot.querySelector('[data-spc-bind="summary"]'); const next = summaryText(ctx.cards); if (el && el.textContent !== next) el.textContent = next; }, }); registerSlot({ id: 'view', order: 20, mount: () => `
${t('advancedMore')}
`, bind(root) { // 展开/收起「更多设置」会改变面板高度,拖过的面板要重新收回可视区 root.querySelectorAll('details.spc-advanced').forEach(box => { box.addEventListener('toggle', () => { handlePanelResize(); }); }); root.querySelectorAll('[data-spc-set]').forEach(input => { if (input.classList.contains('spc-pick')) return; input.addEventListener('change', () => { const key = input.getAttribute('data-spc-set'); if (input.type === 'checkbox') settings[key] = input.checked; else settings[key] = input.value; if (key === 'home') settings.calcTo = settings.home; settings = saveSettings(settings); syncPanelInputs(); refresh(); }); }); }, update() { syncPanelInputs(); }, }); // 汇率来源:自动(公开接口 + 本地快照兜底)还是手动(用户自己钉一个汇率)。 // 1.8.0 收进「高级」折叠区,首屏只留一行摘要 + 刷新按钮,免得把价格计算器挤下去。 // 折叠的只是 UI:fxStatus 双锚点明细、手动汇率、三锚点对照全都还在。 registerSlot({ id: 'fxSource', order: 25, mount: () => `
${t('fxSource')}

`, bind(root) { const refreshBtn = root.querySelector('[data-spc-fx-refresh]'); if (refreshBtn) refreshBtn.addEventListener('click', () => { refreshRates(); }); const toggleBtn = root.querySelector('[data-spc-fx-toggle]'); if (toggleBtn) toggleBtn.addEventListener('click', () => { const box = root.querySelector('[data-spc-fx-advanced]'); if (!box) return; box.hidden = !box.hidden; toggleBtn.classList.toggle('is-on', !box.hidden); if (!box.hidden) paintFxCompare(); handlePanelResize(); }); const fillBtn = root.querySelector('[data-spc-fx-fill]'); if (fillBtn) fillBtn.addEventListener('click', () => { fillCurrentManualRate(); }); root.querySelectorAll('[data-spc-fx-mode]').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.getAttribute('data-spc-fx-mode'); if (mode === 'manual') { settings.fxMode = 'manual'; settings = saveSettings(settings); applyFxOverride(); syncPanelInputs(); refresh(); return; } restoreAutoRate(); }); }); const applyBtn = root.querySelector('[data-spc-fx-apply]'); if (applyBtn) applyBtn.addEventListener('click', () => { applyManualRateSetting(); }); const autoBtn = root.querySelector('[data-spc-fx-auto]'); if (autoBtn) autoBtn.addEventListener('click', () => { restoreAutoRate(); }); const per = root.querySelector('[data-spc-manual-per]'); if (per) per.addEventListener('change', () => { settings.fxManual = Object.assign({}, settings.fxManual, { per: per.value === 'cur' ? 'cur' : 'usd' }); settings = saveSettings(settings); syncPanelInputs(); }); const value = root.querySelector('[data-spc-manual-value]'); if (value) value.addEventListener('change', () => { settings.fxManual = Object.assign({}, settings.fxManual, { value: String(value.value || '').trim() }); settings = saveSettings(settings); syncPanelInputs(); }); }, update() { if (!panelRoot) return; const host = panelRoot.querySelector('[data-spc-bind="fxStatus"]'); if (host) { const next = fxStatusText(); if (host.textContent !== next) host.textContent = next; } panelRoot.querySelectorAll('[data-spc-fx-mode]').forEach(btn => { btn.classList.toggle('is-on', btn.getAttribute('data-spc-fx-mode') === settings.fxMode); }); const manual = panelRoot.querySelector('.spc-manual'); if (manual) manual.classList.toggle('is-on', settings.fxMode === 'manual'); const brief = panelRoot.querySelector('[data-spc-bind="fxBrief"]'); if (brief) { const next = fxBriefText(); if (brief.textContent !== next) brief.textContent = next; } paintFxCompare(); }, }); registerSlot({ id: 'budget', order: 30, mount: () => `
`, bind() {}, update(ctx) { const host = panelRoot && panelRoot.querySelector('[data-spc-bind="budget"]'); if (!host) return; const view = budgetView(ctx.cards, ctx.settings); if (host.getAttribute('data-sig') !== view.sig) { host.setAttribute('data-sig', view.sig); paintBudget(host, view); } }, }); // 分析标签页:比价 / 口碑 / 风险 / 扎堆 / 导出,和预算合计拆开,各自成卡片 registerSlot({ id: 'analysis', order: 35, mount: () => `
${t('cross')}
${regionChipsHtml()}

${t('regionNote')}

${t('cluster')}
${t('review')}
${t('risk')}
`, bind(root) { const btn = root.querySelector('[data-spc-cross-start]'); if (btn) btn.addEventListener('click', () => startCrossCompare({ retry: true })); const reviewBtn = root.querySelector('[data-spc-review-start]'); if (reviewBtn) reviewBtn.addEventListener('click', () => startReviews(false)); const riskBtn = root.querySelector('[data-spc-risk-start]'); if (riskBtn) riskBtn.addEventListener('click', () => startRiskScan()); // 风险按标签筛选:点一下只留该标签的行;计数写在芯片上,跟扫描结果无关 root.querySelectorAll('[data-spc-risk-filter]').forEach(chip => { chip.addEventListener('click', () => { const key = chip.getAttribute('data-spc-risk-filter'); if (RISK_FILTERS.indexOf(key) < 0) return; if (settings.riskFilter === key) return; settings.riskFilter = key; settings = saveSettings(settings); paintRisk(panelRoot && panelRoot.querySelector('[data-spc-bind="risk"]'), lastCards); }); }); const refreshBtn = root.querySelector('[data-spc-refresh]'); if (refreshBtn) refreshBtn.addEventListener('click', () => { // 明确点「刷新」才无视缓存,但只重取已经加载过的那几块, // 不当场给没打开过的口碑/风险也来一轮几十个请求。 refreshAnalysisData(); }); // 区域勾选:至少留一个,否则比价无从谈起 root.querySelectorAll('[data-spc-region]').forEach(chip => { chip.addEventListener('click', () => { const code = chip.getAttribute('data-spc-region'); const list = (settings.crossRegions || []).slice(); const at = list.indexOf(code); if (at >= 0) { if (list.length <= 1) return; list.splice(at, 1); } else { list.push(code); } settings.crossRegions = list; settings = saveSettings(settings); syncRegionChips(); // 区域集合变了,旧结果不能留:直接重比一次(缓存对不上区域会整批重取) if (crossState.rows.size || settings.crossOn) startCrossCompare(); repaint(); }); }); root.querySelectorAll('[data-spc-export]').forEach(exportBtn => { exportBtn.addEventListener('click', () => exportList(exportBtn.getAttribute('data-spc-export'))); }); }, update(ctx) { const rows = budgetRows(ctx.cards); syncRegionChips(); ensureCross(rows); paintCross(panelRoot && panelRoot.querySelector('[data-spc-bind="cross"]'), rows); paintReviews(panelRoot && panelRoot.querySelector('[data-spc-bind="review"]'), rows); paintRisk(panelRoot && panelRoot.querySelector('[data-spc-bind="risk"]'), ctx.cards); paintCluster(panelRoot && panelRoot.querySelector('[data-spc-bind="cluster"]'), ctx.cards); ensureReviews(ctx.cards); }, }); // 折扣标签页:目标是「即将推出 × 愿望单」,列出官方现价与好评摘要 registerSlot({ id: 'deals', order: 37, mount: () => `
${t('deals')}
`, bind(root) { // 整批重扫:价格随时在变,点按钮就要新的,不吃内存缓存 const btn = root.querySelector('[data-spc-deal-start]'); if (btn) btn.addEventListener('click', () => startDealScan(true)); root.querySelectorAll('[data-spc-deal-sort]').forEach(chip => { chip.addEventListener('click', () => { const key = chip.getAttribute('data-spc-deal-sort'); if (key !== 'discount' && key !== 'review' && key !== 'price') return; dealState.sort = key; repaint(); }); }); const shareBtn = root.querySelector('[data-spc-deal-share]'); if (shareBtn) shareBtn.addEventListener('click', () => { const items = dealSortItems(dealView(dealTargets(lastCards)).items); if (!items.length) { toast(t('dealsShareEmpty')); return; } shareDealCard(items); toast(t('dealsShared')); }); }, update(ctx) { const host = panelRoot && panelRoot.querySelector('[data-spc-bind="deals"]'); if (!host) return; ensureDeals(ctx.cards); paintDeals(host, ctx.cards); panelRoot.querySelectorAll('[data-spc-deal-sort]').forEach(chip => { chip.classList.toggle('is-on', chip.getAttribute('data-spc-deal-sort') === dealState.sort); }); }, }); registerSlot({ id: 'calc', order: 40, mount: () => `
${t('calc')}
${PRESETS.map(item => ``).join('')}
${[100, 500, 1000, 5000].map(n => ``).join('')}
`, bind(root) { root.querySelectorAll('[data-spc-calc]').forEach(input => { if (input.classList.contains('spc-pick')) return; input.addEventListener('input', () => { const field = { amount: 'calcAmount', from: 'calcFrom', to: 'calcTo' }[input.getAttribute('data-spc-calc')]; if (!field) return; settings[field] = input.value; settings = saveSettings(settings); paintCalc(); }); }); // 交换方向:只换 from/to,金额不动(金额仍是新方向的输入)。 const swapBtn = root.querySelector('[data-spc-calc-swap]'); if (swapBtn) swapBtn.addEventListener('click', () => { const next = swapCalcPair(settings.calcFrom, settings.calcTo); settings.calcFrom = next.from; settings.calcTo = next.to; settings = saveSettings(settings); syncPanelInputs(); paintCalc(); }); // 常用金额:点一下就填进金额框,不碰 from/to,也不碰手动汇率。 root.querySelectorAll('[data-spc-calc-amount]').forEach(btn => { btn.addEventListener('click', () => { settings.calcAmount = btn.getAttribute('data-spc-calc-amount'); settings = saveSettings(settings); syncPanelInputs(); paintCalc(); }); }); const copyCalcResult = () => { const text = calcResult(); if (!text || text === '—') return; try { GM_setClipboard(text); toast(t('calcCopied')); } catch (e) { try { if (navigator.clipboard) navigator.clipboard.writeText(text); toast(t('calcCopied')); } catch (e2) { toast(t('calcCopyFail')); } } }; const copyBtn = root.querySelector('[data-spc-calc-copy]'); if (copyBtn) copyBtn.addEventListener('click', copyCalcResult); const resultEl = root.querySelector('[data-spc-bind="result"]'); if (resultEl) resultEl.addEventListener('click', copyCalcResult); root.querySelectorAll('[data-spc-preset]').forEach(btn => { btn.addEventListener('click', () => { const pair = btn.getAttribute('data-spc-preset').split(':'); settings.calcFrom = pair[0]; settings.calcTo = pair[1]; settings = saveSettings(settings); syncPanelInputs(); paintCalc(); }); }); }, update() { paintCalc(); }, }); let currencyDropdownClose = null; function closeCurrencyDropdown() { if (currencyDropdownClose) { document.removeEventListener('pointerdown', currencyDropdownClose, true); currencyDropdownClose = null; } document.querySelectorAll('.spc-menu').forEach(el => el.remove()); if (panelRoot) panelRoot.querySelectorAll('.spc-pick-btn').forEach(btn => btn.setAttribute('aria-expanded', 'false')); } function buildCurrencyDropdown(anchor, activeId, onPick) { closeCurrencyDropdown(); const dd = document.createElement('div'); dd.className = 'spc-menu'; dd.setAttribute('data-spc-root', '1'); const list = document.createElement('div'); list.className = 'spc-menu-list'; CURRENCIES.forEach(cur => { const item = document.createElement('button'); item.type = 'button'; const on = cur.id === activeId; item.className = on ? 'spc-currency-item active' : 'spc-currency-item'; item.innerHTML = curFlagHtml(cur.id, 18, 13, 0) + '' + cur.label + '' + '' + cur.id + ''; item.addEventListener('click', event => { event.stopPropagation(); closeCurrencyDropdown(); if (cur.id !== activeId) onPick(cur.id); }); list.appendChild(item); }); dd.appendChild(list); document.documentElement.appendChild(dd); const rect = anchor.getBoundingClientRect(); const width = 220; let left = rect.left; if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8; if (left < 8) left = 8; dd.style.width = width + 'px'; dd.style.left = left + 'px'; const spaceBelow = window.innerHeight - rect.bottom - 8; const spaceAbove = rect.top - 8; const openUp = spaceBelow < 220 && spaceAbove > spaceBelow; const maxH = Math.max(120, Math.min(280, openUp ? spaceAbove : spaceBelow)); list.style.maxHeight = maxH + 'px'; const ddRect = dd.getBoundingClientRect(); let top = openUp ? rect.top - ddRect.height - 4 : rect.bottom + 4; if (top < 8) top = 8; if (top + ddRect.height > window.innerHeight - 8) top = Math.max(8, window.innerHeight - ddRect.height - 8); dd.style.top = top + 'px'; const active = list.querySelector('.spc-currency-item.active'); if (active) list.scrollTop = Math.max(0, active.offsetTop - (list.clientHeight - active.offsetHeight) / 2); list.addEventListener('wheel', event => { const max = list.scrollHeight - list.clientHeight; if (max <= 0) return; const next = Math.min(max, Math.max(0, list.scrollTop + event.deltaY)); if (next === list.scrollTop) return; list.scrollTop = next; event.preventDefault(); event.stopPropagation(); }, { passive: false }); const close = event => { if (dd.contains(event.target) || anchor.contains(event.target)) return; closeCurrencyDropdown(); }; currencyDropdownClose = close; setTimeout(() => document.addEventListener('pointerdown', close, true), 0); anchor.setAttribute('aria-expanded', 'true'); } function chooseCurrency(owner, id) { const cur = currencyById(id); if (owner.hasAttribute('data-spc-manual-currency')) { settings.fxManual = Object.assign({}, settings.fxManual, { currency: cur.id }); settings = saveSettings(settings); syncPanelInputs(); return; } if (owner.hasAttribute('data-spc-set')) { const key = owner.getAttribute('data-spc-set'); settings[key] = cur.id; if (key === 'home') settings.calcTo = settings.home; settings = saveSettings(settings); // 本币变了「本区」就变了,比价结果得按新本区重算(纯本地,不打接口) if (key === 'home') rehomeCross(); syncPanelInputs(); refresh(); return; } const calc = owner.getAttribute('data-spc-calc'); const field = { from: 'calcFrom', to: 'calcTo' }[calc]; if (!field) return; settings[field] = cur.id; settings = saveSettings(settings); syncPanelInputs(); paintCalc(); } function syncPanelInputs() { if (!panelRoot) return; panelRoot.querySelectorAll('[data-spc-set]').forEach(input => { const key = input.getAttribute('data-spc-set'); if (input.classList.contains('spc-pick')) { paintPick(input, settings[key]); return; } if (input.type === 'checkbox') input.checked = !!settings[key]; else if (input.value !== settings[key]) input.value = settings[key]; }); panelRoot.querySelectorAll('[data-spc-calc]').forEach(input => { const key = input.getAttribute('data-spc-calc'); if (input.classList.contains('spc-pick')) { paintPick(input, key === 'from' ? settings.calcFrom : settings.calcTo); return; } const value = key === 'amount' ? settings.calcAmount : (key === 'from' ? settings.calcFrom : settings.calcTo); if (document.activeElement !== input && input.value !== String(value)) input.value = value; }); // 手动汇率那一组:币种走下拉,写法/数值走原生控件 panelRoot.querySelectorAll('[data-spc-manual-currency]').forEach(input => { paintPick(input, settings.fxManual.currency); }); panelRoot.querySelectorAll('[data-spc-manual-per]').forEach(input => { if (document.activeElement !== input && input.value !== settings.fxManual.per) input.value = settings.fxManual.per; }); panelRoot.querySelectorAll('[data-spc-manual-value]').forEach(input => { if (document.activeElement !== input && input.value !== String(settings.fxManual.value)) input.value = settings.fxManual.value; }); panelRoot.querySelectorAll('[data-spc-fx-mode]').forEach(btn => { btn.classList.toggle('is-on', btn.getAttribute('data-spc-fx-mode') === settings.fxMode); }); const manualBox = panelRoot.querySelector('.spc-manual'); if (manualBox) manualBox.classList.toggle('is-on', settings.fxMode === 'manual'); const status = panelRoot.querySelector('[data-spc-bind="fxStatus"]'); if (status) { const next = fxStatusText(); if (status.textContent !== next) status.textContent = next; } panelRoot.classList.toggle('spc-collapsed', !!settings.collapsed); const toggle = panelRoot.querySelector('[data-spc-toggle]'); const toggleLabel = settings.collapsed ? t('expand') : t('collapse'); if (toggle && toggle.textContent !== toggleLabel) toggle.textContent = toggleLabel; syncRegionChips(); applyTab(); } function applyTab() { if (!panelRoot) return; const tab = TAB_IDS.indexOf(settings.panelTab) >= 0 ? settings.panelTab : 'fx'; panelRoot.setAttribute('data-spc-page', tab); panelRoot.querySelectorAll('.spc-tabs button').forEach(btn => { const on = btn.getAttribute('data-spc-tab') === tab; btn.classList.toggle('is-on', on); btn.setAttribute('aria-selected', on ? 'true' : 'false'); }); } function cycleTab(step) { const tabs = TAB_IDS; const at = Math.max(0, tabs.indexOf(settings.panelTab)); settings.panelTab = tabs[(at + step + tabs.length) % tabs.length]; settings = saveSettings(settings); syncPanelInputs(); } // 键盘可达性:Esc 关下拉 / 收起面板,←→ 在四个标签间切换。 // 只在焦点落在面板里时响应,不抢页面自己的快捷键。 function handlePanelKey(event) { if (!panelRoot) return; if (event.key === 'Escape') { if (currencyDropdownClose) { closeCurrencyDropdown(); event.preventDefault(); return; } if (!settings.collapsed && panelRoot.contains(document.activeElement)) { toggleCollapse(); event.preventDefault(); } return; } if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; const active = document.activeElement; if (!active || !active.closest || !active.closest('.spc-tabs')) return; cycleTab(event.key === 'ArrowRight' ? 1 : -1); event.preventDefault(); } function paintCalc() { if (!panelRoot) return; const setText = (bind, next) => { const el = panelRoot.querySelector('[data-spc-bind="' + bind + '"]'); if (el && el.textContent !== next) el.textContent = next; }; setText('result', calcResult()); setText('rate', rateText()); setText('reverseRate', reverseRateText()); setText('calcSource', calcSourceText()); setText('fxBrief', fxBriefText()); paintFxCompare(); } function loadPanelPos() { try { const raw = GM_getValue(PANEL_POS_KEY, null); if (raw && Number.isFinite(Number(raw.x)) && Number.isFinite(Number(raw.y))) { return { x: Number(raw.x), y: Number(raw.y) }; } } catch (e) { /* ignore */ } return null; } function savePanelPos(pos) { try { GM_setValue(PANEL_POS_KEY, { x: Math.round(pos.x), y: Math.round(pos.y) }); } catch (e) { /* ignore */ } } function clearPanelPos() { try { GM_setValue(PANEL_POS_KEY, null); } catch (e) { /* ignore */ } } // 面板拖到哪都别让它整个跑到屏幕外 —— 参考 TM-JB 汇率悬浮窗的 resize 兜底。 function clampPos(x, y, width, height) { const maxX = Math.max(8, window.innerWidth - (width || 360) - 8); const maxY = Math.max(8, window.innerHeight - (height || 200) - 8); return { x: Math.min(Math.max(8, Math.round(x)), maxX), y: Math.min(Math.max(8, Math.round(y)), maxY), }; } function applyPanelPos(pos) { if (!panelRoot) return; if (!pos) { panelRoot.style.left = 'auto'; panelRoot.style.top = 'auto'; panelRoot.style.right = '16px'; panelRoot.style.bottom = '16px'; panelRoot.classList.remove('is-moved'); return; } const pos2 = clampPos(pos.x, pos.y, panelRoot.offsetWidth, panelRoot.offsetHeight); panelRoot.style.left = pos2.x + 'px'; panelRoot.style.top = pos2.y + 'px'; panelRoot.style.right = 'auto'; panelRoot.style.bottom = 'auto'; panelRoot.classList.add('is-moved'); } // 折叠 / 展开时把面板的「右上角」钉在原处。 // 收起按钮就在标题栏右上角,按钮不跳才符合直觉;之前面板收成胶囊后仍停在 // 旧位置的左上角,看起来就像"位置不对"(用户截图反馈的就是这个)。 function anchorTopRight(before) { if (!panelRoot || !before) return; const after = panelRoot.getBoundingClientRect(); if (!after.width || !after.height) return; const pos = clampPos( Math.round(before.right - after.width), Math.round(before.top), after.width, after.height, ); panelRoot.style.left = pos.x + 'px'; panelRoot.style.top = pos.y + 'px'; panelRoot.style.right = 'auto'; panelRoot.style.bottom = 'auto'; panelRoot.classList.add('is-moved'); savePanelPos(pos); } // 折叠 / 展开的唯一入口:标题栏按钮、Esc、Ctrl+Alt+S 都走这里, // 保证三条路径的位置行为一致(以前 Esc / 快捷键只切 class,面板会跳位)。 function toggleCollapse() { if (!panelRoot) return; const before = panelRoot.getBoundingClientRect(); const moved = panelRoot.classList.contains('is-moved'); settings.collapsed = !settings.collapsed; settings = saveSettings(settings); syncPanelInputs(); // 没拖过的面板用 right/bottom 贴右下角,尺寸变了自然跟着变,不用管。 // 拖过的面板得显式重锚,否则胶囊会掉到旧面板的左上角。 if (moved) anchorTopRight(before); handlePanelResize(); } function bindPanelDrag() { const head = panelRoot && panelRoot.querySelector('.spc-head'); if (!head || head.getAttribute('data-spc-drag') === '1') return; head.setAttribute('data-spc-drag', '1'); head.addEventListener('pointerdown', event => { if (event.button !== 0) return; // 点按钮 / 下拉 / 输入框时不启动拖动 if (event.target.closest && event.target.closest('button, select, input, a, label')) return; const rect = panelRoot.getBoundingClientRect(); const offsetX = event.clientX - rect.left; const offsetY = event.clientY - rect.top; let moved = false; const move = ev => { moved = true; const pos = clampPos(ev.clientX - offsetX, ev.clientY - offsetY, panelRoot.offsetWidth, panelRoot.offsetHeight); panelRoot.style.left = pos.x + 'px'; panelRoot.style.top = pos.y + 'px'; panelRoot.style.right = 'auto'; panelRoot.style.bottom = 'auto'; panelRoot.classList.add('is-moved'); }; const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); if (moved) { const now = panelRoot.getBoundingClientRect(); savePanelPos({ x: now.left, y: now.top }); } }; document.addEventListener('pointermove', move); document.addEventListener('pointerup', up); event.preventDefault(); }); } function ensurePanel() { const parent = document.documentElement; if (!parent) return; if (panelRoot && !panelRoot.querySelector('.spc-tabs')) { panelRoot.remove(); panelRoot = null; } if (!panelRoot) { panelRoot = document.createElement('aside'); panelRoot.className = 'spc-panel'; panelRoot.setAttribute('data-spc-root', '1'); panelRoot.setAttribute('role', 'complementary'); panelRoot.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483000;'; panelRoot.innerHTML = `
${t('title')}
`; panelRoot.querySelectorAll('.spc-tabs button').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.getAttribute('data-spc-tab'); if (TAB_IDS.indexOf(tab) < 0) return; settings.panelTab = tab; settings = saveSettings(settings); closeCurrencyDropdown(); syncPanelInputs(); // 页签内容高度不一样,拖过的面板切页后要重新收回可视区 handlePanelResize(); }); }); panelRoot.querySelector('[data-spc-toggle]').addEventListener('click', () => { toggleCollapse(); }); const refreshTabBtn = panelRoot.querySelector('[data-spc-refresh-tab]'); if (refreshTabBtn) refreshTabBtn.addEventListener('click', () => { runRefreshActiveTab(refreshTabBtn); }); const resetBtn = panelRoot.querySelector('[data-spc-reset]'); if (resetBtn) resetBtn.addEventListener('click', () => { clearPanelPos(); applyPanelPos(null); }); bindPanelDrag(); panelRoot.addEventListener('click', event => { const btn = event.target.closest && event.target.closest('.spc-pick-btn'); if (!btn || !panelRoot.contains(btn)) return; event.preventDefault(); event.stopPropagation(); const owner = btn.closest('.spc-pick'); if (!owner) return; if (btn.getAttribute('aria-expanded') === 'true') closeCurrencyDropdown(); else buildCurrencyDropdown(btn, owner.getAttribute('data-spc-value'), nextId => chooseCurrency(owner, nextId)); }); mountSlots(); } if (!panelRoot.isConnected) parent.appendChild(panelRoot); // 恢复上次拖动的位置。只在「还没被拖过」时套用,免得拖动途中被刷新拽回去。 if (!panelRoot.classList.contains('is-moved')) { const savedPos = loadPanelPos(); if (savedPos) applyPanelPos(savedPos); } syncPanelInputs(); } function releaseRecord(card) { return { appId: Number(card.appId) || card.appId, name: card.name || '', wishlist: !!(card.flags && card.flags.wishlist), owned: !!(card.flags && card.flags.owned), }; } function riskOf(appId) { return riskState.rows.get(appId) || null; } function buildReleaseCache(cards) { const upcoming = new Map(); const recent = new Map(); cards.forEach(card => { if (card.section === 'upcoming') { const prev = upcoming.get(card.appId); const risk = riskOf(card.appId); const row = Object.assign(releaseRecord(card), { releaseLabel: card.release ? card.release.label : '', releaseTs: card.release ? card.release.ts : null, unreleased: !!(card.release && card.release.ts > Date.now()), hasDemo: !!(risk && risk.demos > 0), earlyAccess: !!(risk && risk.earlyAccess === true), comingSoon: !!(risk && risk.comingSoon === true), releaseDate: risk ? risk.releaseDate : '', }); if (!prev || (row.releaseTs && !prev.releaseTs)) upcoming.set(card.appId, row); } else if (card.section === 'lastweek' || card.section === 'lastmonth') { const prev = recent.get(card.appId); if (prev && prev.bucket === 'week') return; const price = card.price; recent.set(card.appId, Object.assign(releaseRecord(card), { bucket: card.section === 'lastweek' ? 'week' : 'month', currency: price ? price.currency : null, price: price && price.final != null ? price.final : null, original: price && price.original != null ? price.original : null, discountPct: price ? (price.discountPct || 0) : 0, })); } }); return { upcoming: Array.from(upcoming.values()).sort((a, b) => (a.releaseTs || 9e15) - (b.releaseTs || 9e15)), recent: Array.from(recent.values()).sort((a, b) => (a.bucket === b.bucket ? 0 : (a.bucket === 'week' ? -1 : 1))), }; } let bridgeTimer = 0; function scheduleReleaseBridge(cards) { if (!cards || !cards.length) return; const snapshot = cards.slice(); clearTimeout(bridgeTimer); bridgeTimer = setTimeout(() => { try { writeReleaseBridge(snapshot); } catch (e) { /* 桥失败不影响日历 */ } }, 400); } // ── 愿望单发售浏览器通知(P2)──────────────────────────────────────────── // 纯函数:选出要通知的愿望单游戏。 // 窗口:今天 0 点 ~ 第 windowDays 天结束(按本地时区);同 appId 取最早发售日。 // history.apps[appId] = {ts, phase:'today'|'soon'}:同阶段不重复提醒; // 提前(soon)提醒过的,发售当天还能再提醒一次(today)。 function selectReleaseNotify(cards, now, windowDays, history) { const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); const start = startOfToday.getTime(); const end = start + (Number(windowDays) || 7) * DAY + DAY; const picked = new Map(); (cards || []).forEach(card => { if (card.section !== 'upcoming' || !card.release || !card.release.ts) return; if (!(card.flags && card.flags.wishlist)) return; const ts = Number(card.release.ts); if (!(ts >= start && ts < end)) return; const appId = String(card.appId); const prev = picked.get(appId); if (!prev || ts < prev.ts) picked.set(appId, { appId, name: card.name || appId, ts }); }); const apps = (history && history.apps && typeof history.apps === 'object') ? history.apps : {}; const today = []; const soon = []; picked.forEach(item => { const phase = item.ts < start + DAY ? 'today' : 'soon'; const rec = apps[item.appId]; if (rec && rec.phase === phase) return; (phase === 'today' ? today : soon).push(item); }); today.sort((a, b) => a.ts - b.ts); soon.sort((a, b) => a.ts - b.ts); return { today, soon }; } function checkReleaseNotify(cards) { if (!settings.notifyRelease) return; if (typeof GM_notification !== 'function') return; let history = null; try { history = GM_getValue(NOTIFY_KEY, null); } catch (e) { history = null; } if (!history || typeof history !== 'object' || !history.apps || typeof history.apps !== 'object') { history = { date: '', apps: {} }; } // 通知记录只留 14 天,防止 GM 存储无限增长 const cutoff = Date.now() - 14 * DAY; Object.keys(history.apps).forEach(id => { if (!history.apps[id] || Number(history.apps[id].ts) < cutoff) delete history.apps[id]; }); const result = selectReleaseNotify(cards, new Date(), NOTIFY_WINDOW_DAYS, history); if (!result.today.length && !result.soon.length) return; const formatItem = item => { const d = new Date(item.ts); return (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') + ' ' + (item.name || item.appId); }; const total = result.today.length + result.soon.length; const lines = result.today.concat(result.soon).slice(0, 3).map(formatItem); if (total > 3) lines.push(t('notifyBodyMore', { n: total })); let title; if (result.today.length && result.soon.length) { title = t('notifyTitleMix', { a: result.today.length, b: result.soon.length }); } else if (result.today.length) { title = t('notifyTitleToday', { n: result.today.length }); } else { title = t('notifyTitleSoon', { n: result.soon.length }); } try { GM_notification({ title, text: lines.join('\n'), onclick: () => { try { window.focus(); } catch (e) { /* ignore */ } }, }); } catch (e) { return; } // 通知没发出去就不写历史,下次刷新再试 const stamp = Date.now(); result.today.forEach(i => { history.apps[i.appId] = { ts: stamp, phase: 'today' }; }); result.soon.forEach(i => { history.apps[i.appId] = { ts: stamp, phase: 'soon' }; }); history.date = new Date(stamp).toISOString().slice(0, 10); try { GM_setValue(NOTIFY_KEY, history); } catch (e) { /* ignore */ } } let notifyTimer = 0; function scheduleReleaseNotify(cards) { if (!settings.notifyRelease || typeof GM_notification !== 'function') return; if (!cards || !cards.length) return; const snapshot = cards.slice(); clearTimeout(notifyTimer); notifyTimer = setTimeout(() => { try { checkReleaseNotify(snapshot); } catch (e) { /* 通知失败不影响日历 */ } }, 1500); } function writeReleaseBridge(cards) { const built = buildReleaseCache(cards); if (!built.upcoming.length && !built.recent.length) return; const view = budgetView(cards, settings); const budget = { home: settings.home, scope: 'recent-wishlist-unowned-priced', count: view.count, direct: view.direct === true, converted: view.converted === true, total: view.total != null ? view.total : null, totalText: view.empty ? null : view.totalText, note: view.empty ? t('budgetEmpty') : view.note, missing: view.missing || 0, games: (view.rows || []).map(row => { const cross = crossState.rows.get(row.appId); const rev = reviewState.rows.get(row.appId); const risk = riskOf(row.appId); return { appId: Number(row.appId) || row.appId, name: row.label || row.name || '', currency: row.currency, price: row.amount, homeAmount: row.homeAmount == null ? null : roundMoney(row.homeAmount, settings.home), lowestRegion: cross && cross.lowest ? cross.lowest.region : null, lowestPrice: cross && cross.lowest ? cross.lowest.price : null, lowestCurrency: cross && cross.lowest ? cross.lowest.currency : null, lowestHomeAmount: cross && cross.lowest ? homeMoney(cross.lowest.cny) : null, savedHomeAmount: cross && cross.savedCny != null ? homeMoney(cross.savedCny) : null, reviewScore: rev && !rev.empty && rev.total ? rev.score : null, reviewTotal: rev && !rev.empty ? rev.total : null, recentReviewScore: rev && !rev.empty && rev.recentScore != null ? rev.recentScore : null, hasDemo: !!(risk && risk.demos > 0), demoCount: risk ? risk.demos : 0, earlyAccess: !!(risk && risk.earlyAccess === true), comingSoon: !!(risk && risk.comingSoon === true), releaseDate: risk ? risk.releaseDate : '', }; }), }; const clusters = clusterRows(cards).map(group => ({ month: group.key, label: group.label, count: group.total, wishlist: group.wish, crowded: group.crowded, apps: group.names, })); let riskDemos = 0; let riskEa = 0; let riskSoon = 0; riskState.rows.forEach(rec => { if (!rec) return; if (rec.demos > 0) riskDemos += 1; if (rec.earlyAccess === true) riskEa += 1; if (rec.comingSoon === true) riskSoon += 1; }); const risk = { scanned: riskState.rows.size, at: riskState.at, withDemo: riskDemos, earlyAccess: riskEa, comingSoon: riskSoon, }; const payload = { v: 1, updatedAt: Date.now(), source: 'personalcalendar', upcomingCount: built.upcoming.length, recentCount: built.recent.length, upcoming: built.upcoming, recent: built.recent, budget, risk, clusters, }; try { localStorage.setItem(RELEASE_BRIDGE_KEY, JSON.stringify(payload)); } catch (e) { try { payload.upcoming = payload.upcoming.slice(0, 60); payload.recent = payload.recent.slice(0, 60); if (payload.budget && Array.isArray(payload.budget.games)) payload.budget.games = payload.budget.games.slice(0, 60); payload.truncated = true; localStorage.setItem(RELEASE_BRIDGE_KEY, JSON.stringify(payload)); } catch (e2) { /* 放弃 */ } } } let applying = false; function refresh() { if (!document.documentElement) return; applying = true; try { ensurePanel(); const now = new Date(); lastCards = collectCards(now); const ctx = { cards: lastCards, settings, now, fx, extra: {} }; ordered(steps).forEach(step => step.run(ctx)); ordered(slots).forEach(slot => { if (slot.update) slot.update(ctx); }); scheduleReleaseBridge(lastCards); scheduleReleaseNotify(lastCards); } catch (error) { console.warn('[SPC] 刷新失败', error); } finally { applying = false; } } const api = { version: 1, registerStep, registerSlot, getCards: () => lastCards.slice(), getSettings: () => Object.assign({}, settings), setSettings(patch) { const before = settings.home; settings = saveSettings(Object.assign({}, settings, patch || {})); if (settings.home !== before) rehomeCross(); syncPanelInputs(); if (!applying) refresh(); return Object.assign({}, settings); }, getRates: () => (fx.rates ? Object.assign({}, fx.rates) : null), // 汇率来源状态:source 是实际用的那张表从哪来(cache/erapi/augmented/jsdelivr/snapshot) getRateSource: () => ({ source: fx.source, anchor: fx.anchor, mode: settings.fxMode, manual: Object.assign({}, settings.fxManual), loadedAt: fx.loadedAt, steam: fx.steam ? { source: fx.steam.source, rescued: !!fx.steam.rescued, rateDate: fx.steam.rateDate || 0, ts: fx.steam.ts || 0, } : null, spot: fx.spot ? { source: fx.spot.source, rescued: !!fx.spot.rescued, rateDate: fx.spot.rateDate || 0, ts: fx.spot.ts || 0, } : null, warnings: (fx.warnings || []).slice(), error: fx.manualError || fx.error || '', }), refreshRates: () => refreshRates(), getCrossRows: () => Object.fromEntries(crossState.rows), getReviewRows: () => Object.fromEntries(reviewState.rows), getRiskRows: () => Object.fromEntries(riskState.rows), getClusters: () => clusterRows(lastCards), scanRisk: force => startRiskScan(!!force), scanReviews: force => startReviews(!!force), // compareRegions({force:true}) 整批重取;compareRegions() 只补新款 compareRegions: opts => startCrossCompare(typeof opts === 'object' && opts ? opts : {}), // 导出必须走 exportRows(budgetView 才补 label/homeAmount), // 直接喂 budgetRows 的行会缺游戏名和本币价两列。 exportMarkdown: () => { const picked = exportRows(); return buildExportMarkdown(picked.rows, picked.view); }, exportCsv: () => buildExportCsv(exportRows().rows), getReleaseCache() { try { const raw = localStorage.getItem(RELEASE_BRIDGE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } }, convert: (amount, fromId, toId) => convertCurrency(amount, fromId, toId, fx.rates), refresh, }; try { const host = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; host.__SPC__ = api; } catch (e) { /* ignore */ } GM_addStyle(` .spc-panel { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; width: 360px; max-width: calc(100vw - 24px); /* 1.8.0 动态高度:不再用 72vh 把内容截断。高度随当前页签内容自适应, 上限是「视口高度 - 上下边距」,超出才在 .spc-body 内部滚动。 1.8.2 边距从 32px 收到 24px,能多显示一屏内容。 */ max-height: calc(100vh - 24px); display: flex; flex-direction: column; color: #e2e8f0; background: rgba(15, 19, 42, 0.94); border: 1px solid rgba(139, 92, 246, 0.35); border-radius: 12px; box-shadow: 0 16px 40px rgba(49, 24, 96, 0.45); font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .spc-head { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(139, 92, 246, 0.2); cursor: move; user-select: none; } .spc-head strong { font-size: 13px; } /* 品牌:标题前面的日历方块。折叠成胶囊后这是唯一一眼能认出插件的东西, 所以给它渐变底 + 柔光,别做成跟文字一样灰。 */ .spc-brand { display: inline-flex; align-items: center; gap: 7px; min-width: 0; } .spc-brand strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .spc-brand-mark { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex: 0 0 auto; border-radius: 6px; background: linear-gradient(145deg, #a78bfa 0%, #7c3aed 55%, #4f46e5 100%); color: #fff; box-shadow: 0 2px 8px rgba(124, 58, 237, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.3); } .spc-brand-mark svg { width: 13px; height: 13px; display: block; } .spc-head-actions { display: inline-flex; align-items: center; gap: 8px; } .spc-head .spc-textbtn { cursor: pointer; } /* 图标按钮:标题栏里的刷新 / 重置。用 SVG 不用字符 —— ⟲ 跟刷新太像,点错过。 */ .spc-head-actions .spc-icobtn { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: 1px solid transparent; border-radius: 6px; background: transparent; color: #c4b5fd; cursor: pointer; } .spc-head-actions .spc-icobtn:hover { background: rgba(139, 92, 246, 0.18); color: #ede9fe; } .spc-head-actions .spc-icobtn:focus-visible { outline: 1px solid rgba(139, 92, 246, 0.9); outline-offset: 1px; } .spc-head-actions .spc-icobtn.is-busy { color: #a78bfa; } .spc-icobtn svg { width: 14px; height: 14px; display: block; } .spc-head-actions .spc-icobtn.is-busy svg { animation: spc-spin 0.9s linear infinite; } @keyframes spc-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .spc-textbtn { border: 0; background: transparent; color: #c4b5fd; cursor: pointer; font: inherit; } .spc-tabs { display: flex; flex: 0 0 auto; gap: 4px; margin: 8px 12px 0; padding: 4px; border-radius: 8px; background: rgba(255,255,255,0.06); } .spc-tabs button { flex: 1; min-height: 32px; border: 0; border-radius: 6px; background: transparent; color: rgba(226,232,240,0.62); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; } .spc-tabs button.is-on { background: #7c3aed; color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), 0 4px 12px rgba(124, 58, 237, 0.35); } .spc-collapsed .spc-tabs { display: none; } /* 折叠态收成小胶囊:原来收起还占着 360px 宽,白白挡住日历右下角的卡片 */ .spc-panel.spc-collapsed { width: auto; max-width: none; } .spc-panel.spc-collapsed .spc-head { padding: 6px 10px; border-bottom: 0; } .spc-panel.spc-collapsed .spc-head strong { font-size: 12px; } .spc-panel.spc-collapsed .spc-head-actions .spc-icobtn { display: none; } /* 折叠态的品牌方块放大一号 + 柔光呼吸:胶囊在 Steam 深色页面上要一眼能扫到 */ .spc-panel.spc-collapsed .spc-brand { gap: 8px; } .spc-panel.spc-collapsed .spc-brand-mark { width: 24px; height: 24px; border-radius: 7px; animation: spc-brand-pulse 2.6s ease-in-out infinite; } .spc-panel.spc-collapsed .spc-brand-mark svg { width: 15px; height: 15px; } @keyframes spc-brand-pulse { 0%, 100% { box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.55), 0 3px 10px rgba(124, 58, 237, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.3); } 50% { box-shadow: 0 0 0 1px rgba(196, 181, 253, 0.95), 0 6px 20px rgba(139, 92, 246, 0.9), inset 0 1px 0 rgba(255, 255, 255, 0.35); } } @media (prefers-reduced-motion: reduce) { .spc-panel.spc-collapsed .spc-brand-mark { animation: none; } } .spc-panel[data-spc-page="budget"] [data-spc-slot="fxSource"], .spc-panel[data-spc-page="analysis"] [data-spc-slot="fxSource"], .spc-panel[data-spc-page="deals"] [data-spc-slot="fxSource"] { display: none; } .spc-panel[data-spc-page="budget"] [data-spc-slot="summary"], .spc-panel[data-spc-page="budget"] [data-spc-slot="view"], .spc-panel[data-spc-page="budget"] [data-spc-slot="analysis"], .spc-panel[data-spc-page="budget"] [data-spc-slot="deals"], .spc-panel[data-spc-page="budget"] [data-spc-slot="calc"], .spc-panel[data-spc-page="analysis"] [data-spc-slot="summary"], .spc-panel[data-spc-page="analysis"] [data-spc-slot="view"], .spc-panel[data-spc-page="analysis"] [data-spc-slot="budget"], .spc-panel[data-spc-page="analysis"] [data-spc-slot="deals"], .spc-panel[data-spc-page="analysis"] [data-spc-slot="calc"], .spc-panel[data-spc-page="deals"] [data-spc-slot="summary"], .spc-panel[data-spc-page="deals"] [data-spc-slot="view"], .spc-panel[data-spc-page="deals"] [data-spc-slot="budget"], .spc-panel[data-spc-page="deals"] [data-spc-slot="analysis"], .spc-panel[data-spc-page="deals"] [data-spc-slot="calc"], .spc-panel[data-spc-page="fx"] [data-spc-slot="budget"], .spc-panel[data-spc-page="fx"] [data-spc-slot="analysis"], .spc-panel[data-spc-page="fx"] [data-spc-slot="deals"] { display: none; } /* body 是唯一可压缩、可滚动的区域:flex:1 + min-height:0 让它在限高时自己滚, 而不是把下面的「汇率来源 / 价格计算器 / 首发口碑」裁掉。 */ .spc-body { flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding: 12px; scrollbar-width: thin; scrollbar-color: rgba(139,92,246,0.45) transparent; } .spc-body::-webkit-scrollbar { width: 8px; } .spc-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.45); border-radius: 4px; } .spc-body::-webkit-scrollbar-track { background: transparent; } .spc-collapsed .spc-body { display: none; } .spc-summary { margin: 0 0 6px; font-weight: 650; } .spc-note { margin: 0 0 10px; color: #94a3b8; } [data-spc-bind="fxStatus"] { white-space: pre-line; } .spc-check, .spc-field { display: flex; align-items: center; gap: 6px; margin: 6px 0; min-width: 0; } .spc-field { justify-content: space-between; } .spc-panel input[type="text"] { box-sizing: border-box; width: 100%; max-width: 100%; border-radius: 6px; padding: 4px 6px; font: inherit; background: #1b1730; color: #f8fafc; border: 1px solid rgba(139, 92, 246, 0.28); } .spc-field .spc-pick { width: 156px; flex: 0 0 156px; } .spc-pick { min-width: 0; } .spc-pick-btn { display: flex; align-items: center; gap: 6px; width: 100%; box-sizing: border-box; background: #1b1730; color: #f8fafc; border: 1px solid rgba(139, 92, 246, 0.45); border-radius: 6px; padding: 4px 6px; font: inherit; cursor: pointer; text-align: left; } .spc-pick-btn > span:not(.spc-currency-flag) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } .spc-pick-caret { flex: 0 0 12px; color: #94a3b8; } .spc-currency-flag svg { width: 100%; height: 100%; display: block; border-radius: 2px; box-shadow: 0 0 0 1px rgba(255,255,255,.18); } .spc-menu { position: fixed; z-index: 2147483646; display: flex; flex-direction: column; color: #f8fafc; background: #16132c; border: 1px solid rgba(139, 92, 246, 0.55); border-radius: 10px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); overflow: hidden; } .spc-menu-list { overflow-x: hidden; overflow-y: auto; padding: 4px; overscroll-behavior: contain; } .spc-menu-list::-webkit-scrollbar { width: 8px; } .spc-menu-list::-webkit-scrollbar-thumb { background: rgba(139, 92, 246, 0.45); border-radius: 4px; } .spc-currency-item { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box; border: 0; background: transparent; color: #f8fafc; border-radius: 6px; padding: 6px 8px; cursor: pointer; font: inherit; font-size: 13px; text-align: left; } .spc-currency-item small { margin-left: auto; color: #94a3b8; font-size: 12px; } .spc-currency-item:hover, .spc-currency-item.active { background: rgba(139, 92, 246, 0.28); } .spc-hero { position: sticky; top: 0; z-index: 1; padding: 12px; border-radius: 12px; background: linear-gradient(180deg, rgba(139,92,246,0.18), rgba(139,92,246,0.08)), #12162b; border: 1px solid rgba(139,92,246,0.28); box-shadow: inset 0 1px 0 rgba(255,255,255,0.06); } .spc-hero-kicker { margin: 0; font-size: 11px; color: #c4b5fd; } .spc-hero-total { margin-top: 4px; font-size: 22px; line-height: 1.2; font-weight: 650; color: #fff; font-variant-numeric: tabular-nums; letter-spacing: -0.03em; } .spc-hero-note { margin: 4px 0 0; font-size: 12px; color: rgba(226,232,240,0.72); } .spc-budget-empty { margin: 8px 0 0; color: rgba(226,232,240,0.72); } .spc-budget-list { margin-top: 8px; } .spc-sec { margin-top: 10px; padding: 10px; border: 1px solid rgba(139,92,246,0.18); border-radius: 10px; background: rgba(139,92,246,0.05); } .spc-export-bar { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; } .spc-export-bar + .spc-sec { margin-top: 0; } .spc-export-bar .spc-export-note { flex: 1 1 auto; text-align: right; } .spc-subhead { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 0 4px; font-weight: 700; font-size: 12px; color: #c4b5fd; } .spc-subhead > span { position: relative; padding-left: 9px; } .spc-subhead > span::before { content: ''; position: absolute; left: 0; top: 50%; width: 3px; height: 11px; transform: translateY(-50%); border-radius: 2px; background: #8b5cf6; } .spc-cross-row { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 4px 8px; margin-top: 4px; border-radius: 8px; background: rgba(255,255,255,0.04); } .spc-cross-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } .spc-cross-region { flex: 0 0 auto; font-size: 12px; color: #e2e8f0; } .spc-cross-save { flex: 0 0 auto; font-size: 12px; font-weight: 700; color: #4ade80; } .spc-badge { display: inline-flex; align-items: center; flex: 0 0 auto; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 700; } .spc-badge-good { background: rgba(74, 222, 128, 0.16); color: #86efac; } .spc-badge-mid { background: rgba(250, 204, 21, 0.16); color: #fde047; } .spc-badge-bad { background: rgba(248, 113, 113, 0.16); color: #fca5a5; } .spc-badge-mute { background: rgba(148, 163, 184, 0.16); color: #cbd5e1; } .spc-risk { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; flex: 1 0 100%; min-width: 0; } .spc-cluster-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; } .spc-cluster-label { flex: 0 0 auto; font-size: 12px; color: #e2e8f0; min-width: 62px; } .spc-cluster-bar { flex: 1 1 auto; min-width: 0; height: 8px; border-radius: 999px; background: rgba(255,255,255,0.08); overflow: hidden; } .spc-cluster-bar i { display: block; height: 100%; border-radius: 999px; background: #64748b; } .spc-cluster-row.is-crowded .spc-cluster-bar i { background: linear-gradient(90deg, #f59e0b, #ef4444); } .spc-cluster-count { flex: 0 0 auto; font-size: 11px; color: #94a3b8; } .spc-export-note { font-size: 11px; color: #94a3b8; } .spc-cross-summary { margin: 4px 2px 0; font-size: 11px; color: #94a3b8; } .spc-game { display: flex; align-items: center; gap: 8px; min-height: 44px; margin-top: 4px; padding: 4px 8px; border-radius: 8px; background: rgba(255,255,255,0.04); color: inherit; text-decoration: none; } .spc-game:hover { background: rgba(255,255,255,0.08); } .spc-cover { width: 72px; height: 34px; flex: 0 0 72px; object-fit: cover; border-radius: 4px; background: #1b1730; } .spc-cover-fallback { display: flex; align-items: center; justify-content: center; font-weight: 650; color: #c4b5fd; background: rgba(139,92,246,0.22); } .spc-budget-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font-size: 13px; color: rgba(226,232,240,0.92); } .spc-budget-amt { margin-left: auto; flex: 0 0 auto; font-size: 13px; font-weight: 650; color: #f8fafc; font-variant-numeric: tabular-nums; } .spc-calc-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 8px 0 6px; font-weight: 700; } .spc-amount-presets { margin-top: 6px; } .spc-result { cursor: pointer; } .spc-rate-reverse { color: #64748b; } .spc-calc-source { margin: 6px 0 0; font-size: 11px; } .spc-subhead-actions { display: inline-flex; align-items: center; gap: 8px; } .spc-textbtn.is-on { color: #f5f3ff; } .spc-fx-brief { margin: 0; font-size: 11px; line-height: 1.5; } .spc-fx-advanced { margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(139,92,246,0.25); } .spc-fx-advanced[hidden] { display: none; } .spc-fx-compare { margin-top: 10px; padding-top: 6px; border-top: 1px solid rgba(139,92,246,0.16); } .spc-compare-head { margin-bottom: 2px; font-size: 11px; color: #94a3b8; } .spc-fx-compare-row { display: flex; justify-content: space-between; gap: 8px; padding: 2px 0; font-size: 11px; } .spc-fx-compare-row b { font-weight: 650; color: #e2e8f0; font-variant-numeric: tabular-nums; } .spc-advanced { margin: 8px 0 2px; border: 1px solid rgba(139,92,246,0.18); border-radius: 8px; } .spc-advanced > summary { cursor: pointer; padding: 6px 8px; color: #c4b5fd; font-size: 12px; user-select: none; } .spc-advanced-body { padding: 0 8px 6px; } .spc-presets { display: flex; flex-wrap: wrap; gap: 6px; } .spc-chip { border: 1px solid rgba(139, 92, 246, 0.35); background: rgba(139, 92, 246, 0.12); color: #ddd6fe; border-radius: 999px; padding: 3px 8px; cursor: pointer; font: inherit; } .spc-chip.is-on { background: rgba(139, 92, 246, 0.45); border-color: rgba(167, 139, 250, 0.9); color: #f5f3ff; } /* 芯片上的命中计数:小一号、压暗,点之前就能看出哪个标签有货 */ .spc-chip-count { margin-left: 5px; font-size: 10px; font-weight: 700; opacity: 0.72; } .spc-chip.is-on .spc-chip-count { opacity: 1; } /* 风险筛选:一排芯片,允许换行 */ .spc-risk-filters { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 8px; } /* 风险列表自带高度上限 + 内部滚动:几十条风险不该把面板撑到顶 */ .spc-risk-list { max-height: 320px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: rgba(139,92,246,0.45) transparent; } .spc-risk-list::-webkit-scrollbar { width: 8px; } .spc-risk-list::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.45); border-radius: 4px; } .spc-risk-list::-webkit-scrollbar-track { background: transparent; } .spc-regions { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 6px; } .spc-calc-row { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px; margin-top: 8px; } .spc-calc-row label { display: flex; flex-direction: column; gap: 4px; min-width: 0; color: #94a3b8; } .spc-calc-row select { width: 100%; } .spc-panel select { background: #1b1730; color: #f8fafc; border: 1px solid rgba(139, 92, 246, 0.45); border-radius: 6px; padding: 4px 6px; font: inherit; } .spc-manual { display: none; margin-top: 8px; } .spc-manual.is-on { display: block; } .spc-toast { position: fixed; left: 50%; bottom: 28px; transform: translate(-50%, 12px); z-index: 2147483600; padding: 7px 14px; border-radius: 999px; color: #f8fafc; background: rgba(15, 19, 42, 0.94); border: 1px solid rgba(139, 92, 246, 0.45); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4); font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease; } .spc-toast.is-on { opacity: 1; transform: translate(-50%, 0); } .spc-result { margin-top: 8px; font-size: 18px; font-weight: 750; color: #fff; } .spc-rate { color: #94a3b8; margin-top: 2px; } .spc-fx { display: block; margin-top: 2px; font-size: 11px; line-height: 1.3; color: #c4b5fd; font-weight: 650; } .spc-fx s { color: #94a3b8; font-weight: 500; margin-right: 4px; } .spc-low { display: block; color: #94a3b8; font-weight: 500; } a[data-spc-wish="1"] { box-shadow: inset 0 0 0 2px rgba(74, 222, 128, 0.9); border-radius: 4px; } [data-spc-hide="1"] { display: none !important; } `); let timer = 0; let observedBody = null; function schedule() { if (applying) return; clearTimeout(timer); timer = setTimeout(() => { bindObservers(); refresh(); }, 200); } // 观察器只管「别人动了页面」。我们自己往 挂面板/下拉菜单、 // 或从面板里增删节点,都不该触发 refresh —— 否则点开下拉就重算一轮。 function nodeIsOurs(node) { if (!node) return true; if (node.nodeType !== 1) return true; // 文本节点跟着父节点判定 if (node.matches && node.matches('[data-spc-root], [data-spc-fx], [data-spc-risk]')) return true; return !!(node.closest && node.closest('[data-spc-root], [data-spc-fx], [data-spc-risk]')); } function mutationIsForeign(mutation) { const target = mutation.target; const el = target && target.nodeType === 1 ? target : (target && target.parentElement); if (el && el.closest && el.closest('[data-spc-root], [data-spc-fx], [data-spc-risk]')) return false; const changed = Array.from(mutation.addedNodes || []).concat(Array.from(mutation.removedNodes || [])); if (changed.length && changed.every(nodeIsOurs)) return false; return true; } const observer = new MutationObserver(mutations => { if (applying) return; if (mutations.some(mutationIsForeign)) schedule(); }); function bindObservers() { const root = document.documentElement; if (!root) return; observer.observe(root, { childList: true }); const body = document.body; if (body && body !== observedBody) { observer.observe(body, { childList: true, subtree: true }); observedBody = body; } } // 全局快捷键:Ctrl+Alt+S 折叠/展开面板,Ctrl+Alt+R 刷新汇率。 // 特意避开 Ctrl+Shift+R(浏览器硬刷新)、Ctrl+Alt+方向键(窗口切换)这些系统键。 function isEditableTarget(el) { if (!el) return false; const tag = (el.tagName || '').toLowerCase(); return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true; } function handleGlobalKey(event) { if (!event.ctrlKey || !event.altKey || event.metaKey || event.shiftKey) return; if (isEditableTarget(event.target)) return; const key = (event.key || '').toLowerCase(); if (key === 's') { event.preventDefault(); toggleCollapse(); return; } if (key === 'r') { event.preventDefault(); refreshRates(); } } // 窗口尺寸变了,把面板收回可视区(别让标题栏拖出屏幕找不回来)。 function handlePanelResize() { if (!panelRoot || !panelRoot.classList.contains('is-moved')) return; const rect = panelRoot.getBoundingClientRect(); const pos = clampPos(rect.left, rect.top, panelRoot.offsetWidth, panelRoot.offsetHeight); panelRoot.style.left = pos.x + 'px'; panelRoot.style.top = pos.y + 'px'; savePanelPos(pos); } function start() { bindObservers(); document.addEventListener('keydown', handlePanelKey); document.addEventListener('keydown', handleGlobalKey); window.addEventListener('resize', handlePanelResize); refresh(); loadRates().then(refresh).catch(error => { console.warn('[SPC] 汇率加载失败', error); refresh(); }); // 页面长期开着也保持牌价新鲜:每小时无视 TTL 强刷一次双锚点(失败静默,快照兜底)。 setInterval(() => { ensureFx(true).then(refresh).catch(() => {}); }, 60 * 60 * 1000); let tries = 0; const watchdog = setInterval(() => { tries += 1; bindObservers(); const missingPanel = !panelRoot || !panelRoot.isConnected; const waitingCards = !lastCards.length; if (missingPanel || waitingCards) refresh(); if (!missingPanel && !waitingCards) clearInterval(watchdog); else if (tries >= 40) clearInterval(watchdog); }, 500); console.log('[SPC] v' + VERSION + ' 日历换算已启动,卡片 ' + lastCards.length); } if (document.body) start(); else document.addEventListener('DOMContentLoaded', start, { once: true }); })();