通过 WebUI 的 API 批量替换 Tracker, 基于"「水水」qBittorrent 管理脚本"修改
// ==UserScript==
// @name [xyg] qBittorrent Tracker批量操作脚本
// @namespace https://github.com/fleapo
// @version 1.0.2
// @author fleapo
// @description 通过 WebUI 的 API 批量替换 Tracker, 基于"「水水」qBittorrent 管理脚本"修改
// @license MIT
// @null ----------------------------
// @link https://github.com/fleapo/qb_tracker_tool
// @null ----------------------------
// @noframes
// @run-at document-end
// @include http://*:8080/*
// @grant GM_xmlhttpRequest
// ==/UserScript==
/* eslint-disable */
/* jshint esversion: 6 */
(function () {
'use strict';
const gm_name = "qBit";
// 初始常量或函数
const curUrl = window.location.href;
// -------------------------------------
const _log = (...args) => console.log(`[${gm_name}] \n`, ...args);
// -------------------------------------
// const $ = window.$ || unsafeWindow.$;
function $n(e) {
return document.querySelector(e);
}
class HttpRequest {
constructor() {
if (typeof GM_xmlhttpRequest === "undefined") {
throw new Error("GM_xmlhttpRequest is not defined");
}
}
get(url, headers = {}) {
return this.request({
method: "GET",
url,
headers,
});
}
post(url, data = {}, headers = {}) {
const formData = new FormData();
for (const key in data) {
formData.append(key, data[key]);
}
return this.request({
method: "POST",
url,
data: formData,
headers,
});
}
request(options) {
return new Promise((resolve, reject) => {
const requestOptions = Object.assign({}, options);
requestOptions.onload = function(res) {
resolve(res);
};
requestOptions.onerror = function(error) {
reject(error);
};
GM_xmlhttpRequest(requestOptions);
});
}
}
// 导出实例对象
const http = new HttpRequest();
class defForm {
schemaForm = [
// 替换
{
"name": "replace",
"text": "替换",
"inputs": [
{
"text": "旧 Tracker",
"name": "origUrl",
"type": "text",
},
{
"text": "新 Tracker(每行一个,支持多个)",
"name": "newUrl",
"type": "textarea",
},
],
},
// 添加
{
"name": "add",
"text": "添加",
"inputs": [
{
"text": "添加 Tracker(每行一个,支持多个)",
"name": "trackerUrl",
"type": "textarea",
},
],
},
// 删除
{
"name": "remove",
"text": "删除",
"inputs": [
{
"text": "删除 Tracker,输入 **** 可清空所有 Tracker",
"name": "trackerUrl",
"type": "text",
},
],
},
];
$tab = null;
$body = null;
curSelect = null;
curOption = null;
// 初始
constructor() {
this.$tab = $n(".act-tab");
this.$body = $n(".act-body");
this.schemaForm.forEach((option) => {
const { radioInput, label } = this.createRadioInput(option);
this.$tab.appendChild(radioInput);
this.$tab.appendChild(label);
this.$tab.appendChild(document.createElement("br"));
});
this.updateFormBody("replace"); // Default load
}
createRadioInput(option) {
const radioInput = document.createElement("input");
radioInput.type = "radio";
radioInput.id = option.name;
radioInput.name = "action";
radioInput.value = option.name;
radioInput.dataset.text = option.text;
// Default select "replace"
if (option.name === "replace") radioInput.checked = true;
const label = document.createElement("label");
label.htmlFor = option.name;
label.textContent = option.text;
const _this = this;
radioInput.addEventListener("change", function() {
if (this.checked) {
_this.updateFormBody(this.value);
}
});
return { radioInput, label };
}
updateFormBody(selectedName) {
const selectedOption = this.schemaForm.find(option => option.name === selectedName);
this.$body.innerHTML = ""; // Clear current form
selectedOption.inputs.forEach((input) => {
let inputField;
if (input.type === "textarea") {
inputField = document.createElement("textarea");
inputField.rows = 4;
inputField.style = "width: 95%; resize: vertical;";
} else {
inputField = document.createElement("input");
inputField.type = "text";
inputField.style = "width: 95%;";
}
inputField.name = input.name;
inputField.placeholder = input.text;
inputField.classList.add("js-input");
const label = document.createElement("label");
// label.textContent = input.text;
label.appendChild(inputField);
this.$body.appendChild(label);
this.$body.appendChild(document.createElement("br"));
});
const $submit = document.createElement("input");
$submit.value = selectedOption.text;
$submit.type = "button";
// 设置 class
$submit.className = "btn btn-act";
this.$body.appendChild($submit);
this.curSelect = selectedName;
this.curOption = selectedOption;
}
getFormData() {
const data = {};
this.curOption.inputs.forEach((input) => {
const $input = $n(`.js-input[name="${input.name}"]`);
if ($input) {
data[input.name] = $input.value.trim();
}
});
// 获取筛选类型和对应的值
const filterType = document.querySelector('input[name="filterType"]:checked').value;
data.filterType = filterType;
if (filterType === "category") {
const categoryInput = $n(".js-input[name=category]");
data.category = categoryInput ? categoryInput.value.trim() : "";
} else {
const tagInput = $n(".js-input[name=tag]");
data.tag = tagInput ? tagInput.value.trim() : "";
}
return data;
}
}
/* global jQuery, __GM_api, MochaUI */
if (typeof __GM_api !== "undefined") {
_log(__GM_api);
}
const gob = {
data: {
qbtVer: sessionStorage.qbtVersion,
apiVer: "2.x",
apiBase: curUrl + "api/v2/",
listTorrent: [],
curTorrentTrackers: [],
tips: {
tit: {},
btn: {},
},
modalShow: false,
},
http,
// 解析返回
parseReq(res, type = "text") {
// _log(res.finalUrl, "\n", res.status, res.response);
if (res.status !== 200) {
throw new Error("API Http Request Err");
}
if (type === "json") {
return JSON.parse(res.response);
} else {
return res.response;
}
},
// /api/v2/APIName/methodName
apiUrl(method = "app/webapiVersion") {
return gob.data.apiBase + method;
},
// 获取种子列表: torrents/info?&category=test 或 torrents/info?&tag=test
apiTorrents(filterType = "category", filterValue = "", fn = () => { }) {
let url;
if (filterType === "tag") {
url = gob.apiUrl(`torrents/info?tag=${filterValue}`);
} else {
url = gob.apiUrl(`torrents/info?category=${filterValue}`);
}
gob.http.get(url).then((res) => {
gob.data.listTorrent = gob.parseReq(res, "json");
}).finally(fn);
},
// 获取指定种子的 Trackers: torrents/trackers
apiGetTrackers(hash, fn = () => { }) {
const url = gob.apiUrl(`torrents/trackers?hash=${hash}`);
gob.http.get(url).then((res) => {
_log("apiGetTrackers()\n", hash, gob.parseReq(res, "json"));
gob.data.curTorrentTrackers = gob.parseReq(res, "json");
}).finally(fn);
},
// 替换 Tracker: torrents/editTracker (支持多个新tracker)
apiEdtTracker(hash, origUrl, newUrls, isPartial = false) {
_log("apiEdtTracker()\n", hash, origUrl, newUrls);
const url = gob.apiUrl("torrents/editTracker");
// 将newUrls按行分割,过滤空行
const urlList = newUrls.split('\n').map(url => url.trim()).filter(url => url);
if (isPartial) {
gob.apiGetTrackers(hash, () => {
const seedTrackers = gob.data.curTorrentTrackers;
seedTrackers.forEach((tracker) => {
if (tracker.url.includes(origUrl)) {
// 对于部分匹配,只替换第一个新URL
if (urlList.length > 0) {
const updatedUrl = tracker.url.replace(origUrl, urlList[0]);
gob.http.post(url, { hash, origUrl: tracker.url, newUrl: updatedUrl });
// 如果有多个新URL,添加其余的
for (let i = 1; i < urlList.length; i++) {
const additionalUrl = tracker.url.replace(origUrl, urlList[i]);
gob.apiAddTracker(hash, additionalUrl);
}
}
}
});
});
} else {
// 对于完全匹配,先替换第一个,然后添加其余的
if (urlList.length > 0) {
gob.http.post(url, { hash, origUrl, newUrl: urlList[0] });
// 添加其余的tracker
for (let i = 1; i < urlList.length; i++) {
gob.apiAddTracker(hash, urlList[i]);
}
}
}
},
// 添加 Tracker: torrents/addTrackers (支持多个tracker)
apiAddTracker(hash, urls) {
const url = gob.apiUrl("torrents/addTrackers");
// 如果urls是字符串,按行分割并过滤空行
let urlList;
if (typeof urls === 'string') {
urlList = urls.split('\n').map(url => url.trim()).filter(url => url);
urls = urlList.join('\n');
}
gob.http.post(url, { hash, urls });
},
// 删除 Tracker: torrents/removeTrackers
apiDelTracker(hash, urls) {
const url = gob.apiUrl("torrents/removeTrackers");
gob.http.post(url, { hash, urls });
},
// 获取 API 版本信息
apiInfo(fn = () => { }) {
const url = gob.apiUrl();
gob.http.get(url).then((res) => {
gob.data.apiVer = gob.parseReq(res);
}).finally(fn);
},
// 显示提示信息到页面
viewTips() {
if (!gob.data.modalShow) {
return;
}
for (const key in gob.data.tips) {
if (Object.hasOwnProperty.call(gob.data.tips, key)) {
const tip = gob.data.tips[key];
const $el = $n(`.js-tip-${key}`);
const text = JSON.stringify(tip).replace(/(,|:)"/g, "$1 ").replace(/["{}]/g, "");
$el.innerText = `(${text})`;
}
}
},
// 更新提示信息
upTips(key = "tit", tip) {
const tipData = gob.data.tips[key];
Object.assign(tipData, tip);
gob.viewTips();
},
// 预览功能:获取种子列表和tracker信息
apiPreview(filterType, filterValue, fn = () => { }) {
this.apiTorrents(filterType, filterValue, () => {
const list = gob.data.listTorrent;
if (list.length === 0) {
fn([]);
return;
}
// 获取每个种子的tracker信息
let completedCount = 0;
const previewData = [];
list.forEach((torrent, index) => {
gob.apiGetTrackers(torrent.hash, () => {
previewData[index] = {
name: torrent.name,
hash: torrent.hash,
trackers: [...gob.data.curTorrentTrackers]
};
completedCount++;
if (completedCount === list.length) {
fn(previewData.filter(item => item)); // 过滤undefined项
}
});
});
});
},
init() {
gob.apiInfo(() => {
_log(gob.data);
});
},
};
gob.init();
// 构建编辑入口
$n("#desktopNavbar ul").insertAdjacentHTML(
"beforeend",
"<li><a class=\"js-modal\"><b>→批量替换 Tracker←</b></a></li>",
);
// 构建编辑框
const strHtml = `
<div style="padding:13px 23px;">\
<div class="act-tab" style="display: flex;">操作模式:</div>\
<hr>
<div style="margin-bottom: 10px;">\
<label style="margin-right: 15px;"><input type="radio" name="filterType" value="category" checked> 按分类筛选</label>\
<label><input type="radio" name="filterType" value="tag"> 按标签筛选</label>\
</div>\
<div id="categorySection">\
<h2>分类: (不能是「全部」或「未分类」,区分大小写)</h2>\
<input class="js-input" type="text" name="category" style="width: 80%;" placeholder="包含要修改项目的分类或新建一个">\
<button type="button" class="btn-preview" style="margin-left: 5px;">预览</button>\
</div>\
<div id="tagSection" style="display: none;">\
<h2>标签: (输入标签名称,区分大小写)</h2>\
<input class="js-input" type="text" name="tag" style="width: 80%;" placeholder="输入要筛选的标签名称">\
<button type="button" class="btn-preview" style="margin-left: 5px;">预览</button>\
</div>\
<div id="previewSection" style="display: none; margin: 10px 0; padding: 10px; border: 1px solid #ccc; background: #f9f9f9;">\
<h3>Tracker 预览:</h3>\
<div id="previewContent" style="max-height: 180px; overflow-y: auto; border: 1px solid #ddd; background: #fff; padding: 5px;"></div>\
</div>\
<h2>Tracker: <span class="js-tip-btn"></span></h2>\
<div class="act-body"></div>\
</div>
`;
// js-modal 绑定点击事件
$n(".js-modal").addEventListener("click", function() {
new MochaUI.Window({
id: "js-modal",
title: "批量替换 Tracker <span class=\"js-tip-tit\"></span>",
loadMethod: "iframe",
contentURL: "",
scrollbars: true,
resizable: true,
maximizable: false,
closable: true,
paddingVertical: 0,
paddingHorizontal: 0,
width: 650,
height: 500,
});
const modalContent = $n("#js-modal_content");
modalContent.innerHTML = strHtml;
const modalContentWrapper = $n("#js-modal_contentWrapper");
modalContentWrapper.style.height = "auto";
gob.data.modalShow = true;
gob.upTips("tit", {
qbt: gob.data.qbtVer,
api: gob.data.apiVer,
});
// 初始化表单
gob.formObj = new defForm();
// 添加筛选类型切换事件监听
const filterTypeRadios = document.querySelectorAll('input[name="filterType"]');
const categorySection = document.getElementById('categorySection');
const tagSection = document.getElementById('tagSection');
const previewSection = document.getElementById('previewSection');
filterTypeRadios.forEach(radio => {
radio.addEventListener('change', function() {
if (this.value === 'category') {
categorySection.style.display = 'block';
tagSection.style.display = 'none';
} else {
categorySection.style.display = 'none';
tagSection.style.display = 'block';
}
// 切换筛选类型时隐藏预览
previewSection.style.display = 'none';
});
});
// 添加预览按钮事件监听
const previewButtons = document.querySelectorAll('.btn-preview');
previewButtons.forEach(btn => {
btn.addEventListener('click', function() {
const filterType = document.querySelector('input[name="filterType"]:checked').value;
let filterValue = '';
if (filterType === 'category') {
const categoryInput = document.querySelector('input[name="category"]');
filterValue = categoryInput ? categoryInput.value.trim() : '';
if (!filterValue || filterValue === "全部" || filterValue === "未分类") {
alert('请输入有效的分类名称');
return;
}
} else {
const tagInput = document.querySelector('input[name="tag"]');
filterValue = tagInput ? tagInput.value.trim() : '';
if (!filterValue) {
alert('请输入标签名称');
return;
}
}
// 显示加载状态
const previewContent = document.getElementById('previewContent');
previewContent.innerHTML = '正在加载...种子数量大时,可能需要稍等一下...';
previewSection.style.display = 'block';
// 调用预览API
gob.apiPreview(filterType, filterValue, (previewData) => {
if (previewData.length === 0) {
previewContent.innerHTML = `<p style="color: #666;">未找到符合条件的种子</p>`;
return;
}
// 收集所有有效的tracker信息,过滤掉已禁用的
const allTrackers = [];
let totalTrackerCount = 0;
previewData.forEach((item) => {
item.trackers.forEach((tracker) => {
totalTrackerCount++;
// 只收集有效的tracker(状态不是已禁用)
// qBittorrent中,status: 0=已禁用, 1=未联系, 2=工作中, 3=更新中, 4=已联系未生效
if (tracker.status !== 0) { // 过滤掉已禁用的tracker
allTrackers.push({
url: tracker.url,
status: tracker.status,
msg: tracker.msg || ''
});
}
});
});
// 按URL去重,但保留状态信息
const uniqueTrackers = [];
const seenUrls = new Set();
allTrackers.forEach(tracker => {
if (!seenUrls.has(tracker.url)) {
seenUrls.add(tracker.url);
uniqueTrackers.push(tracker);
}
});
let html = `<p><strong>找到 ${previewData.length} 个种子,去重后,共 ${uniqueTrackers.length} 个有效tracker:</strong></p>`;
if (uniqueTrackers.length === 0) {
html += `<p style="color: #666;">没有找到有效的tracker</p>`;
} else {
html += `<div style="font-family: monospace; font-size: 12px; line-height: 1.6;">`;
// 只显示tracker URL列表,简洁显示
uniqueTrackers.sort((a, b) => a.url.localeCompare(b.url)).forEach((tracker, index) => {
html += `<div style="margin: 2px 0; padding: 3px 6px; background: #fff; border-left: 3px solid #007cba;">`;
html += `<span style="color: #666; font-weight: bold;">${index + 1}.</span> `;
html += `<span style="word-break: break-all;">${tracker.url}</span>`;
html += `</div>`;
});
html += `</div>`;
}
previewContent.innerHTML = html;
});
});
});
// debug
// $n(".js-input[name=category]").value = "test";
// $n(".js-input[name=origUrl]").value = "123";
// $n(".js-input[name=newUrl]").value = "456";
// $n(".js-input[name=matchSubstr]").click();
});
// // 自动点击
// $n(".js-modal").click();
const fnCheckUrl = (name, url) => {
// 判断是否以 udp:// 或 http(s):// 开头
const regex = /^(udp|http(s)?):\/\//;
// 如果是多行URL,检查每一行
if (url.includes('\n')) {
const urls = url.split('\n').map(u => u.trim()).filter(u => u);
const allValid = urls.every(u => regex.test(u));
return [name, allValid];
}
return [
name,
regex.test(url),
];
};
document.addEventListener("click", function(event) {
if (event.target.classList.contains("btn-act")) {
gob.act = gob.formObj.curSelect;
gob.urlCheck = [];
const formData = gob.formObj.getFormData();
// 判断筛选条件
if (formData.filterType === "category") {
if (!formData.category || formData.category === "全部" || formData.category === "未分类") {
gob.upTips("btn", {
num: 0,
msg: "「分类」字段错误",
});
return;
}
} else {
if (!formData.tag) {
gob.upTips("btn", {
num: 0,
msg: "「标签」字段不能为空",
});
return;
}
}
// 遍历数据,如果 key 含有 Url,则判断 value 是否符合要求
for (const key in formData) {
if (Object.prototype.hasOwnProperty.call(formData, key)) {
const value = formData[key];
if (key.indexOf("Url") > -1) {
// 判断是否符合要求
gob.urlCheck.push(fnCheckUrl(key, value));
}
}
}
let isOk = gob.urlCheck.every(function(item) {
return item[1];
});
if (!isOk && gob.act === "replace") {
isOk = confirm("输入的 Tracker 未通过预检,是否尝试子串替换?");
formData.isPartial = isOk;
}
if (gob.act === "remove" && formData.trackerUrl === "****") {
isOk = confirm("继续将清空匹配任务的全部 Tracker");
gob.act = "removeAll";
}
if (!isOk) {
gob.urlCheck.map(function(item) {
if (!item[1]) {
gob.upTips("btn", {
num: 0,
msg: `「${item[0]}」不符合要求`,
});
return;
}
});
return;
}
const fnRemoveAll = (hash) => {
gob.apiGetTrackers(hash, () => {
const seedTrackers = gob.data.curTorrentTrackers;
const seedTrackersUrl = seedTrackers.map(function(item) {
return item.url;
});
gob.apiDelTracker(hash, seedTrackersUrl.join("|"));
});
};
// 根据筛选类型调用API
const filterValue = formData.filterType === "category" ? formData.category : formData.tag;
gob.apiTorrents(formData.filterType, filterValue, () => {
const list = gob.data.listTorrent;
_log("apiTorrents()\n", list);
list.map(function(item) {
switch (gob.act) {
case "replace":
gob.apiEdtTracker(item.hash, formData.origUrl, formData.newUrl, formData.isPartial);
break;
case "add":
gob.apiAddTracker(item.hash, formData.trackerUrl);
break;
case "remove":
gob.apiDelTracker(item.hash, formData.trackerUrl);
break;
case "removeAll":
fnRemoveAll(item.hash);
break;
}
});
gob.upTips("btn", {
num: list.length,
msg: "操作完成",
});
});
return;
}
});
})();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址