// ==UserScript== // @name SGLV Badge Library // @namespace sglv-lib // @version 1.0.0 // @description SGLV 集换式卡牌/勋章检测与数据获取库,统一封装卡牌支持检测(多重降级)、SteamCardExchange 页面解析、社区登录状态检测。 // 数据源降级链:L1 appdetails categories(id=29) → L2 SteamCardExchange GetInventory API → L3 SteamCardExchange 游戏页面解析。 // 设计目标:UI 无关、可被多个插件复用、与 SGLVCore 网络层解耦。 // 暴露 SGLVBadge:checkCardSupport / fetchCardExchangePage / checkCommunityLogin / setCardDb / t / detectLang。 // 更新日志见脚本目录 README。 // @author SGLV // @noframes // ==/UserScript== /* * SGLV Badge Library v1.0.0 * * 模块切分: * 1) 网络层 _http / _getJson / _getText (委托 SGLVCore 或降级 GM_xmlhttpRequest) * 2) i18n 层 detectLang / t * 3) 缓存层 _sceInventoryCache (内存 + GM_setValue 持久化 24h) * 4) 检测层 checkCardSupport (L1→L2→L3 多重降级) * 5) 解析层 fetchCardExchangePage (SteamCardExchange 游戏页面 HTML 解析) * 6) 登录检测 checkCommunityLogin (宽松策略,网络失败不误判为未登录) * * 设计契约: * - 库不依赖 window.SGLVCore 之外的其他 SGLV 库 * - 不引入 GM_* 以外的浏览器 API (DOMParser 除外,用于 HTML 解析) * - 宿主可通过 setCardDb() 注入已加载的卡牌数据库,避免重复请求 */ (function (root) { 'use strict'; // 防止重复挂载 if (root.SGLVBadge && root.SGLVBadge.version === '1.0.0') return; // ==================== 常量 ==================== const VERSION = '1.0.0'; // Steam Trading Cards category id (appdetails API) const TRADING_CARD_CATEGORY_ID = 29; // SteamCardExchange API const SCE_INVENTORY_API = 'https://www.steamcardexchange.net/api/request.php?GetInventory'; const SCE_GAME_PAGE_BASE = 'https://www.steamcardexchange.net/index.php?gamepage-appid-'; // Steam 社区市场 (登录检测) const STEAM_COMMUNITY_MARKET_URL = 'https://steamcommunity.com/market/'; const STEAM_MARKET_LISTING_BASE = 'https://steamcommunity.com/market/listings/753/'; // 缓存 TTL const SCE_INVENTORY_TTL = 24 * 60 * 60 * 1000; // 24 小时 const SCE_PAGE_CACHE_TTL = 30 * 60 * 1000; // 30 分钟 const SCE_INVENTORY_CACHE_KEY = 'sglv_sce_inventory_cache'; const SCE_PAGE_CACHE_PREFIX = 'sglv_sce_page_'; // 超时 const DEFAULT_TIMEOUT = 15000; const LOGIN_CHECK_TIMEOUT = 8000; // ==================== 国际化文案 ==================== const I18N = { en: { loadingCards: 'Loading trading card data...', noCards: 'This game does not have tradable Steam Trading Cards', noCardsHint: 'This game may not support Steam Trading Cards', cardsExistNoPrice: 'Trading cards confirmed, but market prices are unavailable', networkError: 'Network error, unable to fetch card data', networkErrorHint: 'Please check your network connection and try again', sourceAppdetails: 'Steam App Details', sourceSCEApi: 'SteamCardExchange API', sourceSCEPage: 'SteamCardExchange Page', sourceMarket: 'Steam Community Market', sceFallbackNote: 'Card list from SteamCardExchange (prices unavailable)', cardCount: 'Cards', badgeCount: 'Badges', foilBadge: 'Foil Badge', loginRequired: 'Steam Community login required for market prices', loginCheckFailed: 'Unable to verify login status', }, zh: { loadingCards: '正在获取集换式卡牌数据…', noCards: '该游戏没有可交易的集换式卡牌', noCardsHint: '可能不支持 Steam 集换式卡牌', cardsExistNoPrice: '已确认该游戏有集换式卡牌,但市场价格数据不可用', networkError: '网络错误,无法获取卡牌数据', networkErrorHint: '请检查网络连接后重试', sourceAppdetails: 'Steam 应用详情', sourceSCEApi: 'SteamCardExchange API', sourceSCEPage: 'SteamCardExchange 页面', sourceMarket: 'Steam 社区市场', sceFallbackNote: '卡牌列表来自 SteamCardExchange (价格数据不可用)', cardCount: '卡牌', badgeCount: '徽章', foilBadge: '闪卡徽章', loginRequired: '卡牌价格需要登录 Steam 社区才能获取', loginCheckFailed: '无法验证登录状态', }, }; let _lang = null; function detectLang() { if (_lang) return _lang; try { const htmlLang = (document.documentElement.lang || '').toLowerCase(); if (htmlLang === 'zh' || htmlLang.startsWith('zh-')) { _lang = 'zh'; return _lang; } const cookieMatch = document.cookie.match(/Steam_Language=([^;]+)/); if (cookieMatch) { const lang = cookieMatch[1].trim().toLowerCase(); if (lang.startsWith('zh')) { _lang = 'zh'; return _lang; } } const urlMatch = location.href.match(/[?&]l=(schinese|tchinese|spanish|french|german|japanese|koreana|portuguese|russian|thai|english)/); if (urlMatch && urlMatch[1].startsWith('schinese')) { _lang = 'zh'; return _lang; } } catch (e) { /* ignore */ } _lang = 'en'; return _lang; } function t(key) { const lang = detectLang(); return (I18N[lang] && I18N[lang][key]) || I18N.en[key] || key; } // ==================== 网络层 ==================== // 降级路径:无 SGLVCore 时用本地 GM_xmlhttpRequest + fetch function _http(opts) { if (root.SGLVCore && root.SGLVCore.gmFetchText) { return root.SGLVCore.gmFetchText(opts.url, opts) .then(text => ({ status: 200, responseText: text })); } return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest === 'function') { GM_xmlhttpRequest({ method: opts.method || 'GET', url: opts.url, headers: opts.headers || { 'Accept': 'text/html,application/json' }, timeout: opts.timeout || DEFAULT_TIMEOUT, anonymous: false, onload(r) { if (r.status >= 200 && r.status < 300) resolve(r); else reject(new Error('HTTP ' + r.status)); }, onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timeout')), }); return; } const ctl = (typeof AbortController === 'function') ? new AbortController() : null; const timer = ctl ? setTimeout(() => ctl.abort(), opts.timeout || DEFAULT_TIMEOUT) : null; fetch(opts.url, { signal: ctl ? ctl.signal : undefined }) .then(r => { if (timer) clearTimeout(timer); if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }) .then(text => resolve({ status: 200, responseText: text })) .catch(err => { if (timer) clearTimeout(timer); reject(err); }); }); } async function _getJson(url, opts) { if (root.SGLVCore && root.SGLVCore.gmFetchJson) { return root.SGLVCore.gmFetchJson(url, opts); } const r = await _http(Object.assign({ url }, opts)); try { return JSON.parse(r.responseText); } catch (e) { throw new Error('invalid JSON'); } } async function _getText(url, opts) { if (root.SGLVCore && root.SGLVCore.gmFetchText) { return root.SGLVCore.gmFetchText(url, opts); } const r = await _http(Object.assign({ url }, opts)); return r.responseText; } // ==================== 缓存层: SteamCardExchange Inventory ==================== let _sceInventoryCache = null; // { appId: { name, maxLevel } } let _sceInventoryLoading = null; /** * 获取 SteamCardExchange 全量卡牌库存 (所有有卡牌的游戏) * 优先使用宿主注入的数据 (setCardDb), 其次本地缓存, 最后 API 请求 * @returns {Promise} { appId: { name, maxLevel } } */ async function _getSCEInventory() { if (_sceInventoryCache) return _sceInventoryCache; if (_sceInventoryLoading) return _sceInventoryLoading; _sceInventoryLoading = (async () => { try { // 检查持久化缓存 if (typeof GM_getValue === 'function') { const cached = GM_getValue(SCE_INVENTORY_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < SCE_INVENTORY_TTL)) { _sceInventoryCache = cached.data; return _sceInventoryCache; } } const data = await _getJson(SCE_INVENTORY_API, { timeout: 30000 }); const db = {}; if (data && data.data && Array.isArray(data.data)) { // data[i] = [[appid, name], ..., [size]] data.data.forEach((current) => { if (!current || !current[0] || !current[3]) return; const aid = String(current[0][0]); const name = current[0][1] || ''; const maxLevel = current[3][0] || 0; if (aid && maxLevel) { db[aid] = { name, maxLevel }; } }); } _sceInventoryCache = db; if (typeof GM_setValue === 'function') { GM_setValue(SCE_INVENTORY_CACHE_KEY, { timestamp: Date.now(), data: db }); } return db; } catch (e) { return null; } finally { _sceInventoryLoading = null; } })(); return _sceInventoryLoading; } // ==================== 检测层: 多重降级卡牌支持检测 ==================== /** * 检测游戏是否支持 Steam 集换式卡牌 (多重降级策略) * * 降级链: * L1: appdetails categories (category id=29 = "Steam Trading Cards") * L2: SteamCardExchange GetInventory API (检查 appId 是否在卡牌数据库中) * L3: SteamCardExchange 游戏页面 (fetch + parse HTML) * * @param {number|string} appId - Steam App ID * @param {Object} opts - 可选参数 * @param {Array} opts.categories - appdetails 返回的 categoryObjs [{id, description}] * @param {boolean} opts.skipPageFetch - 跳过 L3 页面抓取 (仅用 L1+L2) * @returns {Promise} { supported: boolean, source: string, cardCount: number, gameName: string, cards?: Array } */ async function checkCardSupport(appId, opts) { opts = opts || {}; const aid = String(appId); // L1: 检查 appdetails categories if (opts.categories && Array.isArray(opts.categories)) { const hasCardCategory = opts.categories.some(c => c.id === TRADING_CARD_CATEGORY_ID || /steam\s*trading\s*cards|Steam\s*集换式卡牌/i.test(c.description || '') ); if (hasCardCategory) { return { supported: true, source: 'appdetails', cardCount: 0, gameName: '' }; } } // L2: 检查 SteamCardExchange GetInventory API try { const invData = await _getSCEInventory(); if (invData && invData[aid]) { return { supported: true, source: 'steamcardexchange-api', cardCount: invData[aid].maxLevel || 0, gameName: invData[aid].name || '', }; } } catch (e) { /* fall through to L3 */ } // L3: 抓取 SteamCardExchange 游戏页面 if (!opts.skipPageFetch) { try { const pageData = await fetchCardExchangePage(aid); if (pageData && pageData.supported) { return { supported: true, source: 'steamcardexchange-page', cardCount: pageData.cardCount || (pageData.regular ? pageData.regular.length : 0), gameName: pageData.gameName || '', cards: { regular: pageData.regular, foil: pageData.foil }, }; } } catch (e) { /* fall through */ } } return { supported: false, source: 'none', cardCount: 0, gameName: '' }; } // ==================== 解析层: SteamCardExchange 游戏页面 ==================== /** * 从 SteamCardExchange 游戏页面获取卡牌信息 * URL: https://www.steamcardexchange.net/index.php?gamepage-appid-{appId} * * 解析策略: * 1. 提取 "Cards: N" 文本获取卡牌数量 * 2. 从 Steam 市场链接 URL 中提取卡牌名称 (hash name) * 3. 根据 "(Foil)" 后缀区分普通卡和闪卡 * 4. 排除 Booster Pack / Profile Background / Emoticon 等非卡牌项 * * @param {number|string} appId - Steam App ID * @returns {Promise} { supported, cardCount, badgeCount, gameName, regular: [], foil: [] } */ async function fetchCardExchangePage(appId) { const aid = String(appId); // 检查内存/持久化缓存 const pageCacheKey = SCE_PAGE_CACHE_PREFIX + aid; if (typeof GM_getValue === 'function') { const cached = GM_getValue(pageCacheKey, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < SCE_PAGE_CACHE_TTL)) { return cached.data; } } const url = SCE_GAME_PAGE_BASE + aid; const html = await _getText(url, { timeout: DEFAULT_TIMEOUT }); // 页面过短或明显是错误页 → 不支持卡牌 if (!html || html.length < 500) { return { supported: false, cardCount: 0, badgeCount: 0, gameName: '', regular: [], foil: [] }; } // 提取卡牌数量 ("Cards: 6") const cardCountMatch = html.match(/Cards:\s*(\d+)/i); const cardCount = cardCountMatch ? parseInt(cardCountMatch[1], 10) : 0; // 提取徽章数量 ("Badges: 5") const badgeCountMatch = html.match(/Badges:\s*(\d+)/i); const badgeCount = badgeCountMatch ? parseInt(badgeCountMatch[1], 10) : 0; // 提取游戏名称 (优先 h1, 其次 title) let gameName = ''; const h1Match = html.match(/]*>([\s\S]*?)<\/h1>/i); if (h1Match) { gameName = h1Match[1].replace(/<[^>]*>/g, '').trim(); } if (!gameName) { const titleMatch = html.match(/([\s\S]*?)<\/title>/i); if (titleMatch) { gameName = titleMatch[1].replace(/<[^>]*>/g, '').replace(/\s*[-|]\s*SteamCardExchange.*$/i, '').trim(); } } // 从 Steam 市场链接提取卡牌信息 // URL 格式: steamcommunity.com/market/listings/753/{appId}-{hashName} // hashName 格式: {cardName} (Trading Card) / {cardName} (Foil Trading Card) / {cardName} (Foil) const marketUrlRegex = new RegExp( 'steamcommunity\\.com/market/listings/753/' + aid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '-([^"\'\\s<>]+)', 'gi' ); const regularCards = []; const foilCards = []; const seenHash = new Set(); let match; while ((match = marketUrlRegex.exec(html)) !== null) { let hashName; try { hashName = decodeURIComponent(match[1]).trim(); } catch (e) { hashName = match[1].trim(); } if (!hashName || seenHash.has(hashName)) continue; seenHash.add(hashName); // 排除非卡牌项 if (/Booster\s*Pack/i.test(hashName)) continue; if (/Profile\s*Background/i.test(hashName)) continue; if (/^:[^:]*:$/i.test(hashName)) continue; // 表情包 (如 :BOR_Kagura:) // 判断普通卡还是闪卡 const isFoil = /\(Foil/i.test(hashName); // 提取卡牌名称 (移除后缀) const cardName = hashName .replace(/\s*\(Trading Card\)\s*$/i, '') .replace(/\s*\(Foil Trading Card\)\s*$/i, '') .replace(/\s*\(Foil\)\s*$/i, '') .trim(); if (!cardName) continue; const card = { name: cardName, hashName: aid + '-' + hashName, foil: isFoil, marketUrl: STEAM_MARKET_LISTING_BASE + encodeURIComponent(aid + '-' + hashName), // SCE 降级数据无价格信息 sellPrice: null, buyPrice: null, netPrice: null, sellPriceText: '', iconUrl: '', listings: 0, }; if (isFoil) { foilCards.push(card); } else { regularCards.push(card); } } const supported = cardCount > 0 || regularCards.length > 0 || foilCards.length > 0; const result = { supported, cardCount, badgeCount, gameName, regular: regularCards, foil: foilCards, source: 'steamcardexchange-page', }; // 缓存结果 if (typeof GM_setValue === 'function' && supported) { GM_setValue(pageCacheKey, { timestamp: Date.now(), data: result }); } return result; } // ==================== 登录检测层: 宽松策略 ==================== let _loginCache = null; // null=未检测, 'logged_in', 'not_logged_in', 'unknown' let _loginChecking = false; /** * 检测 Steam 社区登录状态 (宽松策略) * * 与旧版差异: * - 网络失败 → 'unknown' (而非 false), 避免误判为未登录 * - 页面无 #account_pulldown 但也无登录表单 → 'unknown' (而非 false) * - 仅当明确检测到登录表单/重定向时才返回 'not_logged_in' * * @returns {Promise<string>} 'logged_in' | 'not_logged_in' | 'unknown' */ async function checkCommunityLogin() { // 已检测过且有明确结果 → 直接返回 (unknown 允许重试) if (_loginCache === 'logged_in' || _loginCache === 'not_logged_in') { return _loginCache; } if (_loginChecking) return 'unknown'; _loginChecking = true; try { const html = await _getText(STEAM_COMMUNITY_MARKET_URL, { timeout: LOGIN_CHECK_TIMEOUT }); if (!html || html.length < 200) { _loginCache = 'unknown'; return _loginCache; } const doc = new DOMParser().parseFromString(html, 'text/html'); // 多重登录指标检测 const hasAccountPulldown = !!doc.querySelector('#account_pulldown'); const hasLoginForm = !!doc.querySelector('#loginForm, .login_form, #login_btn'); // 如果页面是短重定向到登录页 (内容少 + login URL) const isLoginRedirect = /login\/home|login\/signin|loginHome/i.test(html) && html.length < 3000; if (hasAccountPulldown) { _loginCache = 'logged_in'; } else if (hasLoginForm || isLoginRedirect) { _loginCache = 'not_logged_in'; } else { // 页面加载成功但无明确登录指标 → 不确定 (可能页面结构变更) _loginCache = 'unknown'; } } catch (e) { // 网络错误 → unknown (而非 not_logged_in) _loginCache = 'unknown'; } finally { _loginChecking = false; } return _loginCache; } /** * 重置登录状态缓存 (强制下次重新检测) */ function resetLoginCache() { _loginCache = null; _loginChecking = false; } // ==================== 宿主注入 ==================== /** * 宿主注入已加载的 SteamCardExchange 卡牌数据库 * 避免重复请求 GetInventory API * @param {Object} db - { appId: { name, maxLevel } } */ function setCardDb(db) { if (db && typeof db === 'object') { _sceInventoryCache = db; } } // ==================== 公共 API ==================== const SGLVBadge = { version: VERSION, // 检测 checkCardSupport, fetchCardExchangePage, checkCommunityLogin, resetLoginCache, // 宿主注入 setCardDb, // i18n t, detectLang, // 常量 TRADING_CARD_CATEGORY_ID, }; root.SGLVBadge = SGLVBadge; })(typeof window !== 'undefined' ? window : globalThis);