// ==UserScript== // @name SGLV App Detail Library // @namespace sglv-lib // @version 1.2.0 // @description SGLV 单个 appid 的详情/多地区价格/拥有与共享状态库,统一封装网络抓取、缓存、解析与文案。供 steam-game-library-viewer 等上游调用。 // 数据源:Steam 官方 appdetails API (cc=cn&l=schinese) + steamui appinfo(VDF,封面/系列/本地化名) + store DOM 兜底;多地区价格:同 appdetails 变 cc。 // 汇率:AugmentedSteam(主) + open.er-api.com(降级);缓存:内存 LRU 5min + GM_setValue 持久化 24h。 // 拥有/共享状态:由宿主通过 setHostApi 注入(getOwnedGames/isGameOwnedByMe/getGameOwnerNames/getActiveSteamId), // 本库不直接读 storage,完全解耦。 // 暴露 SGLVAppDetail:loadDetail / loadPrices / getOwnershipBadge / getSteamuiInfo / enrichWithSteamui / buildLibraryAssets / toCNY / sanitizeHtml / detectLang / setHostApi。 // 设计目标:UI 无关、可 Node 端单测、可被多个插件复用。 // 更新日志见脚本目录 README。 // @author SGLV // @noframes // ==/UserScript== /* * SGLV App Detail Library v1.0.0 * * 模块切分: * 1) 缓存层 _memCache / _diskCache (5min LRU + 24h 持久化) * 2) 网络层 _http / _getJson / _mapLimit * 3) 解析层 _parseAppdetails / _parsePriceRegion / _parseRate * 4) 业务层 loadDetail / loadPrices / getOwnershipBadge * 5) 工具层 toCNY / formatPrice / sanitizeHtml / detectLang / i18n * * 状态机:loadDetail() 内部根据 cache 命中状态走"同步返回 → 异步刷新"路径; * onProgress(stage, msg) 提供阶段回调,UI 层可显示加载动画。 * * 设计契约: * - 库不依赖 window.SGLVCore / SGLVPinyin / SGLVPinyin 等其他 SGLV 库 * - 不引入 GM_* 以外的浏览器 API * - 单元测试入口见同级 sglv-app-detail.lib.test.js */ (function (root) { 'use strict'; // 防止重复挂载 if (root.SGLVAppDetail && root.SGLVAppDetail.version === '1.2.0') return; // ==================== 常量 ==================== const VERSION = '1.2.0'; const API_VERSION = 1; // 缓存 TTL(毫秒) const MEM_TTL = 5 * 60 * 1000; // 内存 5 分钟 const DISK_TTL = 24 * 60 * 60 * 1000; // 磁盘 24 小时 const FAIL_TTL = 60 * 1000; // 失败重试节流 1 分钟 // 多地区价格(同 SGIS,可按需扩展) const PRIORITY_REGIONS = ['CN', 'US', 'TR', 'AR', 'RU', 'IN', 'BR', 'UA', 'KZ']; const REGION_CONCURRENCY = 3; const RATE_BASE = 'CNY'; const RATE_TTL = 60 * 60 * 1000; // 汇率缓存 1 小时 // appdetails API const APPDETAILS_BASE = 'https://store.steampowered.com/api/appdetails'; const RATE_PRIMARY_URL = 'https://api.augmentedsteam.com/rates/v1'; const RATE_FALLBACK_URL = 'https://open.er-api.com/v6/latest/CNY'; // v1.2.0: steamui appinfo 渠道 — 详情降级源 + 增强字段(libraryAssets/franchise/本地化名) // 数据由 sglv-cover-fallback.lib.js 统一拉取(VDF 解析 + 三级缓存 + CF/404 负缓存), // 该库未加载时本渠道自动跳过(可选依赖,不破坏独立可用性)。 const STEAMUI_ASSET_CDN = 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/'; // ==================== 国际化文案 ==================== // 默认英文 + 简体中文双语字典,由 detectLang() 决定取哪一个 const I18N = { en: { loadStage0: 'Preparing…', loadStage1: 'Fetching game details…', loadStage2: 'Parsing description & media…', loadStage3: 'Almost done…', priceStage0: 'Loading exchange rates…', priceStage1: 'Querying regional prices…', ownershipMine: 'Owned', ownershipShared: 'Family Shared', ownershipNotOwned: 'Not Owned', ownershipOwnedBy: 'Owned by: {names}', sharedFrom: 'Shared from: {names}', notOwnedHint: 'Not in your library', priceFree: 'Free', priceUserRegion: 'Your region', priceLowest: 'Lowest', priceFailed: 'Failed regions', rateFromAS: 'AugmentedSteam', rateFromOER: 'open.er-api.com', }, zh: { loadStage0: '准备中…', loadStage1: '正在获取游戏详情…', loadStage2: '正在解析简介与媒体…', loadStage3: '即将完成…', priceStage0: '正在加载汇率…', priceStage1: '正在查询多地区价格…', ownershipMine: '已拥有', ownershipShared: '家庭共享', ownershipNotOwned: '未拥有', ownershipOwnedBy: '由 {names} 拥有', sharedFrom: '共享自: {names}', notOwnedHint: '不在你的库中', priceFree: '免费', priceUserRegion: '本地区', priceLowest: '最低价', priceFailed: '失败地区', rateFromAS: 'AugmentedSteam', rateFromOER: 'open.er-api.com', }, }; function detectLang() { try { const lang = (root.document && root.document.documentElement && root.document.documentElement.lang) || (root.navigator && root.navigator.language) || 'en'; return /^zh/i.test(lang) ? 'zh' : 'en'; } catch (e) { return 'en'; } } function t(key) { const lang = detectLang(); return (I18N[lang] && I18N[lang][key]) || I18N.en[key] || key; } // ==================== 宿主 API 注入 ==================== // 上层可选择性注入"拥有/共享"相关 API,未注入时相关接口返回 unknown。 // 这样库在脱离 SGLV 主脚本时仍能加载,不会因为缺少依赖而抛错。 const _host = { getOwnedGames: null, // () => Game[] 其中 g.appid / g.owners / g.name isGameOwnedByMe: null, // (g) => boolean getGameOwnerNames: null, // (g) => string "Alice, Bob" getActiveSteamId: null, // () => string '7656xxxx' isGameFamilyShared: null, // 可选:(g) => boolean }; function setHostApi(api) { if (!api || typeof api !== 'object') return; Object.keys(_host).forEach(k => { if (typeof api[k] === 'function') _host[k] = api[k]; }); } // ==================== 缓存层 ==================== // 内存 LRU(简单 Map 即可,只按时间淘汰,5min 足够) const _memCache = new Map(); const _failBlacklist = new Map(); function _memGet(key) { const e = _memCache.get(key); if (!e) return null; if (e.exp < Date.now()) { _memCache.delete(key); return null; } return e.data; } function _memSet(key, data, ttl) { _memCache.set(key, { data, exp: Date.now() + ttl }); // 限制内存大小(> 200 项时清理最老) if (_memCache.size > 200) { const firstKey = _memCache.keys().next().value; _memCache.delete(firstKey); } } function _memClear() { _memCache.clear(); _failBlacklist.clear(); } // 持久化(GM_setValue)——浏览器环境可选 function _diskGet(key) { try { if (typeof GM_getValue === 'function') { const raw = GM_getValue(key, null); if (!raw) return null; const obj = JSON.parse(raw); if (!obj || obj.exp < Date.now()) return null; return obj.data; } } catch (e) { /* ignore */ } return null; } function _diskSet(key, data, ttl) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, JSON.stringify({ data, exp: Date.now() + ttl })); } } catch (e) { /* ignore */ } } // 失败节流(防止网络雪崩) function _isInFailBlacklist(key) { const exp = _failBlacklist.get(key); if (!exp) return false; if (exp < Date.now()) { _failBlacklist.delete(key); return false; } return true; } function _markFail(key) { _failBlacklist.set(key, Date.now() + FAIL_TTL); } // ==================== 网络层 ==================== // 优先委托 sglv-core.lib.js 的 gmFetchText(自动重试 + 状态码校验) // 降级路径:无 SGLVCore 时(GM 隔离/Node 测试)用本地 GM_xmlhttpRequest + fetch function _http(opts) { // SGLVCore 可用:委托(带重试 + HTTP 状态校验) if (root.SGLVCore && root.SGLVCore.gmFetchText) { return root.SGLVCore.gmFetchText(opts.url, opts) .then(text => ({ status: 200, responseText: text })); } return new Promise((resolve, reject) => { // 浏览器环境优先 GM_xmlhttpRequest(突破 CORS) if (typeof GM_xmlhttpRequest === 'function') { GM_xmlhttpRequest({ method: opts.method || 'GET', url: opts.url, headers: opts.headers || { 'Accept': 'application/json' }, timeout: opts.timeout || 12000, 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; } // 降级到 fetch(无 GM 环境,例如 Node 测试) const ctl = (typeof AbortController === 'function') ? new AbortController() : null; const timer = ctl ? setTimeout(() => ctl.abort(), opts.timeout || 12000) : 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) { // SGLVCore 可用:委托 gmFetchJson(自动重试 + 状态校验 + JSON 解析) 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) { // SGLVCore 可用:委托 gmFetchText(自动重试) if (root.SGLVCore && root.SGLVCore.gmFetchText) { return root.SGLVCore.gmFetchText(url, opts); } const r = await _http(Object.assign({ url }, opts)); return r.responseText; } // 并发限流(优先委托 SGLVCore.concurrentPool,带 429 降并发 + 重试) async function _mapLimit(items, limit, iter) { if (root.SGLVCore && root.SGLVCore.concurrentPool) { const tasks = items.map((item, idx) => () => iter(item, idx)); const results = await root.SGLVCore.concurrentPool(tasks, limit); // 还原 _mapLimit 契约:out[i] = value 或 { __error: msg } return results.map(r => { if (r.status === 'fulfilled') return r.value; return { __error: r.reason && r.reason.message || String(r.reason) }; }); } // 降级路径:本地简易限流 const out = new Array(items.length); let i = 0; async function worker() { while (true) { const idx = i++; if (idx >= items.length) return; try { out[idx] = await iter(items[idx], idx); } catch (e) { out[idx] = { __error: e && e.message || String(e) }; } } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return out; } // ==================== 解析层 ==================== function _parseAppdetails(payload, appid) { if (!payload || !payload[appid] || !payload[appid].success) return null; const d = payload[appid].data || {}; const ov = d.price_overview || null; const reviews = d.reviews || ''; // HTML 串 // 解析评测:Steam reviews HTML 形如 "..." 等 const reviewSummary = (reviews.match(/game_review_summary[^>]*>([^<]+) ({ id: c.id, description: c.description || '' })) : []; // v1.1.0: 支持语言详细解析(检测 * 标记=完全音频支持,提取脚注说明) const supportedLanguages = (() => { const raw = d.supported_languages || ''; if (!raw) return { list: [], note: '' }; let note = '', body = raw; const brIdx = raw.indexOf('
'); if (brIdx >= 0) { body = raw.slice(0, brIdx); note = raw.slice(brIdx + 4).replace(/<[^>]*>/g, '').trim(); } const langs = body.split(',') .map(s => s.replace(/<[^>]*>/g, '').trim()) .filter(Boolean) .map(name => { const hasAudio = name.includes('*'); return { name: name.replace(/\*/g, '').trim(), audio: hasAudio }; }) .filter(x => x.name); return { list: langs, note }; })(); // v1.1.0: 捆绑包/购买选项(从 package_groups 展开) const packages = []; if (Array.isArray(d.package_groups)) { d.package_groups.forEach(group => { (group.subs || []).forEach(sub => { const priceCents = sub.price_in_cents_with_discount; const isFree = sub.is_free_license === true || priceCents === 0; const optionText = (sub.option_text || '').trim(); let name = optionText, priceText = ''; const dashIdx = optionText.lastIndexOf(' - '); if (dashIdx > 0) { name = optionText.slice(0, dashIdx).trim(); priceText = optionText.slice(dashIdx + 3).trim(); } const savings = sub.percent_savings || 0; packages.push({ packageId: sub.packageid, name, priceText: isFree ? '免费' : priceText, isFree, discount: savings > 0 ? savings : 0, groupTitle: group.title || '', }); }); }); } return { appid: Number(appid), type: d.type || 'game', name: d.name || '', isFree: !!d.is_free, cover: d.header_image || '', developers: Array.isArray(d.developers) ? d.developers.slice() : [], publishers: Array.isArray(d.publishers) ? d.publishers.slice() : [], releaseDate: (d.release_date && d.release_date.date) || '', comingSoon: !!(d.release_date && d.release_date.coming_soon), platforms: { win: !!(d.platforms && d.platforms.windows), mac: !!(d.platforms && d.platforms.mac), linux: !!(d.platforms && d.platforms.linux), }, genres: Array.isArray(d.genres) ? d.genres.map(g => g.description || g.id || '').filter(Boolean) : [], // v1.1.0: genres 同时提供对象数组(genreObjs,供 SGIS resolveTagName ID 查找)和字符串数组(向后兼容) genreObjs: Array.isArray(d.genres) ? d.genres.map(g => ({ id: g.id, description: g.description || '' })) : [], // v1.1.0: categories 同时提供对象数组(categoryObjs)和字符串数组(向后兼容) categories: categoryObjs.map(c => c.description || String(c.id || '')).filter(Boolean), categoryObjs, shortDesc: d.short_description || '', aboutDesc: d.about_the_game || '', detailedDescHtml: d.detailed_description || '', website: d.website || '', requirements: { minimum: (d.pc_requirements && d.pc_requirements.minimum) || '', recommended: (d.pc_requirements && d.pc_requirements.recommended) || '', }, languages: (d.supported_languages || '').split(/[,;]\s*/).filter(Boolean).slice(0, 20), supportedLanguages, // v1.1.0: 详细解析版 {list: [{name, audio}], note} cnPrice: ov ? { currency: ov.currency, price: ov.final / 100, initial: ov.initial / 100, discount: ov.discount_percent || 0, } : (d.is_free ? { currency: 'CNY', price: 0, initial: 0, discount: 0, free: true } : null), reviews: { summary: reviewSummary, count: reviewCount, pos: reviewPos }, metacritic: d.metacritic ? { score: d.metacritic.score, url: d.metacritic.url } : null, media: { screenshots: Array.isArray(d.screenshots) ? d.screenshots.map(s => ({ url: s.path_thumbnail || s.path_full, full: s.path_full })) : [], movies: Array.isArray(d.movies) ? d.movies.map(m => ({ name: m.name, url: m.webm && m.webm.max, thumb: m.thumbnail })) : [], }, // v1.1.0: SGIS 概览所需附加字段 movies: Array.isArray(d.movies) ? d.movies.slice(0, 4).map(m => ({ id: m.id, name: m.name || '', thumbnail: m.thumbnail || '', dash_av1: m.dash_av1 || '', dash_h264: m.dash_h264 || '', hls_h264: m.hls_h264 || '', postcard: m.postcard || m.thumbnail || '', highlight: !!m.highlight, })) : [], screenshots: (Array.isArray(d.screenshots) ? d.screenshots : []).slice(0, 9).map(s => ({ thumbnail: s.path_thumbnail, full: s.path_full, })), packages, controllerSupport: d.controller_support || null, requiredAge: (() => { if (d.required_age === undefined || d.required_age === null || d.required_age === '') return 0; const parsed = parseInt(String(d.required_age).replace(/[^\d]/g, ''), 10); return isNaN(parsed) ? 0 : parsed; })(), contentDescriptors: d.content_descriptors ? { ids: d.content_descriptors.ids || [], notes: d.content_descriptors.notes || '' } : null, background: d.background || d.background_raw || '', achievementsTotal: (d.achievements && d.achievements.total) ? d.achievements.total : 0, recommendations: (d.recommendations && d.recommendations.total) ? d.recommendations.total : 0, dlc: Array.isArray(d.dlc) ? d.dlc.slice(0, 30) : [], }; } function _parsePriceRegion(payload, appid, region) { if (!payload || !payload[appid] || !payload[appid].success) return null; const d = payload[appid].data || {}; if (d.is_free) return { region, price: 0, currency: 'FREE', initial: 0, discount: 0, free: true }; if (!d.price_overview) return null; const ov = d.price_overview; return { region, price: ov.final / 100, currency: ov.currency, initial: ov.initial / 100, discount: ov.discount_percent || 0, }; } // D1: 币种不匹配检测 — 借鉴 SGIS 主脚本 v2.3.7 经验 // Steam appdetails API 对登录用户可能忽略 cc= 参数,返回用户实际所在地区的价格 // (如请求 CN 但返回 INR), 这种数据是错误的,应标记为失败 // 返回 { ok: true } 或 { ok: false, error: string, expected, actual } function validatePriceRegion(priceObj, regionMeta) { if (!priceObj) return { ok: false, error: 'no price', expected: null, actual: null }; // FREE / 0 元 不参与校验 if (priceObj.free || priceObj.currency === 'FREE') return { ok: true }; if (!regionMeta || !regionMeta.currency) return { ok: true }; // 无 region meta 时不强制 if (priceObj.currency === regionMeta.currency) return { ok: true }; return { ok: false, error: `币种不匹配(期望${regionMeta.currency}, 实际${priceObj.currency}, 疑似Steam返回用户本区数据)`, expected: regionMeta.currency, actual: priceObj.currency, }; } function _parseAugmentedRate(payload) { // AugmentedSteam 形如 { "rates": { "USD": 0.14, "EUR": 0.13, ... } }, 基准为 USD if (!payload || !payload.rates) return null; const rates = payload.rates; if (!rates[RATE_BASE]) return null; // 转换为 "1 CNY = X 外币" const result = {}; Object.keys(rates).forEach(cur => { result[cur] = rates[cur] / rates[RATE_BASE]; }); return result; } function _parseOpenER(payload) { // open.er-api.com 形如 { "result": "success", "base_code": "CNY", "rates": { "USD": 0.14, ... } } if (!payload || payload.result !== 'success' || !payload.rates) return null; return Object.assign({}, payload.rates); } // ==================== steamui 渠道(v1.2.0) ==================== // 委托 sglv-cover-fallback.lib.js 拉取 steamui appinfo(规范化结构): // { appid, name, nameLocalized, type, oslist, releasestate, associations[], languages[], // assets: { poster, hero, heroBlur, logo, libraryHeader, smallCapsule, storeHeader } } // 该库未加载时返回 null(渠道跳过,不影响主流程)。 async function getSteamuiInfo(appid, options) { const fb = root.SGLVCoverFallback; if (!fb || typeof fb.getSteamuiAssets !== 'function') return null; try { return await fb.getSteamuiAssets(appid, options || {}); } catch (e) { return null; } } // 单资产组 → URL 结构。group 兼容两种形态: // variants: { english: {image, image2x}, japanese: {...} } (library_assets_full) // langMap: { english: 'hash/header.jpg', ... } (small_capsule / header_image) function _steamuiAssetUrls(appid, group, lang) { if (!group || typeof group !== 'object') return null; const langs = Object.keys(group); if (!langs.length) return null; const l = (lang && group[lang] !== undefined) ? lang : (group.english !== undefined ? 'english' : langs[0]); const v = group[l]; const base = STEAMUI_ASSET_CDN + appid + '/'; if (typeof v === 'string') { return /^https?:\/\//.test(v) ? { url: v, url2x: '', lang: l, langs } : { url: base + v, url2x: '', lang: l, langs }; } if (v && typeof v === 'object') { const url = v.image ? base + v.image : ''; const url2x = v.image2x ? base + v.image2x : ''; if (!url && !url2x) return null; return { url, url2x, lang: l, langs }; } return null; } // steamui 规范化信息 → libraryAssets 结构(kind → {url, url2x, lang, langs}) // kind 覆盖: poster(竖版 600x900) / hero(库横幅) / heroBlur / logo / libraryHeader / capsule / header function buildLibraryAssets(info, lang) { if (!info || !info.assets || typeof info.assets !== 'object') return null; const id = info.appid || 0; if (!id) return null; const kindMap = [ ['poster', 'poster'], ['hero', 'hero'], ['heroBlur', 'heroBlur'], ['logo', 'logo'], ['libraryHeader', 'libraryHeader'], ['capsule', 'smallCapsule'], ['header', 'storeHeader'], ]; const out = {}; let any = false; kindMap.forEach(([kind, key]) => { const u = _steamuiAssetUrls(id, info.assets[key], lang); if (u) { out[kind] = u; any = true; } }); return any ? out : null; } // associations → { developers, publishers, franchise } function _steamuiAssoc(info) { const out = { developers: [], publishers: [], franchise: '' }; (Array.isArray(info.associations) ? info.associations : []).forEach(a => { if (!a || !a.name) return; if (a.type === 'developer') out.developers.push(a.name); else if (a.type === 'publisher') out.publishers.push(a.name); else if (a.type === 'franchise' && !out.franchise) out.franchise = a.name; }); return out; } // 增强已有 detail(主路径成功后的补充,只填空缺字段,不覆盖已有值) // 新增/补全: franchise / libraryAssets / chineseName / localizedNames / developers / publishers / cover / background function enrichWithSteamui(detail, info, lang) { if (!detail || !info) return detail; const assoc = _steamuiAssoc(info); if (!detail.franchise && assoc.franchise) detail.franchise = assoc.franchise; if (!Array.isArray(detail.developers) || !detail.developers.length) { if (assoc.developers.length) detail.developers = assoc.developers.slice(); } if (!Array.isArray(detail.publishers) || !detail.publishers.length) { if (assoc.publishers.length) detail.publishers = assoc.publishers.slice(); } if (info.nameLocalized && typeof info.nameLocalized === 'object') { detail.localizedNames = info.nameLocalized; const cn = info.nameLocalized.schinese || info.nameLocalized.tchinese || ''; if (cn && !detail.chineseName) detail.chineseName = cn; } if (!detail.libraryAssets) { detail.libraryAssets = buildLibraryAssets(info, lang); } // cover/background 兜底(appdetails 失败或未返回时) if (!detail.cover && detail.libraryAssets && detail.libraryAssets.header) { detail.cover = detail.libraryAssets.header.url2x || detail.libraryAssets.header.url; } if (!detail.background && detail.libraryAssets && detail.libraryAssets.hero) { detail.background = detail.libraryAssets.hero.url2x || detail.libraryAssets.hero.url; } detail.steamuiEnriched = true; return detail; } // steamui 规范化信息 → 完整 detail 对象(降级源:两路 appdetails 均失败时) // 字段远少于 appdetails(无价格/简介/评测),但名称/封面/平台/厂商/系列/多语言资产齐全 function _parseSteamuiDetail(info, appid, lang) { if (!info) return null; const assoc = _steamuiAssoc(info); const os = Array.isArray(info.oslist) ? info.oslist : []; const assets = buildLibraryAssets(info, lang); const langList = Array.isArray(info.languages) ? info.languages.slice(0, 20) : []; return { appid: Number(appid), type: String(info.type || 'game').toLowerCase(), name: info.name || '', isFree: false, cover: assets && assets.header ? (assets.header.url2x || assets.header.url) : '', developers: assoc.developers, publishers: assoc.publishers, franchise: assoc.franchise, releaseDate: '', comingSoon: !!(info.releasestate && info.releasestate !== 'released'), platforms: { win: os.includes('windows'), mac: os.includes('mac'), linux: os.includes('linux') }, genres: [], genreObjs: [], categories: [], categoryObjs: [], shortDesc: '', aboutDesc: '', detailedDescHtml: '', website: '', requirements: { minimum: '', recommended: '' }, languages: langList, supportedLanguages: { list: langList.map(n => ({ name: n, audio: false })), note: '' }, cnPrice: null, reviews: { summary: '', count: '', pos: 0 }, metacritic: null, media: { screenshots: [], movies: [] }, movies: [], screenshots: [], packages: [], controllerSupport: null, requiredAge: 0, contentDescriptors: null, background: assets && assets.hero ? (assets.hero.url2x || assets.hero.url) : '', achievementsTotal: 0, recommendations: 0, dlc: [], // v1.2.0 steamui 专属字段 chineseName: (info.nameLocalized && (info.nameLocalized.schinese || info.nameLocalized.tchinese)) || '', localizedNames: info.nameLocalized || null, libraryAssets: assets, steamuiEnriched: true, source: 'steamui', }; } // ==================== 业务层 ==================== // --- 详情加载(主入口) --- async function loadDetail(appid, options) { options = options || {}; const lang = options.lang || detectLang(); const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const useCache = options.useCache !== false; const force = !!options.force; const cacheKey = `detail:${appid}:${lang}`; if (useCache && !force) { const hit = _memGet(cacheKey) || _diskGet(cacheKey); if (hit) { if (onProgress) onProgress('done', t('loadStage3'), hit); return hit; } } if (_isInFailBlacklist(cacheKey) && !force) { throw new Error('detail fetch in cool-down'); } if (onProgress) onProgress('fetch', t('loadStage1')); // 主路径:appdetails API(cc=cn + l=schinese 一次拿到中文描述) let detail = null; try { const url = `${APPDETAILS_BASE}?appids=${appid}&cc=cn&l=schinese`; const payload = await _getJson(url, { timeout: 12000 }); detail = _parseAppdetails(payload, appid); } catch (e) { /* fallback below */ } // 降级路径 1:英文 appdetails if (!detail) { try { const url = `${APPDETAILS_BASE}?appids=${appid}&cc=us&l=english`; const payload = await _getJson(url, { timeout: 12000 }); detail = _parseAppdetails(payload, appid); } catch (e) { /* fallback below */ } } // 降级路径 2:steamui appinfo(名称/多语言封面/平台/厂商/系列;无价格与简介) if (!detail) { try { const info = await getSteamuiInfo(appid, { timeout: 6000 }); detail = _parseSteamuiDetail(info, appid, lang); } catch (e) { /* fallback below */ } } // 降级路径 3:store 页面 DOM 抽取 if (!detail) { try { detail = await _extractFromStorePage(appid); } catch (e) { _markFail(cacheKey); throw e; } } if (!detail) { _markFail(cacheKey); throw new Error('detail not found'); } // v1.2.0: steamui 增强 — 补 franchise/libraryAssets/本地化名/封面兜底 // (appdetails 成功时也执行;steamui 缓存/负缓存由 cover-fallback 管理,通常零开销) if (options.enrichSteamui !== false && detail.source !== 'steamui') { try { const info = await getSteamuiInfo(appid, { timeout: 6000 }); if (info) enrichWithSteamui(detail, info, lang); } catch (e) { /* 增强失败不影响主数据 */ } } if (onProgress) onProgress('parse', t('loadStage2')); // 解析 HTML 描述(清洗广告/截断过长描述) if (detail.detailedDescHtml) detail.detailedDescHtml = sanitizeHtml(detail.detailedDescHtml, 4000); if (detail.aboutDesc) detail.aboutDesc = sanitizeHtml(detail.aboutDesc, 1200); detail.cachedAt = Date.now(); detail.lang = lang; _memSet(cacheKey, detail, MEM_TTL); _diskSet(cacheKey, detail, DISK_TTL); if (onProgress) onProgress('done', t('loadStage3'), detail); return detail; } // store 页面 DOM 抽取(降级,仅取基础字段) async function _extractFromStorePage(appid) { const url = `https://store.steampowered.com/app/${appid}/?l=schinese`; const html = await _getText(url, { timeout: 12000 }); // pick(re, fallback, group=1) — group 参数支持选择第 N 个捕获组(默认 1) const pick = (re, fallback, group) => { const m = html.match(re); const idx = group || 1; return m && m[idx] ? m[idx].trim() : fallback; }; const name = pick(/
]*>([^<]+)]+src="([^"]+)"/, ''); const shortDesc = pick(/
]*>([\s\S]*?)<\/div>/, '').replace(/<[^>]+>/g, '').trim(); // 取第 2 个捕获组:

About This Game

之后的描述内容 const aboutDesc = pick(/
]*>([\s\S]*?)

About This Game<\/h2>([\s\S]*?)<\/div>/, '', 2); const developer = pick(/Developer:<\/b>[\s\S]*?]+>([^<]+)Publisher:<\/b>[\s\S]*?]+>([^<]+)Release Date:<\/b>([^<]+) { try { const url = `${APPDETAILS_BASE}?appids=${appid}&cc=${region}&l=english`; const payload = await _getJson(url, { timeout: 10000 }); return _parsePriceRegion(payload, appid, region); } catch (e) { return null; } }); const prices = []; const failed = []; results.forEach((r, i) => { if (!r) { failed.push({ region: regions[i] }); return; } // D1: 币种不匹配检测 — Steam API 对登录用户可能忽略 cc= 参数, // 返回用户实际所在地区的价格(如请求 CN 但返回 INR), 这种数据是错误的 // 借鉴 SGIS 主脚本 v2.3.7 经验 const meta = REGION_META[r.region]; const v = validatePriceRegion(r, meta); if (!v.ok) { failed.push({ region: r.region, error: v.error }); return; } prices.push(r); }); // 转换为 CNY(等值,便于排序) prices.forEach(p => { if (p.free || p.currency === 'FREE') p.cny = 0; else if (p.currency === RATE_BASE) p.cny = p.price; else p.cny = rateInfo.ready ? toCNY(p.price, p.currency, rateInfo.rates) : null; }); // 排序 prices.sort((a, b) => { if (a.cny == null && b.cny == null) return 0; if (a.cny == null) return 1; if (b.cny == null) return -1; return a.cny - b.cny; }); const out = { appid: Number(appid), prices, failed, rateReady: rateInfo.ready, rateSource: rateInfo.source, baseCurrency: RATE_BASE, userRegion: _detectUserRegion(), cachedAt: Date.now(), }; _memSet(cacheKey, out, MEM_TTL); _diskSet(cacheKey, out, DISK_TTL); if (onProgress) onProgress('done', '', out); return out; } function _detectUserRegion() { try { const m = (root.document && root.document.cookie || '').match(/steamCountry=(\w{2})/); if (m) return m[1]; } catch (e) { /* ignore */ } return 'CN'; } // 汇率拉取(主:AS / 降级:open.er-api) let _ratesCache = null; // { rates, exp, source } async function _ensureRates() { if (_ratesCache && _ratesCache.exp > Date.now()) { return { ready: true, rates: _ratesCache.rates, source: _ratesCache.source }; } // 主:AugmentedSteam try { const data = await _getJson(RATE_PRIMARY_URL, { timeout: 8000 }); const rates = _parseAugmentedRate(data); if (rates) { _ratesCache = { rates, exp: Date.now() + RATE_TTL, source: 'augmentedsteam' }; return { ready: true, rates, source: 'augmentedsteam' }; } } catch (e) { /* fallback below */ } // 降级:open.er-api try { const data = await _getJson(RATE_FALLBACK_URL, { timeout: 8000 }); const rates = _parseOpenER(data); if (rates) { _ratesCache = { rates, exp: Date.now() + RATE_TTL, source: 'open.er-api' }; return { ready: true, rates, source: 'open.er-api' }; } } catch (e) { /* ignore */ } return { ready: false, rates: null, source: null }; } // 公共:获取当前内存中的汇率(委托 _ensureRates,带 1h 内存缓存 + 自动重试) // 返回 { ready, rates, source, baseCurrency } // rates 语义为 1 CNY = X 外币 (baseCurrency=CNY 时的 AugmentedSteam 原值); // 跨脚本消费端如需 USD 枢纽表(「1 USD = X 币种」,键小写 apiCode), // 请用 SGLVCurrency.cnyBaseRatesToUsdRates(rates) 适配,勿自行取倒数混用 SGIS 语义。 // 调用方拿到 rates 后: // - 想换算到 baseCurrency: toCNY(amount, code, rates) ← 库已暴露 // - 想做币种校验: rates[code] !== null 即汇率可用 // 失败时 ready=false, rates=null (调用方应降级到本地拉取) async function getRates(options) { const force = !!(options && options.force); if (force) _ratesCache = null; // 强制刷新 const info = await _ensureRates(); return { ready: info.ready, rates: info.rates, source: info.source, baseCurrency: RATE_BASE, }; } // 公共:region 期望币种表(用于 D1 币种不匹配检测) // 默认内置 9 个 PRIORITY_REGIONS; 调用方可通过 setRegionMeta 覆盖或扩展 // v2.9.53 之前的 SGIS 实现 (steam-game-library-viewer 主脚本 line 12327) 有 17 地区 // 完整映射,这里只内置库需要的 9 地区,扩展由调用方负责 const REGION_META = { CN: { name: '中国', flag: '🇨🇳', currency: 'CNY' }, US: { name: '美国', flag: '🇺🇸', currency: 'USD' }, TR: { name: '土耳其', flag: '🇹🇷', currency: 'TRY' }, AR: { name: '阿根廷', flag: '🇦🇷', currency: 'ARS' }, RU: { name: '俄罗斯', flag: '🇷🇺', currency: 'RUB' }, IN: { name: '印度', flag: '🇮🇳', currency: 'INR' }, BR: { name: '巴西', flag: '🇧🇷', currency: 'BRL' }, UA: { name: '乌克兰', flag: '🇺🇦', currency: 'UAH' }, KZ: { name: '哈萨克斯坦', flag: '🇰🇿', currency: 'KZT' }, }; function setRegionMeta(code, meta) { if (!code || typeof code !== 'string') return; REGION_META[code.toUpperCase()] = Object.assign({}, REGION_META[code.toUpperCase()] || {}, meta || {}); } function getRegionMeta(code) { return code ? REGION_META[String(code).toUpperCase()] : null; } // ==================== 拥有 / 共享 状态徽章 ==================== /** * 计算一个 appid 的"拥有 / 共享"状态,返回 UI 可直接使用的徽章信息。 * 返回结构: * { * kind: 'mine' | 'shared' | 'not-owned' | 'unknown', * label: string, // "已拥有" / "家庭共享" / "未拥有" / "—" * tip: string, // tooltip 文本(包含共享者/拥有者姓名) * className: string, // 'sglv-detail-badge-mine' / 'shared' / 'not-owned' / 'unknown' * ownerNames: string, // 原始共享者/拥有者姓名(逗号分隔) * gameObj: object|null // 找到的 ownedGames 项 * } * 若宿主未注入 API,kind 始终为 'unknown'。 * * 第二个参数 `gameOrMap` 用于 O(1) 快速查找(推荐): * - 传 { appid, owners, name, ... }:直接当 gameObj,跳过 Array.find * - 传 Map:从 Map 取 * - 传 null/undefined:降级到 _host.getOwnedGames() + Array.find */ function getOwnershipBadge(appid, gameOrMap /* optional fast lookup */) { const empty = { kind: 'unknown', label: '—', tip: '', className: 'unknown', ownerNames: '', gameObj: null, }; if (!_host.getOwnedGames) return empty; let game = null; if (gameOrMap instanceof Map) { game = gameOrMap.get(Number(appid)) || null; } else if (gameOrMap && typeof gameOrMap === 'object' && Number(gameOrMap.appid) === Number(appid)) { game = gameOrMap; } if (!game) { try { const all = _host.getOwnedGames() || []; game = all.find(g => Number(g.appid) === Number(appid)) || null; } catch (e) { return empty; } } if (!game) { return { kind: 'not-owned', label: t('ownershipNotOwned'), tip: t('notOwnedHint'), className: 'sglv-detail-badge-not-owned', ownerNames: '', gameObj: null, }; } let isMine = true; try { if (_host.isGameOwnedByMe) isMine = !!_host.isGameOwnedByMe(game); } catch (e) { isMine = true; } let ownerNames = ''; try { if (_host.getGameOwnerNames) ownerNames = _host.getGameOwnerNames(game) || ''; } catch (e) { /* ignore */ } if (isMine) { return { kind: 'mine', label: t('ownershipMine'), tip: ownerNames ? t('ownershipOwnedBy').replace('{names}', ownerNames) : '', className: 'sglv-detail-badge-mine', ownerNames, gameObj: game, }; } return { kind: 'shared', label: t('ownershipShared'), tip: ownerNames ? t('sharedFrom').replace('{names}', ownerNames) : t('ownershipShared'), className: 'sglv-detail-badge-shared', ownerNames, gameObj: game, }; } // ==================== 工具层 ==================== function toCNY(amount, currency, rates) { if (!Number.isFinite(amount)) return null; if (currency === RATE_BASE) return amount; if (!rates || !rates[currency]) return null; // rates[CNY] = 1, rates[USD] = 0.14 → 1 USD = 1/0.14 CNY return amount / rates[currency]; } function formatPrice(amount, currency) { if (!Number.isFinite(amount)) return '—'; if (currency === 'CNY') return `¥${amount.toFixed(2)}`; return `${amount.toFixed(2)} ${currency}`; } // HTML 清洗(白名单,strip script/style/on*) // 安全策略: // 1) 整标签剥离: