AnnaUploader (Roblox Multi-File Uploader)

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

当前为 2025-05-05 提交的版本,查看 最新版本

// ==UserScript==
// @name         AnnaUploader (Roblox Multi-File Uploader)
// @namespace    https://www.guilded.gg/u/AnnaBlox
// @version      4.7
// @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        = "Uploaded Using AnnaUploader";

    // Stored settings
    let USER_ID     = GM_getValue('userId', null);

    // Mass-upload state
    let massMode     = false;
    let massQueue    = [];

    let csrfToken    = null;
    let batchTotal   = 0;
    let completed    = 0;
    let statusEl     = null;
    let toggleBtn    = null;
    let startBtn     = null;

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

    async function uploadFile(file, assetType, retries = 0, forceName = false) {
        if (!csrfToken) await fetchCSRFToken();
        const displayName = forceName ? FORCED_NAME : file.name.split('.')[0];
        const fd = new FormData();
        fd.append('fileContent', file, file.name);
        fd.append('request', JSON.stringify({
            displayName,
            description: FORCED_NAME,
            assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal",
            creationContext: { creator: { userId: USER_ID }, expectedPrice: 0 }
        }));

        try {
            const resp = await fetch(ROBLOX_UPLOAD_URL, {
                method: 'POST',
                credentials: 'include',
                headers: { 'x-csrf-token': csrfToken },
                body: fd
            });
            if (resp.ok) {
                console.log(`✅ ${displayName}`);
                completed++;
                updateStatus();
                return;
            }

            const txt = await resp.text();
            let json; try { json = JSON.parse(txt); } catch{}
            const badName = resp.status===400 && json?.message?.includes('moderated');
            if (badName && retries < MAX_RETRIES && !forceName) {
                await new Promise(r=>setTimeout(r, UPLOAD_RETRY_DELAY));
                return uploadFile(file, assetType, retries+1, true);
            }
            if (resp.status===403 && retries<MAX_RETRIES) {
                csrfToken = null;
                await new Promise(r=>setTimeout(r, UPLOAD_RETRY_DELAY));
                return uploadFile(file, assetType, retries+1, forceName);
            }

            console.error(`❌ ${file.name}: [${resp.status}]`, txt);
        } catch(e) {
            console.error('Upload error', e);
        } finally {
            // even on error, count as “done” so status moves
            if (!resp?.ok) {
                completed++;
                updateStatus();
            }
        }
    }

    function updateStatus() {
        if (!statusEl) return;
        if (batchTotal > 0) {
            statusEl.textContent = `${completed} of ${batchTotal} processed`;
        } else {
            statusEl.textContent = massMode
                ? `${massQueue.length} items queued`
                : '';
        }
    }

    function handleFileSelect(files, assetType, both=false) {
        if (!files || files.length===0) return;
        if (massMode) {
            for (let f of files) {
                if (both) {
                    massQueue.push({f,type:ASSET_TYPE_TSHIRT});
                    massQueue.push({f,type:ASSET_TYPE_DECAL});
                } else {
                    massQueue.push({f,type:assetType});
                }
            }
            updateStatus();
            return;
        }

        // immediate parallel upload
        const tasks = [];
        batchTotal = both ? files.length*2 : files.length;
        completed = 0;
        updateStatus();
        for (let f of files) {
            if (both) {
                tasks.push(uploadFile(f, ASSET_TYPE_TSHIRT));
                tasks.push(uploadFile(f, ASSET_TYPE_DECAL));
            } else {
                tasks.push(uploadFile(f, assetType));
            }
        }
        Promise.all(tasks).then(()=>console.log('[Uploader] done'));
    }

    function startMassUpload() {
        if (massQueue.length===0) return alert('Nothing queued!');
        batchTotal = massQueue.length;
        completed = 0;
        updateStatus();

        const tasks = massQueue.map(item => uploadFile(item.f, item.type));
        massQueue = [];
        updateStatus();
        Promise.all(tasks).then(()=>{
            alert('Mass upload complete!');
            toggleBtn.textContent = 'Enable Mass Upload';
            massMode = false;
            startBtn.style.display = 'none';
        });
    }

    function createUploaderUI() {
        const c = document.createElement('div');
        Object.assign(c.style, {
            position:'fixed', top:'10px', right:'10px',
            background:'#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:'8px', fontFamily:'Arial', width:'240px'
        });

        // Close
        const close = document.createElement('button');
        close.textContent='×';
        Object.assign(close.style,{
            position:'absolute',top:'5px',right:'8px',
            background:'transparent',border:'none',fontSize:'16px',cursor:'pointer'
        });
        close.title='Close';
        close.onclick = ()=>c.remove();
        c.appendChild(close);

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

        // Buttons factory
        const makeBtn = (txt,fn)=>{
            const b=document.createElement('button');
            b.textContent=txt;
            Object.assign(b.style,{padding:'8px',cursor:'pointer'});
            b.onclick=fn;
            return b;
        };

        // Upload controls
        c.appendChild(makeBtn('Upload T-Shirts',()=>{
            const inp=document.createElement('input');
            inp.type='file'; inp.accept='image/*'; inp.multiple=true;
            inp.onchange = e=> handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT);
            inp.click();
        }));
        c.appendChild(makeBtn('Upload Decals',()=>{
            const inp=document.createElement('input');
            inp.type='file'; inp.accept='image/*'; inp.multiple=true;
            inp.onchange = e=> handleFileSelect(e.target.files, ASSET_TYPE_DECAL);
            inp.click();
        }));
        c.appendChild(makeBtn('Upload Both',()=>{
            const inp=document.createElement('input');
            inp.type='file'; inp.accept='image/*'; inp.multiple=true;
            inp.onchange = e=> handleFileSelect(e.target.files, null, true);
            inp.click();
        }));

        // Mass-upload toggle
        toggleBtn = makeBtn('Enable Mass Upload', ()=>{
            massMode = !massMode;
            toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload';
            startBtn.style.display = massMode ? 'block' : 'none';
            massQueue = [];
            batchTotal = 0; completed = 0;
            updateStatus();
        });
        c.appendChild(toggleBtn);

        // Start button (hidden until massMode)
        startBtn = makeBtn('Start Mass Upload', startMassUpload);
        startBtn.style.display = 'none';
        c.appendChild(startBtn);

        // Change ID
        c.appendChild(makeBtn('Change ID', ()=>{
            const inp=prompt("Enter your Roblox User ID or Profile URL:", USER_ID||'');
            if (!inp) return;
            const m=inp.match(/users\/(\d+)/);
            const id = m ? m[1] : inp.trim();
            if (!isNaN(id)) {
                USER_ID = Number(id);
                GM_setValue('userId', USER_ID);
                alert(`User ID set to ${USER_ID}`);
            } else alert('Invalid input.');
        }));

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

        // Paste hint & status
        const hint = document.createElement('div');
        hint.textContent='Paste images (Ctrl+V) to queue/upload';
        hint.style.fontSize='12px'; hint.style.color='#555';
        c.appendChild(hint);

        statusEl = document.createElement('div');
        statusEl.style.fontSize='12px'; statusEl.style.color='#000';
        c.appendChild(statusEl);

        document.body.appendChild(c);
    }

    function handlePaste(e) {
        const items = e.clipboardData?.items;
        if (!items) return;
        for (let it of items) {
            if (it.type.startsWith('image')) {
                e.preventDefault();
                const blob = it.getAsFile();
                const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_');
                let name = prompt('Name (no ext):', `pasted_${ts}`);
                if (name===null) return;
                name = name.trim()||`pasted_${ts}`;
                const filename = name.endsWith('.png')?name:`${name}.png`;
                let t = prompt('T=T-Shirt, D=Decal, C=Cancel','D');
                if (!t) return;
                t = t.trim().toUpperCase();
                let type = null;
                if (t==='T') type = ASSET_TYPE_TSHIRT;
                else if (t==='D') type = ASSET_TYPE_DECAL;
                else return;
                const file = new File([blob], filename, {type:blob.type});
                handleFileSelect([file], type);
                break;
            }
        }
    }

    window.addEventListener('load', ()=>{
        createUploaderUI();
        document.addEventListener('paste', handlePaste);
        console.log('[AnnaUploader] initialized, massMode=', massMode);
    });

})();