Greasy Fork

来自缓存

Greasy Fork is available in English.

Nexus Download Collection

Download every mods of a collection in a single click

当前为 2023-12-31 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Nexus Download Collection
// @namespace    NDC
// @version      0.4
// @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%";
    };

    const progressBar = createProgressBar();
    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
            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();

})();