// ==UserScript== // @name 一键下载Steam云存档(增强版) // @namespace SteamCloudSaveEnhanced // @version 1.0.0 // @description Auto download Steam Cloud Save - Enhanced: parallel download, retry, error handling, progress bar // @author DreamNya / SmallFork (enhanced) // @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; } `); // 插入按钮和进度条 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(); try { await main(); if (!_cancelled) { button.text('下载完成'); button.removeClass('disabled cancel-mode'); } else { button.text('已取消'); button.removeClass('disabled cancel-mode'); } } catch (e) { log('致命错误: ' + (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); } function updateProgress(current, total) { const pct = total > 0 ? Math.round((current / total) * 100) : 0; $progressFill.css('width', pct + '%'); $progressText.text(`下载进度 ${current}/${total} (${pct}%)`); } // ==================== 主流程 ==================== async function main() { _cancelled = false; const appid = location.href.match(/appid=(\d+)/)[1]; 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; } } log(`共发现 ${filesInfos.length} 个文件,开始下载...`); updateProgress(0, filesInfos.length); // Phase 2: 并发下载所有文件 await DownloadFiles(filesInfos, tittle); } // ==================== 页面获取 ==================== 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 || ''); } })();