Nexus Download Collection

Download every mods of a collection in a single click

目前為 2024-02-23 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

    CORSViaGM.init(window);

    /** CORSViaGM END */

    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;
    }

    function log(message, type) {
        const logRow = createElement('div', {
            classes: 'flex items-center gap-x-2 px-2 py-1',
            html: `[${new Date().toLocaleTimeString()}] [${type}] ${message}`
        });
        logArea.appendChild(logRow);
        logArea.scrollTop = logArea.scrollHeight;
    }

    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 xml = new DOMParser().parseFromString(text, "text/html");

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

        return downloadUrl;
    };

    async function addModToVortex(mod) {
        // const downloadUrl = await new Promise(resolve => setTimeout(resolve, 1000)); // for testing
        const downloadUrl = await getSlowDownloadModLink(mod, true);
        if (downloadUrl === '') {
            log(`Failed to get download link for <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>.`, 'ERROR');
            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 (!status) {
                continue;
            }

            log(`Downloading <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, mods.length);
        }

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

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

    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',
        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: 'hidden w-full bg-surface-low rounded overflow-y-auto text-white font-montserrat font-semibold text-sm  border border-primary',
        attributes: {
            style: 'height: 5rem; resize: vertical;'
        }
    });
    const logAreaToggleButton = createElement('button', {
        html: 'Show 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();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址