// ==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);