Greasy Fork is available in English.
通过 WebUI 的 API 批量替换 Tracker
// ==UserScript==
// @name 「水水」qBittorrent 管理脚本
// @namespace 做最终做到的事,成为最终成为的人。
// @version 1.0.8
// @author 沉冰浮水
// @description 通过 WebUI 的 API 批量替换 Tracker
// @license MIT
// @null ----------------------------
// @contributionURL https://github.com/wdssmq#%E4%BA%8C%E7%BB%B4%E7%A0%81
// @contributionAmount 5.93
// @null ----------------------------
// @link https://github.com/wdssmq/userscript
// @link https://afdian.com/@wdssmq
// @link http://greasyfork.icu/zh-CN/users/6865-wdssmq
// @null ----------------------------
// @noframes
// @run-at document-end
// @include http://*:8088/
// @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 DefForm {
schemaForm = [
// 替换
{
name: "replace",
text: "替换",
inputs: [
{
text: "旧 Tracker",
name: "origUrl",
},
{
text: "新 Tracker",
name: "newUrl",
},
],
},
// 子串替换
{
name: "partialReplace",
text: "子串替换",
inputs: [
{
text: "旧字符串",
name: "origUrl",
},
{
text: "新字符串",
name: "newUrl",
},
],
},
// 添加
{
name: "add",
text: "添加",
inputs: [
{
text: "添加 Tracker",
name: "trackerUrl",
},
],
},
// 删除
{
name: "remove",
text: "删除",
inputs: [
{
text: "删除 Tracker,输入 **** 可清空所有 Tracker",
name: "trackerUrl",
},
],
},
];
$tab = null;
$body = null;
curSelect = null;
curOption = null;
// 初始
constructor() {
this.$tab = $n(".act-tab");
this.$body = $n(".act-body");
this.$tip = $n(".js-tip-btn");
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) {
// 如果选择子串替换,弹出确认
if (this.value === "partialReplace") {
const confirmed = confirm("子串替换模式用于将 Tracker 中的某个子串替换为另一个子串,是否继续?");
if (!confirmed) {
// 用户取消,切换回替换模式
const replaceRadio = document.getElementById("replace");
if (replaceRadio) {
replaceRadio.checked = true;
_this.updateFormBody("replace");
}
return;
}
}
_this.updateFormBody(this.value);
}
});
return { radioInput, label };
}
updateFormBody(selectedName) {
const selectedOption = this.schemaForm.find(option => option.name === selectedName);
this.$body.innerHTML = ""; // Clear current form
this.$tip.innerHTML = `当前操作:${selectedOption.text}`;
selectedOption.inputs.forEach((input) => {
const inputField = document.createElement("input");
inputField.type = "text";
inputField.name = input.name;
inputField.placeholder = input.text;
inputField.classList.add("js-input");
inputField.style = "width: 95%;";
const p = document.createElement("p");
p.appendChild(inputField);
this.$body.appendChild(p);
});
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();
}
});
data.filter = $n(".js-input[name=filter]").value.trim();
return data;
}
}
class HttpRequest {
constructor() {
if (typeof GM_xmlhttpRequest === "undefined") {
throw new TypeError("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();
var tplEdt = "<div class=\"mz-edt\">\n <div class=\"act-tab\" style=\"display: flex;\">操作模式:</div>\n <hr>\n <h2>「标签」或「分类」(区分大小写): </h2>\n <p>\n <input class=\"js-input\" type=\"text\" name=\"filter\" style=\"width: 97%;\" placeholder=\"包含要修改项目的「标签」或「分类」,或新建一个\">\n </p>\n <h2>Tracker: <span class=\"js-tip-btn\"></span></h2>\n <div class=\"act-body\"></div>\n <p class=\"pb-less text-16\">「<a target=\"_blank\" title=\"投喂支持\" href=\"https://afdian.com/a/wdssmq\" rel=\"nofollow\">打钱给作者-爱发电</a>」\n 「<a target=\"_blank\" title=\"QQ 群 - 我的咸鱼心\" href=\"https://jq.qq.com/?_wv=1027&k=SRYaRV6T\" rel=\"nofollow\">QQ 群 - 我的咸鱼心</a>」\n </p>\n <hr>\n <p class=\"pb-less p-bold\">选中要操作的 Torrent 任务(可多选),右键里「标签」或「分类」添加或指定,建议用「标签」;</p>\n <p class=\"pb-less\">「替换」时请使用完整地址,或者使用「子串替换」;</p>\n <p class=\"pb-less\">特殊需求可「删除」→填入「****」清空旧的后「添加」新的;</p>\n</div>";
function styleInject(css, ref) {
if ( ref === void 0 ) ref = {};
var insertAt = ref.insertAt;
if (!css || typeof document === 'undefined') { return; }
var head = document.head || document.getElementsByTagName('head')[0];
var style = document.createElement('style');
style.type = 'text/css';
if (insertAt === 'top') {
if (head.firstChild) {
head.insertBefore(style, head.firstChild);
} else {
head.appendChild(style);
}
} else {
head.appendChild(style);
}
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
}
var css_248z = ".mz-edt {\n padding: 13px 23px;\n font-size: 14px;\n line-height: 20px\n}\n\n.mz-edt .text-16 {\n font-size: 16px;\n line-height: 24px\n}\n\n.mz-edt .p-bold {\n font-weight: 700;\n border-bottom: 1px solid currentColor;\n margin-bottom: 4px;\n padding-bottom: 0;\n}\n\n.mz-edt p.pb-less {\n padding-bottom: 3px\n}";
styleInject(css_248z);
/* global __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?tag=test 或 category=test
apiTorrents(filter = "", fn = () => { }) {
// category 查询
const tryCategory = () => {
const url = gob.apiUrl(`torrents/info?category=${filter}`);
gob.http.get(url).then((res) => {
gob.data.listTorrent = gob.parseReq(res, "json");
fn();
}).catch(() => {
gob.data.listTorrent = [];
fn();
});
};
// tag 查询
const tryTag = () => {
const url = gob.apiUrl(`torrents/info?tag=${filter}`);
gob.http.get(url).then((res) => {
const list = gob.parseReq(res, "json");
if (list.length > 0) {
gob.data.listTorrent = list;
fn();
}
else {
tryCategory();
}
}).catch(tryCategory);
};
if (filter) {
tryTag();
}
else {
// 如果为空,查询所有
const url = gob.apiUrl("torrents/info");
gob.http.get(url).then((res) => {
gob.data.listTorrent = gob.parseReq(res, "json");
fn();
}).catch(() => {
gob.data.listTorrent = [];
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
apiEdtTracker(hash, origUrl, newUrl, isPartial = false) {
_log("apiEdtTracker()\n", hash, origUrl, newUrl);
const url = gob.apiUrl("torrents/editTracker");
if (isPartial) {
gob.apiGetTrackers(hash, () => {
const seedTrackers = gob.data.curTorrentTrackers;
seedTrackers.forEach((tracker) => {
if (tracker.url.includes(origUrl)) {
const updatedUrl = tracker.url.replace(origUrl, newUrl);
gob.http.post(url, { hash, origUrl: tracker.url, newUrl: updatedUrl });
}
});
});
}
else {
gob.http.post(url, { hash, origUrl, newUrl });
}
},
// 添加 Tracker: torrents/addTrackers
apiAddTracker(hash, urls) {
const url = gob.apiUrl("torrents/addTrackers");
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, "");
if (text) {
$el.textContent = `(${text})`;
}
if (key === "btn") {
$el.style.color = "var(--color-text-red)";
}
}
}
},
// 更新提示信息
upTips(key = "tit", tip) {
const tipData = gob.data.tips[key];
Object.assign(tipData, tip);
gob.viewTips();
},
init() {
gob.apiInfo(() => {
_log(gob.data);
});
},
};
gob.init();
// 构建编辑入口
$n("#desktopNavbar ul").insertAdjacentHTML(
"beforeend",
"<li><a class=\"js-modal\"><b>→批量替换 Tracker←</b></a></li>",
);
// js-modal 绑定点击事件
$n(".js-modal").addEventListener("click", () => {
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: 500,
height: 360,
});
// console.log(modal);
const modalContent = $n("#js-modal_content");
modalContent.innerHTML = tplEdt;
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();
// 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();
});
function fnCheckUrl(name, url) {
// 判断是否以 udp:// 或 http(s):// 开头
const regex = /^(?:udp|https?):\/\//;
return [
name,
regex.test(url),
];
}
document.addEventListener("click", (event) => {
if (event.target.classList.contains("btn-act")) {
gob.act = gob.formObj.curSelect;
gob.urlCheck = [];
const formData = gob.formObj.getFormData();
// 判断筛选条件
if (!formData.filter || /全部|未分类|无标签/.test(formData.filter)) {
gob.upTips("btn", {
msg: "「标签」或「分类」错误,请重新输入",
});
return;
}
// 遍历数据,如果 key 含有 Url,则判断 value 是否符合要求
for (const key in formData) {
if (Object.prototype.hasOwnProperty.call(formData, key)) {
const value = formData[key];
if (key.includes("Url")) {
// 判断是否符合要求
gob.urlCheck.push(fnCheckUrl(key, value));
}
}
}
let isOk = gob.urlCheck.every((item) => {
return item[1];
});
if (gob.act === "partialReplace") {
const isOk2 = gob.urlCheck.every((item) => {
return !item[1];
});
if (!isOk && !isOk2) {
gob.upTips("btn", {
msg: "子串替换模式下,请对应输入要替换的新旧文本",
});
return;
}
}
if (gob.act === "remove" && formData.trackerUrl === "****") {
isOk = confirm("继续将清空匹配任务的全部 Tracker");
gob.act = "removeAll";
}
if (!isOk) {
gob.urlCheck.forEach((item) => {
if (!item[1]) {
gob.upTips("btn", {
msg: `「${item[0]}」不符合要求`,
});
}
});
return;
}
const fnRemoveAll = (hash) => {
gob.apiGetTrackers(hash, () => {
const seedTrackers = gob.data.curTorrentTrackers;
const seedTrackersUrl = seedTrackers.map((item) => {
return item.url;
});
gob.apiDelTracker(hash, seedTrackersUrl.join("|"));
});
};
gob.apiTorrents(formData.filter, () => {
const list = gob.data.listTorrent;
_log("apiTorrents()\n", list);
if (list.length === 0) {
gob.upTips("btn", {
msg: "没有符合条件的种子,请确认分类存在且添加要修改的任务项;",
});
return;
}
list.forEach((item) => {
switch (gob.act) {
case "replace":
gob.apiEdtTracker(item.hash, formData.origUrl, formData.newUrl, false);
break;
case "partialReplace":
gob.apiEdtTracker(item.hash, formData.origUrl, formData.newUrl, true);
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: "操作完成",
});
});
}
});
})();