// ==UserScript== // @name SGLV 封面三级降级 (Library) // @name:en SGLV Cover Fallback (Library) // @namespace https://greasyfork.org/scripts/588485 // @version 1.1.1 // @description Steam 游戏封面三级降级库:内存缓存(跨视图共享)→ 持久化缓存(跨会话,GM_setValue,LRU 500)→ CDN fallback 链(shared.akamai → shared.fastly → cdn.akamai → cloudflare 四 CDN)→ Steam appdetails API 兜底 → SVG placeholder。支持 poster/capsule/header 三种图片类型。v1.1.1 修复浏览器扩展 hook XHR 导致 responseText 不可访问、新增 shared.fastly CDN 源。供 SGLV 系列脚本 @require 引用,复用降级逻辑。不可单独使用。 // @description:en SGLV Cover Fallback Library v1.1.1 — Steam game cover three-tier fallback chain (in-memory cache → persistent GM_setValue cache LRU 500 → CDN chain shared.akamai + shared.fastly + cdn.akamai + cloudflare → appdetails API → SVG placeholder). Supports poster/capsule/header image kinds. **v1.1.1** Fix responseText inaccessible when browser extension hooks XHR; add shared.fastly CDN source. For SGLV host scripts @require only. // @author SmallFork // @license MIT // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @noframes // ==/UserScript== /* * SGLV Cover Fallback Library v1.1.0 * * v1.1.0 变更: * - 新增 kind 参数支持(poster/capsule/header),缓存键按 kind 隔离 * - CDN 链扩展为三源:shared.akamai → 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" 指定图片类型 * * 设计目标:从 SGLV 业务脚本中抽出"游戏封面降级"基础设施, * 供所有 SGLV 系列脚本通过 @require 共享,避免每个脚本都重写一遍: * - 多 CDN fallback 链(shared.akamai → cdn.akamai → cloudflare) * - 跨视图/跨会话的 good URL 缓存(按 kind 隔离) * - 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" * 3) 宿主在根容器上调用 SGLVCoverFallback.bindErrorHandler(rootEl) * 4) 初始 src 调用 SGLVCoverFallback.getCoverUrl(appId, kind) 获取(已自动用缓存) * 5) 错误时自动走降级链;成功时自动写缓存(同会话 + 跨会话) * * 桥接契约(测试/宿主需要注入): * window.__sglvCoverMockGmXhr = function(method, url, opts, cb) 可选,默认走 GM_xmlhttpRequest * * API: * version: '1.1.0' * apiVersion: 2 * SVG_PLACEHOLDER: data:image/svg+xml 兜底图 * CHAIN: 默认 CDN fallback 链(横图 7:3 列表/封面视图,向后兼容) * getCoverUrl(appId, kind?): 获取初始 URL(优先缓存,否则链首) * bindErrorHandler(rootEl, onFailed?): 在 rootEl 上绑定 error/load 委托(全局只调一次) * loadImageWithFallback(img, onFailed?): 手动触发单图降级(自动调用,通常无需手动) * clearPersistedCache(): 清空持久化缓存(用于设置面板"清缓存"按钮) * * 宿主需 @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/'; // 横图 7:3 链(列表/封面视图):先用 shared.akamai(新游戏在此有图),再 shared.fastly,再 cdn.akamai,再 cloudflare // 设计:小尺寸 capsule → 大尺寸 capsule → header(全图,最后兜底) // v1.0.0 向后兼容:CHAIN 导出默认横图链 const CHAIN = ['capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg', 'header.jpg']; // v1.1.0: poster 竖版图链(library_600x900 系列 + header 兜底) const POSTER_FILES = ['library_600x900_2x.jpg', 'library_600x900.jpg']; // v1.1.0: capsule 横幅链 const CAPSULE_FILES = ['capsule_467x181.jpg', 'capsule_231x87.jpg']; // v1.1.0: header 链 const HEADER_FILES = ['header.jpg']; // 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"; // ==================== 内存缓存(同会话跨视图共享) ==================== // v1.1.0: 缓存键按 kind 隔离 → "kind:appid" → url const _goodMap = new Map(); // ==================== 持久化缓存(跨会话) ==================== // v1.1.0: 持久化缓存也按 kind 隔离 → { "kind:appid": {url, ts} } 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); } // v1.1.0: 迁移旧版缓存(sglv_cover_url_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 && 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); // LRU 淘汰:超过上限按 ts 升序淘汰最早的 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; // 50ms 节流刷盘(避免高频 load 事件触发风暴) 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 */ } } } function clearPersisted() { _persisted = {}; _goodMap.clear(); try { GM_setValue(PERSISTED_KEY, {}); } catch (e) { /* ignore */ } // v1.1.0: 同时清除旧版缓存 try { GM_setValue('sglv_cover_url_v1', {}); } catch (e) { /* ignore */ } } // ==================== URL 拼接 ==================== // v1.1.0: kind 感知的全链构建 // poster: shared.akamai library_600x900 → cdn.akamai library_600x900 → cdn.cloudflare library_600x900 → header 三 CDN // capsule: shared.akamai capsule → cdn.akamai capsule → cdn.cloudflare capsule → header 三 CDN // header: shared.akamai header → cdn.akamai header → cdn.cloudflare header function buildFullChain(appId, kind) { const id = String(appId); const k = kind || 'capsule'; // 默认横图(向后兼容 v1.0.0 行为) const full = []; let files; if (k === 'poster') { files = POSTER_FILES; } else if (k === 'capsule') { files = CAPSULE_FILES; } else { files = HEADER_FILES; } // v1.1.1: 四 CDN 交叉:每个 file 先 shared.akamai,再 shared.fastly,再 cdn.akamai,再 cdn.cloudflare 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 链末追加 header.jpg 四 CDN 兜底(几乎所有游戏都有 header.jpg) if (k !== 'header') { for (const cdn of [CDN_SHARED, CDN_FASTLY, CDN_AKAMAI, CDN_CF]) { full.push(cdn + id + '/header.jpg'); } } return full; } // ==================== 公共 API ==================== // v1.1.0: kind 感知 function getCoverUrl(appId, kind) { if (!appId) return SVG_PLACEHOLDER; const id = String(appId); const k = kind || 'capsule'; const cacheKey = k + ':' + id; loadPersisted(); const good = _goodMap.get(cacheKey); if (good) return good; // 链首 const chain = buildFullChain(appId, k); return chain[0]; } // v1.1.0: kind 感知的 API 兜底 // capsule 优先 capsule_image,poster/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 { // v1.1.1: 安全读取 responseText,防止浏览器扩展 hook XHR 导致 InvalidStateError let _txt; try { _txt = r.responseText; } catch (_) { _txt = ''; } if (!_txt && r.response instanceof ArrayBuffer) { try { _txt = new TextDecoder().decode(new Uint8Array(r.response)); } catch (_) {} } const j = JSON.parse(_txt); const d = j[appId]; if (d && d.success && d.data) { // v1.1.0: 按 kind 选择最佳图片 if (k === 'capsule') { finish(d.data.capsule_image || d.data.header_image || ''); } else { // poster / header 都优先 header_image(460x215,比 capsule 更适合竖版裁切) 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 → API → SVG // v1.1.0: 从 img.dataset.kind 提取图片类型 // 可选参数 onFailed(img):所有降级都失败后的回调(宿主可显示自定义 fallback UI) function loadImageWithFallback(img, onFailed) { if (!img || !img.dataset) return; const appId = img.dataset.appid; if (!appId) return; const id = String(appId); const kind = img.dataset.kind || 'capsule'; // v1.1.0: 从 data-kind 提取,默认 capsule const cacheKey = kind + ':' + id; // 命中 good 缓存:直接跳到 loadPersisted(); if (_goodMap.has(cacheKey)) { const good = _goodMap.get(cacheKey); if (good && img.src !== good) { img.src = good; return; } } const fullChain = buildFullChain(appId, kind); 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 tryNext = () => { // 跳过与当前 src 相同的 URL(初始加载已尝试过,避免重复触发) while (idx < fullChain.length && img.src === fullChain[idx]) idx++; if (idx < fullChain.length) { img.dataset.fbIdx = String(idx + 1); img.src = fullChain[idx++]; } else { // CDN 链全失败 → API 兜底 fetchFromAPI(appId, kind).then(url => { if (url) { _goodMap.set(cacheKey, url); setPersisted(cacheKey, url); // API 给的 URL 也可能挂,挂一层 onerror 兜底 img.onerror = () => fallbackToPlaceholder(); img.src = url; } else { fallbackToPlaceholder(); } }); } }; tryNext(); } // 绑定全局 error/load 委托(全局只调一次) // 触发条件:img 标签同时拥有 data-sglv-cover="1" 和 data-appid // v1.1.0: img 可通过 data-kind 指定图片类型 // 可选参数 onFailed(img):所有降级都失败后的回调(宿主可显示自定义 fallback UI) function bindErrorHandler(rootEl, onFailed) { if (!rootEl || rootEl.dataset.sglvCoverHandlerBound) return; rootEl.dataset.sglvCoverHandlerBound = '1'; // error:触发降级链 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); // load:成功 URL 写入缓存(同会话 + 跨会话) 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 kind = t.dataset.kind || 'capsule'; const cacheKey = kind + ':' + String(t.dataset.appid); _goodMap.set(cacheKey, t.src); setPersisted(cacheKey, t.src); } } }, true); } // ==================== 暴露 API ==================== const api = { version: '1.1.1', apiVersion: 2, SVG_PLACEHOLDER, CHAIN, // 向后兼容:默认横图链 CDN_SHARED, CDN_FASTLY, CDN_AKAMAI, CDN_CF, getCoverUrl, bindErrorHandler, loadImageWithFallback, clearPersistedCache: clearPersisted, flushPersisted, }; // 暴露到全局(GM 脚本风格) root.SGLVCoverFallback = api; // 同时挂 unsafeWindow(某些 sandbox 环境) if (typeof unsafeWindow !== 'undefined' && unsafeWindow !== root) { try { unsafeWindow.SGLVCoverFallback = api; } catch (e) { /* ignore */ } } return api; })(typeof window !== 'undefined' ? window : globalThis);