// ==UserScript==
// @name Steam 个人日历增强
// @name:en Steam Personal Calendar
// @namespace https://github.com/steam-card-view
// @version 1.7.0
// @description 打开 Steam 个人日历,右下角把外币标价换成人民币。货币菜单带国旗、符号和地区名。预算面板可跨区域比价,并展示各游戏好评率,还能扫描试玩版与疑似抢先体验、同月扎堆预警、导出 Markdown/CSV 清单。打折会同时换算原价并划掉,愿望单游戏绿框标出,也可以只看这些。近期发售里、已在愿望单且有标价的游戏会按本币做预算,列表带封面;标价已是本币就直接相加。预算和换算分成两个标签。计算器预置卢比、美元、里拉、比索、卢布。即将推出可以只留近 30 天。跨区比价与首发口碑默认手动触发,可在面板里打开自动;标价支持后缀写法(如 140.000₫、Rp 92 999)。汇率来源可切自动或手动:自动按多个公开接口轮着取,断了就用本地快照;手动可自己钉一个汇率,接口挂了一样能换算。面板可拖动、位置会记住。汇率走 Steam 参考价与即期牌价双锚点,陈旧牌价自动弃用、断网有快照互救。愿望单游戏近 7 天发售会弹浏览器通知(可关)。口碑与风险扫描带限流和 429 退避。愿望单折扣雷达把「即将推出×愿望单」的游戏按 Steam 官方现价与好评摘要列出,可按折扣力度/好评率/价格排序,一键生成 1920×1080 高清分享卡片。打开过日历后,即将发售、近期发售、愿望单预算和风险信息会留给 AI 助手读取。
// @description:en Open Steam’s Personal Calendar and a corner panel converts foreign prices into yuan. Currency menus show flags, symbols, and region names. Budget rows can compare prices across regions, show each game’s review score, scan demo and likely-early-access flags, warn about crowded release months, and export the list as Markdown or CSV. Discounts keep a struck-through original, wishlist games get a green outline, and the list can show only those. Recent releases that are wishlisted and priced are totaled in your currency; prices already in that currency are summed directly. Presets cover rupees, dollars, lira, pesos, and rubles. Upcoming releases can be limited to 30 days. Region comparison and review scores are manual by default and can be automated in the panel; prices with suffix currency symbols are parsed too. Rates use dual anchors (Steam reference + market spot) with stale-data rejection and snapshot fallback. A browser notification fires for wishlist games releasing within 7 days (toggleable). Review and risk scans are rate-limited with 429 backoff. A wishlist deal radar lists upcoming wishlist games with official Steam price and review summaries, sortable by discount, review score, or price, and exports a 1920x1080 HD share card. After the calendar has been opened, upcoming and recent releases are cached for the AI assistant.
// @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 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 链互救,快照时间如实保留。
* ===========================================================================
*/
(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.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 () {},
};
}
// ---- 无状态纯函数(实例与顶层 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 || {});
// 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: [] };
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 };
});
}
/**
* 取双锚点汇率。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);
}
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: [] }) : 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,
getMem: function () { return mem; },
// 暴露给测试
_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.7.0';
const RELEASE_BRIDGE_KEY = '__saa_bridge_spc__';
const SETTINGS_KEY = 'spc_settings_v1';
// 旧汇率键: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: '重置位置',
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}',
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} 款没取到,点「扫描风险信息」重试可补',
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',
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}',
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 to retry',
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 = 2;
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,
panelTab: 'budget',
// 比价和口碑一开页就是几十个请求,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,
};
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';
merged.notifyRelease = merged.notifyRelease !== false;
return merged;
}
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;
}
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;
}
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'));
}
// 换算统一走 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 `
${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: () => ` `, bind(root) { 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(); }, }); // 汇率来源:自动(公开接口 + 本地快照兜底)还是手动(用户自己钉一个汇率)。 // 接口挂掉的时候,手动汇率 + 快照还能让标价继续换算,不用退回原货币。 registerSlot({ id: 'fxSource', order: 25, mount: () => `${t('regionNote')}