// ==UserScript== // @name Send to qBittorrent // @namespace https://github.com/dijian // @version 2.2 // @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:"); } 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); } /* ========== 内嵌配置面板 ========== */ let panelEl = null; function openSettingsPanel() { // 如果面板已打开就关闭 if (panelEl && panelEl.parentNode) { closePanel(); return; } // 遮罩层 const mask = document.createElement("div"); mask.id = "qb-settings-mask"; mask.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.45);" + "z-index:2147483647;display:flex;align-items:center;justify-content:center;"; // 面板容器 const panel = document.createElement("div"); panel.id = "qb-settings-panel"; panel.style.cssText = "background:#fff;border-radius:12px;padding:28px 32px;width:420px;" + "max-width:90vw;font-family:-apple-system,'Microsoft YaHei',sans-serif;" + "box-shadow:0 20px 60px rgba(0,0,0,.3);position:relative;"; panel.innerHTML = `

⚙️ qBittorrent 配置

`; // 填入当前值 panel.querySelector("#qb-set-url").value = getConfig("qbUrl") || ""; panel.querySelector("#qb-set-user").value = getConfig("qbUser") || ""; panel.querySelector("#qb-set-pass").value = getConfig("qbPass") || ""; panel.querySelector("#qb-set-path").value = getConfig("savePath") || ""; // 绑定事件 panel.querySelector("#qb-set-cancel").onclick = () => closePanel(); panel.querySelector("#qb-set-save").onclick = () => { setConfig("qbUrl", panel.querySelector("#qb-set-url").value.trim()); setConfig("qbUser", panel.querySelector("#qb-set-user").value.trim()); setConfig("qbPass", panel.querySelector("#qb-set-pass").value.trim()); setConfig("savePath", panel.querySelector("#qb-set-path").value.trim()); const status = panel.querySelector("#qb-set-status"); status.textContent = "✓ 配置已保存"; setTimeout(() => { status.textContent = ""; closePanel(); }, 1200); }; mask.onclick = (e) => { if (e.target === mask) closePanel(); }; mask.appendChild(panel); document.documentElement.appendChild(mask); panelEl = mask; } function closePanel() { if (panelEl && panelEl.parentNode) { panelEl.remove(); panelEl = null; } } /* ========== qBittorrent API ========== */ 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") || ""; 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("下载种子失败")), }); }); 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"; body += atob(b64); 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 (_) { 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:菜单 — 打开配置面板 ========== */ GM_registerMenuCommand("⚙️ qBittorrent 设置", openSettingsPanel); })();