// ==UserScript== // @name FerryLink // @namespace https://github.com/Wjavan/FerryLink // @version 1.2.4 // @author Wjavan // @description 支持百度/阿里/天翼/迅雷/夸克/移动六大网盘直链下载。支持 HTTP/JSON-RPC/cURL,推送至 IDM/XDown/Aria2/NDM/Motrix/终端。基于油小猴(youxiaohou.com)的网盘直链下载助手修改。 // @copyright 2026, Wjavan (https://github.com/Wjavan) // @license GPL-3.0 // @homepage https://github.com/Wjavan/FerryLink // @website https://github.com/Wjavan/FerryLink // @source https://github.com/Wjavan/FerryLink // @supportURL https://github.com/Wjavan/FerryLink/issues // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMC4yNSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzFFNUFBMCIvPjxzdG9wIG9mZnNldD0iMC41NSIgc3RvcC1jb2xvcj0iIzEyM0M3MiIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzBBMjM0NCIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJodWxsIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjRkZCNjNGIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjRTg3NjBGIi8+PC9saW5lYXJHcmFkaWVudD48Y2xpcFBhdGggaWQ9InNxdWlyY2xlIj48cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgcng9IjExNiIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNzcXVpcmNsZSkiPjxyZWN0IHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiBmaWxsPSJ1cmwoI2JnKSIvPjxwYXRoIGQ9Ik0wLDQzNCBDIDY4LDQwOCAxMjQsNDA4IDE4MCw0MzQgQyAyMzYsNDYwIDI5Miw0NjAgMzQ4LDQzNCBDIDQwNCw0MDggNDYwLDQwOCA1MTIsNDM0IEw1MTIsNTEyIEwwLDUxMiBaIgogICAgICAgICAgZmlsbD0iIzFGNUU5QyIvPjxwYXRoIGQ9Ik0wLDQ3MiBDIDc2LDQ0NiAxMzIsNDQ2IDE4OCw0NzIgQyAyNDQsNDk4IDMwMCw0OTggMzU2LDQ3MiBDIDQxMiw0NDYgNDY4LDQ0NiA1MTIsNDcyIEw1MTIsNTEyIEwwLDUxMiBaIgogICAgICAgICAgZmlsbD0iIzNCOENEMCIvPjxyZWN0IHg9IjIzNiIgeT0iNjAiIHdpZHRoPSI0NCIgaGVpZ2h0PSIxNzAiIHJ4PSIxMCIgZmlsbD0iI0ZGRkZGRiIvPjxwYXRoIGQ9Ik0xNjIsMjI2IEwzNTAsMjI2IEwyNTYsMzIwIFoiIGZpbGw9IiNGRkZGRkYiLz48cmVjdCB4PSIyMTgiIHk9IjMzMiIgd2lkdGg9Ijc2IiBoZWlnaHQ9IjQwIiByeD0iMTIiIGZpbGw9IiNGRkZGRkYiLz48cmVjdCB4PSIyMzYiIHk9IjM0NCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjExIiByeD0iNSIgZmlsbD0iIzdGQjNFMCIvPjxwYXRoIGQ9Ik0xNjIsMzc4IEwzNTAsMzc4IFEzNjAsMzc4IDM1NywzODcgTDM0MCw0MTAgUTMzNiw0MTYgMzI4LDQxNiBMMTg0LDQxNiBRMTc2LDQxNiAxNzIsNDEwIEwxNTUsMzg3IFExNTIsMzc4IDE2MiwzNzggWiIKICAgICAgICAgIGZpbGw9InVybCgjaHVsbCkiLz48cmVjdCB4PSIxNDkiIHk9IjM2NiIgd2lkdGg9IjIxNCIgaGVpZ2h0PSIxMiIgcng9IjYiIGZpbGw9IiNGRkZGRkYiLz48L2c+PC9zdmc+ // @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 *://openapi.baidu.com/oauth/* // @match *://www.aliyundrive.com/drive* // @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/* // @run-at document-idle // @require https://unpkg.com/jquery@3.7.0/dist/jquery.min.js // @require https://unpkg.com/sweetalert2@10.16.6/dist/sweetalert2.all.min.js // @require https://unpkg.com/js-md5@0.7.3/build/md5.min.js // @connect baidu.com // @connect baidupcs.com // @connect jomodns.com // @connect aliyundrive.com // @connect aliyundrive.net // @connect alipan.com // @connect aliyundrive.cloud // @connect 189.cn // @connect xunlei.com // @connect quark.cn // @connect yun.139.com // @connect caiyun.139.com // @connect localhost // @connect 127.0.0.1 // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_cookie // @grant GM_openInTab // @grant window.close // ==/UserScript== (function () { 'use strict'; // the three @require libs load from a CDN. If unpkg is unreachable (offline, // blocked network) the old code died on `$(document)` with a bare ReferenceError and the // user saw nothing at all. Fail with a visible banner instead. if (typeof $ === 'undefined' || typeof Swal === 'undefined') { const missing = [typeof $ === 'undefined' ? 'jQuery' : null, typeof Swal === 'undefined' ? 'SweetAlert2' : null] .filter(Boolean).join('、'); document.documentElement.appendChild(Object.assign(document.createElement('div'), { textContent: 'FerryLink 依赖加载失败(缺少 ' + missing + ')。请检查网络后刷新,或从 GitHub 手动安装本地依赖版。', style: 'position:fixed;top:0;left:0;right:0;z-index:2147483647;background:#cc3235;color:#fff;padding:8px 12px;font:13px/1.5 sans-serif;text-align:center' })); return; } let pt = '', selectList = [], params = {}, mode = '', width = 800, pan = {}, color = '', doc = $(document), progress = {}, request = {}, ins = {}; let watched = {}; 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 终端", }; 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) => { return toast.fire({title: text, icon: 'success'}); }, error: (text) => { return toast.fire({title: text, icon: 'error'}); }, warning: (text) => { return toast.fire({title: text, icon: 'warning'}); }, info: (text) => { return toast.fire({title: text, icon: 'info'}); }, question: (text) => { return toast.fire({title: text, icon: 'question'}); } }; // still-live credentials that pre-fix versions used to mirror into // page-accessible localStorage; getStorage/migrate pull these into the // sandboxed GM store on first encounter and stop exposing them. const LEGACY_LOCAL_KEYS = ['baiduyunPlugin_BDUSS', 'accessToken']; 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); }, // alipan keeps rotating its own localStorage token (new access_token + getStorage(key) { let v = null; // token and page-native keys (deviceid, credentials_*, captcha_*): // keep reading localStorage as before. LEGACY_LOCAL_KEYS skip this // and go through the GM-first migration path below. if (key === 'token' || !LEGACY_LOCAL_KEYS.includes(key)) { try { v = localStorage.getItem(key); } catch (e) { /* storage unavailable */ } } if (v === null || v === undefined) v = GM_getValue(key, null); if ((v === null || v === undefined) && LEGACY_LOCAL_KEYS.includes(key)) { // one-time migration: a pre-fix install may have left this // credential sitting only in localStorage. Pull it into GM // storage and scrub it from the page-accessible copy. let legacy = null; try { legacy = localStorage.getItem(key); } catch (e) { /* storage unavailable */ } if (legacy !== null && legacy !== undefined) { GM_setValue(key, legacy); v = legacy; } try { localStorage.removeItem(key); } catch (e) { /* storage unavailable */ } } try { return JSON.parse(v); } catch (e) { return v; } }, // ali access tokens are short-lived. When the stored one is at/past its async refreshAliToken(tk) { if (!tk || !tk.refresh_token) return tk; const now = Date.now(); const exp = tk.expire_time ? Date.parse(tk.expire_time) : 0; // refresh a minute early rather than racing the expiry if (!exp || exp - now > 60000) return tk; try { const res = await base.post( 'https://api.aliyundrive.com/v2/account/token', { grant_type: 'refresh_token', refresh_token: tk.refresh_token }, { 'content-type': 'application/json;charset=utf-8' } ); const merged = Object.assign({}, tk, { access_token: res.access_token, refresh_token: res.refresh_token || tk.refresh_token, token_type: res.token_type || tk.token_type || 'Bearer', expire_time: new Date(now + (res.expire_time ? Date.parse(res.expire_time) : now + 7200000)).toISOString(), }); base.setStorage('token', merged); return merged; } catch (e) { return tk; } }, setStorage(key, value) { // alipan's own page scripts read the ali token straight out of localStorage, // so only that key is mirrored there. Every other credential (access tokens, // BDUSS, captcha, device id, etc.) is kept solely in the sandboxed GM storage, // which page-level XSS cannot read, instead of being exposed via localStorage. let payload = value; if (this.isType(value) === 'object' || this.isType(value) === 'array') { payload = JSON.stringify(value); } if (key === 'token') { try { localStorage.setItem(key, payload); } catch (e) { /* storage unavailable */ } } return GM_setValue(key, payload); }, // throws (wrapped), and a failure just means we retry next load rather than blocking migrate() { const MARK = 'ferrylink_migrated_version'; const VERSION = '1.2.4'; try { if (GM_getValue(MARK, '') === VERSION) return; // orphaned by removing share-page support ['shareToken', 'share_token'].forEach((k) => { try { GM_deleteValue(k); localStorage.removeItem(k); } catch (e) {} }); // Migrate first: if GM is empty, getStorage pulls the localStorage // copy into GM. Then unconditionally scrub the page-accessible copy, // even when GM already had a value (otherwise the old localStorage // entry survives and the CWE-282 fix is incomplete). LEGACY_LOCAL_KEYS.forEach((k) => { try { base.getStorage(k); } catch (e) {} try { localStorage.removeItem(k); } catch (e) {} }); GM_setValue(MARK, VERSION); } catch (e) { /* never block startup */ } }, setClipboard(text) { GM_setClipboard(text, 'text'); }, e(str) { // unescape is deprecated; TextEncoder is the modern equivalent // but btoa still needs a binary string, so use the surrogate-safe form. return btoa(Array.from(new TextEncoder().encode(str), c => String.fromCharCode(c)).join('')); }, 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()); }, esc(str) { return String(str ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }, fixFilename(name) { return String(name || '').replace(/[!?&|`"'*\/:<>\\$;(){}\n\r\x00-\x1F\x7F]/g, '_'); }, // sanitize before a filename enters an aria2 RPC out/dir field. aria2 safeHttpUrl(url) { const u = String(url || ''); if (!/^https?:\/\//i.test(u)) return ''; return this.esc(u); }, validRpcDomain(domain) { const d = String(domain || '').trim(); if (!d) return false; if (d === 'localhost' || d === '::1' || d === '[::1]') return true; let host = d; try { host = new URL(d.includes('://') ? d : 'http://' + d).hostname; } catch (e) { return false; } if (host === 'localhost' || host === '::1') return true; return /^(127\.|10\.|192\.168\.|169\.254\.)/.test(host) || /^172\.(1[6-9]|2[0-9]|3[01])\./.test(host); }, safeRpcFilename(name) { return String(name || '') .replace(/[\x00-\x1F\x7F]/g, '') .replace(/[/\\]/g, '_') .replace(/\.\./g, '_') .replace(/^\.+/, '_') .slice(0, 255); }, // shared by tianyi + yidong; they were defined only on yidong, so // `base.getSign()` in tianyi was undefined and every tianyi download threw. getRandomString(len) { len = len || 16; let $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'; let maxPos = $chars.length; let pwd = ''; for (let i = 0; i < len; i++) { pwd += $chars.charAt(Math.floor(Math.random() * maxPos)); } return pwd; }, getSign(e, t, a, n) { // `i = ""` was an implicit global, which throws ReferenceError under the // IIFE's 'use strict' - every yidong download died here. Declare it locally. let i = ""; if (t) { let s = Object.assign({}, t); i = JSON.stringify(s), i = i.replace(/\s*/g, ""), i = encodeURIComponent(i); let c = i.split(""), u = c.sort(); i = u.join(""); } let A = md5(base.e(i)); let l = md5(a + ":" + n); return md5(A + l).toUpperCase(); }, _resetData() { progress = {}; $.each(request, (key) => { (request[key]).abort(); }); $.each(ins, (key) => { clearInterval(ins[key]); }); ins = {}; request = {}; }, post(url, data, headers, type) { if (this.isType(data) === 'object') { data = JSON.stringify(data); } return new Promise((resolve, reject) => { // tracked so _resetData() can abort it; an untracked request // holds its socket and starves every later one. const key = 'post_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); const clear = () => { delete request[key]; }; request[key] = GM_xmlhttpRequest({ method: "POST", url, headers, data, responseType: type || 'json', timeout: 30000, ontimeout: () => { clear(); reject(new Error('请求超时(30s)')); }, onload: (res) => { clear(); if (type === 'blob') { resolve(res); return; } // a 4xx body is still "loaded", so resolving it if (res.status >= 400) { const body = res.response || res.responseText || ''; let msg = 'HTTP ' + res.status; try { const parsed = JSON.parse(body); msg = parsed.message || parsed.msg || parsed.code || msg; } catch (e) { /* non-JSON error body */ } reject(new Error(msg)); return; } resolve(res.response || res.responseText); }, onerror: (err) => { clear(); reject(err); }, }); }); }, get(url, headers, type, extra) { headers = Object.assign({ 'Referer': location.origin }, headers); return new Promise((resolve, reject) => { // track from the start, not in onloadstart - if the request // errors before onloadstart fires, it's stuck and can't be aborted. const key = 'get_' + (extra && extra.index != null ? extra.index : Date.now()); const clear = () => { delete request[key]; }; let requestObj = GM_xmlhttpRequest({ method: "GET", url, headers, responseType: type || 'json', timeout: type === 'blob' ? 0 : 30000, ontimeout: () => { clear(); reject(new Error('请求超时(30s)')); }, onload: (res) => { clear(); resolve(res.response || res.responseText); }, onprogress: (res) => { if (extra && extra.filename && extra.index != null) { res.total > 0 ? progress[extra.index] = Math.floor(res.loaded * 100 / res.total) : progress[extra.index] = 0; } }, onloadstart() { if (extra && extra.filename && extra.index != null) { progress[extra.index] = 0; } }, onerror: (err) => { clear(); reject(err); }, }); request[key] = requestObj; }); }, getFinalUrl(url, headers) { headers = Object.assign({ 'Referer': location.origin }, headers); return new Promise((resolve, reject) => { // a plain GET streamed the whole body (hundreds of MB) just to read const key = 'finalurl_' + Date.now(); const done = (v) => { delete request[key]; resolve(v); }; request[key] = GM_xmlhttpRequest({ method: "HEAD", url, headers, timeout: 10000, ontimeout: () => { delete request[key]; reject(new Error('请求超时(10s)')); }, onload: (res) => { done(res.finalUrl || url); }, onerror: () => { delete request[key]; request[key] = GM_xmlhttpRequest({ method: "GET", url, headers: Object.assign({}, headers, { 'Range': 'bytes=0-0' }), timeout: 10000, ontimeout: () => { delete request[key]; reject(new Error('请求超时(10s)')); }, onload: (res) => { done(res.finalUrl || url); }, onerror: () => { delete request[key]; resolve(url); } }); } }); }); }, 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)); }, findReact(dom, traverseUp = 0) { if (!dom) return null; const key = Object.keys(dom).find(key => { return key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$"); }); if (!key) return null; const domFiber = dom[key]; if (domFiber == null) return null; if (domFiber._currentElement) { let compFiber = domFiber._currentElement._owner; for (let i = 0; i < traverseUp; i++) { if (!compFiber || !compFiber._currentElement) return null; compFiber = compFiber._currentElement._owner; } return compFiber ? compFiber._instance : null; } const GetCompFiber = fiber => { let parentFiber = fiber.return; while (parentFiber && typeof parentFiber.type == "string") { parentFiber = parentFiber.return; } return parentFiber; }; let compFiber = GetCompFiber(domFiber); if (!compFiber) return null; for (let i = 0; i < traverseUp; i++) { compFiber = GetCompFiber(compFiber); if (!compFiber) return null; } 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: 'C:' }, { name: 'setting_terminal_type', value: /Mac/i.test(navigator.platform) ? 'mt' : /Win/i.test(navigator.platform) ? 'wc' : 'lt' }, { name: 'setting_theme_color', value: '#09AAFF' }, { // IDM's capture endpoint is a fixed local port (1001) with a client id. name: 'setting_idm_rpc', value: [{ id: '1', default: true }] }]; value.forEach((v) => { base.getValue(v.name) === undefined && base.setValue(v.name, v.value); }); }, showSetting() { let dom = '', btn = '', colorList = ['#09AAFF', '#cc3235', '#526efa', '#518c17', '#ed944b', '#f969a5', '#bca280']; dom += ``; dom += ``; dom += ``; dom += ``; dom += ``; colorList.forEach((v) => { btn += `
`; }); dom += ``; const idmCfg = base.getValue('setting_idm_rpc'); const idmId = (Array.isArray(idmCfg) ? (idmCfg.find(i => i && i.default) || idmCfg[0] || {}).id : (idmCfg && idmCfg.id)) || '1'; dom += ``; dom += `