Greasy Fork

Greasy Fork is available in English.

AnnaUploader (Roblox Multi-File Uploader)

allows you to upload multiple T-Shirts/Decals easily with AnnaUploader

目前为 2025-05-04 提交的版本。查看 最新版本

// ==UserScript==
// @name         AnnaUploader (Roblox Multi-File Uploader)
// @namespace    https://www.guilded.gg/u/AnnaBlox
// @version      4.6
// @description  allows you to upload multiple T-Shirts/Decals easily with AnnaUploader
// @match        https://create.roblox.com/*
// @match        https://www.roblox.com/users/*/profile*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const ROBLOX_UPLOAD_URL       = "https://apis.roblox.com/assets/user-auth/v1/assets";
    const ASSET_TYPE_TSHIRT       = 11;
    const ASSET_TYPE_DECAL        = 13;
    const UPLOAD_RETRY_DELAY      = 0;
    const MAX_RETRIES             = 150;
    const FORCED_NAME_ON_MOD      = "Uploaded Using AnnaUploader";

    // Load saved preferences:
    let USER_ID        = GM_getValue('userId', null);
    let MAX_CONCURRENT_UPLOADS  = GM_getValue('maxUploads', 20);

    let uploadQueue    = [];
    let activeUploads  = 0;
    let csrfToken      = null;
    let batchTotal     = 0;
    let completedCount = 0;
    let statusElement  = null;

    async function fetchCSRFToken() {
        try {
            const response = await fetch(ROBLOX_UPLOAD_URL, {
                method: 'POST',
                credentials: 'include',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({})
            });
            if (response.status === 403) {
                const token = response.headers.get('x-csrf-token');
                if (token) {
                    console.log('[CSRF] Token fetched:', token);
                    csrfToken = token;
                    return token;
                }
            }
            throw new Error('Failed to fetch CSRF token');
        } catch (error) {
            console.error('[CSRF] Fetch error:', error);
            throw error;
        }
    }

    async function uploadFile(file, assetType, retries = 0, forceName = false) {
        if (!csrfToken) {
            await fetchCSRFToken();
        }

        const displayName = forceName
            ? FORCED_NAME_ON_MOD
            : file.name.split('.')[0];

        const formData = new FormData();
        formData.append("fileContent", file, file.name);
        formData.append("request", JSON.stringify({
            displayName: displayName,
            description: "Uploaded Using AnnaUploader",
            assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal",
            creationContext: {
                creator: { userId: USER_ID },
                expectedPrice: 0
            }
        }));

        try {
            const response = await fetch(ROBLOX_UPLOAD_URL, {
                method: "POST",
                credentials: "include",
                headers: { "x-csrf-token": csrfToken },
                body: formData
            });

            if (response.ok) {
                console.log(`✅ Uploaded (${assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal"}): ${file.name}`);
                return;
            }

            const status = response.status;
            const text = await response.text();
            let json;
            try { json = JSON.parse(text); } catch {}

            const isModeratedName      = status === 400 && json?.code === "INVALID_ARGUMENT" && json?.message?.includes("fully moderated");
            const isInvalidNameLength  = status === 400 && json?.code === "INVALID_ARGUMENT" && json?.message?.includes("name length is invalid");

            if ((isModeratedName || isInvalidNameLength) && retries < MAX_RETRIES && !forceName) {
                console.warn(`⚠️ Invalid name for ${file.name}: retrying with forced name...`);
                await new Promise(res => setTimeout(res, UPLOAD_RETRY_DELAY));
                return await uploadFile(file, assetType, retries + 1, true);
            }

            if (status === 403 && retries < MAX_RETRIES) {
                console.warn(`🔄 CSRF expired for ${file.name}: fetching new token and retrying...`);
                csrfToken = null;
                await new Promise(res => setTimeout(res, UPLOAD_RETRY_DELAY));
                return await uploadFile(file, assetType, retries + 1, forceName);
            }

            console.error(`❌ Upload failed for ${file.name}: [${status}]`, text);
            throw new Error(`Failed to upload ${file.name} after ${retries} retries.`);
        } catch (error) {
            console.error(`Upload error for ${file.name}:`, error);
            throw error;
        }
    }

    function updateStatus() {
        if (!statusElement) return;
        if (batchTotal > 0) {
            statusElement.textContent = `${completedCount} of ${batchTotal} files uploaded successfully`;
        } else {
            statusElement.textContent = '';
        }
    }

    function processUploadQueue() {
        while (activeUploads < MAX_CONCURRENT_UPLOADS && uploadQueue.length > 0) {
            const { file, assetType } = uploadQueue.shift();
            activeUploads++;
            (async () => {
                try {
                    await uploadFile(file, assetType);
                    completedCount++;
                    updateStatus();
                } catch (e) {
                    // ignore individual errors
                } finally {
                    activeUploads--;
                    processUploadQueue();
                }
            })();
        }
    }

    function handleFileSelect(files, assetType, uploadBoth = false) {
        if (!files || files.length === 0) {
            console.warn('No files selected.');
            return;
        }
        batchTotal = uploadBoth ? files.length * 2 : files.length;
        completedCount = 0;
        updateStatus();

        for (let file of files) {
            if (uploadBoth) {
                uploadQueue.push({ file, assetType: ASSET_TYPE_TSHIRT });
                uploadQueue.push({ file, assetType: ASSET_TYPE_DECAL });
                console.log(`Queued (Both): ${file.name}`);
            } else {
                uploadQueue.push({ file, assetType });
                console.log(`Queued (${assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal"}): ${file.name}`);
            }
        }
        processUploadQueue();
    }

    function createUploaderUI() {
        const container = document.createElement('div');
        Object.assign(container.style, {
            position: 'fixed',
            top: '10px',
            right: '10px',
            backgroundColor: '#fff',
            border: '2px solid #000',
            padding: '15px',
            zIndex: '10000',
            borderRadius: '8px',
            boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
            display: 'flex',
            flexDirection: 'column',
            gap: '10px',
            fontFamily: 'Arial, sans-serif',
            width: '240px'
        });

        const closeBtn = document.createElement('button');
        closeBtn.textContent = '×';
        Object.assign(closeBtn.style, {
            position: 'absolute',
            top: '5px',
            right: '8px',
            background: 'transparent',
            border: 'none',
            fontSize: '16px',
            cursor: 'pointer',
            lineHeight: '1'
        });
        closeBtn.title = 'Close uploader';
        closeBtn.addEventListener('click', () => container.remove());
        container.appendChild(closeBtn);

        const title = document.createElement('h3');
        title.textContent = 'AnnaUploader';
        title.style.margin = '0 0 5px 0';
        title.style.fontSize = '16px';
        container.appendChild(title);

        const makeBtn = (text, onClick) => {
            const btn = document.createElement('button');
            btn.textContent = text;
            Object.assign(btn.style, { padding: '8px', cursor: 'pointer' });
            btn.addEventListener('click', onClick);
            return btn;
        };

        // Basic upload buttons
        container.appendChild(makeBtn('Upload T-Shirts', () => {
            const input = document.createElement('input');
            input.type = 'file'; input.accept = 'image/*'; input.multiple = true;
            input.addEventListener('change', e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT));
            input.click();
        }));
        container.appendChild(makeBtn('Upload Decals', () => {
            const input = document.createElement('input');
            input.type = 'file'; input.accept = 'image/*'; input.multiple = true;
            input.addEventListener('change', e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL));
            input.click();
        }));
        container.appendChild(makeBtn('Upload Both', () => {
            const input = document.createElement('input');
            input.type = 'file'; input.accept = 'image/*'; input.multiple = true;
            input.addEventListener('change', e => handleFileSelect(e.target.files, null, true));
            input.click();
        }));

        // Change stored User ID
        container.appendChild(makeBtn('Change ID', () => {
            const input = prompt("Enter your Roblox User ID or Profile URL:", USER_ID || '');
            if (!input) return;
            const urlMatch = input.match(/roblox\.com\/users\/(\d+)\/profile/i);
            let newId = urlMatch ? urlMatch[1] : (!isNaN(input.trim()) ? input.trim() : null);
            if (newId) {
                USER_ID = Number(newId);
                GM_setValue('userId', USER_ID);
                alert(`User ID updated to ${USER_ID}`);
            } else {
                alert("Invalid input. Please enter a numeric ID or a valid profile URL.");
            }
        }));

        // Profile-page shortcut: auto-set User ID
        const profileMatch = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
        if (profileMatch) {
            container.appendChild(makeBtn('Use This Profile as ID', () => {
                const newId = Number(profileMatch[1]);
                USER_ID = newId;
                GM_setValue('userId', USER_ID);
                alert(`User ID set to ${USER_ID} from profile`);
            }));
        }

        // Set max concurrent uploads
        container.appendChild(makeBtn('Set Max Uploads', () => {
            const input = prompt("Enter preferred max concurrent uploads:", MAX_CONCURRENT_UPLOADS);
            if (!input) return;
            const num = parseInt(input.trim(), 10);
            if (!isNaN(num) && num > 0) {
                MAX_CONCURRENT_UPLOADS = num;
                GM_setValue('maxUploads', num);
                alert(`Max concurrent uploads set to ${num}`);
            } else {
                alert("Invalid number. Please enter a positive integer.");
            }
        }));

        const pasteHint = document.createElement('div');
        pasteHint.textContent = 'Paste images (Ctrl+V) to upload—name & type it first!';
        pasteHint.style.fontSize = '12px';
        pasteHint.style.color = '#555';
        container.appendChild(pasteHint);

        statusElement = document.createElement('div');
        statusElement.style.fontSize = '12px';
        statusElement.style.color = '#000';
        statusElement.textContent = '';
        container.appendChild(statusElement);

        document.body.appendChild(container);
    }

    function handlePaste(event) {
        const items = event.clipboardData?.items;
        if (!items) return;
        for (let item of items) {
            if (item.type.indexOf('image') === 0) {
                event.preventDefault();
                const blob = item.getAsFile();
                const now = new Date();
                const defaultBase = `pasted_image_${now.toISOString().replace(/[^a-z0-9]/gi, '_')}`;

                let nameInput = prompt("Enter a name for the pasted image (no extension):", defaultBase);
                if (nameInput === null) return;
                nameInput = nameInput.trim() || defaultBase;
                const filename = nameInput.endsWith('.png') ? nameInput : `${nameInput}.png`;

                let typeInput = prompt(
                    "Upload as:\n  T = T-Shirt\n  D = Decal\n  C = Cancel",
                    "D"
                );
                if (!typeInput) return;
                typeInput = typeInput.trim().toUpperCase();
                let chosenType = null;
                if (typeInput === 'T') chosenType = ASSET_TYPE_TSHIRT;
                else if (typeInput === 'D') chosenType = ASSET_TYPE_DECAL;
                else return;

                const file = new File([blob], filename, { type: blob.type });
                handleFileSelect([file], chosenType);
                break;
            }
        }
    }

    function init() {
        createUploaderUI();
        document.addEventListener('paste', handlePaste);
        console.log('[Uploader] Initialized with User ID:', USER_ID, 'Max uploads:', MAX_CONCURRENT_UPLOADS);
    }

    window.addEventListener('load', init);
})();