// ==UserScript== // @name 网盘直链下载助手 // @namespace js-hub/netdisk-hub // @version 1.0.1 // @description 网盘文件直链获取与下载调度工具:勾选文件自动换取直链,支持 API 下载(直接下载 / 复制直链 / 推送 IDM)与 Aria2 下载(RPC 推送 / 命令行生成)双通道,配置极简、开箱即用。 // @author EFate // @license MIT // @match *://pan.baidu.com/* // @match *://yun.baidu.com/* // @match *://www.alipan.com/* // @match *://www.aliyundrive.com/* // @match *://pan.quark.cn/* // @match *://drive.uc.cn/* // @match *://*.123pan.com/* // @match *://*.123pan.cn/* // @match *://*.123684.com/* // @match *://*.123865.com/* // @match *://*.123952.com/* // @match *://*.123912.com/* // @match *://yun.139.com/* // @match *://caiyun.139.com/* // @match *://cloud.189.cn/* // @match *://pan.xunlei.com/* // @connect * // @connect localhost // @connect 127.0.0.1 // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @run-at document-start // @noframes // ==/UserScript== /** * 网盘直链下载助手 * * 设计要点: * 1. 文件识别**读页面框架状态**,不扫 DOM。网盘是 SPA,「有哪些文件」「勾选了哪些」 * 都存放在 React / Vue 的内部状态里;从文件列表容器反查框架实例即可读出勾选项, * 再调网盘接口换成直链。文件夹换不出直链,会单独标出并从请求里剔除。 * 2. 未实现换链的网盘由**网络层捕获**兜底。网盘文件列表本身不含直链(地址是点击 * 「下载」时由页面 JS 实时换回来的),所以脚本在文档解析前 hook XMLHttpRequest * 与 fetch,把直链截下来。 * 3. 入口**注入宿主工具栏**,不做悬浮器件。等容器渲染出来再把按钮挂进去,与宿主原生 * 按钮并排;同时始终保留脚本管理器菜单兜底。不注册任何全局快捷键。 * 4. 下载出口层分两条独立通道: * - API 下载通道:直接下载 / 复制直链 / 推送 IDM(可选) * - Aria2 下载通道:JSON-RPC 推送 / 生成 aria2c 命令行 / 连通性测试 * 另提供批量出口(复制全部直链、复制全部命令行、全部推送)。 * 5. 网盘适配层(Providers)做成可插拔表,新增网盘只需追加一项 * (页面判据 + 挂载点 + 请求头 + collect / resolve)。 * 6. UI 遵循仓库统一的轻量设计规范:面板 + 分组卡片 + Toast,零第三方依赖。 */ (function () { "use strict"; const VERSION = "1.0.0"; const KEY = { aria: "nd.aria", opt: "nd.opt", flag: "nd.installed" }; /* ========================================================================== * 1. 工具层 * ========================================================================== */ const util = { type(o) { return Object.prototype.toString.call(o).replace(/^\[object (.+)\]$/, "$1").toLowerCase(); }, isStr(o) { return typeof o === "string"; }, isFn(o) { return typeof o === "function"; }, isObj(o) { return util.type(o) === "object"; }, blank(o) { return o === null || o === undefined || o === ""; }, /** 过滤文件系统非法字符,供下载器安全落盘 */ fixFilename(name, fallback = "download") { const safe = String(name === undefined || name === null ? "" : name) .replace(/[!?&|`"'*\/:<>\\]/g, "_") .replace(/[\r\n\t]/g, " ") .trim(); return safe || fallback; }, /** 取扩展名(大写,无扩展名返回空串) */ ext(name) { const m = String(name || "").match(/\.([A-Za-z0-9]{1,8})$/); return m ? m[1].toUpperCase() : ""; }, /** 字节数 → 可读大小 */ sizeFormat(bytes) { const v = Number(bytes); if (!isFinite(v) || v < 0) return "-"; if (v < 1024) return v + " B"; const units = ["KiB", "MiB", "GiB", "TiB", "PiB"]; let n = v, i = -1; do { n /= 1024; i++; } while (n >= 1024 && i < units.length - 1); const fixed = n >= 100 ? 0 : n >= 10 ? 1 : 2; return n.toFixed(fixed) + " " + units[i]; }, pad(n) { return n < 10 ? "0" + n : String(n); }, /** 剩余秒数 → 人类可读,24 小时制,无 AM/PM */ remain(sec) { if (!isFinite(sec) || sec < 0) return "--:--"; let s = Math.floor(sec); const d = Math.floor(s / 86400); s %= 86400; const h = Math.floor(s / 3600); s %= 3600; const m = Math.floor(s / 60); s = s % 60; if (d > 0) return `${d}天 ${util.pad(h)}:${util.pad(m)}:${util.pad(s)}`; if (h > 0) return `${util.pad(h)}:${util.pad(m)}:${util.pad(s)}`; return `${util.pad(m)}:${util.pad(s)}`; }, /** 请求头归一化:支持对象或原始字符串,键名统一为驼峰 */ standHeaders(headers) { const raw = {}; if (util.isStr(headers)) { headers.split(/[\r\n]+/).forEach((line) => { const idx = line.indexOf(":"); if (idx <= 0) return; const k = line.slice(0, idx).trim().toLowerCase(); const v = line.slice(idx + 1).trim(); if (k) raw[k] = v; }); } else if (util.isObj(headers)) { for (const k in headers) { if (!Object.prototype.hasOwnProperty.call(headers, k)) continue; const v = headers[k]; if (v === undefined || v === null) continue; raw[String(k).toLowerCase()] = util.isObj(v) ? JSON.stringify(v) : String(v); } } const out = {}; for (const k in raw) { const camel = k.split("-").map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w)).join("-"); out[camel] = raw[k]; } return out; }, /** 归一化请求头 → Aria2 的 header 数组:["Key: Value"] */ headersToArray(headers) { const obj = util.standHeaders(headers); return Object.keys(obj).map((k) => `${k}: ${obj[k]}`); }, /** 解析 GM_xmlhttpRequest 的 responseHeaders 原始字符串 */ parseHeaders(text) { const obj = {}; String(text || "").split(/[\r\n]+/).forEach((line) => { const idx = line.indexOf(":"); if (idx <= 0) return; const k = line.slice(0, idx).trim().toLowerCase(); const v = line.slice(idx + 1).trim(); if (k) obj[k] = v; }); return obj; }, /** * 从 URL 猜文件名。 * 仅当路径末段**确实带扩展名**时才采用 —— 否则会把 usercode / report / detail * 这类接口路径段误当文件名,在候选列表里刷出一堆无意义条目。 */ nameFromUrl(url) { try { const path = new URL(url).pathname; const base = decodeURIComponent(path.split("/").filter(Boolean).pop() || ""); return /\.[A-Za-z0-9]{1,8}$/.test(base) ? base : ""; } catch (e) { return ""; } }, /** 拼接 a 标签,避免 innerHTML 注入 */ escapeHtml(s) { return String(s === undefined || s === null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }, sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }, /** 生成短 id,用于列表项与任务标识 */ uid() { return "nd" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } }; /* ========================================================================== * 2. 存储层 * ========================================================================== */ const DEFAULTS = { [KEY.aria]: { domain: "http://localhost", port: "6800", path: "/jsonrpc", token: "", dir: "" }, [KEY.opt]: { showIdm: false, // 是否在直链行上显示「IDM」出口(默认关闭) entryStyle: "auto", // 入口按钮配色:auto 跟随宿主页面 | light 白色 | dark 暗色 firstTip: true, // 首次安装提示 history: [] // 最近提交的任务(最多 20 条) } }; const store = { _mem: {}, raw(key) { try { if (typeof GM_getValue === "function") return GM_getValue(key, undefined); } catch (e) { /* 降级 */ } try { const s = localStorage.getItem(key); return s === null ? undefined : JSON.parse(s); } catch (e) { return store._mem[key]; } }, get(key) { const v = store.raw(key); if (v === undefined || v === null) { const def = DEFAULTS[key]; return util.isObj(def) || Array.isArray(def) ? JSON.parse(JSON.stringify(def)) : def; } // 统一返回深拷贝:调用方就地修改不得污染存储 return util.isObj(v) || Array.isArray(v) ? JSON.parse(JSON.stringify(v)) : v; }, set(key, value) { try { if (typeof GM_setValue === "function") { GM_setValue(key, value); return; } } catch (e) { /* 降级 */ } store._mem[key] = value; try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* 忽略 */ } }, /** 局部更新一个对象型配置 */ patch(key, partial) { const base = store.get(key); const next = Object.assign({}, util.isObj(base) ? base : {}, partial || {}); store.set(key, next); return next; }, aria() { return store.get(KEY.aria); }, opt() { return store.get(KEY.opt); }, /** 记录一条历史(仅保留最近 20 条) */ log(entry) { const opt = store.opt(); const history = Array.isArray(opt.history) ? opt.history.slice(0, 19) : []; history.unshift(Object.assign({ time: Date.now() }, entry)); store.patch(KEY.opt, { history }); } }; /* ========================================================================== * 3. 网络层 * ========================================================================== */ const net = { /** 原始 GM_xmlhttpRequest 封装 */ raw(opt) { const gm = (typeof GM_xmlhttpRequest === "function") ? GM_xmlhttpRequest : (typeof GM !== "undefined" && GM && util.isFn(GM.xmlHttpRequest) ? GM.xmlHttpRequest.bind(GM) : null); if (!gm) throw new Error("当前脚本管理器不提供 GM_xmlhttpRequest,无法发起跨域请求。"); return gm(Object.assign({ timeout: 30000 }, opt)); }, /** POST JSON,用于 Aria2 JSON-RPC */ rpc(url, payload) { return new Promise((resolve, reject) => { net.raw({ method: "POST", url, headers: { "Content-Type": "application/json" }, data: JSON.stringify(payload), responseType: "text", onload(res) { let data; try { data = JSON.parse(res.responseText); } catch (e) { return reject(new Error(`响应不是合法 JSON(HTTP ${res.status})`)); } if (data && data.error) { return reject(new Error(`RPC 返回错误 ${data.error.code}:${data.error.message}`)); } resolve(data); }, onerror() { reject(new Error("无法连接到 RPC 服务,请确认地址、端口与跨域设置。")); }, ontimeout() { reject(new Error("RPC 请求超时,请检查服务是否在运行。")); } }); }); }, /** POST JSON:用于调用网盘自身接口换取直链 */ postJson(url, body, headers) { return new Promise((resolve, reject) => { net.raw({ method: "POST", url, headers: util.standHeaders(headers), data: JSON.stringify(body || {}), responseType: "text", onload(res) { let data = null; try { data = JSON.parse(res.responseText); } catch (e) { data = null; } if (data === null) return reject(new Error("接口未返回合法 JSON(HTTP " + res.status + ")")); resolve(data); }, onerror: () => reject(new Error("请求失败:" + url)), ontimeout: () => reject(new Error("请求超时:" + url)) }); }); }, /** GET 文本 */ async text(url, headers) { return new Promise((resolve, reject) => { net.raw({ method: "GET", url, headers: util.standHeaders(headers), responseType: "text", onload: (res) => resolve(res), onerror: () => reject(new Error("请求失败:" + url)), ontimeout: () => reject(new Error("请求超时:" + url)) }); }); } }; /* ========================================================================== * 4. Aria2 通道(核心能力之一) * ========================================================================== */ const aria = { /** 由配置拼出 RPC 地址,容忍用户填写的各种写法 */ rpcUrl(cfg) { let domain = String((cfg && cfg.domain) || "http://localhost").trim().replace(/\/+$/, ""); if (!/^https?:\/\//i.test(domain)) domain = "http://" + domain; const port = util.blank(cfg && cfg.port) ? "" : ":" + String(cfg.port).trim().replace(/^:/, ""); let path = String((cfg && cfg.path) || "/jsonrpc").trim(); if (path && path.charAt(0) !== "/") path = "/" + path; return domain + port + path; }, /** 把 domain + port 并成一行地址,供设置界面显示(省掉一个输入框) */ addressOf(cfg) { const domain = String((cfg && cfg.domain) || "").trim().replace(/\/+$/, ""); const port = util.blank(cfg && cfg.port) ? "" : String(cfg.port).trim().replace(/^:/, ""); return domain + (port ? ":" + port : ""); }, /** * 把界面上那一行地址拆回 domain + port。 * 容忍 `localhost:6800`、`http://localhost:6800`、`http://localhost:6800/` 等写法; * 未写端口时 port 置空,由 rpcUrl 按原样拼接。 */ parseAddress(text) { let s = String(text || "").trim().replace(/\/+$/, ""); if (!s) return { domain: "", port: "" }; const m = s.match(/^(https?):\/\//i); const proto = m ? m[1].toLowerCase() : "http"; if (m) s = s.slice(m[0].length); s = s.split("/")[0]; // 丢掉可能被粘贴进来的路径 let host = s; let port = ""; const i = s.lastIndexOf(":"); if (i > 0 && /^\d+$/.test(s.slice(i + 1))) { host = s.slice(0, i); port = s.slice(i + 1); } return { domain: proto + "://" + host, port }; }, /** rpc-secret 参数:设置为空时不传 secret,兼容未启用密钥的 Aria2 */ _secret(token) { return util.blank(token) ? null : "token:" + String(token).trim(); }, /** 构建 aria2.addUri 请求体 */ buildAddUri(cfg, file) { const params = []; const secret = aria._secret(cfg && cfg.token); if (secret) params.push(secret); const options = {}; if (cfg && !util.blank(cfg.dir)) options.dir = String(cfg.dir); if (file.out) options.out = util.fixFilename(file.out); const header = util.headersToArray(file.headers); if (header.length) options.header = header; params.push([file.url], options); return { id: Date.now(), jsonrpc: "2.0", method: "aria2.addUri", params }; }, /** 构建 aria2.getVersion 请求体,用于连通性测试 */ buildVersion(cfg) { const params = []; const secret = aria._secret(cfg && cfg.token); if (secret) params.push(secret); return { id: Date.now(), jsonrpc: "2.0", method: "aria2.getVersion", params }; }, /** 推送单个链接到 Aria2 */ async addUri(cfg, file) { const url = aria.rpcUrl(cfg); const data = await net.rpc(url, aria.buildAddUri(cfg, file)); return data.result ? "success" : "fail"; }, /** 连通性测试:成功返回 { version, enabledFeatures } */ async test(cfg) { const url = aria.rpcUrl(cfg); const data = await net.rpc(url, aria.buildVersion(cfg)); return data.result || null; }, /** 生成 aria2c 命令行,供未启用 RPC 的场景直接复制使用 */ toCommand(file) { const parts = ["aria2c", `"${file.url}"`]; if (file.out) parts.push(`--out "${util.fixFilename(file.out)}"`); util.headersToArray(file.headers).forEach((h) => parts.push(`--header="${h}"`)); parts.push("--continue=true"); parts.push("--max-connection-per-server=16"); parts.push("--split=16"); return parts.join(" "); } }; /* ========================================================================== * 5. IDM 推送通道 * ========================================================================== */ const idm = { _seq: 0, /** 通过 IDM 本地捕获端口推送链接(需较新版本 IDM) */ push(file) { return new Promise((resolve) => { const seq = ++idm._seq; const time = Date.now(); const url = `http://127.0.0.1:1001/client/1?seq=${seq}`; const ext = util.ext(file.out); const headersText = util.headersToArray(file.headers).join("\n") + "\n"; const field = (k, v) => { if (v === undefined || v === null) return ""; const s = String(v); // IDM 按字节长度解析字段,必须用 Blob 计算 let len = s.length; try { len = new Blob([s]).size; } catch (e) { /* 忽略 */ } return `${k}=${len}:${s}`; }; const fields = [ field(4, ext), field(6, file.url), field(7, location.origin), field(11, headersText), field(100, util.fixFilename(file.out)) ]; const data = `MSG#${seq}#13#1#10241:${seq + 1000}:0:${time}:0:1:2:${file.size || 0}:0,${fields.join(",")};`; net.raw({ method: "POST", url, data, responseType: "text", onload: (res) => resolve(String(res.responseText || "").endsWith(`${seq}:3;`) ? "success" : "fail"), onerror: () => resolve("fail"), ontimeout: () => resolve("fail") }); }); } }; /* ========================================================================== * 6. API 下载通道(核心能力之二) * ========================================================================== */ const outlet = { /** 直接下载:用隐藏 iframe 触发浏览器原生下载,避免新标签页被拦截 */ direct(url) { const iframe = document.createElement("iframe"); iframe.style.cssText = "display:none;width:0;height:0;border:0;position:absolute;left:-9999px"; iframe.src = url; document.body.appendChild(iframe); setTimeout(() => { try { iframe.remove(); } catch (e) { /* 忽略 */ } }, 60000); }, /** 复制到剪贴板,带降级方案 */ copy(text) { const value = String(text === undefined || text === null ? "" : text); try { if (typeof GM_setClipboard === "function") { GM_setClipboard(value, "text"); return true; } } catch (e) { /* 降级 */ } try { const ta = document.createElement("textarea"); ta.value = value; ta.style.cssText = "position:fixed;left:-9999px;top:0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); return true; } catch (e) { return false; } } }; /* ========================================================================== * 7. 网盘适配层(可插拔表,新增网盘只需追加一项) * ========================================================================== */ /** * 每项字段说明: * id 唯一标识 * name 展示名 * match 宿主域名匹配 * pages 页面类型识别:按 pathname 判定 home / share,逐网盘实测整理, * 用于挑选合适的挂载点,避免在登录页等无关页面乱注入。 * mount 工具栏挂载点,按页面类型分组:{ home: [...], share: [...] }。 * 必须指向宿主既有容器,且**不得使用 :has()** —— 旧内核会直接抛异常。 * header 直链请求所需请求头(Aria2 / 命令行 / IDM 均使用) * hint 未识别到文件时的提示 */ const providers = [ { id: "baidu", name: "百度网盘", match: /(^|\.)(pan|yun)\.baidu\.com$/i, pages: { home: /^\/(disk\/(home|main)|youth\/pan\/main)/, share: /^\/(s|share)\// }, mount: { home: [".wp-s-agile-tool-bar__header"], share: [".module-share-top-bar .x-button-box"] }, header: { "User-Agent": "pan.baidu.com", "Referer": "https://pan.baidu.com/" }, hint: "百度网盘:请在文件列表中勾选目标文件,再点「获取直链」。", /** 读取勾选文件:取数自 Vue 实例(新旧两版页面各有一条通道) */ collect() { const out = []; let list = []; const vue = pageState.findVue(document.querySelector(".file-list")); if (vue && Array.isArray(vue.allFileList)) { list = vue.allFileList.filter((it) => !!it.selected); } if (!list.length) { const wp = pageState.findVue(document.querySelector(".wp-s-core-pan")); if (wp && Array.isArray(wp.selectedList)) list = wp.selectedList; } list.forEach((f) => { out.push({ fid: f.fs_id || f.fid, name: f.server_filename || f.name || f.path, size: f.size, dir: providerApi.isFolder(f), path: f.path }); }); return out; } }, { id: "alipan", name: "阿里云盘", match: /(^|\.)(alipan|aliyundrive)\.com$/i, pages: { home: /^\/drive/, share: /^\/(s|share)\// }, mount: { home: ['[class^="header--"]>[class^="actions--"]'], share: ['[class^="banner--"]>[class^="right--"]'] }, header: { "Referer": "https://www.alipan.com/" }, hint: "阿里云盘:请在文件列表中勾选目标文件,再点「获取直链」。", /** 读取勾选文件:取数自 React 组件的 props */ collect() { const out = []; const dom = document.querySelector('[class^="node-list-table-view--"]'); const props = pageState.propsOf(pageState.findReact(dom, 1)); if (!props) return out; const files = props.dataSource || []; const keys = String(props.selectedKeys || "").split(",").filter(Boolean); files.forEach((f) => { if (keys.indexOf(f.fileId) < 0) return; out.push({ fid: f.fileId, name: f.name, size: f.size, dir: providerApi.isFolder(f) }); }); return out; } }, { id: "quark", name: "夸克 / UC 网盘", match: /(^|\.)(quark|uc)\.cn$/i, pages: { home: /^\/list/, share: /^\/(s|share)\// }, mount: { home: [".btn-operate .btn-main"], share: [".share-btns", ".file-info-share-buttom"] }, /** * 夸克与 UC 共用同一套采集与换链逻辑,但接口地址与客户端 UA 不同 * (两家客户端各自要求),按当前域名取用。 */ api: { quark: { endpoint: "https://drive-pc.quark.cn/1/clouddrive/file/download?entry=ft&fr=pc&pr=ucpro", ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.20.0 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch" }, uc: { endpoint: "https://pc-api.uc.cn/1/clouddrive/file/download?entry=ft&fr=pc&pr=UCBrowser", ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) uc-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch" } }, /** 直链需要 Referer 与 Cookie 才不被判为盗链 */ credential: true, hint: "请在文件列表中勾选要下载的文件;文件夹无法取直链,请进入文件夹后再勾选其中的文件。", /** 读取当前勾选的文件:取数自 React 组件的 props,而非 DOM */ collect() { const out = []; const dom = document.getElementsByClassName("file-list")[0]; const props = pageState.propsOf(pageState.findReact(dom)); if (!props) return out; const stoken = props.stoken || ""; const files = props.list || []; const keys = props.selectedRowKeys || []; for (let i = 0; i < files.length; i++) { const f = files[i]; if (keys.indexOf(f.fid) < 0) continue; out.push({ fid: f.fid, name: f.file_name, size: f.size, dir: providerApi.isFolder(f), stoken, shareToken: f.share_fid_token }); } return out; }, /** 调网盘接口把勾选的文件换成直链 */ async resolve(files, page) { const p = providerApi.byId("quark"); const conf = p.api[providerApi.flavor(p) || "quark"]; const API = conf.endpoint; const BATCH = 15; // 文件夹没有直链可换,先剔除;一个文件都不剩时给出可操作的提示 const list = (files || []).filter((f) => !f.dir); if (!list.length) throw new Error("勾选的都是文件夹 —— 文件夹无法取直链,请进入文件夹后勾选其中的文件。"); let pwdId = ""; let stoken = ""; if (page === "share") { pwdId = providerApi.sharePwdId(); if (!pwdId) throw new Error("无法从页面提取分享 ID,请刷新页面后重试。"); stoken = (list[0] && list[0].stoken) || ""; } // 夸克直链会校验 Referer 与 Cookie,只带 UA 会被判为盗链 const headers = providerApi.downloadHeaders(p); const out = []; for (let i = 0; i < list.length; i += BATCH) { const batch = list.slice(i, i + BATCH); const body = { fids: batch.map((f) => f.fid) }; if (page === "share") { body.fids_token = batch.map((f) => f.shareToken); body.pwd_id = pwdId; body.stoken = stoken; } const res = await net.postJson(API, body, { "Content-Type": "application/json", "User-Agent": conf.ua }); if (res && res.code === 31001) throw new Error("请先在浏览器里登录网盘,再重试。"); if (res && res.code === 23018) throw new Error("超出游客可获取的大小上限,请登录网盘后重试。"); if (res && res.code !== 0) throw new Error("接口返回 code=" + res.code + (res.message ? ":" + res.message : "")); (res.data || []).forEach((d) => { if (d && d.download_url) out.push({ url: d.download_url, name: d.file_name || "", size: d.size, headers }); }); if (i + BATCH < list.length) await util.sleep(1000); // 节流,避免触发风控 } return out; } }, { id: "pan123", name: "123 云盘", match: /(^|\.)(123pan\.(com|cn)|123684\.com|123865\.com|123952\.com|123912\.com)$/i, pages: { home: /^\/$/, share: /^\/s\// }, mount: { home: [".home-operator .home-operator-button-group"], share: [".conter .rightInfo", ".single-file-sharing-container-content-file-operate"] }, header: {}, hint: "123 云盘:请在文件列表中勾选目标文件,再点「获取直链」。", /** 读取勾选文件:取数自 React 组件的 props(列表页与分享页各一条通道) */ collect() { const out = []; const dom = document.querySelector(".ant-table-wrapper, .tiled-list, .file-list, .single-file-sharing-container-content"); const props = pageState.propsOf(pageState.findReact(dom)); if (!props) return out; const files = props.dataSource || props.loadedFileList || props.files || []; const keys = (props.rowSelection && props.rowSelection.selectedRowKeys) || []; files.forEach((f) => { if (f.checked === true || keys.indexOf(f.FileId) >= 0) { out.push({ fid: f.FileId, name: f.FileName, size: f.Size, dir: providerApi.isFolder(f) }); } }); if (props.file && props.file.S3KeyFlag) { out.push({ fid: props.file.FileId, name: props.file.FileName, size: props.file.Size, dir: false }); } return out; } }, { id: "mcloud", name: "移动云盘", match: /(^|\.)(yun|caiyun)\.139\.com$/i, pages: { home: /^\/w/, share: /^\/(link|shareweb)/ }, mount: { home: [".top_button"], share: [".top-btns"] }, header: {}, hint: "移动云盘:勾选文件后在网盘里点一次「下载」,直链会被自动截获。", /** 读取勾选:取自 Vue 实例(列表页与首页两条通道,字段以 content* 为主) */ collect() { const out = []; let list = []; const wrap = pageState.findVue(document.querySelector(".main_file_list")); if (wrap && Array.isArray(wrap.selectList)) list = wrap.selectList.map((v) => v.item || v); if (!list.length) { const home = pageState.findVue(document.querySelector(".home-page")); const w = home && home._computedWatchers; if (w && w.fileList && w.dirList && w.selectedFile && w.selectedDir) { const files = (w.fileList.value || []).filter((v, i) => (w.selectedFile.value || []).indexOf(i) >= 0); const dirs = (w.dirList.value || []).filter((v, i) => (w.selectedDir.value || []).indexOf(i) >= 0); list = files.concat(dirs); } } list.forEach((f) => { out.push({ fid: f.contentID || f.coID, name: f.contentName || f.coName, size: Number(f.contentSize || f.coSize) || 0, dir: !!(f.dirEtag || f.caName) }); }); return out; } }, { id: "tcloud", name: "天翼云盘", match: /(^|\.)cloud\.189\.cn$/i, pages: { home: /^\/web\/main/, share: /^\/web\/share/ }, mount: { home: ['[class*="FileHead_file-head-left"]'], share: [".nav-opea"] }, header: {}, hint: "天翼云盘:勾选文件后在网盘里点一次「下载」,直链会被自动截获。", /** 读取勾选:取自 Vue 实例(列表页与文件详情页两条通道) */ collect() { const out = []; let list = []; const wrap = pageState.findVue(document.querySelector(".c-file-list")); if (wrap && Array.isArray(wrap.selectedList)) list = wrap.selectedList; if (!list.length) { const detail = pageState.findVue(document.querySelector(".info-detail")); if (detail && detail.fileDetail) list = [detail.fileDetail]; } list.forEach((f) => { out.push({ fid: f.fileId, name: f.fileName || f.name, size: Number(f.size) || 0, dir: providerApi.isFolder(f) }); }); return out; } }, { id: "xunlei", name: "迅雷云盘", match: /(^|\.)pan\.xunlei\.com$/i, pages: { home: /^\/$/, share: /^\/(s|share)\// }, mount: { home: ['[class^="FileMenu__menu--"]'], share: ['[class^="Share__batchActionBox--"]'] }, header: {}, hint: "迅雷云盘:勾选文件后在网盘里点一次「下载」,直链会被自动截获。", /** 读取勾选:每个列表项的 Vue 实例自带 selected 数组与 info 对象 */ collect() { const out = []; const doms = document.querySelectorAll('[class*="SourceListItem__item--"]'); for (let i = 0; i < doms.length; i++) { const vue = pageState.findVue(doms[i]); if (!vue || !vue.info) continue; if (!Array.isArray(vue.selected) || vue.selected.indexOf(vue.info.id) < 0) continue; out.push({ fid: vue.info.id, name: vue.info.name, size: Number(vue.info.size) || 0, dir: vue.info.kind !== "drive#file" }); } return out; } } ]; const providerApi = { /** 当前页面命中的网盘适配器,未命中返回 null */ current() { let host = ""; try { host = location.hostname; } catch (e) { host = ""; } if (!host) return null; for (let i = 0; i < providers.length; i++) { if (providers[i].match.test(host)) return providers[i]; } return null; }, byId(id) { for (let i = 0; i < providers.length; i++) { if (providers[i].id === id) return providers[i]; } return null; }, /** * 判定一个文件项是否为文件夹。 * 各网盘字段名不同 —— 夸克/UC 用 `file`(false 表示文件夹),百度用 `isdir`, * 阿里用 `isDir`,123 用 `IsFolder`。早年只判 `dir`,在夸克上恒为 false, * 于是文件夹被当成文件送进换链接口,什么也取不回来。这里统一收敛。 */ isFolder(item) { const it = item || {}; if (typeof it.file === "boolean") return !it.file; // 夸克 / UC:file=false 即文件夹 if (typeof it.isdir === "boolean") return it.isdir; // 百度 if (typeof it.isDir === "boolean") return it.isDir; // 阿里云盘 if (typeof it.IsFolder === "boolean") return it.IsFolder; // 123 云盘 if (typeof it.dir === "boolean") return it.dir; // 兜底字段 if (typeof it.IsDir === "boolean") return it.IsDir; return false; }, /** * 当前命中的是夸克还是 UC。两者采集逻辑相同, * 但换链接口地址与客户端 UA 不同,必须按域名区分。 */ flavor(provider) { const p = provider || providerApi.current(); if (!(p && p.api)) return ""; let host = ""; try { host = (typeof location !== "undefined" && location.hostname) || ""; } catch (e) { host = ""; } return /(^|\.)uc\.cn$/i.test(host) ? "uc" : "quark"; }, /** * 直链下载所需的请求头。 * UA 取自 provider 的 api 表(夸克 / UC 各一份);对标记了 credential 的网盘 * 再补上页面级 Referer 与 Cookie —— 这些网盘的直链会校验这两项,只带 UA 会 403。 */ downloadHeaders(provider) { const p = provider || providerApi.current(); const out = {}; if (!p) return out; if (p.api) { const conf = p.api[providerApi.flavor(p) || "quark"]; if (conf && conf.ua) out["User-Agent"] = conf.ua; } else if (p.header) { for (const k in p.header) { if (Object.prototype.hasOwnProperty.call(p.header, k)) out[k] = p.header[k]; } } if (!p.credential) return out; try { if (typeof location !== "undefined" && location.origin && location.origin !== "null") { out.Referer = location.origin + "/"; } } catch (e) { /* 忽略 */ } try { if (typeof document !== "undefined" && document.cookie) out.Cookie = document.cookie; } catch (e) { /* 忽略 */ } return out; }, /** * 夸克分享页的分享 ID。页面版本不同,参数所在位置也不同, * 依次尝试:新版统计对象 → 旧版统计对象 → URL 路径。 */ sharePwdId() { const w = pageState.win(); const stat = (w && w.factStat) || {}; const ut = stat.ut && stat.ut.baseParams && stat.ut.baseParams.pwd_id; if (ut) return ut; const wa = stat.wa && stat.wa.customStatParams && stat.wa.customStatParams.pwd_id; if (wa) return wa; try { const m = String(location.pathname).match(/^\/(?:s|share)\/([A-Za-z0-9]+)/); return m ? m[1] : ""; } catch (e) { return ""; } }, /** 按 pathname 判定当前页面类型:home | share | ""(无法判定) */ pageType(provider) { const p = provider || providerApi.current(); if (!p || !p.pages) return ""; let path = ""; try { path = location.pathname; } catch (e) { path = ""; } if (!path) return ""; if (p.pages.home && p.pages.home.test(path)) return "home"; if (p.pages.share && p.pages.share.test(path)) return "share"; return ""; }, /** * 从元素上挑出真正可用的直链。 * 页面常见坑:href 是 javascript:; / # 这类占位值,真实地址藏在 data-* 属性里, * 因此必须逐属性尝试,取第一个 http(s) 值,而不是「href 优先且一旦取到就放弃」。 */ pickHref(el) { const keys = ["href", "data-download-url", "data-dlink", "data-url"]; for (let i = 0; i < keys.length; i++) { const v = String(el.getAttribute(keys[i]) || "").trim(); if (/^https?:\/\//i.test(v)) return v; } return ""; }, /** * 通用文件收集策略: * 依次扫描页面上的链接型元素,挑出「看起来是下载直链」的地址。 * 站点改版后只需在对应 provider 上重写 collect 即可,不影响其它网盘。 */ collect(provider) { if (provider && util.isFn(provider.collect)) return provider.collect(); const found = []; const seen = {}; const isDirect = (href) => /^https?:\/\//i.test(href) && !/\.(html?|js|css|png|jpe?g|gif|svg|webp|ico)(\?|$)/i.test(href) && /(download|dlink|file|share|content)/i.test(href); const consider = (href, name) => { if (!href || seen[href] || !isDirect(href)) return; seen[href] = true; found.push({ id: util.uid(), name: util.fixFilename(name || util.nameFromUrl(href) || "未命名文件"), size: 0, url: href, headers: (provider && provider.header) || {} }); }; const nodes = document.querySelectorAll("a[href], [data-download-url], [data-dlink], [data-url]"); for (let i = 0; i < nodes.length && found.length < 50; i++) { const el = nodes[i]; const href = providerApi.pickHref(el); const name = el.getAttribute("download") || el.getAttribute("data-name") || el.getAttribute("title") || (el.textContent || "").trim().slice(0, 120); consider(href, name); } return found; } }; /* ========================================================================== * 8. 引擎层:下载编排 * ========================================================================== */ const engine = { /** 兜底请求头:补上浏览器 UA,避免部分 CDN 拒绝空 UA */ mergeHeaders(headers) { const ua = (typeof navigator !== "undefined" && navigator.userAgent) || ""; const base = ua ? { "User-Agent": ua } : {}; return Object.assign(base, util.standHeaders(headers)); }, /** 推送到 Aria2 */ async pushAria(file) { const cfg = store.aria(); const payload = { url: file.url, out: file.name, headers: engine.mergeHeaders(file.headers) }; const res = await aria.addUri(cfg, payload); store.log({ kind: "aria2", name: file.name, ok: res === "success" }); return res; }, /** 批量推送到 Aria2 */ async pushAriaBatch(files) { let ok = 0; for (let i = 0; i < files.length; i++) { try { if (await engine.pushAria(files[i]) === "success") ok++; } catch (e) { /* 单条失败不阻断其余 */ } await util.sleep(120); } return { ok, total: files.length }; }, /** 读取当前页面勾选的文件(各网盘自行实现 collect) */ selected(provider) { const p = provider || providerApi.current(); if (!p || !util.isFn(p.collect)) return []; try { return p.collect() || []; } catch (e) { return []; } }, /** 勾选文件 → 调网盘接口换直链 → 入库候选池 */ async resolveSelected(provider) { const p = provider || providerApi.current(); if (!p) throw new Error("当前页面不在已内置的网盘范围内"); const files = engine.selected(p); if (!files.length) throw new Error("请先在文件列表中勾选要下载的文件"); // 文件夹换不出直链,提前说清楚,不把注定无结果的请求打到网盘 const real = files.filter((f) => !f.dir); if (!real.length) throw new Error("勾选的都是文件夹 —— 文件夹无法取直链,请进入文件夹后勾选其中的文件。"); if (!util.isFn(p.resolve)) { throw new Error(p.name + " 暂不支持自动换取直链。请在网盘里点一次「下载」,由脚本自动截获。"); } const links = await p.resolve(files, providerApi.pageType(p)); let added = 0; links.forEach((l) => { if (catcher.put(l.url, l.name, l.size, "接口", true, l.headers)) added++; }); return { total: links.length, added, skipped: files.length - real.length }; }, /** 测试 Aria2 连通性 */ async testAria(cfg) { return aria.test(cfg || store.aria()); }, /** 生成 aria2c 命令行文本 */ commandOf(file) { return aria.toCommand({ url: file.url, out: file.name, headers: engine.mergeHeaders(file.headers) }); }, /** 多文件按行拼接(\r\n),供批量复制使用 */ joinLines(files, mapper) { return (files || []).map(mapper).filter(Boolean).join("\r\n"); }, /** 全部直链文本(每行一条) */ allLinks(files) { return engine.joinLines(files, (f) => f.url); }, /** 全部 aria2c 命令行文本(每行一条) */ allCommands(files) { return engine.joinLines(files, (f) => engine.commandOf(f)); } }; /* ========================================================================== * 9. 直链捕获层:从网络请求里截下真正的下载地址 * ========================================================================== */ /** * 网盘页面的文件列表本身**不含直链** —— 地址是用户点击「下载」时由页面 JS * 实时请求接口换回来的。因此这里改在网络层拦截:只要用户在网盘里正常点过一次 * 下载,脚本就能把那条直链截下来,供面板一键分发。这也是「开箱即用」的关键: * 无需为每个网盘实现各自的直链换取逻辑。 */ const catcher = { /** 候选池(新的在前,超出上限截断) */ pool: [], MAX: 60, hooked: false, /** 仅这些键名承载的 http(s) 值才算直链,避免把普通字段误当直链 */ DIRECT_KEY: /^(dlink|download_?url|download_?link|download_?uri|durl|dl_?link)$/i, /** * 强特征:命中即可判定为直链。 * 刻意只保留高置信模式 —— 早先版本还允许「弱域名特征 + 带查询串」入池, * 结果把大量普通接口请求也收了进来(候选列表里全是 usercode / report 这类噪声)。 */ STRONG: /(dlink=|dlink\/|dl\.[a-z0-9-]+\.|\/download\?|\/file\/download\?|getdlink)/i, /** 接口路径特征:命中即排除(无论是否带查询串) */ API_PATH: /(\/api\/|get_?download_?url|get_?share_?link|\/download_?info|sharedownload|getfilemetas|\/report|\/config)/i, /** 排除:页面与静态资源 */ EXCLUDE: /\.(html?|js|css|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|map)(\?|$)/i, /** URL 层面的高置信判定 */ isDirectUrl(url) { const u = String(url || ""); if (!/^https?:\/\//i.test(u)) return false; if (catcher.EXCLUDE.test(u)) return false; if (catcher.API_PATH.test(u)) return false; return catcher.STRONG.test(u); }, /** * 响应头层面的判定:服务端明确声明「附件下载」时,该请求 URL 就是直链。 * 这条通道比猜 URL 可靠得多,用来补齐那些不带强特征的地址。 */ fromResponse(url, headers) { const u = String(url || ""); if (!/^https?:\/\//i.test(u)) return false; if (catcher.EXCLUDE.test(u)) return false; const cd = String((headers && (headers["content-disposition"] || headers["Content-Disposition"])) || ""); const ct = String((headers && (headers["content-type"] || headers["Content-Type"])) || ""); if (/attachment/i.test(cd) || /application\/octet-stream/i.test(ct)) { return catcher.put(u, catcher.nameFromDisposition(cd), 0, "响应头", true); } return false; }, /** 从 Content-Disposition 里取文件名 */ nameFromDisposition(cd) { const m = String(cd || "").match(/filename\*?=(?:UTF-8''|")?([^";]+)/i); if (!m) return ""; try { return decodeURIComponent(m[1].replace(/"/g, "").trim()); } catch (e) { return m[1].trim(); } }, /** 从响应体里递归找直链字段(深度上限 4,避免深递归) */ fromBody(data, depth) { const level = depth || 0; if (!data || level > 4) return; if (Array.isArray(data)) { data.forEach((item) => catcher.fromBody(item, level + 1)); return; } if (typeof data !== "object") return; for (const key in data) { if (!Object.prototype.hasOwnProperty.call(data, key)) continue; const val = data[key]; if (typeof val === "string") { if (catcher.DIRECT_KEY.test(key) && /^https?:\/\//i.test(val)) { catcher.put(val, data.server_filename || data.filename || data.name, data.size || data.file_size, "接口响应", true); } } else if (val && typeof val === "object") { catcher.fromBody(val, level + 1); } } }, /** * 入库(按 URL 去重)。 * trusted = true 表示调用方已通过更可靠的渠道(响应体字段 / 响应头)判定过, * 无需再过一遍 URL 启发式 —— 否则会被 URL 规则误杀,出现「判定了却又被丢掉」。 */ put(url, name, size, from, trusted, headers) { const u = String(url || "").trim(); if (!trusted && !catcher.isDirectUrl(u)) return false; const rawName = String(name || "").trim() || util.nameFromUrl(u); const label = util.fixFilename(rawName || "未命名文件"); /** * 主键是文件名,不是 URL —— 网盘直链每次换取都带新签名, * 按 URL 去重挡不住「重新获取」造成的重复(同名文件会越堆越多)。 * 同名即同一文件:覆盖为最新一条,签名与请求头随之更新。 * 实在拿不到名字时退回按 URL 去重,避免所有无名条目挤成一条。 */ const key = rawName ? "name:" + label.toLowerCase() : "url:" + u; for (let i = 0; i < catcher.pool.length; i++) { const it = catcher.pool[i]; if (it.url === u) return false; // 完全相同,无事可做 if (it.key !== key) continue; it.url = u; it.size = Number(size) || it.size; if (headers) it.headers = headers; it.from = from || it.from; it.time = Date.now(); catcher.pool.splice(i, 1); catcher.pool.unshift(it); // 最新一条置顶 return true; } catcher.pool.unshift({ id: util.uid(), key, url: u, name: label, size: Number(size) || 0, from: from || "请求", headers: headers || null, time: Date.now() }); if (catcher.pool.length > catcher.MAX) catcher.pool.length = catcher.MAX; return true; }, /** 安装 hook。必须在文档解析前调用,否则会漏掉早发的请求 */ install() { if (catcher.hooked) return; catcher.hooked = true; const win = (typeof unsafeWindow !== "undefined" && unsafeWindow) ? unsafeWindow : window; if (!win) return; // --- XMLHttpRequest --- const XP = win.XMLHttpRequest && win.XMLHttpRequest.prototype; if (XP && XP.open && XP.send) { const rawOpen = XP.open; const rawSend = XP.send; XP.open = function (method, url) { try { this.__ndUrl = url; } catch (e) { /* 忽略 */ } return rawOpen.apply(this, arguments); }; XP.send = function () { try { const url = this.__ndUrl; if (url) catcher.put(url, "", 0, "请求"); this.addEventListener("load", function () { try { if (!this.getResponseHeader) return; const ct = this.getResponseHeader("content-type") || ""; // 渠道二:服务端声明为附件下载 catcher.fromResponse(url, { "content-disposition": this.getResponseHeader("content-disposition") || "", "content-type": ct }); // 渠道三:JSON 响应体里带直链字段 if (/json/i.test(ct) && this.responseText && this.responseText.length < 524288) { let data = null; try { data = JSON.parse(this.responseText); } catch (e) { data = null; } if (data) catcher.fromBody(data); } } catch (e) { /* 忽略 */ } }); } catch (e) { /* 忽略 */ } return rawSend.apply(this, arguments); }; } // --- fetch --- const rawFetch = win.fetch; if (typeof rawFetch === "function") { win.fetch = function (input) { try { const url = typeof input === "string" ? input : (input && input.url); if (url) catcher.put(url, "", 0, "请求"); } catch (e) { /* 忽略 */ } const p = rawFetch.apply(this, arguments); try { const reqUrl = typeof input === "string" ? input : (input && input.url); p.then((res) => { try { const h = res.headers; if (!h || !h.get) return; const ct = h.get("content-type") || ""; catcher.fromResponse(reqUrl, { "content-disposition": h.get("content-disposition") || "", "content-type": ct }); if (/json/i.test(ct) && res.clone) { res.clone().json().then((d) => catcher.fromBody(d)).catch(() => { /* 忽略 */ }); } } catch (e) { /* 忽略 */ } }).catch(() => { /* 忽略 */ }); } catch (e) { /* 忽略 */ } return p; }; } }, /** 清空候选池 */ clear() { catcher.pool.length = 0; } }; /* ========================================================================== * 10. 页面状态层:从页面框架内部读文件列表与勾选态 * ========================================================================== */ /** * 网盘页面是 SPA,文件列表与「勾选了哪些」都存放在框架(React / Vue)的内部状态里, * 而不在 DOM 上 —— 这正是「扫 DOM 扫不到东西」的根本原因。 * 这里提供两条通用取数通道,各网盘按自身框架选用其一。 */ const pageState = { /** 从 DOM 节点反查 React fiber,再逐级上探到组件实例 */ findReact(dom, traverseUp) { if (!dom) return null; try { const key = Object.keys(dom).find((k) => k.indexOf("__reactFiber$") === 0 || k.indexOf("__reactInternalInstance$") === 0); if (!key) return null; const fiber = dom[key]; if (!fiber) return null; const isHost = (f) => f && typeof f.type === "string"; const upToComponent = (f) => { let cur = f; while (cur && isHost(cur)) cur = cur.return; return cur; }; let comp = upToComponent(fiber); for (let i = 0; i < (traverseUp || 0); i++) { comp = upToComponent(comp && comp.return); } if (!comp) return null; return comp.stateNode || comp; } catch (e) { return null; } }, /** 取 Vue 实例(兼容 Vue 2 的 __vue__ 与 Vue 3 的 __vueParentComponent) */ findVue(dom) { if (!dom) return null; try { if (dom.__vue__) return dom.__vue__; let cur = dom; for (let i = 0; i < 5 && cur; i++) { if (cur.__vueParentComponent) return cur.__vueParentComponent; cur = cur.parentElement; } } catch (e) { /* 忽略 */ } return null; }, /** 取组件 props(兼容多种 fiber 形态) */ propsOf(reactObj) { if (!reactObj) return null; return reactObj.props || reactObj.pendingProps || reactObj.memoizedProps || null; }, /** 安全取全局对象(油猴沙箱下优先 unsafeWindow) */ win() { return (typeof unsafeWindow !== "undefined" && unsafeWindow) ? unsafeWindow : (typeof window !== "undefined" ? window : null); } }; /* ========================================================================== * 11. 页面注入层:把入口挂进宿主既有的工具栏容器 * ========================================================================== */ const inject = { /** 入口标记 class:同时用于样式与防重复注入 */ FLAG: "nd-entry", /** 精确选择器落空后,等多久再尝试模糊匹配(毫秒) */ fallbackDelay: 3000, /** 按操作词模糊匹配的按钮文案 */ ACTION_WORDS: /^(保存到网盘|下载|上传|新建|分享|保存|批量下载|离线下载)$/, /** 注入过程的现场记录:注入失败时靠它定位(面板「关于」页可一键复制) */ report: { provider: "", pageType: "", url: "", tried: [], via: "", host: "", done: false, note: "" }, /** * 等待选择器命中元素后执行回调。 * 宿主是 SPA、工具栏异步渲染,因此用 MutationObserver 观察,而非轮询或 setTimeout 硬等。 */ waitFor(selector, cb, opts) { const option = opts || {}; const doc = option.doc || (typeof document !== "undefined" ? document : null); if (!doc) return; // 无文档环境(Worker / 测试)直接跳过 let fired = false; const attempt = () => { if (fired) return true; let el = null; try { el = doc.querySelector(selector); } catch (e) { return true; // 选择器非法,放弃而不抛错 } if (!el) return false; fired = true; try { cb(el); } catch (e) { /* 回调异常不得影响观察器 */ } return true; }; if (attempt()) return; const root = doc.documentElement || doc.body; // 兼容受限宿主:MutationObserver 可能不作为裸全局暴露,只能从 window 上取 const MO = (typeof MutationObserver === "function") ? MutationObserver : (typeof window !== "undefined" && typeof window.MutationObserver === "function" ? window.MutationObserver : null); if (!root || !MO) return; const observer = new MO(() => { if (attempt()) observer.disconnect(); }); observer.observe(root, { childList: true, subtree: true }); // 兜底:超时后停止观察,避免长期持有观察器 setTimeout(() => observer.disconnect(), option.timeout || 30000); }, /** * 确保入口样式已就位。 * 入口按钮在页面加载期就创建,而面板样式是懒挂载的 —— 若两者共用一份样式, * 按钮会有一整段时间处于「无样式」状态(图标撑成巨块、没有边框与底色)。 * 所以入口样式必须独立、随注入即时生效。 */ ensureStyle() { if (typeof document === "undefined") return; if (document.getElementById("nd-entry-style")) return; const style = document.createElement("style"); style.id = "nd-entry-style"; style.textContent = ENTRY_CSS; (document.head || document.documentElement).appendChild(style); }, /** 生成融入式入口按钮:描边 + 继承文字色,以适配深/浅两种宿主主题 */ /** * 给入口上色。auto = 按宿主页面背景亮度自动选白/暗; * light / dark 由用户在偏好设置里指定。 */ applyEntryStyle(el) { if (!el) return; const mode = store.opt().entryStyle || "auto"; el.classList.toggle("nd-entry-dark", inject.hostTheme() === "dark" || mode === "dark"); }, /** 探测宿主页面明暗:按 body 背景色算亮度 */ hostTheme() { try { const bg = getComputedStyle(document.body).backgroundColor; const m = bg.match(/rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)/); if (m) { const lum = 0.299 * Number(m[1]) + 0.587 * Number(m[2]) + 0.114 * Number(m[3]); return lum < 128 ? "dark" : "light"; } } catch (e) { /* 忽略 */ } return "light"; }, entry(provider) { inject.ensureStyle(); const btn = document.createElement("div"); btn.className = inject.FLAG; inject.applyEntryStyle(btn); btn.setAttribute("role", "button"); btn.setAttribute("tabindex", "0"); btn.title = (provider ? provider.name + " · " : "") + "打开下载助手"; // 图标自带内联表现属性:即便样式尚未生效,也不会被填充成实心色块 btn.innerHTML = '下载助手'; btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); ui.open(); }); btn.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); ui.open(); } }); return btn; }, /** 算出该网盘在当前页面应使用的挂载点候选 */ selectorsFor(provider) { const mount = provider && provider.mount; if (!mount) return []; if (Array.isArray(mount)) return mount; // 兼容旧的数组写法 const page = providerApi.pageType(provider); if (page && Array.isArray(mount[page])) return mount[page]; // 页面类型识别不出来时合并全部候选,尽量把入口注进去(宁多试,不漏掉) const all = []; Object.keys(mount).forEach((k) => { if (Array.isArray(mount[k])) all.push.apply(all, mount[k]); }); return all; }, /** 为指定挂载点注入入口:容器出现才注入,已存在则跳过 */ mount(provider) { if (!provider) return; inject.ensureStyle(); const selectors = inject.selectorsFor(provider); selectors.forEach((selector) => { inject.waitFor(selector, (host) => { if (host.querySelector("." + inject.FLAG)) return; // 防重复注入 // append 到容器末尾 —— 入口落在工具栏最右侧,紧邻「保存到网盘」等主操作,好找也好点 host.append(inject.entry(provider)); }); }); }, /** 启动注入:仅在识别到网盘且其配置了挂载点时执行 */ start() { const provider = providerApi.current(); if (!provider) return null; inject.mount(provider); return provider; } }; /* ========================================================================== * 11. 样式:遵循仓库统一设计规范 * ========================================================================== */ const CSS = ` html{--nd-accent:#2da44e;--nd-accent-2:#1a7f37;--nd-accent-fg:#fff;--nd-good:#2da44e;--nd-warn:#d29922;--nd-bad:#f85149;} html[data-color-mode="light"]{--nd-accent:#1a7f37;--nd-accent-2:#116329;} #nd-scope{ --nd-bg:#0d1117;--nd-bg-2:#151b23;--nd-bg-3:#1c232b; --nd-bd:#2b323a;--nd-bd-2:#21272e; --nd-fg:#e6edf3;--nd-fg-2:#9aa4af;--nd-fg-3:#6e7681; --nd-shadow:0 18px 48px rgba(0,0,0,.55); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--nd-fg);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased; } #nd-scope.nd-light{ --nd-bg:#fff;--nd-bg-2:#f6f8fa;--nd-bg-3:#eef1f4; --nd-bd:#d8dee4;--nd-bd-2:#e8ecf0; --nd-fg:#1f2328;--nd-fg-2:#59636e;--nd-fg-3:#818b98; --nd-shadow:0 18px 48px rgba(31,35,40,.18); } #nd-scope svg{width:1em;height:1em;fill:currentColor;flex:none;vertical-align:-.125em;} #nd-scope .nd-ico{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;flex:none;} #nd-overlay{position:fixed;inset:0;z-index:2147483001;background:rgba(1,4,9,.62);opacity:0;pointer-events:none;transition:opacity .2s;} #nd-overlay.nd-open{opacity:1;pointer-events:auto;} #nd-panel{position:fixed;left:50%;top:50%;z-index:2147483002;width:560px;max-width:calc(100vw - 32px);max-height:84vh; background:var(--nd-bg);border:1px solid var(--nd-bd);border-radius:14px;box-shadow:var(--nd-shadow); display:flex;flex-direction:column;overflow:hidden;opacity:0;transform:translate(-50%,-46%) scale(.97);pointer-events:none; transition:opacity .22s,transform .22s cubic-bezier(.4,0,.2,1);} #nd-panel.nd-open{opacity:1;transform:translate(-50%,-50%) scale(1);pointer-events:auto;} .nd-head{display:flex;align-items:center;gap:10px;padding:15px 16px;border-bottom:1px solid var(--nd-bd-2);flex:none;} .nd-mark{width:26px;height:26px;border-radius:7px;background:var(--nd-accent);color:#fff;display:flex;align-items:center;justify-content:center;flex:none;} .nd-mark .nd-ico{width:15px;height:15px;stroke-width:1.9;} .nd-head h2{margin:0;font-size:15px;font-weight:600;color:var(--nd-fg);letter-spacing:.2px;} .nd-ver{font-size:11px;color:var(--nd-fg-3);border:1px solid var(--nd-bd);border-radius:6px;padding:1px 7px;} .nd-grow{flex:1;} .nd-icon-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:none;border-radius:7px; background:transparent;color:var(--nd-fg-2);cursor:pointer;font-size:16px;line-height:1;transition:background .15s,color .15s;} .nd-icon-btn:hover{background:var(--nd-bg-3);color:var(--nd-fg);} .nd-tabs{display:flex;gap:2px;padding:0 8px;border-bottom:1px solid var(--nd-bd-2);flex:none;background:var(--nd-bg-2);} .nd-tab{padding:11px 16px;border:none;background:transparent;cursor:pointer;font-family:inherit;font-size:13px; color:var(--nd-fg-2);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s,border-color .15s;} .nd-tab:hover{color:var(--nd-fg);} .nd-tab.nd-on{color:var(--nd-fg);font-weight:600;border-bottom-color:var(--nd-accent);} .nd-body{flex:1;overflow-y:auto;min-height:200px;overscroll-behavior:contain;} .nd-body::-webkit-scrollbar{width:10px;} .nd-body::-webkit-scrollbar-thumb{background:var(--nd-bd);border-radius:5px;border:3px solid transparent;background-clip:content-box;} .nd-body::-webkit-scrollbar-thumb:hover{background:var(--nd-fg-3);background-clip:content-box;} .nd-page{display:none;padding:14px 16px 16px;} .nd-page.nd-on{display:block;} #nd-scope [hidden]{display:none !important;} .nd-btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;height:30px;padding:0 12px; border:1px solid var(--nd-bd);border-radius:7px;background:var(--nd-bg-3);color:var(--nd-fg); font-family:inherit;font-size:12px;cursor:pointer;white-space:nowrap; transition:background .15s,border-color .15s,opacity .15s,transform .1s;} .nd-btn:hover{background:var(--nd-bd);border-color:var(--nd-fg-3);} .nd-btn:active{transform:translateY(1px);} .nd-btn[disabled]{opacity:.45;pointer-events:none;} .nd-btn.nd-primary{background:var(--nd-accent);border-color:var(--nd-accent);color:var(--nd-accent-fg);font-weight:500;} .nd-btn.nd-primary:hover{background:var(--nd-accent-2);border-color:var(--nd-accent-2);} .nd-btn.nd-ghost{background:transparent;border-color:transparent;color:var(--nd-fg-2);} .nd-btn.nd-ghost:hover{background:var(--nd-bg-3);border-color:var(--nd-bd);color:var(--nd-fg);} .nd-btn.nd-danger:hover{background:var(--nd-bad);border-color:var(--nd-bad);color:#fff;} .nd-btn.nd-block{width:100%;} .nd-actions{display:flex;flex-wrap:wrap;gap:8px;padding:2px 0 10px;} .nd-actions:last-child{padding-bottom:0;} .nd-bar-acts{display:flex;flex-wrap:wrap;gap:2px;margin:0 0 12px;} .nd-row{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--nd-bd-2);} .nd-row:last-child{border-bottom:none;} .nd-row-main{flex:1;min-width:0;} .nd-row-name{font-size:13px;color:var(--nd-fg);word-break:break-all;line-height:1.45;} .nd-row-meta{font-size:11px;color:var(--nd-fg-3);margin-top:1px;} .nd-row-acts{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;flex:none;} .nd-card{border:1px solid var(--nd-bd);border-radius:12px;background:var(--nd-bg-2);margin:0 0 12px;overflow:hidden;} .nd-card:last-child{margin-bottom:0;} .nd-card-head{padding:12px 14px 10px;border-bottom:1px solid var(--nd-bd-2);} .nd-card-head h3{margin:0;font-size:13px;font-weight:600;color:var(--nd-fg);} .nd-card-head p{margin:5px 0 0;font-size:12px;color:var(--nd-fg-3);line-height:1.6;} .nd-card-body{padding:10px 14px 12px;} .nd-card-head h3 .nd-badge{display:inline-block;min-width:18px;padding:0 6px;margin-left:6px;border-radius:9px; background:var(--nd-bg-3);color:var(--nd-fg-2);font-size:11px;font-weight:400;line-height:17px;text-align:center;vertical-align:1px;} .nd-empty{display:flex;flex-direction:column;align-items:center;gap:7px;padding:22px 12px; color:var(--nd-fg-3);font-size:12px;text-align:center;line-height:1.6;} .nd-empty .nd-ico{width:22px;height:22px;opacity:.5;} .nd-empty span{max-width:420px;} .nd-field{display:flex;align-items:center;gap:10px;padding:9px 0;} .nd-field + .nd-field{border-top:1px solid var(--nd-bd-2);} .nd-field-label{width:76px;flex:none;font-size:12px;color:var(--nd-fg-2);text-align:right;} .nd-field-desc{flex:1;min-width:0;font-size:12px;color:var(--nd-fg-3);} .nd-field-hint{flex:1;min-width:0;font-size:11px;color:var(--nd-fg-3);} .nd-stack{display:flex;flex-direction:column;gap:8px;padding:2px 0 10px;} .nd-input{flex:1;min-width:0;height:32px;padding:0 10px;border:1px solid var(--nd-bd);border-radius:7px;background:var(--nd-bg); color:var(--nd-fg);font-family:inherit;font-size:13px;transition:border-color .15s,background .15s;} .nd-input::placeholder{color:var(--nd-fg-3);} .nd-input:hover{border-color:var(--nd-fg-3);} .nd-input:focus{outline:none;border-color:var(--nd-accent);background:var(--nd-bg);} .nd-switch{position:relative;width:40px;height:22px;flex:none;cursor:pointer;} .nd-seg{display:flex;flex:none;border:1px solid var(--nd-bd);border-radius:7px;overflow:hidden;} .nd-seg button{padding:5px 11px;border:none;background:transparent;color:var(--nd-fg-2);cursor:pointer; font-family:inherit;font-size:12px;border-right:1px solid var(--nd-bd);transition:background .15s,color .15s;} .nd-seg button:last-child{border-right:none;} .nd-seg button:hover{color:var(--nd-fg);} .nd-seg button.nd-on{background:var(--nd-accent);color:var(--nd-accent-fg);} .nd-switch input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;z-index:1;} .nd-switch i{display:block;width:40px;height:22px;border-radius:6px;background:var(--nd-bd);transition:background .22s;position:relative;} .nd-switch i::after{content:'';position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:4px;background:#fff; transition:transform .22s cubic-bezier(.4,0,.2,1);} .nd-switch input:checked + i{background:var(--nd-accent);} .nd-switch input:checked + i::after{transform:translateX(18px);} .nd-about p{margin:0 0 10px;color:var(--nd-fg-2);font-size:13px;} .nd-about b{color:var(--nd-fg);font-weight:600;} #nd-toasts{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483004;display:flex; flex-direction:column;gap:8px;align-items:center;pointer-events:none;} .nd-toast{display:flex;align-items:center;gap:8px;max-width:min(560px,calc(100vw - 32px));padding:9px 14px; border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;font-size:13px;color:#fff; box-shadow:0 6px 20px rgba(0,0,0,.35);opacity:0;transform:translateY(12px);transition:opacity .22s,transform .22s;} .nd-toast.nd-show{opacity:1;transform:none;} .nd-toast.nd-info{background:#1f6feb;} .nd-toast.nd-ok{background:#2da44e;} .nd-toast.nd-warn{background:#9e6a03;} .nd-toast.nd-err{background:#b62324;} `; /** * 入口样式独立成表:它作用于 #nd-scope 之外,且必须随注入即时生效, * 不能等面板首次打开时才随主样式一起注入。 */ const ENTRY_CSS = ` /* 入口按钮:默认白色(多数网盘工具栏是浅色),可切换暗色或跟随宿主页面明暗 */ .nd-entry{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 14px;margin:0 0 0 10px; border:1px solid rgba(0,0,0,.14);border-radius:6px;background:#fff;color:#1f2328; font-family:inherit;font-size:13px;line-height:1;white-space:nowrap;cursor:pointer;flex:none;vertical-align:middle; box-shadow:0 1px 2px rgba(0,0,0,.06); transition:background .15s,border-color .15s,box-shadow .15s,transform .1s;} .nd-entry:hover{background:#f5f6f7;border-color:rgba(0,0,0,.24);} .nd-entry:active{transform:translateY(1px);} .nd-entry:focus-visible{outline:2px solid #4c8bf5;outline-offset:2px;} .nd-entry.nd-entry-dark{background:#252b33;border-color:#3a4048;color:#e6edf3;box-shadow:0 1px 2px rgba(0,0,0,.3);} .nd-entry.nd-entry-dark:hover{background:#2f363f;border-color:#4a525c;} .nd-entry svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none;} `; /** * 面板图标。统一内联表现属性(fill/stroke/线帽), * 即使样式表尚未生效也不会退化成黑色色块。 */ const ICON = { download: '', empty: '' }; /* ========================================================================== * 12. UI 层 * ========================================================================== */ const ui = { root: null, panel: null, overlay: null, toasts: null, state: { files: [], provider: null, page: "dl", busy: false, phase: "", error: "" }, /** 最近一次已换链的勾选签名,避免同一批勾选反复请求网盘 */ _resolvedSig: "", /* ---------- 基础 ---------- */ ensure() { if (ui.root) return; const style = document.createElement("style"); style.id = "nd-style"; style.textContent = CSS; (document.head || document.documentElement).appendChild(style); const scope = document.createElement("div"); scope.id = "nd-scope"; if (document.documentElement.getAttribute("data-color-mode") === "light") scope.classList.add("nd-light"); scope.innerHTML = ui.template(); document.body.appendChild(scope); ui.root = scope; ui.panel = scope.querySelector("#nd-panel"); ui.overlay = scope.querySelector("#nd-overlay"); ui.toasts = scope.querySelector("#nd-toasts"); ui.bind(); }, template() { return `
网盘直链下载助手 v${VERSION}
用法:在网盘里勾选要下载的文件,打开本面板即自动换取直链。文件夹取不到直链,请进入文件夹后再勾选。
入口:网盘工具栏上的「下载助手」按钮;若未出现,可用脚本管理器菜单中的「打开下载助手」。
出口:推送 Aria2 · 浏览器下载 · 复制直链。