// ==UserScript== // @name 百度网盘转存助手(by 齐) // @namespace https://github.com/yourname // @version 1.0 // @author 齐 // @description 基于稳定内核,优化文件夹转存策略(预分块),支持自动建文件夹、记忆设置、完成提示音 // @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/* // @require https://unpkg.com/jquery@3.7.0/dist/jquery.min.js // @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. 核心转存类 // ================================================================ 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; }; BaiduTransfer.prototype = { request: async function (path, method, params, data, checkErrno) { var url = this.ROOT_URL + path; if (params) { url += '?' + params; } try { var response = await $.ajax({ url: url, type: method, headers: { 'X-Requested-With': 'XMLHttpRequest' }, data: data, xhrFields: { withCredentials: true } }); if (checkErrno && response.errno && 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) { try { await this.listDir(dirPath); return; } catch (error) { if (error.errno !== -9) { throw error; } } var path = "/api/create"; var params = "a=commit&bdstoken=" + this.bdstoken; var data = "path=" + encodeURIComponent(dirPath) + "&isdir=1&block_list=[]"; return await this.request(path, "POST", params, data, true); }, listDir: async function (dirPath) { var path = "/api/list"; var params = "order=time&desc=1&showempty=0&page=1&num=1000&dir=" + this.customUrlEncode(dirPath) + "&bdstoken=" + this.bdstoken; return await this.request(path, "GET", params, null, true); }, transfer: async function (userId, shareId, fsidList, transferPath, retryCount = 0) { if (this.cancel) throw new Error('操作已取消'); var path = "/share/transfer"; var params = "shareid=" + shareId + "&from=" + userId + "&ondup=newcopy&channel=chunlei&bdstoken=" + this.bdstoken; var data = "fsidlist=[" + fsidList.join(",") + "]&path=" + encodeURIComponent(transferPath || "/"); try { var response = await this.request(path, "POST", params, data, false); var errno = response.errno; if (errno !== 0) { if (errno === 2) { var error = new Error("APIParameterError: url=" + path + " param=" + params); throw error; } else if (errno === 12) { var limit = response.target_file_nums_limit; var count = response.target_file_nums; if (limit && count) { var error = new Error("TransferLimitExceededException: limit=" + limit + " count=" + count); throw error; } var error = new Error(response.show_msg); throw error; } else if (errno === 1504) { if (this.onLog) this.onLog(`⏳ 超时重试 (${retryCount+1}/${this.maxRetries})`); if (retryCount < this.maxRetries) { await new Promise(resolve => setTimeout(resolve, 2000)); return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1); } else { var error = new Error("Transfer timeout after maximum retries"); throw error; } } else if (errno === 111) { if (this.onLog) this.onLog(`⏳ API限流,等待15秒... (${retryCount+1}/${this.maxRetries})`); if (retryCount < this.maxRetries) { await new Promise(resolve => setTimeout(resolve, 15000)); return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1); } else { var error = new Error("API rate limit exceeded after maximum retries"); throw error; } } else if (errno === -6 || errno === -9 || errno === 130) { if (this.onLog) this.onLog(`⏳ 网络错误,重试 (${retryCount+1}/${this.maxRetries})`); if (retryCount < this.maxRetries) { await new Promise(resolve => setTimeout(resolve, 3000)); return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1); } else { var error = new Error(`Network/Server error after maximum retries: errno=${errno}`); throw error; } } else { var error = new Error("BaiduYunPanAPIException: [" + errno + "] " + response.errmsg); throw error; } } this.transferredFiles += fsidList.length; if (this.onProgress) { this.onProgress(this.transferredFiles, this.totalFiles); } if (this.onLog) this.onLog(`✅ 成功转存 ${fsidList.length} 个文件/文件夹`); return response; } catch (error) { if (retryCount < this.maxRetries && (error.message.includes('timeout') || error.message.includes('Network'))) { if (this.onLog) this.onLog(`⏳ 网络超时,重试 (${retryCount+1}/${this.maxRetries})`); await new Promise(resolve => setTimeout(resolve, 3000)); return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1); } 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); this.bdstoken = response.result.bdstoken; return this.bdstoken; }, getRandsk: async function (shareKey, pwd) { var path = "/share/verify"; var params = "surl=" + shareKey + "&bdstoken=" + this.bdstoken; var data = "pwd=" + pwd; var response = await this.request(path, "POST", params, data, true); return response.randsk; }, getShareData: async function (shareKey, pwd) { var path = "/s/1" + shareKey; var response = await this.request(path, "GET", null, null, false); var startTag = 'locals.mset('; var endTag = '});'; var startIndex = response.indexOf(startTag); if (startIndex === -1) { throw new Error("Invalid response: unable to find locals.mset"); } startIndex += startTag.length; var endIndex = response.indexOf(endTag, startIndex); if (endIndex === -1) { throw new Error("Invalid response: unable to find end of locals.mset"); } var jsonStr = response.substring(startIndex, endIndex + 1); var data = JSON.parse(jsonStr); return { userId: data.share_uk, shareId: data.shareid, bdstoken: data.bdstoken, shareRoot: data.file_list[0].parent_path, dirList: data.file_list.filter(e => e.isdir === 1).map(function (file) { return { id: file.fs_id, name: file.server_filename }; }), fileList: data.file_list.filter(e => e.isdir !== 1).map(function (file) { return { id: file.fs_id, name: file.server_filename }; }) }; }, updateRandsk: async function (shareKey, pwd) { await this.getBdstoken(); await this.getRandsk(shareKey, pwd); }, initShareData: async function (shareKey, pwd) { if (pwd) { await this.updateRandsk(shareKey, pwd); } try { var shareData = await this.getShareData(shareKey, pwd); this.userId = shareData.userId; this.shareId = shareData.shareId; this.bdstoken = shareData.bdstoken; this.shareRoot = shareData.shareRoot; this.dirList = shareData.dirList; this.fileList = shareData.fileList; this.totalFiles = this.dirList.length + this.fileList.length; } catch (error) { if (error.message.indexOf('/share/init')) { if (pwd) { throw new Error("Wrong password: " + pwd); } else { throw new Error("Password not specified"); } } throw error; } }, transferFiles: async function (fileList, targetPath, retryCount = 0) { if (this.cancel) throw new Error('操作已取消'); if (targetPath) { await this.createDirectory(targetPath); } 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); } if (this.onLog) this.onLog(`✅ 文件转存完成 (${fileList.length} 个)`); } catch (error) { if (retryCount < this.maxRetries && !this.cancel) { if (this.onLog) this.onLog(`⏳ 文件转存失败,重试 (${retryCount+1}/${this.maxRetries})`); await new Promise(resolve => setTimeout(resolve, 5000)); return await this.transferFiles(fileList, targetPath, retryCount + 1); } else { throw error; } } }, transferDirs: async function (dirList, targetPath, retryCount = 0) { if (this.cancel) throw new Error('操作已取消'); if (targetPath) { await this.createDirectory(targetPath); } if (dirList.length === 0) { return; } const MAX_BATCH = 100; const chunks = []; for (let i = 0; i < dirList.length; i += MAX_BATCH) { chunks.push(dirList.slice(i, i + MAX_BATCH)); } if (this.onLog) this.onLog(`📦 文件夹分 ${chunks.length} 批转存(每批≤100个)`); for (let chunk of chunks) { if (this.cancel) throw new Error('操作已取消'); try { await this.transfer(this.userId, this.shareId, chunk.map(dir => dir.id), targetPath); if (this.onLog) this.onLog(`✅ 成功转存 ${chunk.length} 个文件夹`); } catch (error) { if (error.message.includes('TransferLimitExceededException:')) { if (this.onLog) this.onLog(`⚠️ 批次超限,递归拆分 (${chunk.length} 个)`); if (chunk.length >= 2) { var mid = Math.floor(chunk.length / 2); await this.transferDirs(chunk.slice(0, mid), targetPath); await this.transferDirs(chunk.slice(mid), targetPath); } else { var dir = chunk[0]; var dirPath = this.shareRoot; if (targetPath.length > this.rootPath.length) { dirPath += targetPath.slice(this.rootPath.length); } dirPath += '/' + dir.name; var subFiles = await this.listShareDir(this.userId, this.shareId, dirPath); var subDirList = subFiles.filter(function (file) { return file.isDirectory; }); var subFileList = subFiles.filter(function (file) { return !file.isDirectory; }); this.totalFiles += subDirList.length + subFileList.length; if (subDirList.length > 0) { await this.transferDirs(subDirList, targetPath + '/' + dir.name); } if (subFileList.length > 0) { await this.transferFiles(subFileList, targetPath + '/' + dir.name); } } } else { if (retryCount < this.maxRetries && !this.cancel) { if (this.onLog) this.onLog(`⏳ 文件夹转存失败,重试 (${retryCount+1}/${this.maxRetries})`); await new Promise(resolve => setTimeout(resolve, 10000)); return await this.transferDirs(dirList, targetPath, retryCount + 1); } else { throw error; } } } } }, listShareDir: async function (userId, shareId, dirPath, retryCount = 0) { var path = "/share/list"; var page = 1; var limit = 100; var result = []; try { while (true) { var params = `uk=${userId}&shareid=${shareId}&order=name&desc=0&showempty=0&page=${page}&num=100&dir=${this.customUrlEncode(dirPath)}`; var response = await this.request(path, "GET", params, null, true); var list = response.list; list.forEach(function (item) { result.push({ id: item.fs_id, name: item.server_filename, isDirectory: item.isdir === 1 }); }); if (list.length < 100) { break; } page++; } return result; } catch (error) { if (retryCount < this.maxRetries) { if (this.onLog) this.onLog(`⏳ 列表获取失败,重试 (${retryCount+1}/${this.maxRetries})`); await new Promise(resolve => setTimeout(resolve, 3000)); return await this.listShareDir(userId, shareId, dirPath, retryCount + 1); } else { throw error; } } }, // ===== 修复:提取分享 Key 时过滤 # 号 ===== extractShareKey: function (url) { try { // 先去掉 # 号后面的内容 var cleanUrl = url.split('#')[0]; var decodedUrl = decodeURIComponent(cleanUrl); if (decodedUrl.includes("/s/1")) { return decodedUrl.split("/s/1")[1].split("?")[0]; } else if (decodedUrl.includes("surl=")) { return decodedUrl.split("surl=")[1].split("&")[0]; } } catch (e) { console.error("Error extracting share key:", e); } return null; }, customUrlEncode: function (input) { let encoded = ''; for (let c of input) { if (c === ' ' || c === '"' || c === '\'') { encoded += encodeURIComponent(c); } else if (c === '+') { encoded += '%2B'; } else { encoded += c; } } return encoded; }, transferFinal: async function (url, pwd) { var shareKey = this.extractShareKey(url); if (!shareKey) { throw new Error("Unable to extract share key from URL"); } await this.initShareData(shareKey, pwd); this.transferredFiles = 0; if (this.dirList.length > 0) { await this.transferDirs(this.dirList, this.rootPath); } if (this.fileList.length > 0) { await this.transferFiles(this.fileList, this.rootPath); } } }; // ================================================================ // 2. UI 控制面板 // ================================================================ const STATE = { IDLE: 'idle', RUNNING: 'running', COMPLETED: 'completed', ERROR: 'error', STOPPED: 'stopped' }; let appState = { status: STATE.IDLE, totalFiles: 0, transferredFiles: 0, currentBatch: 0, totalBatches: 0, errorMessage: '', logMessages: [], }; let transferInstance = null; function saveMemory(pwd, path) { if (pwd) GM_setValue('saved_pwd', pwd); if (path) GM_setValue('saved_path', path); } function loadMemory() { return { pwd: GM_getValue('saved_pwd', ''), path: GM_getValue('saved_path', '') }; } 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 addLog(message, type = 'info') { const time = new Date().toLocaleTimeString(); appState.logMessages.unshift({ time, message, type }); if (appState.logMessages.length > 200) { appState.logMessages.pop(); } updateUI(); console.log('[转存助手]', message); } function updateUI() { const totalEl = document.getElementById('stat-total'); const transferredEl = document.getElementById('stat-transferred'); 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'); const logContent = document.getElementById('log-content'); if (totalEl) totalEl.textContent = appState.totalFiles || 0; if (transferredEl) transferredEl.textContent = appState.transferredFiles || 0; if (batchEl) batchEl.textContent = `${appState.currentBatch || 0}/${appState.totalBatches || 0}`; const progress = appState.totalFiles > 0 ? (appState.transferredFiles / appState.totalFiles * 100) : 0; if (progressFill) progressFill.style.width = progress.toFixed(1) + '%'; if (progressPercent) progressPercent.textContent = progress.toFixed(1) + '%'; const statusMap = { [STATE.IDLE]: { text: '就绪', class: 'status-idle' }, [STATE.RUNNING]: { text: '运行中', class: 'status-running' }, [STATE.COMPLETED]: { text: '✅ 已完成', class: 'status-completed' }, [STATE.ERROR]: { text: '❌ 错误', class: 'status-error' }, [STATE.STOPPED]: { text: '⏹ 已停止', class: 'status-idle' }, }; const info = statusMap[appState.status] || statusMap[STATE.IDLE]; if (statusBadge) { statusBadge.textContent = info.text; statusBadge.className = 'status-badge ' + info.class; } 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; } } if (logContent) { logContent.innerHTML = appState.logMessages.map(log => { const cls = log.type === 'error' ? 'log-error' : log.type === 'success' ? 'log-success' : log.type === 'warning' ? 'log-warning' : 'log-info'; return `