Steam 游戏封面三级降级库:内存缓存(跨视图共享)→ 持久化缓存(跨会话,GM_setValue,LRU 500)→ CDN fallback 链(Akamai 优先 → Cloudflare 兜底)→ Steam appdetails API 兜底 → SVG placeholder。供 SGLV 系列脚本(game-library-viewer / wishlist / friend / badges ...)@require 引用,复用降级逻辑。不可单独使用。
This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/589926/1909126/SGLV%20%E5%B0%81%E9%9D%A2%E4%B8%89%E7%BA%A7%E9%99%8D%E7%BA%A7%20%28Library%29.js
// ==UserScript==
// @name SGLV 封面三级降级 (Library)
// @name:en SGLV Cover Fallback (Library)
// @namespace https://greasyfork.org/scripts/588485
// @version 1.2.0
// @description Steam 游戏封面多级降级库:内存缓存(跨视图共享)→ steamui.com appinfo 精确 hash URL → 持久化缓存(跨会话,GM_setValue,LRU 500)→ CDN fallback 链(shared.akamai → shared.fastly → cdn.akamai → cloudflare 四 CDN × 语言变体)→ Steam appdetails API 兜底 → SVG placeholder。支持 poster/capsule/header/hero/logo/library_header 六种图片类型与语言变体(schinese 等)。v1.2.0 新增 steamui.com 游戏资产信息渠道(每语言 hash 路径/logo_position/associations)。供 SGLV 系列脚本 @require 引用,复用降级逻辑。不可单独使用。
// @description:en SGLV Cover Fallback Library v1.2.0 — Steam game cover multi-tier fallback chain (in-memory cache → steamui.com appinfo exact hash URL → persistent GM_setValue cache LRU 500 → language-variant CDN chain shared.akamai + shared.fastly + cdn.akamai + cloudflare → appdetails API → SVG placeholder). Supports poster/capsule/header/hero/logo/library_header image kinds and language variants. **v1.2.0** adds steamui.com game asset info channel (per-language hash paths / logo_position / associations). For SGLV host scripts @require only.
// @author SmallFork
// @license MIT
// @match *://*/*
// @connect store.steampowered.com
// @connect steamui.com
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @noframes
// ==/UserScript==
/*
* SGLV Cover Fallback Library v1.2.0
*
* v1.2.0 变更(steamui.com appinfo 渠道增强):
* - 新增 kind: hero(library_hero 3840x1240 库背景) / logo(logo.png 库徽标) /
* library_header(920x430 库页头图)
* - 语言变体链: getCoverUrl/buildFullChain/loadImageWithFallback 新增 lang 参数,
* img 标签可通过 data-lang="schinese" 指定语言;链前部插入 `_{lang}` 变体文件名
* (实测 CDN 语言后缀可直接拼接: library_600x900_schinese.jpg / library_hero_japanese.jpg /
* logo_japanese.png 等全部 200)
* - 新增 steamui.com 信息渠道 getSteamuiAssets(appId): Steam 内部 appinfo 数据源,
* 一次请求返回各资产 hash 精确路径 + 每语言可用性 + logo_position + associations
* (developer/publisher/franchise) + oslist/releasestate/icon hash;
* 站点受 Cloudflare 盾保护,设计为尽力而为:失败静默降级 + 全局会话级负缓存(30min)
* + 按 appid 负缓存(404 游戏不存在, 24h),防止 CF 拦截/404 重试风暴
* - 新增 getSteamuiGoodUrl(appId, kind, lang): 同步读 steamui 缓存构建精确 hash URL
* (免 CDN 探测,语言选择精确: 请求语言 → english → 第一个可用)
* - 新增 resolveCover(appId, kind, lang): Promise 版解析,steamui 优先 → 探测链首
* - loadImageWithFallback 降级顺序增强: CDN 探测链耗尽 → steamui hash URL →
* appdetails API → SVG 占位
* - 新增 parseSteamInfo(text) 容错 VDF/JSON 解析器(纯函数,供 sglv-app-detail 等库复用)
*
* v1.1.0 变更:
* - 新增 kind 参数支持(poster/capsule/header),缓存键按 kind 隔离
* - CDN 链扩展:shared.akamai → shared.fastly → cdn.akamai → cdn.cloudflare
* - poster 链增加 library_600x900_2x/library_600x900 竖版图
* - API 兜底按 kind 选择最佳图片(capsule 优先 capsule_image,poster/header 优先 header_image)
* - img 标签可通过 data-kind="poster|capsule|header" 指定图片类型
* v1.1.1: 修复浏览器扩展 hook XHR 导致 responseText 不可访问、新增 shared.fastly CDN 源
*
* 设计目标:从 SGLV 业务脚本中抽出"游戏封面降级 + 游戏资产信息获取"基础设施,
* 供所有 SGLV 系列脚本通过 @require 共享,避免每个脚本都重写一遍:
* - steamui.com appinfo 渠道(hash 精确 URL / 语言可用性 / logo_position)
* - 多 CDN fallback 链 × 语言变体(shared.akamai → shared.fastly → cdn.akamai → cloudflare)
* - 跨视图/跨会话的 good URL 缓存(按 kind+lang 隔离)
* - appdetails API 兜底(kind 感知)
* - SVG placeholder 兜底
*
* API 暴露方式:window.SGLVCoverFallback (IIFE 直接挂全局,无依赖注入)
*
* 使用方式:
* 1) 宿主 @require 此 lib
* 2) img 标签上添加 data-sglv-cover="1" + data-appid="123"
* + data-kind="poster|capsule|header|hero|logo|library_header"(可选,默认 capsule)
* + data-lang="schinese"(可选,链前部插入语言变体)
* 3) 宿主在根容器上调用 SGLVCoverFallback.bindErrorHandler(rootEl)
* 4) 初始 src 调用 SGLVCoverFallback.getCoverUrl(appId, kind, lang) 获取
* (自动: good 缓存 → steamui 精确 URL → 语言变体链首)
* 5) 错误时自动走降级链;成功时自动写缓存(同会话 + 跨会话)
*
* 桥接契约(测试/宿主需要注入):
* window.__sglvCoverMockGmXhr = function(method, url, opts, cb) 可选,默认走 GM_xmlhttpRequest
*
* API:
* version: '1.2.0'
* apiVersion: 2
* SVG_PLACEHOLDER: data:image/svg+xml 兜底图
* CHAIN: 默认 CDN fallback 链(横图 7:3,向后兼容)
* KINDS: 支持的图片类型列表
* LANG_VARIANTS: 各 kind 的语言变体文件名模板
* getCoverUrl(appId, kind?, lang?): 获取初始 URL(good缓存→steamui→链首)
* resolveCover(appId, kind?, lang?): Promise 版(steamui 拉取优先,失败给链首)
* bindErrorHandler(rootEl, onFailed?): 在 rootEl 上绑定 error/load 委托(全局只调一次)
* loadImageWithFallback(img, onFailed?): 手动触发单图降级(自动调用,通常无需手动)
* clearPersistedCache(): 清空持久化缓存(含 steamui 资产缓存)
* getSteamuiAssets(appId, opts?): Promise — steamui appinfo 规范化数据或 null
* getSteamuiGoodUrl(appId, kind?, lang?): string — steamui 缓存命中的精确 hash URL
* parseSteamInfo(text): VDF/JSON 容错解析(纯函数)
* normalizeSteamInfo(raw, appId): appinfo 原始对象 → 规范化资产结构(纯函数)
*
* 宿主需 @grant GM_xmlhttpRequest, GM_getValue, GM_setValue。
*/
(function (root) {
'use strict';
// ==================== CDN 基础 + 链 ====================
const CDN_SHARED = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/';
const CDN_FASTLY = 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/';
const CDN_AKAMAI = 'https://cdn.akamai.steamstatic.com/steam/apps/';
const CDN_CF = 'https://cdn.cloudflare.steamstatic.com/steam/apps/';
// v1.2.0: steamui hash URL 的 CDN 前缀(store_item_assets 路径,fastly 主 + akamai 备)
const STEAMUI_ASSET_CDNS = [
'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/',
'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/',
];
// 横图 7:3 链(列表/封面视图,向后兼容 v1.0.0)
const CHAIN = ['capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg', 'header.jpg'];
// v1.1.0: 各 kind 基础文件(无语言变体)
const POSTER_FILES = ['library_600x900_2x.jpg', 'library_600x900.jpg'];
const CAPSULE_FILES = ['capsule_467x181.jpg', 'capsule_231x87.jpg'];
const HEADER_FILES = ['header.jpg'];
// v1.2.0: 新 kind 基础文件
const HERO_FILES = ['library_hero_2x.jpg', 'library_hero.jpg'];
const LOGO_FILES = ['logo_2x.png', 'logo.png'];
const LIB_HEADER_FILES = ['library_header_2x.jpg', 'library_header.jpg'];
// v1.2.0: 语言变体文件名生成器 — {v} 为语言代码(schinese/tchinese/japanese/...)
// 实测 CDN 直接支持语言后缀拼接,无需 hash:
// library_600x900_schinese.jpg / library_hero_japanese.jpg / logo_japanese.png 等
const LANG_VARIANTS = {
poster: ['library_600x900_{v}_2x.jpg', 'library_600x900_{v}.jpg'],
capsule: ['capsule_467x181_{v}.jpg', 'capsule_231x87_{v}.jpg'],
header: ['header_{v}.jpg'],
hero: ['library_hero_{v}_2x.jpg', 'library_hero_{v}.jpg'],
logo: ['logo_{v}_2x.png', 'logo_{v}.png'],
library_header: ['library_header_{v}_2x.jpg', 'library_header_{v}.jpg'],
};
const KINDS = ['poster', 'capsule', 'header', 'hero', 'logo', 'library_header'];
// SVG placeholder(URL 编码内嵌,所有降级链失败后显示)
const SVG_PLACEHOLDER = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 184 69'%3E%3Crect fill='%231a1f3a' width='184' height='69'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%2364748b' font-family='system-ui' font-size='10'%3ENo Image%3C/text%3E%3C/svg%3E";
// ==================== 内存缓存(同会话跨视图共享) ====================
// 缓存键按 kind(+lang) 隔离 → "kind:appid" / "kind:appid@lang"
const _goodMap = new Map();
function goodKey(kind, appId, lang) {
const k = kind || 'capsule';
return lang ? k + ':' + appId + '@' + lang : k + ':' + appId;
}
// ==================== 持久化缓存(跨会话) ====================
const PERSISTED_KEY = 'sglv_cover_url_v2';
const PERSISTED_MAX = 500;
let _persisted = null;
function loadPersisted() {
if (_persisted !== null) return _persisted;
_persisted = {};
try {
const raw = GM_getValue(PERSISTED_KEY);
if (raw && typeof raw === 'object') _persisted = raw;
} catch (e) { /* ignore */ }
for (const key in _persisted) {
const ent = _persisted[key];
if (ent && ent.url) _goodMap.set(key, ent.url);
}
try {
const oldRaw = GM_getValue('sglv_cover_url_v1');
if (oldRaw && typeof oldRaw === 'object') {
for (const id in oldRaw) {
const ent = oldRaw[id];
if (ent && ent.url && !_persisted['capsule:' + id]) {
_persisted['capsule:' + id] = { url: ent.url, ts: ent.ts || Date.now() };
_goodMap.set('capsule:' + id, ent.url);
}
}
}
} catch (e) { /* ignore */ }
return _persisted;
}
let _writeTimer = null;
let _writeDirty = false;
function setPersisted(cacheKey, url) {
if (!cacheKey || !url) return;
const container = loadPersisted();
container[cacheKey] = { url, ts: Date.now() };
const keys = Object.keys(container);
if (keys.length > PERSISTED_MAX) {
keys.sort((a, b) => (container[a].ts || 0) - (container[b].ts || 0));
while (keys.length > PERSISTED_MAX) delete container[keys.shift()];
}
_writeDirty = true;
if (_writeTimer) return;
_writeTimer = setTimeout(() => {
_writeTimer = null;
if (!_writeDirty) return;
_writeDirty = false;
try { GM_setValue(PERSISTED_KEY, container); } catch (e) { /* ignore */ }
}, 50);
}
function flushPersisted() {
if (_writeTimer) {
clearTimeout(_writeTimer);
_writeTimer = null;
}
if (_writeDirty && _persisted) {
_writeDirty = false;
try { GM_setValue(PERSISTED_KEY, _persisted); } catch (e) { /* ignore */ }
}
flushSteamuiPersist();
}
function clearPersisted() {
_persisted = {};
_goodMap.clear();
try { GM_setValue(PERSISTED_KEY, {}); } catch (e) { /* ignore */ }
try { GM_setValue('sglv_cover_url_v1', {}); } catch (e) { /* ignore */ }
// v1.2.0: 同时清 steamui 资产缓存
_steamuiMem.clear();
_steamuiAppNeg.clear();
try { GM_setValue(STEAMUI_PERSIST_KEY, {}); } catch (e) { /* ignore */ }
}
// ==================== URL 拼接 ====================
// v1.2.0: kind + lang 感知的文件列表(语言变体在前,默认文件在后)
function buildFiles(kind, lang) {
const k = KINDS.indexOf(kind) >= 0 ? kind : (kind || 'capsule');
let base;
if (k === 'poster') base = POSTER_FILES;
else if (k === 'capsule') base = CAPSULE_FILES;
else if (k === 'header') base = HEADER_FILES;
else if (k === 'hero') base = HERO_FILES;
else if (k === 'logo') base = LOGO_FILES;
else base = LIB_HEADER_FILES;
const files = [];
const tpl = LANG_VARIANTS[k];
if (lang && tpl) {
for (const t of tpl) files.push(t.replace('{v}', lang));
}
for (const f of base) files.push(f);
return files;
}
// v1.2.0: kind + lang 感知的全链构建(每 file 先 shared.akamai,再 shared.fastly,再 cdn.akamai,再 cloudflare)
function buildFullChain(appId, kind, lang) {
const id = String(appId);
const files = buildFiles(kind, lang);
const full = [];
for (const f of files) {
full.push(CDN_SHARED + id + '/' + f);
full.push(CDN_FASTLY + id + '/' + f);
full.push(CDN_AKAMAI + id + '/' + f);
full.push(CDN_CF + id + '/' + f);
}
// poster/capsule/hero/logo 链末追加 header.jpg 四 CDN 兜底(header 本身已是 header)
if (kind !== 'header' && kind !== 'library_header') {
for (const cdn of [CDN_SHARED, CDN_FASTLY, CDN_AKAMAI, CDN_CF]) {
full.push(cdn + id + '/header.jpg');
}
}
return full;
}
// ==================== steamui.com appinfo 渠道 (v1.2.0) ====================
// 数据源: https://steamui.com/api/games/{appid}/info — Steam 内部 appinfo 的 JSON 化
// 提供: library_assets_full(每资产 hash 路径×语言) / small_capsule / header_image /
// icon / logo_position / associations / oslist / releasestate / store_asset_mtime
// 风险: 站点受 Cloudflare managed challenge 保护,脚本环境可能被拦 → 尽力而为:
// CF 拦截 → 全局会话级负缓存 30min(拦截是全站的,不按 appid 记)
// 404 → 按 appid 负缓存 24h(游戏无 appinfo,如未上市/已移除)
const STEAMUI_BASE = 'https://steamui.com/api/games/';
const STEAMUI_PERSIST_KEY = 'sglv_steamui_assets_v1';
const STEAMUI_MAX = 300; // LRU 容量
const STEAMUI_TTL = 7 * 24 * 3600 * 1000; // 数据 7 天
const STEAMUI_NEG_TTL = 24 * 3600 * 1000; // appid 404 负缓存 24h
const STEAMUI_CF_TTL = 30 * 60 * 1000; // CF 拦截全局负缓存 30min
const _steamuiMem = new Map(); // appid → { data, ts }
const _steamuiPending = new Map(); // appid → Promise(并发共享)
const _steamuiAppNeg = new Map(); // appid → 过期时间戳(404 负缓存)
let _steamuiPersisted = null; // 惰性加载的持久化容器
let _steamuiCfBlockedUntil = 0; // CF 全局拦截截止时间
let _steamuiWriteTimer = null;
let _steamuiDirty = false;
function _steamuiLoadPersisted() {
if (_steamuiPersisted !== null) return _steamuiPersisted;
_steamuiPersisted = {};
try {
const raw = GM_getValue(STEAMUI_PERSIST_KEY);
if (raw && typeof raw === 'object') _steamuiPersisted = raw;
} catch (e) { /* ignore */ }
// 预热内存 + 顺带按 TTL/容量清理过期条目
const now = Date.now();
const keys = Object.keys(_steamuiPersisted);
let dirty = false;
for (const k of keys) {
const ent = _steamuiPersisted[k];
if (ent && ent.data && (now - (ent.ts || 0)) < STEAMUI_TTL) {
_steamuiMem.set(k, ent);
} else {
delete _steamuiPersisted[k];
dirty = true;
}
}
if (dirty) {
try { GM_setValue(STEAMUI_PERSIST_KEY, _steamuiPersisted); } catch (e) { /* ignore */ }
}
return _steamuiPersisted;
}
function _steamuiPersist(appid, data) {
const container = _steamuiLoadPersisted();
container[String(appid)] = { data, ts: Date.now() };
const keys = Object.keys(container);
if (keys.length > STEAMUI_MAX) {
keys.sort((a, b) => (container[a].ts || 0) - (container[b].ts || 0));
while (keys.length > STEAMUI_MAX) delete container[keys.shift()];
}
_steamuiDirty = true;
if (_steamuiWriteTimer) return;
_steamuiWriteTimer = setTimeout(() => {
_steamuiWriteTimer = null;
if (!_steamuiDirty) return;
_steamuiDirty = false;
try { GM_setValue(STEAMUI_PERSIST_KEY, container); } catch (e) { /* ignore */ }
}, 200);
}
function flushSteamuiPersist() {
if (_steamuiWriteTimer) {
clearTimeout(_steamuiWriteTimer);
_steamuiWriteTimer = null;
}
if (_steamuiDirty && _steamuiPersisted) {
_steamuiDirty = false;
try { GM_setValue(STEAMUI_PERSIST_KEY, _steamuiPersisted); } catch (e) { /* ignore */ }
}
}
// --- 容错 VDF/JSON 解析器(纯函数) ---
// steamui 响应为 VDF 文本(带引号键值 + {} 块 + [] 对象数组);先试 JSON,失败走 VDF tokenizer
function parseSteamInfo(text) {
if (!text || typeof text !== 'string') return null;
const trimmed = text.replace(/^\uFEFF/, '').trim();
if (!trimmed) return null;
// CF 拦截页特征 → 直接判定不可用
if (trimmed.charAt(0) === '<'
&& (trimmed.indexOf('Just a moment') >= 0 || trimmed.indexOf('_cf_chl_opt') >= 0
|| trimmed.indexOf('challenges.cloudflare.com') >= 0)) {
const err = new Error('steamui: cloudflare challenge');
err.cfBlocked = true;
throw err;
}
if (trimmed.charAt(0) === '{' || trimmed.charAt(0) === '[') {
try { return JSON.parse(trimmed); } catch (e) { /* fallthrough to VDF */ }
}
// 无任何块符号的纯文本不可能是 VDF(真实响应必含 {}),避免裸 token 被误判成键值对
if (trimmed.indexOf('{') < 0 && trimmed.indexOf('[') < 0) return null;
try {
return _parseVdfText(trimmed);
} catch (e) {
return null;
}
}
// VDF tokenizer: 产出字符串/符号数组
function _vdfTokens(text) {
const tokens = [];
let i = 0, n = text.length;
while (i < n) {
const c = text.charAt(i);
// 空白
if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i++; continue; }
// 行注释
if (c === '/' && text.charAt(i + 1) === '/') {
while (i < n && text.charAt(i) !== '\n') i++;
continue;
}
// 块注释
if (c === '/' && text.charAt(i + 1) === '*') {
i += 2;
while (i < n && !(text.charAt(i) === '*' && text.charAt(i + 1) === '/')) i++;
i += 2;
continue;
}
// 符号
if (c === '{' || c === '}' || c === '[' || c === ']') {
tokens.push(c);
i++;
continue;
}
// 带引号字符串(支持 \\ \" 转义)
if (c === '"') {
i++;
let s = '';
while (i < n) {
const ch = text.charAt(i);
if (ch === '\\' && i + 1 < n) { s += text.charAt(i + 1); i += 2; continue; }
if (ch === '"') { i++; break; }
s += ch;
i++;
}
tokens.push(s);
continue;
}
// 裸 token(直到空白/符号/引号)
let w = '';
while (i < n) {
const ch = text.charAt(i);
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n'
|| ch === '{' || ch === '}' || ch === '[' || ch === ']' || ch === '"') break;
w += ch;
i++;
}
if (w) tokens.push(w);
else i++; // 防御:非法字符跳过
}
return tokens;
}
// VDF 递归下降解析: {"key" {"k" "v"} "key2" [{"a" "b"}]}
function _parseVdfText(text) {
const tokens = _vdfTokens(text);
let pos = 0;
function parseBlock(depth) {
const obj = {};
while (pos < tokens.length) {
const t = tokens[pos];
if (t === '}') { pos++; return obj; }
if (t === '{' || t === '[' || t === ']') { pos++; continue; } // 容错:跳过意外符号
// t 为 key
pos++;
const next = tokens[pos];
if (next === undefined) { obj[t] = ''; break; }
if (next === '{') { pos++; obj[t] = parseBlock(depth + 1); }
else if (next === '[') { pos++; obj[t] = parseArray(depth + 1); }
else { pos++; obj[t] = next; }
}
return obj;
}
function parseArray(depth) {
const arr = [];
while (pos < tokens.length) {
const t = tokens[pos];
if (t === ']') { pos++; return arr; }
if (t === '{') { pos++; arr.push(parseBlock(depth + 1)); }
else if (t === '}' || t === '[') { pos++; } // 容错
else { pos++; arr.push(t); } // 标量数组元素
}
return arr;
}
const result = parseBlock(0);
return result;
}
// --- appinfo 原始对象 → 规范化资产结构(纯函数) ---
// 输入形如 { appinfo: { common: {...}, associations: [...] }, appid: "..." }
// 或直接 { common: {...}, associations: [...] }
function normalizeSteamInfo(raw, appId) {
if (!raw || typeof raw !== 'object') return null;
const info = raw.appinfo && typeof raw.appinfo === 'object' ? raw.appinfo : raw;
const common = info.common && typeof info.common === 'object' ? info.common : null;
if (!common) return null;
const full = (common.library_assets_full && typeof common.library_assets_full === 'object')
? common.library_assets_full : {};
// 语言可用性列表(简版 library_assets 的逗号分隔语言串)
const la = (common.library_assets && typeof common.library_assets === 'object')
? common.library_assets : {};
// hash 路径值校验: "hash/filename" 形式,防脏数据
const hp = (v) => (typeof v === 'string' && /^[0-9a-f]{8,}\/.+\.(jpe?g|png)$/i.test(v)) ? v : null;
// 变体组: library_assets_full.X.image/image2x → { lang: {image, image2x} }
function variants(block) {
if (!block || typeof block !== 'object') return null;
const out = {};
let any = false;
for (const lang of ['image', 'image2x']) {
const m = block[lang];
if (!m || typeof m !== 'object') continue;
for (const l in m) {
const p = hp(m[l]);
if (!p) continue;
if (!out[l]) out[l] = {};
out[l][lang === 'image2x' ? 'image2x' : 'image'] = p;
any = true;
}
}
return any ? out : null;
}
// 语言→hash 路径映射(small_capsule / header_image 区块)
function langMap(block) {
if (!block || typeof block !== 'object') return null;
const out = {};
let any = false;
for (const l in block) {
const p = hp(block[l]);
if (p) { out[l] = p; any = true; }
}
return any ? out : null;
}
const assoc = Array.isArray(info.associations) || Array.isArray(raw.associations)
? (Array.isArray(info.associations) ? info.associations : raw.associations)
: [];
const associations = assoc
.filter(a => a && typeof a === 'object')
.map(a => ({ type: String(a.type || ''), name: String(a.name || '') }))
.filter(a => a.name);
const languages = common.languages && typeof common.languages === 'object'
? Object.keys(common.languages) : [];
return {
appid: Number(appId) || Number(raw.appid) || 0,
name: typeof common.name === 'string' ? common.name : '',
// v1.2.0: 本地化名称表(common.name_localized → {schinese: '中文名', ...}),供上游中文名补全
nameLocalized: (common.name_localized && typeof common.name_localized === 'object')
? common.name_localized : null,
type: typeof common.type === 'string' ? common.type : '',
oslist: typeof common.oslist === 'string'
? common.oslist.split(',').map(s => s.trim()).filter(Boolean) : [],
releasestate: typeof common.releasestate === 'string' ? common.releasestate : '',
assetMtime: String(common.store_asset_mtime || ''),
associations,
languages,
icon: typeof common.icon === 'string' ? common.icon : '',
clienticon: typeof common.clienticon === 'string' ? common.clienticon : '',
logoPosition: la.logo_position && typeof la.logo_position === 'object' ? la.logo_position : null,
assets: {
poster: variants(full.library_capsule),
hero: variants(full.library_hero),
heroBlur: variants(full.library_hero_blur),
logo: variants(full.library_logo),
libraryHeader: variants(full.library_header),
smallCapsule: langMap(common.small_capsule),
storeHeader: langMap(common.header_image),
},
source: 'steamui',
fetchedAt: Date.now(),
};
}
// kind → steamui 资产组
function _steamuiAssetOf(info, kind) {
const a = info && info.assets;
if (!a) return null;
if (kind === 'poster') return a.poster;
if (kind === 'hero') return a.hero;
if (kind === 'logo') return a.logo;
if (kind === 'library_header') return a.libraryHeader;
if (kind === 'capsule') return a.smallCapsule;
return a.storeHeader; // header
}
// 语言优先级: 请求语言 → english → 第一个可用
function _pickLang(variants, lang) {
if (!variants) return null;
if (lang && variants[lang]) return lang;
if (variants.english) return 'english';
const first = Object.keys(variants)[0];
return first || null;
}
// steamui 规范化数据 → 精确 hash URL(kind + lang 感知)
// 返回 '' 表示该 kind 无资产
function buildSteamuiUrl(info, kind, lang) {
if (!info) return '';
const group = _steamuiAssetOf(info, kind);
if (!group) return '';
const id = String(info.appid || '');
if (!id) return '';
// 形式判定:任一值为 string → langMap(smallCapsule/storeHeader): {lang: 'hash/file'}
// 否则 → variants(library_assets_full): {lang: {image, image2x}}
const isLangMap = Object.keys(group).some(k => typeof group[k] === 'string');
const l = _pickLang(group, lang);
if (!l) return '';
if (isLangMap) {
return STEAMUI_ASSET_CDNS[0] + id + '/' + group[l];
}
const v = group[l] || {};
const path = v.image2x || v.image;
if (!path) return '';
return STEAMUI_ASSET_CDNS[0] + id + '/' + path;
}
// 安全读 responseText(防扩展 hook XHR,同 v1.1.1)
function _safeText(r) {
let txt = '';
try { txt = r.responseText; } catch (e) { txt = ''; }
if (!txt && r.response instanceof ArrayBuffer) {
try { txt = new TextDecoder().decode(new Uint8Array(r.response)); } catch (e) { /* ignore */ }
}
return txt || '';
}
// 拉取 steamui appinfo(带三级负缓存 + 并发共享)
// opts.force: 忽略负缓存强制重试;opts.timeout: 默认 8000
function getSteamuiAssets(appId, opts) {
const id = String(appId);
opts = opts || {};
const now = Date.now();
if (!id || isNaN(Number(id))) return Promise.resolve(null);
// 内存命中
const mem = _steamuiMem.get(id);
if (mem && (now - mem.ts) < STEAMUI_TTL) return Promise.resolve(mem.data);
// 持久化命中
_steamuiLoadPersisted();
const pEnt = _steamuiPersisted[id];
if (pEnt && pEnt.data && (now - (pEnt.ts || 0)) < STEAMUI_TTL) {
_steamuiMem.set(id, pEnt);
return Promise.resolve(pEnt.data);
}
// CF 全局拦截期
if (!opts.force && now < _steamuiCfBlockedUntil) return Promise.resolve(null);
// appid 404 负缓存
if (!opts.force) {
const neg = _steamuiAppNeg.get(id);
if (neg && neg > now) return Promise.resolve(null);
if (neg) _steamuiAppNeg.delete(id);
}
// 并发共享
if (_steamuiPending.has(id)) return _steamuiPending.get(id);
const p = new Promise((resolve) => {
const gmxhr = root.__sglvCoverMockGmXhr
|| (typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : null);
const finish = (data) => {
_steamuiPending.delete(id);
resolve(data);
};
if (!gmxhr && typeof fetch === 'undefined') { finish(null); return; }
const handleText = (status, text) => {
if (status === 404) {
_steamuiAppNeg.set(id, Date.now() + STEAMUI_NEG_TTL);
finish(null);
return;
}
if (status < 200 || status >= 300) { finish(null); return; }
let raw = null;
try {
raw = parseSteamInfo(text);
} catch (e) {
if (e && e.cfBlocked) {
_steamuiCfBlockedUntil = Date.now() + STEAMUI_CF_TTL;
finish(null);
return;
}
finish(null);
return;
}
const info = raw ? normalizeSteamInfo(raw, id) : null;
if (info && (info.assets.poster || info.assets.hero || info.assets.logo
|| info.assets.smallCapsule || info.assets.storeHeader)) {
_steamuiMem.set(id, { data: info, ts: Date.now() });
_steamuiPersist(id, info);
finish(info);
} else {
// 解析成功但无可用资产(纯 DLC/工具类)→ 按 appid 负缓存,避免反复拉
_steamuiAppNeg.set(id, Date.now() + STEAMUI_NEG_TTL);
finish(null);
}
};
if (gmxhr) {
gmxhr({
method: 'GET',
url: STEAMUI_BASE + id + '/info',
headers: { 'Accept': 'application/json, text/plain, */*' },
timeout: opts.timeout || 8000,
anonymous: false,
onload: (r) => handleText(r.status, _safeText(r)),
onerror: () => finish(null),
ontimeout: () => finish(null),
});
} else {
fetch(STEAMUI_BASE + id + '/info')
.then(r => r.text().then(t => handleText(r.status, t)))
.catch(() => finish(null));
}
});
_steamuiPending.set(id, p);
return p;
}
// 同步版:内存/持久化缓存命中的 steamui 精确 URL(不发起网络请求)
function getSteamuiGoodUrl(appId, kind, lang) {
const id = String(appId);
if (!id) return '';
const mem = _steamuiMem.get(id);
if (!mem) {
_steamuiLoadPersisted();
const ent = _steamuiPersisted[id];
if (!ent || !ent.data) return '';
return buildSteamuiUrl(ent.data, kind, lang);
}
return buildSteamuiUrl(mem.data, kind, lang);
}
// ==================== 公共 API ====================
// v1.2.0: kind + lang 感知(good 缓存 → steamui 精确 URL → 语言变体链首)
function getCoverUrl(appId, kind, lang) {
if (!appId) return SVG_PLACEHOLDER;
const k = kind || 'capsule';
loadPersisted();
// 1. good 缓存(带语言键优先,无语言键兜底)
const goodLang = _goodMap.get(goodKey(k, appId, lang));
if (goodLang) return goodLang;
if (lang) {
const goodPlain = _goodMap.get(goodKey(k, appId));
if (goodPlain) return goodPlain;
}
// 2. steamui 精确 hash URL(缓存命中,免探测)
const steamuiUrl = getSteamuiGoodUrl(appId, k, lang);
if (steamuiUrl) return steamuiUrl;
// 3. 语言变体链首
return buildFullChain(appId, k, lang)[0];
}
// v1.2.0: Promise 版封面解析 — steamui 拉取(网络)优先,失败/无资产给探测链首
function resolveCover(appId, kind, lang) {
if (!appId) return Promise.resolve(SVG_PLACEHOLDER);
const k = kind || 'capsule';
const cached = getCoverUrl(appId, k, lang);
// getCoverUrl 已含 steamui 缓存;仅当未命中 good 且未命中 steamui 缓存时才发起网络拉取
loadPersisted();
const hasGood = _goodMap.has(goodKey(k, appId, lang))
|| (lang && _goodMap.has(goodKey(k, appId)));
const hasSteamuiCache = !!getSteamuiGoodUrl(appId, k, lang);
if (hasGood || hasSteamuiCache) return Promise.resolve(cached);
return getSteamuiAssets(appId).then((info) => {
const url = buildSteamuiUrl(info, k, lang);
return url || buildFullChain(appId, k, lang)[0];
}).catch(() => cached);
}
// kind 感知的 API 兜底(steamui 之后)
// capsule 优先 capsule_image,poster/hero/logo/library_header/header 优先 header_image
function fetchFromAPI(appId, kind) {
return new Promise((resolve) => {
const k = kind || 'capsule';
const apiUrl = `https://store.steampowered.com/api/appdetails?appids=${appId}&filters=basic`;
const gmxhr = root.__sglvCoverMockGmXhr || (typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : null);
const finish = (url) => resolve(url || '');
if (gmxhr) {
gmxhr({
method: 'GET', url: apiUrl, timeout: 8000,
onload: (r) => {
try {
const j = JSON.parse(_safeText(r));
const d = j[appId];
if (d && d.success && d.data) {
if (k === 'capsule') {
finish(d.data.capsule_image || d.data.header_image || '');
} else {
finish(d.data.header_image || d.data.capsule_image || '');
}
} else {
finish('');
}
} catch (e) { finish(''); }
},
onerror: () => finish(''),
ontimeout: () => finish(''),
});
} else if (typeof fetch !== 'undefined') {
fetch(apiUrl).then(r => r.json()).then(j => {
const d = j[appId];
if (d && d.success && d.data) {
if (k === 'capsule') {
finish(d.data.capsule_image || d.data.header_image || '');
} else {
finish(d.data.header_image || d.data.capsule_image || '');
}
} else {
finish('');
}
}).catch(() => finish(''));
} else {
finish('');
}
});
}
// 推进降级链:链中下一种 URL → steamui hash → API → SVG
// v1.2.0: 从 img.dataset.kind/lang 提取图片类型与语言
function loadImageWithFallback(img, onFailed) {
if (!img || !img.dataset) return;
const appId = img.dataset.appid;
if (!appId) return;
const kind = img.dataset.kind || 'capsule';
const lang = img.dataset.lang || '';
const cacheKey = goodKey(kind, appId, lang);
loadPersisted();
const good = _goodMap.get(cacheKey) || _goodMap.get(goodKey(kind, appId));
if (good && img.src !== good) { img.src = good; return; }
const fullChain = buildFullChain(appId, kind, lang);
let idx = parseInt(img.dataset.fbIdx || '0', 10);
const fallbackToPlaceholder = () => {
img.onerror = null;
img.src = SVG_PLACEHOLDER;
img.classList.add('sglv-cover-failed');
if (typeof onFailed === 'function') {
try { onFailed(img); } catch (e) { /* ignore */ }
}
};
const tryApiFallback = () => {
fetchFromAPI(appId, kind).then(url => {
if (url) {
_goodMap.set(cacheKey, url);
setPersisted(cacheKey, url);
img.onerror = () => fallbackToPlaceholder();
img.src = url;
} else {
fallbackToPlaceholder();
}
});
};
// CDN 链全失败 → steamui 精确 hash(网络拉取,一次) → API → SVG
const trySteamuiFallback = () => {
getSteamuiAssets(appId).then(info => {
const url = buildSteamuiUrl(info, kind, lang);
if (url) {
_goodMap.set(cacheKey, url);
setPersisted(cacheKey, url);
// steamui URL 也可能挂 → API 兜底
img.onerror = () => tryApiFallback();
img.src = url;
} else {
tryApiFallback();
}
}).catch(() => tryApiFallback());
};
const tryNext = () => {
while (idx < fullChain.length && img.src === fullChain[idx]) idx++;
if (idx < fullChain.length) {
img.dataset.fbIdx = String(idx + 1);
img.src = fullChain[idx++];
} else {
trySteamuiFallback();
}
};
tryNext();
}
// 绑定全局 error/load 委托(全局只调一次)
// v1.2.0: load 缓存键纳入 data-lang
function bindErrorHandler(rootEl, onFailed) {
if (!rootEl || rootEl.dataset.sglvCoverHandlerBound) return;
rootEl.dataset.sglvCoverHandlerBound = '1';
rootEl.addEventListener('error', (e) => {
const t = e.target;
if (t && t.tagName === 'IMG' && t.dataset && t.dataset.sglvCover && t.dataset.appid) {
loadImageWithFallback(t, onFailed);
}
}, true);
rootEl.addEventListener('load', (e) => {
const t = e.target;
if (t && t.tagName === 'IMG' && t.dataset && t.dataset.sglvCover && t.dataset.appid) {
if (t.src && t.src.indexOf('data:image') !== 0) {
const key = goodKey(t.dataset.kind || 'capsule', String(t.dataset.appid), t.dataset.lang || '');
_goodMap.set(key, t.src);
setPersisted(key, t.src);
}
}
}, true);
}
// ==================== 暴露 API ====================
const api = {
version: '1.2.0',
apiVersion: 2,
SVG_PLACEHOLDER,
CHAIN,
KINDS,
LANG_VARIANTS,
CDN_SHARED,
CDN_FASTLY,
CDN_AKAMAI,
CDN_CF,
getCoverUrl,
resolveCover,
bindErrorHandler,
loadImageWithFallback,
clearPersistedCache: clearPersisted,
flushPersisted,
// v1.2.0: steamui appinfo 渠道
getSteamuiAssets,
getSteamuiGoodUrl,
buildSteamuiUrl,
buildFullChain, // (appId, kind?, lang?) → 完整多CDN回退链(含语言变体前缀),供宿主/兄弟库复用
parseSteamInfo,
normalizeSteamInfo,
// 测试辅助:重置 steamui 负缓存状态
_resetSteamuiState() {
_steamuiCfBlockedUntil = 0;
_steamuiAppNeg.clear();
_steamuiPending.clear();
},
};
root.SGLVCoverFallback = api;
if (typeof unsafeWindow !== 'undefined' && unsafeWindow !== root) {
try { unsafeWindow.SGLVCoverFallback = api; } catch (e) { /* ignore */ }
}
return api;
})(typeof window !== 'undefined' ? window : globalThis);