Greasy Fork | Quick Copy/Download/Install Script Buttons

Adds Copy, Download, and Install buttons to every search result tile and script page header.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Greasy Fork | Quick Copy/Download/Install Script Buttons
// @namespace    https://greasyfork.org/en/users/1462137-piknockyou
// @version      1.2
// @author       Piknockyou (vibe-coded)
// @license      AGPL-3.0
// @description  Adds Copy, Download, and Install buttons to every search result tile and script page header.
// @match        https://greasyfork.org/*
// @match        https://*.greasyfork.org/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=greasyfork.org
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @connect      update.greasyfork.org
// @connect      greasyfork.org
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // ==================== STYLES ====================
    GM_addStyle(`
        .gf-quick-actions {
            position: absolute;
            top: 8px;
            right: 8px;
            display: flex;
            gap: 6px;
            z-index: 100;
            opacity: 1;
        }

        li[data-script-id] {
            position: relative !important;
        }

        .gf-quick-actions-page {
            position: static;
            display: inline-flex;
            gap: 6px;
            margin-left: 12px;
            opacity: 1;
            vertical-align: middle;
        }

        .gf-action-btn {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 32px;
            height: 32px;
            border: 1px solid #ccc;
            border-radius: 6px;
            background: #fff;
            cursor: pointer;
            transition: all 0.2s ease;
            padding: 0;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }

        .gf-action-btn:hover {
            background: #f0f0f0;
            border-color: #999;
            transform: translateY(-1px);
            box-shadow: 0 2px 6px rgba(0,0,0,0.15);
        }

        .gf-action-btn:active {
            transform: translateY(0);
            box-shadow: 0 1px 2px rgba(0,0,0,0.1);
        }

        .gf-action-btn svg {
            width: 16px;
            height: 16px;
            fill: currentColor;
            color: #555;
        }

        .gf-action-btn:hover svg {
            color: #333;
        }

        .gf-action-btn.gf-copy-btn:hover {
            border-color: #4a90d9;
            background: #e8f4fc;
        }
        .gf-action-btn.gf-copy-btn:hover svg {
            color: #4a90d9;
        }

        .gf-action-btn.gf-download-btn:hover {
            border-color: #5cb85c;
            background: #e8f5e8;
        }
        .gf-action-btn.gf-download-btn:hover svg {
            color: #5cb85c;
        }

        .gf-action-btn.gf-install-btn:hover {
            border-color: #d9534f;
            background: #fce8e8;
        }
        .gf-action-btn.gf-install-btn:hover svg {
            color: #d9534f;
        }

        .gf-action-btn.gf-success {
            background: #d4edda !important;
            border-color: #28a745 !important;
        }
        .gf-action-btn.gf-success svg {
            color: #28a745 !important;
        }

        .gf-action-btn.gf-loading {
            pointer-events: none;
            opacity: 0.7;
        }

        .gf-action-btn.gf-loading svg {
            animation: gf-spin 1s linear infinite;
        }

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

        .gf-tooltip {
            position: absolute;
            bottom: 100%;
            left: 50%;
            transform: translateX(-50%);
            padding: 4px 8px;
            background: #333;
            color: #fff;
            font-size: 11px;
            border-radius: 4px;
            white-space: nowrap;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.2s ease;
            margin-bottom: 4px;
            z-index: 1000;
        }

        .gf-action-btn:hover .gf-tooltip {
            opacity: 1;
        }

        /* Dark mode support */
        @media (prefers-color-scheme: dark) {
            .gf-action-btn {
                background: #2d2d2d;
                border-color: #444;
            }
            .gf-action-btn svg {
                color: #bbb;
            }
            .gf-action-btn:hover {
                background: #3d3d3d;
                border-color: #666;
            }
            .gf-action-btn:hover svg {
                color: #fff;
            }
            .gf-action-btn.gf-copy-btn:hover {
                background: #1e3a5f;
                border-color: #4a90d9;
            }
            .gf-action-btn.gf-download-btn:hover {
                background: #1e4620;
                border-color: #5cb85c;
            }
            .gf-action-btn.gf-install-btn:hover {
                background: #5f1e1e;
                border-color: #d9534f;
            }
        }

        /* Greasyfork dark theme */
        body.dark .gf-action-btn,
        html[data-theme="dark"] .gf-action-btn {
            background: #2d2d2d;
            border-color: #444;
        }
        body.dark .gf-action-btn svg,
        html[data-theme="dark"] .gf-action-btn svg {
            color: #bbb;
        }
        body.dark .gf-action-btn:hover,
        html[data-theme="dark"] .gf-action-btn:hover {
            background: #3d3d3d;
            border-color: #666;
        }
    `);

    // ==================== CONSTANTS ====================
    const ICONS = {
        copy: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
            <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
        </svg>`,
        download: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
            <polyline points="7 10 12 15 17 10"></polyline>
            <line x1="12" y1="15" x2="12" y2="3"></line>
        </svg>`,
        install: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <path d="M12 5v14M5 12l7 7 7-7"/>
            <rect x="3" y="19" width="18" height="2" rx="1"/>
        </svg>`,
        check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <polyline points="20 6 9 17 4 12"></polyline>
        </svg>`,
        spinner: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <circle cx="12" cy="12" r="10" stroke-opacity="0.25"/>
            <path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/>
        </svg>`
    };

    const BUTTON_CONFIG = {
        copy: {
            className: 'gf-copy-btn',
            tooltip: 'Copy code to clipboard',
            successMessage: 'Copied!'
        },
        download: {
            className: 'gf-download-btn',
            tooltip: 'Download as .user.js file',
            successMessage: 'Downloaded!'
        },
        install: {
            className: 'gf-install-btn',
            tooltip: 'Install userscript',
            successMessage: null // Install redirects, no success state
        }
    };

    // ==================== UTILITY FUNCTIONS ====================

    /**
     * Fetch script code from URL
     */
    function fetchScriptCode(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(response.responseText);
                    } else {
                        reject(new Error(`HTTP ${response.status}`));
                    }
                },
                onerror: function(error) {
                    reject(error);
                }
            });
        });
    }

    /**
     * Copy text to clipboard
     */
    function copyToClipboard(text) {
        if (typeof GM_setClipboard === 'function') {
            GM_setClipboard(text, 'text');
            return true;
        }
        // Fallback
        const textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.style.position = 'fixed';
        textarea.style.opacity = '0';
        document.body.appendChild(textarea);
        textarea.select();
        const success = document.execCommand('copy');
        document.body.removeChild(textarea);
        return success;
    }

    /**
     * Trigger file download
     */
    function downloadFile(content, filename) {
        const blob = new Blob([content], { type: 'application/javascript' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    /**
     * Extract filename from URL
     */
    function getFilenameFromUrl(url) {
        try {
            const pathname = new URL(url).pathname;
            const filename = decodeURIComponent(pathname.split('/').pop());
            return filename.endsWith('.user.js') ? filename : filename + '.user.js';
        } catch {
            return 'script.user.js';
        }
    }

    /**
     * Show success feedback on button
     */
    function showSuccess(button, message) {
        const originalHTML = button.innerHTML;
        const tooltip = button.querySelector('.gf-tooltip');
        const originalTooltip = tooltip ? tooltip.textContent : '';

        button.classList.add('gf-success');
        const successTooltip = message || 'Success!';
        button.innerHTML = ICONS.check + `<span class="gf-tooltip">${successTooltip}</span>`;

        setTimeout(() => {
            button.classList.remove('gf-success');
            button.innerHTML = originalHTML;
        }, 1500);
    }

    /**
     * Create a single action button
     */
    function createActionButton(type, codeUrl, scriptName) {
        const config = BUTTON_CONFIG[type];
        const button = document.createElement('button');
        button.className = `gf-action-btn ${config.className}`;
        button.innerHTML = ICONS[type] + `<span class="gf-tooltip">${config.tooltip}</span>`;
        button.title = config.tooltip;

        button.addEventListener('click', async (e) => {
            e.preventDefault();
            e.stopPropagation();

            if (type === 'install') {
                // Install: just navigate to the .user.js URL
                window.location.href = codeUrl;
                return;
            }

            // For copy and download, fetch the code
            const container = button.closest('.gf-quick-actions');
            setLoading(button, true);
            if (container) container.classList.add('gf-loading');

            try {
                const code = await fetchScriptCode(codeUrl);

                if (type === 'copy') {
                    copyToClipboard(code);
                    setLoading(button, false);
                    showSuccess(button, config.successMessage);
                } else if (type === 'download') {
                    const filename = getFilenameFromUrl(codeUrl);
                    downloadFile(code, filename);
                    setLoading(button, false);
                    showSuccess(button, config.successMessage);
                }
            } catch (error) {
                console.error(`Failed to ${type}:`, error);
                setLoading(button, false);
                alert(`Failed to ${type} script`);
            }

            if (container) container.classList.remove('gf-loading');
        });

        return button;
    }

    /**
     * Show loading state on button
     */
    function setLoading(button, loading) {
        if (loading) {
            button.classList.add('gf-loading');
            button.dataset.originalIcon = button.querySelector('svg').outerHTML;
            button.querySelector('svg').outerHTML = ICONS.spinner;
        } else {
            button.classList.remove('gf-loading');
            if (button.dataset.originalIcon) {
                button.querySelector('svg').outerHTML = button.dataset.originalIcon;
                delete button.dataset.originalIcon;
            }
        }
    }

    // ==================== BUTTON CREATION ====================

    /**
     * Create action buttons container
     */
    function createActionButtons(codeUrl, scriptName, isPageHeader = false) {
        const container = document.createElement('div');
        container.className = isPageHeader ? 'gf-quick-actions gf-quick-actions-page' : 'gf-quick-actions';

        // Create all three buttons
        container.appendChild(createActionButton('copy', codeUrl, scriptName));
        container.appendChild(createActionButton('download', codeUrl, scriptName));
        container.appendChild(createActionButton('install', codeUrl, scriptName));

        return container;
    }

    // ==================== INJECTION LOGIC ====================

    /**
     * Inject buttons into search result tiles
     */
    function injectSearchResultButtons() {
        const scriptItems = document.querySelectorAll('li[data-script-id][data-code-url]');

        scriptItems.forEach(item => {
            // Skip if already processed
            if (item.querySelector('.gf-quick-actions')) return;

            const codeUrl = item.getAttribute('data-code-url');
            const scriptName = item.getAttribute('data-script-name') || 'script';

            if (!codeUrl) return;

            const buttons = createActionButtons(codeUrl, scriptName, false);
            item.appendChild(buttons);
        });
    }

    /**
     * Inject buttons into script detail page header
     */
    function injectPageHeaderButtons() {
        // Check if we're on a script page
        const scriptInfo = document.querySelector('#script-info');
        if (!scriptInfo) return;

        // Skip if already processed
        if (scriptInfo.querySelector('.gf-quick-actions-page')) return;

        // Try to find the code URL from multiple sources
        let codeUrl = null;
        let scriptId = null;

        // Method 1: From install link
        const installLink = document.querySelector('a.install-link[href*=".user.js"]');
        if (installLink) {
            codeUrl = installLink.href;
        }

        // Method 2: Extract from URL and construct code URL
        if (!codeUrl) {
            const urlMatch = window.location.pathname.match(/\/scripts\/(\d+)/);
            if (urlMatch) {
                scriptId = urlMatch[1];
                // Try to find script name from header
                const scriptName = document.querySelector('#script-info header h2')?.textContent?.trim();
                if (scriptName) {
                    const slug = scriptName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
                    codeUrl = `https://update.greasyfork.org/scripts/${scriptId}/${encodeURIComponent(scriptName.replace(/[^\w\s-]/g, '').replace(/\s+/g, ' '))}.user.js`;
                }
            }
        }

        // Method 3: Look for any .user.js link in the page
        if (!codeUrl) {
            const anyUserJsLink = document.querySelector('a[href*=".user.js"]');
            if (anyUserJsLink) {
                codeUrl = anyUserJsLink.href;
            }
        }

        if (!codeUrl) return;

        const scriptName = document.querySelector('#script-info header h2')?.textContent?.trim() || 'script';

        // Find the header to insert buttons
        const header = scriptInfo.querySelector('header');
        if (!header) return;

        const buttons = createActionButtons(codeUrl, scriptName, true);

        // Adjust header styles and insert buttons
        header.style.position = 'relative';
        header.insertBefore(buttons, header.firstChild);
        buttons.style.position = 'absolute';
        buttons.style.top = '0';
        buttons.style.right = '0';
    }

    /**
     * Inject buttons on code view page
     */
    function injectCodePageButtons() {
        // Check if we're on a code page
        if (!window.location.pathname.includes('/code')) return;

        const codeContainer = document.querySelector('.code-container');
        if (!codeContainer) return;

        // Skip if already processed
        if (codeContainer.querySelector('.gf-quick-actions-page')) return;

        // Find the install link
        const installLink = document.querySelector('a.install-link[href*=".user.js"]');
        if (!installLink) return;

        const codeUrl = installLink.href;
        const scriptName = document.querySelector('#script-info header h2')?.textContent?.trim() || 'script';

        const buttons = createActionButtons(codeUrl, scriptName, true);
        buttons.style.marginBottom = '12px';

        codeContainer.insertBefore(buttons, codeContainer.firstChild);
    }

    // ==================== INITIALIZATION ====================

    function init() {
        // Initial injection
        injectSearchResultButtons();
        injectPageHeaderButtons();
        injectCodePageButtons();

        // Watch for dynamic content (pagination, infinite scroll, etc.)
        const observer = new MutationObserver((mutations) => {
            let shouldCheck = false;

            for (const mutation of mutations) {
                if (mutation.addedNodes.length > 0) {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            if (node.matches?.('li[data-script-id]') ||
                                node.querySelector?.('li[data-script-id]')) {
                                shouldCheck = true;
                                break;
                            }
                        }
                    }
                }
                if (shouldCheck) break;
            }

            if (shouldCheck) {
                // Debounce
                clearTimeout(observer.timeout);
                observer.timeout = setTimeout(() => {
                    injectSearchResultButtons();
                }, 100);
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        // Handle SPA navigation
        let lastUrl = location.href;
        const urlObserver = new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                setTimeout(() => {
                    injectSearchResultButtons();
                    injectPageHeaderButtons();
                    injectCodePageButtons();
                }, 500);
            }
        });

        urlObserver.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    // Start when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();