// ==UserScript== // @name SGLV 封面三级降级 (Library) // @name:en SGLV Cover Fallback (Library) // @namespace https://greasyfork.org/scripts/588485 // @version 2.0.2 // @description Steam 游戏封面多级降级库(v2.0.0 重写 — 性能与覆盖率重设计): // ① 链首 cdn.cloudflare.steamstatic.com(Steam 老游戏 2018 前最完整,实测对 RE1 Remake/RE2/CS 等命中 100%) // ② 链长从 20 砍到 7-8,schinese 变体放链后部(避免老游戏先吃 4-5 个 404) // ③ JS 驱动加载 chainLoad(img, ...) — addEventListener('error') 单 listener,链耗尽异步走 Steam Store API // ④ 内存缓存 + GM 持久化(跨会话 LRU 500) + 负缓存(双 TTL: 确认无资产 24h / 网络抖动快速自愈) // ⑤ 跨视图共享 good URL,匹配方向(竖版/横版)门控防错位 // ⑥ 兼容 sglv-cover-fallback v1.x API: getCoverUrl / resolveCover / bindErrorHandler // ⑦ 移除 v1.x 的 steamui.com 渠道(被 Cloudflare managed challenge 拦截,在受限网络下永久失效) // 供 SGLV 系列脚本 @require 引用,复用降级逻辑。不可单独使用。 // @description:en SGLV Cover Fallback Library v2.0.0 — Perf/cover rewrite. Cloudflare chain head (best for legacy games), 7-8 URL chain, schinese variants at tail, JS-driven chainLoad(img) for stable error handling, in-memory + GM persistent cache (LRU 500), dual-TTL negative cache, drop steamui.com channel (CF-blocked). Backward-compatible API. // @author SmallFork // @license MIT // @match *://*/* // @connect store.steampowered.com // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @noframes // ==/UserScript== /* * SGLV Cover Fallback Library v2.0.2 * * v2.0.2 修复(封面"越改越坏"根因): * - chainLoad 不再混用 img.onerror + addEventListener('error')(同一次失败双推进, * 一次 error 跳过 2 个 URL 直冲 placeholder);改为单一状态机 * - 支持 skipUrl:跳过调用方刚失败的 src(旧实现重设同一 URL → 立刻再失败) * - 加载成功一律 recordGood() 写缓存(旧实现要求 onLoaded,宿主全都没传 → 缓存恒空) * - recordGood 只接受 http(s),data:/blob: 占位图绝不入缓存 * - 成功/放弃时复位 _sglvChainLoading(旧实现一次性锁永不复位,视图切换后彻底失效) * - getCoverUrl 先查 lang 键再回落无 lang 键(旧实现写入无 lang、读取只有 lang → 中文界面永不命中) * * v2.0.0 重写(性能与覆盖率重设计): * - 链首 cdn.cloudflare.steamstatic.com(Steam 老游戏命中率最高,2018 前所有发售游戏都覆盖) * - 链长度从 20 砍到 7-8(去掉冗余的 4 CDN × schinese 变体置顶;实测 schinese 变体在老游戏几乎全 404) * - schinese 变体放链尾部(仅在所有基础 URL 失败后才尝试,节省 4-5 个 404) * - 新增 chainLoad(img, appid, kind, opts) — JS 驱动链式加载(参考 steam-friend-manager 1.5.3 dashChainLoad) * img 创建时无 src(避免浏览器主动加载链首 404),addEventListener('error') 单一 listener, * 链耗尽后异步走 Steam Store API(不阻塞当前链) * - 移除 v1.x 的 steamui.com 渠道(被 Cloudflare managed challenge 拦截,受限网络下 _steamuiCfBlockedUntil * 永久触发,getSteamuiAssets 永远返回 null,整个 hash URL 直通渠道失效——回退到传统 CDN 链 + API 兜底) * - 保留 good URL 缓存 + GM 持久化(LRU 500)+ 负缓存(双 TTL:确认无资产 24h / 网络抖动快速自愈) * - 兼容 v1.x API:getCoverUrl(appid, kind, lang) / resolveCover(appid, kind, lang) / * bindErrorHandler(rootEl, onFailed) / loadImageWithFallback(img, onFailed) * * 设计目标:从 sglv-libs-core 抽出"封面降级 + 多 CDN 探测"基础设施, * 供所有 SGLV 系列脚本 @require 共享,避免每个脚本都重写一遍: * - cdn.cloudflare 链(老游戏最稳)→ store_item_assets 链(新游戏)→ header.jpg 兜底 * - 跨视图/跨会话的 good URL 缓存(按 kind 隔离) * - appdetails API 兜底(kind 感知:capsule 优先 capsule_image, 其余 header_image) * - SVG placeholder * * 使用方式: * 1) 宿主 @require 此 lib * 2) img 标签加 data-sglv-cover="1" + data-appid="123" + data-kind="capsule"(kind 默认 capsule) * 3) 初始 src 推荐用 SGLVCoverFallback.getCoverUrl(appId, kind) 同步取(命中缓存或链首) * 4) 在根容器上调用 SGLVCoverFallback.bindErrorHandler(rootEl) * 5) 错误时自动走 chainLoad(JS 接管,加 addEventListener('error') 单一 listener) * 6) 成功时自动写缓存(同会话 + 跨会话 GM) * 7) 链耗尽后异步走 Steam Store API(不阻塞当前链) * * 桥接契约(测试/宿主需要注入): * window.__sglvCoverMockGmXhr = function(method, url, opts, cb) 可选,默认走 GM_xmlhttpRequest * * API: * version: '2.0.0' * apiVersion: 3 * SVG_PLACEHOLDER: data:image/svg+xml 兜底图 * CHAIN: 默认 CDN fallback 链(横图 7:3,向后兼容) * KINDS: 支持的图片类型列表 * LANG_VARIANTS: 各 kind 的语言变体文件名模板(链尾部,老游戏通常 miss) * getCoverUrl(appId, kind?, lang?): 获取初始 URL(good 缓存→链首) * resolveCover(appId, kind?, lang?): Promise 版(同步 good 缓存→链首;异步仅在 Steam API 调用时使用) * chainLoad(img, appid, kind?, opts?): JS 驱动的链式加载(主要入口 — 推荐) * bindErrorHandler(rootEl, onFailed?): 在 rootEl 上绑定 error/load 委托(全局只调一次) * loadImageWithFallback(img, onFailed?): 兼容 v1.x 手动触发单图降级 * getSteamuiAssets: (v1.x 兼容, v2.0 返回 null, 避免误调) * getSteamuiGoodUrl: (v1.x 兼容, v2.0 返回 '') * * 宿主需 @grant GM_xmlhttpRequest, GM_getValue, GM_setValue。 */ (function (root) { 'use strict'; // ==================== 常量 ==================== const VERSION = '2.0.2'; const API_VERSION = 3; // CDN 前缀(按"老游戏命中率"排序,链首=命中率最高) // v2.0.0: cdn.cloudflare 排第一 — Steam 老游戏(2018 前)基本都在这 // shared.akamai store_item_assets 排第二 — 新游戏(2018+ / 哈希子目录) // cdn.akamai 排第三 — 部分老 CDN 资源 // shared.fastly store_item_assets 排第四 — 备用哈希子目录 const CDN_CF = 'https://cdn.cloudflare.steamstatic.com/steam/apps/'; const CDN_SHARED_AK = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/'; const CDN_AK = 'https://cdn.akamai.steamstatic.com/steam/apps/'; const CDN_SHARED_FASTLY = 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/'; // kind → 基础文件列表(无语言变体;变体放链尾部,避免老游戏先吃 4-5 个 404) const POSTER_FILES = ['library_600x900_2x.jpg', 'library_600x900.jpg']; const CAPSULE_FILES = ['capsule_467x181.jpg', 'capsule_231x87.jpg']; const HEADER_FILES = ['header.jpg']; 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']; // 语言变体文件名模板(仅在基础链全部失败时追加,避免老游戏先吃 schinese 404) // 实测 Steam CDN 路径直接支持语言后缀: capsule_467x181_schinese.jpg / logo_schinese.png 等 // 但 2018 前发售的老游戏几乎都没这些变体 → 放链尾节省 4-5 个 404 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; } // ==================== 持久化缓存(跨会话,GM_setValue) ==================== // 与 v1.x 兼容: 键名 'sglv_cover_url_v2', 同样 LRU 500 const PERSISTED_KEY = 'sglv_cover_url_v2'; const PERSISTED_MAX = 500; // v2.0.1: good URL 跨会话缓存 TTL = 6 个月。 // 封面资产一旦验证可用极少失效,半年内不再重新走整条多 CDN 链探测(省请求 + 免闪烁)。 // 同时保留自愈:游戏换封面后旧 URL 会在 6 个月后自动失效并重新探测, // 无需用户手动清缓存。过期条目在 loadPersisted 时剪除,防止陈旧 URL 长期占 LRU 配额。 const COVER_GOOD_TTL = 6 * 30 * 24 * 3600 * 1000; // 180 天 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 */ } // 剪除超过 TTL 的条目(只装内存 goodMap 的不算过期——同会话内本来就短于 TTL) const now = Date.now(); let pruned = false; for (const key in _persisted) { const ent = _persisted[key]; // v2.0.2: 只有 http(s) 才算有效 good URL —— // 历史版本可能把 data:/blob: 占位图写进过缓存,读到也必须丢弃自愈 if (!ent || !isHttpUrl(ent.url) || (now - (ent.ts || 0)) > COVER_GOOD_TTL) { delete _persisted[key]; pruned = true; continue; } _goodMap.set(key, ent.url); } if (pruned) { try { GM_setValue(PERSISTED_KEY, _persisted); } catch (e) { /* ignore */ } } // v1.x 兼容: 迁移 v1 旧格式 (appid 直接作键) 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 && isHttpUrl(ent.url) && !_persisted['capsule:' + id] && (now - (ent.ts || 0)) <= COVER_GOOD_TTL) { _persisted['capsule:' + id] = { url: ent.url, ts: ent.ts || 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) { // LRU: 删最旧的 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) { /* 配额/序列化失败静默 */ } }, 50); } function flushPersisted() { if (_writeTimer) { clearTimeout(_writeTimer); _writeTimer = null; } if (_writeDirty && _persisted) { _writeDirty = false; try { GM_setValue(PERSISTED_KEY, _persisted); } catch (e) { /* ignore */ } } } function clearPersisted() { _persisted = {}; _goodMap.clear(); try { GM_setValue(PERSISTED_KEY, {}); } catch (e) { /* ignore */ } try { GM_setValue('sglv_cover_url_v1', {}); } catch (e) { /* ignore */ } } // ==================== 负缓存(API 调用失败避免重试风暴) ==================== // 双 TTL: 确认无资产(API 成功但 data 空)24h;网络/限频失败 10min 快速自愈 const COVER_NEG_TTL = 24 * 60 * 60 * 1000; const COVER_NETFAIL_TTL = 10 * 60 * 1000; const _coverNeg = new Map(); // appid:kind → { t, n } (n=1 网络失败,n=0 确认无资产) function _negHit(rec) { if (!rec) return false; const now = Date.now(); return (now - rec.t) < (rec.n ? COVER_NETFAIL_TTL : COVER_NEG_TTL); } // ==================== 链构建 ==================== // v2.0.0: 链首 cdn.cloudflare(老游戏命中率最高),基础文件, schinese 变体放链尾 function buildFiles(kind, lang) { const k = (kind && KINDS.indexOf(kind) >= 0) ? 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 = base.slice(); // schinese 变体放链尾 — 老游戏(2018 前)基本没这些,放链首是 4-5 个浪费的 404 if (lang) { const tpl = LANG_VARIANTS[k]; if (tpl) for (const t of tpl) files.push(t.replace('{v}', lang)); } return files; } // 链构造: cdn.cloudflare(1) → store_item_assets(2,3,4) → header.jpg 兜底 // 总数 7-8 URL(从 v1.x 的 20 砍掉一半多) function buildFullChain(appId, kind, lang) { const id = String(appId); const files = buildFiles(kind, lang); const full = []; for (const f of files) { // 1. cdn.cloudflare 链首(老游戏最稳) full.push(CDN_CF + id + '/' + f); // 2. shared.akamai store_item_assets(新游戏哈希子目录) full.push(CDN_SHARED_AK + id + '/' + f); // 3. cdn.akamai(部分老 CDN 资源) full.push(CDN_AK + id + '/' + f); // 4. shared.fastly store_item_assets(备用) full.push(CDN_SHARED_FASTLY + id + '/' + f); } // header.jpg 兜底(几乎所有游戏都有,放链尾) if (kind !== 'header' && kind !== 'library_header') { full.push(CDN_CF + id + '/header.jpg'); full.push(CDN_SHARED_AK + id + '/header.jpg'); } return full; } // ==================== 链去重 ==================== // 同一 URL 不重复探测(steamui hash URL 可能跟基础链重复) function _dedupeChain(chain) { if (!Array.isArray(chain)) return chain; const seen = new Set(); const out = []; for (const u of chain) { if (u && !seen.has(u)) { seen.add(u); out.push(u); } } return out; } // ==================== good URL 写入(统一入口) ==================== // v2.0.2: 宿主一直调用 recordGood() 但 v2.0.0 没有导出该 API — // 结果 whole 会话内没有任何 URL 被写进 good 缓存,getCoverUrl 永远只返回链首, // 每张封面每次渲染都要从链首重新试错(这也是"越改越慢/越改越坏"的根源之一)。 function isHttpUrl(u) { return typeof u === 'string' && /^https?:\/\//i.test(u); } // 取 img 当前 src(容错: 测试/桩对象可能没有 getAttribute) function _imgSrc(img) { try { if (img && typeof img.getAttribute === 'function') return img.getAttribute('src') || ''; return (img && img.src) || ''; } catch (e) { return ''; } } // 记录"已验证可加载"的 URL:同会话 Map + 跨会话 GM 持久化 // 只接受 http(s) —— data:/blob: 占位图绝不入缓存 function recordGood(appId, url, kind, lang) { if (!appId || !isHttpUrl(url)) return false; const k = (kind && KINDS.indexOf(kind) >= 0) ? kind : 'capsule'; const id = String(appId); _goodMap.set(goodKey(k, id, ''), url); setPersisted(goodKey(k, id, ''), url); if (lang) { _goodMap.set(goodKey(k, id, lang), url); setPersisted(goodKey(k, id, lang), url); } return true; } // ==================== getCoverUrl / resolveCover (兼容 v1.x API) ==================== function getCoverUrl(appId, kind, lang) { if (!appId) return ''; const k = kind || 'capsule'; loadPersisted(); // 1) 同步 good 缓存(同会话 + 跨会话) // v2.0.2: 先查带 lang 的键,再回落到无 lang 键 —— 写入统一走无 lang 键, // 旧实现只查 lang 键导致中文界面下跨会话缓存永远命中不了 const cached = _goodMap.get(goodKey(k, appId, lang)) || _goodMap.get(goodKey(k, appId, '')); if (isHttpUrl(cached)) return cached; // 2) 没缓存 → 链首(基础文件,无 schinese 变体 — 优先快速命中老游戏) const chain = buildFullChain(appId, k, ''); return chain[0] || ''; } // v1.x 兼容: Promise 版封面解析 // v2.0: 同步 good 缓存命中后直接 resolve;未命中走链首(异步 Steam API 仅在显式调用时执行) function resolveCover(appId, kind, lang) { if (!appId) return Promise.resolve(SVG_PLACEHOLDER); const u = getCoverUrl(appId, kind, lang); return Promise.resolve(u || SVG_PLACEHOLDER); } // ==================== Steam Store API 兜底 ==================== // kind 感知: capsule 优先 capsule_image, poster/header/hero/logo/library_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(''); } }); } function _safeText(r) { try { if (!r) return ''; if (typeof r.responseText === 'string') return r.responseText; if (typeof r.response === 'string') return r.response; return ''; } catch (e) { return ''; } } // ==================== chainLoad: JS 驱动的链式加载(v2.0.2 重写)==================== // v2.0.2 修复的关键缺陷: // ① 旧实现同时挂 img.onerror 与 addEventListener('error') —— 同一次失败两套 // handler 各推进一次链(tryFinal 被调两次),一次 error 跳过 2 个 URL, // 直接从链首冲到 placeholder。现在只用一对 addEventListener,单一状态机。 // ② 旧实现忽略调用方当前 src —— bindErrorHandler 明明算了 cur 却没用, // chain[0] 往往就是刚 404 的那个 URL,重设同一 URL 立刻再次失败。 // 现在支持 opts.skipUrl,并且 step() 内部也跳过 curSrc。 // ③ 旧实现只有调用方传 onLoaded 才写 good 缓存,而宿主全部没传 — // 缓存永远是空的。现在加载成功即内部 recordGood()。 // ④ 旧实现的 _sglvChainLoading 一次性锁从不复位 —— 同一 img 节点再次赋值 // src(视图切换复用节点)后 error 全被忽略,永远停在坏图上。现在成功/放弃时复位。 // ⑤ 旧实现链耗尽后还会再试一遍 header.jpg 两连(finalChain),而 buildFullChain // 已经带了 header 兜底 —— 重复请求。现在去重后统一由 chain 覆盖。 // - img 创建时可无 src(不触发浏览器主动加载) // - 链耗尽后异步走 Steam API(不阻塞当前链) // - 失败/超时/API 空: 通知 onGiveUp(宿主替换占位)或设 SVG placeholder function chainLoad(img, appid, kind, opts) { if (!img || !appid) return; const k = (kind && KINDS.indexOf(kind) >= 0) ? kind : 'capsule'; const o = opts || {}; const lang = o.lang || root.__sglvCoverLang || ''; // 1) 同步 good 缓存命中 — 放链首(成功概率最高,避免链首 404) // 只接受 http(s):占位图/脏缓存一律忽略,继续走真实 CDN 链 loadPersisted(); const goodRaw = _goodMap.get(goodKey(k, appid, lang)) || _goodMap.get(goodKey(k, appid, '')); const good = isHttpUrl(goodRaw) ? goodRaw : ''; // 2) 完整链(已含 header.jpg 兜底) const chain = _dedupeChain((good ? [good] : []).concat(buildFullChain(appid, k, lang))); // 3) 从"当前已失败的 src"之后继续,不重复请求同一个 404 const curSrc = (o.skipUrl != null) ? String(o.skipUrl) : _imgSrc(img); let i = 0; const pos = chain.indexOf(curSrc); if (pos >= 0 && pos + 1 < chain.length) i = pos + 1; let done = false; let apiStarted = false; function detach() { img.removeEventListener('error', onError); img.removeEventListener('load', onLoad); img._sglvChainLoading = 0; // 释放一次性锁,允许后续重新接管 } function succeed() { if (done) return; done = true; detach(); const u = img.currentSrc || img.src || ''; recordGood(appid, u, k, lang); if (o.onLoaded) { try { o.onLoaded(u); } catch (e) { /* ignore */ } } } function fail() { if (done) return; done = true; detach(); if (o.onGiveUp) { try { o.onGiveUp(); } catch (e) { /* ignore */ } } // 宿主已替换节点时不再改 src(onGiveUp 内部 replaceChild) if (!o.noPlaceholder && img.isConnected && img.parentElement) img.src = SVG_PLACEHOLDER; } function step() { if (done) return; while (i < chain.length) { const u = chain[i++]; if (!u || u === curSrc) continue; // 刚失败的 URL 不再请求 img.src = u; return; } // 4) 链耗尽 → Steam API 兜底(仅一次) if (!apiStarted) { apiStarted = true; Promise.resolve(fetchFromAPI(appid, k)).then(function (url) { if (done) return; if (isHttpUrl(url) && chain.indexOf(url) < 0) { img.src = url; return; } fail(); }, fail); return; } fail(); } function onError() { if (!done) step(); } function onLoad() { if (done) return; // naturalWidth <= 1 视为占位/空图,不写缓存也不算成功 if (!img.naturalWidth || img.naturalWidth < 2) return; succeed(); } img.addEventListener('load', onLoad); img.addEventListener('error', onError); step(); } // ==================== bindErrorHandler (兼容 v1.x API) ==================== // 在 rootEl 上绑定 error/load 委托 — 任何 data-sglv-cover="1" 的 img 出错时自动走 chainLoad // 全局只调一次(bindErrorHandler 内部守卫) 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') return; if (t.dataset && t.dataset.sglvCover !== '1') return; if (t._sglvChainLoading) return; const appid = t.dataset.appid; if (!appid) return; t._sglvChainLoading = 1; const kind = t.dataset.kind || 'capsule'; // 记录当前失败 src,从 chain 中跳过(避免对同一个 404 重复发请求) const cur = _imgSrc(t); chainLoad(t, appid, kind, { skipUrl: cur, onGiveUp: () => { if (onFailed) try { onFailed(t); } catch (e) {} }, }); }, true); // capture: 捕获内嵌 img 的 error } // ==================== loadImageWithFallback (兼容 v1.x API) ==================== function loadImageWithFallback(img, onFailed) { if (!img) return; const appid = img.dataset && img.dataset.appid; if (!appid) return; const kind = (img.dataset && img.dataset.kind) || 'capsule'; if (img._sglvChainLoading) return; img._sglvChainLoading = 1; chainLoad(img, appid, kind, { skipUrl: _imgSrc(img), onGiveUp: () => { if (onFailed) try { onFailed(img); } catch (e) {} }, }); } // ==================== v1.x 兼容: steamui 接口(全部返回空)==================== // v2.0 移除 steamui.com 渠道(被 CF managed challenge 拦截,失效) // 保留接口名(返回 null/'')避免宿主调用炸 function getSteamuiAssets(appId, opts) { return Promise.resolve(null); } function getSteamuiGoodUrl(appId, kind, lang) { return ''; } function _resetSteamuiState() { /* no-op */ } // ==================== 公开 API ==================== const api = { // 元信息 version: VERSION, apiVersion: API_VERSION, // 常量 SVG_PLACEHOLDER, CHAIN: ['capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg', 'header.jpg'], KINDS, LANG_VARIANTS, // 链构建 buildFiles, buildFullChain, _dedupeChain, // 同步获取 URL getCoverUrl, resolveCover, // JS 驱动链式加载(主要入口) chainLoad, // 委托绑定 bindErrorHandler, loadImageWithFallback, // 缓存管理 recordGood, // v2.0.2: 宿主成功回调统一写 good 缓存(仅接受 http(s)) flushPersisted, clearPersisted, // v1.x 兼容(steamui 渠道已移除,接口保留) getSteamuiAssets, getSteamuiGoodUrl, _resetSteamuiState, // 内部 API(供宿主 monky-patch / 调试) _goodMap, loadPersisted, setPersisted, isHttpUrl, _safeText, fetchFromAPI, }; root.SGLVCoverFallback = api; if (typeof unsafeWindow !== 'undefined' && unsafeWindow !== root) { try { unsafeWindow.SGLVCoverFallback = api; } catch (e) { /* ignore */ } } return api; })(typeof window !== 'undefined' ? window : globalThis);