// ==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);
})();