// ==UserScript== // @name Nexus No Wait ++ // @description Skip Countdown, Auto Download, and More for Nexus Mods. Supports (Manual/Vortex/MO2/NMM) // @version 2.0.0 // @namespace NexusNoWaitPlusPlus // @author Torkelicious // @iconURL https://raw.githubusercontent.com/torkelicious/nexus-no-wait-pp/refs/heads/main/icon.png // @icon https://raw.githubusercontent.com/torkelicious/nexus-no-wait-pp/refs/heads/main/icon.png // @license GPL-3.0-or-later // @include https://*.nexusmods.com/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_info // @grant GM_addStyle // @grant GM_listValues // @grant GM_deleteValue // @connect *.nexusmods.com // @connect raw.githubusercontent.com // @downloadURL none // ==/UserScript== (function () { 'use strict'; // Config const CONFIG_KEY = 'NexusNoWaitPP'; const DEFAULTS = { AutoStartDownload: true, AutoCloseTab: true, SkipRequirements: true, ShowAlertsOnError: true, PlayErrorSound: true, ErrorSoundUrl: 'https://github.com/torkelicious/nexus-no-wait-pp/raw/refs/heads/main/errorsound.mp3', HandleArchivedFiles: true, HidePremiumUpsells: false, CloseTabDelay: 2000, RequestTimeout: 30000, }; function loadConfig() { try { const raw = typeof GM_getValue === 'function' ? GM_getValue(CONFIG_KEY, null) : null; return raw ? { ...DEFAULTS, ...(typeof raw === 'string' ? JSON.parse(raw) : raw), } : DEFAULTS; } catch (e) { return DEFAULTS; } } // this exists because previous versions have a different config system async function cleanResetConfig() { // remove all GM storage keys stored if (typeof GM_listValues === 'function' && typeof GM_deleteValue === 'function') { const keys = await GM_listValues(); for (const key of keys) { await GM_deleteValue(key); } } // Reset cfg to default Object.assign(cfg, DEFAULTS); // save defaults back to storage if (typeof GM_setValue === 'function') { await GM_setValue(CONFIG_KEY, JSON.stringify(cfg)); } location.reload(); } let cfg = loadConfig(); const Logger = (() => { const prefix = () => `[NexusNoWait++ v${GM_info.script.version}]`; const format = (...args) => [prefix(), ...args, `\n at:(${location.href})`]; const log = level => (...args) => console[level](...format(...args)); return { debug: log('debug'), info: log('info'), warn: log('warn'), error: log('error'), }; })(); let errorAudioPlayer = null; function setupAudio() { // audio preloading if (!cfg.PlayErrorSound || !cfg.ErrorSoundUrl) return; errorAudioPlayer = new Audio(cfg.ErrorSoundUrl); errorAudioPlayer.preload = 'auto'; errorAudioPlayer.load(); } function playErrorSound() { if (errorAudioPlayer) { errorAudioPlayer.currentTime = 0; errorAudioPlayer.play().catch(e => Logger.warn('Error playing sound:', e)); } } // NXM URL helpers function getURLPathSegment(index) { return window.location.pathname.split('/')[index] || null; } function parseNXMParamsFromURL(text, params = {}) { const inputText = String(text || ''); const mappings = [ { regex: /(?:md5|key)=([^&"']+)/, key: 'key' }, { regex: /(?:expires|exp)=([^&"']+)/, key: 'expires' }, { regex: /user_id=([^&"']+)/, key: 'user_id' }, { regex: /(?:file_id)=([^&"']+)/, key: 'fileId', condition: () => !params.fileId, }, ]; for (const { regex, key, condition = () => true } of mappings) { const match = inputText.match(regex)?.[1]; if (match && condition()) params[key] = match; } params.game ||= getURLPathSegment(1); params.modId ||= getURLPathSegment(3); return params; } function buildNXMUrl(params = {}) { const needed = ['game', 'modId', 'fileId', 'key', 'expires', 'user_id']; if (needed.some(k => !params[k])) return null; return `nxm://${params.game}/mods/${params.modId}/files/${params.fileId}?key=${params.key}&expires=${params.expires}&user_id=${params.user_id}`; } function parseDownloadURLFromResponse(text) { if (!text) return null; const inputText = String(text); try { const json = JSON.parse(inputText); if (json && json.url) { return { url: json.url.replace(/&/g, '&'), source: 'json-url', }; } } catch (_) {} const match = inputText.match(/id=["']dl_link["'][^>]*value=["']([^"']+)["']/i); if (match) { return { url: match[1].replace(/&/g, '&'), source: 'dl_link-value', }; } return null; } function getGameId() { const sectionElement = document.getElementById('section'); return sectionElement?.dataset?.gameId || ''; } // unified download URL function async function getDownloadUrl({ fileId, gameId, isNMM }) { if (!fileId) return { url: null, error: 'Missing fileId' }; if (isNMM) { // NMM logic const popupEndpoint = `/Core/Libs/Common/Widgets/DownloadPopUp?id=${encodeURIComponent(fileId)}&game_id=${encodeURIComponent(gameId || '')}`; let responseText = ''; await new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: popupEndpoint, headers: { 'X-Requested-With': 'XMLHttpRequest' }, onload(response) { responseText = response.response || response.responseText || ''; resolve(); }, onerror: resolve, ontimeout: resolve, }); }); if (!responseText) return { url: null, error: 'Empty response' }; const extracted = parseDownloadURLFromResponse(responseText); if (!extracted) return { url: null, error: 'No URL extracted\n(Are you logged in?)', }; if (/^nxm:\/\//i.test(extracted.url)) return { url: extracted.url }; if (/^https?:\/\//i.test(extracted.url)) { const params = parseNXMParamsFromURL(extracted.url, { fileId, modId: getURLPathSegment(3), game: getURLPathSegment(1), }); const nxm = buildNXMUrl(params); return { url: nxm || extracted.url }; } return { url: null, error: 'Unknown URL type' }; } else { // Manual logic const endpoint = '/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl'; const body = `fid=${encodeURIComponent(fileId)}&game_id=${encodeURIComponent(gameId || '')}`; return await new Promise(resolve => { GM_xmlhttpRequest({ method: 'POST', url: endpoint, data: body, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', Origin: 'https://www.nexusmods.com', Referer: location.href, }, timeout: cfg.RequestTimeout, onload(response) { const responseText = response.response || response.responseText || ''; const extracted = parseDownloadURLFromResponse(responseText); if (extracted) resolve({ url: extracted.url }); else resolve({ url: null, error: 'No URL in response\n(Are you logged in?)', }); }, onerror() { resolve({ url: null, error: 'Request failed' }); }, ontimeout() { resolve({ url: null, error: 'Timeout' }); }, }); }); } } function setButtonState(button, state, message) { try { const textElement = button.querySelector('span.flex-label, span') || button; const stateConfig = { waiting: { text: 'Please Wait...', color: 'orange' }, downloading: { text: 'Downloading!', color: 'green' }, error: { text: message || 'Error', color: 'red' }, }; const config = stateConfig[state] || stateConfig.error; textElement.innerText = config.text; button.style.color = config.color; } catch (e) {} } function attachClickInterceptor() { async function handleDownload(btn, fileId, isNMM) { setButtonState(btn, 'waiting'); Logger.debug('fileId', fileId, 'isNMM', isNMM); const { url, error } = await getDownloadUrl({ fileId, gameId: getGameId(), isNMM, }); if (error) { setButtonState(btn, 'error', error); if (cfg.PlayErrorSound) playErrorSound(); if (cfg.ShowAlertsOnError) alert(`Download error: ${error}`); return; } setButtonState(btn, 'downloading'); location.assign(url); } const extractFileId = href => { try { const url = new URL(href, location.href); return url.searchParams.get('file_id') || url.searchParams.get('id'); } catch {} return null; }; document.body.addEventListener( 'click', async function (event) { const element = event.target.closest('a,button'); if (!element) return; const linkHref = element.href || element.getAttribute('href') || ''; if (!linkHref) return; const fileId = extractFileId(linkHref); if (!fileId) return; const hasRequirements = linkHref.includes('ModRequirementsPopUp') || linkHref.includes('tab=requirements'); const isNMM = linkHref.includes('nmm=1') || linkHref.includes('&nmm') || element.closest('#action-nmm') !== null; // If SkipRequirements is enabled and this is a requirements popup button, trigger download directly if (hasRequirements && cfg.SkipRequirements) { event.preventDefault(); event.stopImmediatePropagation(); handleDownload(element, fileId, isNMM); return; } // If requirements are present and skip is not enabled, let the popup/tab open as normal if (hasRequirements && !cfg.SkipRequirements) { return; } // Otherwise handle as normal download event.preventDefault(); event.stopImmediatePropagation(); handleDownload(element, fileId, isNMM); }, true ); // Intercept "Slow download" button on file_id pages if (location.search.includes('file_id')) { const setupSlowDownloadIntercept = () => { const modFileDownload = document.querySelector('mod-file-download'); if (modFileDownload?.shadowRoot) { const slowDownloadBtn = modFileDownload.shadowRoot.querySelector( '#upsell-cards > div.flex.flex-col.justify-between.gap-y-6.rounded-lg.bg-surface-translucent-low.p-6 > button' ); if (slowDownloadBtn) { slowDownloadBtn.addEventListener('click', async event => { event.preventDefault(); event.stopImmediatePropagation(); const params = new URLSearchParams(location.search); const fileId = params.get('file_id'); if (!fileId) return; const isNMM = params.has('nmm') || params.get('nmm') === '1'; Logger.debug('Slow download intercept: fileId', fileId, 'isNMM', isNMM); setButtonState(slowDownloadBtn, 'waiting'); const { url } = await getDownloadUrl({ fileId, gameId: getGameId(), isNMM, }); if (url) { setButtonState(slowDownloadBtn, 'downloading'); Logger.info(`Slow download ${isNMM ? 'NMM' : 'manual'}: starting download`); location.assign(url); } }); } } }; setupSlowDownloadIntercept(); const observer = new MutationObserver(() => { setupSlowDownloadIntercept(); }); observer.observe(document.body, { childList: true, subtree: true }); } } function interceptRequirementsTab() { document.body.addEventListener( 'click', function (event) { const linkElement = event.target.closest("a[href*='tab=requirements']"); if (!linkElement) return; if (!cfg.SkipRequirements) return; event.preventDefault(); event.stopImmediatePropagation(); const linkHref = linkElement.href || linkElement.getAttribute('href') || ''; location.replace(linkHref.replace('tab=requirements', 'tab=files')); }, true ); } async function autoStartDownload() { if (!cfg.AutoStartDownload) return; const params = new URLSearchParams(location.search); const fileId = params.get('file_id'); if (!fileId) return; const isNMM = params.has('nmm') || params.get('nmm') === '1'; Logger.debug('Auto-start: fileId', fileId, 'isNMM', isNMM); await new Promise(r => setTimeout(r, 200)); const { url } = await getDownloadUrl({ fileId, gameId: getGameId(), isNMM, }); if (url) { Logger.info( `Auto ${isNMM ? 'NMM' : 'manual'}: final URL type`, url.startsWith('nxm://') ? 'nxm' : url.startsWith('https://') ? 'https' : 'other' ); location.assign(url); if (cfg.AutoCloseTab) setTimeout(() => window.close(), cfg.CloseTabDelay); } } function upsellBlocker() { if (!cfg.HidePremiumUpsells) return; const elementsToHideSelectors = [ // IDs '#nonPremiumBanner', '#freeTrialBanner', '#ig-banner-container', '#rj-vortex', // broad class matches for dynamic content '[class*="ads-bottom"]', '[class*="ads-top"]', '[class*="to-premium"]', '[class*="from-premium"]', '[class*="premium"]', // specific page elements '#mainContent > div.ads-holder.clearfix.ads-top', '#mainContent > div.ads-holder.clearfix.ads-bottom', '#mainContent > div > div.relative.next-container > div > section.flex.items-center.justify-center > div', '#mainContent > div > div.relative.next-container > div > a', '#headlessui-menu-items-_r_ap_ > div.flex.flex-col.gap-y-4.px-3.py-2 > div.hidden.md\\:block', '#head > div.rj-right-tray.rj-profile-tray.rj-open > ul > li.user-profile-menu-section-top > a', '#mainContent > div.flex.items-center.justify-center.gap-x-4.border-y.border-stroke-subdued.bg-surface-low.py-2', '#mainContent > div.hidden.items-center.justify-center.gap-x-4.border-b.border-stroke-subdued.bg-surface-low.py-2.md\\:flex', '#mainContent > div.relative > div.relative.next-container.pb-20 > div.space-y-16 > div.relative.overflow-hidden.rounded-lg.border-2.border-\[\#FCD23F\]', '#mainContent > div.relative > div.relative.next-container.pb-20 > div.mb-6.w-full.space-y-6.border-b.border-stroke-weak.pt-4.pb-6.sm\\:mb-0.sm\\:border-none.sm\\:pb-8 > section > div.flex.flex-col.gap-2.rounded-sm.bg-surface-translucent-low.p-2.5.backdrop-blur-xs.xs\\:w-fit.xs\\:max-w-sm.order-4.h-fit.w-full', '#filters-panel > div.mt-4.hidden.rounded-lg.border.border-creator-subdued.bg-creator-weak.bg-cover.p-4', ]; // hide all selectors GM_addStyle( elementsToHideSelectors.map(selector => `${selector}{display:none!important}`).join('\n') ); // hide upsells in shadow root const modFileDownloadElement = document.querySelector('mod-file-download'); if (modFileDownloadElement?.shadowRoot) { const shadowStyle = document.createElement('style'); shadowStyle.textContent = '#upsell-cards > div.relative.flex.flex-col.justify-between.gap-y-6.rounded-lg.border.bg-gradient-to-t.from-premium-weak.from-25\\%.to-premium-900.to-75\\%.p-6.sm\\:order-last.border-premium-100.border-premium-moderate{display:none!important}'; modFileDownloadElement.shadowRoot.appendChild(shadowStyle); } // hide premium upsell banner const premiumBanner = document.querySelector('.bg-nexus-premium-gradient'); if (premiumBanner) { premiumBanner.remove(); Logger.info('Removed premium upsell banner'); } } function waitForElement(selector, cb) { const el = document.querySelector(selector); if (el) cb(el); const mo = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { cb(el); } }); mo.observe(document.body, { childList: true, subtree: true }); } function archivedFileHandler() { if (!cfg.HandleArchivedFiles) return; const url = location.href; if (url.includes('tab=files') && !url.includes('category=archived')) { waitForElement('#files-tab-footer', footer => { footer.querySelector('p')?.style.setProperty('display', 'none'); // Check for any existing 'File archive' button const hasArchiveBtn = Array.from( footer.querySelectorAll('a.btn.inline-flex .flex-label') ).some(el => el.textContent.trim() === 'File archive'); if (!hasArchiveBtn) { footer.insertAdjacentHTML( 'beforeend', `File archive` ); } }); } if (!url.includes('category=archived')) return; const headers = Array.from(document.getElementsByClassName('file-expander-header')); const downloads = Array.from(document.getElementsByClassName('accordion-downloads')); const base = location.origin + location.pathname; for (const [i, header] of headers.entries()) { const fileId = header?.dataset?.id; const box = downloads[i]; if (!fileId || !box || box.dataset.done) continue; box.dataset.done = '1'; box.innerHTML = ` Mod manager download Manual download `; } } function main() { setupAudio(); attachClickInterceptor(); interceptRequirementsTab(); autoStartDownload(); upsellBlocker(); archivedFileHandler(); SettingsUI(); Logger.debug('NNW++ initiated'); } function SettingsUI() { const SETTING_UI = [ { key: 'AutoStartDownload', label: 'Auto Start Download on file_id= URLs', type: 'bool', description: 'Automatically start downloads when visiting file download pages (URLs containing file_id=)', }, { key: 'AutoCloseTab', label: 'Auto-Close Tab After Automatic Download ', type: 'bool', description: 'Automatically close the tab after a download starts on file download pages', showIf: () => cfg.AutoStartDownload, }, { key: 'SkipRequirements', label: 'Skip Requirements PopUp/Tab', type: 'bool', description: 'Skip the requirements popup/page and proceed directly to download', }, { key: 'ShowAlertsOnError', label: 'Show Alert Messages on Errors', type: 'bool', description: 'Display error messages as browser popup alerts', }, { key: 'PlayErrorSound', label: 'Play Error Sound', type: 'bool', description: 'Play an error sound when download errors occur', }, { key: 'HidePremiumUpsells', label: 'Hide Premium Upsells & misc Annoyances (experimental)', type: 'bool', description: 'Hide premium upgrade banners, trial offers, and other Annoyances on the site (experimental)\n slow and buggy, you are probably better off using an adblocker.', }, { key: 'RequestTimeout', label: 'Request Timeout', type: 'number', description: 'Maximum time to wait for server responses before timing out (in milliseconds)', }, { key: 'CloseTabDelay', label: 'Auto-Close Tab Delay', type: 'number', description: 'Delay before automatically closing the tab after download starts (in milliseconds)', showIf: () => cfg.AutoCloseTab, }, { key: 'ErrorSoundUrl', label: 'Error Sound URL', type: 'text', description: 'URL of the custom sound file to play for error alerts', showIf: () => cfg.PlayErrorSound, }, { key: 'HandleArchivedFiles', label: 'Generate download buttons for Archived Files', type: 'bool', description: 'Enable handling of archived files.', }, ]; const STYLES = { btn: "position:fixed;bottom:20px;right:20px;background:#2f2f2f;color:#fff;padding:10px 15px;border-radius:4px;cursor:pointer;z-index:9999;font-family:'Inter','Helvetica Neue', Helvetica, Arial, sans-serif;font-size:14px;border:none;", modal: "position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2f2f2f;color:#dadada;padding:25px;border-radius:4px;z-index:10000;min-width:300px;max-width:90%;max-height:90vh;overflow-y:auto;font-family:'Inter','Helvetica Neue', Helvetica, Arial, sans-serif;", backdrop: 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.5);z-index:9999;', section: 'background:#363636;padding:15px;border-radius:4px;margin-bottom:15px;', sectionHeader: 'color:#da8e35;margin:0 0 10px 0;font-size:16px;font-weight:500;', input: 'background:#2f2f2f;border:1px solid #444;color:#dadada;border-radius:3px;padding:5px;', row: 'margin-bottom:10px;', label: 'display:flex;align-items:center;gap:8px;', btnObj: { primary: 'padding:8px 15px;border:none;background:#da8e35;color:white;border-radius:3px;cursor:pointer;', secondary: 'padding:8px 15px;border:1px solid #da8e35;background:transparent;color:#da8e35;border-radius:3px;cursor:pointer;', advanced: 'padding:4px 8px;background:transparent;color:#666;border:none;cursor:pointer;', closeX: 'position:absolute;top:10px;right:10px;background:transparent;border:none;color:#fff;font-size:18px;cursor:pointer;line-height:1;padding:5px;', }, }; function save() { try { GM_setValue(CONFIG_KEY, JSON.stringify(cfg)); } catch (e) {} } let activeModal = null; let activeBackdrop = null; function showSettingsModal() { cfg = loadConfig(); if (activeModal) activeModal.remove(); if (activeBackdrop) activeBackdrop.remove(); const backdrop = document.createElement('div'); backdrop.style.cssText = STYLES.backdrop; backdrop.addEventListener('click', closeModal); document.body.appendChild(backdrop); activeBackdrop = backdrop; const modal = document.createElement('div'); modal.style.cssText = STYLES.modal; const build = setting => { const shouldShow = !setting.showIf || setting.showIf(); if (setting.type === 'bool') return `
`; if (setting.type === 'number') { const step = setting.key === 'CloseTabDelay' ? 100 : 1; return `
`; } if (setting.type === 'text') return `
`; return ''; }; const features = SETTING_UI.filter( u => (u.type === 'bool' || u.type === 'text') && u.key !== 'RefreshOnError' ) .map(build) .join(''); const timing = SETTING_UI.filter(u => u.type === 'number') .map(build) .join(''); modal.innerHTML = `

NexusNoWait++ Settings

Features

${features}

Timing

${timing}
v${GM_info.script.version} by Torkelicious
This software is open-source and licensed under the GPLv3
`; const update = element => { const key = element.getAttribute('data-setting'); if (!key) return; let value = element.type === 'checkbox' ? element.checked : element.type === 'number' ? parseInt(element.value, 10) : element.value; if (typeof value === 'number' && isNaN(value)) { element.value = cfg[key]; return; } if (cfg[key] !== value) { cfg[key] = value; save(); } if (key === 'AutoStartDownload') { const row = modal.querySelector('[data-setting="AutoCloseTab"]')?.closest('div'); if (row) row.style.display = element.checked ? 'block' : 'none'; } if (key === 'AutoCloseTab') { const row = modal.querySelector('[data-setting="CloseTabDelay"]')?.closest('div'); if (row) row.style.display = element.checked ? 'block' : 'none'; } if (key === 'PlayErrorSound') { const row = modal.querySelector('[data-setting="ErrorSoundUrl"]')?.closest('div'); if (row) row.style.display = element.checked ? 'block' : 'none'; } }; modal.addEventListener('change', event => { if (event.target?.hasAttribute('data-setting')) update(event.target); }); modal.addEventListener('input', event => { if ( (event.target.type === 'number' || event.target.type === 'text') && event.target?.hasAttribute('data-setting') ) update(event.target); }); const closeX = modal.querySelector('#closeSettingsX'); const closeBtn = modal.querySelector('#closeSettings'); const resetBtn = modal.querySelector('#resetSettings'); function closeModal() { if (activeModal) { activeModal.remove(); activeModal = null; } if (activeBackdrop) { activeBackdrop.remove(); activeBackdrop = null; } document.removeEventListener('keydown', onSettingsKeyDown); } const onSettingsKeyDown = event => { if (event.key === 'Escape') closeModal(); }; closeX.addEventListener('click', closeModal); closeBtn.addEventListener('click', closeModal); resetBtn.addEventListener('click', async () => { await cleanResetConfig(); closeModal(); }); document.body.appendChild(modal); activeModal = modal; document.addEventListener('keydown', onSettingsKeyDown); } if (!document.getElementById('nnwpp-btn')) { const btn = document.createElement('div'); btn.id = 'nnwpp-btn'; btn.textContent = 'NexusNoWait++ ⚙️'; btn.style.cssText = STYLES.btn; btn.onclick = showSettingsModal; btn.onmouseover = () => (btn.style.transform = 'translateY(-2px)'); document.body.appendChild(btn); // keep button persistent if removed by react hydration -.- const observer = new MutationObserver(() => { if (!document.getElementById('nnwpp-btn')) { document.body.appendChild(btn); } }); observer.observe(document.body, { childList: true, subtree: true }); } } main(); // first run // spa navigation support to re-run main() on URL change let lastUrl = location.href; const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function (...args) { originalPushState.apply(this, args); if (location.href !== lastUrl) { lastUrl = location.href; main(); } }; history.replaceState = function (...args) { originalReplaceState.apply(this, args); if (location.href !== lastUrl) { lastUrl = location.href; main(); } }; window.addEventListener('popstate', () => { if (location.href !== lastUrl) { lastUrl = location.href; main(); } }); // fallback for other changes new MutationObserver(() => { if (location.href !== lastUrl) { lastUrl = location.href; main(); } }).observe(document.body, { subtree: true, childList: true }); })();