// ==UserScript== // @name Steam 点数商店增强 // @name:en Steam Points Shop Enhancer // @namespace steam-points-shop-enhancer // @version 2.0.3 // @description 在 Steam 点数商店左侧菜单增加搜索功能,获取用户已拥有的游戏 appid 并交叉比对卡牌数据库,支持按名称/拼音首字母搜索有卡牌的游戏并一键跳转到对应游戏的点数商店页。无需 webapi_token,仅需 Cookie 认证。更新日志见脚本目录 README。 // @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/* // @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 // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @run-at document-idle // @tag Steam // @tag points // @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 () { 'use strict'; // ==================== 环境守卫 ==================== 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 // 卡牌数据库 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…', 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', }; // 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; } /* 加载进度条 */ .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); } `); // ==================== 状态管理 ==================== 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; } // ==================== 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); } bindSearchEvents(); 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; // 输入搜索 input.addEventListener('input', () => { const val = input.value; clearBtn.classList.toggle('spse-show', val.length > 0); 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; 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'); } // 使用 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}]`); } // ==================== 初始化 ==================== 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(); } } }, 2000); } // 启动 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500)); } else { setTimeout(init, 500); } })();