// ==UserScript==
// @name Nexus Download Collection
// @namespace NDC
// @version 0.6.7
// @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
// ==/UserScript==
(async function () {
'use strict';
/** CORSViaGM BEGINING */
let forceStop = false;
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;
}
class LogRow {
constructor(message, type) {
this.message = message;
this.type = type;
this.createdAt = new Date();
this.row = createElement('div', {
classes: 'gap-x-2 px-2 py-1',
html: `[${this.createdAt.toLocaleTimeString()}][${this.type}] ${this.message}`
});
}
updateMessage(message) {
this.message = message;
this.row.innerHTML = `[${this.createdAt.toLocaleTimeString()}][${this.type}] ${this.message}`;
}
destroy() {
this.row.remove();
}
}
function log(message, type) {
const logRow = new LogRow(message, type);
logArea.appendChild(logRow.row);
logArea.scrollTop = logArea.scrollHeight;
return logRow;
}
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 html = new DOMParser().parseFromString(text, "text/html");
const slow = html.getElementById("slowDownloadButton");
if (slow) {
downloadUrl = slow.getAttribute("data-download-url");
}
return { downloadUrl, text, html }
};
async function addModToVortex(mod) {
// const {downloadUrl, text} = await new Promise(resolve => setTimeout(() => resolve({downloadUrl: 'debug', text: 'debug'}), 1000));
// const {downloadUrl, text} = await new Promise(resolve => setTimeout(() => resolve({downloadUrl: '', text: 'debug'}), 1000));
const { downloadUrl, text, html } = await getSlowDownloadModLink(mod, true);
if (downloadUrl === '') {
// make link to copy in the clipboard the response
const logRow = log(`Failed to get download link for
<a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>
<button class="text-primary-moderate" title="Copy response to clipboard"></button>`, 'ERROR');
const svg = 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: 1rem; height: 1rem;'
},
html: '<path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z" style="fill: currentcolor;"></path>'
});
// add svg to the button
const copyButton = logRow.row.querySelector('button');
copyButton.innerHTML = svg.outerHTML;
copyButton.addEventListener('click', () => {
navigator.clipboard.writeText(text);
alert('Response copied to clipboard');
});
// check if find .replaced-login-link in the html it is because the user is not connect on nexusmods
if (html.querySelector('.replaced-login-link')) {
log('You are not connected on NexusMods. <a href="https://users.nexusmods.com/auth/continue?client_id=nexus&redirect_uri=https://www.nexusmods.com/oauth/callback&response_type=code&referrer=https%3A%2F%2Fwww.nexusmods.com%2F" target="_blank" class="text-primary-moderate">Login</a> and try again.', 'ERROR');
forceStop = true;
}
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 (forceStop) {
log(`Force stop.`, 'INFO');
break;
}
if (!status) {
continue;
}
log(`Sending download link to Vortex <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 + 1, mods.length);
// based on download 1.5mb/s wait until the download is supposed to be finished + 5 seconds for the download to start on vortex
const downloadTime = Math.round(mod.file.sizeInBytes / 1500000) + 5;
const downloadEstimatifTimeLog = log(`Waiting approximately ${downloadTime} seconds for the download to finish on Vortex before starting the next one.`, 'INFO');
const downloadProgressLog = log(`Downloading... ${downloadTime} seconds left (~0%)`, 'INFO');
const downloadProgressLogInterval = setInterval(() => {
const timeLeft = downloadTime - Math.round((Date.now() - downloadProgressLog.createdAt) / 1000);
// 0 to 100% based on the time left
const approximativePercent = Math.round((downloadTime - timeLeft) / downloadTime * 100);
downloadProgressLog.updateMessage(`Downloading... ${timeLeft} seconds left (~${approximativePercent}%)`);
}, 1000);
await new Promise(resolve => {
setTimeout(() => {
clearInterval(downloadProgressLogInterval);
downloadEstimatifTimeLog.destroy();
downloadProgressLog.destroy();
resolve();
}, downloadTime * 1000);
});
}
progressBar.style.width = "0%";
progressBarContainer.classList.add('hidden');
btnGroup.classList.remove('hidden');
if (forceStop) {
forceStop = false;
return;
}
logAreaContainer.classList.add('hidden');
logArea.innerHTML = "";
};
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-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: 'w-full bg-surface-low rounded overflow-y-auto text-white font-montserrat font-semibold text-sm border border-primary',
attributes: {
style: 'height: 10rem; resize: vertical;'
}
});
const logAreaToggleButton = createElement('button', {
html: 'Hide 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();
})();