// ==UserScript== // @name Nexus Download Collection++ // @namespace NDC // @version 1.0 // @description Download every mods of a collection in a single click // @author 1Tdd // @license MIT // @match https://www.nexusmods.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=nexusmods.com // @compatible chrome // @compatible edge // @compatible firefox // @compatible safari // @compatible brave // @grant GM.xmlHttpRequest // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM.setValue // @grant GM.getValue // @grant GM_addStyle // @connect nexusmods.com // @downloadURL none // ==/UserScript== // MDI : https://pictogrammers.com/library/mdi/ // MDI : https://github.com/MathewSachin/Captura/blob/master/src/Captura.Core/MaterialDesignIcons.cs GM_addStyle(` .bottom-auto { bottom: auto; } .left-auto { left: auto; } .right-0 { right: 0px; } .top-0 { top: 0px; } .translate-y-\\[2rem\\] { --tw-translate-y: 2rem; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } .min-h-7 { min-height: 1.75rem; } .w-11 { width: 2.75rem; } .w-20{ width:5rem; } .w-32 { width: 8rem; } .w-52 { width: 13rem; } .text-green-600 { --tw-text-opacity: 1; color: rgb(22 163 74 / var(--tw-text-opacity, 1)); } .text-red-600 { --tw-text-opacity: 1; color: rgb(220 38 38 / var(--tw-text-opacity, 1)) } .text-sky-500 { --tw-text-opacity: 1; color: rgb(14 165 233 / var(--tw-text-opacity, 1)); } .backdrop-blur-sm { backdrop-filter: blur(3px); } .backdrop-brightness-50 { backdrop-filter: brightness(50%); } @media (min-width: 768px) { .sm\\:rounded-none { border-radius: 0; } .sm\\:gap-0 { gap: 0; } .sm\\:w-52 { width: 13rem; } .sm\\:justify-start { justify-content: flex-start; } } .bg-ndc-orange { background-color: #FA933C !important; color: #0f0f10 !important; fill: #0f0f10 !important; } .bg-ndc-orange:hover { background-color: #fb923c !important; } `); const convertSize = (sizeInKB) => { // If size is > 1GB, show GB, otherwise MB. Simple math. const sizeInMB = sizeInKB / 1024; const sizeInGB = sizeInMB / 1024; return sizeInGB >= 1 ? `${sizeInGB.toFixed(2)} GB` : `${sizeInMB.toFixed(2)} MB`; }; const CONSTANTS = { DOWNLOAD_PAUSE_BASE: 5, // Base pause (s) DOWNLOAD_SPEED_EST: 1.5, // Est. speed (MB/s) RATE_LIMIT_THRESHOLD: 200, // Anti-ban limit RATE_LIMIT_PAUSE: 300, // Cooldown (s) RETRY_MAX_ATTEMPTS: 3, // Max retries RETRY_DELAY_MS: 2000, // Base retry delay (ms) HISTORY_KEY: "history", LAUNCHED_DOWNLOAD_KEY: "launchedDownload", API_URL_GRAPHQL: "https://api-router.nexusmods.com/graphql", API_URL_DOWNLOAD_GEN: "https://www.nexusmods.com/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl", SELECTOR_MAIN_CONTENT: "#mainContent > div > div.relative > div.next-container", }; class NDC { mods = { all: [], mandatory: [], optional: [], }; constructor(gameId, collectionId, revision = null) { this.element = document.createElement("div"); this.element.classList.add('bg-surface-low', 'w-full', 'space-y-3', 'rounded-lg', 'p-4', 'mt-4'); this.gameId = gameId; this.collectionId = collectionId; this.revision = revision; this.pauseBetweenDownload = CONSTANTS.DOWNLOAD_PAUSE_BASE; this.downloadSpeed = CONSTANTS.DOWNLOAD_SPEED_EST; this.downloadMethod = NDCDownloadButton.DOWNLOAD_METHOD_VORTEX; this.downloadButton = new NDCDownloadButton(this); this.progressBar = new NDCProgressBar(this); this.console = new NDCLogConsole(this); } async init() { this.pauseBetweenDownload = await GM.getValue("pauseBetweenDownload", CONSTANTS.DOWNLOAD_PAUSE_BASE); this.downloadSpeed = await GM.getValue("downloadSpeed", CONSTANTS.DOWNLOAD_SPEED_EST); this.downloadMethod = await GM.getValue( "downloadMethod", NDCDownloadButton.DOWNLOAD_METHOD_VORTEX, ); this.element.innerHTML = ` `; const response = await this.fetchMods(); if (!response) { this.element.innerHTML = '
Failed to fetch mods list
'; return; } const mods = response.modFiles.sort((a, b) => a.file.mod.name.localeCompare(b.file.mod.name), ); const mandatoryMods = mods.filter((mod) => !mod.optional); const optionalMods = mods.filter((mod) => mod.optional); this.mods = { all: [...mandatoryMods, ...optionalMods], mandatory: mandatoryMods, optional: optionalMods, }; this.downloadButton.render(); this.element.innerHTML = ""; this.element.appendChild(this.downloadButton.element); this.element.appendChild(this.progressBar.element); this.element.appendChild(this.console.element); } async fetchMods(collectionId = this.collectionId, revision = this.revision) { // Nexus API spec: https://graphql.nexusmods.com/#definition-CollectionRevisionMod const response = await fetch(CONSTANTS.API_URL_GRAPHQL, { headers: { "content-type": "application/json", }, referrer: document.location.href, 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, uri, size, version, date, mod { adult, modId, name, version, game { domainName, id } } } } } }", variables: { slug: collectionId, viewAdultContent: true, revision: revision }, operationName: "CollectionRevisionMods", }), method: "POST", mode: "cors", credentials: "include", }); if (!response.ok) { return; } const json = await response.json(); if (!json.data.collectionRevision) { return; } json.data.collectionRevision.modFiles = json.data.collectionRevision.modFiles.map((modFile) => { modFile.file.url = `https://www.nexusmods.com/${modFile.file.mod.game.domainName}/mods/${modFile.file.mod.modId}?tab=files&file_id=${modFile.file.fileId}`; return modFile; }); return json.data.collectionRevision; } // Fetch + Retry async fetchDownloadLink(mod) { this.bypassNexusAdsCookie(); const getUrl = async () => { let url = mod.file.url; if (this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX) { url += "&nmm=1"; } const res = await fetch(url); if (!res.ok) throw new Error(`HTTP Error ${res.status}`); return await res.text(); }; const retryOperation = async (operation, attempts = CONSTANTS.RETRY_MAX_ATTEMPTS) => { for (let i = 0; i < attempts; i++) { try { return await operation(); } catch (err) { if (i === attempts - 1) throw err; const delay = CONSTANTS.RETRY_DELAY_MS * Math.pow(2, i); // Exponential backoff console.warn(`[NDC] Network hiccup. Retrying in ${delay}ms...`); await new Promise(r => setTimeout(r, delay)); } } }; try { const text = await retryOperation(getUrl); let downloadUrl = ""; if (this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX) { // Try modern regex first, then fallback to legacy const match = text.match(/const downloadUrl = '([^']+)'/) || text.match(/id="slowDownloadButton".*?data-download-url="([^"]+)"/); downloadUrl = match ? match[1].replaceAll('&', '&') : ""; } else { // Manual download generation const params = new URLSearchParams({ fid: mod.file.fileId, game_id: mod.file.mod.game.id }); const response = await fetch(CONSTANTS.API_URL_DOWNLOAD_GEN, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: params }); if (!response.ok) throw new Error("Failed to generate manual download URL"); const data = await response.json(); downloadUrl = data?.url || ""; } return { downloadUrl, text }; } catch (error) { console.error("[NDC] Failed to fetch download link:", error); return { downloadUrl: "", text: "", error }; } } bypassNexusAdsCookie() { const now = Math.round(Date.now() / 1000); const expirySeconds = 5 * 60; // 5 minutes in seconds const expiryTimestamp = now + expirySeconds; // Create and set the cookie const expiryDate = new Date(Date.now() + expirySeconds * 1000).toUTCString(); document.cookie = `ab=0|${expiryTimestamp};expires=${expiryDate};domain=nexusmods.com;path=/`; } async downloadMods(mods, type = null) { this.startDownload(mods.length); // Load history let history = type ? await GM.getValue(CONSTANTS.HISTORY_KEY, {}) : null; if (history) { history[this.gameId] ??= {}; history[this.gameId][this.collectionId] ??= {}; history[this.gameId][this.collectionId][type] ??= []; const previouslyDownloaded = history[this.gameId][this.collectionId][type]; if (previouslyDownloaded.length > 0) { const skip = await Promise.resolve(window.confirm( `Found ${previouslyDownloaded.length} mods already in history.\nSkip them? (Cancel will redownload everything)` )); if (!skip) { history[this.gameId][this.collectionId][type] = []; await GM.setValue(CONSTANTS.HISTORY_KEY, history); } } } const launchedDownload = await GM.getValue(CONSTANTS.LAUNCHED_DOWNLOAD_KEY, { count: 0, date: Date.now(), }); const failedDownload = []; let forceStop = false; for (const [index, mod] of mods.entries()) { const modNumber = `[${index + 1}/${mods.length}]`; // Format: [1/50] // Reset counter? if (launchedDownload.date < Date.now() - (CONSTANTS.RATE_LIMIT_PAUSE * 1000)) { console.log("[NDC] Resetting rate limit counter (5 mins passed)."); launchedDownload.count = 0; } // Skip existing? if (history?.[this.gameId][this.collectionId][type]?.includes(mod.file.fileId)) { this.console.log(`${modNumber} Skipping (Already Downloaded): ${mod.file.name}`, NDCLogConsole.TYPE_INFO); this.progressBar.incrementProgress(); continue; } // Skip request? if (this.progressBar.skipTo) { if (this.progressBar.skipToIndex - 1 > index) { this.console.log(`${modNumber} Skipping (User Request): ${mod.file.name}`, NDCLogConsole.TYPE_INFO); this.progressBar.incrementProgress(); // Disable skip mode if we reached the target if (this.progressBar.skipToIndex - 1 === index + 1) this.progressBar.skipTo = false; continue; } this.progressBar.skipTo = false; } // Stopped? if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) { this.console.log("Download Process Stopped by User.", NDCLogConsole.TYPE_INFO); break; } // Fetch try { const { downloadUrl, text, error } = await this.fetchDownloadLink(mod); if (!downloadUrl) { // Scraper errors if (text && text.includes('class="replaced-login-link"')) { this.console.log("Error: Not logged in! Please login to NexusMods.", NDCLogConsole.TYPE_ERROR); forceStop = true; } else if (text && text.includes("Just a moment...")) { this.console.log("Cloudflare Check detected. Please solve captcha in the opened tab.", NDCLogConsole.TYPE_ERROR); window.open(mod.file.url, '_blank'); forceStop = true; } else if (text && text.includes("Your access to Nexus Mods has been temporarily suspended")) { this.console.log("Nexus Rate Limit Hit! Pausing for 10 mins recommended.", NDCLogConsole.TYPE_ERROR); forceStop = true; } else { this.console.log(`${modNumber} Failed to get link for ${mod.file.name}`, NDCLogConsole.TYPE_ERROR); failedDownload.push(mod); } } else { // dl if (this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX) { this.console.log(`${modNumber} Vortex: ${mod.file.name} (${convertSize(mod.file.size)})`, NDCLogConsole.TYPE_SUCCESS); window.location.href = downloadUrl; } else { this.console.log(`${modNumber} Browser: ${mod.file.name} (${convertSize(mod.file.size)})`, NDCLogConsole.TYPE_SUCCESS); const a = document.createElement("a"); a.href = downloadUrl; a.download = mod.file.name; a.click(); } this.progressBar.incrementProgress(); if (history) { history[this.gameId][this.collectionId][type].push(mod.file.fileId); } // Stats launchedDownload.count++; launchedDownload.date = Date.now(); // Periodic save (IO opt) if (index % 5 === 0 || index === mods.length - 1) { await GM.setValue(CONSTANTS.LAUNCHED_DOWNLOAD_KEY, launchedDownload); if (history) await GM.setValue(CONSTANTS.HISTORY_KEY, history); } } } catch (err) { this.console.log(`${modNumber} Exception: ${err.message}`, NDCLogConsole.TYPE_ERROR); failedDownload.push(mod); } if (forceStop) break; // Throttling if (index < mods.length - 1) { // Anti-ban check if (launchedDownload.count >= CONSTANTS.RATE_LIMIT_THRESHOLD) { this.console.log(`Hit saftey limit (${CONSTANTS.RATE_LIMIT_THRESHOLD} mods). Pausing for 5 mins to blend in...`, NDCLogConsole.TYPE_WARN); await this.waitWithProgress(CONSTANTS.RATE_LIMIT_PAUSE); // Reset launchedDownload.count = 0; await GM.setValue(CONSTANTS.LAUNCHED_DOWNLOAD_KEY, launchedDownload); } // Pause const sizeDelay = Math.round(mod.file.size / 1024 / this.downloadSpeed); const pauseDuration = this.pauseBetweenDownload === 0 ? 0 : sizeDelay + this.pauseBetweenDownload; if (pauseDuration > 0) { await this.waitWithProgress(pauseDuration); } } } // End Loop // Save if (history) await GM.setValue(CONSTANTS.HISTORY_KEY, history); // Summary if (failedDownload.length) { this.console.log(`Completed with ${failedDownload.length} failures. Retry manually:`, NDCLogConsole.TYPE_WARN); failedDownload.forEach(m => this.console.log(`${m.file.name}`, NDCLogConsole.TYPE_INFO)); } else { this.console.log("All downloads processed successfully!", NDCLogConsole.TYPE_SUCCESS); } this.endDownload(); } // Wait helper async waitWithProgress(seconds) { let remaining = seconds; let logRow = null; while (remaining > 0) { // Check flags to interrupt wait if (this.progressBar.skipPause || this.progressBar.status === NDCProgressBar.STATUS_STOPPED) { if (logRow) logRow.remove(); this.progressBar.skipPause = false; // Reset flag return; } // Only decrement if not paused if (this.progressBar.status !== NDCProgressBar.STATUS_PAUSED) { const min = Math.floor(remaining / 60); const sec = remaining % 60; const msg = `Waiting ${min}m ${sec}s...`; if (!logRow) logRow = this.console.log(msg, NDCLogConsole.TYPE_INFO); else logRow.innerHTML = `${new Date().toLocaleTimeString('en-US', { hour12: false })}[INFO]${msg}`; remaining--; } // Wait 1 second await new Promise(resolve => setTimeout(resolve, 1000)); } if (logRow) logRow.remove(); } startDownload(modsCount) { this.progressBar.setModsCount(modsCount); this.progressBar.setProgress(0); this.progressBar.setStatus(NDCProgressBar.STATUS_DOWNLOADING); this.downloadButton.element.style.display = "none"; this.progressBar.element.style.display = ""; this.console.log("Download started.", NDCLogConsole.TYPE_INFO); } endDownload() { this.progressBar.setStatus(NDCProgressBar.STATUS_FINISHED); this.progressBar.element.style.display = "none"; this.downloadButton.element.style.display = ""; this.console.log("Download finished.", NDCLogConsole.TYPE_INFO); } } class NDCDownloadButton { static DOWNLOAD_METHOD_VORTEX = 0; static DOWNLOAD_METHOD_BROWSER = 1; constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add("flex", "flex-col", "gap-3", "w-100"); this.ndc = ndc; this.html = `
`; this.element.innerHTML = this.html; this.downloadMethods = this.element.querySelectorAll( 'input[name="downloadOption"]', ); this.importDownloadedModsBtn = this.element.querySelector( "#importDownloadedModsBtn", ); this.importDownloadedModsBtnInfo = this.element.querySelector( "#importDownloadedModsBtnInfo", ); this.allBtn = this.element.querySelector("#mainBtn"); this.modsCount = this.element.querySelector("#mainModsCount"); this.mandatoryBtn = this.element.querySelector("#menuBtnMandatory"); this.mandatoryModsCount = this.element.querySelector( "#menuBtnMandatoryModsCount", ); this.optionalBtn = this.element.querySelector("#menuBtnOptional"); this.optionalModsCount = this.element.querySelector( "#menuBtnOptionalModsCount", ); this.selectBtn = this.element.querySelector("#menuBtnSelect"); this.updateBtn = this.element.querySelector("#menuBtnUpdate"); const menuBtn = this.element.querySelector("#menuBtn"); const otherOptionMenu = this.element.querySelector("#otherOptionMenu"); for (const option of this.downloadMethods) { option.addEventListener("change", async () => { this.ndc.downloadMethod = Number.parseInt(option.value); await GM.setValue("downloadMethod", this.ndc.downloadMethod); }); } this.importDownloadedModsBtn.addEventListener("click", () => { // temp input const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.addEventListener("change", async () => { const files = input.files; // Parse filename (ID-Version-Date) const downloadedMods = this.ndc.mods.all.filter((mod) => { for (const file of files) { // if (file.name.includes(`${mod.file.name}-${mod.file.mod.modId}-${mod.file.version.replace(/\./g, '-')}-${mod.file.date}`)) { if (file.name.includes(mod.file.uri)) { return true; } } return false; }); const notMatchedFiles = [...files].filter( (file) => !downloadedMods.some((mod) => file.name.includes(mod.file.uri)), ); // Extract IDs const downloadedModsFileIds = downloadedMods.map((mod) => mod.file.fileId); // Hydrate history console.log("[NDC] Imported mod file IDs:", downloadedModsFileIds); const history = await GM.getValue("history", {}); if (history[this.ndc.gameId] == null) { history[this.ndc.gameId] = {}; } const gameHistory = history[this.ndc.gameId]; if (gameHistory[this.ndc.collectionId] == null) { gameHistory[this.ndc.collectionId] = {}; } const collectionHistory = gameHistory[this.ndc.collectionId]; collectionHistory.all = [...new Set(downloadedModsFileIds)]; collectionHistory.mandatory = [ ...new Set( downloadedMods .filter((mod) => !mod.optional) .map((mod) => mod.file.fileId), ), ]; collectionHistory.optional = [ ...new Set( downloadedMods .filter((mod) => mod.optional) .map((mod) => mod.file.fileId), ), ]; await GM.setValue("history", history); alert( `Imported ${downloadedMods.length } mods to the history.\n\n${downloadedMods .map((mod) => mod.file.name) .join("\n")}`, ); if (notMatchedFiles.length) { alert( `The following files are not matched with any mods:\n\n${notMatchedFiles .map((file) => file.name) .join("\n")}`, ); } }); input.click(); }); this.importDownloadedModsBtnInfo.addEventListener("click", () => { alert( `Importing downloaded mods will allow you to skip the download of mods you already have. \nSelect all the files of the folder where your mods are located and the script will automatically add them to the history so when you start a new download you will be asked if you want to skip the already downloaded mods.\n\nDefault Vortex download path :\n C:\\Users\\YourName\\AppData\\Roaming\\Vortex\\downloads\\${this.ndc.gameId}`, ); }); menuBtn.addEventListener("click", () => { otherOptionMenu.classList.toggle("hidden"); }); document.addEventListener("click", (event) => { const isClickInside = menuBtn.contains(event.target); if (!isClickInside) { otherOptionMenu.classList.add("hidden"); } }); this.allBtn.addEventListener("click", () => this.ndc.downloadMods(this.ndc.mods.all, "all"), ); this.mandatoryBtn.addEventListener("click", () => this.ndc.downloadMods(this.ndc.mods.mandatory, "mandatory"), ); this.optionalBtn.addEventListener("click", () => this.ndc.downloadMods(this.ndc.mods.optional, "optional"), ); this.selectBtn.addEventListener("click", () => { const selectModsModal = new NDCSelectModsModal(this.ndc); document.body.appendChild(selectModsModal.element); selectModsModal.render(); }); this.updateBtn.addEventListener("click", () => { const updateModsModal = new NDCUpdateModsModal(this.ndc); document.body.appendChild(updateModsModal.element); updateModsModal.render(); }); } updateDownloadMethod() { for (const option of this.downloadMethods) { if (Number.parseInt(option.value) === this.ndc.downloadMethod) { option.checked = true; } } } updateModsCount() { this.modsCount.innerHTML = `${this.ndc.mods.mandatory.length + this.ndc.mods.optional.length } mods`; } updateMandatoryModsCount() { this.mandatoryModsCount.innerHTML = `${this.ndc.mods.mandatory.length} mods`; } updateOptionalModsCount() { this.optionalModsCount.innerHTML = `${this.ndc.mods.optional.length} mods`; } render() { this.updateDownloadMethod(); this.updateModsCount(); this.updateMandatoryModsCount(); this.updateOptionalModsCount(); } } class NDCSelectModsModal { constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add( "fixed", "top-0", "left-0", "w-full", "h-full", "z-50", "flex", "justify-center", "items-center", "bg-black/25", "backdrop-brightness-50", ); // Lock body scroll document.body.style.overflow = "hidden"; this.ndc = ndc; this.html = `

Select mods

0 mods selected
`; this.element.innerHTML = this.html; this.searchMods = this.element.querySelector("#searchMods"); this.sortMods = this.element.querySelector("#sortMods"); this.selectModsSelectAll = this.element.querySelector( "#selectModsSelectAll", ); this.selectModsInvertSelection = this.element.querySelector( "#selectModsInvertSelection", ); this.selectModsDeselectAll = this.element.querySelector( "#selectModsDeselectAll", ); this.modsListMobile = this.element.querySelector("#modsListMobile"); this.selectedModsCount = this.element.querySelector("#selectedModsCount"); this.openSelectModsOptionMenu = this.element.querySelector( "#openSelectModsOptionMenu", ); this.selectModsOptionMenu = this.element.querySelector( "#selectModsOptionMenu", ); this.exportModsSelection = this.element.querySelector( "#exportModsSelection", ); this.importModsSelection = this.element.querySelector( "#importModsSelection", ); this.selectModsImportDownloadedMods = this.element.querySelector( "#selectModsImportDownloadedMods", ); this.selectModsBtn = this.element.querySelector("#selectModsBtn"); this.cancelSelectModsBtn = this.element.querySelector( "#cancelSelectModsBtn", ); this.openSelectModsOptionMenu.addEventListener("click", () => { this.selectModsOptionMenu.classList.toggle("hidden"); }); this.selectModsBtn.addEventListener("click", () => { const selectedMods = []; for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); if (checkbox.checked) { selectedMods.push(mod); } } this.close(); this.ndc.downloadMods(selectedMods); }); this.cancelSelectModsBtn.addEventListener("click", () => { this.close(); }); document.addEventListener("click", (event) => { const isClickInside = this.openSelectModsOptionMenu.contains( event.target, ); // Auto-close if (!isClickInside) { this.selectModsOptionMenu.classList.add("hidden"); } }); } updateModList(mods) { this.modsListMobile.innerHTML = ""; for (const [index, mod] of mods.entries()) { const modElementMobile = document.createElement("div"); modElementMobile.classList.add( "border", "border-stroke-subdued", "rounded-lg", "sm:rounded-none", "p-2", "cursor-pointer", "select-none", ); modElementMobile.innerHTML = `
#${index + 1 }
${convertSize(mod.file.size)} ${mod.optional ? "Optional" : "Mandatory" }
${mod.file.mod.name}
${mod.file.name}
`; modElementMobile.addEventListener("click", (event) => { // Handle click + Shift-select const checkbox = modElementMobile.querySelector( 'input[type="checkbox"]', ); checkbox.checked = !checkbox.checked; const modElement = checkbox.parentNode; modElement.classList.toggle("bg-primary-subdued"); modElement .querySelector(".mod-list-index") .classList.toggle("text-white"); if (event.shiftKey && modElement.parentNode.dataset.lastChecked) { const start = Array.from(modElement.parentNode.children).indexOf( modElement, ); const end = modElement.parentNode.dataset.lastChecked; const checkedState = modElement.parentNode.children[ end ].querySelector('input[type="checkbox"]').checked; for (let i = Math.min(start, end); i <= Math.max(start, end); i++) { const modEl = modElement.parentNode.children[i]; const checkboxEl = modEl.querySelector('input[type="checkbox"]'); checkboxEl.checked = checkedState; modEl.classList.toggle("bg-primary-subdued", checkedState); modEl .querySelector(".mod-list-index") .classList.toggle("text-white", checkedState); } } // Save index const index = Array.from(modElement.parentNode.children).indexOf( modElement, ); modElement.parentNode.dataset.lastChecked = index; this.selectedModsCount.firstChild.textContent = `${this.element.querySelectorAll('input[type="checkbox"]:checked').length } mods selected`; }); this.modsListMobile.appendChild(modElementMobile); } } render() { this.updateModList(this.ndc.mods.all); // Backdrop close this.element.addEventListener("click", (event) => { if (event.target === this.element) { this.close(); } }); // Search this.searchMods.addEventListener("input", () => { const search = this.searchMods.value.toLowerCase(); for (const mod of this.ndc.mods.all) { const modElement = this.element.querySelector( `#mod_${mod.file.fileId}`, ).parentNode; if ( mod.file.mod.name.toLowerCase().includes(search) || mod.file.name.toLowerCase().includes(search) ) { modElement.style.display = ""; } else { modElement.style.display = "none"; } } }); // Sort this.sortMods.addEventListener("change", () => { const sort = this.sortMods.value; const mods = [...this.ndc.mods.all]; switch (sort) { case "mod_name_asc": mods.sort((a, b) => a.file.mod.name.localeCompare(b.file.mod.name)); break; case "mod_name_desc": mods.sort((a, b) => b.file.mod.name.localeCompare(a.file.mod.name)); break; case "file_name_asc": mods.sort((a, b) => a.file.name.localeCompare(b.file.name)); break; case "file_name_desc": mods.sort((a, b) => b.file.name.localeCompare(a.file.name)); break; case "size_asc": mods.sort((a, b) => a.file.size - b.file.size); break; case "size_desc": mods.sort((a, b) => b.file.size - a.file.size); break; } this.updateModList(mods); }); this.selectModsSelectAll.addEventListener("click", () => { for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); checkbox.checked = true; const modElement = checkbox.parentNode; modElement.classList.add("bg-primary-subdued"); modElement.querySelector(".mod-list-index").classList.add("text-white"); } this.selectedModsCount.firstChild.textContent = `${this.ndc.mods.all.length} mods selected`; }); this.selectModsInvertSelection.addEventListener("click", () => { for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); checkbox.checked = !checkbox.checked; const modElement = checkbox.parentNode; modElement.classList.toggle("bg-primary-subdued"); modElement .querySelector(".mod-list-index") .classList.toggle("text-white"); } this.selectedModsCount.firstChild.textContent = `${this.element.querySelectorAll('input[type="checkbox"]:checked').length} mods selected`; }); this.selectModsDeselectAll.addEventListener("click", () => { for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); checkbox.checked = false; const modElement = checkbox.parentNode; modElement.classList.remove("bg-primary-subdued"); modElement .querySelector(".mod-list-index") .classList.remove("text-white"); } this.selectedModsCount.firstChild.textContent = "0 mods selected"; }); this.exportModsSelection.addEventListener("click", () => { if (!this.element.querySelector('input[type="checkbox"]:checked')) { alert("You must select at least one mod to export."); return; } const selectedMods = []; for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); if (checkbox.checked) { selectedMods.push(mod); } } const selectedModsText = JSON.stringify(selectedMods, null, 2); const blob = new Blob([selectedModsText], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `ndc_selected_mods_${this.ndc.gameId}_${this.ndc.collectionId }_${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); }); this.importModsSelection.addEventListener("click", () => { const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; input.addEventListener("change", async () => { const file = input.files[0]; const reader = new FileReader(); reader.onload = async () => { const selectedMods = JSON.parse(reader.result); for (const mod of selectedMods) { const checkbox = this.element.querySelector( `#mod_${mod.file.fileId}`, ); if (checkbox == null) { continue; } checkbox.checked = true; const modElement = checkbox.parentNode; modElement.classList.add("bg-primary-subdued"); modElement .querySelector(".mod-list-index") .classList.add("text-white"); } this.selectedModsCount.firstChild.textContent = `${selectedMods.length} mods selected`; }; reader.readAsText(file); }); input.click(); }); this.selectModsImportDownloadedMods.addEventListener("click", () => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.addEventListener("change", async () => { const files = input.files; const downloadedMods = this.ndc.mods.all.filter((mod) => { for (const file of files) { if (file.name.includes(mod.file.uri)) { return true; } } return false; }).reduce((acc, mod) => { acc[mod.file.fileId] = mod; return acc; }, {}); // Auto-check downloaded const notDownloadedMods = []; for (const modElement of this.modsListMobile.childNodes) { const checkbox = modElement.querySelector('input[type="checkbox"]'); const modId = Number.parseInt(checkbox.id.split("_")[1]); if (downloadedMods[modId] == null) { notDownloadedMods.push(downloadedMods[modId]); checkbox.checked = true; modElement.classList.add("bg-primary-subdued"); modElement.querySelector(".mod-list-index").classList.add("text-white"); } } this.selectedModsCount.firstChild.textContent = `${notDownloadedMods.length} mods selected`; if (notDownloadedMods.length === 0) { alert("All mods are already downloaded."); } else { alert( `Selected ${notDownloadedMods.length } mods that are not downloaded yet.`, ); } }); input.click(); }); } close() { document.body.style.overflow = ""; this.element.remove(); } } class NDCUpdateModsModal { constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add( "fixed", "top-0", "left-0", "w-full", "h-full", "z-50", "flex", "justify-center", "items-center", "bg-black/25", "backdrop-brightness-50" ); // Lock body scroll document.body.style.overflow = "hidden"; this.ndc = ndc; this.html = `
`; this.element.innerHTML = this.html; this.currentCollectionRevisionBtn = this.element.querySelector("#currentCollectionRevisionBtn"); this.currentCollectionRevisionList = this.element.querySelector("#currentCollectionRevisionList"); this.newCollectionRevisionBtn = this.element.querySelector("#newCollectionRevisionBtn"); this.newCollectionRevisionList = this.element.querySelector("#newCollectionRevisionList"); this.listOfUpdates = this.element.querySelector("#listOfUpdates"); this.updateModsBtn = this.element.querySelector("#updateModsBtn"); this.cancelUpdateModsBtn = this.element.querySelector( "#cancelUpdateModsBtn", ); this.modsToDownload = []; this.currentRevisionId = null; this.newRevisionId = null; this.updateModsBtn.addEventListener("click", () => { this.ndc.downloadMods(this.modsToDownload); this.close(); }); this.cancelUpdateModsBtn.addEventListener("click", () => { this.close(); }); } async renderListOfUpdates() { if (!this.currentRevisionId || !this.newRevisionId) { this.updateModsBtn.classList.add("hidden"); return; } this.listOfUpdates.classList.remove("hidden"); this.listOfUpdates.classList.remove("overflow-auto"); this.listOfUpdates.innerHTML = `
`; this.updateModsBtn.classList.add("hidden"); const [currentRevision, newRevision] = await Promise.all([ this.ndc.fetchMods(this.ndc.collectionId, parseInt(this.currentRevisionId)), this.ndc.fetchMods(this.ndc.collectionId, parseInt(this.newRevisionId)), ]); // Group const currentMods = currentRevision.modFiles.reduce((acc, mod) => { if (!acc[mod.file.mod.modId]) { acc[mod.file.mod.modId] = []; } acc[mod.file.mod.modId].push(mod); return acc; }, {}); const newMods = newRevision.modFiles.reduce((acc, mod) => { if (!acc[mod.file.mod.modId]) { acc[mod.file.mod.modId] = []; } acc[mod.file.mod.modId].push(mod); return acc; }, {}); const addedMods = []; const updatedMods = []; const removedMods = []; for (const [modId, newModFiles] of Object.entries(newMods)) { const currentModFiles = currentMods[modId] || []; newModFiles.forEach(newModFile => { const currentModFile = currentModFiles.find( modFile => modFile.fileId === newModFile.fileId || modFile.file.name === newModFile.file.name ); if (!currentModFile) { addedMods.push(newModFile); } else if (currentModFile.file.version !== newModFile.file.version) { updatedMods.push(newModFile); } }); const remainingCurrentModFiles = currentModFiles.filter( currentModFile => !newModFiles.some( modFile => modFile.fileId === currentModFile.fileId || modFile.file.name === currentModFile.file.name ) ); removedMods.push(...remainingCurrentModFiles); } this.modsToDownload = [...addedMods, ...updatedMods]; this.listOfUpdates.innerHTML = `

Updated Mods (${updatedMods.length} mods)

${updatedMods.map(mod => `
${mod.file.mod.name}
`).join("")}

Added Mods (${addedMods.length} mods)

${addedMods.map(mod => `
${mod.file.mod.name}
`).join("")}

information Removed Mods (${removedMods.length} mods)

${removedMods.map(mod => `
${mod.file.mod.name}
`).join("")}
`; this.listOfUpdates.classList.add("overflow-auto", "overscroll-contain"); this.updateModsBtn.classList.remove("hidden"); this.listOfUpdates.querySelector("#deletedModsInfo").addEventListener("click", () => { alert("The deleted mods is just for information, the script will not delete any mods from your collection."); }); } async fetchRevisions() { // API Spec: https://graphql.nexusmods.com/#definition-CollectionRevisionMod const response = await fetch("https://api-router.nexusmods.com/graphql", { headers: { "content-type": "application/json", }, referrer: document.location.href, referrerPolicy: "strict-origin-when-cross-origin", body: JSON.stringify({ query: "query CollectionRevisions ($domainName: String, $slug: String!) { collection (domainName: $domainName, slug: $slug) { revisions {adultContent, createdAt, discardedAt, id, latest, revisionNumber, revisionStatus, totalSize, modCount, collectionChangelog { description, id}, gameVersions { reference } } } }", variables: { domainName: this.ndc.gameId, slug: this.ndc.collectionId }, operationName: "CollectionRevisions", }), method: "POST", mode: "cors", credentials: "include", }); if (!response.ok) { return; } const json = await response.json(); if (!json.data.collection) { return; } return json.data.collection.revisions; } async render() { const revisions = await this.fetchRevisions(); if (!revisions) { this.element.innerHTML = `

Update collection

An error occurred while fetching the collection revisions. Please try again later.

`; return; } this.revisions = revisions; // Store for usage // Render item const createItem = (revision) => { const li = document.createElement("li"); const a = document.createElement("a"); a.className = "group block w-full cursor-pointer space-y-0.5 px-4 py-2.5 outline-none text-neutral-moderate text-left transition-colors hover:text-neutral-strong hover:bg-surface-translucent-mid focus-within:bg-surface-translucent-mid"; const date = new Date(revision.createdAt); const dateStr = date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); const sizeGB = (revision.totalSize / (1024 * 1024 * 1024)).toFixed(1) + "GB"; a.innerHTML = `

Revision ${revision.revisionNumber}

${revision.adultContent ? `Adult` : ''}

${sizeGB}

Game version ${revision.gameVersions && revision.gameVersions.length ? revision.gameVersions[0].reference : 'Unknown'}

`; // Click handler is added in populateList below li.appendChild(a); return li; }; // Populate const populateList = (listElement, btnElement, isCurrent) => { const ul = listElement.querySelector("ul"); ul.innerHTML = ""; revisions.forEach(rev => { const li = createItem(rev); li.querySelector("a").addEventListener("click", () => { this.selectRevision(rev, btnElement); listElement.classList.add("hidden"); if (isCurrent) { this.currentRevisionId = rev.revisionNumber; } else { this.newRevisionId = rev.revisionNumber; } this.renderListOfUpdates(); }); ul.appendChild(li); }); }; populateList(this.currentCollectionRevisionList, this.currentCollectionRevisionBtn, true); populateList(this.newCollectionRevisionList, this.newCollectionRevisionBtn, false); // UI Toggles const toggleList = (list) => { const isHidden = list.classList.contains("hidden"); // Close others this.currentCollectionRevisionList.classList.add("hidden"); this.newCollectionRevisionList.classList.add("hidden"); if (isHidden) list.classList.remove("hidden"); }; this.currentCollectionRevisionBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleList(this.currentCollectionRevisionList); }); this.newCollectionRevisionBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleList(this.newCollectionRevisionList); }); // Auto-close document.addEventListener("click", (e) => { if (!this.currentCollectionRevisionList.contains(e.target) && !this.currentCollectionRevisionBtn.contains(e.target)) { this.currentCollectionRevisionList.classList.add("hidden"); } if (!this.newCollectionRevisionList.contains(e.target) && !this.newCollectionRevisionBtn.contains(e.target)) { this.newCollectionRevisionList.classList.add("hidden"); } }); this.element.querySelector(".loadingSpinner").classList.add("hidden"); this.element.querySelector(".elementBody").classList.remove("hidden"); this.element.addEventListener("click", (event) => { if (event.target === this.element) this.close(); }); } selectRevision(revision, btnElement) { const date = new Date(revision.createdAt); const dateStr = date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); const sizeGB = (revision.totalSize / (1024 * 1024 * 1024)).toFixed(1) + "GB"; btnElement.innerHTML = `

Revision ${revision.revisionNumber}

${revision.adultContent ? `Adult` : ''}

${sizeGB}

Game version ${revision.gameVersions && revision.gameVersions.length ? revision.gameVersions[0].reference : 'Unknown'}

`; } close() { document.body.style.overflow = ""; this.element.remove(); } } class NDCProgressBar { static STATUS_DOWNLOADING = 0; static STATUS_PAUSED = 1; static STATUS_FINISHED = 2; static STATUS_STOPPED = 3; static STATUS_TEXT = { [NDCProgressBar.STATUS_DOWNLOADING]: "Downloading...", [NDCProgressBar.STATUS_PAUSED]: "Paused", [NDCProgressBar.STATUS_FINISHED]: "Finished", [NDCProgressBar.STATUS_STOPPED]: "Stopped", }; constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add("flex", "flex-wrap", "w-100"); this.element.style.display = "none"; this.ndc = ndc; this.modsCount = 0; this.progress = 0; this.skipPause = false; this.skipTo = false; this.skipToIndex = 0; this.status = NDCProgressBar.STATUS_DOWNLOADING; this.html = `
${this.progress}%
Downloading...
${this.progress}/${this.modsCount}
information
information
information
`; this.element.innerHTML = this.html; const downloadSpeedInfo = this.element.querySelector("#downloadSpeedInfo"); const extraPauseInfo = this.element.querySelector("#extraPauseInfo"); const skipToIndexInfo = this.element.querySelector("#skipToIndexInfo"); this.progressBarFill = this.element.querySelector("#progressBarFill"); this.progressBarProgress = this.element.querySelector("#progressBarProgress"); this.progressBarTextCenter = this.element.querySelector("#progressBarTextCenter"); this.progressBarTextRight = this.element.querySelector("#progressBarTextRight"); this.playPauseBtn = this.element.querySelector("#playPauseBtn"); this.stopBtn = this.element.querySelector("#stopBtn"); this.downloadSpeedInput = this.element.querySelector("#downloadSpeedInput"); this.pauseBetweenDownloadInput = this.element.querySelector("#pauseBetweenDownloadInput"); this.skipNextBtn = this.element.querySelector("#skipNextBtn"); this.skipToIndexBtn = this.element.querySelector("#skipToIndexBtn"); this.skipToIndexInput = this.element.querySelector("#skipToIndexInput"); downloadSpeedInfo.addEventListener("click", () => { alert( `This is just for the script to guess how long files take—it doesn't actually limit your bandwidth.\n\nYou want to put your REAL Megabytes (MB/s) here, not just what your internet plan says on paper. If you're unsure, run a quick speed test (like fast.com or speedtest.net). Just remember: if the results are in 'Mbps', divide by 8. So if you see 100 Mbps, you'd put 12.5 here.` ); }); extraPauseInfo.addEventListener("click", () => { alert( `Helps prevent Vortex from crashing on huge collections.\n\nThis adds a small cooldown after each download to give Vortex time to process the file. If you have a fast PC, you can lower this, but 5 seconds is safe.\n\nSet to 0 to disable (not recommended for 100+ mod lists).` ); }); skipToIndexInfo.addEventListener("click", () => { alert( `Use this to resume an interrupted download session.\n\nEnter the number of the mod you want to start with (e.g. '50') and click "Skip to index".\n\nThe script will instantly fast-forward past the first 49 mods without checking them, effectively resuming your queue immediately.` ); }); this.playPauseBtn.addEventListener("click", () => { const status = this.status === NDCProgressBar.STATUS_DOWNLOADING ? NDCProgressBar.STATUS_PAUSED : NDCProgressBar.STATUS_DOWNLOADING; this.setStatus(status); }); this.stopBtn.addEventListener("click", () => { this.setStatus(NDCProgressBar.STATUS_STOPPED); }); this.downloadSpeedInput.addEventListener("change", async (event) => { this.ndc.downloadSpeed = Number.parseFloat(event.target.value); await GM.setValue("downloadSpeed", this.ndc.downloadSpeed); }); this.pauseBetweenDownloadInput.addEventListener("change", async (event) => { this.ndc.pauseBetweenDownload = Number.parseInt(event.target.value); await GM.setValue("pauseBetweenDownload", this.ndc.pauseBetweenDownload); }); this.skipNextBtn.addEventListener("click", () => { this.skipPause = true; this.setStatus(NDCProgressBar.STATUS_DOWNLOADING); }); this.skipToIndexBtn.addEventListener("click", () => { const index = Number.parseInt(this.skipToIndexInput.value); if (index > this.progress && index <= this.modsCount) { this.skipTo = true; this.skipToIndex = index; this.setStatus(NDCProgressBar.STATUS_DOWNLOADING); } }); } setState(newState) { Object.assign(this, newState); this.render(); } setModsCount(modsCount) { this.setState({ modsCount }); } setProgress(progress) { this.setState({ progress }); } incrementProgress() { this.setState({ progress: this.progress + 1 }); } setStatus(status) { this.setState({ status }); this.progressBarTextCenter.innerHTML = NDCProgressBar.STATUS_TEXT[status]; } getProgressPercent() { return ((this.progress / this.modsCount) * 100).toFixed(2); } updateProgressBarFillWidth() { this.progressBarFill.style.width = `${this.getProgressPercent()}%`; } updateProgressBarTextProgress() { this.progressBarProgress.innerHTML = `${this.getProgressPercent()}%`; } updateProgressBarTextRight() { this.progressBarTextRight.innerHTML = `${this.progress}/${this.modsCount}`; } updatePlayPauseBtn() { this.playPauseBtn.innerHTML = this.status === NDCProgressBar.STATUS_PAUSED ? '' : ''; } updateDownloadSpeedInput() { this.downloadSpeedInput.value = this.ndc.downloadSpeed; } updatePauseBetweenDownloadInput() { this.pauseBetweenDownloadInput.value = this.ndc.pauseBetweenDownload; } render() { this.updateProgressBarFillWidth(); this.updateProgressBarTextProgress(); this.updateProgressBarTextRight(); this.updatePlayPauseBtn(); this.updateDownloadSpeedInput(); this.updatePauseBetweenDownloadInput(); } } class NDCLogConsole { static TYPE_NORMAL = "NORMAL"; static TYPE_ERROR = "ERROR"; static TYPE_INFO = "INFO"; static TYPE_SUCCESS = "SUCCESS"; static TYPE_WARN = "WARN"; constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add("flex", "flex-col", "w-100", "gap-3", "mt-3"); this.ndc = ndc; this.hidden = true; this.html = `
`; this.element.innerHTML = this.html; this.toggle = this.element.querySelector("#toggleLogsButton"); this.logContainer = this.element.querySelector("#logContainer"); this.toggle.addEventListener("click", () => { this.hidden = !this.hidden; logContainer.style.display = this.hidden ? "none" : ""; this.toggle.innerHTML = this.hidden ? "Show logs" : "Hide logs"; }); } log(message, type = NDCLogConsole.TYPE_NORMAL) { const rowElement = document.createElement("div"); rowElement.classList.add("flex", "gap-x-1", "px-2", "py-1"); const time = new Date().toLocaleTimeString('en-US', { hour12: false }); let badge = ""; switch (type) { case NDCLogConsole.TYPE_ERROR: badge = `[ERROR]`; rowElement.classList.add("text-red-400"); break; case NDCLogConsole.TYPE_INFO: badge = `[INFO]`; rowElement.classList.add("text-sky-300"); break; case NDCLogConsole.TYPE_SUCCESS: badge = `[OK]`; rowElement.classList.add("text-green-400"); break; case NDCLogConsole.TYPE_WARN: badge = `[WARN]`; rowElement.style.color = "#FA933C"; break; default: badge = `[LOG]`; rowElement.classList.add("text-gray-300"); } rowElement.innerHTML = `${time}${badge}${message}`; rowElement.message = rowElement.querySelector(".ndc-log-message"); this.logContainer.appendChild(rowElement); this.logContainer.scrollTop = this.logContainer.scrollHeight; console.log(`[NDC][${type}] ${message}`); return rowElement; } clear() { this.logContainer.innerHTML = ""; } } let previousRoute = null; let ndc = null; function extractRouteDetails(pathname) { // games/cyberpunk2077/collections/iszwwe/revisions/464 const pathParts = pathname.split("/").filter(Boolean); if (pathParts.length >= 4 && pathParts[2] === "collections") { return { gameDomain: pathParts[1], collectionSlug: pathParts[3], revisionNumber: pathParts.length > 5 ? pathParts[5] : null, }; } return null; } function handleRouteChange() { const pathname = window.location.pathname; const routeDetails = extractRouteDetails(pathname); if (routeDetails) { const { gameDomain, collectionSlug, revisionNumber } = routeDetails; const currentRoute = `${gameDomain}/${collectionSlug}/`; const currentRevision = revisionNumber ? parseInt(revisionNumber, 10) : null; if (previousRoute !== currentRoute || ndc?.revision !== currentRevision) { previousRoute = currentRoute; if (ndc) { ndc.element.remove(); } ndc = new NDC(gameDomain, collectionSlug, currentRevision); ndc.init().then(() => { const container = document.querySelector("#mainContent > div > div.relative > div.next-container"); if (container) { container.append(ndc.element); } }); } } } // Monitor DOM try { const observer = new MutationObserver(() => { try { handleRouteChange(); } catch (err) { console.error("[NDC] Observer Error:", err); } }); observer.observe(document.body, { childList: true, subtree: true }); } catch (e) { console.error("[NDC] Critical Observer Failure:", e); } // Boot handleRouteChange();