Sauvegarde / Pixiv Batch Downloader

// ==UserScript==
// @name         Pixiv Batch Downloader
// @namespace    https://openuserjs.org/users/Sauvegarde
// @description  Add a "Batch Download" button that saves every picture in order.
// @author       Sauvegarde
// @include      https://www.pixiv.net/artworks/*
// @include      https://www.pixiv.net/en/artworks/*
// @icon         https://www.pixiv.net/favicon.ico
// @grant        GM_download
// @connect      i.pximg.net
// @version      1.0
// @updateURL    https://openuserjs.org/meta/Sauvegarde/Pixiv_Batch_Downloader.meta.js
// @downloadURL  https://openuserjs.org/install/Sauvegarde/Pixiv_Batch_Downloader.user.js
// @supportURL   https://openuserjs.org/scripts/Sauvegarde/Pixiv_Batch_Downloader/issues
// @copyright    2020, Sauvegarde (https://openuserjs.org/users/Sauvegarde)
// @license		 MIT
// ==/UserScript==

/* jshint esversion: 8 */

(function() {
    'use strict';

	/** Modal variables to be used with accessors in async methods (closures). */
	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; },
	};

	/** Utility functions. */
	const Utils = {
		/** Returns true if the image is fully rendered. */
		isComplete(img) {
			return img.complete && img.naturalHeight > 0;
		},
		/** Pads a number to the desired length, with a leading '1'. */
		pad(n, width, z) {
			z = z || '0';
			n = n + '';
			return n.length >= width ? n : '1' + new Array(width - n.length).join(z) + n;
		},
		/** Selects the first button with corresponding label. */
		getButtonByInnerText(context, label) {
			return [...context.querySelectorAll("button")].filter(el => el.innerText === label)[0];
		}
	};

	/** Creates a SVG icon "💾". */
	function createDisketSvgElement(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;
	}

	/** Downloads the target directly to the 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.pixiv.net/" },
			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;
		}
	}

	/** Downloads the pictures while updating the button's state. */
	function downloadAll(context, batchButton) {
		if(batchButton.classList.contains("used")) {
			alert(`The ${Modal.downloaded} pictures in this post have already be downloaded. Please check your default folder.`);
			return;
		}
		const imgLinks = context.querySelectorAll(".gtm-expand-full-size-illust");
		if(imgLinks.length == 0) {
			alert('No picture found, please refresh the page and try again.');
			return;
		}
		const I = imgLinks.length;
		const padLength = Math.max(3, I.toString().length + 1);
		batchButton.disabled = true;
		// Marks the button as "used"
		batchButton.classList.add("used");
		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];
			// The pictures will be named "101", "102", etc in order
			download(imgLink.href, Utils.pad(i+1, padLength), () => {
				batchProgress.innerHTML = `${Modal.downloaded}/${I}`;
				if(Modal.downloaded === I) {
					batchProgress.style.color = "#084";
					batchButton.disabled = false;
				}
			});
		}
	}

	/** Executes when you click on the button. */
	function prepareDownload(context, event) {
		const seeAll = Utils.getButtonByInnerText(context, "See all");
		if(seeAll) {
			alert('Please click on the "See all" button first.');
		} else {
			downloadAll(context, event.target);
		}
	}

	/** Creates the "Batch Download" button everybody wants to click.
      * This may be called multiple (5-6) times on page load.
      */
	function createActionButton() {
		// Breaks out the default body which is within an iframe
		const context = window.top.document.body;
		const figure = context.querySelector("figcaption");
		const id = "pixiv-batch-downloader-button";
		const previous = context.querySelector("#" + id);
		if(previous) {
			// Removes the previous button: if it already exists it points to the former post
			previous.parentElement.removeChild(previous);
		}
		if(figure) {
			// Creates and inserts the button
			const batchDownload = document.createElement("button");
			batchDownload.id = id;
			batchDownload.style.position = "absolute";
			batchDownload.style.right = "12px";
			batchDownload.style.height = "max-content";
			batchDownload.style.zIndex = "10";
			batchDownload.style.cursor = "pointer";
			batchDownload.addEventListener("click", event => prepareDownload(context, event));
			const batchIcon = createDisketSvgElement(18, 18);
			batchIcon.style.verticalAlign = "text-bottom";
			batchDownload.appendChild(batchIcon);
			const batchText = document.createElement("span");
			batchText.innerHTML = "Batch Download";
			batchText.style.marginLeft = "3px";
			batchDownload.appendChild(batchText);
			figure.parentElement.insertBefore(batchDownload, figure);
		} else if(!figure) {
			console.info('Could not insert "Batch Download" button: no figure has been found.');
		}
	}

	// Program starts here
	createActionButton();
})();