// ==UserScript== // @name Send to qBittorrent // @namespace https://github.com/dijian // @version 2.0 // @description 左键点击 / 右键菜单发送 .torrent 和磁力链接到本地 qBittorrent // @author dijian // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_notification // @connect 127.0.0.1 // @connect localhost // @run-at document-idle // ==/UserScript== (function () { "use strict"; /* ========== 配置(默认值) ========== */ const DEFAULTS = { qbUrl: "http://127.0.0.1:8080", qbUser: "dijian", qbPass: "", savePath: "", }; function getConfig(key) { return GM_getValue(key, DEFAULTS[key]); } function setConfig(key, value) { GM_setValue(key, value); } /* ========== 工具函数 ========== */ function isTorrentUrl(url) { if (!url) return false; return /\.torrent(\?.*)?$/i.test(url) || url.startsWith("magnet:"); } /** 从文本中提取第一个 magnet 或 .torrent 链接 */ function extractUrl(text) { if (!text) return null; const m = text.match( /(magnet:\?[^"\s]+|https?:\/\/[^\s"']+\.torrent)/i ); return m ? m[1] : null; } /** 页面内 Toast 提示 */ function toast(msg) { const div = document.createElement("div"); div.textContent = msg; div.style.cssText = "position:fixed;top:16px;right:16px;background:#1a73e8;color:#fff;" + "padding:10px 16px;border-radius:6px;z-index:2147483647;font-size:14px;" + "box-shadow:0 2px 10px rgba(0,0,0,.3);font-family:sans-serif;" + "transition:opacity .3s;"; (document.body || document.documentElement).appendChild(div); setTimeout(() => { div.style.opacity = "0"; setTimeout(() => div.remove(), 300); }, 2500); } /** 高亮闪烁元素 */ function flash(el) { const old = el.style.outline; el.style.outline = "2px solid #1a73e8"; setTimeout(() => (el.style.outline = old), 1500); } /* ========== qBittorrent API ========== */ /** * 用 GM_xmlhttpRequest 发请求(支持跨域到本地 qB) * 返回 Promise responseText */ function qbFetch(url, options = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || "GET", url: url, data: options.body, headers: options.headers || {}, responseType: "text", onload: (res) => resolve(res.responseText), onerror: (err) => reject(new Error("网络错误: " + (err.error || err))), ontimeout: () => reject(new Error("请求超时")), }); }); } async function sendToQb(url) { const base = (getConfig("qbUrl") || DEFAULTS.qbUrl).replace(/\/+$/, ""); const user = getConfig("qbUser") || DEFAULTS.qbUser; const pass = getConfig("qbPass") || ""; const savePath = getConfig("savePath") || ""; try { // 1. 登录 const loginParams = "username=" + encodeURIComponent(user) + "&password=" + encodeURIComponent(pass); const loginText = await qbFetch(base + "/api/v2/auth/login", { method: "POST", body: loginParams, headers: { "Content-Type": "application/x-www-form-urlencoded" }, }); if (loginText !== "Ok.") { notify("qB 登录失败: " + loginText); return false; } // 2. 添加种子 if (url.startsWith("magnet:")) { let addParams = "urls=" + encodeURIComponent(url); if (savePath) addParams += "&savepath=" + encodeURIComponent(savePath); const text = await qbFetch(base + "/api/v2/torrents/add", { method: "POST", body: addParams, headers: { "Content-Type": "application/x-www-form-urlencoded" }, }); const ok = text.includes("Ok"); toast(ok ? "磁力链接已发送 ✓" : "添加失败: " + text); return ok; } else { // .torrent 文件:先下载再上传 const torrentBlob = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, responseType: "blob", onload: (res) => resolve(res.response), onerror: (err) => reject(new Error("下载种子失败")), }); }); // 用 FileReader 转成 base64 再以 multipart 上传 const reader = new FileReader(); const b64 = await new Promise((resolve) => { reader.onload = () => resolve(reader.result.split(",")[1]); reader.readAsDataURL(torrentBlob); }); const boundary = "----ScriptCatFormBoundary" + Date.now(); let body = "--" + boundary + '\r\n' + 'Content-Disposition: form-data; name="torrents"; filename="torrent.torrent"\r\n' + "Content-Type: application/x-bittorrent\r\n\r\n"; // base64 → binary in body (GM_xhr handles string) body += atob(b64); // eslint-disable-line no-undef if (savePath) { body += "\r\n--" + boundary + '\r\n' + 'Content-Disposition: form-data; name="savepath"\r\n\r\n' + savePath; } body += "\r\n--" + boundary + "--\r\n"; const text = await qbFetch(base + "/api/v2/torrents/add", { method: "POST", body: body, headers: { "Content-Type": "multipart/form-data; boundary=" + boundary }, }); const ok = text.includes("Ok"); toast(ok ? "种子已发送 ✓" : "添加失败: " + text); return ok; } } catch (e) { toast("错误: " + e.message); return false; } } function notify(msg) { try { GM_notification({ title: "Send to qBittorrent", text: msg, timeout: 3000, }); } catch (_) { // GM_notification 不可用时 fallback 到 toast toast(msg); } } /* ========== 功能 1:拦截页面上种子链接的左键点击 ========== */ document.addEventListener( "click", (e) => { const a = e.target.closest("a"); if (!a) return; const href = a.href || ""; const text = (a.textContent || "").trim(); let target = null; if (isTorrentUrl(href)) target = href; else if (isTorrentUrl(text)) target = text; if (target) { e.preventDefault(); e.stopPropagation(); sendToQb(target); flash(a); } }, true // 捕获阶段 ); /* ========== 功能 2:右键菜单 — 发送当前页选中链接 ========== */ GM_registerMenuCommand("📎 发送选中的种子链接", () => { const sel = window.getSelection().toString().trim(); const url = extractUrl(sel); if (url) { sendToQb(url); } else { toast("没找到有效的种子链接(请选中文本中的 magnet 或 .torrent 链接)"); } }); /* ========== 功能 3:右键菜单 — 手动输入链接 ========== */ GM_registerMenuCommand("✏️ 手动输入链接发送", () => { const url = prompt( "粘贴磁力链接或 .torrent 地址:", "" ); if (url && url.trim()) { sendToQb(url.trim()); } }); /* ========== 功能 4:设置 qBittorrent 连接信息 ========== */ GM_registerMenuCommand("⚙️ 设置 qBittorrent 地址和账号", () => { const qbUrl = prompt( "qB WebUI 地址:", getConfig("qbUrl") ); if (qbUrl !== null) setConfig("qbUrl", qbUrl.trim()); const qbUser = prompt( "用户名:", getConfig("qbUser") ); if (qbUser !== null) setConfig("qbUser", qbUser.trim()); const qbPass = prompt("密码:", getConfig("qbPass")); if (qbPass !== null) setConfig("qbPass", qbPass); const savePath = prompt( "保存路径(留空用默认):", getConfig("savePath") ); if (savePath !== null) setConfig("savePath", savePath.trim()); toast("配置已保存 ✓"); }); })();