// ==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 || '');
}
})();