// ==UserScript== // @name Steam 个人日历增强 // @name:en Steam Personal Calendar // @namespace https://github.com/steam-card-view // @version 1.4.0 // @description 打开 Steam 个人日历,右下角把外币标价换成人民币。货币菜单带国旗、符号和地区名。预算面板可跨区域比价,并展示各游戏好评率,还能扫描试玩版与疑似抢先体验、同月扎堆预警、导出 Markdown/CSV 清单。打折会同时换算原价并划掉,愿望单游戏绿框标出,也可以只看这些。近期发售里、已在愿望单且有标价的游戏会按本币做预算,列表带封面;标价已是本币就直接相加。预算和换算分成两个标签。计算器预置卢比、美元、里拉、比索、卢布。即将推出可以只留近 30 天。跨区比价与首发口碑默认手动触发,可在面板里打开自动;标价支持后缀写法(如 140.000₫、Rp 92 999)。打开过日历后,即将发售、近期发售、愿望单预算和风险信息会留给 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. 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 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.enhancedsteam.com // @connect store.steampowered.com // @connect api.augmentedsteam.com // @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) { /* 注册失败不影响日历本身 */ } const VERSION = '1.4.0'; const RELEASE_BRIDGE_KEY = '__saa_bridge_spc__'; const SETTINGS_KEY = 'spc_settings_v1'; const FX_KEY = 'spc_fx_v1'; const FX_TTL = 60 * 60 * 1000; 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; 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: '显示本币换算', home: '本币', wishOnly: '只看愿望单', wishMark: '标出愿望单', horizon: '即将推出只看近 {n} 天', tabBudget: '预算', tabAnalysis: '分析', 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: '打开分析页时自动读口碑', 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} 款没取到,点「扫描风险信息」重试可补', 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', home: 'Currency', wishOnly: 'Wishlist only', wishMark: 'Mark wishlist', horizon: 'Upcoming: next {n} days', tabBudget: 'Budget', tabAnalysis: 'Analysis', 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', 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', 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, }; 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(); } 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 = Object.assign({}, DEFAULTS, 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; } merged.crossRegions = normalizeRegions(merged.crossRegions); if (Number(saved.v || 1) < SETTINGS_VERSION) { try { GM_setValue(SETTINGS_KEY, merged); } catch (e) { /* ignore */ } } return merged; } function saveSettings(next) { const merged = Object.assign({}, DEFAULTS, next, { v: SETTINGS_VERSION }); merged.crossRegions = normalizeRegions(merged.crossRegions); try { GM_setValue(SETTINGS_KEY, merged); } catch (e) { /* ignore */ } return merged; } function gmFetch(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, 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')), }); }); } const fx = { rates: null, loadedAt: 0, error: '' }; function ratesFresh(stamp) { return stamp && (Date.now() - stamp) < FX_TTL; } function readCachedRates() { const keys = [FX_KEY, 'swe_fx_cache']; for (const key of keys) { try { const cached = GM_getValue(key, null); if (cached && cached.rates && ratesFresh(cached.ts)) return cached.rates; } catch (e) { /* ignore */ } } return null; } async function loadRates() { if (fx.rates && ratesFresh(fx.loadedAt)) return fx.rates; const cached = readCachedRates(); if (cached) { fx.rates = cached; fx.loadedAt = Date.now(); fx.error = ''; return fx.rates; } let rates = null; try { const data = await gmFetch('https://api.enhancedsteam.com/v1/supportedcurrencies/'); // 这个端点的键名见过 rates,也见过 supported_currencies,两种都兜住 if (data && (data.rates || data.supported_currencies)) rates = data.rates || data.supported_currencies; } catch (e) { /* 下一来源 */ } if (!rates) { try { const data = await gmFetch('https://open.er-api.com/v6/latest/USD'); if (data && data.rates) rates = data.rates; } catch (e) { fx.error = e.message || 'fx'; } } if (rates) { fx.rates = rates; fx.loadedAt = Date.now(); fx.error = ''; try { GM_setValue(FX_KEY, { rates, ts: fx.loadedAt, base: 'USD' }); } catch (e) { /* ignore */ } } return fx.rates; } function convertCurrency(amount, fromId, toId, rates) { const value = Number(amount); if (!Number.isFinite(value)) return null; if (!fromId || !toId || fromId === toId) return value; if (!rates) return null; const from = Number(rates[fromId]); const to = Number(rates[toId]); if (!from || !to || from <= 0 || to <= 0) return null; return (value / from) * to; } 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 }); } 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: currencyById(settings.calcFrom).label, value: shown }); } function calcResult() { const amount = Number(String(settings.calcAmount).replace(/,/g, '')); if (!Number.isFinite(amount)) 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 }; // 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([gmFetch(base), 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 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(); }); } // ── 同月扎堆 ──────────────────────────────────────────────────────────── // 日历页同一款游戏会出现两张卡(日视图 + 周视图),所以必须按 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); }); } 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); }; 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 seen = new Set(); cards.forEach(card => { if (card.section !== 'upcoming') return; if (seen.has(card.appId)) return; seen.add(card.appId); const risk = riskState.rows.get(card.appId); if (!risk) return; const badges = []; if (risk.demos > 0) badges.push({ text: t('riskDemo'), tone: 'good' }); if (risk.earlyAccess === true) badges.push({ text: t('riskEa'), tone: 'mid' }); if (risk.comingSoon === true) badges.push({ text: t('riskSoon'), tone: 'bad' }); if (!badges.length) return; const line = document.createElement('div'); line.className = 'spc-cross-row'; const name = document.createElement('span'); name.className = 'spc-cross-name'; name.textContent = card.name || card.appId; line.appendChild(name); badges.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: () => ` `, 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: '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('review')}
${t('risk')}
${t('cluster')}
`, 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()); const refreshBtn = root.querySelector('[data-spc-refresh]'); if (refreshBtn) refreshBtn.addEventListener('click', () => { // 明确点「刷新」才无视缓存,但只重取已经加载过的那几块, // 不当场给没打开过的口碑/风险也来一轮几十个请求。 if (crossState.at || settings.crossOn || crossState.rows.size) startCrossCompare({ force: true }); if (reviewState.at || settings.reviewOn) startReviews(true); if (riskState.at) startRiskScan(true); }); // 区域勾选:至少留一个,否则比价无从谈起 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: 'calc', order: 40, mount: () => `
${t('calc')}
${PRESETS.map(item => ``).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(); }); }); 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-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.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 = ['budget', 'analysis', 'fx'].indexOf(settings.panelTab) >= 0 ? settings.panelTab : 'budget'; 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 = ['budget', 'analysis', 'fx']; 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)) { settings.collapsed = true; settings = saveSettings(settings); syncPanelInputs(); 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 result = panelRoot.querySelector('[data-spc-bind="result"]'); const rate = panelRoot.querySelector('[data-spc-bind="rate"]'); if (result && result.textContent !== calcResult()) result.textContent = calcResult(); const rateLine = rateText(); if (rate && rate.textContent !== rateLine) rate.textContent = rateLine; } 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 !== 'budget' && tab !== 'analysis' && tab !== 'fx') return; settings.panelTab = tab; settings = saveSettings(settings); closeCurrencyDropdown(); syncPanelInputs(); }); }); panelRoot.querySelector('[data-spc-toggle]').addEventListener('click', () => { settings.collapsed = !settings.collapsed; settings = saveSettings(settings); syncPanelInputs(); }); 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); 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); } 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); } 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), 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); max-height: 72vh; 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; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(139, 92, 246, 0.2); } .spc-head strong { font-size: 13px; } .spc-textbtn { border: 0; background: transparent; color: #c4b5fd; cursor: pointer; font: inherit; } .spc-tabs { display: flex; 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; } .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="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="calc"], .spc-panel[data-spc-page="fx"] [data-spc-slot="budget"], .spc-panel[data-spc-page="fx"] [data-spc-slot="analysis"] { display: none; } .spc-body { overflow-x: hidden; overflow-y: auto; padding: 12px; } .spc-collapsed .spc-body { display: none; } .spc-summary { margin: 0 0 6px; font-weight: 650; } .spc-note { margin: 0 0 10px; color: #94a3b8; } .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 { margin: 8px 0 6px; font-weight: 700; } .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-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-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; } } function start() { bindObservers(); document.addEventListener('keydown', handlePanelKey); refresh(); loadRates().then(refresh).catch(error => { console.warn('[SPC] 汇率加载失败', error); refresh(); }); 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 }); })();