Nexus Download Collection

Download every mods of a collection in a single click

当前为 2024-04-07 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Nexus Download Collection
// @namespace    NDC
// @version      0.6.6
// @description  Download every mods of a collection in a single click
// @author       Drigtime
// @match        https://next.nexusmods.com/*/collections*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        GM.xmlHttpRequest
// @grant        GM_xmlhttpRequest
// @connect      nexusmods.com
// ==/UserScript==

(async function () {
    'use strict';
    /** CORSViaGM BEGINING */

    let forceStop = false;

    const CORSViaGM = document.body.appendChild(Object.assign(document.createElement('div'), { id: 'CORSViaGM' }))

    addEventListener('fetchViaGM', e => GM_fetch(e.detail.forwardingFetch))

    CORSViaGM.init = function (window) {
        if (!window) throw 'The `window` parameter must be passed in!'
        window.fetchViaGM = fetchViaGM.bind(window)

        // Support for service worker
        window.forwardingFetch = new BroadcastChannel('forwardingFetch')
        window.forwardingFetch.onmessage = async e => {
            const req = e.data
            const { url } = req
            const res = await fetchViaGM(url, req)
            const response = await res.blob()
            window.forwardingFetch.postMessage({ type: 'fetchResponse', url, response })
        }

        window._CORSViaGM && window._CORSViaGM.inited.done()

        const info = '🙉 CORS-via-GM initiated!'
        console.info(info)
        return info
    }

    function GM_fetch(p) {
        GM_xmlhttpRequest({
            ...p.init,
            url: p.url, method: p.init.method || 'GET',
            onload: responseDetails => p.res(new Response(responseDetails.response, responseDetails))
        })
    }

    function fetchViaGM(url, init) {
        let _r
        const p = new Promise(r => _r = r)
        p.res = _r
        p.url = url
        p.init = init || {}
        dispatchEvent(new CustomEvent('fetchViaGM', { detail: { forwardingFetch: p } }))
        return p
    }

    CORSViaGM.init(window);

    /** CORSViaGM END */

    function createElement(elementName, options) {
        var element = document.createElement(elementName);
        if (options.html) {
            element.innerHTML = options.html;
        }
        if (options.elements) {
            for (var i = 0; i < options.elements.length; i++) {
                element.appendChild(options.elements[i]);
            }
        }
        if (options.classes) {
            element.className = options.classes;
        }
        if (options.attributes) {
            for (var key in options.attributes) {
                element.setAttribute(key, options.attributes[key]);
            }
        }
        if (options.events) {
            for (var key in options.events) {
                element.addEventListener(key, options.events[key]);
            }
        }
        return element;
    }

    class LogRow {
        constructor(message, type) {
            this.message = message;
            this.type = type;
            this.createdAt = new Date();
            this.row = createElement('div', {
                classes: 'gap-x-2 px-2 py-1',
                html: `[${this.createdAt.toLocaleTimeString()}][${this.type}] ${this.message}`
            });
        }

        updateMessage(message) {
            this.message = message;
            this.row.innerHTML = `[${this.createdAt.toLocaleTimeString()}][${this.type}] ${this.message}`;
        }

        destroy() {
            this.row.remove();
        }
    }

    function log(message, type) {
        const logRow = new LogRow(message, type);
        logArea.appendChild(logRow.row);
        logArea.scrollTop = logArea.scrollHeight;

        return logRow;
    }

    function refreshProgressBar(percent, currentMod, totalMods) {
        progressBar.style.width = `${percent}%`;
        progressBarButtonProgress.innerText = `${Math.round(percent)}%`;
        progressBarButtonDownloaded.innerText = `${currentMod}/${totalMods}`;
    }

    async function getModCollection(gameId, collectionId) {
        const response = await fetch("https://next.nexusmods.com/api/graphql", {
            "headers": {
                "accept": "*/*",
                "accept-language": "fr;q=0.5",
                "api-version": "2023-09-05",
                "content-type": "application/json",
                "sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Brave\";v=\"120\"",
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": "\"Windows\"",
                "sec-fetch-dest": "empty",
                "sec-fetch-mode": "cors",
                "sec-fetch-site": "same-origin",
                "sec-gpc": "1"
            },
            "referrer": `https://next.nexusmods.com/${gameId}/collections/${collectionId}?tab=mods`,
            "referrerPolicy": "strict-origin-when-cross-origin",
            "body": JSON.stringify({
                "query": "query CollectionRevisionMods ($revision: Int, $slug: String!, $viewAdultContent: Boolean) { collectionRevision (revision: $revision, slug: $slug, viewAdultContent: $viewAdultContent) { externalResources { id, name, resourceType, resourceUrl }, modFiles { fileId, optional, file { fileId, name, scanned, size, sizeInBytes, version, mod { adult, author, category, modId, name, pictureUrl, summary, version, game { domainName }, uploader { avatar, memberId, name } } } } } }",
                "variables": { "slug": collectionId, "viewAdultContent": true },
                "operationName": "CollectionRevisionMods"
            }),
            "method": "POST",
            "mode": "cors",
            "credentials": "include"
        });

        const data = await response.json();

        data.data.collectionRevision.modFiles = data.data.collectionRevision.modFiles.map(modFile => {
            modFile.file.url = `https://www.nexusmods.com/${gameId}/mods/${modFile.file.mod.modId}?tab=files&file_id=${modFile.file.fileId}`;
            return modFile;
        });

        return data.data.collectionRevision;
    }

    async function getSlowDownloadModLink(mod) {
        let downloadUrl = '';
        const url = mod.file.url + '&nmm=1';

        const response = await fetchViaGM(url, {
            "headers": {
                "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
                "accept-language": "fr;q=0.6",
                "cache-control": "max-age=0",
                "sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Brave\";v=\"120\"",
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": "\"Windows\"",
                "sec-fetch-dest": "document",
                "sec-fetch-mode": "navigate",
                "sec-fetch-site": "same-origin",
                "sec-fetch-user": "?1",
                "sec-gpc": "1",
                "upgrade-insecure-requests": "1"
            },
            "referrer": url,
            "referrerPolicy": "strict-origin-when-cross-origin",
            "body": null,
            "method": "GET",
            "mode": "cors",
            "credentials": "include"
        });

        const text = await response.text();
        const html = new DOMParser().parseFromString(text, "text/html");

        const slow = html.getElementById("slowDownloadButton");
        if (slow) {
            downloadUrl = slow.getAttribute("data-download-url");
        }

        return { downloadUrl, text, html }
    };

    async function addModToVortex(mod) {
        // const {downloadUrl, text} = await new Promise(resolve => setTimeout(() => resolve({downloadUrl: 'debug', text: 'debug'}), 1000));
        // const {downloadUrl, text} = await new Promise(resolve => setTimeout(() => resolve({downloadUrl: '', text: 'debug'}), 1000));
        const { downloadUrl, text, html } = await getSlowDownloadModLink(mod, true);
        if (downloadUrl === '') {
            // make link to copy in the clipboard the response

            const logRow = log(`Failed to get download link for
            <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>
            <button class="text-primary-moderate" title="Copy response to clipboard"></button>`, 'ERROR');
            const svg = createElement('svg', {
                classes: 'w-4 h-4 fill-current',
                attributes: {
                    viewBox: '0 0 24 24',
                    xmlns: 'http://www.w3.org/2000/svg',
                    role: 'presentation',
                    style: 'width: 1rem; height: 1rem;'
                },
                html: '<path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z" style="fill: currentcolor;"></path>'
            });
            // add svg to the button
            const copyButton = logRow.row.querySelector('button');
            copyButton.innerHTML = svg.outerHTML;
            copyButton.addEventListener('click', () => {
                navigator.clipboard.writeText(text);
                alert('Response copied to clipboard');
            });

            // check if find .replaced-login-link in the html it is because the user is not connect on nexusmods
            if (html.querySelector('.replaced-login-link')) {
                log('You are not connected on NexusMods. <a href="https://users.nexusmods.com/auth/continue?client_id=nexus&redirect_uri=https://www.nexusmods.com/oauth/callback&response_type=code&referrer=https%3A%2F%2Fwww.nexusmods.com%2F" target="_blank" class="text-primary-moderate">Login</a> and try again.', 'ERROR');
                forceStop = true;
            }

            return false;
        }

        document.location.href = downloadUrl;
        return true;
    };

    async function downloadMods(mods) {
        let downloadProgress = 0;
        let downloadProgressPercent = 0;

        refreshProgressBar(0, 0, mods.length);

        btnGroup.classList.add('hidden');
        progressBarContainer.classList.remove('hidden');
        logAreaContainer.classList.remove('hidden');

        for (const [index, mod] of mods.entries()) {
            if (downloadPaused) {
                log(`Download paused.`, 'INFO');
                while (downloadPaused) {
                    await new Promise(resolve => setTimeout(resolve, 100));
                }
                log(`Download resumed.`, 'INFO');
            }

            const status = await addModToVortex(mod);

            if (forceStop) {
                log(`Force stop.`, 'INFO');
                break;
            }

            if (!status) {
                continue;
            }

            log(`Sending download link to Vortex <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>`, 'INFO');

            downloadProgress += 1;
            downloadProgressPercent = downloadProgress / mods.length * 100;

            refreshProgressBar(downloadProgressPercent, index + 1, mods.length);

            // based on download 1.5mb/s wait until the download is supposed to be finished
            const downloadTime = Math.round(mod.file.sizeInBytes / 1500000);
            const downloadEstimatifTimeLog = log(`Waiting approximately ${downloadTime} seconds for the download to finish on Vortex before starting the next one.`, 'INFO');
            const downloadProgressLog = log(`Downloading... ${downloadTime} seconds left (~0%)`, 'INFO');
            const downloadProgressLogInterval = setInterval(() => {
                const timeLeft = downloadTime - Math.round((Date.now() - downloadProgressLog.createdAt) / 1000);
                // 0 to 100% based on the time left
                const approximativePercent = Math.round((downloadTime - timeLeft) / downloadTime * 100);
                downloadProgressLog.updateMessage(`Downloading... ${timeLeft} seconds left (~${approximativePercent}%)`);
            }, 1000);

            await new Promise(resolve => {
                setTimeout(() => {
                    clearInterval(downloadProgressLogInterval);
                    downloadEstimatifTimeLog.destroy();
                    downloadProgressLog.destroy();
                    resolve();
                }, downloadTime * 1000);
            });
        }

        progressBar.style.width = "0%";
        progressBarContainer.classList.add('hidden');

        btnGroup.classList.remove('hidden');

        if (forceStop) {
            forceStop = false;
            return;
        }

        logAreaContainer.classList.add('hidden');
        logArea.innerHTML = "";
    };

    const loadingContainer = createElement('div', {
        html: 'Loading...',
        classes: 'w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued rounded',
    });

    const modsCountSpan = createElement('span', {
        classes: 'p-2 bg-surface-low rounded-full text-xs text-white whitespace-nowrap',
    });
    const downloadAllButton = createElement('button', {
        html: 'Add all mods to vortex',
        elements: [
            modsCountSpan
        ],
        classes: 'w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-l',
        events: {
            click: () => {
                downloadMods(mods.modFiles);
            }
        }
    });

    const dropdownCarret = createElement('svg', {
        classes: 'w-4 h-4 fill-current',
        attributes: {
            viewBox: '0 0 24 24',
            xmlns: 'http://www.w3.org/2000/svg',
            role: 'presentation',
            style: 'width: 1.5rem; height: 1.5rem;'
        },
        html: '<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" style="fill: currentcolor;"></path>'
    });
    const dropdownItemMandatoryModsCount = createElement('span', {
        classes: 'p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap',
    });
    const dropdownItemMandatory = createElement('button', {
        html: 'Add all mandatory mods',
        elements: [
            dropdownItemMandatoryModsCount
        ],
        classes: 'font-montserrat text-sm font-semibold uppercase leading-none tracking-wider first:rounded-t last:rounded-b relative flex w-full items-center gap-x-2 p-2 text-left font-normal hover:bg-surface-mid hover:text-primary-moderate focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between',
        events: {
            click: () => {
                downloadMods(mandatoryMods)
            }
        }
    });
    const dropdownItemOptionalModsCount = createElement('span', {
        classes: 'p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap',
    });
    const dropdownItemOptional = createElement('button', {
        html: 'Add all optional mods',
        elements: [
            dropdownItemOptionalModsCount
        ],
        classes: 'font-montserrat text-sm font-semibold uppercase leading-none tracking-wider first:rounded-t last:rounded-b relative flex w-full items-center gap-x-2 p-2 text-left font-normal hover:bg-surface-mid hover:text-primary-moderate focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between',
        events: {
            click: () => {
                downloadMods(optionalMods)
            }
        }
    });
    const dropdownMenu = createElement('div', {
        classes: 'absolute z-10 min-w-48 py-1 px-0 mt-1 text-base text-gray-600 border-stroke-subdued bg-surface-low border border-gray-200 rounded-md shadow-lg outline-none hidden',
        elements: [
            dropdownItemMandatory,
            dropdownItemOptional
        ]
    });
    const dropdownButton = createElement('button', {
        html: dropdownCarret.outerHTML,
        classes: 'font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-r',
        events: {
            click: function () {
                const btnGroupOffset = btnGroup.getBoundingClientRect();
                dropdownMenu.classList.toggle('hidden');
                const dropdownMenuOffset = dropdownMenu.getBoundingClientRect();
                dropdownMenu.style.transform = `translate(${btnGroupOffset.width - dropdownMenuOffset.width}px, ${btnGroupOffset.height}px)`;
            }
        }
    });
    const btnGroup = createElement('div', {
        classes: 'flex w-100',
        elements: [
            downloadAllButton,
            dropdownButton,
            dropdownMenu
        ]
    });

    document.addEventListener('click', function (event) {
        const isClickInside = dropdownButton.contains(event.target);
        if (!isClickInside) {
            dropdownMenu.classList.add('hidden');
        }
    });

    const progressBar = createElement('div', {
        classes: 'absolute top-0 left-0 w-0 h-full bg-primary-moderate',
        attributes: {
            style: 'transition: width 0.3s ease;'
        }
    });
    const progressBarButtonProgress = createElement('div', {
        classes: 'ml-3',
        html: '0%',
    });
    const progressBarButtonText = createElement('div', {
        classes: 'text-center',
        html: 'Downloading...',
    });
    const progressBarButtonDownloaded = createElement('div', {
        classes: 'text-right',
        attributes: {
            style: 'margin-right: .75rem;'
        },
    });
    const progressBarButton = createElement('div', {
        elements: [
            progressBarButtonProgress,
            progressBarButtonText,
            progressBarButtonDownloaded
        ],
        classes: 'absolute top-0 left-0 w-full h-full cursor-pointer grid grid-cols-3 items-center text-white font-montserrat font-semibold text-sm leading-none tracking-wider uppercase',
        events: {
            click: function () {
                downloadPaused = !downloadPaused;
                progressBarButtonText.innerHTML = downloadPaused ? 'Resume' : 'Pause';
            },
            mouseenter: function () {
                progressBarButtonText.innerHTML = downloadPaused ? 'Resume' : 'Pause';
            },
            mouseleave: function () {
                progressBarButtonText.innerHTML = downloadPaused ? 'Resume' : 'Downloading...';
            },
        }
    });
    const progressBarContainer = createElement('div', {
        classes: 'relative w-100 min-h-9 bg-surface-low rounded overflow-hidden hidden',
        elements: [
            progressBar,
            progressBarButton
        ]
    });

    const logArea = createElement('div', {
        classes: 'w-full bg-surface-low rounded overflow-y-auto text-white font-montserrat font-semibold text-sm border border-primary',
        attributes: {
            style: 'height: 10rem; resize: vertical;'
        }
    });
    const logAreaToggleButton = createElement('button', {
        html: 'Hide logs',
        classes: 'w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase',
        events: {
            click: function () {
                logArea.classList.toggle('hidden');
                logAreaToggleButton.innerText = logArea.classList.contains('hidden') ? 'Show logs' : 'Hide logs';
            }
        }
    });
    const logAreaContainer = createElement('div', {
        classes: 'flex flex-col w-100 gap-3 hidden',
        elements: [
            logAreaToggleButton,
            logArea
        ]
    });

    const NDCContainer = createElement('div', {
        classes: 'flex flex-col w-100 gap-3 mb-3',
        elements: [
            btnGroup,
            progressBarContainer,
            logAreaContainer
        ]
    });

    let previousRoute = null;

    let mods = null;
    let mandatoryMods = [];
    let optionalMods = [];

    let downloadPaused = false; // used for pause button

    async function handleNextRouterChange() {
        if (next.router.state.route === "/[gameDomain]/collections/[collectionSlug]") {
            const { gameDomain, collectionSlug, tab } = next.router.query;

            if (previousRoute !== `${gameDomain}/${collectionSlug}`) {
                previousRoute = `${gameDomain}/${collectionSlug}`;

                if (tab === "mods") {
                    const tabcontentMods = document.querySelector("#tabcontent-mods > div > div > div");
                    tabcontentMods.prepend(loadingContainer);
                }

                mods = await getModCollection(gameDomain, collectionSlug);
                const modFiles = mods.modFiles.sort((a, b) => a.file.name.localeCompare(b.file.name));
                mandatoryMods = modFiles.filter(mod => !mod.optional);
                optionalMods = modFiles.filter(mod => mod.optional);

                if (tab === "mods") {
                    loadingContainer.remove();
                }
            }

            while (mods === null) {
                await new Promise(resolve => setTimeout(resolve, 100));
            }

            if (tab === "mods") {
                const tabcontentMods = document.querySelector("#tabcontent-mods > div > div > div");

                const modsCount = mods.modFiles.length;
                modsCountSpan.innerText = `${modsCount} mods`;
                dropdownItemMandatoryModsCount.innerText = `${mandatoryMods.length} mods`;
                dropdownItemOptionalModsCount.innerText = `${optionalMods.length} mods`;

                tabcontentMods.prepend(NDCContainer);
            }
        }
    }

    // Add an event listener for the hashchange event
    next.router.events.on('routeChangeComplete', handleNextRouterChange);

    handleNextRouterChange();
})();