GreasyFork: Unified Script Downloader & Sorter

Agrega un botón flotante para descargar todos los scripts de un usuario de Greasy Fork镜像, un botón de descarga directa en la vista individual del script, y una opción para ordenar scripts por fecha de creación (más antiguos primero), utilizando iconos Font Awesome. Ahora compatible con perfiles de usuario que muestran scripts desde el inicio.

As of 2025-08-08. See the latest version.

// ==UserScript==
// @name GreasyFork: Unified Script Downloader & Sorter
// @namespace http://tampermonkey.net/
// @version 1.2.1
// @description Agrega un botón flotante para descargar todos los scripts de un usuario de Greasy Fork镜像, un botón de descarga directa en la vista individual del script, y una opción para ordenar scripts por fecha de creación (más antiguos primero), utilizando iconos Font Awesome. Ahora compatible con perfiles de usuario que muestran scripts desde el inicio.
// @author YouTubeDrawaria, Konf
// @match https://greasyforks.org/es/users/*
// @match https://greasyforks.org/*/users/*
// @match https://greasyforks.org/es/scripts/by-site/drawaria.online*
// @match https://greasyforks.org/es/scripts/by-site/*
// @match https://greasyforks.org/es/scripts*
// @match https://greasyforks.org/*/scripts/by-site/drawaria.online*
// @match https://greasyforks.org/*/scripts/by-site/*
// @match https://greasyforks.org/*/scripts*
// @match https://greasyforks.org/*/scripts/*
// @match https://sleazyfork.org/*/scripts/*
// @match https://web.archive.org/web/*/https://greasyforks.org/*/scripts/*
// @match https://web.archive.org/web/*/https://sleasyfork.org/*/scripts/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect greasyforks.org
// @connect update.greasyforks.org
// @connect sleazyfork.org
// @connect cdnjs.cloudflare.com
// @license MIT
// @icon https://drawaria.online/avatar/cache/86e33830-86ea-11ec-8553-bff27824cf71.jpg
// @run-at document-end
// @noframes
// ==/UserScript==

(function() {
'use strict';

// ========== Font Awesome Inclusion ==========
function ensureFontAwesome() {
    if (!document.querySelector('link[href*="fontawesome"]')) {
        const fa = document.createElement('link');
        fa.rel = 'stylesheet';
        fa.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css';
        document.head.appendChild(fa);
        console.log('[Greasyfork Unified Downloader & Sorter] Font Awesome CDN injected.');
    }
}

// ========== Unified Utility Functions ==========

/**
 * Converts a new format Greasyfork script href to an old format URL for better compatibility with direct downloads.
 * @param {string} href - The script href (often from data-code-url or install-link).
 * @returns {string} The converted old format URL or the original href if conversion fails.
 */
function convertScriptHrefToAnOldFormat(href) {
    // Remove web.archive.org prefix if present to get the original Greasyfork URL
    const webArchiveMatch = href.match(/(https:\/\/web\.archive\.org\/web\/\d+\/)?id_?\/(https:\/\/(greas|sleaz)yfork\.org.+)/);
    if (webArchiveMatch) {
        href = webArchiveMatch[2];
    }

    // Regex to extract domain, script ID, and filename with extension
    const regex = /https:\/\/(?:update\.)?(\w+\.org)\/scripts\/(\d+)(?:\/\d+)?\/?([^?#]+)/;
    const match = href.match(regex);

    if (!match) {
        console.warn(`[Greasyfork Unified Downloader & Sorter] Could not convert href to old format, returning original: ${href}`);
        return href;
    }

    const domain = match[1];
    const scriptId = match[2];
    let scriptNameWithExt = match[3];

    // Ensure scriptNameWithExt includes a valid file extension
    if (!scriptNameWithExt.match(/\.(user\.js|user\.css|js|css)$/i)) {
         scriptNameWithExt += '.user.js'; // Default to .user.js for user scripts
    }

    // Construct the old format URL which typically points to the raw 'code' endpoint
    return `https://${domain}/scripts/${scriptId}/code/${scriptNameWithExt}`;
}

/**
 * Robustly downloads a file using GM_xmlhttpRequest, trying multiple URLs if provided.
 * @param {object} options
 * @param {string|string[]} options.urls - The URL(s) to download from. Can be a single string or an array of strings.
 * @param {string} options.filename - The desired filename for the downloaded file.
 * @returns {Promise<void>}
 */
async function downloadFileRobustly({ urls, filename }) {
    if (!Array.isArray(urls)) {
        urls = [urls]; // Ensure it's an array for iteration
    }

    const errors = [];

    for (let i = 0; i < urls.length; i++) {
        const url = urls[i];
        if (!url) continue;

        try {
            await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    responseType: "blob",
                    onload: function(response) {
                        if (response.status === 200) {
                            const blob = response.response;
                            const objUrl = URL.createObjectURL(blob);

                            const a = document.createElement('a');
                            a.href = objUrl;
                            a.download = filename;
                            document.body.appendChild(a); // Needed for Firefox
                            a.click();
                            a.remove();
                            URL.revokeObjectURL(objUrl);
                            resolve(); // Success, resolve the current promise
                        } else {
                            const errorMsg = `Failed to fetch ${url}: Status ${response.status} - ${response.statusText}`;
                            errors.push(new Error(errorMsg));
                            console.warn(`[Greasyfork Unified Downloader & Sorter] ${errorMsg}`);
                            reject(new Error('Attempt failed, try next URL')); // Reject to move to next URL
                        }
                    },
                    onerror: function(error) {
                        const errorMsg = `Network error fetching ${url}: ${error.error || 'Unknown error'}`;
                        errors.push(new Error(errorMsg));
                        console.warn(`[Greasyfork Unified Downloader & Sorter] ${errorMsg}`);
                        reject(new Error('Attempt failed, try next URL')); // Reject to move to next URL
                    }
                });
            });
            return; // If successful, exit the function
        } catch (e) {
            // If the promise was rejected with 'Attempt failed, try next URL', continue loop
            if (e.message !== 'Attempt failed, try next URL') {
                // Catch other unexpected errors
                errors.push(e);
                console.error(`[Greasyfork Unified Downloader & Sorter] Unexpected error during download attempt from ${url}:`, e);
            }
        }
    }

    // If all URLs failed
    errors.forEach(e => console.error(e));
    throw new Error('Failed to download file after all attempts. See console for details.');
}

/**
 * Sanitizes a string to be a valid and clean filename.
 * @param {string} filename - The original filename string.
 * @returns {string} The sanitized filename.
 */
function sanitizeFilename(filename) {
    // Remove query parameters and hash fragments
    let cleanedFilename = filename.split('?')[0].split('#')[0];

    // Separate Greasyfork specific extensions (.user.js, .user.css)
    let baseName = cleanedFilename.replace(/\.(user\.js|user\.css)$/i, '');
    let extension = (cleanedFilename.match(/\.(user\.js|user\.css)$/i) || [''])[0];

    // If no specific Greasyfork extension, check for general .js/.css
    if (!extension) {
        let generalExtMatch = cleanedFilename.match(/\.(js|css)$/i);
        if (generalExtMatch) {
            baseName = cleanedFilename.substring(0, cleanedFilename.lastIndexOf(generalExtMatch[0]));
            extension = generalExtMatch[0];
        }
    }

    // Remove invalid characters, keeping alphanumeric, hyphens, underscores, periods, and spaces (temporarily)
    baseName = baseName.replace(/[^\p{L}\p{N}\-_. ]/gu, '');
    baseName = baseName.replace(/[\s]+/g, '_'); // Replace spaces with single underscores
    baseName = baseName.replace(/_+/g, '_'); // Collapse multiple underscores
    baseName = baseName.replace(/^[._\-]+|[._\-]+$/g, ''); // Remove leading/trailing unwanted chars

    // Ensure baseName is not empty; fallback to a generic name
    if (baseName.length === 0) {
        baseName = 'greasyfork_script';
    }

    return `${baseName}${extension}`;
}

// ========== Unified Styles ==========
function addUnifiedStyles() {
    GM_addStyle(`
        /* Mass Downloader Button Styles */
        #downloadAllGreasyforkScriptsUnified {
            position: fixed;
            bottom: 20px;
            right: 20px;
            z-index: 99999;
            padding: 12px 25px;
            background-color: #4CAF50;
            color: white;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-size: 18px;
            font-weight: bold;
            box-shadow: 0 4px 8px rgba(0,0,0,0.3);
            transition: background-color 0.3s ease, transform 0.1s ease, box-shadow 0.3s ease;
        }
        #downloadAllGreasyforkScriptsUnified:hover:not(:disabled) {
            background-color: #45a049;
            box-shadow: 0 6px 12px rgba(0,0,0,0.4);
        }
        #downloadAllGreasyforkScriptsUnified:active:not(:disabled) {
            transform: translateY(2px);
        }
        #downloadAllGreasyforkScriptsUnified:disabled {
            background-color: #cccccc !important;
            cursor: not-allowed !important;
            box-shadow: none !important;
            transform: translateY(0) !important;
        }

        /* Individual/Library Download Button Styles */
        .GF-DSB__script-download-button-unified {
            position: relative;
            padding: 8px 22px;
            cursor: pointer;
            border: none;
            background: #0F750F;
            transition: box-shadow 0.2s;
            font-size: 15px;
            line-height: 1.5;
            font-weight: 700;
            color: #fff;
            border-radius: 3px;
            display: inline-block;
            margin-right: 5px;
            text-decoration: none;
            text-align: center; /* Center text/icon within the button */
            vertical-align: middle; /* Align button vertically */
        }

        .GF-DSB__script-download-button-unified:hover,
        .GF-DSB__script-download-button-unified:focus {
            box-shadow: 0 8px 16px 0 rgb(0 0 0 / 20%), 0 6px 20px 0 rgb(0 0 0 / 19%);
        }

        /* Base icon styling (for both download and loading states) */
        .GF-DSB__script-download-icon-unified {
            position: absolute;
            /* Positioning for the icon within the button */
            top: 4px;
            left: 7px;
        }

        /* Download Icon Specific Styling (Font Awesome) */
        .GF-DSB__script-download-icon--download-unified {
            font-size: 30px; /* Size for Font Awesome icon */
            color: white; /* Color of the icon */
        }

        /* Loading Icon Specific Styling (Spinner) */
        .GF-DSB__script-download-icon--loading-unified {
            font-size: 0 !important; /* Hide the Font Awesome icon */
            text-indent: -9999px; /* Ensure text/icon is not visible */
            overflow: hidden; /* Hide any overflow */

            /* Spinner specific positioning - slightly different from download icon to center the spinner */
            top: 8px;
            left: 11px;

            /* Spinner animation styles */
            border-radius: 50%;
            width: 16px;
            height: 16px;
            border-top: 3px solid rgba(255, 255, 255, 0.2);
            border-right: 3px solid rgba(255, 255, 255, 0.2);
            border-bottom: 3px solid rgba(255, 255, 255, 0.2);
            border-left: 3px solid #ffffff;
            transform: translateZ(0);
            animation: GF-DSB__script-download-loading-icon-unified 1.1s infinite linear;
        }

        @keyframes GF-DSB__script-download-loading-icon-unified {
            0% {
                transform: rotate(0deg);
            }
            100% {
                transform: rotate(360deg);
            }
        }

        .GF-DSB__library-download-button-unified {
            transition: box-shadow 0.2s;
        }

        .GF-DSB__library-download-button--loading-unified {
            animation: GF-DSB__loading-text-unified 1s infinite linear;
        }

        @keyframes GF-DSB__loading-text-unified {
            50% {
                opacity: 0.4;
            }
        }

        /* Estilos adicionales para perfiles de usuario */
        #user-profile-sort {
            font-family: inherit;
        }

        #user-profile-sort ul {
            align-items: center;
        }

        #user-profile-sort a:hover {
            color: #0056b3 !important;
            text-decoration: underline;
        }
    `);
}

// ========== Block: Mass Download ==========

function initMassDownload() {
    console.log('[Greasyfork Unified Downloader & Sorter] Initializing Mass Download feature.');
    addDownloadAllButton();
}

// Function to add the floating download all button
function addDownloadAllButton() {
    if (document.getElementById('downloadAllGreasyforkScriptsUnified')) {
        console.log('[Greasyfork Unified Downloader & Sorter] Mass download button already exists, skipping.');
        return;
    }

    const downloadButton = document.createElement('button');
    downloadButton.textContent = 'Descargar Todos los Scripts';
    downloadButton.id = 'downloadAllGreasyforkScriptsUnified';

    document.body.appendChild(downloadButton);
    console.log('[Greasyfork Unified Downloader & Sorter] Mass download button added to DOM.');

    downloadButton.addEventListener('click', downloadAllScriptsMass);
}

async function downloadAllScriptsMass() {
    const scriptListItems = document.querySelectorAll('li[data-code-url]');

    console.log(`[Greasyfork Unified Downloader & Sorter] Found ${scriptListItems.length} potential scripts for mass download.`);

    if (scriptListItems.length === 0) {
        alert('No se encontraron scripts para descarga masiva. Asegúrate de estar en una página de listado de scripts o perfil de usuario con scripts públicos.');
        console.error('[Greasyfork Unified Downloader & Sorter] No <li> elements with data-code-url found for mass download.');
        return;
    }

    const downloadQueue = [];
    scriptListItems.forEach((li) => {
        const url = li.getAttribute('data-code-url');
        if (!url) {
            console.warn(`[Greasyfork Unified Downloader & Sorter] Element <li> without data-code-url:`, li);
            return;
        }

        let initialFilename = url.substring(url.lastIndexOf('/') + 1);
        const finalFilename = sanitizeFilename(initialFilename);

        downloadQueue.push({ url, filename: finalFilename });
    });

    const totalScripts = downloadQueue.length;
    let downloadedCount = 0;

    alert(`Se encontraron ${totalScripts} scripts para descargar. Iniciando descarga de cada uno...`);

    const downloadButton = document.getElementById('downloadAllGreasyforkScriptsUnified');
    if (downloadButton) {
        downloadButton.disabled = true;
        downloadButton.textContent = `Descargando 0/${totalScripts}...`;
    }

    const delayMs = 1200; // 1.2 seconds between each download

    for (const { url, filename } of downloadQueue) {
        console.log(`[Greasyfork Unified Downloader & Sorter] Attempting to download: "${filename}" from "${url}"`);
        try {
            const urlsToTry = [convertScriptHrefToAnOldFormat(url), url];
            await downloadFileRobustly({ urls: urlsToTry, filename: filename });
            downloadedCount++;
            if (downloadButton) {
                downloadButton.textContent = `Descargando ${downloadedCount}/${totalScripts}...`;
            }
            console.log(`[Greasyfork Unified Downloader & Sorter] Successfully downloaded: "${filename}"`);
        } catch (error) {
            console.error(`[Greasyfork Unified Downloader & Sorter] ERROR downloading "${filename}":`, error);
        }
        if (downloadedCount < totalScripts) {
            await new Promise(resolve => setTimeout(resolve, delayMs));
        }
    }

    setTimeout(() => {
        alert(`Se completó el intento de descarga de ${downloadedCount} de ${totalScripts} scripts. Revise las descargas de su navegador y la consola (F12) para cualquier error.`);

        if (downloadButton) {
            downloadButton.disabled = false;
            downloadButton.textContent = 'Descargar Todos los Scripts';
        }
    }, 1000);
}

// ========== Block: Individual Download Button ==========

const i18n = {
    download: 'download',
    downloadWithoutInstalling: 'downloadWithoutInstalling',
    failedToDownload: 'failedToDownload',
};

const translate = (function() {
    const userLang = location.pathname.split('/')[1];
    const strings = {
        'en': {
            [i18n.download]: 'Download ⇩',
            [i18n.downloadWithoutInstalling]: 'Download without installing',
            [i18n.failedToDownload]:
                'Failed to download the script. There is might be more info in the browser console',
        },
        'ru': {
            [i18n.download]: 'Скачать ⇩',
            [i18n.downloadWithoutInstalling]: 'Скачать no устанавливая',
            [i18n.failedToDownload]:
                'Не удалось скачать скрипт. Больше информации может быть в консоли браузера',
        },
        'zh-CN': {
            [i18n.download]: '下载 ⇩',
            [i18n.downloadWithoutInstalling]: '下载此脚本',
            [i18n.failedToDownload]: '无法下载此脚本',
        },
        'es': {
            [i18n.download]: 'Descargar ⇩',
            [i18n.downloadWithoutInstalling]: 'Descargar sin instalar',
            [i18n.failedToDownload]:
                'No se pudo descargar el script. Puede haber más información en la consola del navegador',
        }
    };

    return id => (strings[userLang] || strings.en)[id] || strings.en[id];
}());

function initIndividualDownloadButton() {
    console.log('[Greasyfork Unified Downloader & Sorter] Initializing Individual Download Button feature.');
    const installArea = document.querySelector('div#install-area');
    const installBtns = installArea?.querySelectorAll(':scope > a.install-link');
    const installHelpLinks = document.querySelectorAll('a.install-help-link');
    const suggestion = document.querySelector('div#script-feedback-suggestion');
    const libraryRequire = document.querySelector('div#script-content > p > code');
    const libraryVersion = document.querySelector(
        '#script-stats > dd.script-show-version > span'
    );

    if (
        installArea &&
        (installBtns.length > 0) &&
        (installBtns.length === installHelpLinks.length)
    ) {
        for (let i = 0; i < installBtns.length; i++) {
            mountScriptDownloadButton(installBtns[i], installArea, installHelpLinks[i]);
        }
    }
    else if (suggestion && libraryRequire) {
        mountLibraryDownloadButton(suggestion, libraryRequire, libraryVersion);
    }
}

function mountScriptDownloadButton(
    installBtn,
    installArea,
    installHelpLink,
) {
    if (!installBtn.href) {
        console.error('[Greasyfork Unified Downloader & Sorter] Script href is not found for individual button.');
        return;
    }

    if (installArea.querySelector('.GF-DSB__script-download-button-unified')) {
        console.log('[Greasyfork Unified Downloader & Sorter] Individual script download button already exists, skipping.');
        return;
    }

    const b = document.createElement('a');
    const bIcon = document.createElement('i');

    b.href = '#';
    b.title = translate(i18n.downloadWithoutInstalling);
    b.draggable = false;
    b.className = 'GF-DSB__script-download-button-unified';

    bIcon.className =
        'fas fa-download GF-DSB__script-download-icon-unified GF-DSB__script-download-icon--download-unified';

    installHelpLink.style.position = 'relative';

    b.appendChild(bIcon);
    installArea.insertBefore(b, installHelpLink);

    let isFetchingAllowed = true;

    async function clicksHandler(ev) {
        ev.preventDefault();

        setTimeout(() => b === document.activeElement && b.blur(), 250);

        if (isFetchingAllowed === false) return;

        isFetchingAllowed = false;
        bIcon.className =
            'GF-DSB__script-download-icon-unified GF-DSB__script-download-icon--loading-unified';

        try {
            let scriptName = installBtn.dataset.scriptName;

            if (installBtn.dataset.scriptVersion) {
                scriptName += ` ${installBtn.dataset.scriptVersion}`;
            }

            const fileExt = `.${installBtn.dataset.installFormat || 'user.js'}`;
            const filename = sanitizeFilename(`${scriptName}${fileExt}`);
            const href = installBtn.href;

            const urlsToTry = [convertScriptHrefToAnOldFormat(href), href];

            await downloadFileRobustly({
                urls: urlsToTry,
                filename: filename,
            });
        } catch (e) {
            console.error('[Greasyfork Unified Downloader & Sorter] Error during individual script download:', e);
            alert(`${translate(i18n.failedToDownload)}: \n${e.message || e}`);
        } finally {
            setTimeout(() => {
                isFetchingAllowed = true;
                bIcon.className =
                    'fas fa-download GF-DSB__script-download-icon-unified GF-DSB__script-download-icon--download-unified';
            }, 300);
        }
    }

    b.addEventListener('click', clicksHandler);
    b.addEventListener('auxclick', e => e.button === 1 && clicksHandler(e));
}

function mountLibraryDownloadButton(suggestion, libraryRequire, libraryVersion) {
    let match = libraryRequire.innerText.match(
        /\/\/ @require (https:\/\/.+\/scripts\/\d+\/\d+\/(.*)\.js)/
    );

    if (!match) {
        console.error('[Greasyfork Unified Downloader & Sorter] Library href regex match failed.');
        return;
    }

    let libraryHref = match[1];
    let libraryName = decodeURIComponent(match[2]);

    if (!libraryHref) {
        console.error('[Greasyfork Unified Downloader & Sorter] Library href is not found for library button.');
        return;
    }

    if (suggestion.querySelector('.GF-DSB__library-download-button-unified')) {
        console.log('[Greasyfork Unified Downloader & Sorter] Library download button already exists, skipping.');
        return;
    }

    if (libraryVersion?.innerText) libraryName += ` ${libraryVersion.innerText}`;

    const b = document.createElement('a');

    b.href = '#';
    b.draggable = false;
    b.innerText = translate(i18n.download);
    b.className = 'GF-DSB__library-download-button-unified';

    suggestion.appendChild(b);

    let isFetchingAllowed = true;

    async function clicksHandler(ev) {
        ev.preventDefault();

        setTimeout(() => b === document.activeElement && b.blur(), 250);

        if (isFetchingAllowed === false) return;

        isFetchingAllowed = false;
        b.className =
            'GF-DSB__library-download-button-unified GF-DSB__library-download-button--loading-unified';

        try {
            const fileExt = '.js';
            const filename = sanitizeFilename(`${libraryName}${fileExt}`);

            const urlsToTry = [convertScriptHrefToAnOldFormat(libraryHref), libraryHref];

            await downloadFileRobustly({
                urls: urlsToTry,
                filename: filename,
            });
        } catch (e) {
            console.error('[Greasyfork Unified Downloader & Sorter] Error during library download:', e);
            alert(`${translate(i18n.failedToDownload)}: \n${e.message || e}`);
        } finally {
            setTimeout(() => {
                isFetchingAllowed = true;
                b.className = 'GF-DSB__library-download-button-unified';
            }, 300);
        }
    }

    b.addEventListener('click', clicksHandler);
    b.addEventListener('auxclick', e => e.button === 1 && clicksHandler(e));
}

// ========== Block: Order Scripts by Older ==========

function getList() {
    let list = document.querySelector('#browse-script-list');

    if (!list && isUserProfilePage()) {
        const firstScript = document.querySelector('li[data-code-url]');
        if (firstScript) {
            list = firstScript.parentElement;
        }
    }

    return list;
}

function getOptionBar() {
    let bar = document.querySelector('#script-list-sort ul');

    if (!bar && isUserProfilePage()) {
        bar = document.querySelector('#user-profile-sort ul');
    }

    return bar;
}

function addOldestButton() {
    const bar = getOptionBar();

    if (!bar && isUserProfilePage()) {
        createUserProfileSortBar();
        const newBar = getOptionBar(); // Try to get the newly created bar
        if (newBar) {
            addOldestButtonToBar(newBar);
        }
        return;
    }

    if (!bar || bar.querySelector('.sort-oldest-added')) {
        console.log('[Greasyfork Unified Downloader & Sorter] "Más antiguo" button already exists or no bar found, skipping.');
        return;
    }

    addOldestButtonToBar(bar);
}

function addOldestButtonToBar(barElement) {
    if (barElement.querySelector('.sort-oldest-added')) {
        console.log('[Greasyfork Unified Downloader & Sorter] "Más antiguo" button already exists in this bar, skipping.');
        return;
    }

    const li = document.createElement('li');
    li.className = 'list-option sort-oldest-added';

    const a = document.createElement('a');
    a.href = "#";
    a.textContent = "Más antiguo";
    a.style.cssText = "font-weight: bold !important; color: #007bff; text-decoration: none;"; // Added !important
    a.onclick = function(e){
        e.preventDefault();
        sortByOldest();
    };
    li.appendChild(a);
    barElement.appendChild(li);
    console.log('[Greasyfork Unified Downloader & Sorter] "Más antiguo" button added.');
}

function createUserProfileSortBar() {
    if (document.getElementById('user-profile-sort')) {
        console.log('[Greasyfork Unified Downloader & Sorter] User profile sort bar already exists, skipping creation.');
        return;
    }

    let insertBeforeElement = document.querySelector('ul.list-options') ||
        document.querySelector('div.scripts-list') ||
        document.querySelector('li[data-code-url]')?.parentElement ||
        document.querySelector('article.script')?.parentElement;

    let targetParent = null;

    if (insertBeforeElement) {
        targetParent = insertBeforeElement.parentElement;
    } else {
        targetParent = document.querySelector('.user-content') ||
                       document.querySelector('main');
    }

    if (!targetParent) {
        console.warn('[Greasyfork Unified Downloader & Sorter] Could not find a suitable target parent to insert user profile sort bar.');
        return;
    }

    const sortBar = document.createElement('div');
    sortBar.id = 'user-profile-sort';
    sortBar.style.cssText = `
        margin: 10px 0;
        padding: 10px;
        background: #f8f9fa;
        border-radius: 4px;
        border: 1px solid #dee2e6;
        display: flex;
        justify-content: flex-start;
        flex-wrap: wrap;
    `;

    const sortList = document.createElement('ul');
    sortList.style.cssText = `
        list-style: none;
        margin: 0;
        padding: 0;
        display: flex;
        gap: 15px;
        flex-wrap: wrap;
    `;
    sortBar.appendChild(sortList);

    if (insertBeforeElement && targetParent) {
        targetParent.insertBefore(sortBar, insertBeforeElement);
    } else {
        targetParent.appendChild(sortBar);
    }
    console.log('[Greasyfork Unified Downloader & Sorter] User profile sort bar created.');
}

function sortByOldest() {
    const list = getList();
    if (!list) {
        console.warn('[Greasyfork Unified Downloader & Sorter] No script list found to sort.');
        return;
    }
    const items = Array.from(list.querySelectorAll('li[data-script-created-date]'));

    if (items.length === 0) {
        alert('No se encontraron scripts con fecha de creación para ordenar.');
        console.warn('[Greasyfork Unified Downloader & Sorter] No sortable items found.');
        return;
    }

    items.sort(function (a, b) {
        const da = new Date(a.getAttribute('data-script-created-date'));
        const db = new Date(b.getAttribute('data-script-created-date'));
        return da - db;
    });
    while (list.firstChild) list.removeChild(list.firstChild);
    for (const it of items) list.appendChild(it);
    console.log('[Greasyfork Unified Downloader & Sorter] Scripts sorted by oldest.');
}

function initOrderByOldest() {
    console.log('[Greasyfork Unified Downloader & Sorter] Initializing "Order by Older" feature.');
    addOldestButton();
}

// ========== Helper functions to determine the current page type ==========

function isUserProfilePage() {
    const path = location.pathname;
    const userProfilePattern = /\/(?:[a-z]{2}\/)?users\/\d+-/;
    return userProfilePattern.test(path);
}

function isScriptListPage() {
    // Check for standard list page elements
    const hasGeneralScriptList = document.querySelector('#browse-script-list') &&
                                 document.querySelector('#script-list-sort ul');

    // Check for user profile page URL pattern AND presence of any script list items
    const hasScriptsInUserContext = isUserProfilePage() && (
        document.querySelector('li[data-code-url]') ||
        document.querySelector('.script-list') ||
        document.querySelector('div.scripts-list') ||
        document.querySelector('ul.list-options')
    );

    // Fallback: check if there's *any* installable script item on the page, regardless of container
    const anyDownloadableScriptItem = document.querySelectorAll('li[data-code-url]').length > 0;

    const result = hasGeneralScriptList || hasScriptsInUserContext || anyDownloadableScriptItem;
    console.log(`[Greasyfork Unified Downloader & Sorter] isScriptListPage returns: ${result}`);
    return result;
}

function isIndividualScriptPage() {
    const path = location.pathname;
    const parts = path.split('/').filter(Boolean);

    if (parts.length >= 2 && parts.includes('scripts')) {
        const scriptsIndex = parts.indexOf('scripts');
        const potentialIdPart = parts[scriptsIndex + 1];

        return potentialIdPart && /^\d+/.test(potentialIdPart) &&
               !path.includes('/by-site/') &&
               !path.includes('/new') &&
               !path.includes('/discussions') &&
               !path.includes('/versions') &&
               !path.includes('/users/');
    }
    return false;
}

// ========== Global Initialization with MutationObserver ==========
function initializeUnifiedScript() {
    console.log('[Greasyfork Unified Downloader & Sorter] Initializing unified script...');
    ensureFontAwesome();
    addUnifiedStyles();

    let listFeaturesInitialized = false;
    let individualFeatureInitialized = false;
    let observer = null;
    let debounceTimeoutId = null;

    function tryInitFeatures() {
        if (debounceTimeoutId) {
            clearTimeout(debounceTimeoutId);
            debounceTimeoutId = null;
        }

        if (!individualFeatureInitialized && isIndividualScriptPage()) {
            console.log('[Greasyfork Unified Downloader & Sorter] Individual script page detected, initializing individual features.');
            initIndividualDownloadButton();
            individualFeatureInitialized = true;
        }

        if (!listFeaturesInitialized && isScriptListPage()) {
            console.log('[Greasyfork Unified Downloader & Sorter] Script list page (or user profile with scripts) detected, initializing list features.');
            initMassDownload();
            initOrderByOldest();
            listFeaturesInitialized = true;
        }

        if (individualFeatureInitialized || listFeaturesInitialized) {
             if (observer) {
                observer.disconnect();
                console.log('[Greasyfork Unified Downloader & Sorter] MutationObserver disconnected as features initialized.');
             }
        }
    }

    setTimeout(tryInitFeatures, 100); // Initial quick check

    const observerTarget = document.body;
    if (observerTarget) {
        observer = new MutationObserver((mutationsList, observerInstance) => {
            if (listFeaturesInitialized && individualFeatureInitialized) {
                observerInstance.disconnect();
                return;
            }

            // Debounce the call to tryInitFeatures to avoid excessive re-runs
            if (!debounceTimeoutId) {
                debounceTimeoutId = setTimeout(tryInitFeatures, 200); // Wait 200ms after last mutation
            }
        });

        observer.observe(observerTarget, { childList: true, subtree: true });
        console.log('[Greasyfork Unified Downloader & Sorter] MutationObserver started for dynamic content.');
    } else {
        console.warn('[Greasyfork Unified Downloader & Sorter] Could not find observer target (document.body). Dynamic features might not initialize.');
    }
}

if (document.readyState === 'loading') {
    window.addEventListener('DOMContentLoaded', initializeUnifiedScript);
} else {
    initializeUnifiedScript();
}
})();
长期地址
遇到问题?请前往 GitHub 提 Issues。