// ==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(/]*\bid=["']locals-data["'][^>]*>([\s\S]*?)<\/script>/i); if (m) { try { return JSON.parse(m[1].trim()); } catch (e) { /* 落到后面的方案 */ } } // 2) 旧版:locals.mset({...}),括号匹配取出完整对象 var tagIndex = html.indexOf('locals.mset'); if (tagIndex !== -1) { var openParen = html.indexOf('(', tagIndex); if (openParen !== -1) { var start = openParen + 1; var depth = 1; var pos = start; var inStr = false; var strChar = ''; while (pos < html.length && depth > 0) { var ch = html[pos]; if (inStr) { if (ch === '\\') { pos += 2; continue; } if (ch === strChar) inStr = false; } else if (ch === '"' || ch === "'") { inStr = true; strChar = ch; } else if (ch === '(') { depth++; } else if (ch === ')') { depth--; } pos++; } if (depth === 0) { var jsonStr = html.substring(start, pos - 1); try { return JSON.parse(jsonStr); } catch (e) { /* 落到后面的方案 */ } } } } // 3) 兜底:页面里任意一段「以 { 开头且含 file_list」的大块 JSON var scripts = html.match(/]*>[\s\S]*?<\/script>/gi) || []; for (var i = 0; i < scripts.length; i++) { var body = scripts[i].replace(/^]*>/i, '').replace(/<\/script>$/i, '').trim(); if (body.length < 200 || body.charAt(0) !== '{' || body.indexOf('"file_list"') === -1) continue; try { return JSON.parse(body); } catch (e) { /* 继续试下一段 */ } } return null; }, getShareData: async function (shareKey, pwd) { var path = "/s/1" + shareKey; // 这个接口返回的是 HTML 页面(数据容器见 extractShareDataJSON),需要 raw 模式获取原始文本 var response = await this.request(path, "GET", null, null, false, true); if (this.onLog) this.onLog('📄 分享页面获取成功,长度: ' + response.length + ' 字符'); var data = this.extractShareDataJSON(response); if (!data) { if (this.onLog) this.onLog('⚠️ 页面里没有找到分享数据容器(locals-data / locals.mset),可能需要先验证提取码、链接已失效,或页面版式又变了', 'warning'); throw new Error("Invalid response: unable to find share data container. 页面结构可能已更新,请检查脚本版本"); } if (!data || !data.share_uk || !data.shareid || !data.file_list || !Array.isArray(data.file_list) || data.file_list.length === 0) { if (this.onLog) this.onLog('⚠️ JSON 数据缺少必要字段: share_uk=' + (data && data.share_uk) + ' shareid=' + (data && data.shareid) + ' file_list=' + (data && data.file_list ? data.file_list.length : 'null'), 'warning'); throw new Error("Invalid response: share data is incomplete or missing required fields"); } return { userId: data.share_uk, shareId: data.shareid, bdstoken: data.bdstoken, shareRoot: decodeURIComponent(data.file_list[0].parent_path || ''), dirList: data.file_list.filter(function (e) { return e.isdir === 1; }).map(function (file) { return { id: file.fs_id, name: file.server_filename }; }), fileList: data.file_list.filter(function (e) { return e.isdir !== 1; }).map(function (file) { return { id: file.fs_id, name: file.server_filename, size: file.size || 0, md5: file.md5 || '' }; }) }; }, // 把 BDCLND Cookie 对齐成本次分享的 randsk。 // 这是本脚本最阴的一个坑,写成通杀式清理,别再“只清 path=/”。实测 2026-11-02: // 1) 百度自己在 /disk 路径也写过一条 BDCLND,document.cookie 在 /disk/main 下能读到; // 2) path 更长的 cookie 排在 Cookie 头更前面,服务端取第一条 → 永远是旧票 → list 恒 -9; // 3) path=/share、/s 这种形态当前路径读不到,但请求 /share/* 接口时会被带上; // 4) 写入时若 fallback 到 host-only path=/,会新增第二条同名 cookie,越修越脏。 // 所以:枚举 domain × path 全清 → 只写一条 .baidu.com path=/ → 校验“恰好一条且值对”。 __purgeBdclnd: function () { var expire = '; expires=Thu, 01 Jan 1970 00:00:00 GMT'; var domains = ['', '; domain=.baidu.com', '; domain=baidu.com', '; domain=.pan.baidu.com']; var paths = ['/', '/disk', '/share', '/s', '/disk/main']; for (var i = 0; i < domains.length; i++) { for (var j = 0; j < paths.length; j++) { try { document.cookie = 'BDCLND=; path=' + paths[j] + domains[i] + expire; } catch (e) { } } } }, __syncBdclnd: function (randsk) { if (!randsk || typeof document === 'undefined') return false; var v = String(randsk); var target = v; try { target = decodeURIComponent(v); } catch (e) { /* 非法转义时直接用原值比 */ } var readAll = function () { try { return document.cookie.split(';').map(function (s) { return s.trim(); }) .filter(function (s) { return s.indexOf('BDCLND=') === 0; }) .map(function (s) { return s.slice(7); }); } catch (e) { return []; } }; var decodeSafe = function (x) { try { return decodeURIComponent(x); } catch (e) { return x; } }; var all = readAll(); if (all.length === 1 && decodeSafe(all[0]) === target) return true; // 已经干净,不白清一次 this.__purgeBdclnd(); // 只写这一种形态:两种并存时服务端取哪条不可控 try { document.cookie = 'BDCLND=' + v + '; path=/; domain=.baidu.com'; } catch (e) { } all = readAll(); return all.length === 1 && decodeSafe(all[0]) === target; }, updateRandsk: async function (shareKey, pwd) { await this.getBdstoken(); await this.getRandsk(shareKey, pwd); }, initShareData: async function (shareKey, pwd) { // 优先用 gettemplatevariable 获取完整的 bdstoken(分享页面的可能不完整) await this.getBdstoken(); this.shareKey = shareKey; this.__pwdVerified = false; try { // 提取码验证也放进 try:失败时 errno -9 会被下面的 catch 映射成「提取码错误」 if (pwd) { await this.getRandsk(shareKey, pwd); // 能走到这里说明 /share/verify 返回了 errno 0,提取码本身是对的。 // 记一笔:后面 getShareData 再失败就是「码对但分享页打不开」,不是码错。 this.__pwdVerified = true; } var shareData = await this.getShareData(shareKey, pwd); this.userId = shareData.userId; this.shareId = shareData.shareId; // 注意:不覆盖 this.bdstoken,保持 getBdstoken() 获取的完整 token this.shareRoot = shareData.shareRoot; this.dirList = shareData.dirList; this.fileList = shareData.fileList; this.totalFiles = this.dirList.length + this.fileList.length; } catch (error) { // 光看 HTTP 状态不够,errno 和关键词都得认 var msg = error.message || ''; // 提取码已经过接口验证通过,却还是拿不到分享数据:绝大多数是百度侧拒了 // (分享被限流 / 已被分享方撤销 / 需要人机验证),此时报「提取码错误」会把人带偏 if (this.__pwdVerified) { throw new Error('SharePageUnavailable: 提取码校验通过,但百度没返回分享内容'); } if (msg.indexOf('/share/init') !== -1 || error.errno === -9 || msg.indexOf('share data container') !== -1 || msg.indexOf('share data is incomplete') !== -1 || (msg.indexOf('not found') !== -1 && msg.indexOf('share') !== -1)) { if (pwd) { throw new Error("Wrong password: " + pwd); } else { throw new Error("Password not specified"); } } throw error; } }, transferFiles: async function (fileList, targetPath, retryCount) { if (retryCount === undefined) retryCount = 0; if (this.cancel) throw new Error('操作已取消'); if (fileList.length === 0) return; var maxTransferCount = 100; try { for (var i = 0; i < fileList.length; i += maxTransferCount) { if (this.cancel) throw new Error('操作已取消'); var batch = fileList.slice(i, i + maxTransferCount); var fsidList = batch.map(function (file) { return file.id; }); await this.transfer(this.userId, this.shareId, fsidList, targetPath, retryCount, 'file'); } if (this.onLog) this.onLog('\u2705 文件转存完成 (' + fileList.length + ' 个)'); } catch (error) { if (isNoRetryError(error)) { throw error; } // 超限 / 服务端超时(这一批太大)→ 拆分成两半重试 if (isTooBigError(error) && fileList.length > 1) { var mid = Math.floor(fileList.length / 2); var left = fileList.slice(0, mid); var right = fileList.slice(mid); if (this.onLog) this.onLog('\u21AA 本批太大' + describeTooBig(error) + ' · 拆分为 ' + left.length + ' + ' + right.length + ' 继续'); // retryCount 必须带下去:否则每个子批都从 0 重新计,日志里会冒出多条 (1/5),重试预算也会成倍膨胀 if (left.length > 0) await this.transferFiles(left, targetPath, retryCount); if (right.length > 0) await this.transferFiles(right, targetPath, retryCount); return; } if (retryCount < this.maxRetries && !this.cancel && error.message.indexOf('after maximum retries') === -1 && // 超时类在 transfer() 里已经退避重试过一次(3 秒),外层再叠一轮 // 只会白等,还容易把自己搞成限流 error.message.indexOf('TransferTimeoutException') === -1) { if (this.onLog) this.onLog('\u23F3 文件转存失败(' + fileList.length + ' 个),重试 (' + (retryCount + 1) + '/' + this.maxRetries + '):' + error.message); await new Promise(function (resolve) { setTimeout(resolve, 5000); }); return await this.transferFiles(fileList, targetPath, retryCount + 1); } else { if (error.message.indexOf('TransferTimeoutException') !== -1) { await this.diagnoseTransferFailure(targetPath, fileList.map(function (f) { return f.name; }), error); } throw error; } } }, transferDirs: async function (dirList, targetPath, retryCount) { if (retryCount === undefined) retryCount = 0; if (this.cancel) throw new Error('操作已取消'); if (dirList.length === 0) return; var fsidList = dirList.map(function (dir) { return dir.id; }); try { // 传 retryCount 而不是硬编码 0:否则每次进 transfer 都从 0 重新计, // 内外两层各有一套重试,实际请求数会成倍膨胀(maxRetries=5 时最坏 30 次) await this.transfer(this.userId, this.shareId, fsidList, targetPath, retryCount, 'dir'); if (this.onProgress) this.onProgress({ transferred: this.transferredFiles, total: this.totalFiles, files: this.totalFileCount, dirs: this.totalDirCount, batches: this.batchCount }); // 成功日志由 transfer() 统一打(它知道 itemType,会区分「个文件夹」/「个文件」),这里不再重复打一遍 } catch (error) { if (isNoRetryError(error)) { throw error; } // 超限 / 服务端超时(这一批太大)→ 拆分成两半重试 if (isTooBigError(error) && dirList.length > 1) { var mid = Math.floor(dirList.length / 2); var left = dirList.slice(0, mid); var right = dirList.slice(mid); if (this.onLog) this.onLog('\u21AA 本批太大' + describeTooBig(error) + ' · 拆分为 ' + left.length + ' + ' + right.length + ' 继续'); // retryCount 必须带下去:否则每个子批都从 0 重新计,日志里会冒出多条 (1/5),重试预算也会成倍膨胀 if (left.length > 0) await this.transferDirs(left, targetPath, retryCount); if (right.length > 0) await this.transferDirs(right, targetPath, retryCount); return; } // 单个文件夹太大(超限 / 服务端转存超时)→ 展开成子项,把请求切小后递归转存 if (isTooBigError(error) && dirList.length === 1) { var dir = dirList[0]; // /share/list 的 dir 要用「shareRoot + 分享内相对路径」: // 只给 /<文件夹名>(相对分享根)会回 errno 2「啊哦,链接出错了」。 // shareRoot 取分享页的 parent_path,listShareDir 内部补前缀,这里只传相对路径。 var relPath = ''; if (targetPath.length > this.rootPath.length) { relPath = targetPath.slice(this.rootPath.length); } var shareDirPath = relPath + '/' + dir.name; var targetDirPath = targetPath + '/' + dir.name; if (this.onLog) this.onLog('\u21AA 单个文件夹过大,展开为子项继续:' + dir.name); // 创建目标文件夹 try { await this.createDirectory(targetDirPath); } catch (e) { /* 已存在则忽略 */ } // 列出子内容 var subFiles = await this.listShareDir(this.userId, this.shareId, shareDirPath); var subDirList = subFiles.filter(function (file) { return file.isDirectory; }); var subFileList = subFiles.filter(function (file) { return !file.isDirectory; }); // 保险起见:能走到"展开"说明这个文件夹整批转不过去,那它里面必然有内容。 // 万一一个子项都没列出来,说明 /share/list 出状况了, // 就这么 return 的话整个文件夹会被静默丢掉、收尾还报"完成"。 // 悄悄漏内容比报错严重得多,所以宁可炸出来。 if (subDirList.length === 0 && subFileList.length === 0) { throw new Error("TransferLimitExceededException: 展开「" + dir.name + "」后没有列出任何子项,无法确认内容完整(/share/list 异常?),不敢当作成功"); } this.totalFiles += subDirList.length + subFileList.length; this.totalFileCount += subFileList.length; this.totalDirCount += subDirList.length; if (subDirList.length > 0) await this.transferDirs(subDirList, targetDirPath); if (subFileList.length > 0) await this.transferFiles(subFileList, targetDirPath); return; } if (retryCount < this.maxRetries && !this.cancel && error.message.indexOf('after maximum retries') === -1 && // 同 transferFiles:超时类在 transfer() 里已经退避过一次,别让外层再叠一轮 error.message.indexOf('TransferTimeoutException') === -1) { if (this.onLog) this.onLog('\u23F3 文件夹转存失败(' + dirList.length + ' 个),重试 (' + (retryCount + 1) + '/' + this.maxRetries + '):' + error.message); await new Promise(function (resolve) { setTimeout(resolve, 5000); }); return await this.transferDirs(dirList, targetPath, retryCount + 1); } else { if (error.message.indexOf('TransferTimeoutException') !== -1) { await this.diagnoseTransferFailure(targetPath, dirList.map(function (d) { return d.name; }), error); } throw error; } } }, listShareDir: async function (userId, shareId, dirPath, retryCount) { if (retryCount === undefined) retryCount = 0; var path = "/share/list"; var page = 1; var result = []; // 是「分享根」还是「分享里的某个子目录」 var isShareRoot = (!dirPath || dirPath === '/'); // dir 的写法是实测出来的(五组对照,全是真机请求): // shorturl + root=1 + dir=/ → errno 0(列分享根) // shorturl + dir=/<子目录> → errno 0(列子目录) // shorturl + dir=<子目录>(相对分享根) → errno 2「啊哦,链接出错了」 // 子目录必须带 shareRoot 前缀,否则拆分/展开这条重试路径整条走不通。 var fullDir = isShareRoot ? '/' : (this.shareRoot || '') + (dirPath.charAt(0) === '/' ? dirPath : '/' + dirPath); // 分享根 parent_path 为 "/"(分享的是根目录下的文件夹)或缺失时会拼出 "//子目录" / 相对路径, // 百度一律回 errno 2「啊哦,链接出错了」,这里收敛掉。 fullDir = fullDir.replace(/\/{2,}/g, '/'); try { while (true) { var params = "shorturl=" + this.shareKey + (isShareRoot ? "&root=1" : "") + "&sekey=" + this.normalizeSekey(this.randsk || "") + "&page=" + page + "&num=100&order=name&desc=0&showempty=0&web=1&clienttype=0" + "&dir=" + this.customUrlEncode(fullDir); var response = await this.request(path, "GET", params, null, true); var list = response.list; if (!list || !Array.isArray(list)) { break; } list.forEach(function (item) { result.push({ id: item.fs_id, name: item.server_filename, // 注意:root=1 列分享根时 isdir / size 返回的是字符串("1" / "0"), // 用 === 1 判断会把文件夹当成文件 isDirectory: Number(item.isdir) === 1, size: Number(item.size) || 0, md5: item.md5 || '' }); }); if (list.length < 100) { break; } page++; } return result; } catch (error) { 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.listShareDir(userId, shareId, dirPath, retryCount + 1); } else { throw error; } } }, extractShareKey: function (url) { try { if (!url) return null; // 先解码再去 # 后面的内容,不然 %23 这类编码字符会混进来 var decodedUrl = decodeURIComponent(url); var cleanUrl = decodedUrl.split('#')[0]; if (cleanUrl.indexOf("/s/1") !== -1) { return cleanUrl.split("/s/1")[1].split("?")[0]; } else if (cleanUrl.indexOf("surl=") !== -1) { return cleanUrl.split("surl=")[1].split("&")[0]; } } catch (e) { // 解码失败时回退到原始 URL 的简单解析 try { var fallbackUrl = url.split('#')[0]; if (fallbackUrl.indexOf("/s/1") !== -1) { return fallbackUrl.split("/s/1")[1].split("?")[0]; } else if (fallbackUrl.indexOf("surl=") !== -1) { return fallbackUrl.split("surl=")[1].split("&")[0]; } } catch (e2) {} } return null; }, customUrlEncode: function (input) { // 只保留 / 不编码,其余按标准转义。 // 原实现只转义空格/引号/加号,& # % ? = 会原样进 query, // 路径里一出现 & 或 #,dir 就被截断成另一条路径。 return encodeURIComponent(input).replace(/%2F/g, '/'); }, // 列出目标目录(自己的网盘),返回 name 列表 // dirs: { 原名: true, 带时间戳的也映射到原名 } listTargetNames: async function (targetPath) { try { // 确保 bdstoken 已正确获取(分享页面的 bdstoken 可能不完整) await this.getBdstoken(); var response = await this.listDir(targetPath); var list = response.list; var result = { dirs: {}, files: {}, dirOrigNames: {}, fileOrigNames: {}, dirEmpty: {} }; var timestampRe = /_\d{8}_\d{6}$/; if (list && Array.isArray(list)) { list.forEach(function (item) { var name = item.server_filename; if (item.isdir === 1) { result.dirs[name] = true; // dir_empty=1 表示这个文件夹是空的(返回的可能是数字 1 也可能是字符串 "1")。 // 用途:同名文件夹只有非空时才算"上次已转存好了"。 // 否则会踩这种坑 —— 上次只建好目录就失败了,重跑时当"已存在"跳过, // 用户永远拿不到内容,日志还告诉他"已完成"。 result.dirEmpty[name] = (String(item.dir_empty) === '1'); // 如果是带时间戳的重复文件夹,也记录其原始名称 var baseName = name.replace(timestampRe, ''); if (baseName !== name) { result.dirOrigNames[baseName] = true; } } else { result.files[name] = { size: item.size || 0, md5: item.md5 || '' }; var baseFile = name.replace(timestampRe, ''); if (baseFile !== name) { result.fileOrigNames[baseFile] = { size: item.size || 0, md5: item.md5 || '' }; } } }); } return result; } catch (error) { // errno=-9 表示目录不存在,返回空 if (error.errno === -9) { return { dirs: {}, files: {}, dirOrigNames: {}, fileOrigNames: {}, dirEmpty: {} }; } throw error; } }, // 收尾复核(1):列一次目标目录,交回名字和条数。 // 只交名字不交结论,怎么比由调用方在同一口径下决定。 // 列目录失败返回 null 且不抛异常 —— 复核只是加分项,不能盖掉主流程结论。 verifyTargetCount: async function (targetPath) { try { var t = await this.listTargetNames(targetPath); var dirNames = Object.keys(t.dirs || {}); var fileNames = Object.keys(t.files || {}); return { dirNames: dirNames, fileNames: fileNames, dirs: dirNames.length, files: fileNames.length, total: dirNames.length + fileNames.length }; } catch (e) { return null; } }, // 收尾复核(2):异步批次真正提交到了哪些目录、那些目录现在有多少项。 // 这一条才是"内容齐不齐"的有效凭据。不能只查顶层: // 转存目标是镜像分享目录结构的,项数散在各层,顶层永远只有那 1 个文件夹。 // 之前拿"累计 117"比"顶层 1",把一个完全正常的转存报成"数量偏少",纯属自己吓自己。 // 异步批次通常集中在少数几个目录(这次 53 项全在一个目录),所以请求数很少。 // 【新增】只转分享里的某一个子目录(或某个散文件) // 做法:沿分享路径逐层定位拿到 fsid,只在网盘镜像出它的父目录,然后只提交这一项。 // 返回 true = 已按子目录模式处理完,调用方不要再走全量流程。 transferSubPath: async function (subPath) { var segs = String(subPath || '').split('/').filter(function (s) { return s && s.trim(); }); if (segs.length === 0) return false; if (this.onLog) { this.onLog('📂 子目录模式:本次只转 /' + segs.join('/') + ',分享里其余内容一律不动', 'info'); } // 1) 沿路径逐层定位(每层重新列,避免拿到过期的 fsid) var hit = null; var rel = ''; for (var i = 0; i < segs.length; i++) { // retryCount 传个超过 maxRetries 的值:“路径不存在”重试一万次也还是不存在, // 不传的话 listShareDir 会白重试 5 次(每次等 3 秒),用户干看着没任何反馈。 var items = null; try { items = await this.listShareDir(this.userId, this.shareId, rel, 999); } catch (e2) { throw new Error('分享里读不到「/' + segs.slice(0, i + 1).join('/') + '」(' + ((e2 && e2.message) || e2) + ')。请点面板上的「📋 列目录」按实际名称选择'); } var found = null; for (var k = 0; k < items.length; k++) { if (items[k].name === segs[i]) { found = items[k]; break; } } if (!found) { throw new Error('分享里找不到「/' + segs.slice(0, i + 1).join('/') + '」。请点面板上的「📋 列目录」按实际名称选择(注意全角括号与大小写)'); } if (i === segs.length - 1) hit = found; // 关键:先 list(rel) 再匹配 segs[i],匹配上之后 rel 才拼进这一层。 // 分享根(root=1 + dir=/)列出来的就是「分享项本身」, // 所以第 0 段必须在分享根列表里找;旧写法先拼路径再 list, // 等于拿 / 的内容去找 segs[0],第一步必然落空。 rel += '/' + segs[i]; } // 2) 在网盘镜像出目标项的全部父目录(层级与分享保持一致) var parent = this.rootPath || '/'; for (var j = 0; j < segs.length - 1; j++) { if (parent === '/') { parent = '/' + segs[j]; } else { parent = parent.replace(/\/+$/, '') + '/' + segs[j]; } await this.createDirectory(parent); } // 3) 统计口径:分母就是这一项,不再按整个分享算进度 this.topLevelCount = (this.topLevelCount || 0) + 1; this.totalFiles = (this.totalFiles || 0) + 1; this.totalFileCount = (this.totalFileCount || 0) + (hit.isDirectory ? 0 : 1); this.totalDirCount = (this.totalDirCount || 0) + (hit.isDirectory ? 1 : 0); // 4) 提交 // 必须走 transferDirs / transferFiles,不能直接调 transfer: // 直接提交一个「目录」fsid 时,百度按递归后的文件总数算账, // 法规这种几千文件的目录必然被 errno 12(非会员单批 500 上限)整批拒掉, // 而 transferDirs 自带的「单个文件夹过大 → 展开成子项递归」才能绕过上限、 // 把整个目录一格格搬完。直接调 transfer 的旧写法会中途报错落地半截。 if (this.onLog) { this.onLog('➡ 提交:' + hit.name + (hit.isDirectory ? '(文件夹)' : '(文件)') + ' → 目标 ' + parent, 'info'); } if (hit.isDirectory) { await this.transferDirs([hit], parent, 0); } else { await this.transferFiles([hit], parent, 0); } return true; }, verifyAsyncPaths: async function () { var paths = Object.keys(this.asyncPaths || {}); var out = []; for (var i = 0; i < paths.length; i++) { var p = paths[i]; var row = { path: p, submittedAsync: this.asyncPaths[p], submittedAll: (this.pathSubmitted && this.pathSubmitted[p]) || this.asyncPaths[p], actual: null }; try { var r = await this.listTargetNames(p); row.actual = Object.keys(r.dirs || {}).length + Object.keys(r.files || {}).length; } catch (e) { row.actual = null; // 列不出来就如实说"未知",不猜 } out.push(row); } return out; }, // 过滤已存在的文件夹,只返回需要转存的 // 已存在的直接跳过(不递归),避免 ondup=newcopy 产生重复 // 同时识别带时间戳后缀的重复文件夹(如 原名_20260830_104450) filterNewDirs: async function (dirList, targetPath) { // 没有待转存目录就别去列目录了(每次 listDir 都是一次真实请求) if (!dirList || dirList.length === 0) return []; try { var target = await this.listTargetNames(targetPath); var newDirs = []; var skipped = 0; var skippedTs = 0; var skippedEmpty = 0; var skippedNames = []; var targetDirNames = Object.keys(target.dirs); var dirEmpty = target.dirEmpty || {}; for (var i = 0; i < dirList.length; i++) { var name = dirList[i].name; var exists = target.dirs[name] || target.dirOrigNames[name]; var isTs = !target.dirs[name] && !!target.dirOrigNames[name]; if (exists && dirEmpty[name] === true) { // 同名但目标是空目录:不算"已转存"。宁可多发一次请求(最多被回一个 errno 4 已存在), // 也不能让用户以为好了、实际什么都没拿到。 skippedEmpty++; newDirs.push(dirList[i]); } else if (exists) { if (isTs) { skippedTs++; } skipped++; skippedNames.push(name); } else { newDirs.push(dirList[i]); } } this.lastSkippedDirNames = skippedNames; if (this.onLog) { var dirCount = targetDirNames.length; var srcNames = dirList.map(function(d){return d.name;}).slice(0,5).join('、'); var tgtNames = targetDirNames.slice(0,5).join('、'); this.onLog('\uD83D\uDD0D [' + targetPath + '] 目标已有 ' + dirCount + ' 个文件夹,待转存 ' + dirList.length + ' 个,跳过 ' + skipped + ' 个(其中时间戳匹配 ' + skippedTs + ' 个)'); if (skippedEmpty > 0) { this.onLog('\u26A0\uFE0F 有 ' + skippedEmpty + ' 个同名文件夹在目标里是**空**的,不当成已转存,仍会重新转一次'); } if (dirCount > 0 && skipped === 0) { this.onLog('\uD83D\uDD0D 待转存前5个:' + srcNames + (dirList.length > 5 ? '...' : '')); this.onLog('\uD83D\uDD0D 目标已有前5个:' + tgtNames + (dirCount > 5 ? '...' : '')); } } if (skipped > 0) { this.totalFiles = Math.max(0, (this.totalFiles || 0) - skipped); this.skippedCount = (this.skippedCount || 0) + skipped; if (this.onProgress) { this.onProgress({ transferred: this.transferredFiles, total: this.totalFiles, files: this.totalFileCount, dirs: this.totalDirCount, batches: this.batchCount, skipped: this.skippedCount }); } } return newDirs; } catch (error) { // 检查失败时全部转存(保守策略) if (this.onLog) this.onLog('\u26A0\uFE0F 文件夹去重检查失败,全部转存:' + error.message, 'warning'); return dirList; } }, // 过滤已存在的文件(同名+同大小+同MD5 则跳过) // 同时识别带时间戳后缀的重复文件 filterNewFiles: async function (fileList, targetPath) { // 同上:空列表直接返回,省掉一次 /api/list if (!fileList || fileList.length === 0) return []; try { var target = await this.listTargetNames(targetPath); var newFiles = []; var skipped = 0; var skippedTs = 0; var noMd5 = 0; var targetFileNames = Object.keys(target.files); for (var i = 0; i < fileList.length; i++) { var src = fileList[i]; var tgt = target.files[src.name]; var tgtTs = target.fileOrigNames[src.name]; // 分享页不一定带 md5,没有 md5 就没法确认是同一个文件, // 这时不跳过 —— 宁可多发一次让服务端判重,也不能误跳过导致漏文件。 var comparable = !!src.md5; if (tgt && comparable && src.size !== undefined && String(src.size) === String(tgt.size) && src.md5 === tgt.md5) { // 完全同名+同大小+同MD5,跳过 skipped++; } else if (tgt && comparable && src.size !== undefined && String(src.size) === String(tgt.size) && tgt.md5 && src.md5 !== tgt.md5) { // 同名同大小但内容不同(分享方换了文件)→ 必须转,不能跳 newFiles.push(src); } else if (tgtTs && comparable && src.size !== undefined && String(src.size) === String(tgtTs.size) && src.md5 === tgtTs.md5) { skippedTs++; skipped++; } else { if (!comparable && tgt) noMd5++; newFiles.push(src); } } if (this.onLog) { var fileCount = targetFileNames.length; this.onLog('\uD83D\uDD0D [' + targetPath + '] 文件检测:目标已有 ' + fileCount + ' 个文件,待转存 ' + fileList.length + ' 个,跳过 ' + skipped + ' 个(其中时间戳匹配 ' + skippedTs + ' 个)'); if (noMd5 > 0) { this.onLog('\u2139 有 ' + noMd5 + ' 个文件名在目标里撞上了、但分享页没给 md5,无法确认是不是同一个文件 → 交给服务端判重(会返回「文件已转存」)'); } } if (skipped > 0) { this.totalFiles = Math.max(0, (this.totalFiles || 0) - skipped); this.skippedCount = (this.skippedCount || 0) + skipped; if (this.onProgress) { this.onProgress({ transferred: this.transferredFiles, total: this.totalFiles, files: this.totalFileCount, dirs: this.totalDirCount, batches: this.batchCount, skipped: this.skippedCount }); } } return newFiles; } catch (error) { // 检查失败时全部转存(保守策略) if (this.onLog) this.onLog('\u26A0\uFE0F 文件去重检查失败,全部转存:' + error.message, 'warning'); return fileList; } }, transferFinal: async function (url, pwd, subPath) { var shareKey = this.extractShareKey(url); if (!shareKey) { throw new Error("Unable to extract share key from URL"); } await this.initShareData(shareKey, pwd); // 分享的顶层条目数(还没展开、没过滤)。收尾讲数时要区分"顶层几项"和 // "逐层展开后累计几项",这两者差一个数量级,混用会把正常转存报成"数量偏少"。 this.topLevelCount = (this.dirList ? this.dirList.length : 0) + (this.fileList ? this.fileList.length : 0); this.transferredFiles = 0; this.transferredFileCount = 0; this.transferredDirCount = 0; this.batchCount = 0; this.skippedCount = 0; this.asyncTaskCount = 0; this.asyncItemCount = 0; this.asyncDirCount = 0; this.asyncFileCount = 0; this.pathSubmitted = {}; this.asyncPaths = {}; // 统一创建目标目录(仅一次),避免 transfer API 的 ondup=newcopy 产生重复文件夹 if (this.rootPath) { await this.createDirectory(this.rootPath); } // 【新增】指定子目录模式:只转这一项,不走「全量过滤 + 逐层递归」那一套。 // 场景:整个分享几 TB、账号装不下,但其中某个科目只要几十 GB。 var subPaths = normalizeSubPaths(subPath); if (subPaths.length) { if (this.onLog && subPaths.length > 1) { this.onLog('📂 多选模式:本次只转 ' + subPaths.length + ' 项,分享里其余内容一律不动', 'info'); } // 分母从 0 起算:每轮 transferSubPath 累加,展开递归时再随子项增长 this.topLevelCount = 0; this.totalFiles = 0; this.totalFileCount = 0; this.totalDirCount = 0; for (var si = 0; si < subPaths.length; si++) { var pickedSub = await this.transferSubPath(subPaths[si]); if (!pickedSub) break; } return; } if (this.rootPath) { // 转存前先查一眼目标目录,同名项直接跳过。 // filterNewDirs / filterNewFiles 早写好了却从来没被调用过(死代码), // 于是每次都白发一轮注定被拒的转存请求,用户看到的是「errno 4 失败」, // 真相是目标目录里已经有上次转出来的东西了。 // 计数基线要在这里给:filterNewDirs/filterNewFiles 内部会做 this.totalFiles -= skipped。 this.totalFiles = this.dirList.length + this.fileList.length; this.dirList = await this.filterNewDirs(this.dirList, this.rootPath); this.fileList = await this.filterNewFiles(this.fileList, this.rootPath); } this.totalFileCount = this.fileList.length; this.totalDirCount = this.dirList.length; if (this.dirList.length === 0 && this.fileList.length === 0) { // 全部已被跳过:这是「无需操作」,不是「失败」,也不是「转存了 0 个」 if (this.onLog) this.onLog('\u23ED\uFE0F 目标目录 ' + this.rootPath + ' 里已有同名内容,本次未做任何转存(要强制重新拉取,请换一个空的目标路径)', 'warning'); var _sd = this.lastSkippedDirNames || []; if (_sd.length && this.onLog) { this.onLog('⚠️ 被跳过的文件夹:' + _sd.slice(0, 5).join('、') + (_sd.length > 5 ? '…' : '') + ' —— 判定依据只有「名字相同」,没有核对里面文件是否齐全。若目标里这些文件夹是半截的(之前只转了一部分),这次不会补齐;请换一个空目录重跑,或者用子目录模式。', 'warning'); } return; } if (this.dirList.length > 0) { await this.transferDirs(this.dirList, this.rootPath); } if (this.fileList.length > 0) { await this.transferFiles(this.fileList, this.rootPath); } } }; // ================================================================ // 3. UI 控制面板 // ================================================================ const STATE = { IDLE: 'idle', RUNNING: 'running', COMPLETED: 'completed', ERROR: 'error', STOPPED: 'stopped' }; // 日志上限。之前压到过 30 条说是修性能,结果大转存根本看不到开头 —— // 日志刷了上百条,复制出来只剩最后 28 行,起步阶段的拆分和总项数全丢了,排查等于瞎子。 // 400 条够覆盖一次大转存的全过程,DOM 增量插入的开销可以忽略。 const MAX_LOG_ENTRIES = 400; let appState = { status: STATE.IDLE, totalFiles: 0, transferredFiles: 0, totalFileCount: 0, totalDirCount: 0, // 分享顶层条目数(未展开)。跟上面两个"逐层展开后的累计数"不是一个口径, // 必须分开存,混用会让"数量对不对得上"这类判断彻底失效。 topLevelCount: 0, asyncItemCount: 0, asyncTaskCount: 0, batchCount: 0, // 「本轮跳过(目标已有)」的项数,和「本轮实际转存」分开统计 skippedCount: 0, errorMessage: '', logMessages: [], }; let transferInstance = null; // 密码别持久化,会话结束就该没 function saveMemory(pwd, path) { // 密码仅保存到 sessionStorage(浏览器关闭后自动清除) try { if (pwd) sessionStorage.setItem('bt_pwd_session', pwd); } catch (e) { /* sessionStorage 不可用时忽略 */ } if (path) GM_setValue('saved_path', path); } function loadMemory() { let pwd = ''; try { pwd = sessionStorage.getItem('bt_pwd_session') || ''; } catch (e) { /* sessionStorage 不可用时忽略 */ } return { pwd: pwd, path: GM_getValue('saved_path', '') }; } // 面板加载时自动填入的日期路径。用于跨零点识别: // 只有「值仍等于这份自动值」才允许换成今天,用户手改过的路径不动。 var __autoPathAtLoad = ''; function getDefaultPath() { const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); return '/转存_' + dateStr; } function isAutoDatePath(path) { return /^\/转存_\d{4}-\d{2}-\d{2}$/.test(path || ''); } function normalizeTargetPath(path) { path = (path || '').trim(); if (!path) return ''; if (!path.startsWith('/')) { path = '/' + path; } return path.replace(/\/+/g, '/'); } function isNoRetryError(error) { if (!error) return false; const msg = error && error.message ? error.message : String(error); const lower = msg.toLowerCase(); // 空间不足:重试/拆批都不可能变好,必须立刻停手(挂 errno 12,光看 errno 会跟"文件数超限"混掉) if (error.__spaceIssue || error.errno === -32 || msg.indexOf('NotEnoughSpaceException') !== -1) { return true; } // 空间不足 if (msg.indexOf('剩余空间不足') !== -1 || msg.indexOf('空间不足') !== -1 || msg.indexOf('容量不足') !== -1 || lower.indexOf('insufficient storage') !== -1) { return true; } // 百度安全验证(短信/设备/风险验证),重试无效 if (lower.indexOf('verifysms') !== -1 || msg.indexOf('需要验证') !== -1 || lower.indexOf('verify') !== -1 || lower.indexOf('rmb') !== -1) { return true; } // 分享链接失效 / 被删 / 无效 if (msg.indexOf('Share is cancelled') !== -1 || msg.indexOf('分享的文件已被删除') !== -1 || msg.indexOf('分享链接已失效') !== -1 || lower.indexOf('sharelink is invalid') !== -1 || lower.indexOf('file is not exist') !== -1 || msg.indexOf('链接已失效') !== -1) { return true; } // 登录态 / 鉴权(未登录、源文件不存在) if (lower.indexOf('no such file or directory') !== -1 || msg.indexOf('登录') !== -1 || error.errno === -6 || error.errno === -9) { return true; } // 安全验证 / 页面过期 / 文件被禁止分享:重试不会变好,只会白等一轮(含 15s 限流等待) if (error.errno === 19 || error.errno === 112 || error.errno === 115 || error.errno === 200025 || msg.indexOf('验证码') !== -1 || msg.indexOf('页面已过期') !== -1 || msg.indexOf('禁止分享') !== -1) { return true; } // 其他明确不可重试 if (msg.indexOf('已达上限') !== -1) { return true; } return false; } // 「这一批太大」类错误:拆小就能过。 // 12 = 单批数量超限,响应里会同时给 target_file_nums / _limit // 4/1504 重试后仍失败 —— 22:27 实机:同一批 6 项先 errno 4(235ms 秒回), // 3 秒后原样重发变成 errno 12,拆成 3+3 两个都成功。 // 所以这种 errno 4 的实质就是"这批太大",拆开是唯一有效的办法。 // 教训:旧版本把它当限流、坚持原样重试且不让外层拆批, // 结果是死等 3/6/12 秒 → 抛异常 → 外层不重试它 → 直接失败,一次拆批都没试过。 // 上层靠它决定"拆成两半"还是"展开子目录递归"。 // 把 verifyAsyncPaths() 的结果汇总成"还差多少 / 多少没法确认"。 // 抽成纯函数是为了能离线跑断言,免得再出现"口径不同却下结论"的误报。 function summarizeAsyncRows(rows) { var pending = 0, unknown = 0, done = 0; var list = rows || []; for (var i = 0; i < list.length; i++) { var r = list[i]; if (!r || r.actual === null || r.actual === undefined) { unknown++; continue; } if (r.actual < r.submittedAll) pending += (r.submittedAll - r.actual); else done++; } return { pending: pending, unknown: unknown, done: done, total: list.length }; } function isTooBigError(error) { // 「空间不足」也挂着 errno 12,但它跟"太大"是反的:拆到多小都装不下,拆批纯属白跑。 // 22:43 实机:单个文件夹 367GB,按"太大"去展开会把它逐层拆完、每个子批再被拒一次, // 白白发几十个必败请求。这里直接排除。 if (error && error.__spaceIssue) return false; var m = (error && error.message) ? error.message : String(error || ''); return m.indexOf('TransferLimitExceededException') !== -1 || m.indexOf('TransferTimeoutException') !== -1; } // 字节数转人话,394138697269 这种数字在日志里没法看, // 换成 367.1 GB 用户一眼就懂。保留 1 位小数,整数就省掉 ".0"(2 TB 而不是 2.0 TB)。 function formatSize(bytes) { var n = Number(bytes); if (!isFinite(n) || n <= 0) return '0 B'; var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; var i = 0; while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } if (i === 0) return Math.round(n) + ' B'; var s = n.toFixed(1); if (s.slice(-2) === '.0') s = s.slice(0, -2); return s + ' ' + units[i]; } // 把"为什么算太大"说清楚,用户才看得懂这堆拆分日志在干什么。 // 数字全部来自响应,不猜:target_file_nums / target_file_nums_limit / non_vip_limit。 // 没有上限数字时不许瞎编,旧写法会输出"上限只有 ? 个"这种没信息量的话。 function describeTooBig(error) { if (!error || !error.__needFiles) return ''; if (error.__spaceIssue) return ''; if (!error.__limitFiles) return ''; var s = ' · ' + error.__needFiles + ' 个文件 > 单次上限 ' + error.__limitFiles; return s; } function friendlyErrorMessage(error) { const msg = error && error.message ? error.message : String(error || ''); // 「空间不足」必须排在 CODE_MAP 前面判:它跟"单批文件数超限"共用 errno 12, // 只看 errno 就会把"装不下、拆小也没用"说成"非会员 500 上限、已自动拆小", // 用户会一直等一个永远不会发生的重试。 // 判据用响应里的 show_msg / info[].errno,不猜。 if ((error && error.__spaceIssue) || (error && error.errno === -32) || msg.indexOf('NotEnoughSpaceException') !== -1 || msg.indexOf('剩余空间不足') !== -1 || msg.indexOf('空间不足') !== -1 || msg.indexOf('空间已满') !== -1 || msg.indexOf('容量不足') !== -1) { var sp = []; if (error && error.__needSize) sp.push('本次要转存约 ' + formatSize(error.__needSize)); if (error && error.__quota && error.__quota.total) { sp.push('你的网盘总 ' + formatSize(error.__quota.total) + '、已用 ' + formatSize(error.__quota.used) + '、剩余 ' + formatSize(error.__quota.remaining)); } if (error && error.__spaceItems && error.__spaceItems.length) { sp.push('涉及的是「' + error.__spaceItems.slice(0, 3).join('」「') + '」'); } return '目标网盘剩余空间不足,转存被直接拒绝(errno 12)' + (sp.length ? ':' + sp.join(';') : '') + '。拆小重试没有用(拆到多小都装不下),请先清理网盘空间(例如把这次已转存进去的内容删掉),' + '或换一个空间充足的账号再重试'; } // 再按 errno 给准确含义:分享错误码一个数字对应多种业务, // 只靠关键词猜会把「访问密码错误」说成「网络超时」这种级别的误导 const CODE_MAP = { '-9': '提取码错误,或分享里的文件已被删除', '-8': '目标目录下已存在同名文件', '-7': '文件或目录名错误,或没有访问权限', '-6': '登录态已失效,请重新登录百度网盘', '-11': '父目录不存在,请检查目标路径', '2': '请求参数错误(脚本版本可能已过旧),请把日志发给开发者', '10': '该文件已经在网盘里了', '12': '这批被拒(errno 12):单批文件数超过上限(非会员 500 个文件),脚本会自动拆小重试', '4': '这一批太大,接口瞬时拒绝(errno 4)——脚本会自动拆小重试;若反复出现,说明这个分享单次转存总量超过了你账号的上限', '1504': '这一批太大,服务端瞬时拒绝(errno 1504)——脚本会自动拆小重试', '19': '需要输入验证码,请在浏览器里完成验证后重试', '112': '页面已过期,请刷新分享页后重试', '115': '该文件被禁止分享', '31034': '操作过于频繁,请稍后重试', '200025': '提取码不正确,或分享凭证(sekey/BDCLND)已失效,请刷新分享页后重试', }; if (error && error.errno !== undefined && CODE_MAP[String(error.errno)]) { return CODE_MAP[String(error.errno)] + '(errno ' + error.errno + ')'; } // 空间不足已在函数开头统一处理(它和 errno 12「文件数超限」共用错误码,必须先判) if (msg.toLowerCase().indexOf('verifysms') !== -1 || msg.indexOf('需要验证') !== -1 || msg.toLowerCase().indexOf('verify') !== -1 || msg.toLowerCase().indexOf('rmb') !== -1) { return '触发了安全验证(短信/设备验证),请在浏览器中打开百度网盘完成验证后重试'; } if (msg.indexOf('Share is cancelled') !== -1 || msg.indexOf('分享的文件已被删除') !== -1 || msg.indexOf('分享链接已失效') !== -1 || msg.toLowerCase().indexOf('sharelink is invalid') !== -1 || msg.toLowerCase().indexOf('file is not exist') !== -1 || msg.indexOf('链接已失效') !== -1) { return '分享链接已失效或被取消,请确认链接有效'; } if ((error && error.errno === -6) || msg.indexOf('登录') !== -1 || msg.toLowerCase().indexOf('bdstoken') !== -1) { return '未登录或登录态已过期,请重新登录百度网盘后重试'; } if (msg.toLowerCase().indexOf('no such file or directory') !== -1) { return '源文件不存在或已被删除'; } if (msg.indexOf('TransferLimitExceededException') !== -1) { return '这一批要转存的文件数超过单次上限(非会员为 500 个文件)——脚本会自动拆小重试。转这种大合集本来就会拆很多次,这是账号额度的限制,不是脚本故障'; } if (msg.indexOf('TransferTimeoutException') !== -1) { return '在没有任何「已存在」标记的情况下连续被拒(errno 4/1504,都是秒回)。脚本已原样重试 1 次,并已把它当作「这一批太大」去拆小/展开;若仍看到这句,说明拆到最小还是被拒 —— 请把这批的名字发我。注意:日志里若出现「目标已有 … 文件已转存」,那是成功,不是故障'; } if (msg.indexOf('timeout') !== -1 || msg.indexOf('Network') !== -1) { return '网络超时或接口响应慢,请稍后重试'; } if (msg.indexOf('rate limit') !== -1 || msg.indexOf('限流') !== -1) { return '接口限流,请等待一段时间后再试'; } if (msg.indexOf('SharePageUnavailable') !== -1) { return '提取码本身是对的(已通过接口校验),但百度没返回分享内容。多数是当前账号被限流(短时间请求太多)或该分享被撤销/限制。先停手等 10~20 分钟再试,期间别反复点;急着用就换网络(手机热点)或手机 App 打开'; } if (msg.indexOf('Wrong password') !== -1) { return '提取码错误,请检查后重试'; } if (msg.indexOf('Password not specified') !== -1) { return '该分享需要提取码,请填写后重试'; } if (msg.indexOf('Unable to extract share key') !== -1) { return '无法识别分享链接,请检查链接是否完整'; } if (msg.indexOf('locals.mset') !== -1 || msg.indexOf('parse') !== -1) { return '页面结构可能已更新,请检查脚本是否有新版本'; } // 兜底:把 errno 一并带出来,否则 "过5分钟重试" 这种百度兜底文案完全没法定位 var tail = msg || '未知错误'; if (msg.indexOf('过5分钟重试') !== -1) { return '接口返回参数错误或触发限流(脚本未识别的 errno:' + ((error && error.errno !== undefined) ? error.errno : '未知') + '),请把日志发给开发者'; } if (error && error.errno !== undefined) { tail += '(errno ' + error.errno + ')'; } return tail; } // 增量更新 DOM,别整块重写 innerHTML(日志一多会明显卡) function addLog(message, type) { if (!type) type = 'info'; const time = new Date().toLocaleTimeString(); const logEntry = { time: time, message: message, type: type }; appState.logMessages.unshift(logEntry); // 超限时移除数组末尾元素 while (appState.logMessages.length > MAX_LOG_ENTRIES) { appState.logMessages.pop(); } // 增量 DOM:仅在头部插入新条目 const logContent = document.getElementById('log-content'); if (logContent) { const cls = type === 'error' ? 'log-error' : type === 'success' ? 'log-success' : type === 'warning' ? 'log-warning' : 'log-info'; const div = document.createElement('div'); div.className = 'log-item ' + cls; div.innerHTML = '[' + time + '] ' + escapeHtml(message); logContent.insertBefore(div, logContent.firstChild); // 移除超出上限的尾部 DOM 节点 while (logContent.children.length > MAX_LOG_ENTRIES) { logContent.removeChild(logContent.lastChild); } logContent.scrollTop = 0; } // 更新统计和状态 UI(不重渲染日志) updateStatsUI(); console.log('[转存助手]', message); } function escapeHtml(text) { var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } // 统一的「复制到剪贴板」:避免 fallback 逻辑散落在多处 function copyText(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text); } return new Promise(function (resolve, reject) { try { var textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); var ok = document.execCommand('copy'); document.body.removeChild(textarea); ok ? resolve() : reject(new Error('execCommand copy 返回 false')); } catch (e) { reject(e); } }); } // 版本号只留一个取法。脚本管理器不给 GM_info 时得有个兜底常量, // 否则日志里会显示 "vundefined"。 // FALLBACK_VERSION 没法自动同步,每次发版都要跟 @version 一起改。 // 忘了改不会崩,但会在 GM_info 不可用的环境里报旧版本号,把排查带偏 —— // 这处最容易忘(真忘过,日志里版本号对不上,排查绕了一大圈)。测试里有条断言钉住两者相等。 var FALLBACK_VERSION = '1.1.7'; function getScriptVersion() { try { if (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) { return GM_info.script.version; } } catch (e) { /* 某些管理器禁用了 GM_info,按兜底走 */ } return FALLBACK_VERSION; } function updateStatsUI() { const filesEl = document.getElementById('stat-files'); const dirsEl = document.getElementById('stat-dirs'); const transferredEl = document.getElementById('stat-transferred'); const skippedEl = document.getElementById('stat-skipped'); const batchEl = document.getElementById('stat-batch'); const progressFill = document.getElementById('progress-fill'); const progressPercent = document.getElementById('progress-percent'); const statusBadge = document.getElementById('status-badge'); const statusText = document.getElementById('status-text'); const startBtn = document.getElementById('btn-start'); const stopBtn = document.getElementById('btn-stop'); if (filesEl) filesEl.textContent = appState.totalFileCount || 0; if (dirsEl) dirsEl.textContent = appState.totalDirCount || 0; if (transferredEl) transferredEl.textContent = appState.transferredFiles || 0; if (skippedEl) skippedEl.textContent = appState.skippedCount || 0; if (batchEl) batchEl.textContent = (appState.batchCount || 0); // 跑完了就一定是 100%:被跳过的项(目标已有)不再计入分母, // 否则「全部已存在、一项没转」会显示成 0%,看着像卡住了 const progress = (appState.status === STATE.COMPLETED) ? 100 : (appState.totalFiles > 0 ? (appState.transferredFiles / appState.totalFiles * 100) : 0); if (progressFill) progressFill.style.width = progress.toFixed(1) + '%'; if (progressPercent) progressPercent.textContent = progress.toFixed(1) + '%'; var statusMap = {}; statusMap[STATE.IDLE] = { text: '就绪', cls: 'status-idle' }; statusMap[STATE.RUNNING] = { text: '运行中', cls: 'status-running' }; statusMap[STATE.COMPLETED] = appState.allSkipped ? { text: '⏭ 无需转存(目标已有同名项)', cls: 'status-idle' } : { text: '✅ 已完成', cls: 'status-completed' }; statusMap[STATE.ERROR] = { text: '❌ 错误', cls: 'status-error' }; statusMap[STATE.STOPPED] = { text: '⏹ 已停止', cls: 'status-idle' }; var info = statusMap[appState.status] || statusMap[STATE.IDLE]; if (statusBadge) { statusBadge.textContent = info.text; statusBadge.className = 'status-badge ' + info.cls; } if (statusText) { statusText.textContent = appState.errorMessage || ''; } if (startBtn && stopBtn) { if (appState.status === STATE.IDLE || appState.status === STATE.COMPLETED || appState.status === STATE.ERROR || appState.status === STATE.STOPPED) { startBtn.style.display = 'inline-block'; stopBtn.style.display = 'none'; startBtn.textContent = appState.status === STATE.COMPLETED ? '🔄 重新开始' : '▶ 开始转存'; startBtn.disabled = false; } else if (appState.status === STATE.RUNNING) { startBtn.style.display = 'none'; stopBtn.style.display = 'inline-block'; stopBtn.disabled = false; } } } // 全量 UI 更新(仅在清空日志等需要重绘时使用) function updateUI() { updateStatsUI(); const logContent = document.getElementById('log-content'); if (logContent) { // 先清空再重建(仅在需要时调用) var html = ''; for (var i = 0; i < appState.logMessages.length; i++) { var log = appState.logMessages[i]; var cls = log.type === 'error' ? 'log-error' : log.type === 'success' ? 'log-success' : log.type === 'warning' ? 'log-warning' : 'log-info'; html += '
[' + log.time + '] ' + escapeHtml(log.message) + '
'; } logContent.innerHTML = html; logContent.scrollTop = 0; } } async function startTransfer(url, pwd, targetPath, subPath) { var subPaths = normalizeSubPaths(subPath); if (appState.status === STATE.RUNNING) return; // 跨零点保护:面板可能从前一天一直开着,输入框里还是脚本自动填的旧日期。 // 只在「值未被手改过」时换成今天,用户手动填的路径一律尊重。 if (__autoPathAtLoad && targetPath === __autoPathAtLoad) { var __freshPath = getDefaultPath(); if (__freshPath !== targetPath) { targetPath = __freshPath; var __pathEl = document.getElementById('input-path'); if (__pathEl) __pathEl.value = __freshPath; __autoPathAtLoad = __freshPath; addLog('📅 已跨天,转存目录更新为:' + __freshPath, 'info'); } } if (!targetPath || targetPath.trim() === '') { targetPath = getDefaultPath(); document.getElementById('input-path').value = targetPath; addLog('📁 未指定路径,自动创建:' + targetPath, 'info'); } const normalizedPath = normalizeTargetPath(targetPath); if (normalizedPath !== targetPath) { targetPath = normalizedPath; document.getElementById('input-path').value = targetPath; addLog('📁 目标路径已自动修正为:' + targetPath, 'info'); } saveMemory(pwd, targetPath); appState.status = STATE.RUNNING; appState.allSkipped = false; appState.errorMessage = ''; appState.transferredFiles = 0; appState.totalFiles = 0; appState.totalFileCount = 0; appState.totalDirCount = 0; appState.topLevelCount = 0; appState.asyncItemCount = 0; appState.asyncTaskCount = 0; appState.batchCount = 0; appState.skippedCount = 0; updateUI(); try { transferInstance = new BaiduTransfer(targetPath || ''); transferInstance.maxRetries = 5; transferInstance.cancel = false; transferInstance.onLog = function (msg) { addLog(msg, 'info'); }; transferInstance.onProgress = function (info) { appState.transferredFiles = info.transferred; appState.totalFiles = info.total; appState.totalFileCount = info.files; appState.totalDirCount = info.dirs; appState.batchCount = info.batches; if (info.skipped !== undefined) appState.skippedCount = info.skipped; updateStatsUI(); }; // URL 可能带 ?pwd=xxxx,日志会被「复制日志」整段外发(发给别人排错很常见), // 下一行都把提取码打码了,这里必须一起打,否则等于没脱敏 addLog('开始转存:' + String(url).replace(/([?&]pwd=)[^&#]*/gi, '$1****'), 'info'); if (pwd) addLog('提取码:****', 'info'); // 日志中不显示明文密码 addLog('目标路径:' + targetPath, 'info'); if (subPaths.length) addLog('📂 只转 ' + subPaths.length + ' 项:' + subPaths.join('、') + '(分享里其余内容不转)', 'warning'); await transferInstance.transferFinal(url, pwd, subPaths); if (transferInstance.cancel) { appState.status = STATE.STOPPED; addLog('⏹ 转存已停止', 'warning'); } else { appState.totalFileCount = transferInstance.totalFileCount || 0; appState.totalDirCount = transferInstance.totalDirCount || 0; appState.batchCount = transferInstance.batchCount || 0; appState.transferredFiles = transferInstance.transferredFiles || 0; appState.skippedCount = transferInstance.skippedCount || 0; appState.asyncItemCount = transferInstance.asyncItemCount || 0; appState.asyncTaskCount = transferInstance.asyncTaskCount || 0; appState.topLevelCount = transferInstance.topLevelCount || 0; appState.totalFiles = appState.transferredFiles + appState.skippedCount; // 一项没转、全是「已存在」跳过:不能报「已完成」。 // 目标里可能只是同名但不全(例如上次只用子目录模式建了个壳), // 报成功会让用户误以为整个分享都已落地,而实际一个文件也没动。 appState.allSkipped = (appState.transferredFiles === 0 && appState.skippedCount > 0); appState.status = STATE.COMPLETED; var asyncN = appState.asyncItemCount || 0; // 收尾复核:只要出现过"受理了但没当场完成",就不能凭"受理"宣布成功。 // 比对口径必须一致:totalDirCount/totalFileCount 是跨层级累加的(每展开一层 +=), // 而目标顶层永远只有那 1 个文件夹,拿 117 去比 1 必然报"数量偏少"。 // 正确做法:查异步批次真正提交到的那些目录,拿"该目录现有项数"比 // "本次提交到该目录的项数",同一层级、同一口径。 if (asyncN > 0) { addLog('⏳ 后台异步受理 ' + appState.asyncTaskCount + ' 批 / ' + asyncN + ' 项' + ' · 提交时接口返回「已受理」,并未当场完成' + ' · 正在复核这些目录的实际项数…', 'info'); var rows = await transferInstance.verifyAsyncPaths(); if (rows && rows.length) { var sum = summarizeAsyncRows(rows); for (var i = 0; i < rows.length; i++) { var r = rows[i]; if (r.actual === null || r.actual === undefined) { addLog('⚠️ ' + r.path + ' 列不出来(无法确认该目录现有多少项)', 'warning'); continue; } addLog('🔍 ' + r.path + ' —— 本次提交 ' + r.submittedAll + ' 项(其中 ' + r.submittedAsync + ' 项走后台异步),现在实际有 ' + r.actual + ' 项', r.actual >= r.submittedAll ? 'success' : 'info'); } if (sum.pending > 0) { addLog('⏳ 后台还在跑(这些目录合计约差 ' + sum.pending + ' 项)。异步任务不受本页控制,' + '等 1~2 分钟后**再跑一次同样的链接**即可:已存在的会自动跳过,只补缺的,不会重复。', 'warning'); } else if (sum.unknown === 0) { addLog('✅ 复核:异步批次提交到的目录,项数都已达到本次提交量。', 'success'); } else { addLog('⚠️ 有 ' + sum.unknown + ' 个目录没能列出来,**无法确认**是否齐全。' + '可稍后再跑一次同样的链接:已存在的会自动跳过,只补缺的。', 'warning'); } } else { addLog('⚠️ 复核没能列到任何异步目录,**无法确认**后台任务是否完成。' + '建议等 1~2 分钟后**再跑一次同样的链接**:已存在的会自动跳过,只补缺的。', 'warning'); } } // 收尾要分开说,不能用同一句「全部转存完成」糊过去: // 1) 全都跳过了 → 是"无需操作",说"完成"会让人以为脚本刚干了活 // 2) 一半转一半跳 → 如实报两个数 // 3) 有后台异步 → 只能说"流程走完",完成与否以上面的复核为准 // 4) 正常同步转完 → 才能说"全部转存完成" if (appState.transferredFiles === 0 && appState.skippedCount > 0) { addLog('✅ 无需重复转存 · 跳过 ' + appState.skippedCount + ' 项 · 目标目录已有全部内容:' + targetPath, 'success'); GM_notification({ title: '无需重复转存', text: '目标目录已有全部内容(跳过 ' + appState.skippedCount + ' 项)', timeout: 5000, }); } else if (appState.skippedCount > 0) { addLog('🎉 转存完成 · 新转存 ' + appState.transferredFiles + ' 项 · 跳过已存在 ' + appState.skippedCount + ' 项 · 分享顶层 ' + (appState.topLevelCount || 0) + ' 项(展开后 ' + (appState.totalFileCount || 0) + ' 个散文件 + ' + (appState.totalDirCount || 0) + ' 个文件夹)· ' + (appState.batchCount || 0) + ' 次提交', 'success'); GM_notification({ title: '转存完成', text: '新转存 ' + appState.transferredFiles + ' 项,跳过已存在 ' + appState.skippedCount + ' 项', timeout: 5000, }); } else if (asyncN > 0) { addLog('🎉 流程走完 · 提交 ' + (appState.totalFileCount || 0) + ' 个散文件 + ' + (appState.totalDirCount || 0) + ' 个文件夹 · ' + (appState.batchCount || 0) + ' 次提交 · 其中 ' + asyncN + ' 项为后台异步,完成与否以复核为准', 'success'); GM_notification({ title: '转存流程走完', text: '有 ' + asyncN + ' 项走后台异步处理,请留意日志里的复核结果', timeout: 5000, }); } else { addLog('🎉 全部转存完成 · ' + (appState.totalFileCount || 0) + ' 个散文件 + ' + (appState.totalDirCount || 0) + ' 个文件夹 · 分享顶层 ' + (appState.topLevelCount || 0) + ' 项 · ' + (appState.batchCount || 0) + ' 次提交', 'success'); GM_notification({ title: '转存完成', text: '成功转存 ' + (appState.totalFileCount || 0) + ' 个散文件、' + (appState.totalDirCount || 0) + ' 个文件夹', timeout: 5000, }); } playBeep(); } updateStatsUI(); } catch (error) { if (error.message === '操作已取消') { appState.status = STATE.STOPPED; addLog('⏹ 已安全停止', 'warning'); } else { appState.status = STATE.ERROR; var message = friendlyErrorMessage(error); appState.errorMessage = message; addLog('❌ 转存失败:' + message, 'error'); playBeep(); } updateStatsUI(); } finally { transferInstance = null; if (appState.status !== STATE.COMPLETED && appState.status !== STATE.ERROR && appState.status !== STATE.STOPPED) { appState.status = STATE.IDLE; updateStatsUI(); } } } function stopTransfer() { if (transferInstance) { transferInstance.cancel = true; addLog('正在停止...', 'warning'); } } function createPanel() { const panel = document.createElement('div'); panel.id = 'transfer-helper-panel'; panel.innerHTML = '' + '
' + ' 📦 转存助手 by Qi' + ' ' + '
' + '
' + ' ' + '
' + ' ' + '
' + ' ' + ' ' + '
' + '
' + '
' + '
' + ' ' + ' ' + ' ' + '
' + ' ' + '
' + '
' + '
📄 散文件0
' + '
📁 文件夹0
' + '
✅ 已转存0
' + '
⏭️ 已存在0
' + '
📦 批次0
' + '
' + '
注:文件夹内的文件随文件夹整体转存,不单独计入"散文件";「已存在」= 目标目录里已有同名内容、本轮跳过(通常是上一次已经转存成功过)
' + '
' + '
总进度0%
' + '
' + '
' + '
' + ' 就绪' + ' ' + '
' + '
' + ' ' + ' ' + '
' + '
' + '
' + ' 运行日志' + '
' + ' ' + ' ' + '
' + '
' + '
' + '
' + '
'; document.body.appendChild(panel); // 自动识别链接时同样先解码再去 # var urlInput = document.getElementById('input-url'); var pwdInput = document.getElementById('input-pwd'); var pathInput = document.getElementById('input-path'); var currentUrl; try { currentUrl = decodeURIComponent(window.location.href).split('#')[0]; } catch (e) { currentUrl = window.location.href.split('#')[0]; } if (currentUrl.indexOf('/s/') !== -1) { urlInput.value = currentUrl; var pwdMatch = currentUrl.match(/[?&]pwd=([^&]+)/); if (pwdMatch) pwdInput.value = pwdMatch[1]; } var memory = loadMemory(); if (memory.pwd && !pwdInput.value) { pwdInput.value = memory.pwd; } if (memory.path && !isAutoDatePath(memory.path)) { pathInput.value = memory.path; // 用户自定义路径,不参与日期切换 } else { pathInput.placeholder = '留空自动建: ' + getDefaultPath(); if (isAutoDatePath(memory.path)) { pathInput.value = getDefaultPath(); __autoPathAtLoad = pathInput.value; } } setupPanelEvents(panel); setupPanelDrag(panel); updateUI(); } function setupPanelEvents(panel) { var startBtn = document.getElementById('btn-start'); var stopBtn = document.getElementById('btn-stop'); var clearBtn = document.getElementById('btn-clear-log'); var copyBtn = document.getElementById('btn-copy-log'); var toggleBtn = panel.querySelector('.panel-toggle'); toggleBtn.addEventListener('click', function () { var body = panel.querySelector('.panel-body'); body.style.display = body.style.display === 'none' ? 'block' : 'none'; toggleBtn.textContent = body.style.display === 'none' ? '+' : '—'; }); startBtn.addEventListener('click', async function () { var url = document.getElementById('input-url').value.trim(); var pwd = document.getElementById('input-pwd').value.trim(); var path = document.getElementById('input-path').value.trim(); var subEl = document.getElementById('input-subdir'); var subPath = subEl ? subEl.value.trim() : ''; if (!url) { alert('请输入分享链接'); return; } startBtn.disabled = true; startBtn.textContent = '⏳ 处理中...'; try { await startTransfer(url, pwd, path, subPath); } finally { startBtn.disabled = false; } }); var listDirBtn = document.getElementById('btn-list-dir'); var subdirChips = document.getElementById('subdir-chips'); var clearSubBtn = document.getElementById('btn-clear-subdir'); if (listDirBtn) { listDirBtn.addEventListener('click', function () { openDirPicker(); }); } if (clearSubBtn) { clearSubBtn.addEventListener('click', async function () { dirPicker.path = []; dirPicker.selected = []; syncSubdirInput(); if (subdirChips) { // 面板开着就重绘一遍,把「已勾选 N 项」那行擦掉 if (subdirChips.style.display !== 'none' && dirPicker.inst) { try { await renderDirPicker(); } catch (e) { subdirChips.style.display = 'none'; } } else { subdirChips.style.display = 'none'; } } addLog('📂 已改为转整个分享(不再限定子目录)', 'info'); }); } if (subdirChips) { subdirChips.addEventListener('click', async function (ev) { var t = (ev.target && ev.target.closest) ? ev.target.closest('[data-dp]') : null; if (!t) return; var kind = t.getAttribute('data-dp'); var rawName = t.getAttribute('data-name') || ''; var name = rawName ? decodeURIComponent(rawName) : ''; var rawPath = t.getAttribute('data-path') || ''; var p = rawPath ? decodeURIComponent(rawPath) : ''; if (kind === 'up') { dirPicker.path.pop(); await renderDirPicker(); } else if (kind === 'enter') { dirPicker.path.push(name); await renderDirPicker(); } else if (kind === 'toggle') { // 勾选 = 只转这一项;点方框不进入,避免两只手抢一个点击 toggleSelected(p); await renderDirPicker(); } else if (kind === 'untoggle') { var idx = dirPicker.selected.indexOf(p); if (idx >= 0) dirPicker.selected.splice(idx, 1); await renderDirPicker(); } else if (kind === 'clearall') { dirPicker.selected = []; await renderDirPicker(); addLog('📂 已清空勾选 = 转整个分享', 'info'); } else if (kind === 'done') { syncSubdirInput(); subdirChips.style.display = 'none'; addLog(dirPicker.selected.length ? '📂 已勾选 ' + dirPicker.selected.length + ' 项:' + dirPicker.selected.join('、') : '📂 未勾选任何项 = 转整个分享', 'info'); } }); } stopBtn.addEventListener('click', function () { if (confirm('确定要停止转存吗?')) { stopTransfer(); } }); clearBtn.addEventListener('click', function () { appState.logMessages = []; // 清空时需要全量重绘日志区域 updateUI(); }); copyBtn.addEventListener('click', async function () { var text = appState.logMessages .slice() .reverse() .map(function (log) { return '[' + log.time + '] ' + log.message; }) .join('\n'); try { await copyText(text); addLog('📋 日志已复制,可直接粘贴反馈', 'success'); } catch (e) { addLog('⚠️ 日志复制失败,请手动选择日志内容复制', 'warning'); } }); } // ================================================================ // 【新增】分享目录浏览器 —— 只转指定子目录 // ================================================================ var dirPicker = { inst: null, url: '', pwd: '', path: [], selected: [] }; // 输入框里的「多路径」统一解析:支持 | 或换行分隔,去空、去重、去掉被上层包含的 function normalizeSubPaths(subPath) { var raw = Array.isArray(subPath) ? subPath : String(subPath || '').split(/[|\n]/); var out = []; for (var i = 0; i < raw.length; i++) { var p = String(raw[i] || '').trim().replace(/^\/+|\/+$/g, ''); if (!p) continue; if (out.indexOf(p) >= 0) continue; out.push(p); } // 勾了 A,又勾了 A/B —— A 已经包含 B,只留 A,免得重复提交 return out.filter(function (p) { for (var j = 0; j < out.length; j++) { if (out[j] !== p && p.indexOf(out[j] + '/') === 0) return false; } return true; }); } // 勾选/取消一项。已勾上层时再勾下层无效(上层已包含它) function toggleSelected(p) { if (!p) return; var sel = dirPicker.selected; var i = sel.indexOf(p); if (i >= 0) { sel.splice(i, 1); return; } for (var k = 0; k < sel.length; k++) { if (p.indexOf(sel[k] + '/') === 0) return; } dirPicker.selected = sel.filter(function (x) { return x.indexOf(p + '/') !== 0; }); dirPicker.selected.push(p); } async function openDirPicker() { var box = document.getElementById('subdir-chips'); if (!box) return; var urlEl = document.getElementById('input-url'); var pwdEl = document.getElementById('input-pwd'); var url = urlEl ? urlEl.value.trim() : ''; var pwd = pwdEl ? pwdEl.value.trim() : ''; box.style.display = 'block'; if (!url) { box.innerHTML = '请先填写分享链接'; return; } box.innerHTML = '正在读取分享目录…'; try { if (!dirPicker.inst || dirPicker.url !== url || dirPicker.pwd !== pwd) { var inst = new BaiduTransfer(''); inst.maxRetries = 5; var key = inst.extractShareKey(url); if (!key) throw new Error('分享链接里没有识别出 share key'); await inst.initShareData(key, pwd); dirPicker.inst = inst; dirPicker.url = url; dirPicker.pwd = pwd; dirPicker.path = []; } // 以输入框为准恢复已勾列表(用户可能手改过),浏览位置不清(下次打开还在原地) var curSub = ((document.getElementById('input-subdir') || {}).value || '').trim(); dirPicker.selected = normalizeSubPaths(curSub); try { await renderDirPicker(); } catch (e1) { // 上次浏览的位置可能因换分享而失效:退回根再试一次 dirPicker.path = []; await renderDirPicker(); } } catch (e) { box.innerHTML = '读取失败:' + escapeHtml(friendlyErrorMessage(e)) + ''; } } // 把「已勾选项」同步到输入框。 // 语义:一个都不勾 = 转整个分享;勾了 N 项 = 只转这 N 项。 function syncSubdirInput() { var el = document.getElementById('input-subdir'); var cb = document.getElementById('btn-clear-subdir'); if (!el) return; var list = dirPicker.selected || []; el.value = list.join(' | '); el.dispatchEvent(new Event('input', { bubbles: true })); if (list.length) { el.style.borderColor = '#0a7cff'; el.style.background = '#f2f8ff'; el.title = '已勾选 ' + list.length + ' 项,只转这些(点「✖ 转全部」改为整个分享)'; } else { el.style.borderColor = '#ccc'; el.style.background = ''; el.title = ''; } if (cb) cb.style.display = list.length ? 'inline-block' : 'none'; } async function renderDirPicker() { var box = document.getElementById('subdir-chips'); var inst = dirPicker.inst; if (!box || !inst) return; var rel = '/' + dirPicker.path.join('/'); var items = await inst.listShareDir(inst.userId, inst.shareId, dirPicker.path.length ? rel : '/'); items.sort(function (a, b) { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; return String(a.name).localeCompare(String(b.name)); }); syncSubdirInput(); var sel = dirPicker.selected || []; var curPath = dirPicker.path.length ? '/' + dirPicker.path.join('/') : '根目录'; // 整块改成分区结构:① 面包屑 ② 已勾选(只在有勾选时出现)③ 目录列表 ④ 底部操作栏。 // 之前所有项都在一个 flex-wrap 里,↳ 按钮和勾选框、文件名混排,长名字换行后完全看不出层级。 var html = '
'; // ① 面包屑:当前位置固定一行,过长自动省略,不再把「上一层」挤到下一行 html += '
' + '位置' + '' + escapeHtml(curPath) + '' + (dirPicker.path.length ? '← 上一层' : '') + '
'; // ② 已勾选区:独立一块,只放已勾项,和下面的目录项彻底分开 if (sel.length) { html += '
' + '
' + '✅ 已勾选 ' + sel.length + ' 项只转这些,其余不动' + '清空' + '
' + '
'; // 已勾路径就是当前这一层开头的,只显示相对名,名字短不占行;跨目录的才显示完整路径 var prefix = dirPicker.path.length ? dirPicker.path.join('/') + '/' : ''; for (var s = 0; s < sel.length; s++) { var showName = (prefix && sel[s].indexOf(prefix) === 0) ? sel[s].slice(prefix.length) : '/' + sel[s]; html += '✕ ' + escapeHtml(showName) + ''; } html += '
'; } else { html += '
未勾选任何项 → 转整个分享
'; } // ③ 目录列表:一行一项,左侧勾选框、中间文件名(超长省略)、右侧固定「›」进入 html += '
'; if (!items.length) { html += '
这个目录是空的
'; } for (var i = 0; i < items.length; i++) { var it = items[i]; var safeName = encodeURIComponent(it.name); var full = dirPicker.path.concat([it.name]).join('/'); var checked = sel.indexOf(full) >= 0; var covered = !checked && sel.some(function (x) { return full.indexOf(x + '/') === 0; }); html += '
' + '' + (checked ? '☑' : '☐') + '' + '' + (it.isDirectory ? '📁' : '📄') + '' + '' + escapeHtml(it.name) + '' + (it.isDirectory ? '›' : '') + '
'; } html += '
'; // ④ 底部操作栏:固定一行,说明文字靠右小字弱化 html += '
' + '✓ 收起' + '✖ 转全部' + '点行勾选 · › 进下一层' + '
'; html += '
'; box.innerHTML = html; } function setupPanelDrag(panel) { var header = panel.querySelector('.panel-header'); var isDragging = false, startX, startY, startLeft, startTop; function onMouseMove(e) { if (!isDragging) return; panel.style.left = Math.max(0, startLeft + e.clientX - startX) + 'px'; panel.style.top = Math.max(0, startTop + e.clientY - startY) + 'px'; panel.style.right = 'auto'; } function onMouseUp() { isDragging = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); window.removeEventListener('blur', onBlur); } function onBlur() { if (isDragging) onMouseUp(); } header.addEventListener('mousedown', function (e) { if (e.target.classList.contains('panel-toggle')) return; isDragging = true; startX = e.clientX; startY = e.clientY; startLeft = panel.offsetLeft; startTop = panel.offsetTop; e.preventDefault(); // 每次拖拽时绑定独立的 mousemove/mouseup,避免多面板冲突 document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); // 防止鼠标离开窗口后事件未清理 window.addEventListener('blur', onBlur); }); } function addStyles() { GM_addStyle('' + '#transfer-helper-panel {' + ' position: fixed;' + ' top: 20px;' + ' right: 20px;' + ' width: 420px;' + ' max-height: 88vh;' + ' background: #fff;' + ' border-radius: 10px;' + ' box-shadow: 0 6px 28px rgba(0,0,0,0.25);' + ' z-index: 99999;' + ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;' + ' font-size: 14px;' + ' color: #333;' + ' overflow: hidden;' + ' border: 1px solid rgba(0,0,0,0.08);' + '}' + '#transfer-helper-panel .panel-header {' + ' display: flex;' + ' justify-content: space-between;' + ' align-items: center;' + ' padding: 12px 16px;' + ' background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);' + ' color: white;' + ' cursor: move;' + ' user-select: none;' + ' font-size: 16px;' + '}' + '#transfer-helper-panel .panel-title { font-weight: 600; letter-spacing: 0.3px; }' + '#transfer-helper-panel .panel-toggle {' + ' background: none; border: none; color: white; font-size: 20px; cursor: pointer;' + ' padding: 0 5px; line-height: 1; opacity: 0.8;' + '}' + '#transfer-helper-panel .panel-toggle:hover { opacity: 1; }' + '#transfer-helper-panel .panel-body {' + ' padding: 14px;' + ' overflow-y: auto;' + ' max-height: calc(88vh - 48px);' + '}' + '#transfer-helper-panel .input-section { margin-bottom: 12px; }' + '#transfer-helper-panel .feedback-tip {' + ' margin-bottom: 10px;' + ' padding: 7px 10px;' + ' background: #f6f4ff;' + ' border: 1px solid #e5ddff;' + ' border-radius: 6px;' + ' color: #6b52c8;' + ' font-size: 13px;' + ' font-weight: 500;' + ' text-align: center;' + '}' + '#transfer-helper-panel .input-section input { font-size: 14px !important; }' + /* 占位提示也常被当作说明看,默认灰太淡 */ '#transfer-helper-panel .input-section input::placeholder { color: #8a8a8a !important; }' + '#transfer-helper-panel .stats-row {' + ' display: grid;' + ' grid-template-columns: repeat(5, 1fr);' + ' gap: 6px;' + ' margin-bottom: 12px;' + ' background: #f8f9fa;' + ' border-radius: 6px;' + ' padding: 10px 6px;' + '}' + '#transfer-helper-panel .stat-item { text-align: center; }' + '#transfer-helper-panel .stat-label {' + ' display: block;' + ' color: #666;' + ' font-size: 12px;' + ' margin-bottom: 2px;' + ' white-space: nowrap;' + '}' + '#transfer-helper-panel .stat-value {' + ' display: block;' + ' font-size: 20px;' + ' font-weight: 700;' + ' color: #333;' + '}' + '#transfer-helper-panel .progress-section { margin-bottom: 8px; }' + '#transfer-helper-panel .progress-header {' + ' display: flex; justify-content: space-between; margin-bottom: 4px;' + ' font-size: 13px; color: #333; font-weight: 500;' + '}' + '#transfer-helper-panel .progress-bar {' + ' height: 10px;' + ' background: #e8e8e8;' + ' border-radius: 5px;' + ' overflow: hidden;' + '}' + '#transfer-helper-panel .progress-fill {' + ' height: 100%;' + ' background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);' + ' border-radius: 5px;' + ' transition: width 0.3s ease;' + ' width: 0%;' + '}' + '#transfer-helper-panel .status-section {' + ' display: flex; align-items: center; gap: 8px; margin-bottom: 10px;' + '}' + '#transfer-helper-panel .status-badge {' + ' padding: 4px 14px;' + ' border-radius: 14px;' + ' font-size: 13px;' + ' font-weight: 500;' + ' white-space: nowrap;' + '}' + '#transfer-helper-panel .status-badge.status-idle { background: #e8e8e8; color: #444; }' + '#transfer-helper-panel .status-badge.status-running { background: #e6f7ff; color: #1890ff; }' + '#transfer-helper-panel .status-badge.status-completed { background: #f6ffed; color: #52c41a; }' + '#transfer-helper-panel .status-badge.status-error { background: #fff2f0; color: #ff4d4f; }' + '#transfer-helper-panel .status-text {' + ' font-size: 13px;' + ' color: #ff4d4f;' + ' flex: 1;' + ' overflow: hidden;' + ' text-overflow: ellipsis;' + ' white-space: nowrap;' + '}' + '#transfer-helper-panel .control-buttons {' + ' display: flex; gap: 8px; margin-bottom: 10px;' + '}' + '#transfer-helper-panel .btn {' + ' flex: 1; padding: 10px 16px; border: none; border-radius: 6px;' + ' cursor: pointer; font-size: 15px; font-weight: 600;' + ' transition: all 0.2s;' + '}' + '#transfer-helper-panel .btn:hover { opacity: 0.85; transform: translateY(-1px); }' + '#transfer-helper-panel .btn:active { transform: scale(0.97); }' + '#transfer-helper-panel .btn-primary {' + ' background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);' + ' color: white;' + '}' + '#transfer-helper-panel .btn-danger {' + ' background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);' + ' color: white;' + '}' + '#transfer-helper-panel .log-section { margin-top: 10px; }' + '#transfer-helper-panel .log-header {' + ' display: flex; justify-content: space-between; align-items: center;' + ' margin-bottom: 6px; font-size: 13px; color: #333; font-weight: 500;' + '}' + '#transfer-helper-panel .log-actions {' + ' display: flex;' + ' gap: 10px;' + ' align-items: center;' + '}' + '#transfer-helper-panel .btn-clear,' + '#transfer-helper-panel .btn-copy-log {' + ' background: none; border: none; color: #888; cursor: pointer; font-size: 13px; padding: 0;' + '}' + '#transfer-helper-panel .btn-clear:hover,' + '#transfer-helper-panel .btn-copy-log:hover { color: #333; }' + /* 列目录 / 转全部是真功能按钮,不跟着日志操作一起发灰 */ '#transfer-helper-panel #btn-list-dir,' + '#transfer-helper-panel #btn-clear-subdir { color: #444; font-weight: 500; }' + '#transfer-helper-panel #btn-list-dir:hover,' + '#transfer-helper-panel #btn-clear-subdir:hover { color: #0a7cff; }' + '#transfer-helper-panel .log-content {' + ' height: 220px;' + ' overflow-y: auto;' + ' background: #fafafa;' + ' border-radius: 6px;' + ' padding: 8px 10px;' + ' font-size: 13px;' + ' font-family: Consolas, Monaco, monospace;' + ' border: 1px solid #eee;' + '}' + '#transfer-helper-panel .log-item { margin-bottom: 4px; line-height: 1.6; word-break: break-all; }' + '#transfer-helper-panel .log-time { color: #777; margin-right: 8px; font-weight: 600; }' + '#transfer-helper-panel .log-info { color: #333; }' + '#transfer-helper-panel .log-success { color: #52c41a; }' + '#transfer-helper-panel .log-warning { color: #fa8c16; }' + '#transfer-helper-panel .log-error { color: #ff4d4f; }' + '#transfer-helper-panel .log-content::-webkit-scrollbar { width: 8px; }' + '#transfer-helper-panel .log-content::-webkit-scrollbar-track { background: #f0f0f0; border-radius: 4px; }' + '#transfer-helper-panel .log-content::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }' + '#transfer-helper-panel .log-content::-webkit-scrollbar-thumb:hover { background: #aaa; }' + '#transfer-helper-panel ::-webkit-scrollbar { width: 6px; }' + '#transfer-helper-panel ::-webkit-scrollbar-track { background: #f5f5f5; border-radius: 3px; }' + '#transfer-helper-panel ::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }' + '#transfer-helper-panel ::-webkit-scrollbar-thumb:hover { background: #ccc; }' + '#transfer-helper-panel .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }' + /* ===== 分享目录选择器(列目录):分区列表布局 ===== */ '#subdir-chips .bdp-wrap { display: block; border: 1px solid #e3e8ee; border-radius: 8px; overflow: hidden; background: #fff; }' + '#subdir-chips .bdp-crumb { display: flex; align-items: center; gap: 6px; padding: 7px 10px; background: #f6f8fa; border-bottom: 1px solid #e3e8ee; }' + '#subdir-chips .bdp-crumb-tag { flex: none; color: #8b95a1; }' + '#subdir-chips .bdp-crumb-path { flex: 1; min-width: 0; color: #333; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }' + '#subdir-chips .bdp-crumb-root { flex: none; color: #b0b8c1; font-size: 11px; }' + '#subdir-chips .bdp-btn { flex: none; padding: 2px 8px; border: 1px solid #d5dde5; border-radius: 6px; background: #fff; color: #555; cursor: pointer; line-height: 17px; }' + '#subdir-chips .bdp-btn:hover { background: #f2f8ff; border-color: #0a7cff; color: #0a7cff; }' + '#subdir-chips .bdp-note { padding: 6px 10px; background: #fafbfc; border-bottom: 1px solid #eef1f4; color: #8b95a1; }' + '#subdir-chips .bdp-sel { padding: 8px 10px; background: #f2f8ff; border-bottom: 1px solid #d8e8fb; }' + '#subdir-chips .bdp-sel-head { display: flex; align-items: center; justify-content: space-between; color: #0a7cff; font-weight: 600; margin-bottom: 6px; }' + '#subdir-chips .bdp-sel-sub { color: #7a94ad; font-weight: 400; margin-left: 6px; }' + '#subdir-chips .bdp-sel-clear { color: #7a94ad; font-weight: 400; cursor: pointer; }' + '#subdir-chips .bdp-sel-clear:hover { color: #ff4d4f; }' + '#subdir-chips .bdp-sel-list { display: flex; flex-wrap: wrap; gap: 5px; max-height: 62px; overflow-y: auto; }' + '#subdir-chips .bdp-sel-item { display: inline-block; max-width: 100%; padding: 2px 8px; border: 1px solid #b9d9fb; border-radius: 11px; background: #fff; color: #0a7cff; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }' + '#subdir-chips .bdp-sel-item:hover { background: #e8f4ff; border-color: #0a7cff; }' + '#subdir-chips .bdp-list { max-height: 250px; overflow-y: auto; overflow-x: hidden; }' + '#subdir-chips .bdp-row { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border-bottom: 1px solid #f2f4f6; cursor: pointer; color: #333; }' + '#subdir-chips .bdp-row:last-child { border-bottom: none; }' + '#subdir-chips .bdp-row:hover { background: #f7f9fb; }' + '#subdir-chips .bdp-row.is-on { background: #f0f7ff; color: #0a7cff; font-weight: 600; }' + '#subdir-chips .bdp-row.is-on:hover { background: #e4f0ff; }' + '#subdir-chips .bdp-row.is-off { opacity: .45; }' + '#subdir-chips .bdp-box { flex: none; width: 13px; text-align: center; color: #9aa5b1; font-size: 13px; }' + '#subdir-chips .bdp-row.is-on .bdp-box { color: #0a7cff; }' + '#subdir-chips .bdp-ico { flex: none; width: 15px; text-align: center; }' + '#subdir-chips .bdp-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }' + '#subdir-chips .bdp-enter { flex: none; padding: 0 7px; border-radius: 9px; background: #e2edfa; color: #2f6fae; cursor: pointer; line-height: 18px; font-size: 15px; font-weight: 700; }' + '#subdir-chips .bdp-row:hover .bdp-enter { background: #dcecff; color: #0a7cff; }' + '#subdir-chips .bdp-enter:hover { background: #0a7cff; color: #fff; }' + '#subdir-chips .bdp-foot { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: #fafbfc; border-top: 1px solid #e3e8ee; }' + '#subdir-chips .bdp-primary { flex: none; padding: 3px 11px; border: 1px solid #0a7cff; border-radius: 6px; background: #0a7cff; color: #fff; cursor: pointer; }' + '#subdir-chips .bdp-primary:hover { background: #0a6fdd; border-color: #0a6fdd; }' + '#subdir-chips .bdp-hint { margin-left: auto; color: #8b95a1; font-size: 11px; font-weight: 600; }' + '#subdir-chips .bdp-empty { padding: 16px 10px; text-align: center; color: #b0b8c1; }' + ''); } // ================================================================ // 0.5 分享页凭证预修复(BDCLND 串票 / 双票) // ================================================================ // 实测 2026-11-02,三次独立分享全部复现: // /share/verify 返回 errno 0 并给出本次 randsk,但 document.cookie 里的 BDCLND **不变** // (百度不会替你刷新),而且会出现两条同名 BDCLND(host-only 与 .baidu.com 各一条)。 // 服务端取到的是旧票 → 所有 /share/list 一律 errno -9 → 前端一直转「提取中…」。 // 这里在页面早期核一次:list 能通就什么都不做,只有 -9 才 verify + 把 cookie 收敛成单条。 function extractShareCtx() { try { var u = new URL(window.location.href); if (/\/share\/init/.test(u.pathname)) { var s = u.searchParams.get('surl') || ''; return { key: s.replace(/^1(?=[A-Za-z])/, ''), pwd: u.searchParams.get('pwd') || '', onInit: true }; } var m = u.pathname.match(/^\/s\/1?([A-Za-z0-9_-]+)/); if (m) return { key: m[1], pwd: u.searchParams.get('pwd') || '', onInit: false }; } catch (e) { } return null; } function readBdclndAll() { try { return document.cookie.split(';').map(function (s) { return s.trim(); }) .filter(function (s) { return s.indexOf('BDCLND=') === 0; }) .map(function (s) { return s.slice(7); }); } catch (e) { return []; } } function decodeSafe(v) { try { return decodeURIComponent(v); } catch (e) { return v; } } // 清干净所有形态的 BDCLND。必须枚举 domain × path,原因见 __syncBdclnd 的注释: // 百度在 /disk 路径自己写过一条,path 更长的排 Cookie 头更前面, // 服务端取第一条 → 旧票 → /share/list 恒 -9。只清 path=/ 永远修不好。 function purgeBdclnd() { var expire = '; expires=Thu, 01 Jan 1970 00:00:00 GMT'; var domains = ['', '; domain=.baidu.com', '; domain=baidu.com', '; domain=.pan.baidu.com']; var paths = ['/', '/disk', '/share', '/s', '/disk/main']; for (var i = 0; i < domains.length; i++) { for (var j = 0; j < paths.length; j++) { try { document.cookie = 'BDCLND=; path=' + paths[j] + domains[i] + expire; } catch (e) { } } } } // 收敛为单条 BDCLND:先全量清,再只写 .baidu.com path=/ 一条,最后校验“恰好一条且值对” function syncBdclnd(randsk) { if (!randsk) return false; var v = String(randsk); var target = decodeSafe(v); var all = readBdclndAll(); if (all.length === 1 && decodeSafe(all[0]) === target) return true; purgeBdclnd(); try { document.cookie = 'BDCLND=' + v + '; path=/; domain=.baidu.com'; } catch (e) { } all = readBdclndAll(); return all.length === 1 && decodeSafe(all[0]) === target; } async function preflightShareTicket() { var ctx = extractShareCtx(); if (!ctx || !ctx.key || !ctx.pwd) return; // 公开分享不需要票,不插手 try { var listUrl = '/share/list?shorturl=' + ctx.key + '&root=1&dir=%2F&order=name&num=1&page=1' + '&channel=chunlei&web=1&app_id=250528&clienttype=0'; var j = await (await fetch(listUrl, { credentials: 'include' })).json(); if (j && j.errno === 0) return; // 凭证正常,不插手 if (!j || j.errno !== -9) return; // 分享失效等其它错误不归这里管 // 同一个分享只预修一次:百度限流时 list 也会报 -9,无限 reload 只会火上浇油。 // 锁只在“复核通过”后才落,否则一次失败就把这个分享永久放弃了。 var pfKey = 'bdt_pf_' + ctx.key; try { if (sessionStorage.getItem(pfKey)) return; } catch (e) { } var vj = await (await fetch('/share/verify?surl=' + ctx.key + '&t=' + Date.now() + '&channel=chunlei&web=1&app_id=250528&clienttype=0', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'pwd=' + encodeURIComponent(ctx.pwd) + '&vcode=&vcode_str=' })).json(); if (!vj || vj.errno !== 0 || !vj.randsk) return; if (!syncBdclnd(vj.randsk)) return; var j2 = await (await fetch(listUrl, { credentials: 'include' })).json(); // 修完必须复核 if (!j2 || j2.errno !== 0) return; try { sessionStorage.setItem(pfKey, '1'); } catch (e) { } // 复核通过才落锁 // 票据已对齐且复核通过。不再自己拼 /s/ 地址——短链前导 1 的形态很容易拼错 // (实测拼成 /s/xxx 少了前导 1,直接「页面不存在」),统一让百度自己重新路由: // /share/init 刷新后前端拿到正确票据会自行跳转,/s/ 页刷新即恢复首屏。 window.location.reload(); } catch (e) { /* 预修复失败就静默放过,不影响原有流程 */ } } // ================================================================ // 0.6 从剪贴板自动填入分享链接 // ================================================================ // 用法:复制一个网盘分享链接 → 刷新网盘页 → 链接和密码自动进面板。 // Chrome 的 clipboard-read 权限为 granted 时,无手势也能读到;若为 prompt/denied // 会直接报错,此时静默放过,用户按 Ctrl+V(下面的 paste 监听)再兵。 function fillShareFromText(text) { text = (text || '').trim(); if (!text || text.length > 800) return false; // 只认百度网盘分享链接,避免把随便复制的一段话填进去 var m = text.match(/https?:\/\/(?:pan|yun)\.baidu\.com\/(?:s\/[A-Za-z0-9_-]+(?:\?[^\s"'<>]*)?|share\/init\?[^\s"'<>]*)/i); if (!m) return false; // 去掉 #list/path 这类尾部(pwd 在 ? 后面,不会被误删) var link = m[0].replace(/[))】\]>"',。;;]+$/, '').split('#')[0]; var urlInput = document.getElementById('input-url'); var pwdInput = document.getElementById('input-pwd'); if (!urlInput || !pwdInput) return false; // 已经填了別的链接就不抢,避免刷新时把用户手上的东西顶掉 if (urlInput.value && urlInput.value.trim() !== link) return false; var pwd = ''; var pm = link.match(/[?&]pwd=([^&#]+)/); if (pm) pwd = pm[1]; urlInput.value = link; if (pwd) pwdInput.value = pwd; if (typeof addLog === 'function') { addLog('📋 已从剪贴板识别分享链接' + (pwd ? ',密码:' + pwd : '(无密码)'), 'info'); } return true; } async function tryFillFromClipboard() { var urlInput = document.getElementById('input-url'); if (!urlInput || urlInput.value) return; // 面板上已有链接(比如正在分享页)就不插手 if (!navigator.clipboard || !navigator.clipboard.readText) return; try { var text = await navigator.clipboard.readText(); fillShareFromText(text); } catch (e) { /* 未授权 / 页面未聚焦,静默放过 */ } } function init() { var url = window.location.href; if (url.indexOf('pan.baidu.com') === -1 && url.indexOf('yun.baidu.com') === -1) { return; } // 防止重复初始化(@all-frames 可能在多个 iframe 中触发) if (document.getElementById('transfer-helper-panel')) { return; } // 跨域 iframe 中跳过(无法访问 parent,说明是被嵌入的第三方 iframe) try { if (window.top !== window.self && window.parent && window.parent.location.href.indexOf('baidu.com') === -1) { return; } } catch (e) { return; // 跨域访问 parent.location 抛异常 = 第三方 iframe,跳过 } try { addStyles(); createPanel(); // 版本号直接从元数据读,避免改代码时忘记同步(曾因此无法判断浏览器里装的是哪一版) var __ver = getScriptVersion(); addLog('📦 转存助手 by Qi 已加载 (v' + __ver + ')', 'info'); addLog('💡 自动记忆 + 日期文件夹 + 完成提示音', 'info'); addLog('💬 问题反馈 QQ:544439919', 'info'); // 剪贴板:复制链接后刷新页面就能直接开转;读不到时用 Ctrl+V 兜底 tryFillFromClipboard(); if (!window.__bdtPasteBound) { window.__bdtPasteBound = true; document.addEventListener('paste', function (e) { var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return; // 输入框内正常粘贴 var text = ''; try { text = (e.clipboardData || window.clipboardData).getData('text'); } catch (x) { return; } if (fillShareFromText(text)) e.preventDefault(); }, true); } } catch (e) { console.error('[转存助手] 初始化失败:', e); } } // 先修凭证,再建面板:凭证不通时页面一直卡「提取中」,面板给了也白搭 preflightShareTicket(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();