// ==UserScript==
// @name Nexus Download Collection
// @namespace NDC
// @version 0.6.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==
(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 <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>.`, '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 <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>`, '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: '<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" style="fill: currentcolor;"></path>'
});
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',
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();
})();