Nexus Download Collection

Download every mods of a collection in a single click

当前为 2024-01-21 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Nexus Download Collection
// @namespace    NDC
// @version      0.6.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
// ==/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">${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">${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 fill-font-primary text-font-primary border-transparent focus:bg-primary-lighter hover:bg-primary-darker rounded',
    });

    const modsCountSpan = createElement('span', {
        classes: 'p-2 bg-secondary 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 fill-font-primary text-font-primary border-transparent focus:bg-primary-lighter hover:bg-primary-darker 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 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-secondary-lighter hover:text-primary 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 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-secondary-lighter hover:text-primary 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-secondary-lighter bg-secondary 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 fill-font-primary text-font-primary border-transparent focus:bg-primary-lighter hover:bg-primary-darker 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-secondary rounded overflow-hidden hidden',
        elements: [
            progressBar,
            progressBarButton
        ]
    });

    const logArea = createElement('div', {
        classes: 'hidden w-full bg-secondary 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();
})();