// ==UserScript==
// @name 百度网盘转存助手
// @namespace https://github.com/Wayne-Qi
// @version 1.1.7
// @author Qi
// @description 百度网盘分享链接一键转存
// @supportURL tencent://message/?uin=544439919
// @match *://pan.baidu.com/disk/home*
// @match *://yun.baidu.com/disk/home*
// @match *://pan.baidu.com/disk/main*
// @match *://yun.baidu.com/disk/main*
// @match https://pan.baidu.com/s/*
// @match https://yun.baidu.com/s/*
// @match https://pan.baidu.com/share/init*
// @match https://yun.baidu.com/share/init*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_notification
// @run-at document-end
// @all-frames true
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ================================================================
// 0. 辅助工具:提示音(Web Audio 生成,无需外部文件)
// ================================================================
function playBeep() {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.frequency.value = 880;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
oscillator.start(audioCtx.currentTime);
oscillator.stop(audioCtx.currentTime + 0.3);
} catch (e) {}
}
// ================================================================
// 1. 通用 HTTP 请求工具(替代 jQuery.ajax)
// ================================================================
function httpRequest(url, method, data, withCredentials, raw) {
const options = {
method: method,
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: withCredentials ? 'include' : 'same-origin',
};
if (data && method === 'POST') {
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
options.body = data;
}
return fetch(url, options).then(function (resp) {
return resp.text().then(function (text) {
// raw 模式:直接返回原始文本,不解析 JSON(用于获取 HTML 页面)
if (raw) {
if (!resp.ok) {
throw new Error('HTTP ' + resp.status + ': ' + resp.statusText);
}
return text;
}
// 非 JSON 响应保护:百度可能返回 HTML 登录页或错误页
if (!resp.ok) {
try {
var errJson = JSON.parse(text);
var errmsg = errJson.show_msg || errJson.errmsg || ('HTTP ' + resp.status);
var err = new Error(errmsg);
err.errno = errJson.errno;
err.status = resp.status;
throw err;
} catch (parseErr) {
if (parseErr.errno !== undefined) throw parseErr;
throw new Error('HTTP ' + resp.status + ': ' + resp.statusText + (text.length < 200 ? ' ' + text : ''));
}
}
try {
return JSON.parse(text);
} catch (e) {
throw new Error('Invalid JSON response from API: ' + text.substring(0, 200));
}
});
});
}
// ================================================================
// 2. 核心转存类
// ================================================================
window.BaiduTransfer = function (rootPath) {
this.ROOT_URL = 'https://pan.baidu.com';
this.bdstoken = null;
this.shareId = null;
this.shareRoot = null;
this.userId = null;
this.dirList = [];
this.fileList = [];
this.rootPath = rootPath || "";
this.maxRetries = 5;
this.cancel = false;
this.onProgress = null;
this.onLog = null;
this.totalFiles = 0;
this.transferredFiles = 0;
this.totalFileCount = 0;
this.totalDirCount = 0;
this.transferredFileCount = 0;
this.transferredDirCount = 0;
// 「目标已有、本轮跳过」单独计数:它们的性质是"以前就成功过",
// 不算本次成功也不算失败,混进 transferredFiles 会虚报战绩。
this.skippedCount = 0;
this.batchCount = 0;
// 收尾复核用:目标路径 → 本次提交成功的项数(同步+异步)。
// 必须按路径记而不是记总数:转存目标是镜像分享目录结构的,
// 项数散在各层,顶层永远只有那 1 个文件夹。拿累计数比顶层数必然误报
// (就是这么把一次完全正常的转存吓成"数量偏少"的)。
this.pathSubmitted = {};
// 其中走「百度后台异步受理」的部分(这些才是真正需要复核的)
this.asyncPaths = {};
};
BaiduTransfer.prototype = {
request: async function (path, method, params, data, checkErrno, raw) {
var url = this.ROOT_URL + path;
if (params) {
url += '?' + params;
}
try {
var response = await httpRequest(url, method, data, true, raw);
if (!raw && checkErrno && response.errno && Number(response.errno) !== 0) {
var errno = response.errno;
var errmsg = response.show_msg || "过5分钟重试";
var customError = new Error(errmsg);
customError.errno = errno;
throw customError;
}
return response;
} catch (error) {
throw error;
}
},
createDirectory: async function (dirPath) {
// 同一次运行内同一路径只建一次:多选多目录时它们会共享父目录,
// 重复建 + rtype=1 会造出「2026年一级建造师_20261102_140141」这种副本目录。
this.__createdDirs = this.__createdDirs || {};
// 根目录必然存在:/api/create 的 path=/ 会被判「参数错误 errno 2」,目标填 "/" 或留空时照建会让整个转存直接失败
dirPath = String(dirPath || '').replace(/\/{2,}/g, '/').replace(/\/+$/, '');
if (dirPath === '') return;
var dirKey = String(dirPath || '').replace(/\/+$/, '') || '/';
if (this.__createdDirs[dirKey]) return;
// 先查父目录,确认目标文件夹是否已存在(不依赖 errno=-9)
var parts = dirPath.split('/').filter(function (p) { return p; });
var folderName = parts[parts.length - 1];
var parentPath = '/' + parts.slice(0, -1).join('/');
try {
var listing = await this.listDir(parentPath);
var existList = listing.list || [];
for (var i = 0; i < existList.length; i++) {
if (existList[i].server_filename === folderName && existList[i].isdir === 1) {
this.__createdDirs[dirKey] = true;
return;
}
}
} catch (e) {
// 父目录列表获取失败,继续尝试创建
}
try {
var path = "/api/create";
// rtype=0:同名冲突直接回 -8。这里 -8 是好事(说明目录已经在),
// 旧版用 rtype=1 自动重命名,结果就是多出一个「_20261102_140141」副本空目录。
var params = "a=commit&bdstoken=" + this.bdstoken;
var data = "path=" + encodeURIComponent(dirPath) + "&isdir=1&rtype=0&block_list=[]";
await this.request(path, "POST", params, data, true);
this.__createdDirs[dirKey] = true;
} catch (error) {
// -8 = 该目录下已有此文件,算建好了。
// 注意 -9 是"文件或目录不存在",不是"已存在",之前当成功吞掉过,
// 把真正的建目录失败藏起来,后面连锁报错。
if (Number(error.errno) === -8) {
this.__createdDirs[dirKey] = true;
return;
}
throw error;
}
},
listDir: async function (dirPath) {
var path = "/api/list";
var params = "order=time&desc=1&showempty=1&page=1&num=1000&dir=" + this.customUrlEncode(dirPath) + "&bdstoken=" + this.bdstoken;
return await this.request(path, "GET", params, null, true);
},
// 只在彻底失败之后调:查一眼目标目录,帮用户分清楚两种长得一样的失败
// A) 目标里早就有同名内容(之前转过)→ 重复转存,一样会被拒
// B) 目标里干干净净 → 那就是限流,等几分钟就好
// 绝不抛错,诊断失败不能盖掉原始错误。
diagnoseTransferFailure: async function (targetPath, names, error) {
if (!targetPath || !names || names.length === 0) return;
try {
var listing = await this.listDir(targetPath);
var exist = {};
(listing && listing.list ? listing.list : []).forEach(function (item) {
exist[item.server_filename] = true;
});
var hit = names.filter(function (n) { return exist[n]; });
if (!this.onLog) return;
if (hit.length === 0) {
this.onLog('\u2139 诊断:目标目录 ' + targetPath + ' 里没有任何同名项 → 不是重复转存,属于接口限流/繁忙,等几分钟或换个目标路径再试', 'warning');
} else if (hit.length === names.length) {
this.onLog('\u2139 诊断:目标目录 ' + targetPath + ' 里已存在全部 ' + names.length + ' 个同名项(之前应该已经转存过了)→ 这次失败是重复转存被拒,无需再转', 'warning');
} else {
this.onLog('\u2139 诊断:目标目录 ' + targetPath + ' 里已有 ' + hit.length + '/' + names.length + ' 个同名项:' + hit.slice(0, 5).join('、') + ' → 重复转存与限流可能同时存在', 'warning');
}
} catch (e) {
// 诊断失败就算了(可能未登录 / 目录不存在),绝不能盖掉原始错误
}
},
// 查网盘配额,只读,只在"空间不足"时兜底调一下。
// GET /api/quota?checkfree=1&checkexpire=1
// → {"errno":0,"total":...,"free":...,"used":...}
// 单位是字节。free 是"免费容量"不是"剩余空间",剩余得自己算 total - used。
// 失败就抛,调用方吞掉。
getQuota: async function () {
var response = await this.request("/api/quota", "GET",
"checkfree=1&checkexpire=1&bdstoken=" + this.bdstoken, null, false);
var total = Number(response.total) || Number(response.quota) || 0;
var used = Number(response.used) || 0;
var remaining = Number(response.remaining);
if (!isFinite(remaining) || remaining < 0) remaining = Math.max(0, total - used);
return {
errno: Number(response.errno) || 0,
total: total,
used: used,
remaining: remaining,
free: Number(response.free) || 0
};
},
transfer: async function (userId, shareId, fsidList, transferPath, retryCount, itemType, withSekey, timeoutCount) {
if (retryCount === undefined) retryCount = 0;
// 超时重试单独计数:不能复用 retryCount,否则递归到深层级(retryCount>=2)时
// 会「一进来就没预算」,连一次退避重试都不做
if (timeoutCount === undefined) timeoutCount = 0;
if (this.cancel) throw new Error('操作已取消');
var path = "/share/transfer";
// sekey 的取舍是实测出来的,别想当然:
// 不带 sekey + BDCLND Cookie → 能跑通(v1.1.4 一直这么跑的)
// 带"二次编码"的 sekey → 200025「提取码输入错误」
// 所以默认不带 sekey,只有凭证确实没生效(200025 / -9)时才用 normalizeSekey 兜一次。
// 参数跟 v1.1.4 保持一致:shareid / from / ondup / channel / bdstoken 这五项。
// 中间几版试加的 web=1 / app_id / clienttype 没在真机验过,不留。
var params = "shareid=" + shareId + "&from=" + userId
+ ((withSekey && this.randsk) ? "&sekey=" + this.normalizeSekey(this.randsk) : "")
+ "&ondup=skip&channel=chunlei&bdstoken=" + this.bdstoken;
// 目标路径统一收敛:目标填 "/" 时上层会拼出 "//子目录",百度一律判 errno 2
transferPath = String(transferPath || '/').replace(/\/{2,}/g, '/').replace(/\/+$/, '') || '/';
var data = "fsidlist=[" + fsidList.join(",") + "]&path=" + encodeURIComponent(transferPath || "/");
try {
// 记一下真实耗时,用来区分两件被混为一谈的事:
// 真的网络/服务端超时(耗时接近阈值) vs. 百度秒回一个 errno 4 把你挡掉(限流)
var __t0 = Date.now();
var response = await this.request(path, "POST", params, data, false);
var elapsed = Date.now() - __t0;
var errno = Number(response.errno);
if (errno !== 0) {
if (errno === 2) {
throw new Error("APIParameterError: url=" + path + " param=" + params);
} else if (errno === 12) {
// errno 12 有两种成因相反的含义,只能靠响应体区分(两次真实抓包,不是猜的):
//
// A) 单批文件数超限 —— show_msg「转存文件数超限」,带 target_file_nums / _limit,
// info[].errno 是 120/130。→ 拆小 / 展开子目录有效,交给外层去拆。
// B) 目标网盘空间不足 —— 同一个 errno 12,show_msg「剩余空间不足,无法转存」,
// info[].errno 是 -32,带 target_size。→ 拆到多小都装不下,拆批纯属白跑。
//
// 旧版把 B 也当成 A,回给用户"非会员 500 上限、脚本会自动拆小",
// 用户就一直等一个永远不会发生的重试。误导比报错更糟。
var showMsg12 = String(response.show_msg || response.errmsg || '');
var info12 = Array.isArray(response.info) ? response.info : [];
var spaceHit12 = /空间不足|空间已满|容量不足|空间已用完|insufficient storage/i.test(showMsg12)
|| info12.some(function (it) { return Number(it && it.errno) === -32; });
if (spaceHit12) {
var needSize = Number(response.target_size) || 0;
var needFiles12 = Number(response.target_file_nums) || 0;
var spaceErr = new Error("NotEnoughSpaceException: " + (showMsg12 || '剩余空间不足')
+ (needSize ? '(本次要转存约 ' + formatSize(needSize)
+ (needFiles12 ? ',' + needFiles12 + ' 个文件' : '') + ')' : ''));
spaceErr.errno = 12;
spaceErr.__spaceIssue = true;
spaceErr.__needSize = needSize;
spaceErr.__needFiles = needFiles12;
spaceErr.__spaceItems = info12
.filter(function (it) { return Number(it && it.errno) === -32; })
.map(function (it) { return it.path; })
.filter(function (p) { return !!p; });
// 顺手查一眼真实配额,让"装不下"这个结论有数字撑着。
// 查不到就算了,结论本身来自上面的 show_msg / info[].errno。
try {
var q = await this.getQuota();
if (q && q.total > 0) {
spaceErr.__quota = q;
if (this.onLog) {
this.onLog('\u274C 这批被拒:' + (showMsg12 || '空间不足')
+ '。你的网盘:总 ' + formatSize(q.total) + ',已用 ' + formatSize(q.used)
+ ',剩余 ' + formatSize(q.remaining)
+ ';本次要转存 ' + formatSize(needSize)
+ ',还差 ' + formatSize(Math.max(0, needSize - q.remaining)), 'error');
}
}
} catch (e) { /* 查配额失败绝不盖掉原始错误 */ }
throw spaceErr;
}
// ↓ A) 单批文件数超限:把百度给的两个数字挂在错误上带出去,
// 让外层能直接说清"超了多少",而不是只丢一句含糊的"文件数超限"。
var limit = Number(response.target_file_nums_limit) || 0;
var count = Number(response.target_file_nums) || 0;
var limitMsg = (limit && count)
? ("TransferLimitExceededException: limit=" + limit + " count=" + count)
: ("TransferLimitExceededException: " + (showMsg12 || "转存文件数超限"));
var limitErr = new Error(limitMsg);
limitErr.errno = 12;
limitErr.__needFiles = count;
limitErr.__limitFiles = limit;
limitErr.__nonVipLimit = Number(response.non_vip_limit) || 0;
throw limitErr;
} else if (errno === 4 || errno === 1504) {
// errno 4 也有"长得一样、含义相反"的两种,必须靠响应体区分:
// A) 已经转存过 —— 带 duplicated 列表,show_msg「文件已转存」→ 不是失败,是早就成功了
// B) 没有 duplicated —— 才是服务端瞬时拒绝,按"同一请求 + 拉长间隔"重试
// 教训:早先几个版本把 A 当超时/限流,傻等 5/10/15/20 秒重试 4 次最后报失败,
// 而真相是第一次就转成功了,东西就躺在目标目录里。误报比漏报更坑。
var dup = response.duplicated;
var dupMsg = String(response.show_msg || response.errmsg || '');
var isDuplicate = !!(dup && (dup.list || dup.total !== undefined)) ||
/已转存|已存在|重复/.test(dupMsg);
if (isDuplicate) {
var dupNames = (dup && dup.list ? dup.list : []).map(function (d) {
return d.server_filename || d.path || '';
}).filter(function (n) { return !!n; });
this.skippedCount = (this.skippedCount || 0) + fsidList.length;
this.totalFiles = Math.max(0, (this.totalFiles || 0) - fsidList.length);
if (this.onLog) {
this.onLog('\u23ED\uFE0F 目标已有 ' + fsidList.length + (itemType === 'dir' ? ' 个文件夹' : ' 个文件')
+ ',响应「' + (dupMsg || '文件已转存') + '」'
+ (dupNames.length ? ':' + dupNames.slice(0, 3).join('、') + (dupNames.length > 3 ? '…' : '') : '')
+ ' → 视为已完成,跳过(不再重试)', 'success');
}
if (this.onProgress) {
this.onProgress({
transferred: this.transferredFiles,
total: this.totalFiles,
files: this.totalFileCount,
dirs: this.totalDirCount,
batches: this.batchCount,
skipped: this.skippedCount
});
}
return response;
}
// 只重试一次(3 秒)。拿 96 条真实 transfer 抓包数的:
// 5 次 errno 4 里 1 次 3 秒后重发直接成功,3 次变成 errno 12(说明实质是这批太大),
// 只有 1 次是连吃两次才在第 3 次变 12 —— 多等的 6 秒纯浪费。
// 所以重试一次,还不行就当成"这批太大"交给外层拆批/展开,不再原地死磕。
// 旧版会死等 3/6/12 秒共 21 秒然后抛异常,而外层不重试它 → 直接失败,
// 一次拆批都没试过。这条死路必须堵上。
var backoff = [3000];
if (timeoutCount < backoff.length) {
var waitMs = backoff[timeoutCount];
// 把响应原文也打出来,errno 4 有"已转存"和"批太大"两种含义,带上就不用下次再猜
var dbg = [];
var dbgMsg = String(response.show_msg || response.errmsg || '');
if (dbgMsg) dbg.push('响应「' + dbgMsg + '」');
if (response.target_file_nums !== undefined) {
dbg.push('本次要转存 ' + response.target_file_nums + ' 个文件'
+ (response.target_file_nums_limit !== undefined ? ',上限 ' + response.target_file_nums_limit : ''));
}
if (this.onLog) this.onLog('\u23F3 本批被拒 errno=' + errno + ' · ' + fsidList.length + ' 项 · ' + elapsed + 'ms'
+ (dbg.length ? ' · ' + dbg.join(' · ') : '')
+ ' · ' + (waitMs / 1000) + 's 后重试');
await new Promise(function (resolve) { setTimeout(resolve, waitMs); });
return await this.transfer(userId, shareId, fsidList, transferPath, retryCount, itemType, withSekey, timeoutCount + 1);
}
// 消息里带 TransferTimeoutException:外层 isTooBigError 会认它,
// 然后对多项目批拆成两半、对单项展开子目录 —— 也就是把请求切小。
var timeoutErr = new Error("TransferTimeoutException(too-big): errno=" + errno + " items=" + fsidList.length + " type=" + itemType + " elapsed=" + elapsed + "ms");
timeoutErr.errno = errno;
throw timeoutErr;
} else if (errno === 111) {
if (retryCount < this.maxRetries) {
if (this.onLog) this.onLog('\u23F3 API限流,等待15秒... (' + (retryCount + 1) + '/' + this.maxRetries + ')');
await new Promise(function (resolve) { setTimeout(resolve, 15000); });
return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1, itemType, withSekey);
} else {
throw new Error("API rate limit exceeded after maximum retries");
}
} else if (errno === 200025 || errno === -9) {
// 「提取码输入错误 / 提取码验证失败」—— 分享凭证没生效。
// 两种原因:缺凭证(没带 sekey 且 Cookie 也没兜住),或带了错的 sekey。
// 这里用正确编码的 sekey 兜一次,只兜一次,不消耗重试次数。
if (!withSekey && this.randsk) {
if (this.onLog) this.onLog('\u21A9 提取码凭证未生效(errno ' + errno + '),改用 sekey 重试');
return await this.transfer(userId, shareId, fsidList, transferPath, retryCount, itemType, true);
}
var credErr = new Error("BaiduYunPanAPIException: [" + errno + "] " + (response.errmsg || response.show_msg || ''));
credErr.errno = errno;
throw credErr;
} else if (errno === -6 || errno === 130) {
// 注意:-6 是「请重新登录」,不一定能靠重试恢复,
// 所以日志只在真的要重试时打,避免出现「重试 (6/5)」这种误导
if (retryCount < this.maxRetries) {
if (this.onLog) this.onLog('\u23F3 网络错误,重试 (' + (retryCount + 1) + '/' + this.maxRetries + ')');
await new Promise(function (resolve) { setTimeout(resolve, 3000); });
return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1, itemType, withSekey);
} else {
var netErr = new Error("Network/Server error after maximum retries: errno=" + errno);
// 带上 errno:分享场景下 -6 是「请重新登录」,
// 不带上就会在上层被当成网络问题,提示完全跑偏
netErr.errno = errno;
throw netErr;
}
} else {
throw new Error("BaiduYunPanAPIException: [" + errno + "] " + (response.errmsg || response.show_msg || ''));
}
}
// errno === 0 也有两种含义,得继续看响应体:
// A) 同步转完 → show_msg 为空、task_id 为 0 ← 真·完成
// B) 后台异步 → show_msg「文件转存中」、task_id 有值 ← 只是受理了
// 旧版一视同仁都打「成功转存」。实机日志里有 3 条这样的(26+13+14=53 个文件夹,
// 占最终 109 个的一半),其实没有任何"已完成"的凭据。
// 后台任务要是随后失败,用户拿到一句"全部完成"却缺内容,这种漏法很难被发现。
var acceptedMsg = String(response.show_msg || '');
var rawTaskId = response.task_id;
var taskIdStr = (rawTaskId === undefined || rawTaskId === null) ? '' : String(rawTaskId);
var asyncByMsg = /转存中|正在转存|进行中|排队中/.test(acceptedMsg);
var asyncByTask = (taskIdStr !== '' && taskIdStr !== '0');
var isAsync = asyncByMsg || asyncByTask;
this.transferredFiles += fsidList.length;
if (itemType === 'dir') {
this.transferredDirCount += fsidList.length;
} else {
this.transferredFileCount += fsidList.length;
}
this.batchCount += 1;
// 防御:这两个账本由构造函数初始化;离线测试脚手架用 Object.create 绕过了
// 构造函数,这里补一次,避免"读 undefined 的属性"直接炸掉整轮转存。
if (!this.pathSubmitted) this.pathSubmitted = {};
if (!this.asyncPaths) this.asyncPaths = {};
// 按"提交到的目标路径"记账:收尾复核要拿这个数跟该目录的实际条目数比。
// 注意 transferPath 是目标路径(镜像分享结构),跟分享里的路径不是一回事。
if (transferPath) {
this.pathSubmitted[transferPath] = (this.pathSubmitted[transferPath] || 0) + fsidList.length;
}
if (isAsync) {
// 单独记账:这些项"已提交但未确认",收尾要不要复核全看这个数
this.asyncTaskCount = (this.asyncTaskCount || 0) + 1;
this.asyncItemCount = (this.asyncItemCount || 0) + fsidList.length;
if (transferPath) {
this.asyncPaths[transferPath] = (this.asyncPaths[transferPath] || 0) + fsidList.length;
}
if (itemType === 'dir') {
this.asyncDirCount = (this.asyncDirCount || 0) + fsidList.length;
} else {
this.asyncFileCount = (this.asyncFileCount || 0) + fsidList.length;
}
}
if (this.onProgress) {
this.onProgress({
transferred: this.transferredFiles,
total: this.totalFiles,
files: this.totalFileCount,
dirs: this.totalDirCount,
batches: this.batchCount
});
}
if (this.onLog) {
if (isAsync) {
this.onLog('\u23F3 已受理,但**还没完成**:' + fsidList.length
+ (itemType === 'dir' ? ' 个文件夹' : ' 个文件')
+ ' 转后台异步处理(响应「' + (acceptedMsg || '文件转存中') + '」'
+ (taskIdStr && taskIdStr !== '0'
? ',任务号 ' + taskIdStr
: ',未给任务号')
+ ';判据=' + (asyncByMsg ? 'show_msg' : '') + (asyncByMsg && asyncByTask ? '+' : '')
+ (asyncByTask ? 'task_id' : '')
+ ')。跑完统一复核目标目录,不以这句话当成功');
} else {
this.onLog('\u2705 成功转存 ' + fsidList.length
+ (itemType === 'dir'
? ' 个文件夹(整目录递归复制,含其中全部子目录与文件)'
: ' 个文件'));
}
}
return response;
} catch (error) {
if (isNoRetryError(error)) {
throw error;
}
// 注意:TransferTimeoutException 不走这里。它上面已经做过递增退避,
// 再套一层只会让请求数成倍膨胀;它要交给上层「拆小 / 展开」处理。
if (retryCount < this.maxRetries &&
error.message.indexOf('after maximum retries') === -1 &&
error.message.indexOf('TransferTimeoutException') === -1 &&
error.message.indexOf('TransferLimit') === -1 &&
(error.message.indexOf('timeout') !== -1 || error.message.indexOf('Network') !== -1)) {
if (this.onLog) this.onLog('\u23F3 网络超时,重试 (' + (retryCount + 1) + '/' + this.maxRetries + ')');
await new Promise(function (resolve) { setTimeout(resolve, 3000); });
// withSekey 必须带下去:否则重试会丢掉「凭证兜底」这个状态,退回 200025
return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1, itemType, withSekey);
}
throw error;
}
},
getBdstoken: async function () {
if (this.bdstoken) {
return this.bdstoken;
}
var path = "/api/gettemplatevariable";
var params = "fields=[\"bdstoken\"]";
var response = await this.request(path, "GET", params, null, true);
if (!response || !response.result || !response.result.bdstoken) {
throw new Error('获取 bdstoken 失败(可能未登录或登录态已过期,请重新登录百度网盘后重试)');
}
this.bdstoken = response.result.bdstoken;
return this.bdstoken;
},
// /share/verify 返回的 randsk 本身已经是 URL 编码的(形如 ...%2F...%3D),
// 千万别再套一次 encodeURIComponent —— % 会被二次编码成 %25,
// 服务端解出来的 sekey 就变了。实测:
// sekey 原样 → errno 0
// 解码后原样 → errno 0
// encodeURIComponent → errno -9「提取码验证失败」(transfer 上表现为 200025)
// 统一走"先解码还原、再编码一次",对两种形态都安全。
normalizeSekey: function (randsk) {
if (!randsk) return '';
var s = String(randsk);
try {
return encodeURIComponent(decodeURIComponent(s));
} catch (e) {
return s; // 含非法 % 转义时保持原样,不冒险
}
},
getRandsk: async function (shareKey, pwd) {
var path = "/share/verify";
// 参数与网页自己发的那条保持一致(t 是毫秒时间戳,每次请求都要重新生成)
var params = "t=" + Date.now() + "&surl=" + shareKey + "&channel=chunlei&web=1&app_id=250528"
+ "&bdstoken=" + this.bdstoken + "&clienttype=0";
var data = "pwd=" + encodeURIComponent(pwd);
var response = await this.request(path, "POST", params, data, true);
// 留一份 randsk 备用。实测(2026-11-02):/share/verify 返回 errno 0 并给出新 randsk,
// 但百度**不会**替你刷新 BDCLND Cookie,浏览器里留的仍是上一个分享的旧票,
// 导致随后所有 /share/list 都返 errno -9,分享页自己也会一直卡在「提取中…」。
// 所以下面必须手动把 Cookie 对齐成本次 randsk(见 __syncBdclnd),不能指望服务端顺手写。
this.randsk = response.randsk;
this.__syncBdclnd(response.randsk);
return response.randsk;
},
// 分享页的数据容器有两套版式,实测两套并存、随机下发,都得支持:
// A(旧):locals.mset({...})
// B(新):
// 两套里 share_uk / shareid / file_list 结构一致,解析后直接复用。
extractShareDataJSON: function (html) {
// 1) 新版:
var m = html.match(/