AnnaUploader (Roblox Multi-File Uploader)

allows you to upload multiple T-Shirts/Decals easily with AnnaUploader; retries with default name on name-too-long errors

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         AnnaUploader (Roblox Multi-File Uploader)
// @namespace    https://www.guilded.gg/u/AnnaBlox
// @version      5.6
// @description  allows you to upload multiple T-Shirts/Decals easily with AnnaUploader; retries with default name on name-too-long errors
// @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
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.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 FORCED_NAME        = "Uploaded Using AnnaUploader";

    const STORAGE_KEY = 'annaUploaderAssetLog';
    const SCAN_INTERVAL_MS = 10_000;

    let USER_ID = GM_getValue('userId', null);
    let useForcedName = false;
    let useMakeUnique = false;
    let uniqueCopies = 1;
    let useDownload = false;

    let massMode = false;
    let massQueue = [];
    let batchTotal = 0;
    let completed = 0;

    let csrfToken = null;
    let statusEl, toggleBtn, startBtn, copiesInput, downloadBtn;

    // Utility: extract base name without extension
    function baseName(filename) {
        return filename.replace(/\.[^/.]+$/, '');
    }

    function loadLog() {
        const raw = GM_getValue(STORAGE_KEY, '{}');
        try { return JSON.parse(raw); }
        catch { return {}; }
    }

    function saveLog(log) {
        GM_setValue(STORAGE_KEY, JSON.stringify(log));
    }

    function logAsset(id, imageURL, name) {
        const log = loadLog();
        log[id] = {
            date: new Date().toISOString(),
            image: imageURL || log[id]?.image || null,
            name: name || log[id]?.name || '(unknown)'
        };
        saveLog(log);
        console.log(`[AssetLogger] logged asset ${id} at ${log[id].date}, name: ${log[id].name}, image: ${log[id].image || "none"}`);
    }

    function scanForAssets() {
        console.log('[AssetLogger] scanning for assets…');
        document.querySelectorAll('[href]').forEach(el => {
            let m = el.href.match(/(?:https?:\/\/create\.roblox\.com)?\/store\/asset\/(\d+)/)
                 || el.href.match(/\/dashboard\/creations\/store\/(\d+)\/configure/);
            if (m) {
                const id = m[1];
                let image = null;
                const container = el.closest('*');
                const img = container?.querySelector('img');
                if (img?.src) image = img.src;
                let name = null;
                const nameEl = container?.querySelector('span.MuiTypography-root');
                if (nameEl) name = nameEl.textContent.trim();
                logAsset(id, image, name);
            }
        });
    }
    setInterval(scanForAssets, SCAN_INTERVAL_MS);

    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] token fetched'); return tok; }
        }
        throw new Error('Cannot fetch CSRF token');
    }

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

    async function uploadFile(file, assetType, retries = 0, forceName = false) {
        if (!csrfToken) await fetchCSRFToken();
        const displayName = forceName ? FORCED_NAME : baseName(file.name);
        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
            });
            const txt = await resp.text();
            let json; try { json = JSON.parse(txt); } catch {}
            if (resp.ok && json.assetId) {
                logAsset(json.assetId, null, displayName);
                completed++;
                updateStatus();
                return;
            }
            // If name-too-long error, retry with default name
            if (json?.message === 'Asset name length is invalid.' && !forceName && retries < 5) {
                console.warn('[Upload] name too long, retrying with default name');
                return uploadFile(file, assetType, retries + 1, true);
            }
            // Moderation error: try forced name
            if (resp.status === 400 && json?.message?.includes('moderated') && retries < 5) {
                return uploadFile(file, assetType, retries + 1, true);
            }
            // CSRF expired: retry
            if (resp.status === 403 && retries < 5) {
                csrfToken = null;
                return uploadFile(file, assetType, retries + 1, forceName);
            }
            console.error(`[Upload] failed ${file.name} [${resp.status}]`, txt);
        } catch (e) {
            console.error('[Upload] error', e);
        } finally {
            if (completed < batchTotal) {
                completed++;
                updateStatus();
            }
        }
    }

    function makeUniqueFile(file, origBase, copyIndex) {
        return new Promise(resolve => {
            const img = new Image();
            img.onload = () => {
                const canvas = document.createElement('canvas');
                canvas.width = img.width;
                canvas.height = img.height;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0);
                const x = Math.floor(Math.random() * canvas.width);
                const y = Math.floor(Math.random() * canvas.height);
                ctx.fillStyle = `rgba(${Math.random()*255|0},${Math.random()*255|0},${Math.random()*255|0},1)`;
                ctx.fillRect(x, y, 1, 1);
                canvas.toBlob(blob => {
                    const ext = file.name.split('.').pop();
                    const newName = `${origBase}_variant${copyIndex}.${ext}`;
                    resolve(new File([blob], newName, { type: file.type }));
                }, file.type);
            };
            img.src = URL.createObjectURL(file);
        });
    }

    async function handleFileSelect(files, assetType, both = false) {
        if (!files?.length) return;

        const downloadsMap = {};
        const copies = useMakeUnique ? uniqueCopies : 1;
        batchTotal = files.length * (both ? 2 : 1) * copies;
        completed = 0;
        updateStatus();

        const tasks = [];

        for (const original of files) {
            const origBase = baseName(original.name);
            downloadsMap[origBase] = [];

            for (let i = 1; i <= copies; i++) {
                const filePromise = useMakeUnique
                    ? makeUniqueFile(original, origBase, i)
                    : Promise.resolve(new File([original], `${origBase}_copy${i}.${original.name.split('.').pop()}`, { type: original.type }));

                const fileTask = filePromise.then(toUse => {
                    if (useMakeUnique && useDownload) downloadsMap[origBase].push(toUse);
                    if (both) {
                        tasks.push(uploadFile(toUse, ASSET_TYPE_TSHIRT, 0, useForcedName));
                        tasks.push(uploadFile(toUse, ASSET_TYPE_DECAL,   0, useForcedName));
                    } else {
                        tasks.push(uploadFile(toUse, assetType, 0, useForcedName));
                    }
                });

                await fileTask;
            }
        }

        Promise.all(tasks).then(() => {
            console.log('[Uploader] batch done');
            scanForAssets();
            if (useMakeUnique && useDownload) {
                for (const [origBase, fileList] of Object.entries(downloadsMap)) {
                    if (!fileList.length) continue;
                    const zip = new JSZip();
                    fileList.forEach(f => zip.file(f.name, f));
                    zip.generateAsync({ type: 'blob' }).then(blob => {
                        const url = URL.createObjectURL(blob);
                        const a = document.createElement('a');
                        a.href = url;
                        a.download = `${origBase}.zip`;
                        document.body.appendChild(a);
                        a.click();
                        document.body.removeChild(a);
                        URL.revokeObjectURL(url);
                    });
                }
            }
        });
    }

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

        const tasks = massQueue.map(item => uploadFile(item.f, item.type, 0, useForcedName));
        massQueue = [];

        Promise.all(tasks).then(() => {
            alert('Mass upload complete!');
            massMode = false;
            toggleBtn.textContent = 'Enable Mass Upload';
            startBtn.style.display = 'none';
            scanForAssets();
        });
    }

    function createUI() {
        const c = document.createElement('div');
        Object.assign(c.style, {
            position:'fixed', top:'10px', right:'10px', width:'260px',
            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'
        });

        function btn(text, fn) {
            const b = document.createElement('button');
            b.textContent = text;
            Object.assign(b.style, { padding:'8px', cursor:'pointer' });
            b.onclick = fn;
            return b;
        }

        const close = btn('×', () => c.remove());
        Object.assign(close.style, {
            position:'absolute', top:'5px', right:'8px',
            background:'transparent', border:'none', fontSize:'16px'
        });
        close.title = 'Close';
        c.appendChild(close);

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

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

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

        startBtn = btn('Start Mass Upload', startMassUpload);
        startBtn.style.display = 'none';
        c.appendChild(startBtn);

        const nameBtn = btn('Use default Name: Off', () => {
            useForcedName = !useForcedName;
            nameBtn.textContent = `Use default Name: ${useForcedName?'On':'Off'}`;
        });
        c.appendChild(nameBtn);

        const slipBtn = btn('Slip Mode: Off', () => {
            useMakeUnique = !useMakeUnique;
            slipBtn.textContent = `Slip Mode: ${useMakeUnique?'On':'Off'}`;
            copiesInput.style.display = useMakeUnique ? 'block' : 'none';
            downloadBtn.style.display = useMakeUnique ? 'block' : 'none';
            if (!useMakeUnique) {
                useDownload = false;
                downloadBtn.textContent = 'Download Images: Off';
            }
        });
        c.appendChild(slipBtn);

        copiesInput = document.createElement('input');
        copiesInput.type='number'; copiesInput.min='1'; copiesInput.value=uniqueCopies;
        copiesInput.style.width='100%'; copiesInput.style.boxSizing='border-box';
        copiesInput.style.display='none';
        copiesInput.onchange = e => {
            const v = parseInt(e.target.value,10);
            if (v>0) uniqueCopies = v;
            else e.target.value = uniqueCopies;
        };
        c.appendChild(copiesInput);

        downloadBtn = btn('Download Images: Off', () => {
            useDownload = !useDownload;
            downloadBtn.textContent = `Download Images: ${useDownload?'On':'Off'}`;
        });
        downloadBtn.style.display = 'none';
        c.appendChild(downloadBtn);

        c.appendChild(btn('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.');
        }));

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

        c.appendChild(btn('Show Logged Assets', () => {
            const log = loadLog();
            const entries = Object.entries(log);
            const w = window.open('', '_blank');
            w.document.write(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Logged Assets</title>
<style>
body { font-family:Arial; padding:20px; background:#fff; color:#000; transition:background 0.3s, color 0.3s; }
h1 { margin-bottom:10px; }
ul { padding-left:20px; }
li { margin-bottom:10px; }
img { max-height:40px; border:1px solid #ccc; }
.asset-name { font-size:90%; color:#333; margin-left:20px; }
button { margin-bottom:10px; }
</style></head><body>
<button onclick="document.body.style.background=(document.body.style.background==='black'?'white':'black');document.body.style.color=(document.body.style.color==='white'?'black':'white');document.querySelectorAll('img').forEach(i=>i.style.border=(document.body.style.background==='black'?'1px solid #fff':'1px solid #ccc'));">Toggle Background</button>
<h1>Logged Assets</h1>
${ entries.length ? `<ul>${entries.map(([id,entry])=>`
  <li>
    <div style="display:flex;align-items:center;gap:10px;">
      ${ entry.image ? `<img src="${entry.image}" alt> ` : `<span>(no image)</span>` }
      <a href="https://create.roblox.com/store/asset/${id}" target="_blank">${id}</a> — ${entry.date}
    </div>
    <div class="asset-name">${entry.name}</div>
  </li>`).join('') }</ul>` : `<p><em>No assets logged yet.</em></p>`}
</body></html>`);
            w.document.close();
        }));

        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 (const 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();
                const type = t==='T'? ASSET_TYPE_TSHIRT : t==='D'? ASSET_TYPE_DECAL : null;
                if (!type) return;
                handleFileSelect([new File([blob], filename, {type: blob.type})], type);
                break;
            }
        }
    }

    window.addEventListener('load', () => {
        createUI();
        document.addEventListener('paste', handlePaste);
        scanForAssets();
        console.log('[AnnaUploader] v5.6 initialized; asset scan every ' + (SCAN_INTERVAL_MS/1000) + 's');
    });

})();