// ==UserScript== // @name 「水水」qBittorrent 管理脚本「QQ 群:189574683」 // @namespace http://沉冰浮水.tk/ // @version 1.0.1 // @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.net/@wdssmq // @link https://greasyfork.org/zh-CN/users/6865-wdssmq // @null ---------------------------- // @noframes // @run-at document-end // @match http://127.0.0.1:8080 // @include http://*:8088/ // @grant GM_xmlhttpRequest // @require https://cdn.bootcdn.net/ajax/libs/jquery/3.6.3/jquery.min.js // @downloadURL none // ==/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); // ------------------------------------- function fnCheckObj(obj, schema) { for (const key in schema) { const value = obj[key]; // 模式中定义的键必须存在 if (typeof value === "undefined") { throw new Error(`${key} is missing from object`); } // 针对每个键值的模式 const valueSchema = schema[key]; valueSchema.forEach((itemSchema) => { const msg = itemSchema.msg; for (const check in itemSchema) { if (Object.hasOwnProperty.call(itemSchema, check)) { const checkVal = itemSchema[check]; switch (check) { case "not": if (value === checkVal) { throw new Error(`${key} ${msg}`); } break; } } } }); } return true; } 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(); /* global jQuery, __GM_api, MochaUI */ const jq = jQuery; 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 apiTorrents(category = "", fn = () => { }) { const url = gob.apiUrl(`torrents/info?category=${category}`); 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 apiEdtTracker(hash, origUrl, newUrl) { _log("apiEdtTracker()\n", hash, origUrl, newUrl); const url = gob.apiUrl("torrents/editTracker"); 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 }); }, // 获取 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 = jq(`.js-tip-${key}`); const text = JSON.stringify(tip).replace(/(,|:)"/g, "$1 ").replace(/["{}]/g, ""); $el.text(`(${text})`); } } }, // 更新提示信息 upTips(key = "tit", tip) { const tipData = gob.data.tips[key]; Object.assign(tipData, tip); gob.viewTips(); }, init() { gob.apiInfo(() => { _log(gob.data); }); }, }; gob.init(); // 构建编辑入口 jq("#desktopNavbar>ul").append( "
  • →批量替换 Tracker←
  • ", ); // 构建编辑框 const strHtml = `
    \

    分类:(不能是「全部」或「未分类」,区分大小写)


    \

    旧 Trakcer:


    \

    新 Tracker:


    \

    子串模式:仅替换链接中的部分文本

    \
    \ \ \
    \ 「投喂支持」\ 「QQ 群 - 我的咸鱼心」\

    `; // js-modal 绑定点击事件 jq(".js-modal").click(function () { new MochaUI.Window({ id: "js-modal", title: "批量替换 Tracker ", loadMethod: "iframe", contentURL: "", scrollbars: true, resizable: false, maximizable: false, closable: true, paddingVertical: 0, paddingHorizontal: 0, width: 500, height: 250, }); jq("#js-modal_content").append(strHtml); jq("#js-modal_contentWrapper").css({ height: "auto", }); gob.data.modalShow = true; gob.upTips("tit", { qbt: gob.data.qbtVer, api: gob.data.apiVer, }); // debug // jq(".js-input[name=category]").val("test"); // jq(".js-input[name=origUrl]").val("123"); // jq(".js-input[name=newUrl]").val("456"); // jq(".js-input[name=matchSubstr]").click(); }); const schemeObj = { category: [ { not: "", msg: "不能为空", }, ], newUrl: [ { not: "", msg: "不能为空", }, ], }; const fnCheckUrl = (url) => { // 判断是否以 udp:// 或 http(s):// 开头 const regex = /^(udp|http(s)?):\/\//; return regex.test(url); }; jq(document).on("click", ".js-replace", function () { // alert(jq(".js-input[name=category]").val()); const obj = { category: jq(".js-input[name=category]").val().trim(), origUrl: jq(".js-input[name=origUrl]").val().trim(), newUrl: jq(".js-input[name=newUrl]").val().trim(), matchSubstr: jq(".js-input[name=matchSubstr]").is(":checked"), }; try { fnCheckObj(obj, schemeObj); } catch (error) { alert(error); return; } if (obj.origUrl === "") { if (!confirm("未填写「旧 Tracker」,将执行添加操作,是否继续?")) { return; } if (!fnCheckUrl(obj.newUrl)) { alert("「新 Tracker」必须以 udp:// 或 http(s):// 开头"); return; } } else { // 新旧 Tracker 执行 fnCheckUrl 判断的结果应该相同 if (fnCheckUrl(obj.origUrl) !== fnCheckUrl(obj.newUrl)) { alert("「旧 Tracker」和「新 Tracker」必须同为链接或同为子串"); return; } if (!fnCheckUrl(obj.origUrl) && !obj.matchSubstr) { alert("必须以 udp:// 或 http(s):// 开头\n或者勾选「子串模式」"); return; } } // 根据子串获取完整的 Url const replaceBySubstr = async (hash, oldSubstr, newSubstr) => { gob.apiGetTrackers(hash, () => { const seedTrackers = gob.data.curTorrentTrackers; const rlt = { oldUrl: "", newUrl: "", bolMatch: false, }; seedTrackers.forEach((item) => { let oldUrl = item.url, newUrl = ""; // _log("oldUrl", oldUrl, oldSubstr); if (oldUrl.indexOf(oldSubstr) > -1 && !rlt.bolMatch) { newUrl = oldUrl.replace(oldSubstr, newSubstr); rlt.bolMatch = true; rlt.oldUrl = oldUrl; rlt.newUrl = newUrl; } }); if (rlt.bolMatch) { gob.apiEdtTracker(hash, rlt.oldUrl, rlt.newUrl); } }); }; gob.apiTorrents(obj.category, () => { const list = gob.data.listTorrent; _log("apiTorrents()\n", list); list.map(function (item) { if (obj.matchSubstr) { replaceBySubstr(item.hash, obj.origUrl, obj.newUrl); return; } // 替换或添加 Tracker(完整匹配) if (obj.origUrl !== "") { gob.apiEdtTracker(item.hash, obj.origUrl, obj.newUrl); } else { gob.apiAddTracker(item.hash, obj.newUrl); } }); gob.upTips("btn", { num: list.length, msg: "操作成功", }); }); return; }); })();