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