Steam Workshop Downloader (Skymods/Modsbase)

Download mod via skymods.ru and modsbase.com directly from steam workshop

// ==UserScript==
// @name         Steam Workshop Downloader (Skymods/Modsbase)
// @namespace    http://tampermonkey.net/
// @version      0.06
// @description  Download mod via skymods.ru and modsbase.com directly from steam workshop
// @author       Skrylor  - Maintainer
// @author       Namkazt ( [email protected] ) - Original Author
// @match        https://steamcommunity.com/sharedfiles/filedetails/*
// @match        https://steamcommunity.com/workshop/filedetails/*
// @match        https://steamcommunity.com/workshop/browse/*
// @connect      smods.ru
// @connect      modsbase.com
// @run-at       document-end
// @require      https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js
// @require      http://code.jquery.com/jquery-3.6.0.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_notification
// @grant        GM_download
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @license MIT
// ==/UserScript==

function createElementFromHTML(htmlString) {
    var div = document.createElement("div");
    div.innerHTML = htmlString.trim();
    return div.firstChild;
}

function getAppId() {
    return document.querySelector(".apphub_OtherSiteInfo a").getAttribute('data-appid');
}

function isCitiesSkylines() {
    return (
        document.querySelector(".apphub_HeaderTop .apphub_AppName").innerText ===
        "Cities: Skylines"
    );
}

function isCV6() {
    return (
        document.querySelector(".apphub_HeaderTop .apphub_AppName").innerText ===
        "Sid Meier's Civilization VI"
    );
}

function isCollectionPage() {
    const collectionsLink = document.querySelector('a[href*="/workshop/browse/?section=collections"]');
    return collectionsLink !== null; // Returns true if the link is found
}

function getDownloadId(downloadUrl) {
    console.log("----------- parsing download url: " + downloadUrl);
    var regex = /\/[^\/]*\//gm;
    var m;
    var downloadId = "";
    while ((m = regex.exec(downloadUrl)) !== null) {
        if (m.index === regex.lastIndex) {
            regex.lastIndex++;
        }
        if (m.index > 6) {
            downloadId = m[0].substr(1, m[0].length - 2);
        }
    }
    return downloadId;
}

function getDownloadLinkFromModsBase(downloadId, referer, callback) {
    const formData = new FormData();
    formData.append("op", "download2");
    formData.append("id", downloadId);
    formData.append("rand", "");
    formData.append("referer", "");
    formData.append("method_free", "");
    formData.append("method_premium", "");

    GM_xmlhttpRequest({
        method: "POST",
        url: "https://modsbase.com/",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded",
            "Referer": referer,
        },
        data: new URLSearchParams(formData).toString(),
        onload: function(response) {
            if (response.status === 200) {
                const parser = new DOMParser();
                const doc = parser.parseFromString(response.responseText, "text/html");
                const downloadLinkElement = doc.querySelector('.download-details a');

                if (downloadLinkElement) {
                    const directDownloadLink = downloadLinkElement.href;
                    callback(null, directDownloadLink);
                } else {
                    callback("Download link not found in response", null);
                }

            } else {
                callback(`Request failed with status ${response.status}`, null);
            }
        },
        onerror: function(error) {
            callback(`Request error: ${error.statusText}`, null);
        }
    });
}


function searchForMod(id, callback) {
    var appId = getAppId();
    var url = "http://catalogue.smods.ru/?s=" + id + "&app=" + appId;

    console.log("----------- URL: " + url);

    GM_xmlhttpRequest({
        anonymous: true,
        method: "GET",
        url: url,
        headers: {
            "Referer": "http://catalogue.smods.ru"
        },
        onload: function(e) {
            doc = new DOMParser().parseFromString(e.responseText, "text/html");
            if (doc.getElementsByClassName("post-inner").length > 0) {
                var downloadUrl = doc.querySelector(".post-inner .skymods-excerpt-btn").href;
                var downloadId = getDownloadId(downloadUrl);
                if (downloadId != undefined || downloadId != null || downloadId != "") {
                    console.log("----------- download id: " + downloadId);
                    var rDateStr = doc.querySelector(".post-inner .skymods-item-date").innerText;
                    var updated = moment(rDateStr, "DD MMM at HH:mm YYYY").format(
                        "DD MMM, YYYY"
                    );
                    let titleElement = doc.querySelector(".post-inner h2 a");
                    let title = titleElement ? titleElement.textContent.trim() : "Unknown Mod Title";
                    callback(true, downloadId, downloadUrl, updated, title);
                } else {
                    callback(false, downloadId, downloadUrl, "");
                }
            } else {
                callback(false, downloadId, downloadUrl, "");
            }
        },
        onerror: function(error) {
            console.error("Request failed:", error);
            callback(false, null, null, "Error fetching mod info");
        }
    });
}

function gotoRequestPage(id) {
    var url = "https://steamcommunity.com/sharedfiles/filedetails/?id=" + id;
    if (isCitiesSkylines()) {
         window.open('https://docs.google.com/forms/d/e/1FAIpQLSdXlq9OAWVwX5lRLNvpkMSmpKbEDY50Bl-UU3f6P7OBI2Ny3Q/viewform?c=0&w=1&entry.417177883=' + url, '_blank');
    } else {
         window.open('https://docs.google.com/forms/d/e/1FAIpQLSe7MisYbKNUlTXBcSR2clHxpwaoo0HiZ3zWto0osemubdDP1g/viewform?entry.417177883=' + url, '_blank');
    }
}

function changeButtonGradient(btn, color1, color2) {
    var gradient =
        "linear-gradient(42deg, #" + color1 + " 35%, #" + color2 + " 65%)";
    btn.style.background = gradient;
    btn.querySelector("#DownloadTxt").style.background = gradient;
}

function searchForDownloadLink(btn, downloadId, downloadUrl, modTitle) {
    let textNode = btn.querySelector("#DownloadTxt");
    let spinner = btn.querySelector(".loading-spinner");
    spinner.style.display = "inline-block";
    textNode.style.opacity = 0;
    btn.classList.add('loading');
    getDownloadLinkFromModsBase(downloadId, downloadUrl, function(err, directDownloadLink) {
        spinner.style.display = "none";
        textNode.style.opacity = 1;
        btn.classList.remove('loading');
        if (err) {
            console.error(err);
            textNode.innerText = "Failed to get link";
            return;
        }

        textNode.innerText = "Downloading...";
        spinner.style.display = "inline-block";
        textNode.style.opacity = 0;


        let fileName = modTitle.replace(/[^a-zA-Z0-9_.-]/g, '_') + ".zip";
        fileName = fileName.substring(0, 250);

        GM_download({
            url: directDownloadLink,
            name: fileName,
            onload: function() {
                spinner.style.display = "none";
                textNode.style.opacity = 1;
                textNode.innerHTML = "Downloaded!";
            },
            onerror: function(error) {
                spinner.style.display = "none";
                textNode.style.opacity = 1;
                console.error("Download error:", error);
                textNode.innerText = "Download Failed";
            }
        });
    });
}

var DOWNLOAD_BTN_TEMPLATE = `
    <button id="DownloadBtn" class="steam-button">
        <span id="DownloadTxt">Download</span>
        <span class="loading-spinner" style="display: none;">
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
                <circle cx="8" cy="8" r="7" stroke="#fff" stroke-width="2" style="animation: rotate 1s linear infinite;"/>
            </svg>
        </span>
    </button>
`;

GM_addStyle(`
 .steam-button {
    background-color: #7cb342;
    border: none;
    color: white;
    padding: 6px 12px;
    border-radius: 4px;
    cursor: pointer;
    text-decoration: none;
    font-weight: bold;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    transition: background-color 0.2s ease, transform 0.1s ease;
    display: inline-block;
    position: relative;
}

.steam-button:hover {
    background-color: #669933;
    transform: scale(1.02);
}

.steam-button.loading #DownloadTxt {
    opacity: 0;
    transition: opacity 0.2s ease;
}

.steam-button.loading .loading-spinner {
    display: inline-block;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
}

.steam-button .loading-spinner svg {
    animation: rotate 1s linear infinite;
}

.steam-button.not-available {
    background-color: #d32f2f;
}

.steam-button.not-available:hover {
    background-color: #c62828;
}

.game_area_purchase_game > div {
    height: 30px; /* Replace 30px with the actual height of the Subscribe button */
    display: flex;
    align-items: center;
}

.game_area_purchase_game > div > a#SubscribeItemBtn + button.steam-button {
    margin-left: -10px; /* Adjust this value as needed for left alignment */
    margin-right: 0; /* Ensure no right margin */
}


@keyframes rotate {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
}

.steam-button.loading {
    opacity: 0.7;
    pointer-events: none;
}

/* Floating Downloader Styles */
.floating-downloader {
    position: fixed;
    right: 20px;
    top: 50%;
    transform: translateY(-50%);
    background: #1b2838;
    border: 1px solid #4582a5;
    border-radius: 4px;
    padding: 15px;
    width: 300px;
    color: #fff;
    z-index: 9999;
    box-shadow: 0 0 10px rgba(0,0,0,0.5);
    display: none; /* Initially hidden */
}

.floating-downloader-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 10px;
    border-bottom: 1px solid #4582a5;
    padding-bottom: 10px;
}

.floating-downloader-title {
    font-weight: bold;
    font-size: 16px;
}

.floating-downloader-close {
    background: none;
    border: none;
    color: #fff;
    cursor: pointer;
    font-size: 18px;
}

.download-progress {
    margin: 10px 0;
}

.progress-bar {
    width: 100%;
    height: 20px;
    background: #2a475e;
    border-radius: 10px;
    overflow: hidden;
}

.progress-bar-fill {
    height: 100%;
    background: #66c0f4;
    transition: width 0.3s ease;
}

.download-stats {
    display: flex;
    justify-content: space-between;
    margin-top: 5px;
    font-size: 12px;
}

.download-list {
    max-height: 200px;
    overflow-y: auto;
    margin: 10px 0;
}

.download-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 5px 0;
    border-bottom: 1px solid #2a475e;
}

.download-item-status {
    font-size: 12px;
    color: #66c0f4;
}
`);

const FLOATING_DOWNLOADER_TEMPLATE = `
    <div class="floating-downloader" id="batchDownloader">
        <div class="floating-downloader-header">
            <div class="floating-downloader-title">Batch Downloader</div>
            <button class="floating-downloader-close">×</button>
        </div>
        <div class="download-progress">
            <div class="progress-bar">
                <div class="progress-bar-fill" style="width: 0%"></div>
            </div>
            <div class="download-stats">
                <span class="downloads-completed">0/0 completed</span>
                <span class="download-size">0 MB</span>
            </div>
        </div>
        <div class="download-list"></div>
        <button id="startBatchDownload" class="steam-button">Download All</button>
    </div>
`;

class BatchDownloadManager {
    constructor() {
        this.downloads = new Map();
        this.completed = 0;
        this.total = 0;
        this.currentlyDownloading = false;
        this.downloadQueue = [];
        this.initializeUI();
    }

    initializeUI() {
        const downloaderEl = createElementFromHTML(FLOATING_DOWNLOADER_TEMPLATE);
        document.body.appendChild(downloaderEl);

        downloaderEl.querySelector('.floating-downloader-close').addEventListener('click', () => {
            downloaderEl.style.display = 'none';
        });

        document.getElementById('startBatchDownload').addEventListener('click', () => {
            this.startBatchDownload();
        });
    }

    addDownload(workshopId, modTitle, downloadId, downloadUrl) {
        this.downloads.set(workshopId, { modTitle, downloadId, downloadUrl, status: 'pending' });
        this.updateUI();
    }

    async startBatchDownload() {
        if (this.currentlyDownloading) return;
        this.currentlyDownloading = true;
        this.downloadQueue = Array.from(this.downloads.entries()).filter(([_, info]) => info.status === 'pending');
        this.total = this.downloadQueue.length;
        this.completed = 0;
        this.processNextDownload();
    }

    async processNextDownload() {
        if (this.downloadQueue.length === 0) {
            this.currentlyDownloading = false;
            this.updateUI();
            return;
        }

        const [workshopId, info] = this.downloadQueue.shift();

        try {
            await this.downloadMod(workshopId, info);
            this.completed++;
            info.status = 'completed';
        } catch (error) {
            console.error(`Failed to download ${info.modTitle}:`, error);
            info.status = 'failed';
        }

        this.updateUI();
        this.processNextDownload();
    }

    async downloadMod(workshopId, info) {
        return new Promise((resolve, reject) => {
            getDownloadLinkFromModsBase(info.downloadId, info.downloadUrl, (err, directDownloadLink) => {
                if (err) {
                    reject(err);
                    return;
                }

                let fileName = info.modTitle.replace(/[^a-zA-Z0-9_.-]/g, '_') + ".zip";
                fileName = fileName.substring(0, 250);

                GM_download({
                    url: directDownloadLink,
                    name: fileName,
                    onload: resolve,
                    onerror: reject
                });
            });
        });
    }

    updateUI() {
        const progress = (this.completed / this.total) * 100 || 0;
        const progressBar = document.querySelector('.progress-bar-fill');
        const statsEl = document.querySelector('.downloads-completed');
        const downloadList = document.querySelector('.download-list');

        progressBar.style.width = `${progress}%`;
        statsEl.textContent = `${this.completed}/${this.total} completed`;

        downloadList.innerHTML = '';
        this.downloads.forEach((info, workshopId) => {
            const itemEl = document.createElement('div');
            itemEl.className = 'download-item';
            itemEl.innerHTML = `<span class="download-item-title">${info.modTitle}</span><span class="download-item-status">${info.status}</span>`;
            downloadList.appendChild(itemEl);
        });
    }
}


function init() {
    $(document).ready(function() {
        const batchManager = new BatchDownloadManager(); // Initialize here for collection pages
        if (window.location.href.indexOf("appid=") >= 0) {
            console.log("----------- Workshop browser page");
            var itemList = document.querySelectorAll(".workshopItemPreviewHolder");

            for (var item of itemList) {
                var itemDownloadId = item.id.replace("sharedfile_", "");
                var btnNode = createElementFromHTML(DOWNLOAD_BTN_TEMPLATE);

                searchForMod(itemDownloadId,
                    (function() {
                        var workshopId = itemDownloadId;
                        var btn = btnNode;
                        var textNode = btn.querySelector("#DownloadTxt");
                        textNode.innerText = "Checking for mod";
                        return function(found, downloadId, downloadUrl, updated, modTitle) {
                            if (found) {
                                textNode.innerText = "Download - " + updated;
                                btn.addEventListener("click", function() {
                                    searchForDownloadLink(btn, downloadId, downloadUrl, modTitle);
                                });
                            } else {
                                textNode.innerText = "Not Available (REQUEST)";
                                btn.classList.add("not-available");
                                btn.addEventListener("click", function() {
                                    gotoRequestPage(workshopId);
                                });
                            }
                        };
                    })()
                );

                var subscriptionControls = item.parentNode.querySelector('.subscriptionControls');
                if (subscriptionControls) subscriptionControls.appendChild(btnNode);
            }
        } else if (isCollectionPage()) {
            console.log("----------- Collection page");
            var itemList = document.querySelectorAll(".collectionItem");
            for (var item of itemList) {
                var itemDownloadId = item.id.replace("sharedfile_", "");
                var btnNode = createElementFromHTML(DOWNLOAD_BTN_TEMPLATE);
                searchForMod(itemDownloadId,
                    (function() {
                        var workshopId = itemDownloadId;
                        var btn = btnNode;
                        var textNode = btn.querySelector("#DownloadTxt");
                        textNode.innerText = "Checking for mod";
                        return function(found, downloadId, downloadUrl, updated, modTitle) {
                            if (found) {
                                textNode.innerText = "Download - " + updated;
                                btn.addEventListener("click", function() {
                                    searchForDownloadLink(btn, downloadId, downloadUrl, modTitle);
                                });
                            } else {
                                textNode.innerText = "Not Available (REQUEST)";
                                btn.classList.add("not-available");
                                btn.addEventListener("click", function() {
                                    gotoRequestPage(workshopId);
                                });
                            }
                        };
                    })()
                );
                var subscriptionControls = item.querySelector('.subscriptionControls');
                if (subscriptionControls) subscriptionControls.appendChild(btnNode);

            }
             // Add items to batch manager for collection page
            document.querySelectorAll('.collectionItem').forEach(item => {
                const itemDownloadId = item.id.replace("sharedfile_", "");
                searchForMod(itemDownloadId, (found, downloadId, downloadUrl, updated, modTitle) => {
                    if (found) {
                        batchManager.addDownload(itemDownloadId, modTitle, downloadId, downloadUrl);
                    }
                });
            });

            // Show the floating downloader after processing all items
            document.getElementById('batchDownloader').style.display = 'block';
        } else {
            console.log("----------- Single item page");
            var publishedfileid = window.location.href.match(/id=(\d+)/)[1];
            var btnNode = createElementFromHTML(DOWNLOAD_BTN_TEMPLATE);
            var textNode = btnNode.querySelector("#DownloadTxt");
            textNode.innerText = "Checking for mod";
            searchForMod(publishedfileid, function(
                found,
                downloadId,
                downloadUrl,
                updated,
                modTitle
            ) {
                if (found) {
                    textNode.innerText = "Download - " + updated;
                    btnNode.addEventListener("click", function() {
                        searchForDownloadLink(btnNode, downloadId, downloadUrl, modTitle);
                    });
                } else {
                    textNode.innerText = "Not Available (REQUEST)";
                    btnNode.classList.add("not-available");
                    btnNode.addEventListener("click", function() {
                        gotoRequestPage(publishedfileid);
                    });
                }
            });

            const subscribeButton = document.getElementById("SubscribeItemBtn");
            if (subscribeButton) {
                subscribeButton.parentNode.insertBefore(btnNode, subscribeButton.nextSibling);
            } else {
                const subscriptionControls = document.querySelector('.subscriptionControls');
                if (subscriptionControls) {
                    subscriptionControls.insertBefore(btnNode, subscriptionControls.firstChild);
                } else {
                    console.error("Neither Subscribe button nor Subscription Controls found. Appending to body.");
                    document.body.appendChild(btnNode);
                }
            }
        }
        console.log("----------- Init successfully");
    });
}

(function() {
    "use strict";

    init();
})();