Greasy Fork 还支持 简体中文。

Nexus Download Collection

Download every mods of a collection in a single click

目前為 2023-12-31 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Nexus Download Collection
// @namespace    NDC
// @version      0.3
// @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 createButtonGroup(options) {
        // Function to create a button with specified classes, text, and text content
        function createButton(classes, text, content) {
            const button = document.createElement('button');
            button.className = classes;
            button.innerHTML = text;
            if (content) {
                button.appendChild(content);
            }
            return button;
        }

        // Function to create a link with specified classes, text, and href
        function createLink(classes, text, href) {
            const link = document.createElement('a');
            link.className = classes;
            link.innerHTML = text;
            link.href = href;
            return link;
        }

        // Create the button group container
        const buttonGroup = document.createElement('div');
        buttonGroup.className = 'inline-flex position-relative vertical-align-middle align-middle w-full';

        // Create the main button
        const mainButton = createButton('font-montserrat font-semibold text-sm leading-none tracking-wider uppercase leading-none 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 rounded-l px-4 py-1 cursor-pointer bg-primary fill-font-primary text-font-primary border-transparent focus:bg-primary-lighter hover:bg-primary-darker w-full', options.text);

        for (const [eventName, callback] of Object.entries(options.callbacks)) {
            mainButton.addEventListener(eventName, callback);
        }

        // Create the button with the caret icon
        const dropdownButton = createButton('font-montserrat font-semibold text-sm leading-none tracking-wider uppercase leading-none 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 rounded-r px-4 py-1 cursor-pointer bg-primary fill-font-primary text-font-primary border-transparent focus:bg-primary-lighter hover:bg-primary-darker', '');
        const caretIcon = document.createElementNS("http://www.w3.org/2000/svg", 'svg');
        caretIcon.setAttribute('viewBox', '0 0 24 24');
        caretIcon.style.width = '1.5rem';
        caretIcon.style.height = '1.5rem';
        caretIcon.setAttribute('role', 'presentation');
        const caretIconPath = document.createElementNS("http://www.w3.org/2000/svg", 'path');
        caretIconPath.style.fill = 'currentColor';
        caretIconPath.setAttribute('d', 'M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z');
        caretIcon.appendChild(caretIconPath);
        dropdownButton.appendChild(caretIcon);


        // Create the dropdown container
        const dropdownContainer = document.createElement('div');
        dropdownContainer.className = 'absolute z-10 hidden 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';


        for (const dropdownItem of options.dropdownItems) {
            // Create the dropdown item
            const dropdownItemText = dropdownItem.text;
            const dropdownItemElement = createLink('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-4 text-left font-normal hover:bg-secondary-lighter hover:text-primary focus:shadow-accent focus:z-10 focus:outline-none text-center', dropdownItemText, '#');

            for (const [eventName, callback] of Object.entries(dropdownItem.callbacks)) {
                dropdownItemElement.addEventListener(eventName, callback);
            }

            // Append the elements to the dropdown container
            dropdownContainer.appendChild(dropdownItemElement);
        }

        // Append the buttons and dropdown to the button group
        buttonGroup.appendChild(mainButton);
        buttonGroup.appendChild(dropdownButton);
        buttonGroup.appendChild(dropdownContainer);
        buttonGroup.mainButton = mainButton;
        buttonGroup.dropdownButton = dropdownButton;
        buttonGroup.dropdownContainer = dropdownContainer;

        // Add event listener to toggle dropdown visibility
        dropdownButton.addEventListener('click', () => {
            // we need to add tranform translate to the dropdown container, so we need to get the width of the main button and add it to the dropdown container
            const mainButtonWidth = mainButton.offsetWidth;
            const mainButtonHeight = mainButton.offsetHeight;
            const dropdownButtonWidth = dropdownButton.offsetWidth;
            dropdownContainer.classList.toggle('hidden');
            const dropdownContainerWidth = dropdownContainer.offsetWidth;
            dropdownContainer.style.transform = `translate(${(mainButtonWidth + dropdownButtonWidth) - dropdownContainerWidth}px, ${mainButtonHeight}px)`;
        });

        document.addEventListener('click', (event) => {
            if (!dropdownButton.contains(event.target) && !dropdownContainer.contains(event.target)) {
                // transform translate to the dropdown button
                dropdownContainer.style
                dropdownContainer.classList.add('hidden');
            }
        });

        // return the button group and its children
        return buttonGroup;
    }

    function createProgressBar() {
        const progressBar = document.createElement("div");
        progressBar.classList.add("mt-2", "w-full", "bg-gray-200", "rounded-full");
        progressBar.style.display = "none";
        progressBar.style.height = "1.1rem";
        progressBar.style.backgroundColor = "rgb(229 231 235 / 1)";

        const bar = document.createElement("div");
        bar.classList.add("bg-blue-600", "rounded-full");
        bar.style.height = "1.1rem";
        bar.style.fontSize = "0.8rem";
        bar.style.padding = "0 5px";
        bar.style.width = "0%";
        bar.style.backgroundColor = "rgb(28 100 242 / 1)";
        progressBar.appendChild(bar);

        progressBar.bar = bar;

        return progressBar;
    }

    const getModCollection = async (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;
    }

    const getSlowDownloadModLink = async (mod, nmm) => {
        let downloadUrl = '';
        const url = nmm ? mod.file.url + '&nmm=1' : mod.file.url;

        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");
        if (nmm) {
            const slow = xml.getElementById("slowDownloadButton");
            downloadUrl = slow.getAttribute("data-download-url");
        } else {
            const current_game_id = text.split("\n").find(line => line.includes("window.current_game_id = ")).split("window.current_game_id = ")[1].split(";")[0];

            const response = await fetchViaGM("https://www.nexusmods.com/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl", {
                "headers": {
                    "accept": "*/*",
                    "accept-language": "fr;q=0.5",
                    "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
                    "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": "no-cors",
                    "sec-fetch-site": "same-origin",
                    "sec-gpc": "1",
                    "x-requested-with": "XMLHttpRequest"
                },
                // set cookie

                "referrer": url,
                "referrerPolicy": "strict-origin-when-cross-origin",
                "body": `fid=${mod.file.fileId}&game_id${current_game_id}`,
                "method": "POST",
                "mode": "cors",
                "credentials": "include"
            });

            const data = await response.json();
            downloadUrl = data.url;
        }

        return downloadUrl;
    };

    const addModToVortex = async (mod) => {
        const downloadUrl = await getSlowDownloadModLink(mod, true);
        document.location.href = downloadUrl;
    };

    const downloadModFile = async (mod) => {
        const downloadUrl = await getSlowDownloadModLink(mod, false);
        document.location.href = downloadUrl;
    };

    const downloadMods = async (mods, callback) => {
        let downloadProgress = 0;
        let downloadProgressPercent = 0;

        progressBar.style.display = "block";

        for (const mod of mods) {
            await callback(mod);

            console.log(`[NDC] Opened : ${mod.file.url}`);

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

            progressBar.bar.style.width = `${downloadProgressPercent}%`;
            progressBar.bar.innerText = `${Math.round(downloadProgressPercent)}%`;
            console.log(`[NDC] Progress : ${Math.round(downloadProgressPercent)}%`);
        }

        progressBar.style.display = "none";
        progressBar.bar.style.width = "0%";
    };

    let buttonFlag = false;

    const appendButtonToTabContent = async () => {
        // on element div id="tabcontent-mods" appear append button
        const tabcontentMods = document.querySelector("#tabcontent-mods");
        const NDCContainer = document.querySelector("#NDCContainer");

        if (!tabcontentMods) {
            buttonFlag = false;
        }
        
        if (tabcontentMods && NDCContainer === null && !buttonFlag) {
            buttonFlag = true;

            const loadingContainer = createButtonGroup({
                text: `Loading...`,
                callbacks: {},
                dropdownItems: []
            });

            document.querySelector("#tabcontent-mods > div > div > div").prepend(loadingContainer);
            
            const gameId = document.location.pathname.split("/")[1];
            const collectionId = document.location.pathname.split("/")[3];        

            const mods = await getModCollection(gameId, collectionId);
            const { mandatoryMods, optionalMods } = mods.modFiles.reduce((acc, mod) => {
                if (mod.optional) {
                    acc.optionalMods.push(mod);
                } else {
                    acc.mandatoryMods.push(mod);
                }
                return acc;
            }, { mandatoryMods: [], optionalMods: [] });

            // Example usage:
            const addToVortexButtonGroup = createButtonGroup({
                text: `Add all mods to vortex <span class="p-2 bg-secondary rounded-full text-xs text-white">${mods.modFiles.length} mods</span>`,
                callbacks: {
                    click: () => downloadMods([...mandatoryMods, ...optionalMods], addModToVortex),
                },
                dropdownItems: [
                    {
                        text: `Add all mandatory mods <span class="p-2 bg-primary rounded-full text-xs text-white">${mandatoryMods.length} mods</span>`,
                        callbacks: {
                            click: () => downloadMods(mandatoryMods, addModToVortex),
                        },
                    },
                    {
                        text: `Add all optional mods <span class="p-2 bg-primary rounded-full text-xs text-white">${optionalMods.length} mods</span>`,
                        callbacks: {
                            click: () => downloadMods(optionalMods, addModToVortex),
                        },
                    },
                ]
            });
            const downloadFilesButtonGroup = createButtonGroup({
                text: `Download all mods files <span class="p-2 bg-secondary rounded-full text-xs text-white">${mods.modFiles.length} mods</span>`,
                callbacks: {
                    click: () => downloadMods([...mandatoryMods, ...optionalMods], downloadModFile),
                },
                dropdownItems: [
                    {
                        text: `Download all mandatory mods files <span class="p-2 bg-primary rounded-full text-xs text-white">${mandatoryMods.length} mods</span>`,
                        callbacks: {
                            click: () => downloadMods(mandatoryMods, downloadModFile),
                        },
                    },
                    {
                        text: `Download all optional mods files <span class="p-2 bg-primary rounded-full text-xs text-white">${optionalMods.length} mods</span>`,
                        callbacks: {
                            click: () => downloadMods(optionalMods, downloadModFile),
                        },
                    },
                ]
            });

            const NDCContainer = document.createElement("div");
            NDCContainer.id = "NDCContainer";
            NDCContainer.classList.add("flex", "flex-col", "gap-y-2", "w-full");

            // add addToVortexButtonGroup and downloadFilesButtonGroup in a row
            const NDCContainerButtonGroup = document.createElement("div");
            NDCContainerButtonGroup.classList.add("flex", "gap-x-2");
            NDCContainerButtonGroup.appendChild(addToVortexButtonGroup);
            // NDCContainerButtonGroup.appendChild(downloadFilesButtonGroup);
            NDCContainer.appendChild(NDCContainerButtonGroup);

            // add progress bar
            const progressBar = createProgressBar();
            NDCContainer.appendChild(progressBar);

            loadingContainer.remove();
            document.querySelector("#tabcontent-mods > div > div > div").prepend(NDCContainer);
        }
    };

    // on element div id="tabcontent-mods" appear append button
    const observer = new MutationObserver(appendButtonToTabContent);

    observer.observe(document.querySelector('#__next'), { childList: true, subtree: true });
    // trigger the observer callback for the first time
    appendButtonToTabContent();

})();