// ==UserScript== // @name SGLV 云存档模块 (Library) // @name:en SGLV Cloud Save (Library) // @namespace https://greasyfork.org/scripts/588485 // @version 1.3.0 // @description Steam 游戏库查看器 - 云存档模块库(全局云存档列表抓取 / HTML解析 / 增量diff / 单App存档文件列表)。供主脚本 @require 引用,不可单独使用。 // @description:en Steam Game Library Viewer - Cloud Save module library (global cloud save list scrape / HTML parse / incremental diff / per-app file list). For host script @require only. // @author SmallRob // @license MIT // @noframes // ==/UserScript== /* * SGLV Cloud Save Library v1.0.0 * * 桥接契约(Host / ctx 需提供): * apiVersion: 1 * GM_xmlhttpRequest(宿主已 @grant) * isZh: boolean * showToast(msg) * cacheGet(key), cacheSet(key, val, ttl) * cacheTTL: { cloudSave: number } * storage: { getCloudSaveData(), setCloudSaveData(v) } * * 暴露 API: * init(ctx) -> boolean 初始化,注入宿主上下文 * getApiVersion() -> number * fetchGlobalCloudSaves(force) -> Promise<{games, source, lastUpdate, diff}> * 抓取全局云存档页面,解析游戏列表,与 IDB 缓存做增量 diff * fetchAppCloudSaveFiles(appid) -> Promise<{files, totalFiles}> * 抓取单 App 云存档文件列表(懒加载) * getCachedCloudSaves() -> {games, lastUpdate, source} | null * 从内存缓存读取(init 时已从 IDB 加载) * normalizeSize(text) -> number * 将 "1.2 MB" / "345 KB" 等文本转为字节数 * formatSize(bytes) -> string * 将字节数格式化为人类可读字符串 * * 宿主需 @grant GM_xmlhttpRequest, * 并 @connect store.steampowered.com(云存档页面)。 */ window.SGLVCloudSave = (function () { 'use strict'; // ---- 桥接宿主(由 init(ctx) 注入) ---- let Host = null; const apiVersion = 1; // 内存缓存 let _memCache = null; // {games: [], lastUpdate: 0, source: ''} // ==================== 工具函数 ==================== /** * 将 Steam 页面显示的大小文本转为字节数 * 支持: "345 bytes", "1.2 KB", "10.5 MB", "2 GB" */ function normalizeSize(text) { if (!text) return 0; const t = String(text).trim(); const m = t.match(/([\d.]+)\s*(bytes?|KB|MB|GB|TB)/i); if (!m) { // 纯数字也尝试解析 const n = parseFloat(t); return isNaN(n) ? 0 : n; } const val = parseFloat(m[1]) || 0; const unit = m[2].toLowerCase(); if (unit.startsWith('byte')) return Math.round(val); if (unit === 'kb') return Math.round(val * 1024); if (unit === 'mb') return Math.round(val * 1024 * 1024); if (unit === 'gb') return Math.round(val * 1024 * 1024 * 1024); if (unit === 'tb') return Math.round(val * 1024 * 1024 * 1024 * 1024); return 0; } /** * 将字节数格式化为人类可读字符串 */ function formatSize(bytes) { if (!bytes || bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let val = bytes; while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } /** * 从 HTML 文本中提取数字(如文件数 "42 files" -> 42) */ function parseFileCount(text) { if (!text) return 0; const m = String(text).match(/(\d+)/); return m ? parseInt(m[1], 10) : 0; } /** * 从游戏名称链接中提取 appid */ function extractAppId(href) { if (!href) return 0; const m = String(href).match(/appid=(\d+)/) || String(href).match(/\/app\/(\d+)/); return m ? parseInt(m[1], 10) : 0; } // ==================== 网络请求 ==================== /** * GM_xmlhttpRequest Promise 封装(text 模式) */ function gmFetchText(url, opts = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ url, method: opts.method || 'GET', responseType: 'text', timeout: opts.timeout || 30000, headers: opts.headers || { 'Accept': 'text/html', 'Accept-Language': 'en-US,en;q=0.9' }, onload: (xhr) => { if (xhr.status === 200) { resolve(xhr.responseText); } else { reject(new Error(`HTTP ${xhr.status}: ${url}`)); } }, onerror: () => reject(new Error(`Network error: ${url}`)), ontimeout: () => reject(new Error(`Timeout: ${url}`)), }); }); } /** * 检测响应是否为 Steam 登录页(未登录被重定向) */ function isLoginPage(html) { if (!html) return true; // 登录页特征:有 username 输入框 且 无 #main_content return html.includes('input name="username"') && !html.includes('id="main_content"'); } // ==================== 全局云存档页面解析 ==================== /** * 抓取全局云存档页面,解析所有有云存档的游戏列表 * * 页面结构(store.steampowered.com/account/remotestorage): * 每个游戏一个条目,包含游戏名称、各平台文件数、总大小、查看/下载链接 * * 防御性解析:尝试多种 DOM 选择器,兼容页面结构变化 * * @returns {Promise>} */ async function scrapeGlobalCloudSavePage() { const url = 'https://store.steampowered.com/account/remotestorage'; const html = await gmFetchText(url); if (isLoginPage(html)) { throw new Error('LOGIN_REQUIRED'); } const doc = new DOMParser().parseFromString(html, 'text/html'); const games = []; // 策略1: 查找所有指向 remotestorageapp?appid= 的链接 // Steam 全局页面通常每个游戏一行,链接到单App页面 const appLinks = doc.querySelectorAll('a[href*="remotestorageapp"]'); if (appLinks.length > 0) { appLinks.forEach(link => { const href = link.href; const appid = extractAppId(href); if (!appid) return; const name = (link.textContent || '').trim() || `App ${appid}`; // 向上查找包含此链接的行容器(tr / div / li) let row = link.closest('tr') || link.closest('div') || link.parentElement; let fileCount = 0; let sizeText = ''; let sizeBytes = 0; if (row) { // 尝试从行内单元格/子元素提取文件数和大小 const cells = [...row.querySelectorAll('td, .cloudsave_size, .cloudsave_files, span, div')]; for (const cell of cells) { const txt = (cell.textContent || '').trim(); // 匹配大小文本 if (/[\d.]+\s*(bytes?|KB|MB|GB|TB)/i.test(txt) && !sizeText) { sizeText = txt; sizeBytes = normalizeSize(txt); } // 匹配文件数 if (/\d+\s*(file|文件)/i.test(txt) && fileCount === 0) { fileCount = parseFileCount(txt); } } } games.push({ appid, name, fileCount, totalSize: sizeText || formatSize(sizeBytes), sizeBytes, href }); }); } // 策略2: 如果策略1未找到数据,尝试查找 #main_content 下的所有表格行 if (games.length === 0) { const mainContent = doc.querySelector('#main_content'); if (mainContent) { const rows = mainContent.querySelectorAll('tr'); rows.forEach(row => { const link = row.querySelector('a[href*="remotestorageapp"]') || row.querySelector('a[href*="/app/"]'); if (!link) return; const href = link.href; const appid = extractAppId(href); if (!appid) return; const name = (link.textContent || '').trim() || `App ${appid}`; const cells = [...row.children]; let fileCount = 0; let sizeText = ''; let sizeBytes = 0; // 尝试从各列提取信息 for (const cell of cells) { const txt = (cell.textContent || '').trim(); if (/[\d.]+\s*(bytes?|KB|MB|GB|TB)/i.test(txt) && !sizeText) { sizeText = txt; sizeBytes = normalizeSize(txt); } if (/\d+\s*(file|文件)/i.test(txt) && fileCount === 0) { fileCount = parseFileCount(txt); } } games.push({ appid, name, fileCount, totalSize: sizeText || formatSize(sizeBytes), sizeBytes, href }); }); } } // 策略3: 降级模式 - 如果全局页面无法解析,返回空数组让宿主降级处理 if (games.length === 0) { console.warn('[SGLVCloudSave] 全局云存档页面未能解析出游戏列表,可能页面结构已变更'); } return games; } // ==================== 单 App 云存档文件列表解析 ==================== /** * 抓取指定 App 的云存档文件列表(分页) * * 页面结构(store.steampowered.com/account/remotestorageapp?appid=XXX): * 每页50文件,表格行包含 主目录/子目录/大小/日期/下载链接 * 底部有翻页链接 * * @param {number} appid * @returns {Promise<{files: Array, totalFiles: number}>} */ async function scrapeAppCloudSaveFiles(appid) { const files = []; let index = 0; const pageSize = 50; while (true) { const url = `https://store.steampowered.com/account/remotestorageapp?appid=${appid}&index=${index}`; const html = await gmFetchText(url); if (isLoginPage(html)) { throw new Error('LOGIN_REQUIRED'); } const doc = new DOMParser().parseFromString(html, 'text/html'); const pageFiles = parseAppFileRows(doc, index); files.push(...pageFiles); // 翻页检测(null 安全) if (checkNextPage(doc, index)) { index += pageSize; await sleep(200); // 翻页间隔 } else { break; } } return { files, totalFiles: files.length }; } /** * 解析单App页面表格行(使用原生 children,不依赖 Prototype.js) * v1.2.0: 修复选择器——精确匹配 #main_content 内的 accountTable tbody tr, * 避免页面其他表格(导航/页脚)的 tr 被误选导致文件数计算错误 */ function parseAppFileRows(dom, globalIndex) { // v1.2.0: 精确匹配 accountTable 的 tbody tr(参考 Steam 云.html 页面结构) const rows = [...dom.querySelectorAll('#main_content table.accountTable tbody tr, #main_content .accountTable tbody tr')]; // 兜底:如果精确匹配无结果,回退到全文档 tr(兼容旧页面结构) const finalRows = rows.length > 0 ? rows : [...dom.querySelectorAll('tr')].slice(1); return finalRows.map((node, index) => { const childrens = [...node.children]; if (childrens.length < 4) return null; const mainFolder = (childrens[0].innerText || '').trim(); const subFolder = (childrens[1].innerText || '').trim(); const path = mainFolder ? `${mainFolder}/${subFolder}` : subFolder; const size = (childrens[2].innerText || '').trim(); const date = (childrens[3].innerText || '').trim(); const href = childrens[4]?.querySelector('a')?.href || ''; return { index: globalIndex + index, path, size, date, href, sizeBytes: normalizeSize(size) }; }).filter(Boolean); } /** * 翻页检测(null 安全) */ function checkNextPage(dom, currentIndex) { try { const nextLink = dom.querySelector('#main_content > a:last-child'); if (!nextLink || !nextLink.href) return false; const match = nextLink.href.match(/index=(\d+)/); if (!match) return false; const nextIndex = parseInt(match[1], 10) || 0; return nextIndex > currentIndex; } catch { return false; } } // ==================== 增量 Diff 引擎 ==================== /** * 将新抓取的游戏列表与缓存做增量比对 * * @param {Array} newGames - 新抓取的游戏列表 * @param {Array} oldGames - 缓存中的旧游戏列表 * @returns {{new: Array, changed: Array, removed: Array, merged: Array}} * - new: 新增的游戏(appid 不在旧缓存中) * - changed: 文件数或大小发生变化的游戏 * - removed: 旧缓存中有但新抓取中没有的游戏 * - merged: 合并后的完整列表(新数据 + _status 标记) */ function diffCloudSaves(newGames, oldGames, options = {}) { const { additive = false } = options; // v2.9.39: additive=true 时只增不减,保留旧数据中未被新抓取覆盖的项 const oldMap = new Map(); if (Array.isArray(oldGames)) { oldGames.forEach(g => oldMap.set(g.appid, g)); } const newMap = new Map(); newGames.forEach(g => newMap.set(g.appid, g)); const added = []; const changed = []; const removed = []; const merged = []; // 检查新增和变化 for (const game of newGames) { const old = oldMap.get(game.appid); if (!old) { // 新增 game._status = 'new'; added.push(game); } else { // 比较文件数和大小 const fileChanged = game.fileCount !== old.fileCount; const sizeChanged = game.sizeBytes !== old.sizeBytes; // v1.3.0: 保留旧 fileCount——全局页面不展示文件数,新抓取 fileCount=0 不代表文件数变了 // 只有旧缓存的 fileCount 才是逐 App 页面获取的真实值,避免每次重新抓取后全部丢失 if ((!game.fileCount || game.fileCount === 0) && old.fileCount > 0) { game.fileCount = old.fileCount; } if (fileChanged || sizeChanged) { game._status = 'changed'; changed.push(game); } else { game._status = 'unchanged'; } } merged.push(game); } // 检查移除 for (const [appid, oldGame] of oldMap) { if (!newMap.has(appid)) { if (additive) { // v2.9.39: 只增不减模式——保留旧数据,标记为保留 oldGame._status = 'kept'; merged.push(oldGame); // 不推入 removed(不向用户展示"删除"徽章) } else { removed.push(oldGame); } } } return { new: added, changed, removed, merged }; } // ==================== 对外 API ==================== /** * 抓取指定 App 的云存档文件列表(对外封装,含错误处理) * * @param {number} appid * @returns {Promise<{files: Array, totalFiles: number}>} */ async function fetchAppCloudSaveFiles(appid) { if (!Host) throw new Error('SGLVCloudSave not initialized'); if (!appid) throw new Error('appid is required'); return scrapeAppCloudSaveFiles(appid); } /** * v1.2.0: 轻量级获取单 App 云存档文件数量(不下载完整文件列表) * 仅抓取页面并计数 accountTable tbody tr 行数,支持分页累计 * * @param {number} appid * @returns {Promise} 文件总数 */ async function fetchAppFileCount(appid) { if (!Host) throw new Error('SGLVCloudSave not initialized'); if (!appid) throw new Error('appid is required'); let total = 0; let index = 0; const pageSize = 50; while (true) { const url = `https://store.steampowered.com/account/remotestorageapp?appid=${appid}&index=${index}`; const html = await gmFetchText(url); if (isLoginPage(html)) throw new Error('LOGIN_REQUIRED'); const doc = new DOMParser().parseFromString(html, 'text/html'); // v1.2.0: 精确匹配 accountTable tbody tr(参考 Steam 云.html 页面结构) let rows = [...doc.querySelectorAll('#main_content table.accountTable tbody tr, #main_content .accountTable tbody tr')]; if (rows.length === 0) rows = [...doc.querySelectorAll('tr')].slice(1); // 过滤掉非数据行(children < 4 的行) const dataRows = rows.filter(r => r.children.length >= 4); total += dataRows.length; if (dataRows.length >= pageSize && checkNextPage(doc, index)) { index += pageSize; await sleep(200); } else { break; } } return total; } /** * v1.2.0: 批量获取游戏列表的文件数量(后台渐进式填充) * 对 fileCount=0 的游戏逐一请求单 App 页面解析文件数,通过 onProgress 回调通知宿主更新 UI * * @param {Array} games - 游戏列表 [{appid, fileCount, ...}] * @param {object} [options] * @param {function} [options.onProgress] - 回调 (updatedGame, allGames) => void * @param {number} [options.concurrency=3] - 并发数 * @param {number} [options.delay=500] - 每批之间的延迟(ms) * @param {AbortSignal} [options.signal] - 可选中止信号 * @returns {Promise} 更新后的游戏列表 */ async function enrichFileCounts(games, options = {}) { if (!Host) throw new Error('SGLVCloudSave not initialized'); const { onProgress, concurrency = 3, delay = 500, signal } = options; if (!Array.isArray(games) || games.length === 0) return games; // 筛选需要获取文件数的游戏(fileCount=0 且有 appid) const pending = games.filter(g => g.appid && (!g.fileCount || g.fileCount === 0)); if (pending.length === 0) return games; console.log(`[SGLVCloudSave] enrichFileCounts — 需获取 ${pending.length}/${games.length} 款游戏的文件数`); let processed = 0; // 并发池(限流) const pool = [...pending]; const workers = Array.from({ length: Math.min(concurrency, pool.length) }, async () => { while (pool.length > 0) { if (signal?.aborted) return; const game = pool.shift(); if (!game) return; try { const count = await fetchAppFileCount(game.appid); game.fileCount = count; processed++; console.log(`[SGLVCloudSave] 文件数: App ${game.appid} = ${count} (${processed}/${pending.length})`); if (onProgress) onProgress(game, games); } catch (e) { // 单个失败不影响整体,保留 fileCount=0 console.warn(`[SGLVCloudSave] 获取 App ${game.appid} 文件数失败:`, e.message); processed++; } // 请求间隔 await sleep(delay); } }); await Promise.all(workers); // 持久化到 IDB try { if (_memCache) { _memCache.games = games; // v1.3.0: 记录文件数填充时间戳,供宿主判断 5 天内是否需要重新获取 _memCache.fileCountAt = Date.now(); Host.storage.setCloudSaveData(_memCache); console.log(`[SGLVCloudSave] enrichFileCounts 完成,已持久化 ${games.length} 款游戏数据 (fileCountAt=${new Date(_memCache.fileCountAt).toLocaleString()})`); } } catch (e) { console.warn('[SGLVCloudSave] enrichFileCounts 持久化失败:', e); } return games; } /** * 抓取全局云存档数据,与缓存做增量 diff,持久化到 IDB * * @param {boolean} force - 是否强制刷新(忽略缓存TTL) * @param {object} [options] * @param {boolean} [options.additive=false] v2.9.39: 只增不减模式(保留旧数据中未在新抓取中出现的项) * @param {boolean} [options.silent=false] v2.9.39: 静默模式(不修改 lastUpdate,用于后台增量更新;保留旧时间戳以便节流) * @returns {Promise<{games, source, lastUpdate, diff, fromCache?}>} */ async function fetchGlobalCloudSaves(force = false, options = {}) { if (!Host) throw new Error('SGLVCloudSave not initialized'); const { additive = false, silent = false } = options; // 非强制刷新时检查缓存TTL if (!force && _memCache && _memCache.lastUpdate) { const ttl = Host.cacheTTL?.cloudSave || (24 * 3600 * 1000); if (Date.now() - _memCache.lastUpdate < ttl) { return { games: _memCache.games, source: 'cache', lastUpdate: _memCache.lastUpdate, diff: null, fromCache: true, }; } } // 抓取全局页面 const newGames = await scrapeGlobalCloudSavePage(); // 与旧缓存做 diff const oldGames = _memCache?.games || []; const diff = diffCloudSaves(newGames, oldGames, { additive }); // 更新内存缓存 _memCache = { games: diff.merged, // v2.9.39: 静默模式保留原始 lastUpdate(避免重置后台刷新节流时间) lastUpdate: silent && _memCache?.lastUpdate ? _memCache.lastUpdate : Date.now(), source: 'web', }; // 持久化到 IDB try { Host.storage.setCloudSaveData(_memCache); } catch (e) { console.warn('[SGLVCloudSave] IDB 持久化失败:', e); } return { games: diff.merged, source: 'web', lastUpdate: _memCache.lastUpdate, diff: { new: diff.new, changed: diff.changed, removed: diff.removed }, }; } /** * v2.9.39: 仅从 IDB 加载缓存到内存(不抓取网络),用于立即显示已有数据 * @returns {Promise<{games, source, lastUpdate}|null>} */ async function getCachedCloudSavesAsync() { if (!Host?.storage?.getCloudSaveData) return null; try { const cached = await Host.storage.getCloudSaveData(); if (cached && cached.games && Array.isArray(cached.games) && cached.games.length > 0) { _memCache = cached; // v1.3.0: 返回 fileCountAt 供宿主判断文件数是否需要重新获取(5 天 TTL) return { games: cached.games, source: 'cache', lastUpdate: cached.lastUpdate || 0, fileCountAt: cached.fileCountAt || 0, }; } } catch (e) { console.warn('[SGLVCloudSave] IDB 缓存加载失败:', e); } return null; } /** * 从 IDB 加载缓存到内存(init 时调用) */ async function loadCacheFromIDB() { if (!Host?.storage?.getCloudSaveData) return; try { const cached = await Host.storage.getCloudSaveData(); if (cached && cached.games && Array.isArray(cached.games)) { _memCache = cached; console.log(`[SGLVCloudSave] 从 IDB 加载缓存: ${cached.games.length} 款游戏, 更新于 ${new Date(cached.lastUpdate).toLocaleString()}`); } } catch (e) { console.warn('[SGLVCloudSave] IDB 缓存加载失败:', e); } } /** * 获取内存缓存(同步) */ function getCachedCloudSaves() { return _memCache ? { ..._memCache } : null; } // ==================== 初始化 ==================== function init(ctx) { if (!ctx || typeof ctx !== 'object') { console.error('[SGLVCloudSave] init 失败: ctx 为空'); return false; } if (ctx.apiVersion !== apiVersion) { console.error(`[SGLVCloudSave] API 版本不匹配: 宿主=${ctx.apiVersion}, 库=${apiVersion}`); return false; } Host = ctx; console.log('[SGLVCloudSave] v1.2.0 初始化成功'); // 异步加载 IDB 缓存到内存(非阻塞) loadCacheFromIDB(); return true; } function getApiVersion() { return apiVersion; } // ==================== 工具 ==================== function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // ==================== 导出 ==================== return { init, getApiVersion, fetchGlobalCloudSaves, fetchAppCloudSaveFiles, fetchAppFileCount, // v1.2.0: 轻量级获取单 App 文件数 enrichFileCounts, // v1.2.0: 批量获取文件数(后台渐进式) getCachedCloudSaves, getCachedCloudSavesAsync, // v2.9.39: 异步从 IDB 加载缓存到内存(不抓取网络) normalizeSize, formatSize, // 内部函数导出(供测试/调试) _internal: { scrapeGlobalCloudSavePage, scrapeAppCloudSaveFiles, diffCloudSaves, parseAppFileRows, checkNextPage, isLoginPage, }, }; })();