您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
allows you to upload multiple T-Shirts/Decals easily with AnnaUploader
当前为
// ==UserScript== // @name AnnaUploader (Roblox Multi-File Uploader) // @namespace https://github.com/AnnaRoblox // @version 6.0 // @description allows you to upload multiple T-Shirts/Decals easily with AnnaUploader // @match https://create.roblox.com/* // @match https://www.roblox.com/users/*/profile* // @match https://www.roblox.com/communities/* // @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'; // Constants for Roblox API and asset types 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"; // Default name for assets // Storage keys and scan interval for asset logging const STORAGE_KEY = 'annaUploaderAssetLog'; const SCAN_INTERVAL_MS = 10_000; // Script configuration variables, managed with Tampermonkey's GM_getValue/GM_setValue let USER_ID = GM_getValue('userId', null); let IS_GROUP = GM_getValue('isGroup', false); let useForcedName = false; let useMakeUnique = false; let uniqueCopies = 1; let useDownload = false; // Mass upload mode variables let massMode = false; // True if mass upload mode is active let massQueue = []; // Array to hold files/metadata for mass upload let batchTotal = 0; // Total items to process in current batch/queue let completed = 0; // Number of items completed in current batch/queue let csrfToken = null; // Roblox CSRF token for authenticated requests let statusEl, toggleBtn, startBtn, copiesInput, downloadBtn; // UI elements /** * Utility function to extract the base name of a filename (without extension). * @param {string} filename The full filename. * @returns {string} The filename without its extension. */ function baseName(filename) { return filename.replace(/\.[^/.]+$/, ''); } /** * Loads the asset log from GM_getValue storage. * @returns {Object} The parsed asset log, or an empty object if parsing fails. */ function loadLog() { const raw = GM_getValue(STORAGE_KEY, '{}'); try { return JSON.parse(raw); } catch { return {}; } } /** * Saves the asset log to GM_setValue storage. * @param {Object} log The asset log object to save. */ function saveLog(log) { GM_setValue(STORAGE_KEY, JSON.stringify(log)); } /** * Logs an uploaded asset's details. * @param {string} id The asset ID. * @param {string|null} imageURL The URL of the asset's image. * @param {string} name The name of the asset. */ function logAsset(id, imageURL, name) { const log = loadLog(); log[id] = { date: new Date().toISOString(), image: imageURL || log[id]?.image || null, // Preserve existing image if new one is null name: name || log[id]?.name || '(unknown)' // Preserve existing name if new one is null }; saveLog(log); console.log(`[AssetLogger] logged asset ${id} at ${log[id].date}, name: ${log[id].name}, image: ${log[id].image || "none"}`); } /** * Scans the current page for Roblox asset links and logs them. * Runs periodically. */ function scanForAssets() { console.log('[AssetLogger] scanning for assets…'); document.querySelectorAll('[href]').forEach(el => { // Match asset IDs from various Roblox URLs 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('*'); // Find the closest parent element to search for image/name const img = container?.querySelector('img'); if (img?.src) image = img.src; let name = null; const nameEl = container?.querySelector('span.MuiTypography-root'); // Common element for asset names if (nameEl) name = nameEl.textContent.trim(); logAsset(id, image, name); } }); } // Start periodic scanning for new assets setInterval(scanForAssets, SCAN_INTERVAL_MS); /** * Fetches a new CSRF token from Roblox. This token is required for upload requests. * @returns {Promise<string>} A promise that resolves with the CSRF token. * @throws {Error} If the CSRF token cannot be fetched. */ async function fetchCSRFToken() { const resp = await fetch(ROBLOX_UPLOAD_URL, { method: 'POST', credentials: 'include', // Important for sending cookies headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) // Empty body to trigger a 403 and get the token }); 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'); } /** * Updates the status display in the UI. * Shows progress for ongoing uploads or queued count in mass mode. */ function updateStatus() { if (!statusEl) return; if (massMode) { statusEl.textContent = `${massQueue.length} queued`; } else if (batchTotal > 0) { statusEl.textContent = `${completed} of ${batchTotal} processed`; } else { statusEl.textContent = ''; // Clear status if nothing is happening } } /** * Uploads a single file to Roblox as a T-Shirt or Decal. * Includes retry logic for common errors like bad CSRF token or moderated names. * @param {File} file The file to upload. * @param {number} assetType The type of asset (ASSET_TYPE_TSHIRT or ASSET_TYPE_DECAL). * @param {number} [retries=0] Current retry count. * @param {boolean} [forceName=false] Whether to force the default name for the asset. * @returns {Promise<void>} A promise that resolves when the upload is attempted. */ async function uploadFile(file, assetType, retries = 0, forceName = false) { if (!csrfToken) { try { await fetchCSRFToken(); } catch (e) { console.error("[Upload] Failed to fetch initial CSRF token:", e); completed++; // Count this as a failed attempt to proceed with batch updateStatus(); return; } } const displayName = forceName ? FORCED_NAME : baseName(file.name); const creator = IS_GROUP ? { groupId: USER_ID } : { userId: USER_ID }; const fd = new FormData(); fd.append('fileContent', file, file.name); // The actual image file fd.append('request', JSON.stringify({ // JSON payload for the asset details displayName, description: FORCED_NAME, // Description is always the forced name assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal", creationContext: { creator, expectedPrice: 0 } // Price is always 0 })); try { const resp = await fetch(ROBLOX_UPLOAD_URL, { method: 'POST', credentials: 'include', headers: { 'x-csrf-token': csrfToken }, // Add CSRF token to headers body: fd }); const txt = await resp.text(); let json; try { json = JSON.parse(txt); } catch (e) { console.error('[Upload] Failed to parse response JSON:', e, txt); } // Handle successful upload if (resp.ok && json?.assetId) { logAsset(json.assetId, null, displayName); completed++; // Increment on success updateStatus(); // Update status immediately return; // Exit after successful upload } // Retry logic for common errors (no increment here, the recursive call will eventually increment) if (json?.message === 'Asset name length is invalid.' && !forceName && retries < 5) { console.warn(`[Upload] "${file.name}" name too long, retrying with default name. Retry ${retries + 1}.`); return uploadFile(file, assetType, retries + 1, true); // Retry with forced name } if (resp.status === 400 && json?.message?.includes('moderated') && retries < 5) { // If moderated, try again with default name (often resolves this) console.warn(`[Upload] "${file.name}" content moderated, retrying with default name. Retry ${retries + 1}.`); return uploadFile(file, assetType, retries + 1, true); } if (resp.status === 403 && retries < 5) { // CSRF token invalid or expired, fetch new and retry console.warn(`[Upload] "${file.name}" 403 Forbidden, fetching new CSRF and retrying. Retry ${retries + 1}.`); csrfToken = null; // Clear token to force refetch await fetchCSRFToken(); // Ensure a new token is fetched before retrying return uploadFile(file, assetType, retries + 1, forceName); } // If we reach here, it's a final failure after retries or an unhandled HTTP error console.error(`[Upload] failed "${file.name}" [${resp.status}]`, txt); completed++; // Increment even on final failure updateStatus(); // Update status for failed upload } catch (e) { console.error(`[Upload] error during fetch for "${file.name}":`, e); completed++; // Increment on network/unhandled JS error updateStatus(); // Update status for error } } /** * "Slip Mode": subtly randomizes ALL non-transparent pixels by ±1 per channel. * This creates unique images to bypass potential Roblox duplicate detection. * @param {File} file The original image file. * @param {string} origBase The base name of the original file. * @param {number} copyIndex The index of the copy (for naming). * @returns {Promise<File>} A promise that resolves with the new unique image File object. */ 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 imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; // Pixel data: [R, G, B, A, R, G, B, A, ...] for (let i = 0; i < data.length; i += 4) { if (data[i + 3] !== 0) { // Check if alpha channel is not zero (i.e., not transparent) const delta = Math.random() < 0.5 ? -1 : 1; // Randomly add or subtract 1 data[i] = Math.min(255, Math.max(0, data[i] + delta)); // Red data[i+1] = Math.min(255, Math.max(0, data[i+1] + delta)); // Green data[i+2] = Math.min(255, Math.max(0, data[i+2] + delta)); // Blue } } ctx.putImageData(imageData, 0, 0); // Put modified data back to canvas canvas.toBlob(blob => { const ext = file.name.split('.').pop(); // Get original extension const newName = `${origBase}_${copyIndex}.${ext}`; // Create new name with index resolve(new File([blob], newName, { type: file.type })); // Resolve with new File object }, file.type); // Preserve original file type }; img.src = URL.createObjectURL(file); // Load image from file blob URL }); } /** * Handles file selection from input or paste events. * Depending on `massMode`, it either queues files or initiates immediate uploads. * @param {FileList|File[]} files The list of files selected. * @param {number|null} assetType The asset type (TSHIRT, DECAL, or null for 'both'). * @param {boolean} [both=false] If true, upload as both T-Shirt and Decal. */ async function handleFileSelect(files, assetType, both = false) { if (!files?.length) return; const downloadsMap = {}; const copies = useMakeUnique ? uniqueCopies : 1; if (massMode) { // In mass mode, add files to the queue after processing displayMessage('Processing files to add to queue...', 'info'); const processingTasks = []; for (const original of files) { const origBase = baseName(original.name); for (let i = 1; i <= copies; i++) { processingTasks.push( (async () => { const filePromise = useMakeUnique ? makeUniqueFile(original, origBase, i) : Promise.resolve(original); const fileToQueue = await filePromise; if (both) { massQueue.push({ f: fileToQueue, type: ASSET_TYPE_TSHIRT, forceName: useForcedName }); massQueue.push({ f: fileToQueue, type: ASSET_TYPE_DECAL, forceName: useForcedName }); } else { massQueue.push({ f: fileToQueue, type: assetType, forceName: useForcedName }); } })() ); } } await Promise.all(processingTasks); // Wait for all files to be processed and queued displayMessage(`${processingTasks.length} files added to queue!`, 'success'); updateStatus(); // Update status to show queued items } else { // Not in mass mode, proceed with immediate upload const totalFilesToUpload = files.length * (both ? 2 : 1) * copies; batchTotal = totalFilesToUpload; // Set total for immediate batch completed = 0; updateStatus(); displayMessage(`Starting upload of ${batchTotal} files...`, 'info'); const uploadPromises = []; // Array to hold upload promises for (const original of files) { const origBase = baseName(original.name); downloadsMap[origBase] = []; // Initialize for potential downloads for (let i = 1; i <= copies; i++) { const filePromise = useMakeUnique ? makeUniqueFile(original, origBase, i) : Promise.resolve(original); const fileToUpload = await filePromise; // Get the processed file if (useMakeUnique && useDownload) downloadsMap[origBase].push(fileToUpload); if (both) { uploadPromises.push(uploadFile(fileToUpload, ASSET_TYPE_TSHIRT, 0, useForcedName)); uploadPromises.push(uploadFile(fileToUpload, ASSET_TYPE_DECAL, 0, useForcedName)); } else { uploadPromises.push(uploadFile(fileToUpload, assetType, 0, useForcedName)); } } } // Wait for all immediate uploads to complete Promise.all(uploadPromises).then(() => { console.log('[Uploader] batch done'); scanForAssets(); // Rescan for newly uploaded assets displayMessage('Immediate upload batch complete!', 'success'); // Handle downloading of unique images if enabled 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); }); } } }).catch(error => { console.error("Immediate upload batch encountered an error:", error); displayMessage('Immediate upload batch finished with errors. Check console.', 'error'); }); } } /** * Starts the mass upload process for all files currently in the queue. */ function startMassUpload() { if (!massQueue.length) { displayMessage('Nothing queued for mass upload!', 'info'); return; } batchTotal = massQueue.length; // Set total for this mass upload batch completed = 0; // Reset completed counter for the new batch updateStatus(); displayMessage(`Starting mass upload of ${batchTotal} files...`, 'info'); // Create an array of promises for each upload task const tasks = massQueue.map(item => uploadFile(item.f, item.type, 0, item.forceName)); massQueue = []; // Clear the queue once uploads begin // Wait for all uploads in the mass batch to complete Promise.all(tasks).then(() => { displayMessage('Mass upload complete!', 'success'); // Reset mass mode and UI elements after completion massMode = false; toggleBtn.textContent = 'Enable Mass Upload'; startBtn.style.display = 'none'; scanForAssets(); // Rescan for all newly uploaded assets batchTotal = completed = 0; // Reset progress counters for next operation updateStatus(); // Final status update }).catch(error => { console.error("Mass upload encountered an error:", error); displayMessage('Mass upload finished with errors. Check console.', 'error'); massMode = false; toggleBtn.textContent = 'Enable Mass Upload'; startBtn.style.display = 'none'; batchTotal = completed = 0; updateStatus(); }); } /** * Displays a custom modal message instead of `alert()`. * @param {string} message The message to display. * @param {'info'|'success'|'error'} [type='info'] The type of message for styling. */ function displayMessage(message, type = 'info') { const modal = document.createElement('div'); Object.assign(modal.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', padding: '20px', background: '#333', color: '#fff', borderRadius: '8px', boxShadow: '0 4px 10px rgba(0,0,0,0.5)', zIndex: '10001', fontFamily: 'Inter, Arial, sans-serif', textAlign: 'center', minWidth: '250px', transition: 'opacity 0.3s ease-in-out', opacity: '0' // Start hidden for transition }); if (type === 'success') { modal.style.background = '#4CAF50'; // Green } else if (type === 'error') { modal.style.background = '#f44336'; // Red } modal.textContent = message; document.body.appendChild(modal); // Fade in setTimeout(() => modal.style.opacity = '1', 10); // Fade out and remove after a delay setTimeout(() => { modal.style.opacity = '0'; modal.addEventListener('transitionend', () => modal.remove()); }, 3000); } /** * Displays a custom modal prompt instead of `prompt()`. * @param {string} message The message to display in the prompt. * @param {string} [defaultValue=''] The default value for the input field. * @returns {Promise<string|null>} A promise that resolves with the input value or null if canceled. */ function customPrompt(message, defaultValue = '') { return new Promise(resolve => { const modal = document.createElement('div'); Object.assign(modal.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', padding: '20px', background: '#222', color: '#fff', borderRadius: '8px', boxShadow: '0 6px 15px rgba(0,0,0,0.4)', zIndex: '10002', // Higher z-index than message modal fontFamily: 'Inter, Arial, sans-serif', textAlign: 'center', minWidth: '300px', display: 'flex', flexDirection: 'column', gap: '15px', transition: 'opacity 0.3s ease-in-out', opacity: '0' }); const textDiv = document.createElement('div'); textDiv.textContent = message; textDiv.style.fontSize = '16px'; modal.appendChild(textDiv); const input = document.createElement('input'); input.type = 'text'; input.value = defaultValue; Object.assign(input.style, { padding: '10px', borderRadius: '5px', border: '1px solid #555', background: '#333', color: '#fff', fontSize: '14px', outline: 'none' }); modal.appendChild(input); const buttonContainer = document.createElement('div'); Object.assign(buttonContainer.style, { display: 'flex', justifyContent: 'space-around', gap: '10px', marginTop: '10px' }); const okBtn = document.createElement('button'); okBtn.textContent = 'OK'; Object.assign(okBtn.style, { padding: '10px 20px', cursor: 'pointer', color: '#fff', background: '#007bff', border: 'none', borderRadius: '5px', fontSize: '14px', flexGrow: '1' }); okBtn.onmouseover = () => okBtn.style.background = '#0056b3'; okBtn.onmouseout = () => okBtn.style.background = '#007bff'; okBtn.onclick = () => { modal.style.opacity = '0'; modal.addEventListener('transitionend', () => modal.remove()); resolve(input.value); }; buttonContainer.appendChild(okBtn); const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Cancel'; Object.assign(cancelBtn.style, { padding: '10px 20px', cursor: 'pointer', color: '#fff', background: '#6c757d', border: 'none', borderRadius: '5px', fontSize: '14px', flexGrow: '1' }); cancelBtn.onmouseover = () => cancelBtn.style.background = '#5a6268'; cancelBtn.onmouseout = () => cancelBtn.style.background = '#6c757d'; cancelBtn.onclick = () => { modal.style.opacity = '0'; modal.addEventListener('transitionend', () => modal.remove()); resolve(null); }; buttonContainer.appendChild(cancelBtn); modal.appendChild(buttonContainer); document.body.appendChild(modal); // Fade in setTimeout(() => modal.style.opacity = '1', 10); input.focus(); input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { okBtn.click(); } }); }); } /** * Creates and injects the AnnaUploader UI panel into the page. */ function createUI() { const c = document.createElement('div'); Object.assign(c.style, { position: 'fixed', top: '10px', right: '10px', width: '260px', background: '#1a1a1a', // Darker background border: '2px solid #333', // Subtle border color: '#e0e0e0', // Lighter text color padding: '15px', zIndex: 10000, borderRadius: '8px', boxShadow: '0 4px 12px rgba(0,0,0,0.4)', // Stronger shadow display: 'flex', flexDirection: 'column', gap: '10px', // More spacing fontFamily: 'Inter, Arial, sans-serif' // Modern font }); // Helper to create styled buttons function btn(text, fn) { const b = document.createElement('button'); b.textContent = text; Object.assign(b.style, { padding: '10px', cursor: 'pointer', color: '#fff', background: '#3a3a3a', // Darker button background border: '1px solid #555', borderRadius: '5px', // Slightly more rounded transition: 'background 0.2s ease-in-out', fontSize: '14px' }); b.onmouseover = () => b.style.background = '#505050'; // Hover effect b.onmouseout = () => b.style.background = '#3a3a3a'; b.onclick = fn; return b; } // Close button for the UI panel const close = btn('×', () => c.remove()); Object.assign(close.style, { position: 'absolute', top: '5px', right: '8px', background: 'transparent', border: 'none', fontSize: '18px', color: '#e0e0e0', fontWeight: 'bold', transition: 'color 0.2s', padding: '5px 8px' // Make it easier to click }); close.onmouseover = () => close.style.color = '#fff'; close.onmouseout = () => close.style.color = '#e0e0e0'; close.title = 'Close AnnaUploader'; c.appendChild(close); const title = document.createElement('h3'); title.textContent = 'AnnaUploader'; title.style.margin = '0 0 10px 0'; // More margin below title title.style.color = '#4af'; // Accent color for title title.style.textAlign = 'center'; c.appendChild(title); // Upload T-Shirts button 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(); })); // Upload Decals button 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(); })); // Upload Both button 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); // null means 'both' i.click(); })); // Mass Upload toggle button toggleBtn = btn('Enable Mass Upload', () => { massMode = !massMode; toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload'; startBtn.style.display = massMode ? 'block' : 'none'; // Show/hide start button massQueue = []; // Clear queue when toggling mode batchTotal = completed = 0; // Reset progress updateStatus(); // Update status display displayMessage(`Mass Upload Mode: ${massMode ? 'Enabled' : 'Disabled'}`, 'info'); }); c.appendChild(toggleBtn); // Start Mass Upload button (initially hidden) startBtn = btn('Start Mass Upload', startMassUpload); startBtn.style.display = 'none'; Object.assign(startBtn.style, { background: '#28a745', // Green for start border: '1px solid #218838' }); startBtn.onmouseover = () => startBtn.style.background = '#218838'; startBtn.onmouseout = () => startBtn.style.background = '#28a745'; c.appendChild(startBtn); // Use default Name toggle const nameBtn = btn(`Use default Name: ${useForcedName ? 'On' : 'Off'}`, () => { useForcedName = !useForcedName; nameBtn.textContent = `Use default Name: ${useForcedName ? 'On' : 'Off'}`; }); c.appendChild(nameBtn); // Slip Mode toggle const slipBtn = btn(`Slip Mode: ${useMakeUnique ? 'On' : 'Off'}`, () => { useMakeUnique = !useMakeUnique; slipBtn.textContent = `Slip Mode: ${useMakeUnique ? 'On' : 'Off'}`; copiesInput.style.display = useMakeUnique ? 'block' : 'none'; // Show/hide copies input downloadBtn.style.display = useMakeUnique ? 'block' : 'none'; // Show/hide download button if (!useMakeUnique) { // If turning Slip Mode off, also turn off download useDownload = false; downloadBtn.textContent = 'Download Images: Off'; } }); c.appendChild(slipBtn); // Copies input for Slip Mode copiesInput = document.createElement('input'); copiesInput.type = 'number'; copiesInput.min = '1'; copiesInput.value = uniqueCopies; Object.assign(copiesInput.style, { width: '100%', boxSizing: 'border-box', display: 'none', // Initially hidden padding: '8px', borderRadius: '4px', border: '1px solid #555', background: '#333', color: '#fff', textAlign: 'center' }); copiesInput.onchange = e => { const v = parseInt(e.target.value, 10); if (v > 0) uniqueCopies = v; else e.target.value = uniqueCopies; // Revert to valid value if invalid input }; c.appendChild(copiesInput); // Download Images toggle for Slip Mode downloadBtn = btn(`Download Images: ${useDownload ? 'On' : 'Off'}`, () => { useDownload = !useDownload; downloadBtn.textContent = `Download Images: ${useDownload ? 'On' : 'Off'}`; }); downloadBtn.style.display = 'none'; // Initially hidden c.appendChild(downloadBtn); // Change ID button c.appendChild(btn('Change ID', async () => { const inp = await customPrompt("Enter your Roblox User ID/URL or Group URL:", USER_ID || ''); if (inp === null) return; // User cancelled let id, isGrp = false; const um = inp.match(/users\/(\d+)/); const gm = inp.match(/communities\/(\d+)/); if (um) { id = um[1]; } else if (gm) { id = gm[1]; isGrp = true; } else { id = inp.trim(); if (isNaN(id) || id === '') { // Check for empty string after trim as well displayMessage('Invalid input. Please enter a number or a valid URL.', 'error'); return; } } USER_ID = Number(id); IS_GROUP = isGrp; GM_setValue('userId', USER_ID); GM_setValue('isGroup', IS_GROUP); displayMessage(`Set to ${isGrp ? 'Group' : 'User'} ID: ${USER_ID}`, 'success'); })); // "Use This Profile as ID" button (contextual) const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/); if (pm) { c.appendChild(btn('Use This Profile as ID', () => { USER_ID = Number(pm[1]); IS_GROUP = false; GM_setValue('userId', USER_ID); GM_setValue('isGroup', IS_GROUP); displayMessage(`User ID set to ${USER_ID}`, 'success'); })); } // "Use This Group as ID" button (contextual) const gm = window.location.pathname.match(/^\/communities\/(\d+)/); if (gm) { c.appendChild(btn('Use This Group as ID', () => { USER_ID = Number(gm[1]); IS_GROUP = true; GM_setValue('userId', USER_ID); GM_setValue('isGroup', IS_GROUP); displayMessage(`Group ID set to ${USER_ID}`, 'success'); })); } // Show Logged Assets button c.appendChild(btn('Show Logged Assets', () => { const log = loadLog(); const entries = Object.entries(log); const w = window.open('', '_blank'); // Open a new blank window w.document.write(`<!DOCTYPE html> <html><head><meta charset="utf-8"><title>Logged Assets</title> <style> body { font-family:Arial; padding:20px; background:#121212; color:#f0f0f0; } h1 { margin-bottom:15px; color:#4af; } ul { list-style:none; padding:0; } li { margin-bottom:15px; padding:10px; background:#1e1e1e; border-radius:8px; display:flex; flex-direction:column; gap:8px;} img { max-height:60px; border:1px solid #444; border-radius:4px; object-fit:contain; background:#333; } .asset-info { display:flex;align-items:center;gap:15px; } a { color:#7cf; text-decoration:none; font-weight:bold; } a:hover { text-decoration:underline; } .asset-name { font-size:0.9em; color:#bbb; margin-left: auto; text-align: right; } button { margin-bottom:20px; color:#fff; background:#3a3a3a; border:1px solid #555; padding:8px 15px; border-radius:5px; cursor:pointer; } button:hover { background:#505050; } </style></head><body> <button onclick="document.body.style.background=(document.body.style.background==='#121212'?'#f0f0f0':'#121212');document.body.style.color=(document.body.style.color==='#f0f0f0'?'#121212':'#f0f0f0');document.querySelectorAll('li').forEach(li=>li.style.background=(document.body.style.background==='#121212'?'#1e1e1e':'#e0e0e0'));document.querySelectorAll('a').forEach(a=>a.style.color=(document.body.style.background==='#121212'?'#7cf':'#007bff'));document.querySelectorAll('img').forEach(i=>i.style.border=(document.body.style.background==='#121212'?'1px solid #444':'1px solid #ccc'));">Toggle Theme</button> <h1>Logged Assets</h1> ${ entries.length ? `<ul>${entries.map(([id,entry])=> `<li> <div class="asset-info"> ${ entry.image ? `<img src="${entry.image}" alt="Asset thumbnail">` : `<span style="color:#888;">(no image)</span>` } <a href="https://create.roblox.com/store/asset/${id}" target="_blank">${id}</a> <span style="font-size:0.85em; color:#999;">${new Date(entry.date).toLocaleString()}</span> </div> <div class="asset-name">${entry.name}</div> </li>`).join('') }</ul>` : `<p style="color:#888;"><em>No assets logged yet.</em></p>`} </body></html>`); w.document.close(); // Close the document stream to ensure content is rendered })); const hint = document.createElement('div'); hint.textContent = 'Paste images (Ctrl+V) to queue/upload'; hint.style.fontSize = '12px'; hint.style.color = '#aaa'; hint.style.textAlign = 'center'; hint.style.marginTop = '5px'; c.appendChild(hint); // Status element at the bottom statusEl = document.createElement('div'); statusEl.style.fontSize = '13px'; statusEl.style.color = '#fff'; statusEl.style.textAlign = 'center'; statusEl.style.paddingTop = '10px'; statusEl.style.borderTop = '1px solid #333'; c.appendChild(statusEl); document.body.appendChild(c); } /** * Handles paste events, attempting to extract image data and process it for upload. * @param {ClipboardEvent} e The paste event object. */ async function handlePaste(e) { const items = e.clipboardData?.items; if (!items) return; for (const it of items) { if (it.type.startsWith('image')) { e.preventDefault(); // Prevent default paste behavior const blob = it.getAsFile(); const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_'); // Timestamp for default name const pastedName = await customPrompt('Enter a name for the image (no extension):', `pasted_${ts}`); if (pastedName === null) return; // User cancelled const name = pastedName.trim() || `pasted_${ts}`; const filename = name.endsWith('.png') ? name : `${name}.png`; const typeChoice = await customPrompt('Upload as T=T-Shirt, D=Decal, or C=Cancel?', 'D'); if (!typeChoice) return; // User cancelled const t = typeChoice.trim().toUpperCase(); const type = t === 'T' ? ASSET_TYPE_TSHIRT : t === 'D' ? ASSET_TYPE_DECAL : null; if (!type) { displayMessage('Invalid asset type selected. Please choose T or D.', 'error'); return; } // Process the pasted file like any other selected file handleFileSelect([new File([blob], filename, {type: blob.type})], type); break; // Process only the first image found } } } // Initialize the UI and event listeners when the window loads window.addEventListener('load', () => { createUI(); document.addEventListener('paste', handlePaste); scanForAssets(); // Initial scan console.log('[AnnaUploader] initialized; asset scan every ' + (SCAN_INTERVAL_MS/1000) + 's'); }); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址