// ==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[^>]*>([^<]+)) || [])[1] || '';
const reviewCount = (reviews.match(/(\d+(?:[,.]\d+)*)\s*user reviews/) || [])[1] || '';
const reviewPos = parseInt((reviews.match(/\((\d+)%\)/) || [])[1] || '0', 10) || 0;
// v1.1.0: categories 保留 {id, description} 对象数组(供 SGIS 概览使用),同时提供字符串数组(向后兼容)
const categoryObjs = Array.isArray(d.categories) ? d.categories.map(c => ({
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(/