// ==UserScript==
// @name Send to qBittorrent
// @namespace https://github.com/dijian
// @version 2.3
// @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";
/* ===== 只在顶层窗口运行,避免 iframe 重复注册 ===== */
if (window.top !== window.self) return;
/* ========== 配置 ========== */
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);
})();