Greasy Fork

Greasy Fork is available in English.

Nexus Download Collection

Download every mods of a collection in a single click

当前为 2024-01-13 提交的版本,查看 最新版本

// ==UserScript==
// @name         Nexus Download Collection
// @namespace    NDC
// @version      0.5
// @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==

(async 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 */

	function createButton(classes, text, content) {
		const button = document.createElement('button');
		button.type = 'button';
		for (const className of classes) {
			button.classList.add(className);
		}
		button.innerHTML = text;
		if (content) {
			button.appendChild(content);
		}
		return button;
	}

	// Function to create a link with specified classes, text, and href
	function createLink(classes, text, href) {
		const link = document.createElement('a');
		link.className = classes;
		link.innerHTML = text;
		link.href = href;
		return link;
	}

	function createButtonGroup(options) {
		const defaultOptions = {
			text: '',
			btnGroupClasses: [],
			btnClasses: [],
			callbacks: [],
			dropdownItems: []
		};

		options = { ...defaultOptions, ...options };

		// Create the button group container
		const buttonGroup = document.createElement('div');
		const buttonGroupClasses = [
			...'inline-flex position-relative vertical-align-middle align-middle w-full'.split(" "),
			...options.btnGroupClasses
		];
		buttonGroup.className = buttonGroupClasses.join(" ");

		// Create the main button
		const mainButton = createButton([
			..."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-l px-2 py-1 cursor-pointer bg-primary fill-font-primary text-font-primary border-transparent focus:bg-primary-lighter hover:bg-primary-darker w-full justify-between".split(" "),
			...options.btnClasses
		], options.text);

		for (const [eventName, callback] of Object.entries(options.callbacks)) {
			mainButton.addEventListener(eventName, callback);
		}

		// Append the buttons and dropdown to the button group
		buttonGroup.appendChild(mainButton);
		buttonGroup.mainButton = mainButton;

		if (options.dropdownItems.length > 0) {
			// Create the button with the caret icon
			const dropdownButton = createButton([
				..."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-r px-2 py-1 cursor-pointer bg-primary fill-font-primary text-font-primary border-transparent focus:bg-primary-lighter hover:bg-primary-darker".split(" "),
			], '');
			const caretIcon = document.createElementNS("http://www.w3.org/2000/svg", 'svg');
			caretIcon.setAttribute('viewBox', '0 0 24 24');
			caretIcon.style.width = '1.5rem';
			caretIcon.style.height = '1.5rem';
			caretIcon.setAttribute('role', 'presentation');
			const caretIconPath = document.createElementNS("http://www.w3.org/2000/svg", 'path');
			caretIconPath.style.fill = 'currentColor';
			caretIconPath.setAttribute('d', 'M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z');
			caretIcon.appendChild(caretIconPath);
			dropdownButton.appendChild(caretIcon);

			// Create the dropdown container
			const dropdownContainer = document.createElement('div');
			dropdownContainer.className = 'absolute z-10 hidden min-w-48 py-1 px-0 mt-1 text-base text-gray-600 border-secondary-lighter bg-secondary border border-gray-200 rounded-md shadow-lg outline-none';
			for (const dropdownItem of options.dropdownItems) {
				// Create the dropdown item
				const dropdownItemText = dropdownItem.text;
				const dropdownItemElement = createButton([
					...'font-montserrat text-sm font-semibold uppercase leading-none tracking-wider first:rounded-t last:rounded-b relative flex w-full items-center gap-x-2 p-2 text-left font-normal hover:bg-secondary-lighter hover:text-primary focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between'.split(" "),
				], dropdownItemText);

				for (const [eventName, callback] of Object.entries(dropdownItem.callbacks)) {
					dropdownItemElement.addEventListener(eventName, callback);
				}

				// Append the elements to the dropdown container
				dropdownContainer.appendChild(dropdownItemElement);
			}

			buttonGroup.appendChild(dropdownButton);
			buttonGroup.appendChild(dropdownContainer);
			buttonGroup.dropdownButton = dropdownButton;
			buttonGroup.dropdownContainer = dropdownContainer;

			// Add event listener to toggle dropdown visibility
			dropdownButton.addEventListener('click', () => {
				// we need to add tranform translate to the dropdown container, so we need to get the width of the main button and add it to the dropdown container
				const mainButtonWidth = mainButton.offsetWidth;
				const mainButtonHeight = mainButton.offsetHeight;
				const dropdownButtonWidth = dropdownButton.offsetWidth;
				dropdownContainer.classList.toggle('hidden');
				const dropdownContainerWidth = dropdownContainer.offsetWidth;
				dropdownContainer.style.transform = `translate(${(mainButtonWidth + dropdownButtonWidth) - dropdownContainerWidth}px, ${mainButtonHeight}px)`;
			});

			document.addEventListener('click', (event) => {
				if (!dropdownButton.contains(event.target) && !dropdownContainer.contains(event.target)) {
					// transform translate to the dropdown button
					dropdownContainer.style
					dropdownContainer.classList.add('hidden');
				}
			});
		}

		// return the button group and its children
		return buttonGroup;
	}

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

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

		progressBar.bar = bar;

		return progressBar;
	}

	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();

		data.data.collectionRevision.modFiles = data.data.collectionRevision.modFiles.map(modFile => {
			modFile.file.url = `https://www.nexusmods.com/${gameId}/mods/${modFile.file.mod.modId}?tab=files&file_id=${modFile.file.fileId}`;
			return modFile;
		});

		return data.data.collectionRevision;
	}

	const getSlowDownloadModLink = async (mod, nmm) => {
		let downloadUrl = '';
		const url = nmm ? mod.file.url + '&nmm=1' : mod.file.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");
		if (nmm) {
			const slow = xml.getElementById("slowDownloadButton");
			downloadUrl = slow.getAttribute("data-download-url");
		} else {
			// const current_game_id = text.split("\n").find(line => line.includes("window.current_game_id = ")).split("window.current_game_id = ")[1].split(";")[0];

			// const response = await fetchViaGM("https://www.nexusmods.com/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl", {
			//     "headers": {
			//         "accept": "*/*",
			//         "accept-language": "fr;q=0.5",
			//         "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
			//         "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": "no-cors",
			//         "sec-fetch-site": "same-origin",
			//         "sec-gpc": "1",
			//         "x-requested-with": "XMLHttpRequest"
			//     },
			//     // set cookie

			//     "referrer": url,
			//     "referrerPolicy": "strict-origin-when-cross-origin",
			//     "body": `fid=${mod.file.fileId}&game_id${current_game_id}`,
			//     "method": "POST",
			//     "mode": "cors",
			//     "credentials": "include"
			// });

			// const data = await response.json();
			// downloadUrl = data.url;
			downloadUrl = url;
		}

		return downloadUrl;
	};

	const addModToVortex = async (mod) => {
		const downloadUrl = await getSlowDownloadModLink(mod, true);
		document.location.href = downloadUrl;
	};

	const downloadModFile = async (mod) => {
		const downloadUrl = await getSlowDownloadModLink(mod, false);
		window.open(downloadUrl, "_blank");
	};

	const downloadMods = async (mods, callback) => {
		let downloadProgress = 0;
		let downloadProgressPercent = 0;

		progressBar.style.display = "block";

		for (const mod of mods) {
			await callback(mod);

			console.log(`[NDC] Opened : ${mod.file.url}`);

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

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

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

	const manualDownloadMods = async (mods) => {
		// prompt message to inform that the action needs preparation
		const message = 'This action requires a few preparations so that everything works fine.\n\n' +
			'1. In your browser, allow pop-ups for "next.nexusmods.com".\n' +
			'2. In your browser settings, disable "Ask where to save each file before downloading."\n\n' +
			'When the script is gonna start it will open a lot of tabs (one for each mod), don\'t close them, they will close automatically when the download start.\n\n' +
			'Are you ready to start ?';
		const confirm = window.confirm(message);
		if (!confirm) {
			return;
		}

		await downloadMods(mods, downloadModFile);
	};

	const progressBar = createProgressBar();
	const loadingContainer = createButtonGroup({ text: `Loading...`, btnClasses: ["text-center", "rounded"] });

	const NDCContainer = document.createElement("div");
	NDCContainer.id = "NDCContainer";
	NDCContainer.classList.add("flex", "flex-col", "gap-y-2", "w-full", "mb-2");

	// add addToVortexButtonGroup and downloadFilesButtonGroup in a row
	const NDCContainerButtonGroup = document.createElement("div");
	NDCContainerButtonGroup.classList.add("flex", "gap-2", "sm:flex-row", "flex-col");

	NDCContainer.appendChild(progressBar);

	let previousHash = null;

	let mods = [];
	let mandatoryMods = [];
	let optionalMods = [];

	const handleHashChange = async () => {
		if (next.router.state.route === "/[gameDomain]/collections/[collectionSlug]") {
			const { gameDomain, collectionSlug, tab } = next.router.query;

			if (previousHash !== `${gameDomain}/${collectionSlug}`) {
				if (tab === "mods") {
					const tabcontentMods = document.querySelector("#tabcontent-mods > div > div > div");
					tabcontentMods.prepend(loadingContainer);
				}

				mods = await getModCollection(gameDomain, collectionSlug);
				mandatoryMods = mods.modFiles.filter(mod => !mod.optional);
				optionalMods = mods.modFiles.filter(mod => mod.optional);

				if (tab === "mods") {
					loadingContainer.remove();
				}
			}

			previousHash = `${gameDomain}/${collectionSlug}`;

			if (tab === "mods") {
				
				// Example usage:
				const addToVortexButtonGroup = createButtonGroup({
					text: `Add all mods to vortex <span class="p-2 bg-secondary rounded-full text-xs text-white whitespace-nowrap">${mods.modFiles.length} mods</span>`,
					callbacks: {
						click: () => downloadMods([...mandatoryMods, ...optionalMods], addModToVortex),
					},
					dropdownItems: [
						{
							text: `Add all mandatory mods <span class="p-2 bg-primary rounded-full text-xs text-white whitespace-nowrap">${mandatoryMods.length} mods</span>`,
							callbacks: {
								click: () => downloadMods(mandatoryMods, addModToVortex),
							},
						},
						{
							text: `Add all optional mods <span class="p-2 bg-primary rounded-full text-xs text-white whitespace-nowrap">${optionalMods.length} mods</span>`,
							callbacks: {
								click: () => downloadMods(optionalMods, addModToVortex),
							},
						},
					]
				});
				// const downloadFilesButtonGroup = createButtonGroup({
				// 	text: `Download all mods files <span class="p-2 bg-secondary rounded-full text-xs text-white whitespace-nowrap">${mods.modFiles.length} mods</span>`,
				// 	callbacks: {
				// 		click: () => manualDownloadMods([...mandatoryMods, ...optionalMods]),
				// 	},
				// 	dropdownItems: [
				// 		{
				// 			text: `Download all mandatory mods files <span class="p-2 bg-primary rounded-full text-xs text-white whitespace-nowrap">${mandatoryMods.length} mods</span>`,
				// 			callbacks: {
				// 				click: () => manualDownloadMods(mandatoryMods),
				// 			},
				// 		},
				// 		{
				// 			text: `Download all optional mods files <span class="p-2 bg-primary rounded-full text-xs text-white whitespace-nowrap">${optionalMods.length} mods</span>`,
				// 			callbacks: {
				// 				click: () => manualDownloadMods(optionalMods),
				// 			},
				// 		},
				// 	]
				// });

				const tabcontentMods = document.querySelector("#tabcontent-mods > div > div > div");

				NDCContainer.innerHTML = "";
				NDCContainerButtonGroup.innerHTML = "";

				NDCContainerButtonGroup.appendChild(addToVortexButtonGroup);
				// NDCContainerButtonGroup.appendChild(downloadFilesButtonGroup);
				NDCContainer.appendChild(NDCContainerButtonGroup);
				NDCContainer.appendChild(progressBar);

				// add progress bar
				tabcontentMods.prepend(NDCContainer);
			}
		}
	}

	// Add an event listener for the hashchange event
	next.router.events.on('routeChangeComplete', handleHashChange);

	handleHashChange();
})();