// ==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 https://greasyfork.org/zh-CN/users/6865-wdssmq // @null ---------------------------- // @noframes // @run-at document-end // @include http://*:8088/ // @grant GM_xmlhttpRequest // @downloadURL https://update.greasyfork.icu/scripts/391688/%E3%80%8C%E6%B0%B4%E6%B0%B4%E3%80%8DqBittorrent%20%E7%AE%A1%E7%90%86%E8%84%9A%E6%9C%AC.user.js // @updateURL https://update.greasyfork.icu/scripts/391688/%E3%80%8C%E6%B0%B4%E6%B0%B4%E3%80%8DqBittorrent%20%E7%AE%A1%E7%90%86%E8%84%9A%E6%9C%AC.meta.js // ==/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 = "
\n
操作模式:
\n
\n

「标签」或「分类」(区分大小写):

\n

\n \n

\n

Tracker:

\n
\n

打钱给作者-爱发电」\n 「QQ 群 - 我的咸鱼心」\n

\n
\n

选中要操作的 Torrent 任务(可多选),右键里「标签」或「分类」添加或指定,建议用「标签」;

\n

「替换」时请使用完整地址,或者使用「子串替换」;

\n

特殊需求可「删除」→填入「****」清空旧的后「添加」新的;

\n
"; function styleInject(css, ref) { if ( ref === void 0 ) ref = {}; var insertAt = ref.insertAt; if (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", "
  • →批量替换 Tracker←
  • ", ); // js-modal 绑定点击事件 $n(".js-modal").addEventListener("click", () => { new MochaUI.Window({ id: "js-modal", title: "批量替换 Tracker ", 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: "操作完成", }); }); } }); })();