qBittorrent-wiki-plugins-packager

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

  1. // ==UserScript==
  2. // @name qBittorrent-wiki-plugins-packager
  3. // @name:zh-CN 一键下载qBittorrent插件文件
  4. // @namespace http://tampermonkey.net/
  5. // @version 1.0.1
  6. // @description Package and download qBittorrent unoffical public plugins's .py files on qBittorrent plugin wiki page.
  7. // @description:zh-CN 自动下载qbittorrent公用插件py文件并保存到压缩包中
  8. // @author ValueGreasyFork
  9. // @homepage https://github.com/ValueXu/qBittorrent-wiki-plugins-packager/
  10. // @homepageURL https://github.com/ValueXu/qBittorrent-wiki-plugins-packager/
  11. // @supportURL https://github.com/ValueXu/qBittorrent-wiki-plugins-packager/issues
  12. // @match https://github.com/qbittorrent/search-plugins/wiki/Unofficial-search-plugins
  13. // @icon http://github.com/favicon.icoa
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.9.1/jszip.min.js
  15. // @grant GM_xmlhttpRequest
  16. // @grant GM_notification
  17. // @grant GM_download
  18. // @license MIT
  19. // ==/UserScript==
  20.  
  21. // must add this requirement for jszip 3.10.x. For the reason, see the issuses below
  22. // https://github.com/Stuk/jszip/issues/909
  23. // https://github.com/Tampermonkey/tampermonkey/issues/1600
  24. // //@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
  25.  
  26. (function () {
  27. "use strict";
  28.  
  29. /**
  30. * @description get urls from page element
  31. * @returns {string[]}
  32. */
  33. const getUrlsFromEl = () => {
  34. // get unoffical public plugin table element
  35. const tableEl = document.querySelector(
  36. "#wiki-body > div.markdown-body > table:nth-child(7) > tbody"
  37. );
  38. if (!tableEl) {
  39. return;
  40. }
  41. // get tr elements
  42. const trEls = tableEl.getElementsByTagName("tr");
  43. const pluginUrls = [];
  44. // start from second row
  45. for (let i = 1; i < trEls.length; i++) {
  46. const cTrEl = trEls.item(i);
  47. if (!cTrEl) {
  48. continue;
  49. }
  50. // get url from fifth row cell
  51. const tdEl = cTrEl.cells.item(4);
  52. if (!tdEl) {
  53. continue;
  54. }
  55. const aEl = tdEl.querySelector("a");
  56. if (!aEl) {
  57. continue;
  58. }
  59. if (!aEl.href) {
  60. continue;
  61. }
  62. pluginUrls.push("" + aEl.href);
  63. }
  64. return pluginUrls;
  65. };
  66.  
  67. /**
  68. *
  69. * @param {string} url
  70. * @returns {string} fileName
  71. */
  72. const getFileNameFromUrl = (url) => {
  73. if (typeof url !== "string") {
  74. return null;
  75. }
  76. const startIndex = url.lastIndexOf("/") + 1;
  77. return url.substring(startIndex);
  78. };
  79.  
  80. /**
  81. * @typedef {{blob:Blob;name:string;url?:string}} FileObj
  82. */
  83.  
  84. /**
  85. *
  86. * @param {string|URL} url
  87. * @returns {Promise<FileObj>}
  88. */
  89. const downloadFile = (url) => {
  90. return new Promise((resolve, reject) => {
  91. const _url =
  92. typeof url === "string"
  93. ? url
  94. : url instanceof URL
  95. ? url.toString()
  96. : null;
  97. if (!url) {
  98. reject("invalid url");
  99. }
  100.  
  101. /**
  102. * @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
  103. */
  104.  
  105. /**
  106. * @description on request load
  107. * @param {ResponseObject} res
  108. */
  109. const onLoad = (res) => {
  110. if (res.status < 200 || res.status >= 300) {
  111. reject(`response status is ${res.status}`);
  112. }
  113. const encoder = new TextEncoder();
  114. const ui8Arr = encoder.encode(res.responseText);
  115. const blob = new Blob([ui8Arr], { type: "text/plain" });
  116. const fileName = getFileNameFromUrl(_url);
  117. resolve({
  118. blob,
  119. url: _url,
  120. name: fileName,
  121. });
  122. };
  123. /**
  124. * @description on request error
  125. * @param {ResponseObject} res
  126. */
  127. const onErr = (res) => {
  128. reject(`download file error, status is ${res.status}`);
  129. };
  130. GM_xmlhttpRequest({
  131. url,
  132. method: "GET",
  133. headers: {
  134. accept: "text/html,text/plain",
  135. "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
  136. "cache-control": "no-cache",
  137. pragma: "no-cache",
  138. "sec-ch-ua":
  139. '"Not_A Brand";v="8", "Chromium";v="120", "Microsoft Edge";v="120"',
  140. "sec-ch-ua-mobile": "?0",
  141. "sec-ch-ua-platform": '"Windows"',
  142. "sec-fetch-dest": "document",
  143. "sec-fetch-mode": "navigate",
  144. "sec-fetch-site": "none",
  145. "sec-fetch-user": "?1",
  146. "upgrade-insecure-requests": "1",
  147. },
  148. onload: onLoad,
  149. onerror: onErr,
  150. });
  151. });
  152. };
  153.  
  154. /**
  155. *
  156. * @param {FileObj[]} files
  157. * @returns {FileObj|null}
  158. */
  159. const packageFiles = async (files) => {
  160. const { JSZip } = window;
  161. if (!JSZip) {
  162. GM_notification({
  163. text: "第三方库未初始化,请更新脚本或检查网络\nexternal lib not inited, please update the script or check the internet connection.",
  164. title: "Error",
  165. timeout: 2000,
  166. });
  167. return null;
  168. }
  169. if (!files) {
  170. return null;
  171. }
  172. const jsZip = new JSZip();
  173. files.forEach((file) => {
  174. jsZip.file(file.name, file.blob);
  175. });
  176.  
  177. /** @type {Uint8Array} */
  178. const ui8Arr = await jsZip.generateAsync({
  179. type: "uint8array",
  180. compression: "STORE",
  181. });
  182. const blob = new Blob([ui8Arr], { type: "application/zip" });
  183. return {
  184. blob,
  185. name: "qBittorrent_plugins.zip",
  186. };
  187. };
  188.  
  189. /**
  190. * downfile fileobj via tampermonkey
  191. * @param {FileObj} fileObj
  192. * @returns {Promise<undefined>}
  193. */
  194. const downloadFileObj = (fileObj) => {
  195. const url = URL.createObjectURL(fileObj.blob);
  196. return new Promise((resolve, reject) => {
  197. const onFinish = () => {
  198. URL.revokeObjectURL(url);
  199. };
  200. /**
  201. *
  202. * @param {string} error
  203. * @param {string} details
  204. */
  205. const onErr = (error, details) => {
  206. onFinish();
  207. reject(error);
  208. };
  209. const onLoad = () => {
  210. onFinish();
  211. resolve();
  212. };
  213. const onTimeout = () => {
  214. const errMsg = "download timeout";
  215. onFinish();
  216. reject(errMsg);
  217. };
  218. GM_download({
  219. url,
  220. name: fileObj.name,
  221. saveAs: true,
  222. onerror: onErr,
  223. onload: onLoad,
  224. ontimeout: onTimeout,
  225. });
  226. });
  227. };
  228.  
  229. const onClick = async () => {
  230. const urls = getUrlsFromEl();
  231. GM_notification({
  232. text: `找到${urls ? urls.length : 0}个脚本,开始下载\nfound ${
  233. urls ? urls.length : 0
  234. } scripts, start to download.`,
  235. title: "Info",
  236. timeout: 1500,
  237. });
  238. const res = await Promise.allSettled(urls.map((url) => downloadFile(url)));
  239. /** @type {PromiseFulfilledResult<FileObj>[]} */
  240. const successRes = [];
  241. /** @type {PromiseRejectedResult<FileObj>[]} */
  242. const failedRes = [];
  243. res.forEach((value) => {
  244. if (value.status === "fulfilled") {
  245. successRes.push(value);
  246. } else {
  247. failedRes.push(value);
  248. }
  249. });
  250. if (failedRes.length) {
  251. console.error(`download file error: `, failedRes);
  252. }
  253. GM_notification({
  254. title: "Info",
  255. text: `成功${successRes.length}个,失败${failedRes.length}个,打包中\n${successRes.length} success, ${failedRes.length} failed, packaging`,
  256. timeout: 1000,
  257. silent: true,
  258. });
  259. const zipFile = await packageFiles(successRes.map((res) => res.value));
  260. GM_notification({
  261. text: `打包成功,请选择保存位置\nPackage success, please choose the floder to save`,
  262. title: "Info",
  263. timeout: 1500,
  264. });
  265. await downloadFileObj(zipFile);
  266. };
  267.  
  268. const onWindowLoaded = () => {
  269. const button = document.createElement("button");
  270. button.style.display = "flex";
  271. button.style.justifyContent = "center";
  272. button.style.alignItems = "center";
  273.  
  274. button.style.position = "fixed";
  275. button.style.zIndex = "999";
  276. button.style.bottom = "1rem";
  277. button.style.right = "1.5rem";
  278.  
  279. button.style.height = "";
  280. button.style.width = "";
  281. button.style.minHeight = "64px";
  282.  
  283. button.style.border = "2px solid transparent";
  284. button.style.boxShadow = String.raw`0 1px 3px #0000001a, 0 1px 2px #0000000f`;
  285.  
  286. button.style.fontFamily = String.raw`-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"`;
  287. button.style.fontSize = "1.6rem";
  288. button.style.textAlign = "center";
  289.  
  290. 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>`;
  291. button.innerHTML = svgHtml;
  292. button.addEventListener("click", onClick);
  293. document.body.appendChild(button);
  294. };
  295. window.addEventListener("load", onWindowLoaded);
  296. })();