Sauvegarde / Fanbox Batch Downloader

// ==UserScript==
// @name         Fanbox Batch Downloader
// @namespace    https://openuserjs.org/users/Sauvegarde
// @version      0.4
// @author       Sauvegarde
// @description  Batch download all pictures in a creator's post.
// @include      https://*.fanbox.cc*
// @grant        GM_download
// @iconURL      https://www.fanbox.cc/favicon.ico
// @updateURL    https://openuserjs.org/meta/Sauvegarde/Fanbox_Batch_Downloader.meta.js
// @downloadURL  https://openuserjs.org/install/Sauvegarde/Fanbox_Batch_Downloader.user.js
// @copyright    2021, Sauvegarde (https://openuserjs.org/users/Sauvegarde)
// @license		 MIT
// ==/UserScript==

/* jshint esversion: 8 */

(function() {
    'use strict';

	/** to be used with accessors in async methods */
	const modal = {
		_downloading: false,
		get downloading() { return this._downloading; },
		set downloading(state) { this._downloading = state; },
		_downloaded: 0,
		get downloaded() { return this._downloaded; },
		set downloaded(state) { this._downloaded = state; },
		_retry: false,
		get retry() { return this._retry; },
		set retry(state) { this._retry = state; },
	};

	/** pads a number to the desired length, with a leading '1' */
	function pad(n, length, z) {
		z = z || '0';
		n = n + '';
		return n.length >= length ? n : '1' + new Array(length - n.length).join(z) + n;
	}

	/** SVG icon 💾 */
	function disketSvg(width, height) {
		const ns = "http://www.w3.org/2000/svg";
		const svg = document.createElementNS(ns, "svg");
		svg.setAttributeNS(null, "width", width);
		svg.setAttributeNS(null, "height", height);
		svg.setAttributeNS(null, "viewBox", "0 0 24 24");
		const path = document.createElementNS(ns, "path");
		path.setAttributeNS(null, "d", "M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 "
							+ "0 2-.9 2-2V7l-4-4zm2 16H5V5h11.17L19 7.83V19zm-7-7c-1.66 "
							+ "0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3zM6 6h9v4H6z");
		path.setAttributeNS(null, "style", "fill: currentColor;");
		svg.appendChild(path);
		return svg;
	}

	/** direct download to default folder */
	function download(url, name, callback) {
		if (!url) {
			console.error("Invalid download target:", url);
			return;
		}
		if (!name) {
			console.error("Invalid download name:", name);
			return;
		}
		const ext = url.split('.').pop();
		if (!ext) {
			console.error("Invalid extension:", ext);
			return;
		}
		name += "." + ext;
		modal.downloading = true;
		GM_download({
			url,
			name,
			saveAs: false,
			headers: { Referer: "https://www.fanbox.cc/" },
			onload: () => handleLoad(),
			onerror: (err) => handleError(err, ext),
			ontimeout: () => handleTimeout(),
		});

		function handleLoad() {
			console.info(name + " successfully downloaded");
			modal.downloading = false;
			modal.downloaded++;
			if(callback) {
				callback();
			}
		}

		function handleTimeout() {
			alert("The download target has timed out :(");
			modal.downloading = false;
		}

		function handleError(error, ext) {
			switch(error.error) {
				case "not_enabled":
					alert("GM_download is not enabled.");
					break;
				case "not_permitted":
					alert("GM_download permission has not been granted.");
					break;
				case "not_supported":
					alert("GM_download is not supported by the browser/version.");
					break;
				case "not_succeeded":
					console.error(error);
					alert("GM_download has vulgarly failed. Please retry.");
					break;
				case "not_whitelisted":
					// https://github.com/Tampermonkey/tampermonkey/issues/643
					alert("The requested file extension (" + ext + ") is not whitelisted.\n\n"
						  +"You have to add it manually (see 'Downloads' in Tampermonkey settings).");
					break;
				case "Download canceled by the user":
					// User just clicked "Cancel" on the prompt
					break;
				default:
					console.error(error);
					alert("GM_download has unexpectedly failed with the following error: " + error.error);
					break;
			}
			modal.downloading = false;
		}
	}

	function downloadAll(event) {
		const imgLinks = [...document.querySelectorAll("article a img")].map(el => el.closest("a"));
		const I = imgLinks.length;
		const padLength = Math.max(3, I.toString().length + 1);
		const batchButton = event.target;
		batchButton.disabled = true;
		batchButton.style.cursor = "pointer";
		const batchProgress = document.createElement("span");
		batchProgress.style.marginLeft = "3px";
		batchProgress.innerHTML = "0/" + I;
		batchButton.appendChild(batchProgress);
		modal.downloaded = 0;
		for(let i = 0; i < I; ++i) {
			const imgLink = imgLinks[i];
			download(imgLink.href, pad(i+1, padLength), () => {
				batchProgress.innerHTML = `${modal.downloaded}/${I}`;
				if(modal.downloaded === I) {
					batchProgress.style.color = "#084";
					batchButton.style.cursor = "";
					batchButton.disabled = false;
				}
			});
		}
	}

	function onDocumentMutation(action) {
		const observer = new MutationObserver(changes => action());
		observer.observe(document.body, {childList : true, subtree: true});
	}

	function createBatchDownloadButton() {
		const id = "fanbox-batch-downloader";
		const batchDownload = document.getElementById(id);
		const actionBar = document.querySelector("article + div + div");
		if (!batchDownload && actionBar) {
			const like = actionBar.querySelector("button");
			if (like) {
				const batchDownload = document.createElement("button");
				batchDownload.id = id;
				batchDownload.className = like.className;
				batchDownload.style.marginLeft = "8px";
				batchDownload.appendChild(disketSvg(18, 18));
				const batchText = document.createElement("span");
				batchText.innerHTML = "Batch Download";
				batchText.style.marginLeft = "3px";
				batchDownload.appendChild(batchText);
				batchDownload.addEventListener("click", e => downloadAll(e));
				like.parentElement.appendChild(batchDownload);
			} else {
				// No "Like <3" button (paid post or video).
			}
		}
	}

	onDocumentMutation(() => createBatchDownloadButton());
})();