Greasy Fork

Greasy Fork is available in English.

Nexus Download Collection

Download every mods of a collection in a single click

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();