// ==UserScript== // @name 一键下载Steam云存档(增强版) // @namespace SteamCloudSaveEnhanced // @version 1.1.1 // @description:en Auto download Steam Cloud Save - Enhanced: parallel download, retry, error handling, progress bar. v1.1.0 prominent alert (banner + Steam native dialog) when appid is empty or no downloadable files, no more empty zip // @description 一键批量下载 Steam 云存档(增强版):并发下载、失败自动重试、超时控制、进度条与日志,打包 ZIP 并附 CSV 下载清单;v1.1.0 起 appid 为空/无可用下载文件时给出醒目提示(横幅 + Steam 原生弹窗),不再生成空压缩包 // @author DreamNya / SmallRob (enhanced) // @icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%2316295e'/%3E%3Cstop offset='.55' stop-color='%231d4ed8'/%3E%3Cstop offset='1' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Cg transform='translate(11 12) scale(1.75)' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z'/%3E%3Cpath d='M12 13v8'/%3E%3Cpath d='m8 17.5 4 4 4-4'/%3E%3C/g%3E%3C/svg%3E // @match https://store.steampowered.com/account/remotestorageapp?appid=* // @match https://store.steampowered.com/account/remotestorageapp/?appid=* // @require https://cdn.jsdelivr.net/npm/jszip@3.9.1/dist/jszip.min.js // @grant GM_xmlhttpRequest // @grant GM_download // @grant GM_addStyle // @run-at document-end // @license MIT // @connect steampowered.com // @connect steamusercontent.com // ==/UserScript== /* global JSZip */ /* eslint-disable no-constant-condition */ (function () { 'use strict'; // ==================== 配置 ==================== const settings = { globalDelay: 100, // 存档下载间隔 单位:ms concurrency: 3, // 并发下载数 maxRetry: 2, // 失败重试次数 retryDelay: 1000, // 重试间隔 ms requestTimeout: 30000, // 单文件请求超时 30s pageSize: 50, // Steam 每页文件数 }; const $ = unsafeWindow.jQuery; let _cancelled = false; // 取消标志 // ==================== UI ==================== GM_addStyle(` #AutoDownload { display: inline-block; padding: 10px 20px; border-radius: 8px; background: #2ECC71; color: #ffffff; font-size: 15px; border: 1px solid #2ECC71; transition: background .2s, transform .1s; cursor: pointer; } #AutoDownload:hover { background: #27AE60; } #AutoDownload:active { transform: scale(0.97); } #AutoDownload.disabled { cursor: not-allowed; opacity: 0.7; } #AutoDownload.cancel-mode { background: #E74C3C; border-color: #E74C3C; } #AutoDownload.cancel-mode:hover { background: #C0392B; } #csProgressWrap { margin-top: 12px; display: none; } #csProgressBar { width: 100%; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; } #csProgressFill { height: 100%; background: linear-gradient(90deg, #2ECC71, #27AE60); border-radius: 3px; transition: width .3s ease; width: 0%; } #csProgressText { margin-top: 6px; font-size: 12px; color: #8a9ba8; } #csLog { margin-top: 8px; max-height: 150px; overflow-y: auto; font-size: 12px; font-family: monospace; color: #8a9ba8; display: none; } #csLog .log-fail { color: #E74C3C; } #csLog .log-ok { color: #27AE60; } #csLog .log-warn { color: #F39C12; } /* v1.1.0: 醒目提示横幅(appid 无效 / 无可用存档文件等) */ #csAlert { display: none; margin-top: 12px; padding: 12px 16px; border-radius: 6px; font-size: 14px; line-height: 1.7; word-break: break-all; } #csAlert.cs-alert-fail { background: rgba(231, 76, 60, 0.16); border: 1px solid #E74C3C; color: #ff9d8f; } #csAlert.cs-alert-warn { background: rgba(243, 156, 18, 0.16); border: 1px solid #F39C12; color: #ffc46b; } #csAlert .cs-alert-title { display: block; font-weight: bold; font-size: 15px; margin-bottom: 4px; } `); // 插入按钮和进度条 const button = $('').prependTo('#main_content'); const progressWrap = $(`
准备中...
`).insertAfter(button); const $progressFill = $('#csProgressFill'); const $progressText = $('#csProgressText'); const $log = $('#csLog'); button.on('click', async function () { if ($(this).hasClass('disabled') && !$(this).hasClass('cancel-mode')) return; if ($(this).hasClass('cancel-mode')) { _cancelled = true; $(this).removeClass('cancel-mode disabled').text('取消中...').prop('disabled', true); return; } $(this).addClass('disabled').text('下载中... 点击取消'); // 第二次点击进入取消模式 setTimeout(() => { if (!_cancelled) $(this).addClass('cancel-mode').text('取消下载'); }, 500); progressWrap.show(); $log.show(); $('#csAlert').hide(); // v1.1.0: 新一轮下载先隐藏上次的提示横幅 try { const result = await main(); button.removeClass('disabled cancel-mode'); if (_cancelled) { button.text('已取消'); } else if (result === 'ok') { button.text('下载完成'); } else if (result === 'nofiles') { button.text('无存档文件'); } else { button.text('无法下载'); // appid 无效等前置检查失败 } } catch (e) { showAlert('致命错误', String(e.message || e), 'fail'); button.text('下载失败').removeClass('disabled cancel-mode'); } }); // ==================== 日志 ==================== function log(msg, type) { const cls = type === 'fail' ? 'log-fail' : type === 'ok' ? 'log-ok' : type === 'warn' ? 'log-warn' : ''; $log.append(`
[${new Date().toLocaleTimeString()}] ${msg}
`); $log.scrollTop($log[0].scrollHeight); } // v1.1.0: 醒目提示(横幅常驻展示 + Steam 原生弹窗兜底),用于 appid 无效/无可用文件等需要用户明确感知的场景 function showAlert(title, msg, type) { const cls = type === 'fail' ? 'cs-alert-fail' : 'cs-alert-warn'; let $alert = $('#csAlert'); if (!$alert.length) $alert = $('
').insertAfter(button); $alert.attr('class', cls) .html(`${title}${msg}`) .show(); log(`${title}: ${msg}`, type); // Steam 商店页原生弹窗(v5 模态框,存在时更显眼;非 Steam 环境/异常时静默降级为仅横幅) try { if (unsafeWindow && typeof unsafeWindow.ShowAlertDialog === 'function') { unsafeWindow.ShowAlertDialog(title, msg); } } catch (e) { /* 忽略弹窗失败,横幅已展示 */ } } function updateProgress(current, total) { const pct = total > 0 ? Math.round((current / total) * 100) : 0; $progressFill.css('width', pct + '%'); $progressText.text(`下载进度 ${current}/${total} (${pct}%)`); } // ==================== 主流程 ==================== // 返回值:'ok' 下载完成 / 'nofiles' 无可用存档文件 / 'invalid' 前置检查失败(appid 无效) async function main() { _cancelled = false; // v1.1.0: appid 前置校验——URL 缺少/无法解析 appid 时直接醒目提示,不再抛 TypeError const appidMatch = location.href.match(/[?&]appid=(\d+)/); const appid = appidMatch && appidMatch[1]; if (!appid || appid === '0') { showAlert( '⚠ 无法识别游戏 AppID', '当前页面 URL 缺少有效的 appid 参数,无法确定要下载哪个游戏的云存档。请从 Steam「帐户详情 → 云存储」或游戏社区中心的云存档链接进入本页。', 'fail' ); return 'invalid'; } const tittle = ($('h2:last').text().trim() || `appid${appid}`).replace(/[\\/:*?"<>|]/g, '_'); let index = 0; const filesInfos = []; // Phase 1: 分页抓取文件列表 log(`开始获取 AppID ${appid} 的云存档文件列表...`); while (true) { if (_cancelled) { log('用户取消', 'warn'); break; } const url = `https://store.steampowered.com/account/remotestorageapp?appid=${appid}&index=${index}`; const dom = await GetPage(url); const filesInfo = GetFilesInfo(dom, index); filesInfos.push(...filesInfo); log(`第 ${Math.floor(index / settings.pageSize) + 1} 页: 获取 ${filesInfo.length} 个文件`, 'ok'); const nextPage = CheckNextPage(dom, index); if (nextPage) { index += settings.pageSize; await sleep(200); } else { break; } } // v1.1.0: 无可用下载文件拦截——不再走打包流程生成只含 CSV 的空压缩包 if (!_cancelled && filesInfos.length === 0) { showAlert( '⚠ 未发现云存档文件', `AppID ${appid} 没有可下载的云存档文件。可能原因:① 该游戏不支持 Steam 云存档;② 存档尚未同步上云(可先启动一次游戏触发上传);③ 该 AppID 不对应当前游戏的云存档。`, 'warn' ); $progressText.text('无可用存档文件'); return 'nofiles'; } // v1.1.0: 过滤无下载链接的条目(页面解析异常时 href 可能为空,避免无效请求) const downloadable = filesInfos.filter(f => f.href); if (downloadable.length < filesInfos.length) { log(`跳过 ${filesInfos.length - downloadable.length} 个无下载链接的条目`, 'warn'); } if (!_cancelled && downloadable.length === 0) { showAlert( '⚠ 无可用下载链接', `AppID ${appid} 虽列出 ${filesInfos.length} 个文件,但均未解析到下载链接(页面结构可能已变化),请重试或反馈脚本作者。`, 'fail' ); return 'nofiles'; } log(`共发现 ${downloadable.length} 个可下载文件,开始下载...`); updateProgress(0, downloadable.length); // Phase 2: 并发下载所有文件 await DownloadFiles(downloadable, tittle); return 'ok'; } // ==================== 页面获取 ==================== function GetPage(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ url, method: 'GET', responseType: 'text', timeout: settings.requestTimeout, onload: (xhr) => { if (xhr.status === 200) { const dom = new DOMParser().parseFromString(xhr.responseText, 'text/html'); // 检测是否被重定向到登录页 if (dom.querySelector('input[name="username"]') && !dom.querySelector('#main_content')) { reject(new Error('Steam 登录态已过期,请重新登录')); return; } resolve(dom); } else { reject(new Error(`页面获取失败 status:${xhr.status} url:${url}`)); } }, onerror: () => reject(new Error(`网络错误: ${url}`)), ontimeout: () => reject(new Error(`请求超时: ${url}`)), }); }); } // ==================== 解析文件信息 ==================== function GetFilesInfo(dom, globalIndex) { // 使用原生 children 替代非标准的 childElements() return [...dom.querySelectorAll('tr')].slice(1).map((node, index) => { const childrens = [...node.children]; if (childrens.length < 5) 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 }; }).filter(Boolean); } // ==================== 翻页检测(null 安全) ==================== function CheckNextPage(dom, index) { 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 > index; } catch { return false; } } // ==================== 并发下载+打包 ==================== async function DownloadFiles(filesInfos, tittle) { const zip = new JSZip(); const csvRows = [['序号', '文件名', '文件大小', '写入日期', '下载状态']]; const total = filesInfos.length; let completed = 0; let successCount = 0; let failCount = 0; // 并发池:N 个 worker 从队列取任务 let taskIdx = 0; async function worker(workerId) { while (taskIdx < filesInfos.length) { if (_cancelled) break; const myIdx = taskIdx++; const fileInfo = filesInfos[myIdx]; const { blob, status } = await DownloadFileWithRetry(fileInfo.href); const statusText = status === 200 ? 'OK' : `Fail(${status})`; csvRows.push([ String(myIdx + 1), csvEscape(fileInfo.path), csvEscape(fileInfo.size), csvEscape(fileInfo.date), `${status} ${statusText}` ]); if (status === 200 && blob) { zip.file(fileInfo.path, blob); successCount++; } else { failCount++; log(`[W${workerId}] 失败: ${fileInfo.path} (status:${status})`, 'fail'); } completed++; updateProgress(completed, total); await sleep(settings.globalDelay); } } // 启动并发 worker const workers = []; for (let i = 0; i < Math.min(settings.concurrency, filesInfos.length); i++) { workers.push(worker(i)); } await Promise.all(workers); log(`下载完成: 成功 ${successCount}, 失败 ${failCount}`, successCount > 0 ? 'ok' : 'fail'); // 即使部分失败也打包已下载的文件 // CSV 带 BOM 防止 Excel 乱码,字段双引号包裹防止逗号破坏 const csvContent = '\uFEFF' + csvRows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',') ).join('\n'); zip.file('下载结果.csv', csvContent); log('正在生成压缩包...'); const content = await zip.generateAsync({ type: 'blob' }); const blobUrl = URL.createObjectURL(content); GM_download(blobUrl, `${tittle} ${Date.now()}.zip`); log(`压缩包已生成并触发下载: ${tittle}.zip`, 'ok'); // 延迟 revoke(GM_download 异步触发浏览器下载) setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); } // ==================== 单文件下载(带重试) ==================== function DownloadFileWithRetry(url) { return new Promise((resolve) => { let attempt = 0; function tryFetch() { GM_xmlhttpRequest({ url, method: 'GET', responseType: 'blob', timeout: settings.requestTimeout, onload: (xhr) => { resolve({ blob: xhr.response, status: xhr.status }); }, onerror: () => { if (attempt < settings.maxRetry) { attempt++; log(`重试 ${attempt}/${settings.maxRetry}: ${url}`, 'warn'); setTimeout(tryFetch, settings.retryDelay * attempt); } else { resolve({ blob: null, status: 0 }); } }, ontimeout: () => { if (attempt < settings.maxRetry) { attempt++; log(`超时重试 ${attempt}/${settings.maxRetry}: ${url}`, 'warn'); setTimeout(tryFetch, settings.retryDelay * attempt); } else { resolve({ blob: null, status: 408 }); } }, }); } tryFetch(); }); } // ==================== 工具函数 ==================== function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms ?? settings.globalDelay)); } function csvEscape(str) { return String(str || ''); } })();