Nexus Download Collection

Download every mods of a collection in a single click

目前为 2023-12-28 提交的版本。查看 最新版本

// ==UserScript==
// @name         Nexus Download Collection
// @namespace    NDC
// @version      0.2
// @description  Download every mods of a collection in a single click
// @author       Drigtime
// @match        https://next.nexusmods.com/*/collections/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        GM.xmlHttpRequest
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function () {
	'use strict';

	/** CORSViaGM BEGINING */

	const CORSViaGM = document.body.appendChild(Object.assign(document.createElement('div'), { id: 'CORSViaGM' }))

	addEventListener('fetchViaGM', e => GM_fetch(e.detail.forwardingFetch))

	CORSViaGM.init = function (window) {
		if (!window) throw 'The `window` parameter must be passed in!'
		window.fetchViaGM = fetchViaGM.bind(window)

		// Support for service worker
		window.forwardingFetch = new BroadcastChannel('forwardingFetch')
		window.forwardingFetch.onmessage = async e => {
			const req = e.data
			const { url } = req
			const res = await fetchViaGM(url, req)
			const response = await res.blob()
			window.forwardingFetch.postMessage({ type: 'fetchResponse', url, response })
		}

		window._CORSViaGM && window._CORSViaGM.inited.done()

		const info = '🙉 CORS-via-GM initiated!'
		console.info(info)
		return info
	}

	function GM_fetch(p) {
		GM_xmlhttpRequest({
			...p.init,
			url: p.url, method: p.init.method || 'GET',
			onload: responseDetails => p.res(new Response(responseDetails.response, responseDetails))
		})
	}

	function fetchViaGM(url, init) {
		let _r
		const p = new Promise(r => _r = r)
		p.res = _r
		p.url = url
		p.init = init || {}
		dispatchEvent(new CustomEvent('fetchViaGM', { detail: { forwardingFetch: p } }))
		return p
	}

	CORSViaGM.init(window);

	/** CORSViaGM END */

	const getModCollection = async (gameId, collectionId) => {
		const response = await fetch("https://next.nexusmods.com/api/graphql", {
			"headers": {
				"accept": "*/*",
				"accept-language": "fr;q=0.5",
				"api-version": "2023-09-05",
				"content-type": "application/json",
				"sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Brave\";v=\"120\"",
				"sec-ch-ua-mobile": "?0",
				"sec-ch-ua-platform": "\"Windows\"",
				"sec-fetch-dest": "empty",
				"sec-fetch-mode": "cors",
				"sec-fetch-site": "same-origin",
				"sec-gpc": "1"
			},
			"referrer": `https://next.nexusmods.com/${gameId}/collections/${collectionId}?tab=mods`,
			"referrerPolicy": "strict-origin-when-cross-origin",
			"body": JSON.stringify({
				"query": "query CollectionRevisionMods ($revision: Int, $slug: String!, $viewAdultContent: Boolean) { collectionRevision (revision: $revision, slug: $slug, viewAdultContent: $viewAdultContent) { externalResources { id, name, resourceType, resourceUrl }, modFiles { fileId, optional, file { fileId, name, scanned, size, sizeInBytes, version, mod { adult, author, category, modId, name, pictureUrl, summary, version, game { domainName }, uploader { avatar, memberId, name } } } } } }",
				"variables": { "slug": collectionId, "viewAdultContent": true },
				"operationName": "CollectionRevisionMods"
			}),
			"method": "POST",
			"mode": "cors",
			"credentials": "include"
		});

		const data = await response.json();

		return data.data.collectionRevision;
	}

	// create a button to open all urls in new tabs
	const button = document.createElement("button");
	button.classList.add("font-montserrat", "font-semibold", "text-sm", "leading-none", "tracking-wider", "uppercase", "leading-none", "flex", "gap-x-2", "justify-center", "items-center", "transition-colors", "relative", "min-h-9", "focus:outline", "focus:outline-2", "focus:outline-accent", "focus:outline-offset-2", "rounded", "px-4", "py-1", "cursor-pointer", "bg-primary", "fill-font-primary", "text-font-primary", "border-transparent", "focus:bg-primary-lighter", "hover:bg-primary-darker", "add-to-vortex-button");
	button.innerText = "Download all mods";

	const progress = document.createElement("div");
	progress.classList.add("mt-2", "w-full", "bg-gray-200", "rounded-full");
	progress.style.display = "none";
	progress.style.height = "1.1rem";
	progress.style.backgroundColor = "rgb(229 231 235 / 1)";

	const progressBar = document.createElement("div");
	progressBar.classList.add("bg-blue-600", "rounded-full");
	progressBar.style.height = "1.1rem";
	progressBar.style.fontSize = "0.8rem";
	progressBar.style.padding = "0 5px";
	progressBar.style.width = "0%";
	progressBar.style.backgroundColor = "rgb(28 100 242 / 1)";

	progress.appendChild(progressBar);

	button.onclick = async () => {
		// from current document get gameId and collectionId eg. https://next.nexusmods.com/newvegas/collections/jscbqj
		const gameId = document.location.pathname.split("/")[1];
		const collectionId = document.location.pathname.split("/")[3];

		// open all modFiles in new tabs
		const modCollection = await getModCollection(gameId, collectionId);
		const { modFiles, externalResources } = modCollection;

		const modUrls = modFiles.map(modFile => {
			// https://www.nexusmods.com/newvegas/mods/45126?tab=files&file_id=103242&nmm=1
			const modId = modFile.file.mod.modId;
			const gameId = modFile.file.mod.game.domainName;
			const fileId = modFile.file.fileId;
			return `https://www.nexusmods.com/${gameId}/mods/${modId}?tab=files&file_id=${fileId}&nmm=1`;
		});

		// const externalUrls = externalResources.map(externalResource => {
		//   return externalResource.resourceUrl;
		// });

		const urls = [
			...modUrls,
			// ...externalUrls
		];

		progress.style.display = "block";

		let downloadProgress = 0;
		let downloadProgressPercent = 0;

		const downloadMod = async (url) => {
			const response = await fetchViaGM(url, {
				"headers": {
					"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
					"accept-language": "fr;q=0.6",
					"cache-control": "max-age=0",
					"sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Brave\";v=\"120\"",
					"sec-ch-ua-mobile": "?0",
					"sec-ch-ua-platform": "\"Windows\"",
					"sec-fetch-dest": "document",
					"sec-fetch-mode": "navigate",
					"sec-fetch-site": "same-origin",
					"sec-fetch-user": "?1",
					"sec-gpc": "1",
					"upgrade-insecure-requests": "1"
				},
				"referrer": url,
				"referrerPolicy": "strict-origin-when-cross-origin",
				"body": null,
				"method": "GET",
				"mode": "cors",
				"credentials": "include"
			});

			const text = await response.text();
			const xml = new DOMParser().parseFromString(text, "text/html");
			const slow = xml.getElementById("slowDownloadButton");
			const downloadUrl = slow.getAttribute("data-download-url");

			document.location.href = downloadUrl; // open downloadUrl to Nexusmods
			console.log(`[NDC] Opened : ${downloadUrl}`);

			downloadProgress += 1;
			downloadProgressPercent = downloadProgress / urls.length * 100;

			progressBar.style.width = `${downloadProgressPercent}%`;
			progressBar.innerText = `${Math.round(downloadProgressPercent)}%`;
			console.log(`[NDC] Progress : ${Math.round(downloadProgressPercent)}%`);
		};

		const downloadAllMods = async () => {
			const promises = urls.map((url, index) => {
				return new Promise((resolve) => {
					setTimeout(async () => {
						await downloadMod(url, index);
						resolve();
					}, index * 250);
				});
			});

			await Promise.all(promises);

			progress.style.display = "none";
			progressBar.style.width = "0%";
		};

		await downloadAllMods();
	}

	const observerCallback = () => {
		// on element div id="tabcontent-mods" appear append button
		const tabcontentMods = document.querySelector("#tabcontent-mods");
		if (tabcontentMods) {
			document.querySelector("#tabcontent-mods > div > div > div").prepend(progress);
			document.querySelector("#tabcontent-mods > div > div > div").prepend(button);
		}
	};

	// on element div id="tabcontent-mods" appear append button
	const observer = new MutationObserver(observerCallback);

	const sectionWrapper = document.querySelector("#collection-tabs > section.py-4.section.bg-background-primary > div");
	// sectionWrapper child is the content of a tab, so sometime its (id="tabcontent-mods") sometime its (id="tabcontent-comments")
	observer.observe(sectionWrapper, { childList: true });
	// trigger the observer callback for the first time
	observerCallback();

})();