// ==UserScript==
// @name Nexus Download Collection
// @namespace NDC
// @version 0.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==
(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 */
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();
return data.data.collectionRevision;
}
// create a button to open all urls in new tabs
const button = document.createElement("button");
button.classList.add("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", "px-4", "py-1", "cursor-pointer", "bg-primary", "fill-font-primary", "text-font-primary", "border-transparent", "focus:bg-primary-lighter", "hover:bg-primary-darker", "add-to-vortex-button");
button.innerText = "Download all mods";
const progress = document.createElement("div");
progress.classList.add("mt-2", "w-full", "bg-gray-200", "rounded-full");
progress.style.display = "none";
progress.style.height = "1.1rem";
progress.style.backgroundColor = "rgb(229 231 235 / 1)";
const progressBar = document.createElement("div");
progressBar.classList.add("bg-blue-600", "rounded-full");
progressBar.style.height = "1.1rem";
progressBar.style.fontSize = "0.8rem";
progressBar.style.padding = "0 5px";
progressBar.style.width = "0%";
progressBar.style.backgroundColor = "rgb(28 100 242 / 1)";
progress.appendChild(progressBar);
button.onclick = async () => {
// from current document get gameId and collectionId eg. https://next.nexusmods.com/newvegas/collections/jscbqj
const gameId = document.location.pathname.split("/")[1];
const collectionId = document.location.pathname.split("/")[3];
// open all modFiles in new tabs
const modCollection = await getModCollection(gameId, collectionId);
const { modFiles, externalResources } = modCollection;
const modUrls = modFiles.map(modFile => {
// https://www.nexusmods.com/newvegas/mods/45126?tab=files&file_id=103242&nmm=1
const modId = modFile.file.mod.modId;
const gameId = modFile.file.mod.game.domainName;
const fileId = modFile.file.fileId;
return `https://www.nexusmods.com/${gameId}/mods/${modId}?tab=files&file_id=${fileId}&nmm=1`;
});
// const externalUrls = externalResources.map(externalResource => {
// return externalResource.resourceUrl;
// });
const urls = [
...modUrls,
// ...externalUrls
];
progress.style.display = "block";
let downloadProgress = 0;
let downloadProgressPercent = 0;
const downloadMod = async (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");
const slow = xml.getElementById("slowDownloadButton");
const downloadUrl = slow.getAttribute("data-download-url");
document.location.href = downloadUrl; // open downloadUrl to Nexusmods
console.log(`[NDC] Opened : ${downloadUrl}`);
downloadProgress += 1;
downloadProgressPercent = downloadProgress / urls.length * 100;
progressBar.style.width = `${downloadProgressPercent}%`;
progressBar.innerText = `${Math.round(downloadProgressPercent)}%`;
console.log(`[NDC] Progress : ${Math.round(downloadProgressPercent)}%`);
};
const downloadAllMods = async () => {
const promises = urls.map((url, index) => {
return new Promise((resolve) => {
setTimeout(async () => {
await downloadMod(url, index);
resolve();
}, index * 250);
});
});
await Promise.all(promises);
progress.style.display = "none";
progressBar.style.width = "0%";
};
await downloadAllMods();
}
const observerCallback = () => {
// on element div id="tabcontent-mods" appear append button
const tabcontentMods = document.querySelector("#tabcontent-mods");
if (tabcontentMods) {
document.querySelector("#tabcontent-mods > div > div > div").prepend(progress);
document.querySelector("#tabcontent-mods > div > div > div").prepend(button);
}
};
// on element div id="tabcontent-mods" appear append button
const observer = new MutationObserver(observerCallback);
const sectionWrapper = document.querySelector("#collection-tabs > section.py-4.section.bg-background-primary > div");
// sectionWrapper child is the content of a tab, so sometime its (id="tabcontent-mods") sometime its (id="tabcontent-comments")
observer.observe(sectionWrapper, { childList: true });
// trigger the observer callback for the first time
observerCallback();
})();