// ==UserScript== // @name 网盘直链下载助手(免微信验证/支持 NDM) // @namespace https://greasyfork.org/zh-CN/users/76501-eroslp // @version 6.13.1 // @author eroslp(原作者)/ 本地静态化修改 // @description 支持批量获取百度、阿里、天翼、迅雷、夸克、移动云盘直链;支持一键批量发送到 NDM;配置完全内置,不访问作者服务器,无需任何第三方口令验证。 // @license AGPL-3.0-or-later // @homepage https://greasyfork.org/zh-CN/users/76501-eroslp // @supportURL https://greasyfork.org/zh-CN/users/76501-eroslp // @updateURL https://raw.githubusercontent.com/ppanphper/tampermonkey-scripts/main/netdisk-direct-download-helper.user.js // @downloadURL https://raw.githubusercontent.com/ppanphper/tampermonkey-scripts/main/netdisk-direct-download-helper.user.js // @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 *://pan.baidu.com/s/* // @match *://yun.baidu.com/s/* // @match *://pan.baidu.com/share/* // @match *://yun.baidu.com/share/* // @match *://openapi.baidu.com/* // @match *://www.aliyundrive.com/s/* // @match *://www.aliyundrive.com/drive* // @match *://www.alipan.com/s/* // @match *://www.alipan.com/drive* // @match *://cloud.189.cn/web/* // @match *://pan.xunlei.com/* // @match *://pan.quark.cn/* // @match *://yun.139.com/* // @match *://caiyun.139.com/* // @require https://unpkg.com/jquery@3.7.0/dist/jquery.min.js#sha256=d8f9afbf492e4c139e9d2bcb9ba6ef7c14921eb509fb703bc7a3f911b774eff8 // @require https://unpkg.com/sweetalert2@10.16.6/dist/sweetalert2.all.min.js#sha256=042037da753e9b278b09dff0f7a73e163b628c355988442eb983893122c5ed49 // @require https://unpkg.com/js-md5@0.7.3/build/md5.min.js#sha256=31d57206075645a34f442e3a56d5d2b445e437cf99739889bc1d65662b02bc31 // @connect baidu.com // @connect baidupcs.com // @connect aliyundrive.com // @connect alipan.com // @connect 189.cn // @connect xunlei.com // @connect quark.cn // @connect yun.139.com // @connect caiyun.139.com // @connect localhost // @connect 127.0.0.1 // @run-at document-idle // @grant unsafeWindow // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_openInTab // @grant GM_info // @grant GM_registerMenuCommand // @grant GM_cookie // @grant window.close // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cGF0aCBkPSJNMTAzLjYgMTA3LjRjMy41LTIuMiA4LjktNi4xIDEzLjgtMTIuNXM3LjMtMTIuNSA4LjUtMTYuNWMuNS0xLjcgMi4yLTcuNSAyLjItMTQuNyAwLTEwLjEtMy4zLTI1LjEtMTUuNC0zNi44LTE0LjUtMTQtMzIuMS0xNC4zLTM1LjctMTQuMy04IDAtMTUuNyAxLjktMjIuNiA1LjJDNDQgMjMgMzUuNyAzMS40IDMwLjggNDEuN2MtMS4zIDIuOC00IDQuNy03LjEgNS00IC4zLTcuNSA0LjQtOC45IDkuNi0uNSAxLjktMS42IDMuNS0zLjEgNC43QzQuNCA2Ni44IDAgNzUuNyAwIDg1YzAgNi44IDIuMyAxMy4xIDYuMSAxOC4yIDUuNSA3LjQgMTQuMiAxMi4yIDI0IDEyLjJoNDcuMWM0LjQgMCAxMS0uNSAxOC4zLTMuNSAzLjItMS40IDUuOS0zIDguMS00LjV6IiBmaWxsPSIjNDQ0Ii8+PHBhdGggZD0iTTExOS44IDY0LjNjLjEtMTcuMS0xMC40LTI4LTEyLjUtMzAuMUM5NSAyMi4xIDc5LjkgMjEuOCA3Ni45IDIxLjhjLTE3LjYgMC0zMy4zIDEwLjUtMzkuOSAyNi43LS42IDEuMy0xLjggMi4zLTMuNCAyLjNoLS40Yy01LjggMC0xMC42IDQuOC0xMC42IDEwLjd2LjVjMCAxLjQtLjggMi42LTEuOSAzLjNDMTMuNCA2OSA4LjggNzYuOCA4LjggODVjMCAxMi4yIDkuOSAyMi4zIDIyLjIgMjIuM2g0NS4yYzMuNi0uMSAxNy42LS45IDI5LjYtMTIgMi45LTIuOCAxMy45LTEzLjcgMTQtMzF6IiBmaWxsPSIjMTM5N2Q4Ii8+PHBhdGggZD0iTTExMC44IDU3LjRsLjIgMy4zYzAgMS4zLTEuMSAyLjQtMi4zIDIuNC0xLjMgMC0yLjMtMS4xLTIuMy0yLjRsLS4xLTIuOHYtLjNjMC0xLjIuOS0yLjIgMi4xLTIuM2guM2MuNyAwIDEuMy4zIDEuNy43LS4yLjEuMy41LjQgMS40em0tMy4zLTEwLjNjMCAxLjItMSAyLjMtMi4yIDIuM2gtLjFjLS44IDAtMS42LS41LTItMS4yLTQuNi04LjMtMTMuMy0xMy41LTIyLjgtMTMuNS0xLjIgMC0yLjMtMS0yLjMtMi4ydi0uMWMwLTEuMiAxLTIuMyAyLjItMi4zaC4xYTMwLjM3IDMwLjM3IDAgMCAxIDE1LjggNC40YzQuNiAyLjggOC40IDYuOCAxMS4xIDExLjUuMS4zLjIuNy4yIDEuMXpNODguMyA3My44TDczLjUgOTMuMmMtMS41IDEuOS0zLjUgMy4xLTUuNyAzLjVoLS4yYy0uNC4xLS44LjEtMS4yLjEtLjYgMC0xLjEtLjEtMS42LS4yLTIuMi0uNC00LjItMS43LTUuNi0zLjVMNDQuMyA3My45Yy0yLTIuNi0yLjUtNS40LTEuNC03LjcuMS0uMS4xLS4yLjItLjIgMS4yLTIgMy41LTMuMiA2LjQtMy4yaDYuNnYtNS43YzAtNi44IDQuNy0xMiAxMC45LTEyIDQuOCAwIDguNSAyLjYgMTAuMyA3LjIuNSAxLjMtLjIgMi43LTEuNSAzLjJzLTIuOC0uMS0zLjMtMS40Yy0xLjEtMi43LTIuOS00LTUuNS00LTMuNSAwLTYgMy02IDd2OC4xYzAgLjUtLjIgMS0uNiAxLjQtLjYuNy0xLjcgMS4xLTIuNiAxLjFoLTguNGMtMS4zIDAtMiAuNC0yLjEuNy0uMi40IDAgMS4zLjkgMi40TDYzLjEgOTBjLjkgMS4yIDIuMSAxLjggMy4zIDEuOHMyLjMtLjYgMy4xLTEuN2wxNC44LTE5LjNjLjktMS4xIDEuMS0yIC45LTIuNC0uMi0uMy0uOS0uNy0yLjEtLjdoLTcuNmMtLjkgMC0xLjctLjUtMi4xLTEuMi0uMy0uNC0uNC0uOC0uNC0xLjMgMC0xLjQgMS4xLTIuNSAyLjUtMi41aDcuNmMzLjEgMCA1LjUgMS4zIDYuNiAzLjVsLjMuN2MuNyAyLjEuMSA0LjYtMS43IDYuOXoiIGZpbGw9IiM0NDQiLz48L3N2Zz4= // ==/UserScript== (function () { 'use strict'; let pt = '', selectList = [], params = {}, mode = '', width = 800, pan = {}, color = '', doc = $(document), progress = {}, request = {}, ins = {}, idm = {}; const scriptInfo = GM_info.script; const version = scriptInfo.version; const name = scriptInfo.name; const manageHandler = GM_info.scriptHandler; const manageVersion = GM_info.version; const customClass = { popup: 'pl-popup', header: 'pl-header', title: 'pl-title', closeButton: 'pl-close', content: 'pl-content', input: 'pl-input', footer: 'pl-footer' }; const terminalType = { wc: "Windows CMD", wp: "Windows PowerShell", lt: "Linux 终端", ls: "Linux Shell", mt: "MacOS 终端", }; const createStaticConfig = (config) => Object.freeze({ code: 200, version, api: ['NDM 下载(协议不支持指定目录)', '底部可一键批量推送。NDM 不返回任何回执,脚本只能确认“已投递”;发送完请核对 NDM 列表数量,缺失的可单独点击重发。'], aria: ['Aria2 命令', '点击命令即可复制到剪贴板;已按「助手设置」里的保存路径附加 --dir。'], rpc: ['RPC 下载', '底部可一次性提交全部任务(aria2 system.multicall)。网盘按单任务限速,总速度≈同时任务数×单任务速度,可在助手设置里调「同时任务数」。排队中的任务成败未定,稍后点「核对下载结果」即可查真实状态,失败项会翻红等你重试。'], curl: ['cURL 命令', '点击命令即可复制到剪贴板,支持断点续传;已按「助手设置」里的保存路径拼好输出路径。'], bc: ['比特彗星下载', '点击链接可交给比特彗星处理。'], footer: '', new: '', newX: '', ...config, }); const staticConfig = Object.freeze({ baidu: createStaticConfig({ pcs: Object.freeze([ 'https://pan.baidu.com/rest/2.0/xpan/multimedia?method=filemetas&dlink=1', 'https://pan.baidu.com/api/sharedownload?channel=chunlei&clienttype=12&web=1&app_id=250528', 'https://pan.baidu.com/share/tplconfig?fields=sign,timestamp&channel=chunlei&web=1&app_id=250528&clienttype=0', 'https://openapi.baidu.com/oauth/2.0/authorize?client_id=IlLqBbU3GjQ0t46TRwFateTprHWl39zF&response_type=token&redirect_uri=oob&confirm_login=0&scope=basic,netdisk', 'https://openapi.baidu.com/oauth/2.0/login_success', ]), btn: Object.freeze({ home: '.tcuLAu', main: '.wp-s-agile-tool-bar__header', share: '.module-share-top-bar .x-button-box', }), ua: 'pan.baidu.com', }), ali: createStaticConfig({ pcs: Object.freeze([ 'https://api.aliyundrive.com/v2/file/get_share_link_download_url', 'https://api.aliyundrive.com/v2/file/get_download_url', ]), btn: Object.freeze({home: '.actions--M9Np-', share: '.right--x0Z1g'}), dom: Object.freeze({ list: '[class^="node-list-table-view--"]', grid: '[class^="node-list-grid-view--"]', switch: '[class^="switch-wrapper--"]', }), }), tianyi: createStaticConfig({ pcs: Object.freeze([ 'https://cloud.189.cn/api/open/file/getFileDownloadUrl.action', 'https://api.cloud.189.cn/open/oauth2/ssoH5.action', 'https://api.cloud.189.cn/open/file/getFileDownloadUrl.action', ]), btn: Object.freeze({home: '.nav-opea', share: '.nav-opea'}), }), xunlei: createStaticConfig({ pcs: Object.freeze(['https://api-pan.xunlei.com/drive/v1/files/']), // 迅雷前端使用 CSS Modules,末尾哈希会随版本/发布变化。 // 旧版是 FileMenu__menu--...,当前版为 FileMenu__menus--...。 // 用稳定的类名前缀匹配,避免脚本升级后按钮完全不显示。 btn: Object.freeze({ home: '[class*="FileMenu__menus--"], [class*="FileMenu__menu--"]', share: '[class*="Share__batchActionBox--"]' }), }), quark: createStaticConfig({ pcs: Object.freeze(['https://drive.quark.cn/1/clouddrive/file/download?pr=ucpro&fr=pc']), btn: Object.freeze({home: '.btn-operate .btn-main', share: '.file-info-share-buttom'}), ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch', }), yidong: createStaticConfig({ pcs: Object.freeze([ 'https://yun.139.com/orchestration/personalCloud/uploadAndDownload/v1.0/downloadRequest', 'https://caiyun.139.com/stapi/outlink/content/download', ]), btn: Object.freeze({home: '.top_button', share: '.top-btns'}), }), }); let toast = Swal.mixin({ toast: true, position: 'top', showConfirmButton: false, timer: 3500, timerProgressBar: false, didOpen: (toast) => { toast.addEventListener('mouseenter', Swal.stopTimer); toast.addEventListener('mouseleave', Swal.resumeTimer); } }); const message = { success: (text) => { toast.fire({title: text, icon: 'success'}); }, error: (text) => { toast.fire({title: text, icon: 'error'}); }, warning: (text) => { toast.fire({title: text, icon: 'warning'}); }, info: (text) => { toast.fire({title: text, icon: 'info'}); }, question: (text) => { toast.fire({title: text, icon: 'question'}); } }; let base = { getCookie(name) { let cname = name + "="; let ca = document.cookie.split(';'); for (let i = 0; i < ca.length; i++) { let c = ca[i].trim(); if (c.indexOf(cname) == 0) return c.substring(cname.length, c.length); } return ""; }, isType(obj) { return Object.prototype.toString.call(obj).replace(/^\[object (.+)\]$/, '$1').toLowerCase(); }, getValue(name) { return GM_getValue(name); }, setValue(name, value) { GM_setValue(name, value); }, deleteValue(name) { GM_deleteValue(name); }, getStorage(key) { try { return JSON.parse(localStorage.getItem(key)); } catch (e) { return localStorage.getItem(key); } }, setStorage(key, value) { if (this.isType(value) === 'object' || this.isType(value) === 'array') { return localStorage.setItem(key, JSON.stringify(value)); } return localStorage.setItem(key, value); }, setClipboard(text) { GM_setClipboard(text, 'text'); }, e(str) { return btoa(unescape(encodeURIComponent(str))); }, /** * HTML 属性/文本转义。文件名里出现半角引号时,未转义会把 * data-filename="xxx" 提前截断,导致后续拿到的文件名只剩前半截。 */ escapeHtml(value) { const map = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}; return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, (c) => map[c]); }, getExtension(name) { const reg = /(?!\.)\w+$/; if (reg.test(name)) { let match = name.match(reg); return match[0].toUpperCase(); } return ''; }, sizeFormat(value) { if (value === +value) { let unit = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; let index = Math.floor(Math.log(value) / Math.log(1024)); let size = value / Math.pow(1024, index); size = size.toFixed(1); return size + unit[index]; } return ''; }, sortByName(arr) { const handle = () => { return (a, b) => { const p1 = a.filename ? a.filename : a.server_filename; const p2 = b.filename ? b.filename : b.server_filename; return p1.localeCompare(p2, "zh-CN"); }; }; arr.sort(handle()); }, fixFilename(name) { return name.replace(/[!?&|`"'*\/:<>\\]/g, '_'); }, blobDownload(blob, filename) { if (blob instanceof Blob) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } }, post(url, data, headers, type) { if (this.isType(data) === 'object') { data = JSON.stringify(data); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url, headers, data, responseType: type || 'json', onload: (res) => { type === 'blob' ? resolve(res) : resolve(res.response || res.responseText); }, onerror: (err) => { reject(err); }, }); }); }, get(url, headers, type, extra) { return new Promise((resolve, reject) => { let requestObj = GM_xmlhttpRequest({ method: "GET", url, headers, responseType: type || 'json', onload: (res) => { if (res.status === 204) { requestObj.abort(); idm[extra.index] = true; } if (type === 'blob') { res.status === 200 && base.blobDownload(res.response, extra.filename); resolve(res); } else { resolve(res.response || res.responseText); } }, onprogress: (res) => { if (extra && extra.filename && extra.index) { res.total > 0 ? progress[extra.index] = (res.loaded * 100 / res.total).toFixed(2) : progress[extra.index] = 0.00; } }, onloadstart() { extra && extra.filename && extra.index && (request[extra.index] = requestObj); }, onerror: (err) => { reject(err); }, }); }); }, getFinalUrl(url, headers) { return new Promise((resolve, reject) => { let requestObj = GM_xmlhttpRequest({ method: "GET", url, headers, onload: (res) => { resolve(res.finalUrl) }, onerror: (err) => { reject(err); } }); }); }, /** * 「保存路径」设置。RPC 走 aria2 的 dir 参数,Aria2/cURL 命令则拼进命令行; * NDM 和比特彗星的协议里没有目录字段,只能在各自的软件里设全局目录。 */ getSaveDir() { let dir = this.getValue('setting_rpc_dir'); return dir && String(dir).trim() ? String(dir).trim().replace(/[\/\\]+$/, '') : ''; }, /** aria2c 的 --dir,目录不存在会自动创建 */ ariaDirArg() { let dir = this.getSaveDir(); return dir ? ` --dir "${dir}"` : ''; }, /** curl 的输出参数;带目录时要加 --create-dirs,否则目录不存在会直接失败 */ curlOutArgs(filename) { let dir = this.getSaveDir(); return dir ? `--create-dirs -o "${dir}/${filename}"` : `-o "${filename}"`; }, /** 读取 RPC 配置,顺带拼出接口地址 */ getRpcConfig() { let rpc = { domain: this.getValue('setting_rpc_domain'), port: this.getValue('setting_rpc_port'), path: this.getValue('setting_rpc_path'), token: this.getValue('setting_rpc_token'), dir: this.getValue('setting_rpc_dir'), }; rpc.url = `${rpc.domain}:${rpc.port}${rpc.path}`; return rpc; }, /** * 组装一条 aria2.addUri 调用。out 用网盘给的文件名,不像 NDM 那样 * 只能听服务端 Content-Disposition 的;dir 留空就交给下载器自己的默认目录。 */ createRpcCall(filename, link, header, rpc) { let option = {out: filename}; if (rpc.dir && String(rpc.dir).trim()) option.dir = rpc.dir; if (header && header.length) option.header = header; return {methodName: 'aria2.addUri', params: [`token:${rpc.token}`, [link], option]}; }, /** 把下载器返回的失败项翻译成一句人话 */ readRpcFault(item) { if (!item) return '下载器没有返回这一条的结果'; if (item.faultString) return `aria2 错误 ${item.faultCode}:${item.faultString}`; if (item.message) return `aria2 错误 ${item.code}:${item.message}`; return '下载器返回了无法识别的错误'; }, /** * 统一渲染单个任务按钮的成败状态;成功的打上 rpc-done,批量重试会跳过。 * 顺便把 gid 记在按钮上,之后随时能拿它回查下载器里的真实结果。 */ markRpcResult(element, ok, msg, gid) { let $el = $(element); $el.find('.pl-loading').remove(); $el.find('.icon').remove(); if (ok) { gid && (element.dataset.gid = gid); $el.addClass('rpc-done').removeClass('pl-btn-danger').attr('title', '已提交到下载器') .stop(true, true).html('发送成功,快去看看吧!').animate({opacity: '0.5'}, "slow"); } else { $el.removeClass('rpc-done').addClass('pl-btn-danger').attr('title', msg || '') .stop(true, true).css('opacity', 1).text('发送失败,点击可重试'); } }, /** aria2 的 errorCode 对照表,直接说清楚该去改什么 */ rpcErrorText(code, msg) { const map = { '1': '未知错误', '2': '超时', '3': '资源不存在,直链多半已过期,回网盘重新获取', '4': '多次重试仍找不到资源,直链多半已过期', '6': '网络错误', '8': '服务端不支持断点续传', '9': '磁盘空间不足', '13': '同名文件已存在', '16': '无法创建文件,检查保存目录的写权限', '17': '文件读写失败', '18': '无法创建保存目录,请到「助手设置」把「保存路径」清空,或填成真实存在的绝对路径(D: 这种 Windows 写法在 macOS/Linux 上必然失败)', '19': '域名解析失败', '22': 'HTTP 响应头异常,可能被防盗链拦截', '23': '重定向次数过多', '24': 'HTTP 认证失败', '29': '服务端暂时不可用', }; return `下载器报错 ${code}:${map[String(code)] || msg || '详情见下载器'}`; }, /** 按 gid 批量回查状态,只把已经失败的挑出来;查不到的多半是被手动删了,不算失败 */ async queryRpcStatus(entries, rpc, options) { let broken = []; for (let i = 0; i < entries.length; i += 100) { let part = entries.slice(i, i + 100); try { let res = await this.post(rpc.url, { id: `pl-status-${i}`, jsonrpc: '2.0', method: 'system.multicall', params: [part.map((v) => ({ methodName: 'aria2.tellStatus', params: [`token:${rpc.token}`, v.gid, ['status', 'errorCode', 'errorMessage']] }))] }, (options || {}).postHeaders || {}, ''); let data = typeof res === 'string' ? JSON.parse(res) : res; let results = Array.isArray(data && data.result) ? data.result : []; part.forEach((v, idx) => { let item = Array.isArray(results[idx]) ? results[idx][0] : null; if (item && item.status === 'error') { broken.push({el: v.el, message: this.rpcErrorText(item.errorCode, item.errorMessage)}); } }); } catch (e) { // 回查失败不影响已经提交出去的任务,静默跳过这一批 } } return broken; }, /** * addUri 返回 gid 只说明下载器收下了任务,保存目录不可写、直链已失效 * 这类问题要等它真正开始下载才暴露。这里隔一会儿回查一次, * 免得脚本报「全部成功」而下载器里一片红。 */ async verifyRpcTasks(entries, rpc, options) { await this.sleep(2500); return this.queryRpcStatus(entries, rpc, options); }, /** * 手动核对。一次提交几百条时,提交后那一刻绝大多数还排在「等待中」, * 真正的成败要等它排到队首才知道(下载器默认只同时跑 5 个), * 所以留一个按钮,什么时候想核对就核对,失败的会翻红等着重试。 */ async recheckRpcTasks(trigger, options) { let $trigger = $(trigger); if ($trigger.data('rpc-checking')) return; let entries = $('.listener-link-rpc').toArray() .filter((el) => el.dataset.gid) .map((el) => ({el, gid: el.dataset.gid})); if (!entries.length) { message.info('还没有已提交的任务可以核对。'); return; } $trigger.data('rpc-checking', true).text(`正在核对 ${entries.length} 个任务…`); let broken = await this.queryRpcStatus(entries, this.getRpcConfig(), options); broken.forEach((v) => { delete v.el.dataset.gid; this.markRpcResult(v.el, false, v.message); }); $trigger.data('rpc-checking', false) .text(broken.length ? `核对完成:${broken.length} 个已失败,可点击重试` : `核对完成:${entries.length} 个都正常`); broken.length ? message.error(`${broken.length} 个任务失败:${broken[0].message}`) : message.success('暂时没有失败的任务。'); }, /** 单条推送;返回 'success' / 'cookie-missing' / 失败原因文本 */ async sendOneToRPC(filename, link, options, element) { if (options === 'cookie-missing') return 'cookie-missing'; let rpc = this.getRpcConfig(); if (!rpc.domain || !rpc.port) return '请先在「助手设置」里填写 RPC 主机和端口'; let call = this.createRpcCall(filename, link, (options || {}).header, rpc); try { let res = await this.post(rpc.url, { id: 'pl-one', jsonrpc: '2.0', method: call.methodName, params: call.params }, (options || {}).postHeaders || {}, ''); let data = typeof res === 'string' ? JSON.parse(res) : res; if (data && data.error) return this.readRpcFault(data.error); if (!data || !data.result) return '下载器没有返回任务 ID'; element && (element.dataset.gid = data.result); let broken = await this.verifyRpcTasks([{el: element || null, gid: data.result}], rpc, options); return broken.length ? broken[0].message : 'success'; } catch (e) { return `连不上 ${rpc.url},请确认 Motrix / aria2 已启动且开启了 RPC`; } }, /** * 下载器同时跑几个任务,直接改 aria2 的运行时设置。 * * 实测(夸克,2026-08):并发 10 / 20 / 32 时单任务均速分别是 198 / 198 / 196 KB/s, * 总速度 1.93 / 3.86 / 6.12 MB/s——夸克限的是单任务,没有账号级总量限制, * 所以总速度约等于「并发数 × 200KB/s」。而 Motrix 界面把并发上限锁死在 10, * 引擎本身没有这个限制,用 RPC 改运行时值即可绕过。下载器重启会还原,所以每批都重设一次。 */ async applyRpcConcurrency(rpc, options) { let n = parseInt(this.getValue('setting_rpc_concurrent'), 10); if (!n || n < 1) return ''; n = Math.min(n, 64); try { let res = await this.post(rpc.url, { id: 'pl-concurrent', jsonrpc: '2.0', method: 'aria2.changeGlobalOption', params: [`token:${rpc.token}`, {'max-concurrent-downloads': String(n)}] }, (options || {}).postHeaders || {}, ''); let data = typeof res === 'string' ? JSON.parse(res) : res; return data && data.result === 'OK' ? `同时下载任务数已设为 ${n}` : ''; } catch (e) { return ''; } }, /** * 批量推送。aria2 的 system.multicall 支持把 N 条 addUri 打包进一个请求, * 返回值与入参按下标一一对应(成功是 [gid],失败是 {faultCode, faultString}), * 所以既不用像逐个点击那样同时打出几十个并发请求(那正是报错的来源), * 又能逐条判定成败。已成功的按钮标记 rpc-done,再次点击只重试失败项。 */ async sendBatchToRPC(trigger, options) { let $trigger = $(trigger); if ($trigger.data('rpc-sending')) return; if (options === 'cookie-missing') { message.error('未读取到百度登录 Cookie;请使用支持读取 HttpOnly Cookie 的脚本管理器。'); return; } let rpc = this.getRpcConfig(); if (!rpc.domain || !rpc.port) { message.error('请先在「助手设置」里填写 RPC 主机和端口。'); return; } let pending = $('.listener-link-rpc').toArray().filter((el) => !el.classList.contains('rpc-done')); if (!pending.length) { message.info('没有待发送的任务,全部都已推送成功。'); return; } let applied = await this.applyRpcConcurrency(rpc, options); applied && message.info(applied); $trigger.data('rpc-sending', true).addClass('pl-btn-warning'); let chunkSize = 50, success = 0, fail = 0, firstError = '', submitted = []; for (let i = 0; i < pending.length; i += chunkSize) { let chunk = pending.slice(i, i + chunkSize); $trigger.text(`正在提交 ${i + 1}-${i + chunk.length} / 共 ${pending.length} 个任务…`); chunk.forEach((el) => { let $el = $(el); $el.find('.icon').remove(); $el.find('.pl-loading').remove(); $el.prepend(this.createLoading()); }); let results = null, error = ''; try { let res = await this.post(rpc.url, { id: `pl-batch-${i}`, jsonrpc: '2.0', method: 'system.multicall', params: [chunk.map((el) => this.createRpcCall(el.dataset.filename, el.dataset.link, (options || {}).header, rpc))] }, (options || {}).postHeaders || {}, ''); let data = typeof res === 'string' ? JSON.parse(res) : res; if (data && data.error) error = this.readRpcFault(data.error); else if (Array.isArray(data && data.result)) results = data.result; else error = '返回内容无法识别,请确认地址指向的是 aria2 的 JSON-RPC 接口'; } catch (e) { error = `连不上 ${rpc.url},请确认 Motrix / aria2 已启动且开启了 RPC`; } chunk.forEach((el, idx) => { let item = results ? results[idx] : null; let ok = Array.isArray(item) ? !!item[0] : false; if (ok) { success++; submitted.push({el, gid: item[0]}); this.markRpcResult(el, true, '', item[0]); } else { fail++; let msg = results ? this.readRpcFault(item) : error; firstError = firstError || msg; this.markRpcResult(el, false, msg); } }); } if (submitted.length) { $trigger.text(`已提交 ${submitted.length} 个,正在核对下载器里的实际状态…`); let broken = await this.verifyRpcTasks(submitted, rpc, options); broken.forEach((v) => { success--; fail++; firstError = v.message; this.markRpcResult(v.el, false, v.message); }); } success && $('.listener-rpc-task').show(); $trigger.data('rpc-sending', false).removeClass('pl-btn-warning') .text(`已成功 ${success}${fail ? `,失败 ${fail},点此重试失败项` : ',全部完成'}`); fail ? message.error(`${fail} 个任务失败:${firstError}`) : message.success(`${success} 个任务已在下载器中正常开始。`); }, /** * 按 NDM 扩展协议(neatextension.v1)拼装任务报文。 * 协议为「一条消息 = 一个下载」,字段:1 方法 / 2 地址 / 4 文件名 / 6 模式(normal|media), * 其余按 `Header: Value` 原样附加。NDM 不支持在一条消息里提交多个任务。 */ buildNdmTask(filename, link, headers = {}, validateUrl) { let downloadUrl; try { downloadUrl = new URL(link); } catch (error) { throw new Error('下载地址无效,请重新获取直链!'); } if (!/^https?:$/.test(downloadUrl.protocol)) { throw new Error('NDM 仅接受 HTTP 或 HTTPS 下载地址!'); } if (validateUrl && !validateUrl(downloadUrl)) { throw new Error('下载地址不属于当前网盘!'); } const safeFilename = String(filename || 'download').replace(/[\r\n]+/g, ' '); const headerLines = []; for (const [headerName, headerValue] of Object.entries(headers)) { if (headerValue === undefined || headerValue === null || headerValue === '') continue; if (!/^[A-Za-z0-9-]+$/.test(headerName) || /[\r\n]/.test(String(headerValue))) { throw new Error('下载请求头包含非法字符!'); } headerLines.push(`${headerName}: ${headerValue}`); } return [ '1:GET', `2:${downloadUrl.href}`, `4:${safeFilename}`, '6:normal', ...headerLines, '' ].join('\r\n'); }, /** 建立一条到 NDM 的连接,供整批任务复用 */ openNdmSocket() { return new Promise((resolve, reject) => { let socket; let settled = false; const finish = (error) => { if (settled) return; settled = true; clearTimeout(timeout); if (error) { try { socket && socket.close(); } catch (e) { } reject(error); } else { resolve(socket); } }; const timeout = setTimeout(() => { finish(new Error('连接 NDM 超时,请确认桌面端已启动!')); }, 4000); try { socket = new WebSocket('ws://127.0.0.1:10007/download', 'neatextension.v1'); } catch (error) { finish(new Error('浏览器阻止连接本机 NDM,请检查站点权限!')); return; } socket.addEventListener('open', () => finish()); socket.addEventListener('error', () => finish(new Error('无法连接 NDM,请确认桌面端已启动!'))); }); }, /** * 发送单个任务。传入 socket 时复用该连接(批量场景),否则单独建连后关闭。 */ async sendLinkToNDM(filename, link, headers = {}, validateUrl, socket) { const task = this.buildNdmTask(filename, link, headers, validateUrl); if (socket && socket.readyState === WebSocket.OPEN) { socket.send(task); await this.sleep(60); return; } return new Promise((resolve, reject) => { let socket; let settled = false; const finish = (error) => { if (settled) return; settled = true; clearTimeout(timeout); socket && socket.close(); error ? reject(error) : resolve(); }; const timeout = setTimeout(() => { finish(new Error('连接 NDM 超时,请确认桌面端已启动!')); }, 4000); try { socket = new WebSocket('ws://127.0.0.1:10007/download', 'neatextension.v1'); } catch (error) { finish(new Error('浏览器阻止连接本机 NDM,请检查站点权限!')); return; } socket.addEventListener('open', () => { socket.send(task); setTimeout(() => finish(), 300); }); socket.addEventListener('error', () => { finish(new Error('无法连接 NDM,请确认桌面端已启动!')); }); }); }, /** * 把单个「发送到 NDM」按钮对应的任务推送给 NDM。 * options: { headers, validateUrl, resolveLink, send, silent, socket } * 返回 true / false,供批量发送统计结果。 */ async sendElementToNDM(element, options = {}) { const {headers = {}, validateUrl, resolveLink, send, silent = false, socket} = options; const target = $(element); const originalText = target.attr('data-ndm-original-text') || target.text(); target.attr('data-ndm-original-text', originalText) .removeClass('ndm-done ndm-fail') .addClass('element-clicked') .css('pointer-events', 'none') .text('正在发送到 NDM...'); try { const link = resolveLink ? await resolveLink(element) : element.dataset.link; if (!link) throw new Error('未获取到有效下载地址,请刷新页面后重试!'); element.dataset.link = link; send ? await send(element.dataset.filename, link, socket) : await this.sendLinkToNDM(element.dataset.filename, link, headers, validateUrl, socket); target.addClass('ndm-done').attr('title', '已发送到 NDM').text('已发送到 NDM'); !silent && message.success('任务已发送到 Neat Download Manager!'); return true; } catch (error) { const tip = error.message || '无法连接 Neat Download Manager!'; target.addClass('ndm-fail').attr('title', tip).text('发送失败,点击重试'); !silent && message.error(tip); return false; } finally { target.removeClass('element-clicked').css('pointer-events', ''); } }, ndmBatch: {running: false, cancelled: false}, /** * 批量发送:整批复用同一条 NDM 连接,串行遍历弹窗内所有「发送到 NDM」按钮。 * 已成功的会自动跳过,所以再次点击即为「重试剩余/失败任务」。运行中再次点击可停止。 * * 针对 NDM 1.3.2.4 的实测结论(改动前请先复现,别凭直觉调小间隔): * - 一条 WebSocket 消息只能承载一个任务。多个任务拼进同一条消息时, * 后面的字段会覆盖前面的,最终只下载最后一个地址,且不报错; * - 并发连接没用:8 条连接同时各发 1 个任务,只有 1 个被接收; * - 同一条连接可连续发多个任务,但 NDM 是全局串行的,处理完一个才收下一个。 * 实测 8 个任务:1000ms→8/8,600ms→8/8,500ms→8/8,420ms→3/8,350ms→4/8,80ms→1/5; * - 被丢弃时没有任何信号:不回消息、不 close、不 error,socket 仍是 OPEN。 * NDM 也不提供任务回执,所以脚本只能确认「已投递」,无法确认「已接收」。 * 因此这里按配置间隔逐个投递,默认 600ms(在 500ms 下限上留 100ms 余量)。 */ async sendAllToNDM(trigger, sendOne) { const $trigger = $(trigger); if (this.ndmBatch.running) { this.ndmBatch.cancelled = true; $trigger.text('正在停止,请稍候…'); return; } const all = $('.pl-main .listener-link-ndm').toArray(); const pending = all.filter((el) => !$(el).hasClass('ndm-done')); if (pending.length === 0) { message.info(all.length === 0 ? '没有可发送的任务!' : '所有任务都已发送到 NDM!'); return; } this.ndmBatch.running = true; this.ndmBatch.cancelled = false; $trigger.removeClass('pl-btn-success').addClass('pl-btn-warning').text('正在连接 NDM…'); // 整批只建一次连接;连不上则退回「每条任务各自建连」的老路径 let socket = null; try { socket = await this.openNdmSocket(); } catch (e) { } // 低于 500ms NDM 会丢任务,这里兜底不让配置小于 500 const interval = Math.max(500, parseInt(base.getValue('setting_ndm_interval'), 10) || 800); let success = 0, fail = 0, done = 0; for (const el of pending) { if (this.ndmBatch.cancelled) break; if (!document.body.contains(el)) break; // 弹窗被关闭,终止批量发送 done++; const alive = socket && socket.readyState === WebSocket.OPEN; const eta = Math.ceil((pending.length - done) * interval / 1000); $trigger.text(`正在发送 ${done}/${pending.length}${eta ? `,约剩 ${eta} 秒` : ''}(点击可停止)`); (await sendOne(el, alive ? socket : null)) ? success++ : fail++; // 独立建连时内部已等待 300ms,这里补足剩余间隔 await this.sleep(alive ? interval : Math.max(0, interval - 300)); } if (socket) { await this.sleep(300); // 等最后一条报文送达再断开 try { socket.close(); } catch (e) { } } const stopped = this.ndmBatch.cancelled || done < pending.length; const rest = pending.length - success - fail; this.ndmBatch.running = false; this.ndmBatch.cancelled = false; let summary = `${stopped ? '已停止' : '发送完成'}:已提交 ${success}`; fail && (summary += `,失败 ${fail}`); rest > 0 && (summary += `,剩余 ${rest}`); $trigger.removeClass('pl-btn-warning'); if (fail || rest > 0) { $trigger.text(`${summary}(点击重试剩余任务)`); message.warning(summary); } else { $trigger.addClass('pl-btn-success').text(`${summary},请核对 NDM 列表数量`); // NDM 没有回执,投递成功不等于 NDM 一定建了任务,提示用户核对 message.success(`${summary},请核对 NDM 列表;数量不符时可单独重发缺失项。`); } }, stringify(obj) { let str = ''; for (var key in obj) { if (obj.hasOwnProperty(key)) { var value = obj[key]; if (Array.isArray(value)) { for (var i = 0; i < value.length; i++) { str += encodeURIComponent(key) + '=' + encodeURIComponent(value[i]) + '&'; } } else { str += encodeURIComponent(key) + '=' + encodeURIComponent(value) + '&'; } } } return str.slice(0, -1); // 去掉末尾的 "&" }, addStyle(id, tag, css) { tag = tag || 'style'; let doc = document, styleDom = doc.getElementById(id); if (styleDom) return; let style = doc.createElement(tag); style.rel = 'stylesheet'; style.id = id; tag === 'style' ? style.innerHTML = css : style.href = css; doc.getElementsByTagName('head')[0].appendChild(style); }, sleep(time) { return new Promise(resolve => setTimeout(resolve, time)); }, getMajorVersion(version) { const [major] = (version || '').split('.'); return /^\d+$/.test(major) ? major : null; }, findReact(dom, traverseUp = 0) { const key = Object.keys(dom).find(key => { return key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$"); }); const domFiber = dom[key]; if (domFiber == null) return null; if (domFiber._currentElement) { let compFiber = domFiber._currentElement._owner; for (let i = 0; i < traverseUp; i++) { compFiber = compFiber._currentElement._owner; } return compFiber._instance; } const GetCompFiber = fiber => { let parentFiber = fiber.return; while (typeof parentFiber.type == "string") { parentFiber = parentFiber.return; } return parentFiber; }; let compFiber = GetCompFiber(domFiber); for (let i = 0; i < traverseUp; i++) { compFiber = GetCompFiber(compFiber); } return compFiber.stateNode || compFiber; }, initDefaultConfig() { let value = [{ name: 'setting_rpc_domain', value: 'http://localhost' }, { name: 'setting_rpc_port', value: '16800' }, { name: 'setting_rpc_path', value: '/jsonrpc' }, { name: 'setting_rpc_token', value: '' }, { name: 'setting_rpc_dir', value: '' }, { name: 'setting_rpc_concurrent', value: '' }, { name: 'setting_ndm_interval', value: '600' }, { name: 'setting_terminal_type', value: 'wc' }, { name: 'setting_theme_color', value: '#09AAFF' }]; value.forEach((v) => { base.getValue(v.name) === undefined && base.setValue(v.name, v.value); }); // 旧版默认保存路径是 Windows 的 D:,aria2 会当成相对目录去创建, // 而 Motrix 里 aria2 的工作目录是 /,于是必然报错 18(无法创建目录)。 // 这个默认值在 macOS/Linux 上从没生效过,直接清掉,交回下载器自己的默认目录。 if (base.getValue('setting_rpc_dir') === 'D:' && !/Windows/i.test(navigator.userAgent)) { base.setValue('setting_rpc_dir', ''); } }, showSetting() { let dom = '', btn = '', colorList = ['#09AAFF', '#cc3235', '#526efa', '#518c17', '#ed944b', '#f969a5', '#bca280']; dom += ``; dom += ``; dom += ``; dom += ``; dom += ``; dom += ``; dom += ``; colorList.forEach((v) => { btn += `
`; }); dom += ``; dom += `