// ==UserScript== // @name Steam 点数商店增强 // @name:en Steam Points Shop Enhancer // @namespace steam-points-shop-enhancer // @version 2.4.0 // @description 在 Steam 点数商店左侧菜单增加搜索功能,获取用户已拥有的游戏 appid 并交叉比对卡牌数据库,支持按名称/拼音首字母搜索有卡牌的游戏并一键跳转到对应游戏的点数商店页。无需 webapi_token,仅需 Cookie 认证。更新日志见脚本目录 README。v2.3.0 新增 SAA 桥:点数历史解析后镜像到 localStorage __saa_bridge_pts__(store 域),供 Steam AI Agent 的 readPluginCache 工具毫秒级读取(点数余额/获得/兑换/退款汇总),写入静默降级绝不影响本插件功能 // @description:en Enhance Steam Points Shop with sidebar search. Fetches owned app IDs via dynamicstore and cross-references with card game database. Search by name or pinyin initials, one-click jump to game's Points Shop page. No webapi_token required. // @author SmallRob // @license MIT // @match https://store.steampowered.com/points/shop* // @match https://store.steampowered.com/points/shop/* // @match https://store.steampowered.com/pointssummary* // @connect store.steampowered.com // @connect www.steamcardexchange.net // @connect raw.githubusercontent.com // @connect cdn.akamai.steamstatic.com // @connect cdn.cloudflare.steamstatic.com // @connect shared.akamai.steamstatic.com // @connect shared.fastly.steamstatic.com // @require https://update.greasyfork.org/scripts/589437/1894057/SGLV%20Core%20Library%20%28SGLV-Suite%29.js // @require https://update.greasyfork.org/scripts/590086/1895071/SGLV%20%E6%8B%BC%E9%9F%B3%E5%AD%97%E5%BA%93%20%28Library%29.js // @require https://scriptcat.org/lib/8510/0.1.2/SGLV%20Plugin%20Host.js // @require https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/libs/sglv-plugin-host.lib.js // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_info // @run-at document-idle // @tag Steam // @tag points // @tag plugin-trial // @icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%231b2838'/%3E%3Cstop offset='1' stop-color='%23101a26'/%3E%3C/linearGradient%3E%3ClinearGradient id='gold' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%23f5c050'/%3E%3Cstop offset='1' stop-color='%23d4961a'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23bg)'/%3E%3Ccircle cx='30' cy='28' r='16' fill='none' stroke='url(%23gold)' stroke-width='2.5' opacity='.25'/%3E%3Ccircle cx='30' cy='28' r='12' fill='none' stroke='url(%23gold)' stroke-width='2' opacity='.5'/%3E%3Ccircle cx='30' cy='28' r='8' fill='url(%23gold)'/%3E%3Ctext x='30' y='31.5' text-anchor='middle' font-family='Arial,sans-serif' font-weight='700' font-size='9' fill='%231b2838'%3EP%3C/text%3E%3Cpath d='M40 40 L52 52' stroke='url(%23gold)' stroke-width='4.5' stroke-linecap='round'/%3E%3C/svg%3E // ==/UserScript== (function () { // v2.32.0 插件版本清单注册(共享宿主 v0.3.0)—— 让 Steam AI Agent 能读到本插件版本号。 // ⚠️ 共享宿主缺失时静默跳过,绝不影响本插件主流程。 try { var __PH = (typeof unsafeWindow !== 'undefined' && unsafeWindow.__PLUGIN_HOST__) || (typeof window !== 'undefined' && window.__PLUGIN_HOST__); if (__PH && typeof __PH.registerPlugin === 'function') { __PH.registerPlugin({ id: 'points-shop', name: 'Steam 点数商店增强', family: 'utility' }); } } catch (e) { /* ignore */ } 'use strict'; // ==================== Plugin Trial · Phase 1 试点 ==================== // 这是 2.0.3 → 2.0.4-plugin-trial 唯一新增的代码块。 // 任何错误都被 try/catch 兜住,不影响下面原版主流程。 // 删除本块(约 35 行)+ 删 @require file:// + 删 @grant GM_info + version 改回 2.0.3 = 100% 还原。 try { var __host = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).__PLUGIN_HOST__; if (__host) { var registered = __host.register({ id: '@steam-points-shop-enhancer/main', version: '2.2.3', displayName: 'Steam 点数商店增强', description: '在 Steam 点数商店左侧菜单增加搜索功能', author: 'SmallRob', scope: { read: ['games'], // 读已拥有的游戏 appid write: [], action: [], }, effects: [ 'read:network', // 调 dynamicstore / card db API 'read:storage', // 读 GM_setValue 缓存 ], entry: { userscript: 'https://update.greasyfork.org/scripts/.../steam-points-shop-enhancer.user.js' }, ui: { panels: [ { id: 'spse-search', title: '点数商店搜索' }, ], }, }); if (registered) { console.info('%c[SPSE Plugin Trial] manifest 已注册,scope 最小化', 'color:#2f6f5e'); } } else { console.info('[SPSE Plugin Trial] host 未就绪(缺少 GM API 或 feature flag 关闭),manifest 跳过'); } } catch (e) { console.warn('[SPSE Plugin Trial] register 异常,主流程继续', e); } // ==================== Plugin Trial · 结束 ==================== // ==================== 环境守卫 ==================== if (window.self !== window.top) return; const _v = typeof GM_info !== 'undefined' ? GM_info.script.version : 'unknown'; console.log(`%c[Steam 点数商店增强] v${_v} 已启动`, 'color:#f0a030;font-weight:bold;font-size:13px'); // ==================== 依赖检测 ==================== const Pinyin = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVPinyin) || window.SGLVPinyin; if (!Pinyin || Pinyin.apiVersion !== 1) { console.warn('[SPSE] SGLVPinyin 未加载,拼音搜索降级为普通子串匹配'); } // ==================== 常量 ==================== const CARD_DB_CACHE_KEY = 'spse_card_db_cache'; const CARD_DB_TTL = 24 * 60 * 60 * 1000; // 卡牌数据库缓存 24 小时 const OWNED_APPS_TTL = 60 * 60 * 1000; // owned appids 内存缓存 1 小时 const SEARCH_LIMIT = 40; // 搜索结果最多显示条数 const DOM_OBSERVE_THROTTLE = 500; // DOM 观测节流 ms // v2.2: 点数历史来自 /pointssummary/ 页面本身,不再请求不存在的 YearlySummary 接口 const POINTS_PAGE_URL = 'https://store.steampowered.com/pointssummary/'; const POINTS_HISTORY_CACHE_KEY = 'spse_points_history_cache'; const POINTS_SUMMARY_TTL = 30 * 60 * 1000; // 卡牌数据库 API const STEAM_CARD_EXCHANGE_URL = 'https://www.steamcardexchange.net/api/request.php?GetInventory'; const BADGES_DB_URL = 'https://raw.githubusercontent.com/nolddor/steam-badges-db/main/data/badges.min.json'; // ==================== 工具函数 ==================== function escHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } const toast = { _el: null, _timer: null, _show(msg, type) { if (!this._el) { this._el = document.createElement('div'); this._el.id = 'spse-toast'; document.body.appendChild(this._el); } this._el.textContent = msg; this._el.className = 'spse-toast' + (type ? ' spse-toast-' + type : ''); requestAnimationFrame(() => this._el.classList.add('spse-toast-show')); if (this._timer) clearTimeout(this._timer); this._timer = setTimeout(() => this._el.classList.remove('spse-toast-show'), 2500); }, success(msg) { this._show(msg, 'success'); }, error(msg) { this._show(msg, 'error'); }, info(msg) { this._show(msg, 'info'); }, }; function detectLang() { const lang = (document.documentElement.lang || '').toLowerCase(); if (lang.includes('zh')) return 'zh'; return 'en'; } const isZh = detectLang() === 'zh'; const i18n = { searchPlaceholder: isZh ? '搜索游戏名称 / 拼音首字母…' : 'Search games by name / pinyin…', searchPlaceholderProfile: isZh ? '搜索游戏 / 拼音,并筛选个人资料…' : 'Search games / pinyin, filter profiles…', profileFilterHint: isZh ? '文字会同步到商店自带搜索,筛选当前个人资料商品' : 'Text is sent to the shop search and filters profile items', loadingOwned: isZh ? '正在获取游戏列表…' : 'Fetching owned games…', loadingCardDb: isZh ? '正在加载卡牌数据库…' : 'Loading card database…', indexed: isZh ? '已索引' : 'Indexed', games: isZh ? '款游戏' : 'games', cards: isZh ? '卡牌' : 'cards', noResults: isZh ? '未找到匹配的游戏' : 'No matching games found', jumpTo: isZh ? '查看点数商店' : 'View Points Shop', refreshIndex: isZh ? '刷新索引' : 'Refresh index', indexReady: isZh ? '索引就绪' : 'Index ready', indexFailed: isZh ? '数据获取失败' : 'Data fetch failed', ownedMode: isZh ? '已拥有' : 'Owned', allMode: isZh ? '全部卡牌' : 'All Cards', // v2.1: 点数面板 pointsTitle: isZh ? '我的点数' : 'My Points', pointsUnit: isZh ? '点数' : 'points', pointsBalance: isZh ? '当前余额' : 'Balance', pointsEarnedY: isZh ? '累计获得' : 'Earned', pointsSpentY: isZh ? '已兑换' : 'Redeemed', pointsRefundY: isZh ? '退款' : 'Refunded', pointsRefresh: isZh ? '刷新' : 'Refresh', pointsLoading: isZh ? '正在读取点数历史…' : 'Loading points history…', pointsUnavail: isZh ? '暂时读不到点数历史' : 'Points history unavailable', pointsUpdated: isZh ? '更新于' : 'Updated', pointsChartHint: isZh ? '获得 → 退款后 → 当前余额' : 'Earned → after refunds → balance', pointsOpenHistory: isZh ? '查看点数历史' : 'View points history', pointsFlowStart: isZh ? '起点' : 'Start', pointsFlowEarn: isZh ? '获得' : 'Earned', pointsFlowEarly: isZh ? '早期获得' : 'Earlier', pointsFlowRefund: isZh ? '退款' : 'Refunds', pointsFlowRedeem: isZh ? '兑换' : 'Redeemed', pointsFlowBalance: isZh ? '余额' : 'Balance', }; // GM_xmlhttpRequest Promise 包装 function gmFetch(url, opts = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: opts.method || 'GET', url: url, headers: opts.headers || {}, data: opts.data || null, timeout: opts.timeout || 20000, onload(r) { if (r.status >= 200 && r.status < 300) { try { resolve(opts.json ? JSON.parse(r.responseText) : r.responseText); } catch (e) { reject(new Error('JSON parse fail: ' + e.message)); } } else { reject(new Error(`HTTP ${r.status}`)); } }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); } // ==================== CSS 注入 ==================== GM_addStyle(` /* ==================== 搜索框 ==================== */ .spse-search-container { padding: 0 12px 12px 12px; margin-top: 5px; flex-shrink: 0; } .spse-search-wrap { position: relative; width: 100%; } .spse-search-input { width: 100%; box-sizing: border-box; padding: 8px 32px 8px 32px; background: rgba(0, 0, 0, 0.35); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 4px; color: #c7d5e0; font-size: 13px; font-family: "Motiva Sans", Arial, Helvetica, sans-serif; outline: none; transition: border-color 0.16s, background 0.16s; } .spse-search-input::placeholder { color: #5a6a7a; font-size: 12px; } .spse-search-input:focus { border-color: rgba(240, 160, 48, 0.5); background: rgba(0, 0, 0, 0.5); } .spse-search-icon { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: #5a6a7a; pointer-events: none; transition: color 0.16s; } .spse-search-input:focus + .spse-search-icon { color: #f0a030; } .spse-search-clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 20px; height: 20px; border: none; background: rgba(255, 255, 255, 0.08); border-radius: 50%; color: #8a9ba8; font-size: 14px; cursor: pointer; display: none; align-items: center; justify-content: center; line-height: 1; transition: background 0.16s, color 0.16s; } .spse-search-clear:hover { background: rgba(255, 255, 255, 0.18); color: #fff; } .spse-search-clear.spse-show { display: flex; } /* 个人资料栏目:官方 #point_search 与插件搜索框重复。 藏掉官方那一行(槽位是 input 往上第 3 层 div),文字仍写回该 input 驱动商品筛选。 */ #points_shop_root:has(#spse-search-container) div:has(> div > div > input#point_search), .spse-official-search-slot { display: none !important; } /* 加载进度条 */ .spse-progress-bar { width: 100%; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.06); margin: 8px 0 2px 0; overflow: hidden; position: relative; } .spse-progress-bar .spse-progress-fill { height: 100%; border-radius: 2px; background: linear-gradient(90deg, rgba(240,160,48,0.8), rgba(240,160,48,1)); width: 0%; transition: width 0.3s ease; position: relative; } .spse-progress-bar .spse-progress-fill::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: spse-progress-shimmer 1.5s ease-in-out infinite; } @keyframes spse-progress-shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } .spse-progress-bar.spse-ready .spse-progress-fill { width: 100%; background: #4caf50; animation: none; } .spse-progress-bar.spse-ready .spse-progress-fill::after { display: none; } .spse-progress-bar.spse-error .spse-progress-fill { width: 100%; background: #f44336; animation: none; } .spse-progress-bar.spse-error .spse-progress-fill::after { display: none; } .spse-status-text { font-size: 11px; color: #8a9ba8; text-align: center; margin-top: 4px; user-select: none; cursor: pointer; transition: color 0.16s; } .spse-status-text:hover { color: #c7d5e0; } .spse-status-text.spse-ready { color: #4caf50; } .spse-status-text.spse-error { color: #f44336; } /* ==================== 搜索结果下拉 ==================== */ .spse-results { position: fixed; max-width: calc(100vw - 32px); max-height: 420px; overflow-y: auto; background: rgba(23, 26, 33, 0.98); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 4px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); z-index: 100000; display: none; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } .spse-results.spse-show { display: block; animation: spse-fade-in 0.16s ease-out; } @keyframes spse-fade-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } .spse-results::-webkit-scrollbar { width: 5px; } .spse-results::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 3px; } .spse-results::-webkit-scrollbar-track { background: transparent; } .spse-result-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; cursor: pointer; transition: background 0.12s; border-bottom: 1px solid rgba(255, 255, 255, 0.04); } .spse-result-item:last-child { border-bottom: none; } .spse-result-item:hover { background: rgba(240, 160, 48, 0.12); } .spse-result-item.spse-active { background: rgba(240, 160, 48, 0.18); } .spse-result-thumb { width: 51px; height: 24px; flex-shrink: 0; border-radius: 3px; object-fit: cover; background: rgba(255, 255, 255, 0.04); } .spse-result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; } .spse-result-name { font-size: 13px; font-weight: 600; color: #c7d5e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .spse-result-meta { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #8a9ba8; } .spse-result-appid { font-size: 10px; color: #5a6a7a; font-variant-numeric: tabular-nums; } .spse-result-type { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; background: rgba(102, 192, 244, 0.12); color: #66c0f4; white-space: nowrap; } .spse-result-arrow { width: 14px; height: 14px; flex-shrink: 0; color: #5a6a7a; transition: color 0.12s; } .spse-result-item:hover .spse-result-arrow, .spse-result-item.spse-active .spse-result-arrow { color: #f0a030; } .spse-result-empty { padding: 24px 16px; text-align: center; color: #5a6a7a; font-size: 13px; } /* ==================== Toast ==================== */ #spse-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); padding: 8px 20px; background: rgba(23, 26, 33, 0.95); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 6px; color: #c7d5e0; font-size: 13px; font-family: "Motiva Sans", Arial, Helvetica, sans-serif; z-index: 100001; opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } #spse-toast.spse-toast-show { opacity: 1; transform: translateX(-50%) translateY(0); } #spse-toast.spse-toast-success { border-color: rgba(76, 175, 80, 0.4); } #spse-toast.spse-toast-error { border-color: rgba(244, 67, 54, 0.4); } #spse-toast.spse-toast-info { border-color: rgba(102, 192, 244, 0.3); } /* ==================== v2.1: 我的点数面板 ==================== */ .spse-points-panel { margin: 0 12px 12px 12px; background: rgba(0, 0, 0, 0.25); border: 1px solid rgba(255, 255, 255, 0.08); border-left: 2px solid #f0a030; border-radius: 3px; padding: 10px 12px; } .spse-points-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .spse-points-title { font-size: 10px; letter-spacing: 1px; text-transform: uppercase; color: #8f98a0; font-weight: 600; } .spse-points-refresh { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 18px; background: none; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 2px; color: #8f98a0; cursor: pointer; transition: color 0.15s, border-color 0.15s; } .spse-points-refresh:hover { color: #f0a030; border-color: rgba(240, 160, 48, 0.5); } .spse-points-balance { font-size: 22px; font-weight: 700; color: #fff; line-height: 1.1; font-variant-numeric: tabular-nums; } .spse-points-unit { font-size: 11px; color: #c7d5e0; font-weight: 400; margin-left: 4px; } .spse-points-rows { display: flex; margin-top: 8px; border-top: 1px solid rgba(255, 255, 255, 0.08); padding-top: 8px; } .spse-points-cell { flex: 1; text-align: left; min-width: 0; } .spse-points-cell + .spse-points-cell { border-left: 1px solid rgba(255, 255, 255, 0.08); padding-left: 10px; } .spse-points-cell-label { font-size: 9px; color: #8f98a0; text-transform: uppercase; letter-spacing: 0.5px; } .spse-points-cell-value { font-size: 13px; color: #c7d5e0; font-weight: 600; font-variant-numeric: tabular-nums; margin-top: 2px; } .spse-points-cell-value--earned { color: #8bc53f; } .spse-points-cell-value--spent { color: #d94a4a; } .spse-points-meta { margin-top: 8px; font-size: 9px; color: #5a6a7a; } .spse-points-msg { font-size: 11px; color: #8f98a0; line-height: 1.6; text-align: center; padding: 4px 0; } a.spse-points-title { color: inherit; text-decoration: none; } a.spse-points-title:hover { color: #f0a030; } .spse-points-chart { display: block; margin-top: 8px; padding: 6px 6px 4px; border-radius: 3px; background: rgba(0, 0, 0, 0.22); border: 1px solid rgba(255, 255, 255, 0.06); text-decoration: none; } .spse-points-chart:hover { border-color: rgba(240, 160, 48, 0.45); } .spse-spark { display: block; width: 100%; height: 52px; } .spse-points-chart-cap { display: block; margin-top: 2px; font-size: 9px; color: #8f98a0; text-align: center; letter-spacing: 0.2px; } .spse-points-chart:hover .spse-points-chart-cap { color: #f0a030; } .spse-points-history-link { display: block; margin-top: 8px; text-align: center; font-size: 11px; color: #f0a030; text-decoration: none; } .spse-points-history-link:hover { text-decoration: underline; } `); // ==================== 状态管理 ==================== const state = { games: [], // 游戏搜索索引 [{appid, name, cardCount, pointsShopUrl, image}] ownedAppIds: null, // Set 用户拥有的 appids,null = 未获取到(显示全部) ownedAppIdsAt: 0, // owned appids 获取时间 cardDb: null, // { "appid": { name, cardCount } } indexStatus: 'idle', // idle | building | ready | error indexSource: '', // 'owned' | 'all' searchTimer: null, activeResultIdx: -1, currentResults: [], }; // ==================== 数据获取层 ==================== /** * 获取用户已拥有的游戏 appid 列表 * 三级回退:GDynamicStore 页面变量 → dynamicstore/userdata API → null(降级显示全部) */ async function fetchOwnedAppIds() { // 内存缓存 if (state.ownedAppIds && (Date.now() - state.ownedAppIdsAt) < OWNED_APPS_TTL) { return state.ownedAppIds; } // L1: 页面 GDynamicStore.s_rgOwnedApps(零请求,即时) try { const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const raw = win.GDynamicStore?.s_rgOwnedApps; if (raw) { const arr = Array.isArray(raw) ? raw : Object.keys(raw); if (arr.length > 0) { const set = new Set(arr.map(Number).filter(n => Number.isFinite(n) && n > 0)); if (set.size > 0) { state.ownedAppIds = set; state.ownedAppIdsAt = Date.now(); console.log(`[SPSE] GDynamicStore 获取 ${set.size} 个 owned appids`); return set; } } } } catch (e) { /* ignore */ } // L2: dynamicstore/userdata API(Cookie 认证,无需 token) try { const data = await gmFetch('https://store.steampowered.com/dynamicstore/userdata/', { json: true, timeout: 15000 }); if (data?.rgOwnedApps && Array.isArray(data.rgOwnedApps)) { const set = new Set(data.rgOwnedApps.map(Number).filter(n => Number.isFinite(n) && n > 0)); state.ownedAppIds = set; state.ownedAppIdsAt = Date.now(); console.log(`[SPSE] dynamicstore/userdata 获取 ${set.size} 个 owned appids`); return set; } } catch (e) { console.warn('[SPSE] dynamicstore/userdata 获取失败:', e.message); } // L3: null(降级为显示全部卡牌游戏) console.warn('[SPSE] 无法获取 owned appids,将显示全部卡牌游戏'); return null; } /** * 加载卡牌游戏数据库 * 双数据源:SteamCardExchange API(主) → nolddor/steam-badges-db(备用) * GM_setValue 持久化缓存 24 小时 */ async function loadCardDatabase() { // 1. 检查 GM_setValue 缓存 try { const cached = GM_getValue(CARD_DB_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < CARD_DB_TTL) && cached.data) { console.log(`[SPSE] 卡牌数据库缓存命中: ${Object.keys(cached.data).length} 款游戏`); return cached.data; } } catch (e) { /* ignore */ } // 2. SteamCardExchange API(主数据源) try { const data = await gmFetch(STEAM_CARD_EXCHANGE_URL, { json: true, timeout: 30000 }); if (data?.data && Array.isArray(data.data)) { const db = {}; let count = 0; // data[i] = [[appid, name], ..., [size]] data.data.forEach(item => { if (!item?.[0]?.[0] || !item?.[3]?.[0]) return; const appid = String(item[0][0]); const name = item[0][1] || ''; const cardCount = item[3][0] || 0; if (appid && name) { db[appid] = { name, cardCount }; count++; } }); if (count > 0) { GM_setValue(CARD_DB_CACHE_KEY, { timestamp: Date.now(), data: db }); console.log(`[SPSE] SteamCardExchange 卡牌数据库加载完成: ${count} 款游戏`); return db; } } } catch (e) { console.warn('[SPSE] SteamCardExchange API 失败:', e.message); } // 3. nolddor/steam-badges-db(备用数据源) try { const data = await gmFetch(BADGES_DB_URL, { json: true, timeout: 30000 }); if (data && typeof data === 'object' && !Array.isArray(data)) { const db = {}; let count = 0; for (const [appid, info] of Object.entries(data)) { if (info && info.name) { db[appid] = { name: info.name, cardCount: info.size || 0 }; count++; } } if (count > 0) { GM_setValue(CARD_DB_CACHE_KEY, { timestamp: Date.now(), data: db }); console.log(`[SPSE] nolddor/steam-badges-db 加载完成: ${count} 款游戏`); return db; } } } catch (e) { console.warn('[SPSE] nolddor/steam-badges-db 失败:', e.message); } return null; } /** * 构建搜索索引:交叉比对 owned appids 与卡牌数据库 * @param {Set|null} ownedAppIds - 用户拥有的 appid 集合,null = 不按拥有过滤 * @param {Object} cardDb - 卡牌数据库 { "appid": { name, cardCount } } * @returns {Array<{appid, name, cardCount, pointsShopUrl, image}>} */ function buildSearchIndex(ownedAppIds, cardDb) { const games = []; if (!cardDb) return games; for (const [appidStr, info] of Object.entries(cardDb)) { const appid = parseInt(appidStr); if (!appid || !info?.name) continue; // 如果有 owned 列表,只保留用户拥有的游戏 if (ownedAppIds && !ownedAppIds.has(appid)) continue; games.push({ appid: appid, name: info.name, cardCount: info.cardCount || 0, pointsShopUrl: `/points/shop/app/${appid}`, image: `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_184x69.jpg`, }); } // 按名称排序 games.sort((a, b) => a.name.localeCompare(b.name, 'zh')); console.log(`[SPSE] 搜索索引构建完成: ${games.length} 款游戏`); return games; } // ==================== 搜索引擎 ==================== function searchGames(query) { const q = String(query || '').trim(); if (!q || !state.games.length) return []; // 优先使用拼音搜索 if (Pinyin && Pinyin.searchByPinyin) { const results = Pinyin.searchByPinyin(q, state.games, { limit: SEARCH_LIMIT, getName: (g) => g.name, }); return results; } // 降级:普通子串匹配 const qLower = q.toLowerCase(); const matched = []; for (const game of state.games) { const nameLower = (game.name || '').toLowerCase(); if (nameLower.includes(qLower)) { matched.push(game); if (matched.length >= SEARCH_LIMIT) break; } } return matched; } // ==================== v2.2: 点数历史 ==================== // /pointssummary/ 是账户页上的汇总表(购买 / 退款 / 已兑换 / 余额)。 // 侧栏把这些变化收成一条小曲线;历史页本身再挂分析看板。 const pointsState = { loading: false, data: null, error: null }; let _sparkSeq = 0; function gmGetText(url, timeout) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, timeout: timeout || 20000, onload(r) { const finalUrl = r.finalUrl || ''; if (/\/login(?:\/|\?|$)/i.test(finalUrl)) reject(new Error('login')); else if (r.status >= 200 && r.status < 300) resolve(r.responseText || ''); else reject(new Error('HTTP ' + r.status)); }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); } function parseLooseInt(text) { const s = String(text == null ? '' : text).replace(/\u00a0/g, ' ').trim(); const m = s.match(/[−–+-]?\d[\d,,.\s]*/); if (!m) return null; let raw = m[0].replace(/[,,\s]/g, '').replace(/[−–]/g, '-'); if (/^-?\d{1,3}(\.\d{3})+$/.test(raw)) raw = raw.replace(/\./g, ''); else raw = raw.replace(/\./g, ''); const n = parseInt(raw, 10); return Number.isFinite(n) ? n : null; } function pureAmount(text) { const t = String(text || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); if (!/^[−–+-]?\d[\d,,.\s]*$/.test(t)) return null; return parseLooseInt(t); } function isSummaryLabel(label) { return /购买|退款|兑换|余额|总数|合计|purchase|refund|redeem|balance|total|award|奖励|ugc|徽章|badge|content awards|用户生成/i.test(label); } function classifySummary(label) { const s = String(label || ''); const low = s.toLowerCase(); if (/余额|balance|残高/.test(low)) return 'balance'; if (/退款|refund/.test(low)) return 'refund'; if (/兑换|redeem|spent/.test(low)) return 'redeem'; if (/总数|合計|合计|(^|\s)total(\s|$)/i.test(s)) return 'total'; if (/购买|purchase|奖励|award|ugc|徽章|badge|content awards|用户生成/.test(low)) return 'earn'; return 'other'; } function parseSummaryTable(root) { if (!root) return null; const rows = []; root.querySelectorAll('tr').forEach(tr => { if (tr.closest && tr.closest('#spse-hx-strip, #spse-hx-modal, #spse-points-panel')) return; const cells = [...tr.children].filter(el => el.tagName === 'TD' || el.tagName === 'TH'); if (cells.length < 2) return; let amountCell = null; let amount = null; for (let i = cells.length - 1; i >= 0; i--) { const n = pureAmount(cells[i].textContent); if (n != null) { amountCell = cells[i]; amount = n; break; } } if (amount == null) return; const label = cells.filter(c => c !== amountCell) .map(c => (c.textContent || '').replace(/查看详情|查看详细|view details|show details|詳細を表示|詳細を見る/ig, '')) .join(' ').replace(/\s+/g, ' ').trim(); if (!label || label.length > 160 || !isSummaryLabel(label)) return; const kind = classifySummary(label); if (kind === 'other') return; if ((kind === 'refund' || kind === 'redeem') && amount > 0) amount = -amount; rows.push({ label, amount, kind }); }); return finalizeSummary(rows); } function parseTokenSummary(box) { if (!box) return null; const cells = [...box.children].filter(el => el.classList && el.classList.contains('cell')); const rows = []; for (let i = 0; i + 2 < cells.length; i += 3) { const label = (cells[i].textContent || '').replace(/\s+/g, ' ').trim(); let amount = pureAmount(cells[i + 1].textContent); if (!label || amount == null) continue; const cls = ((cells[i].className || '') + ' ' + (cells[i + 1].className || '')).toLowerCase(); let kind = 'other'; if (cls.indexOf('total_current') !== -1) kind = 'balance'; else if (cls.indexOf('total_spent') !== -1) kind = 'redeem'; else if (cls.indexOf('total_points') !== -1) kind = 'total'; else kind = classifySummary(label); if (kind === 'other') continue; if ((kind === 'refund' || kind === 'redeem') && amount > 0) amount = -amount; rows.push({ label: label, amount: amount, kind: kind }); } return finalizeSummary(rows); } function parsePointsSummaryHtml(html) { try { const doc = new DOMParser().parseFromString(html, 'text/html'); const box = doc.querySelector('.rewards_token_summary'); if (box) { const parsed = parseTokenSummary(box); if (parsed) return parsed; } let best = null; doc.querySelectorAll('table').forEach(table => { const parsed = parseSummaryTable(table); if (parsed && (!best || parsed.rows.length > best.rows.length)) best = parsed; }); return best || parseSummaryTable(doc.body); } catch (e) { return null; } } function flowStepLabel(row, earnRows) { if (row.kind === 'refund') return i18n.pointsFlowRefund; if (row.kind === 'redeem') return i18n.pointsFlowRedeem; if (row.kind === 'earn') { return earnRows.length > 1 && earnRows.indexOf(row) > 0 ? i18n.pointsFlowEarly : i18n.pointsFlowEarn; } return row.label.slice(0, 8); } function buildFlow(rows, balance) { const steps = rows.filter(r => r.kind === 'earn' || r.kind === 'refund' || r.kind === 'redeem'); if (!steps.length) return { flow: [], labels: [] }; const flow = [0]; const labels = [i18n.pointsFlowStart]; const earnRows = steps.filter(r => r.kind === 'earn'); let cursor = 0; steps.forEach(r => { cursor += r.amount; flow.push(cursor); labels.push(flowStepLabel(r, earnRows)); }); if (typeof balance === 'number' && Math.abs(flow[flow.length - 1] - balance) > 1) { flow.push(balance); labels.push(i18n.pointsFlowBalance); } return { flow, labels }; } function finalizeSummary(rows) { if (!rows || !rows.length) return null; let earned = 0, refunded = 0, redeemed = 0, balance = null; rows.forEach(r => { if (r.kind === 'earn') earned += r.amount; else if (r.kind === 'refund') refunded += Math.abs(r.amount); else if (r.kind === 'redeem') redeemed += Math.abs(r.amount); else if (r.kind === 'balance') balance = r.amount; }); if (balance == null && earned === 0 && redeemed === 0) return null; const built = buildFlow(rows, balance); return { balance: balance, earned: earned, refunded: refunded, redeemed: redeemed, rows: rows, flow: built.flow, flowLabels: built.labels, months: [], events: [], fetchedAt: Date.now(), partial: false, }; } function readPointsCache() { try { const cached = JSON.parse(GM_getValue(POINTS_HISTORY_CACHE_KEY, 'null')); if (cached && cached.data) return cached; } catch (e) { /* ignore */ } return null; } function writePointsCache(data) { const prev = readPointsCache(); const merged = Object.assign({}, data); if ((!merged.months || !merged.months.length) && prev && prev.data && prev.data.months && prev.data.months.length) { merged.months = prev.data.months; } if ((!merged.events || !merged.events.length) && prev && prev.data && prev.data.events && prev.data.events.length) { merged.events = prev.data.events; } try { GM_setValue(POINTS_HISTORY_CACHE_KEY, JSON.stringify({ data: merged, at: Date.now() })); } catch (e) { /* ignore */ } return merged; } function extractBalanceFromDom() { try { const root = document.getElementById('points_shop_root'); if (!root) return null; const nodes = root.querySelectorAll('div, span, p'); for (const el of nodes) { const own = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('').trim(); if (!/点数余额|points balance/i.test(own) || own.length > 40) continue; const scope = el.parentElement || el; const candidates = scope.querySelectorAll('div, span'); for (const c of candidates) { if (c === el) continue; const n = pureAmount(c.textContent); if (n != null && n >= 0 && (c.textContent || '').trim().length < 16) return n; } } } catch (e) { /* ignore */ } return null; } function fmtPoints(n) { return (typeof n === 'number' && Number.isFinite(n)) ? Math.abs(n).toLocaleString() : '—'; } function fmtSigned(n) { if (typeof n !== 'number' || !Number.isFinite(n)) return '—'; const body = Math.abs(n).toLocaleString(); if (n > 0) return '+' + body; if (n < 0) return '−' + body; return body; } function sparklineSvg(values) { if (!values || values.length < 2) return ''; const w = 280, h = 56, pad = 6; const id = 'spse-sg-' + (++_sparkSeq); let min = values[0], max = values[0]; values.forEach(v => { if (v < min) min = v; if (v > max) max = v; }); const span = (max - min) || 1; const pts = values.map((v, i) => { const x = pad + (i / (values.length - 1)) * (w - pad * 2); const y = pad + (1 - (v - min) / span) * (h - pad * 2); return [x, y]; }); const line = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' '); const area = line + ' L' + pts[pts.length - 1][0].toFixed(1) + ' ' + (h - pad) + ' L' + pts[0][0].toFixed(1) + ' ' + (h - pad) + ' Z'; const last = pts[pts.length - 1]; return ''; } function applyPointsData(data) { pointsState.data = data; pointsState.error = null; scheduleSaaPtsBridge(); // v2.3.0:点数数据应用后镜像到 SAA 桥(防抖写 localStorage) } // ==================== SAA 桥(Steam AI Agent 点数数据镜像,v2.3.0) ==================== // ai-agent 的 readPluginCache 工具(plugin="bridge", key="__saa_bridge_pts__")读本键做 // 点数余额/获得/兑换/退款汇总,毫秒级免 Steam 请求。结构约定见 ai-agent 仓库 // docs/plugin-cache-bridge-plan.md。写入 try/catch 静默降级,绝不影响本插件功能。 const SAA_PTS_BRIDGE_KEY = '__saa_bridge_pts__'; let _saaPtsBridgeTimer = null; function scheduleSaaPtsBridge() { if (_saaPtsBridgeTimer) return; _saaPtsBridgeTimer = setTimeout(() => { _saaPtsBridgeTimer = null; try { writeSaaPtsBridge(); } catch (e) { /* 静默:桥永不影响宿主 */ } }, 2000); } function writeSaaPtsBridge() { const d = pointsState.data; if (!d) return; const payload = { v: 1, updatedAt: Date.now(), balance: d.balance != null ? d.balance : null, // 当前点数余额 earned: d.earned != null ? d.earned : null, // 累计获得 refunded: d.refunded != null ? d.refunded : null, // 累计退款返还 redeemed: d.redeemed != null ? d.redeemed : null, // 累计兑换(消费) partial: d.partial === true, // true = 仅从 DOM 提取到余额,明细缺失 rows: Array.isArray(d.rows) ? d.rows.map(r => ({ label: r.label || '', amount: r.amount || 0, kind: r.kind || '' })) : [], }; try { localStorage.setItem(SAA_PTS_BRIDGE_KEY, JSON.stringify(payload)); } catch (e) { // Quota 降级(三桥统一三段式):截断明细重试;仍失败写占位(保留概览数字) try { payload.rows = payload.rows.slice(0, 2000); payload.truncated = true; localStorage.setItem(SAA_PTS_BRIDGE_KEY, JSON.stringify(payload)); } catch (e2) { try { localStorage.setItem(SAA_PTS_BRIDGE_KEY, JSON.stringify({ v: 1, updatedAt: Date.now(), rows: null, rowsSkipped: 'quota', balance: payload.balance, redeemed: payload.redeemed })); } catch (e3) { /* 放弃:不影响宿主 */ } } } } async function fetchPointsSummary(force) { if (pointsState.loading) return; if (!force) { const cached = readPointsCache(); if (cached && cached.data && (Date.now() - cached.at) < POINTS_SUMMARY_TTL) { applyPointsData(cached.data); renderPointsPanel(); return; } } pointsState.loading = true; pointsState.error = null; renderPointsPanel(); try { const html = await gmGetText(POINTS_PAGE_URL, 20000); const parsed = parsePointsSummaryHtml(html); if (!parsed) throw new Error('empty'); applyPointsData(writePointsCache(parsed)); console.log('[SPSE] 点数历史已解析 balance=' + parsed.balance + ' earned=' + parsed.earned + ' redeemed=' + parsed.redeemed); } catch (e) { const cached = readPointsCache(); if (cached && cached.data && (cached.data.balance != null || (cached.data.flow && cached.data.flow.length))) { applyPointsData(cached.data); } else { const dom = extractBalanceFromDom(); if (dom != null) { applyPointsData({ balance: dom, earned: null, refunded: null, redeemed: null, rows: [], flow: [], flowLabels: [], months: [], events: [], fetchedAt: Date.now(), partial: true, }); } else { pointsState.error = e.message || 'fetch failed'; } } console.warn('[SPSE] 点数历史读取失败:', e.message); } pointsState.loading = false; renderPointsPanel(); } function renderPointsPanel() { const panel = document.getElementById('spse-points-panel'); if (!panel) return; const fmtTime = (ts) => { const d = new Date(ts); const p = (x) => String(x).padStart(2, '0'); return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; }; const refreshSvg = '' + '' + '' + ''; const head = '
' + '' + escHtml(i18n.pointsTitle) + '' + '' + '
'; let body; const data = pointsState.data; if (pointsState.loading && !data) { body = '
' + escHtml(i18n.pointsLoading) + '
'; } else if (!data) { body = '
' + escHtml(i18n.pointsUnavail) + '
' + '' + escHtml(i18n.pointsOpenHistory) + ''; } else { const balance = data.balance != null ? data.balance : extractBalanceFromDom(); const chart = (data.flow && data.flow.length >= 2) ? '' + sparklineSvg(data.flow) + '' + escHtml(i18n.pointsChartHint) + ' · ' + escHtml(i18n.pointsOpenHistory) + '' : '' + escHtml(i18n.pointsOpenHistory) + ''; const stats = data.partial ? '' : '
' + '
' + escHtml(i18n.pointsEarnedY) + '
' + '
' + fmtPoints(data.earned) + '
' + '
' + escHtml(i18n.pointsSpentY) + '
' + '
' + fmtPoints(data.redeemed) + '
' + '
'; const metaBits = []; if (!data.partial && data.refunded) metaBits.push(i18n.pointsRefundY + ' ' + fmtPoints(data.refunded)); if (data.fetchedAt) metaBits.push(i18n.pointsUpdated + ' ' + fmtTime(data.fetchedAt)); body = '
' + fmtPoints(balance) + '' + escHtml(i18n.pointsUnit) + '
' + stats + chart + (metaBits.length ? '
' + escHtml(metaBits.join(' · ')) + '
' : ''); } panel.innerHTML = head + body; const refreshBtn = document.getElementById('spse-points-refresh'); if (refreshBtn) refreshBtn.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); fetchPointsSummary(true); }); } // ==================== 官方个人资料搜索(#point_search)==================== // 「游戏个人资料」栏目会在侧边栏渲染官方搜索框,和插件搜索框叠在一起。 // 官方框负责筛选当前商品(React 受控 input);插件框负责拼音跳转。 // 做法:藏掉官方框,把插件输入写进 #point_search,两边效果都保留。 let officialSearchEl = null; let officialPushGuard = { value: null, at: 0 }; function findOfficialSearchInput() { const root = document.getElementById('points_shop_root'); if (!root) return null; const byId = root.querySelector('#point_search'); if (byId && byId.tagName === 'INPUT') return byId; const inputs = root.querySelectorAll('input[type="text"], input:not([type])'); for (const el of inputs) { if (el.id === 'spse-search-input' || el.closest('#spse-search-container')) continue; const ph = (el.getAttribute('placeholder') || '').toLowerCase(); if (ph.includes('个人资料') || ph.includes('profile')) return el; } return null; } function officialSearchSlot(input) { let node = input; while (node && node.id !== 'points_shop_root') { if (node.classList && node.classList.contains('padding-bottom-none') && !node.querySelector('a[href*="/points/shop"]') && !node.querySelector('#spse-search-container')) { return node; } node = node.parentElement; } return input.parentElement || input; } /** * 写入 React 19 受控 input。 * React 会在元素上挂自己的 value setter 和 _valueTracker; * 必须走 HTMLInputElement.prototype 的 setter,tracker 才认为值变了,onChange 才会更新筛选。 */ function setReactInputValue(input, value) { if (!input) return; const next = value == null ? '' : String(value); if (input.value === next) return; const tracker = input._valueTracker; if (tracker && typeof tracker.setValue === 'function') { tracker.setValue(input.value); } const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value'); if (desc && desc.set) desc.set.call(input, next); else input.value = next; officialPushGuard = { value: next, at: Date.now() }; let ev; try { ev = new InputEvent('input', { bubbles: true, data: next, inputType: next ? 'insertText' : 'deleteContentBackward', }); } catch (e) { ev = new Event('input', { bubbles: true }); } input.dispatchEvent(ev); } function hideOfficialSearchSlot(input) { const slot = officialSearchSlot(input); document.querySelectorAll('.spse-official-search-slot').forEach((el) => { if (el !== slot) el.classList.remove('spse-official-search-slot'); }); if (slot && slot.classList) slot.classList.add('spse-official-search-slot'); } function applyProfileSearchPlaceholder(active) { const ours = document.getElementById('spse-search-input'); if (!ours) return; const ph = active ? i18n.searchPlaceholderProfile : i18n.searchPlaceholder; if (ours.placeholder !== ph) ours.placeholder = ph; ours.title = active ? i18n.profileFilterHint : ''; } /** * 官方搜索出现时藏掉它,并把当前插件搜索词灌进去。 * 若用户先在官方框里输入,则把该词接回插件框,避免筛选还在、输入框却是空的。 */ function syncOfficialSearch() { const official = findOfficialSearchInput(); const ours = document.getElementById('spse-search-input'); if (!official) { officialSearchEl = null; document.querySelectorAll('.spse-official-search-slot').forEach((el) => { el.classList.remove('spse-official-search-slot'); }); applyProfileSearchPlaceholder(false); return; } hideOfficialSearchSlot(official); applyProfileSearchPlaceholder(true); const appeared = official !== officialSearchEl; officialSearchEl = official; if (!appeared || !ours) return; const officialFocused = document.activeElement === official; if (official.value && (!ours.value || officialFocused)) { ours.value = official.value; const clearBtn = document.getElementById('spse-search-clear'); if (clearBtn) clearBtn.classList.add('spse-show'); handleSearch(official.value); console.log('[SPSE] 已接管官方个人资料搜索框,保留其当前关键词'); return; } if (ours.value !== official.value) { const justPushed = officialPushGuard.value === ours.value && (Date.now() - officialPushGuard.at) < 1000; if (!justPushed) setReactInputValue(official, ours.value); } console.log('[SPSE] 已隐藏官方个人资料搜索框,插件搜索会同步筛选商品'); } function pushOfficialSearch(value) { const official = findOfficialSearchInput(); if (!official) return; hideOfficialSearchSlot(official); applyProfileSearchPlaceholder(true); officialSearchEl = official; setReactInputValue(official, value); } // ==================== UI: 搜索框注入 ==================== function createSearchBoxHtml() { return `
${escHtml(i18n.loadingOwned)}
`; } function injectSearchBox() { if (document.getElementById('spse-search-container')) return false; const root = document.getElementById('points_shop_root'); if (!root) return false; // 查找侧边栏:React SPA 结构为 // #points_shop_root > div > div.Panel > div[style*="flex-direction: row"] > div[sidebar] + div[main] const flexRow = root.querySelector('[style*="flex-direction: row"]'); if (!flexRow) return false; const sidebar = flexRow.children[0]; if (!sidebar) return false; // 验证是否为侧边栏(包含 /points/shop 链接) const hasCategoryLinks = sidebar.querySelector('a[href*="/points/shop"]'); if (!hasCategoryLinks) return false; // 创建搜索框元素 const searchBox = document.createElement('div'); searchBox.innerHTML = createSearchBoxHtml(); const searchEl = searchBox.firstElementChild; // 插入到分类列表容器之前 const firstCatLink = sidebar.querySelector('a[href*="/points/shop"]'); let insertBefore = null; if (firstCatLink) { let node = firstCatLink; while (node && node !== sidebar) { const siblingLinks = node.querySelectorAll('a[href*="/points/shop"]'); if (siblingLinks.length >= 2 || node.parentElement === sidebar) { insertBefore = node; break; } node = node.parentElement; } } if (insertBefore && insertBefore.parentElement) { insertBefore.parentElement.insertBefore(searchEl, insertBefore); } else { sidebar.insertBefore(searchEl, sidebar.firstChild); } // 结果下拉框挂到 body,避免 React SPA 父级 transform 破坏 fixed 定位 if (!document.getElementById('spse-results')) { const resultsEl = document.createElement('div'); resultsEl.id = 'spse-results'; resultsEl.className = 'spse-results'; document.body.appendChild(resultsEl); } // v2.1: 点数面板插在搜索框下方(随搜索容器一起被重注入守卫覆盖) if (!document.getElementById('spse-points-panel')) { const pointsWrap = document.createElement('div'); pointsWrap.innerHTML = '
'; const pointsEl = pointsWrap.firstElementChild; searchEl.insertAdjacentElement('afterend', pointsEl); fetchPointsSummary(false); } bindSearchEvents(); syncOfficialSearch(); console.log('[SPSE] 搜索框已注入侧边栏'); return true; } function bindSearchEvents() { const input = document.getElementById('spse-search-input'); const clearBtn = document.getElementById('spse-search-clear'); const results = document.getElementById('spse-results'); const statusText = document.getElementById('spse-status-text'); if (!input) return; // 输入搜索。个人资料栏目下同时把文字写进官方 #point_search,筛选商品。 input.addEventListener('input', () => { const val = input.value; clearBtn.classList.toggle('spse-show', val.length > 0); pushOfficialSearch(val); if (state.searchTimer) clearTimeout(state.searchTimer); state.searchTimer = setTimeout(() => { handleSearch(val); }, 200); }); // 清除按钮 clearBtn.addEventListener('click', () => { input.value = ''; clearBtn.classList.remove('spse-show'); results.classList.remove('spse-show'); state.currentResults = []; state.activeResultIdx = -1; pushOfficialSearch(''); input.focus(); }); // 键盘导航 input.addEventListener('keydown', (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); navigateResults(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); navigateResults(-1); } else if (e.key === 'Enter') { e.preventDefault(); if (state.activeResultIdx >= 0 && state.currentResults[state.activeResultIdx]) { jumpToGame(state.currentResults[state.activeResultIdx]); } else if (state.currentResults.length > 0) { jumpToGame(state.currentResults[0]); } } else if (e.key === 'Escape') { results.classList.remove('spse-show'); input.blur(); } }); // 点击外部关闭结果 document.addEventListener('click', (e) => { if (!e.target.closest('#spse-search-container')) { results.classList.remove('spse-show'); } }); // 窗口变化时重新定位下拉框 window.addEventListener('resize', () => { if (results.classList.contains('spse-show')) positionResults(); }); window.addEventListener('scroll', () => { if (results.classList.contains('spse-show')) positionResults(); }, true); // 状态文字点击刷新 if (statusText) { statusText.addEventListener('click', () => { if (state.indexStatus === 'building') return; refreshIndex(); }); } } function navigateResults(dir) { const results = document.getElementById('spse-results'); if (!results || !state.currentResults.length) return; state.activeResultIdx += dir; if (state.activeResultIdx < 0) state.activeResultIdx = state.currentResults.length - 1; if (state.activeResultIdx >= state.currentResults.length) state.activeResultIdx = 0; const items = results.querySelectorAll('.spse-result-item'); items.forEach((el, i) => el.classList.toggle('spse-active', i === state.activeResultIdx)); const active = items[state.activeResultIdx]; if (active) active.scrollIntoView({ block: 'nearest' }); } // ==================== 搜索处理 ==================== function positionResults() { const resultsEl = document.getElementById('spse-results'); const input = document.getElementById('spse-search-input'); if (!resultsEl || !input) return; const rect = input.getBoundingClientRect(); const top = rect.bottom + 4; const left = rect.left; const width = rect.width; resultsEl.style.top = `${top}px`; resultsEl.style.left = `${left}px`; resultsEl.style.width = `${width}px`; } function handleSearch(query) { const resultsEl = document.getElementById('spse-results'); if (!resultsEl) return; const q = String(query || '').trim(); if (!q) { resultsEl.classList.remove('spse-show'); state.currentResults = []; state.activeResultIdx = -1; return; } const matched = searchGames(q); state.currentResults = matched; state.activeResultIdx = -1; if (!matched.length) { resultsEl.innerHTML = `
${escHtml(i18n.noResults)}
`; resultsEl.classList.add('spse-show'); positionResults(); return; } resultsEl.innerHTML = matched.map((game, idx) => { const cardStr = game.cardCount ? `${game.cardCount} ${i18n.cards}` : ''; return `
${escHtml(game.name)}
${cardStr ? `${escHtml(cardStr)}` : ''} ${game.appid}
`; }).join(''); resultsEl.classList.add('spse-show'); positionResults(); // 绑定点击事件 resultsEl.querySelectorAll('.spse-result-item').forEach((el) => { el.addEventListener('click', () => { const idx = parseInt(el.dataset.idx); if (state.currentResults[idx]) { jumpToGame(state.currentResults[idx]); } }); el.addEventListener('mouseenter', () => { state.activeResultIdx = parseInt(el.dataset.idx); resultsEl.querySelectorAll('.spse-result-item').forEach((e, i) => { e.classList.toggle('spse-active', i === state.activeResultIdx); }); }); }); } // ==================== 跳转导航 ==================== function jumpToGame(game) { if (!game) return; const path = game.pointsShopUrl; // /points/shop/app/{appid} // 关闭搜索结果 const results = document.getElementById('spse-results'); if (results) results.classList.remove('spse-show'); // 清空搜索框,并清掉官方个人资料筛选 const input = document.getElementById('spse-search-input'); if (input) { input.value = ''; const clearBtn = document.getElementById('spse-search-clear'); if (clearBtn) clearBtn.classList.remove('spse-show'); } pushOfficialSearch(''); // 使用 React SPA 导航(pushState + popstate 触发 React Router 重渲染) try { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); // 如果 React 没有响应,回退到整页跳转 setTimeout(() => { if (!window.location.pathname.includes(`/app/${game.appid}`)) { window.location.href = `https://store.steampowered.com${path}`; } }, 300); } catch (e) { window.location.href = `https://store.steampowered.com${path}`; } console.log(`[SPSE] 跳转到: ${game.name} → ${path}`); } // ==================== 索引进度更新 ==================== function updateProgressBar() { const bar = document.getElementById('spse-progress-bar'); const fill = document.getElementById('spse-progress-fill'); const textEl = document.getElementById('spse-status-text'); if (!bar || !fill || !textEl) return; bar.classList.remove('spse-ready', 'spse-error'); textEl.classList.remove('spse-ready', 'spse-error'); if (state.indexStatus === 'building') { const pct = state._progressPct || 10; fill.style.width = pct + '%'; textEl.textContent = state._progressText || i18n.loadingOwned; } else if (state.indexStatus === 'ready') { bar.classList.add('spse-ready'); textEl.classList.add('spse-ready'); const modeLabel = state.indexSource === 'owned' ? i18n.ownedMode : i18n.allMode; textEl.textContent = `${i18n.indexed} ${state.games.length} ${i18n.games} [${modeLabel}]`; } else if (state.indexStatus === 'error') { bar.classList.add('spse-error'); textEl.classList.add('spse-error'); textEl.textContent = i18n.indexFailed; } } function setProgress(pct, text) { state._progressPct = pct; state._progressText = text; updateProgressBar(); } // ==================== 索引构建主流程 ==================== async function refreshIndex() { state.indexStatus = 'building'; setProgress(10, i18n.loadingOwned); // 1. 并行获取 owned appids 和卡牌数据库 const [ownedAppIds, cardDb] = await Promise.all([ fetchOwnedAppIds().then(ids => { setProgress(40, i18n.loadingCardDb); return ids; }), loadCardDatabase().then(db => { setProgress(80, i18n.loadingCardDb); return db; }), ]); state.cardDb = cardDb; if (!cardDb) { console.error('[SPSE] 卡牌数据库加载失败'); state.indexStatus = 'error'; updateProgressBar(); toast.error(i18n.indexFailed); return; } // 2. 构建搜索索引 state.ownedAppIds = ownedAppIds; state.games = buildSearchIndex(ownedAppIds, cardDb); state.indexStatus = 'ready'; state.indexSource = ownedAppIds ? 'owned' : 'all'; updateProgressBar(); toast.success(`${i18n.indexReady}: ${state.games.length} ${i18n.games}`); console.log(`[SPSE] 索引构建完成: ${state.games.length} 款游戏 [${state.indexSource}]`); } // ==================== v2.2: 点数历史页分析看板 ==================== const HX = isZh ? { title: '点数分析', overview: '概览', curve: '变化曲线', mix: '收支构成', load: '读取消费明细', loading: '正在读取明细…', loaded: '已加载 {n} 笔明细', empty: '点「读取消费明细」后,这里按时间画出获得和兑换。先读当前窗口,再向前翻最多 8 个 90 天。', note: '这条曲线直接来自历史页上的汇总行:获得、退款、兑换,最后落到当前余额。', month: '月度获得 / 支出', window: '已加载明细的累计变化', earned: '累计获得', refund: '退款', redeem: '已兑换', balance: '当前余额', legendEarn: '获得', legendSpend: '支出', } : { title: 'Points analysis', overview: 'Overview', curve: 'Curve', mix: 'Breakdown', load: 'Load transactions', loading: 'Loading transactions…', loaded: 'Loaded {n} transactions', empty: 'Load transactions to plot earnings and redemptions over time. This reads the current window, then up to 8 earlier 90-day pages.', note: 'This curve is the summary rows on the history page: earned, refunded, redeemed, ending at the current balance.', month: 'Monthly earned / spent', window: 'Cumulative change of loaded transactions', earned: 'Earned', refund: 'Refunded', redeem: 'Redeemed', balance: 'Balance', legendEarn: 'Earned', legendSpend: 'Spent', }; const hx = { data: null, events: [], seen: new Set(), loading: false, status: '', tab: 'overview', open: false }; function parseDateMs(text) { const src = String(text || ''); let m = src.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})/); if (m) return Date.UTC(+m[1], +m[2] - 1, +m[3]); m = src.match(/(\d{4})-(\d{2})-(\d{2})/); if (m) return Date.UTC(+m[1], +m[2] - 1, +m[3]); const months = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }; m = src.match(/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2}),?\s+(\d{4})/i); if (m) return Date.UTC(+m[3], months[m[1].slice(0, 3).toLowerCase()], +m[2]); m = src.match(/\b(\d{1,2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{4})/i); if (m) return Date.UTC(+m[3], months[m[2].slice(0, 3).toLowerCase()], +m[1]); return null; } function amountInRow(tr) { const cells = [...tr.querySelectorAll('td, th')]; for (let i = cells.length - 1; i >= 0; i--) { const n = pureAmount(cells[i].textContent); if (n != null) return n; } const text = (tr.innerText || '').replace(/\d{4}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日?/g, ' '); const matches = text.match(/[−–+-]?\d{1,3}(?:[,,]\d{3})+/g); if (matches && matches.length) return parseLooseInt(matches[matches.length - 1]); return null; } function collectEventsFrom(root) { if (!root || !root.querySelectorAll) return; root.querySelectorAll('tr').forEach(tr => { if (tr.dataset && tr.dataset.spseSummary) return; const text = (tr.innerText || '').replace(/\s+/g, ' ').trim(); if (!text || parseDateMs(text) == null) return; const amount = amountInRow(tr); if (amount == null || amount === 0) return; const t = parseDateMs(text); const placeholder = t + '|' + amount + '|'; if (hx.seen.has(placeholder)) { hx.seen.delete(placeholder); hx.events = hx.events.filter(e => !(e.t === t && e.delta === amount && !e.label)); } const key = t + '|' + amount + '|' + text.slice(0, 60); if (hx.seen.has(key)) return; hx.seen.add(key); hx.events.push({ t: t, delta: amount, label: text.slice(0, 120) }); }); } function monthsFromEvents(events) { const map = new Map(); events.forEach(e => { const d = new Date(e.t); const ym = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); const bucket = map.get(ym) || { ym: ym, earned: 0, spent: 0 }; if (e.delta >= 0) bucket.earned += e.delta; else bucket.spent += -e.delta; map.set(ym, bucket); }); return [...map.values()].sort((a, b) => a.ym < b.ym ? -1 : 1); } function eventCurve(events) { const sorted = events.slice().sort((a, b) => a.t - b.t); const vals = [0]; let cursor = 0; sorted.forEach(e => { cursor += e.delta; vals.push(cursor); }); if (vals.length <= 80) return vals; const step = Math.ceil(vals.length / 80); const slim = []; for (let i = 0; i < vals.length; i += step) slim.push(vals[i]); if (slim[slim.length - 1] !== vals[vals.length - 1]) slim.push(vals[vals.length - 1]); return slim; } function monthBarsSvg(months) { const list = months.slice(-18); if (!list.length) return ''; const w = 640, h = 168, padL = 8, padB = 22, padT = 10; let max = 1; list.forEach(m => { max = Math.max(max, m.earned, m.spent); }); const slot = (w - padL * 2) / list.length; const barW = Math.max(2, (slot - 8) / 2); let bars = ''; list.forEach((m, i) => { const x = padL + i * slot; const base = h - padB; const eh = (m.earned / max) * (h - padT - padB); const sh = (m.spent / max) * (h - padT - padB); bars += ''; bars += ''; if (list.length <= 12 || i % 2 === 0) { bars += '' + m.ym.slice(2) + ''; } }); return '' + bars + ''; } function mixHtml(rows) { const items = (rows || []).filter(r => r.kind === 'earn' || r.kind === 'refund' || r.kind === 'redeem'); const max = Math.max(...items.map(r => Math.abs(r.amount)), 1); return items.map(r => { const pct = Math.max(2, Math.round(Math.abs(r.amount) / max * 100)); const color = r.kind === 'earn' ? '#8bc53f' : (r.kind === 'refund' ? '#f0a030' : '#d94a4a'); return '
' + escHtml(r.label) + '' + '' + '' + escHtml(fmtSigned(r.amount)) + '
'; }).join(''); } function locateSummary() { const box = document.querySelector('.rewards_token_summary'); if (box && !box.closest('#spse-hx-strip, #spse-hx-modal')) { const parsed = parseTokenSummary(box); if (parsed) return { parsed: parsed, anchor: box }; } let best = null; let bestTable = null; document.querySelectorAll('table').forEach(table => { if (table.closest('#spse-hx-strip, #spse-hx-modal')) return; const parsed = parseSummaryTable(table); if (parsed && (!best || parsed.rows.length > best.rows.length)) { best = parsed; bestTable = table; } }); return { parsed: best, anchor: bestTable }; } function waitRows(maxMs) { return new Promise(resolve => { const root = document.querySelector('#main_content') || document.body; let settled = null; const done = () => { obs.disconnect(); clearTimeout(cap); clearTimeout(settled); resolve(); }; const obs = new MutationObserver(() => { clearTimeout(settled); settled = setTimeout(done, 400); }); obs.observe(root, { childList: true, subtree: true }); const cap = setTimeout(done, maxMs); }); } function summaryLinks() { const root = document.querySelector('.rewards_token_summary') || document.querySelector('#main_content') || document.body; const clicks = [...root.querySelectorAll('[onclick]')].filter(el => /ViewPointsDetails|ViewPointRedemptionHistory/i.test(el.getAttribute('onclick') || '')); if (clicks.length) return clicks; return [...root.querySelectorAll('a, span, button')].filter(el => { const t = (el.textContent || '').replace(/\s+/g, ' ').trim(); return /^(查看详情|查看详细|view details|show details|詳細を表示|詳細を見る)$/i.test(t); }); } function prev90Link() { const root = document.querySelector('#main_content') || document.body; return [...root.querySelectorAll('a')].find(a => /前\s*90|previous\s*90|過去\s*90|先前\s*90|上一个\s*90|이전\s*90/i.test(a.textContent || '')); } async function pullLink(a) { const href = a.getAttribute('href') || ''; if (/^https?:\/\//i.test(href) && /steampowered\.com/i.test(href)) { const html = await gmGetText(href, 20000); collectEventsFrom(new DOMParser().parseFromString(html, 'text/html')); return; } a.click(); await waitRows(2500); collectEventsFrom(document.querySelector('#main_content') || document.body); } function setHxStatus(text) { hx.status = text; const el = document.getElementById('spse-hx-status'); if (el) el.textContent = text; } async function syncDetails() { if (hx.loading) return; hx.loading = true; setHxStatus(HX.loading); renderHx(); try { const links = summaryLinks(); for (let i = 0; i < links.length; i++) { setHxStatus((isZh ? '正在读取分类 ' : 'Reading section ') + (i + 1) + '/' + links.length); await pullLink(links[i]); for (let p = 0; p < 8; p++) { const prev = prev90Link(); if (!prev) break; const navKey = 'nav:' + (prev.getAttribute('href') || '') + '|' + (prev.textContent || '').replace(/\s+/g, ' ').trim(); if (hx.seen.has(navKey)) break; hx.seen.add(navKey); setHxStatus((isZh ? '向前翻 90 天 ' : 'Previous 90 days ') + (p + 1)); await pullLink(prev); } } hx.data.months = monthsFromEvents(hx.events); hx.data.events = hx.events.slice(-4000).map(e => ({ t: e.t, delta: e.delta })); writePointsCache(hx.data); setHxStatus(HX.loaded.replace('{n}', String(hx.events.length))); console.log('[SPSE] 点数明细已加载 ' + hx.events.length + ' 笔'); } catch (e) { setHxStatus(isZh ? '明细读取中断,已保留已读到的记录' : 'Stopped early; kept what was read'); console.warn('[SPSE] 点数明细读取失败', e); } hx.loading = false; renderHx(); } function kpiCard(label, value, tone) { return '
' + escHtml(label) + '
' + escHtml(value) + '
'; } function renderHx() { const data = hx.data; if (!data) return; const strip = document.getElementById('spse-hx-strip'); if (strip) { strip.innerHTML = '
' + kpiCard(HX.balance, fmtPoints(data.balance), 'balance') + kpiCard(HX.earned, fmtPoints(data.earned), 'earn') + kpiCard(HX.refund, fmtPoints(data.refunded), 'refund') + kpiCard(HX.redeem, fmtPoints(data.redeemed), 'spend') + '
' + '
' + ((data.flow && data.flow.length >= 2) ? sparklineSvg(data.flow) : '') + '' + '
'; const openBtn = document.getElementById('spse-hx-open'); if (openBtn) openBtn.addEventListener('click', openHxModal); } const body = document.getElementById('spse-hx-body'); if (!body) return; document.querySelectorAll('[data-hx-tab]').forEach(btn => { btn.classList.toggle('is-on', btn.dataset.hxTab === hx.tab); }); let html = ''; if (hx.tab === 'mix') { html = '
' + mixHtml(data.rows) + '
'; } else if (hx.tab === 'curve') { const curve = hx.events.length ? sparklineSvg(eventCurve(hx.events)) : ''; const bars = data.months && data.months.length ? monthBarsSvg(data.months) : ''; html = '
' + escHtml(HX.window) + '
' + (curve || '

' + escHtml(HX.empty) + '

') + '
' + (bars ? '
' + escHtml(HX.month) + '
' + escHtml(HX.legendEarn) + '' + escHtml(HX.legendSpend) + '
' + bars + '
' : '') + ''; } else { html = '
' + kpiCard(HX.balance, fmtPoints(data.balance), 'balance') + kpiCard(HX.earned, fmtPoints(data.earned), 'earn') + kpiCard(HX.refund, fmtPoints(data.refunded), 'refund') + kpiCard(HX.redeem, fmtPoints(data.redeemed), 'spend') + '
' + '
' + escHtml(i18n.pointsChartHint) + '
' + ((data.flow && data.flow.length >= 2) ? sparklineSvg(data.flow) : '') + '

' + escHtml(HX.note) + '

' + ''; } body.innerHTML = html; const loadBtn = document.getElementById('spse-hx-load'); if (loadBtn) loadBtn.addEventListener('click', () => syncDetails()); const status = document.getElementById('spse-hx-status'); if (status) status.textContent = hx.status || ''; } function openHxModal() { hx.open = true; const modal = document.getElementById('spse-hx-modal'); if (modal) modal.classList.add('is-open'); renderHx(); } function closeHxModal() { hx.open = false; const modal = document.getElementById('spse-hx-modal'); if (modal) modal.classList.remove('is-open'); } function injectHistoryStyles() { if (document.getElementById('spse-hx-style')) return; const style = document.createElement('style'); style.id = 'spse-hx-style'; style.textContent = [ '#spse-hx-strip{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;align-items:stretch;margin:0 0 18px;padding:0;background:transparent;border:none;color:#c7d5e0;box-sizing:border-box}', '#spse-hx-strip .spse-hx-kpis{display:contents}', '.spse-hx-kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}', '.spse-hx-kpis--modal{margin-bottom:16px}', '.spse-hx-kpi,.spse-hx-side{position:relative;min-width:0;min-height:112px;box-sizing:border-box;background:linear-gradient(180deg,#223246 0%,#172430 100%);border:1px solid rgba(255,255,255,.08);border-radius:10px;box-shadow:inset 0 1px 0 rgba(255,255,255,.05);overflow:hidden}', '.spse-hx-kpi{display:flex;flex-direction:column;justify-content:space-between;padding:16px 18px 14px}', '.spse-hx-kpi::before,.spse-hx-side::before{content:"";position:absolute;left:0;right:0;top:0;height:3px}', '.spse-hx-kpi--balance::before{background:#f5c050}', '.spse-hx-kpi--earn::before{background:#8bc53f}', '.spse-hx-kpi--refund::before{background:#ffb84d}', '.spse-hx-kpi--spend::before{background:#e25b5b}', '.spse-hx-side::before{background:#f0a030}', '.spse-hx-kpi-label{font-size:13px;font-weight:700;color:#a8b8c6;letter-spacing:.06em}', '.spse-hx-kpi-value{margin-top:10px;font-size:28px;font-weight:800;line-height:1;letter-spacing:-.03em;color:#fff;font-variant-numeric:tabular-nums;white-space:nowrap}', '.spse-hx-kpi--earn .spse-hx-kpi-value{color:#9ad64a}', '.spse-hx-kpi--refund .spse-hx-kpi-value{color:#ffb84d}', '.spse-hx-kpi--spend .spse-hx-kpi-value{color:#ff6b6b}', '.spse-hx-kpi--balance .spse-hx-kpi-value{color:#f5c050}', '.spse-hx-side{display:flex;flex-direction:column;justify-content:space-between;gap:8px;padding:14px 12px 12px}', '.spse-hx-side .spse-spark{flex:1 1 auto;width:100%;height:0;min-height:52px;background:transparent;display:block}', '.spse-hx-open,.spse-hx-load,#spse-hx-launch{border:1px solid rgba(240,160,48,.6);background:linear-gradient(180deg,rgba(240,160,48,.28),rgba(240,160,48,.1));color:#ffe0a8;border-radius:8px;padding:8px 12px;cursor:pointer;font-size:14px;font-weight:800;letter-spacing:.08em}', '.spse-hx-open{width:100%}', '.spse-hx-open:hover,.spse-hx-load:hover,#spse-hx-launch:hover{background:rgba(240,160,48,.2)}', '.spse-hx-load{margin-top:12px}', '.spse-hx-load:disabled{opacity:.6;cursor:default}', '#spse-hx-launch{position:fixed;right:22px;bottom:22px;z-index:10050;box-shadow:0 8px 24px rgba(0,0,0,.35)}', '#spse-hx-modal{position:fixed;inset:0;z-index:10060;display:none;align-items:center;justify-content:center;background:rgba(8,14,22,.72)}', '#spse-hx-modal.is-open{display:flex}', '#spse-hx-card{width:min(760px,calc(100vw - 32px));max-height:min(86vh,820px);display:flex;flex-direction:column;background:#16202d;border:1px solid rgba(255,255,255,.08);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.45);color:#c7d5e0}', '.spse-hx-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 8px}', '.spse-hx-title{font-size:16px;font-weight:700;color:#fff}', '.spse-hx-close{width:28px;height:28px;border:none;background:transparent;color:#8f98a0;font-size:20px;cursor:pointer;line-height:1}', '.spse-hx-tabs{display:flex;gap:6px;padding:0 16px 12px}', '.spse-hx-tab{border:1px solid rgba(255,255,255,.08);background:transparent;color:#8f98a0;border-radius:8px;padding:6px 10px;cursor:pointer;font-size:12px}', '.spse-hx-tab.is-on{color:#f0a030;border-color:rgba(240,160,48,.5);background:rgba(240,160,48,.1)}', '#spse-hx-body{padding:0 16px 8px;overflow:auto}', '#spse-hx-body .spse-spark{height:120px;background:rgba(0,0,0,.2);border-radius:6px}', '.spse-hx-block{margin-top:8px}', '.spse-hx-block-title{font-size:12px;color:#8f98a0;margin-bottom:6px}', '.spse-hx-note,.spse-hx-empty{font-size:12px;line-height:1.6;color:#8f98a0;margin:8px 0 0}', '.spse-hx-bars{width:100%;height:168px;display:block;background:rgba(0,0,0,.2);border-radius:6px}', '.spse-hx-legend{display:flex;gap:8px;align-items:center;font-size:11px;color:#8f98a0;margin-bottom:6px}', '.spse-hx-dot{width:8px;height:8px;border-radius:50%;display:inline-block}', '.spse-hx-dot--earn{background:#8bc53f;margin-right:2px}', '.spse-hx-dot--spend{background:#d94a4a;margin:0 2px 0 8px}', '.spse-hx-mix-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,2fr) auto;gap:10px;align-items:center;margin:8px 0;font-size:13px}', '.spse-hx-mix-label{color:#c7d5e0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.spse-hx-mix-track{height:8px;background:rgba(255,255,255,.06);border-radius:4px;overflow:hidden}', '.spse-hx-mix-track span{display:block;height:100%;border-radius:4px}', '.spse-hx-mix-num{font-variant-numeric:tabular-nums;color:#fff;white-space:nowrap}', '#spse-hx-status{min-height:18px;padding:0 16px 14px;font-size:12px;color:#8f98a0}', '@media(max-width:860px){#spse-hx-strip{grid-template-columns:repeat(2,minmax(0,1fr))}#spse-hx-strip .spse-hx-side{grid-column:1 / -1}.spse-hx-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.spse-hx-kpi-value{font-size:24px}}' ].join(''); document.head.appendChild(style); } function mountHistory(found) { if (document.getElementById('spse-hx-strip')) return; const anchor = found.anchor; anchor.querySelectorAll('tr, .td.cell').forEach(el => { el.dataset.spseSummary = '1'; }); hx.data = found.parsed; const cached = readPointsCache(); if (cached && cached.data) { if (cached.data.months && cached.data.months.length) hx.data.months = cached.data.months; if (cached.data.events && cached.data.events.length) { hx.events = cached.data.events.map(e => ({ t: e.t, delta: e.delta, label: '' })); hx.events.forEach(e => hx.seen.add(e.t + '|' + e.delta + '|')); } } writePointsCache(hx.data); const strip = document.createElement('div'); strip.id = 'spse-hx-strip'; const settings = anchor.closest('.account_settings_container'); const header = settings && settings.previousElementSibling; const before = (header && header.classList.contains('account_header_line')) ? header : anchor; before.parentNode.insertBefore(strip, before); const launch = document.createElement('button'); launch.id = 'spse-hx-launch'; launch.type = 'button'; launch.textContent = HX.title; launch.addEventListener('click', openHxModal); document.body.appendChild(launch); const modal = document.createElement('div'); modal.id = 'spse-hx-modal'; modal.innerHTML = '
' + '
' + escHtml(HX.title) + '' + '
' + '
' + '' + '' + '' + '
' + '
' + '
'; document.body.appendChild(modal); modal.addEventListener('click', e => { if (e.target === modal) closeHxModal(); }); modal.querySelector('#spse-hx-close').addEventListener('click', closeHxModal); modal.querySelectorAll('[data-hx-tab]').forEach(btn => { btn.addEventListener('click', () => { hx.tab = btn.dataset.hxTab; renderHx(); }); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && hx.open) closeHxModal(); }); renderHx(); console.log('[SPSE] 点数历史看板已挂载 balance=' + hx.data.balance); } function initHistoryPage() { injectHistoryStyles(); const boot = () => { const found = locateSummary(); if (!found.parsed || !found.anchor) return false; mountHistory(found); return true; }; if (boot()) return; const obs = new MutationObserver(() => { if (boot()) obs.disconnect(); }); obs.observe(document.body, { childList: true, subtree: true }); setTimeout(() => obs.disconnect(), 10000); } // ==================== 初始化 ==================== let _indexStarted = false; let _reinjectionTimer = null; function init() { // 等待 React SPA 渲染 #points_shop_root const rootEl = document.getElementById('points_shop_root'); if (!rootEl) { const waitObserver = new MutationObserver(() => { if (document.getElementById('points_shop_root')) { waitObserver.disconnect(); setTimeout(init, 300); } }); waitObserver.observe(document.body, { childList: true, subtree: true }); return; } // 尝试注入搜索框 const injected = injectSearchBox(); if (!injected) { // SPA 尚未完整渲染侧边栏,等待并重试 const retryObserver = new MutationObserver(() => { if (injectSearchBox()) { retryObserver.disconnect(); startIndexBuild(); } }); retryObserver.observe(rootEl, { childList: true, subtree: true }); setTimeout(() => retryObserver.disconnect(), 15000); return; } startIndexBuild(); } function startIndexBuild() { if (_indexStarted) return; _indexStarted = true; refreshIndex(); startReinjectionGuard(); } /** * React SPA 导航时可能重新渲染侧边栏导致搜索框丢失 * 定期检测并在需要时重新注入 */ function startReinjectionGuard() { if (_reinjectionTimer) clearInterval(_reinjectionTimer); _reinjectionTimer = setInterval(() => { if (!document.getElementById('spse-search-container')) { if (injectSearchBox()) { console.log('[SPSE] 搜索框重新注入成功'); // 重新注入后更新进度条状态 updateProgressBar(); } } else { syncOfficialSearch(); } }, 2000); watchOfficialSearch(); } /** 切换到「游戏个人资料」时官方搜索框是后挂上的,盯着商店根节点及时接管。 */ function watchOfficialSearch() { const root = document.getElementById('points_shop_root'); if (!root || root.dataset.spseOfficialWatch === '1') return; root.dataset.spseOfficialWatch = '1'; let timer = null; const obs = new MutationObserver(() => { if (timer) return; timer = setTimeout(() => { timer = null; if (document.getElementById('spse-search-container')) syncOfficialSearch(); }, 150); }); obs.observe(root, { childList: true, subtree: true }); } // 启动 function bootSpse() { if (location.pathname.indexOf('/pointssummary') === 0) initHistoryPage(); else init(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(bootSpse, 500)); } else { setTimeout(bootSpse, 500); } })();