// ==UserScript== // @name Nexus Download Collection // @namespace NDC // @version 0.7.1 // @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 // @grant GM_setValue // @grant GM_getValue // @grant GM.setValue // @grant GM.getValue // @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 /** 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 */ class NDC { pauseBetweenDownload = 5; thisforceStop = false; mods = { all: [], mandatory: [], optional: [] }; constructor(gameId, collectionId) { this.element = document.createElement('div'); this.gameId = gameId; this.collectionId = collectionId; this.downloadButton = new NDCDownloadButton(this); this.progressBar = new NDCProgressBar(this); this.console = new NDCLogConsole(this); } async init() { this.pauseBetweenDownload = await GM.getValue('pauseBetweenDownload', 5); this.element.innerHTML = ` `; const response = await this.fetchMods(); if (!response) { this.element.innerHTML = '
Failed to fetch mods list
'; return; } this.mods = { all: response.modFiles, mandatory: response.modFiles.filter(mod => !mod.optional), optional: response.modFiles.filter(mod => mod.optional) } this.downloadButton.render(); this.downloadButton.allBtn.addEventListener('click', () => this.downloadMods(this.mods.all, "all")); this.downloadButton.mandatoryBtn.addEventListener('click', () => this.downloadMods(this.mods.mandatory, "mandatory")); this.downloadButton.optionalBtn.addEventListener('click', () => this.downloadMods(this.mods.optional, "optional")); this.element.innerHTML = ''; this.element.appendChild(this.downloadButton.element); this.element.appendChild(this.progressBar.element); this.element.appendChild(this.console.element); } async fetchMods() { const response = await fetch("https://next.nexusmods.com/api/graphql", { "headers": { "content-type": "application/json", }, "referrer": `https://next.nexusmods.com/${this.gameId}/collections/${this.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": this.collectionId, "viewAdultContent": true }, "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/${this.gameId}/mods/${modFile.file.mod.modId}?tab=files&file_id=${modFile.file.fileId}`; return modFile; }); return json.data.collectionRevision; } async fetchSlowDownloadLink(mod) { const url = `${mod.file.url}&nmm=1`; const response = await fetchViaGM(url, { headers: { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "cache-control": "max-age=0", }, referrer: url, referrerPolicy: "strict-origin-when-cross-origin", method: "GET", mode: "cors", credentials: "include" }); if (!response.ok) return { downloadUrl: '', text: '' }; const text = await response.text(); const downloadUrlMatch = text.match(/id="slowDownloadButton".*?data-download-url="([^"]+)"/); const downloadUrl = downloadUrlMatch ? downloadUrlMatch[1] : ''; return { downloadUrl, text }; } async 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 } = await this.fetchSlowDownloadLink(mod); if (downloadUrl === '') { // make link to copy in the clipboard the response const logRow = this.console.log(`Failed to get download link for ${mod.file.name} `, 'ERROR'); logRow.element.querySelector('button').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 (text.match(/class="replaced-login-link"/)) { this.console.log('You are not connected on NexusMods. Login and try again.', 'ERROR'); this.forceStop = true; } return false; } document.location.href = downloadUrl; return true; } // function to avoid repeating the same code this.downloadButtonContainer.hide(); this.progressBarContainer.setModsCount(this.downloadButtonContainer.modsCount); this.progressBarContainer.show(); async downloadMods(mods, type = "all") { const history = await GM.getValue('history', {}); // {"gameId": {"collectionId": {"type": []}}} this.startDownload(mods.length); // get history for this collection (index is the collectionId) let historyForThisCollection = history[this.gameId]?.[this.collectionId]?.[type] || []; history[this.gameId] ??= {}; history[this.gameId][this.collectionId] ??= {}; history[this.gameId][this.collectionId][type] ??= []; if (historyForThisCollection?.length) { const confirm = await new Promise(resolve => { resolve(window.confirm(`You already downloaded ${historyForThisCollection.length} out of ${mods.length} mods from this collection.\nDo you want to resume the download?\nCancel will clear the history and download all mods again.`)); }); if (!confirm) { historyForThisCollection = []; history[this.gameId][this.collectionId][type] = []; await GM.setValue('history', history); } } const failedDownload = []; for (const [index, mod] of mods.entries()) { history[this.gameId][this.collectionId][type] = [...new Set([...history[this.gameId][this.collectionId][type], mod.fileId])]; // remove duplicate and update history await GM.setValue('history', history); if (historyForThisCollection.length > index) { this.console.log(`[${index + 1}/${mods.length}] Already downloaded ${mod.file.name}`); this.progressBar.incrementProgress(); continue; } if (this.progressBar.skipToIndex && (this.progressBar.skipIndex - 1) > index) { this.console.log(`[${index + 1}/${mods.length}] Skipping ${mod.file.name}`); this.progressBar.incrementProgress(); if ((this.progressBar.skipIndex - 1) === (index + 1)) { // if skip to index is the next index this.progressBar.skipToIndex = false; } continue; } const status = await this.addModToVortex(mod); this.progressBar.incrementProgress(); if (this.forceStop) { this.console.log('Download stopped.'); break; } if (!status) { failedDownload.push(mod); continue; } this.console.log(`[${index + 1}/${mods.length}] Sending download link to Vortex ${mod.file.name}`); // 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 = this.pauseBetweenDownload == 0 ? 0 : Math.round(mod.file.sizeInBytes / 1500000) + this.pauseBetweenDownload; const downloadEstimatifTimeLog = this.console.log(`Waiting approximately ${downloadTime} seconds for the download to finish on Vortex before starting the next one.`); const downloadProgressLog = this.console.log(`Downloading... ${downloadTime} seconds left (~0%)`); const downloadProgressLogCreatedAt = Date.now(); await new Promise(resolve => { const downloadProgressLogInterval = setInterval(async () => { // if STATUS_PAUSED loop until STATUS_DOWNLOADING or STATUS_STOPPED or skip if (this.progressBar.status === NDCProgressBar.STATUS_PAUSED) { while (this.progressBar.status === NDCProgressBar.STATUS_PAUSED) { // if STATUS_STOPPED clear the interval and resolve the promise if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) { clearInterval(downloadProgressLogInterval); resolve(); } // if skip to index if (this.progressBar.skipPause) { this.progressBar.skipPause = false; clearInterval(downloadProgressLogInterval); resolve(); } // if skipToIndex if (this.progressBar.skipToIndex) { clearInterval(downloadProgressLogInterval); resolve(); } await new Promise(resolve => setTimeout(resolve, 100)); } } // if STATUS_STOPPED clear the interval and resolve the promise if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) { clearInterval(downloadProgressLogInterval); resolve(); } // if skip to index if (this.progressBar.skipPause) { this.console.log('Skip to next mod.'); this.progressBar.skipPause = false; clearInterval(downloadProgressLogInterval); resolve(); } // if skipToIndex if (this.progressBar.skipToIndex) { this.console.log(`Skip to index ${this.progressBar.skipIndex}.`); clearInterval(downloadProgressLogInterval); resolve(); } const elapsedTime = Math.round((Date.now() - downloadProgressLogCreatedAt) / 1000); const remainingTime = downloadTime - elapsedTime; downloadProgressLog.innerHTML = `Downloading... ${remainingTime} seconds left (~${Math.round((elapsedTime / downloadTime) * 100)}%)`; if (remainingTime <= 0) { clearInterval(downloadProgressLogInterval); resolve(); } }, 100); }); downloadEstimatifTimeLog.remove(); downloadProgressLog.remove(); if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) { this.console.log('Download stopped.'); break; } // if all mods are downloaded clear the history if (this.progressBar.progress === this.progressBar.modsCount) { history[this.gameId][this.collectionId][type] = []; await GM.setValue('history', history); } } if (failedDownload.length) { this.console.log(`Failed to download ${failedDownload.length} mods:`, 'ERROR'); for (const mod of failedDownload) { this.console.log(`${mod.file.name}`); } } this.endDownload(); } startDownload(modsCount) { this.forceStop = false; 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.'); } endDownload() { this.forceStop = false; this.progressBar.setStatus(NDCProgressBar.STATUS_FINISHED); this.progressBar.element.style.display = 'none'; this.downloadButton.element.style.display = ''; this.console.log('Download finished.'); } } class NDCDownloadButton { constructor(ndc) { this.element = document.createElement('div'); this.element.classList.add('flex', 'w-100'); this.ndc = ndc; this.html = ` `; this.element.innerHTML = this.html; 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'); const menuBtn = this.element.querySelector('#menuBtn'); const menu = this.element.querySelector('#menu'); menuBtn.addEventListener('click', () => { const btnGroupOffset = this.element.getBoundingClientRect(); menu.classList.toggle('hidden'); const dropdownMenuOffset = menu.getBoundingClientRect(); menu.style.transform = `translate(${btnGroupOffset.width - dropdownMenuOffset.width}px, ${btnGroupOffset.height}px)`; }); document.addEventListener('click', (event) => { const isClickInside = menu.contains(event.target) || menuBtn.contains(event.target); if (!isClickInside) { menu.classList.add('hidden'); } }); } 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.updateModsCount(); this.updateMandatoryModsCount(); this.updateOptionalModsCount(); } } 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, options = {}) { 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.skipToIndex = false; this.skipIndex = 0; this.status = NDCProgressBar.STATUS_DOWNLOADING; this.html = `
${this.progress}%
Downloading...
${this.progress}/${this.modsCount}
information
`; this.element.innerHTML += this.html; // Append the HTML to the body or any other element you want const extraPauseInfo = this.element.querySelector('#extraPauseInfo'); 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.pauseBetweenDownloadInput = this.element.querySelector('#pauseBetweenDownloadInput'); this.skipNextBtn = this.element.querySelector('#skipNextBtn'); this.skipToIndexBtn = this.element.querySelector('#skipToIndexBtn'); this.skipToIndexInput = this.element.querySelector('#skipToIndexInput'); extraPauseInfo.addEventListener('click', () => { alert(`"Extra pause" is the time in seconds the script waits before starting the next download. Without it, downloads begin immediately but Vortex may become unresponsive with large collections.\n\nA supplementary pause is calculated based on the mod file size and download speed (1.5mb/s), noticeable only with large mods.\n\nIf "extra pause" is set to 0, the calculated pause is ignored.`); }); 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.pauseBetweenDownloadInput.addEventListener('change', async (event) => { this.ndc.pauseBetweenDownload = parseInt(event.target.value); await GM.setValue('pauseBetweenDownload', this.ndc.pauseBetweenDownload); }); this.skipNextBtn.addEventListener('click', () => { this.skipPause = true; }); this.skipToIndexBtn.addEventListener('click', () => { this.skipToIndex = true; const index = parseInt(this.skipToIndexInput.value); if (index > this.progress && index <= this.modsCount) { this.skipIndex = index; } }); } 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 ? '' : ''; } updatePauseBetweenDownloadInput() { this.pauseBetweenDownloadInput.value = this.ndc.pauseBetweenDownload; } render() { this.updateProgressBarFillWidth() this.updateProgressBarTextProgress() this.updateProgressBarTextRight() this.updatePlayPauseBtn() this.updatePauseBetweenDownloadInput() } } class NDCLogConsole { constructor(ndc, options = {}) { this.element = document.createElement('div'); this.element.classList.add('flex', 'flex-col', 'w-100', 'gap-3', 'mt-3'); this.ndc = ndc; this.hidden = false; 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 = 'INFO') { const rowElement = document.createElement('div'); rowElement.classList.add('gap-x-2', 'px-2', 'py-1'); rowElement.innerHTML = `[${new Date().toLocaleTimeString()}][${type}] ${message}`; this.logContainer.appendChild(rowElement); this.logContainer.scrollTop = this.logContainer.scrollHeight; console.log(`[${type}] ${message}`); return rowElement; } clear() { this.logContainer.innerHTML = ''; } } let previousRoute = null; let ndc = null; 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}`; ndc = new NDC(gameDomain, collectionSlug); ndc.init(); } if (tab === "mods") { document.querySelector("#tabcontent-mods > div > div > div").prepend(ndc.element); } else { ndc.element.remove(); } } } // Add an event listener for the hashchange event next.router.events.on('routeChangeComplete', handleNextRouterChange); handleNextRouterChange();