// ==UserScript== // @name Nexus Download Collection // @namespace NDC // @version 0.6.4 // @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 // @connect nexusmods.com // @downloadURL none // ==/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 createElement(elementName, options) { var element = document.createElement(elementName); if (options.html) { element.innerHTML = options.html; } if (options.elements) { for (var i = 0; i < options.elements.length; i++) { element.appendChild(options.elements[i]); } } if (options.classes) { element.className = options.classes; } if (options.attributes) { for (var key in options.attributes) { element.setAttribute(key, options.attributes[key]); } } if (options.events) { for (var key in options.events) { element.addEventListener(key, options.events[key]); } } return element; } function log(message, type) { const logRow = createElement('div', { classes: 'flex items-center gap-x-2 px-2 py-1', html: `[${new Date().toLocaleTimeString()}] [${type}] ${message}` }); logArea.appendChild(logRow); logArea.scrollTop = logArea.scrollHeight; } function refreshProgressBar(percent, currentMod, totalMods) { progressBar.style.width = `${percent}%`; progressBarButtonProgress.innerText = `${Math.round(percent)}%`; progressBarButtonDownloaded.innerText = `${currentMod}/${totalMods}`; } async function getModCollection(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; } async function getSlowDownloadModLink(mod) { let downloadUrl = ''; const url = mod.file.url + '&nmm=1'; 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"); if (slow) { downloadUrl = slow.getAttribute("data-download-url"); } return downloadUrl; }; async function addModToVortex(mod) { // const downloadUrl = await new Promise(resolve => setTimeout(resolve, 1000)); // for testing const downloadUrl = await getSlowDownloadModLink(mod, true); if (downloadUrl === '') { log(`Failed to get download link for ${mod.file.name}.`, 'ERROR'); return false; } document.location.href = downloadUrl; return true; }; async function downloadMods(mods) { let downloadProgress = 0; let downloadProgressPercent = 0; refreshProgressBar(0, 0, mods.length); btnGroup.classList.add('hidden'); progressBarContainer.classList.remove('hidden'); logAreaContainer.classList.remove('hidden'); for (const [index, mod] of mods.entries()) { if (downloadPaused) { log(`Download paused.`, 'INFO'); while (downloadPaused) { await new Promise(resolve => setTimeout(resolve, 100)); } log(`Download resumed.`, 'INFO'); } const status = await addModToVortex(mod); if (!status) { continue; } log(`Downloading ${mod.file.name}`, 'INFO'); downloadProgress += 1; downloadProgressPercent = downloadProgress / mods.length * 100; refreshProgressBar(downloadProgressPercent, index, mods.length); } progressBar.style.width = "0%"; progressBarContainer.classList.add('hidden'); logAreaContainer.classList.add('hidden'); logArea.innerHTML = ""; btnGroup.classList.remove('hidden'); }; const loadingContainer = createElement('div', { html: 'Loading...', classes: 'w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase 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 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued rounded', }); const modsCountSpan = createElement('span', { classes: 'p-2 bg-surface-low rounded-full text-xs text-white whitespace-nowrap', }); const downloadAllButton = createElement('button', { html: 'Add all mods to vortex', elements: [ modsCountSpan ], classes: 'w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase 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 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-l', events: { click: () => { downloadMods(mods.modFiles); } } }); const dropdownCarret = createElement('svg', { classes: 'w-4 h-4 fill-current', attributes: { viewBox: '0 0 24 24', xmlns: 'http://www.w3.org/2000/svg', role: 'presentation', style: 'width: 1.5rem; height: 1.5rem;' }, html: '' }); const dropdownItemMandatoryModsCount = createElement('span', { classes: 'p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap', }); const dropdownItemMandatory = createElement('button', { html: 'Add all mandatory mods', elements: [ dropdownItemMandatoryModsCount ], classes: '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-surface-mid hover:text-primary-moderate focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between', events: { click: () => { downloadMods(mandatoryMods) } } }); const dropdownItemOptionalModsCount = createElement('span', { classes: 'p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap', }); const dropdownItemOptional = createElement('button', { html: 'Add all optional mods', elements: [ dropdownItemOptionalModsCount ], classes: '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-surface-mid hover:text-primary-moderate focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between', events: { click: () => { downloadMods(optionalMods) } } }); const dropdownMenu = createElement('div', { classes: 'absolute z-10 min-w-48 py-1 px-0 mt-1 text-base text-gray-600 border-stroke-subdued bg-surface-low border border-gray-200 rounded-md shadow-lg outline-none hidden', elements: [ dropdownItemMandatory, dropdownItemOptional ] }); const dropdownButton = createElement('button', { html: dropdownCarret.outerHTML, classes: 'font-montserrat font-semibold text-sm leading-none tracking-wider uppercase 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 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-r', events: { click: function () { const btnGroupOffset = btnGroup.getBoundingClientRect(); dropdownMenu.classList.toggle('hidden'); const dropdownMenuOffset = dropdownMenu.getBoundingClientRect(); dropdownMenu.style.transform = `translate(${btnGroupOffset.width - dropdownMenuOffset.width}px, ${btnGroupOffset.height}px)`; } } }); const btnGroup = createElement('div', { classes: 'flex w-100', elements: [ downloadAllButton, dropdownButton, dropdownMenu ] }); document.addEventListener('click', function (event) { const isClickInside = dropdownButton.contains(event.target); if (!isClickInside) { dropdownMenu.classList.add('hidden'); } }); const progressBar = createElement('div', { classes: 'absolute top-0 left-0 w-0 h-full bg-primary-moderate', attributes: { style: 'transition: width 0.3s ease;' } }); const progressBarButtonProgress = createElement('div', { classes: 'ml-3', html: '0%', }); const progressBarButtonText = createElement('div', { classes: 'text-center', html: 'Downloading...', }); const progressBarButtonDownloaded = createElement('div', { classes: 'text-right', attributes: { style: 'margin-right: .75rem;' }, }); const progressBarButton = createElement('div', { elements: [ progressBarButtonProgress, progressBarButtonText, progressBarButtonDownloaded ], classes: 'absolute top-0 left-0 w-full h-full cursor-pointer grid grid-cols-3 items-center text-white font-montserrat font-semibold text-sm leading-none tracking-wider uppercase', events: { click: function () { downloadPaused = !downloadPaused; progressBarButtonText.innerHTML = downloadPaused ? 'Resume' : 'Pause'; }, mouseenter: function () { progressBarButtonText.innerHTML = downloadPaused ? 'Resume' : 'Pause'; }, mouseleave: function () { progressBarButtonText.innerHTML = downloadPaused ? 'Resume' : 'Downloading...'; }, } }); const progressBarContainer = createElement('div', { classes: 'relative w-100 min-h-9 bg-surface-low rounded overflow-hidden hidden', elements: [ progressBar, progressBarButton ] }); const logArea = createElement('div', { classes: 'hidden w-full bg-surface-low rounded overflow-y-auto text-white font-montserrat font-semibold text-sm border border-primary', attributes: { style: 'height: 5rem; resize: vertical;' } }); const logAreaToggleButton = createElement('button', { html: 'Show logs', classes: 'w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase', events: { click: function () { logArea.classList.toggle('hidden'); logAreaToggleButton.innerText = logArea.classList.contains('hidden') ? 'Show logs' : 'Hide logs'; } } }); const logAreaContainer = createElement('div', { classes: 'flex flex-col w-100 gap-3 hidden', elements: [ logAreaToggleButton, logArea ] }); const NDCContainer = createElement('div', { classes: 'flex flex-col w-100 gap-3 mb-3', elements: [ btnGroup, progressBarContainer, logAreaContainer ] }); let previousRoute = null; let mods = null; let mandatoryMods = []; let optionalMods = []; let downloadPaused = false; // used for pause button async function handleNextRouterChange() { if (next.router.state.route === "/[gameDomain]/collections/[collectionSlug]") { const { gameDomain, collectionSlug, tab } = next.router.query; if (previousRoute !== `${gameDomain}/${collectionSlug}`) { previousRoute = `${gameDomain}/${collectionSlug}`; if (tab === "mods") { const tabcontentMods = document.querySelector("#tabcontent-mods > div > div > div"); tabcontentMods.prepend(loadingContainer); } mods = await getModCollection(gameDomain, collectionSlug); const modFiles = mods.modFiles.sort((a, b) => a.file.name.localeCompare(b.file.name)); mandatoryMods = modFiles.filter(mod => !mod.optional); optionalMods = modFiles.filter(mod => mod.optional); if (tab === "mods") { loadingContainer.remove(); } } while (mods === null) { await new Promise(resolve => setTimeout(resolve, 100)); } if (tab === "mods") { const tabcontentMods = document.querySelector("#tabcontent-mods > div > div > div"); const modsCount = mods.modFiles.length; modsCountSpan.innerText = `${modsCount} mods`; dropdownItemMandatoryModsCount.innerText = `${mandatoryMods.length} mods`; dropdownItemOptionalModsCount.innerText = `${optionalMods.length} mods`; tabcontentMods.prepend(NDCContainer); } } } // Add an event listener for the hashchange event next.router.events.on('routeChangeComplete', handleNextRouterChange); handleNextRouterChange(); })();