SGLV 封面三级降级 (Library)

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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