qBittorrent-wiki-plugins-packager

Package and download qBittorrent unoffical public plugins's .py files on qBittorrent plugin wiki page.

// ==UserScript==
// @name         qBittorrent-wiki-plugins-packager
// @name:zh-CN  一键下载qBittorrent插件文件
// @namespace    http://tampermonkey.net/
// @version      1.0.1
// @description  Package and download qBittorrent unoffical public plugins's .py files on qBittorrent plugin wiki page.
// @description:zh-CN 自动下载qbittorrent公用插件py文件并保存到压缩包中
// @author       ValueGreasyFork
// @homepage     https://github.com/ValueXu/qBittorrent-wiki-plugins-packager/
// @homepageURL  https://github.com/ValueXu/qBittorrent-wiki-plugins-packager/
// @supportURL   https://github.com/ValueXu/qBittorrent-wiki-plugins-packager/issues
// @match        https://github.com/qbittorrent/search-plugins/wiki/Unofficial-search-plugins
// @icon         http://github.com/favicon.icoa
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.9.1/jszip.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_notification
// @grant        GM_download
// @license      MIT
// ==/UserScript==

// must add this requirement for jszip 3.10.x. For the reason, see the issuses below
// https://github.com/Stuk/jszip/issues/909
// https://github.com/Tampermonkey/tampermonkey/issues/1600
// //@require      data:application/javascript,window.setImmediate%20%3D%20window.setImmediate%20%7C%7C%20((f%2C%20...args)%20%3D%3E%20window.setTimeout(()%20%3D%3E%20f(args)%2C%200))%3B

(function () {
  "use strict";

  /**
   * @description get urls from page element
   * @returns {string[]}
   */
  const getUrlsFromEl = () => {
    // get unoffical public plugin table element
    const tableEl = document.querySelector(
      "#wiki-body > div.markdown-body > table:nth-child(7) > tbody"
    );
    if (!tableEl) {
      return;
    }
    // get tr elements
    const trEls = tableEl.getElementsByTagName("tr");
    const pluginUrls = [];
    // start from second row
    for (let i = 1; i < trEls.length; i++) {
      const cTrEl = trEls.item(i);
      if (!cTrEl) {
        continue;
      }
      // get url from fifth row cell
      const tdEl = cTrEl.cells.item(4);
      if (!tdEl) {
        continue;
      }
      const aEl = tdEl.querySelector("a");
      if (!aEl) {
        continue;
      }
      if (!aEl.href) {
        continue;
      }
      pluginUrls.push("" + aEl.href);
    }
    return pluginUrls;
  };

  /**
   *
   * @param {string} url
   * @returns {string}   fileName
   */
  const getFileNameFromUrl = (url) => {
    if (typeof url !== "string") {
      return null;
    }
    const startIndex = url.lastIndexOf("/") + 1;
    return url.substring(startIndex);
  };

  /**
   * @typedef {{blob:Blob;name:string;url?:string}} FileObj
   */

  /**
   *
   * @param {string|URL} url
   * @returns {Promise<FileObj>}
   */
  const downloadFile = (url) => {
    return new Promise((resolve, reject) => {
      const _url =
        typeof url === "string"
          ? url
          : url instanceof URL
          ? url.toString()
          : null;
      if (!url) {
        reject("invalid url");
      }

      /**
       * @typedef {{ readyState:number; status:number; statusText:string; responseText:string; responseHeaders:string; responseXML?:Document; response:string|Blob|ArrayBuffer|Document|Object|null; finalUrl:string; context:any; }} ResponseObject
       */

      /**
       * @description on request load
       * @param {ResponseObject} res
       */
      const onLoad = (res) => {
        if (res.status < 200 || res.status >= 300) {
          reject(`response status is ${res.status}`);
        }
        const encoder = new TextEncoder();
        const ui8Arr = encoder.encode(res.responseText);
        const blob = new Blob([ui8Arr], { type: "text/plain" });
        const fileName = getFileNameFromUrl(_url);
        resolve({
          blob,
          url: _url,
          name: fileName,
        });
      };
      /**
       * @description on request error
       * @param {ResponseObject} res
       */
      const onErr = (res) => {
        reject(`download file error, status is ${res.status}`);
      };
      GM_xmlhttpRequest({
        url,
        method: "GET",
        headers: {
          accept: "text/html,text/plain",
          "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
          "cache-control": "no-cache",
          pragma: "no-cache",
          "sec-ch-ua":
            '"Not_A Brand";v="8", "Chromium";v="120", "Microsoft Edge";v="120"',
          "sec-ch-ua-mobile": "?0",
          "sec-ch-ua-platform": '"Windows"',
          "sec-fetch-dest": "document",
          "sec-fetch-mode": "navigate",
          "sec-fetch-site": "none",
          "sec-fetch-user": "?1",
          "upgrade-insecure-requests": "1",
        },
        onload: onLoad,
        onerror: onErr,
      });
    });
  };

  /**
   *
   * @param {FileObj[]} files
   * @returns {FileObj|null}
   */
  const packageFiles = async (files) => {
    const { JSZip } = window;
    if (!JSZip) {
      GM_notification({
        text: "第三方库未初始化,请更新脚本或检查网络\nexternal lib not inited, please update the script or check the internet connection.",
        title: "Error",
        timeout: 2000,
      });
      return null;
    }
    if (!files) {
      return null;
    }
    const jsZip = new JSZip();
    files.forEach((file) => {
      jsZip.file(file.name, file.blob);
    });

    /** @type {Uint8Array} */
    const ui8Arr = await jsZip.generateAsync({
      type: "uint8array",
      compression: "STORE",
    });
    const blob = new Blob([ui8Arr], { type: "application/zip" });
    return {
      blob,
      name: "qBittorrent_plugins.zip",
    };
  };

  /**
   * downfile fileobj via tampermonkey
   * @param {FileObj} fileObj
   * @returns {Promise<undefined>}
   */
  const downloadFileObj = (fileObj) => {
    const url = URL.createObjectURL(fileObj.blob);
    return new Promise((resolve, reject) => {
      const onFinish = () => {
        URL.revokeObjectURL(url);
      };
      /**
       *
       * @param {string} error
       * @param {string} details
       */
      const onErr = (error, details) => {
        onFinish();
        reject(error);
      };
      const onLoad = () => {
        onFinish();
        resolve();
      };
      const onTimeout = () => {
        const errMsg = "download timeout";
        onFinish();
        reject(errMsg);
      };
      GM_download({
        url,
        name: fileObj.name,
        saveAs: true,
        onerror: onErr,
        onload: onLoad,
        ontimeout: onTimeout,
      });
    });
  };

  const onClick = async () => {
    const urls = getUrlsFromEl();
    GM_notification({
      text: `找到${urls ? urls.length : 0}个脚本,开始下载\nfound ${
        urls ? urls.length : 0
      } scripts, start to download.`,
      title: "Info",
      timeout: 1500,
    });
    const res = await Promise.allSettled(urls.map((url) => downloadFile(url)));
    /** @type {PromiseFulfilledResult<FileObj>[]} */
    const successRes = [];
    /** @type {PromiseRejectedResult<FileObj>[]} */
    const failedRes = [];
    res.forEach((value) => {
      if (value.status === "fulfilled") {
        successRes.push(value);
      } else {
        failedRes.push(value);
      }
    });
    if (failedRes.length) {
      console.error(`download file error: `, failedRes);
    }
    GM_notification({
      title: "Info",
      text: `成功${successRes.length}个,失败${failedRes.length}个,打包中\n${successRes.length} success, ${failedRes.length} failed, packaging`,
      timeout: 1000,
      silent: true,
    });
    const zipFile = await packageFiles(successRes.map((res) => res.value));
    GM_notification({
      text: `打包成功,请选择保存位置\nPackage success, please choose the floder to save`,
      title: "Info",
      timeout: 1500,
    });
    await downloadFileObj(zipFile);
  };

  const onWindowLoaded = () => {
    const button = document.createElement("button");
    button.style.display = "flex";
    button.style.justifyContent = "center";
    button.style.alignItems = "center";

    button.style.position = "fixed";
    button.style.zIndex = "999";
    button.style.bottom = "1rem";
    button.style.right = "1.5rem";

    button.style.height = "";
    button.style.width = "";
    button.style.minHeight = "64px";

    button.style.border = "2px solid transparent";
    button.style.boxShadow = String.raw`0 1px 3px #0000001a, 0 1px 2px #0000000f`;

    button.style.fontFamily = String.raw`-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"`;
    button.style.fontSize = "1.6rem";
    button.style.textAlign = "center";

    const svgHtml = String.raw`<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 512 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M216 0h80c13.3 0 24 10.7 24 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3c-7.5 7.5-19.8 7.5-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24c0-13.3 10.7-24 24-24zm296 376v112c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V376c0-13.3 10.7-24 24-24h146.7l49 49c20.1 20.1 52.5 20.1 72.6 0l49-49H488c13.3 0 24 10.7 24 24zm-124 88c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20zm64 0c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20z"></path></svg>`;
    button.innerHTML = svgHtml;
    button.addEventListener("click", onClick);
    document.body.appendChild(button);
  };
  window.addEventListener("load", onWindowLoaded);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址