AnnaUploader (Roblox Multi-File Uploader)

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

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

  1. // ==UserScript==
  2. // @name AnnaUploader (Roblox Multi-File Uploader)
  3. // @namespace https://www.guilded.gg/u/AnnaBlox
  4. // @version 4.5
  5. // @description allows you to upload multiple T-Shirts/Decals easily with AnnaUploader
  6. // @match https://create.roblox.com/*
  7. // @run-at document-idle
  8. // @grant GM_getValue
  9. // @grant GM_setValue
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. const ROBLOX_UPLOAD_URL = "https://apis.roblox.com/assets/user-auth/v1/assets";
  18. const ASSET_TYPE_TSHIRT = 11;
  19. const ASSET_TYPE_DECAL = 13;
  20. const UPLOAD_RETRY_DELAY = 0;
  21. const MAX_RETRIES = 150;
  22. const FORCED_NAME_ON_MOD = "Uploaded Using AnnaUploader";
  23. const MAX_CONCURRENT_UPLOADS = 50; // Number of parallel uploads
  24.  
  25. let USER_ID = GM_getValue('userId', null);
  26. let uploadQueue = [];
  27. let activeUploads = 0;
  28. let csrfToken = null;
  29. let batchTotal = 0;
  30. let completedCount = 0;
  31. let statusElement = null;
  32.  
  33. async function fetchCSRFToken() {
  34. try {
  35. const response = await fetch(ROBLOX_UPLOAD_URL, {
  36. method: 'POST',
  37. credentials: 'include',
  38. headers: { 'Content-Type': 'application/json' },
  39. body: JSON.stringify({})
  40. });
  41. if (response.status === 403) {
  42. const token = response.headers.get('x-csrf-token');
  43. if (token) {
  44. console.log('[CSRF] Token fetched:', token);
  45. csrfToken = token;
  46. return token;
  47. }
  48. }
  49. throw new Error('Failed to fetch CSRF token');
  50. } catch (error) {
  51. console.error('[CSRF] Fetch error:', error);
  52. throw error;
  53. }
  54. }
  55.  
  56. async function uploadFile(file, assetType, retries = 0, forceName = false) {
  57. if (!csrfToken) {
  58. await fetchCSRFToken();
  59. }
  60.  
  61. const displayName = forceName
  62. ? FORCED_NAME_ON_MOD
  63. : file.name.split('.')[0];
  64.  
  65. const formData = new FormData();
  66. formData.append("fileContent", file, file.name);
  67. formData.append("request", JSON.stringify({
  68. displayName: displayName,
  69. description: "Uploaded Using AnnaUploader",
  70. assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal",
  71. creationContext: {
  72. creator: { userId: USER_ID },
  73. expectedPrice: 0
  74. }
  75. }));
  76.  
  77. try {
  78. const response = await fetch(ROBLOX_UPLOAD_URL, {
  79. method: "POST",
  80. credentials: "include",
  81. headers: { "x-csrf-token": csrfToken },
  82. body: formData
  83. });
  84.  
  85. if (response.ok) {
  86. console.log(`✅ Uploaded (${assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal"}): ${file.name}`);
  87. return;
  88. }
  89.  
  90. const status = response.status;
  91. const text = await response.text();
  92. let json;
  93. try { json = JSON.parse(text); } catch {}
  94.  
  95. const isModeratedName = status === 400 && json?.code === "INVALID_ARGUMENT" && json?.message?.includes("fully moderated");
  96. const isInvalidNameLength = status === 400 && json?.code === "INVALID_ARGUMENT" && json?.message?.includes("name length is invalid");
  97.  
  98. if ((isModeratedName || isInvalidNameLength) && retries < MAX_RETRIES && !forceName) {
  99. console.warn(`⚠️ Invalid name for ${file.name}: retrying with forced name...`);
  100. await new Promise(res => setTimeout(res, UPLOAD_RETRY_DELAY));
  101. return await uploadFile(file, assetType, retries + 1, true);
  102. }
  103.  
  104. if (status === 403 && retries < MAX_RETRIES) {
  105. console.warn(`🔄 CSRF expired for ${file.name}: fetching new token and retrying...`);
  106. csrfToken = null;
  107. await new Promise(res => setTimeout(res, UPLOAD_RETRY_DELAY));
  108. return await uploadFile(file, assetType, retries + 1, forceName);
  109. }
  110.  
  111. console.error(`❌ Upload failed for ${file.name}: [${status}]`, text);
  112. throw new Error(`Failed to upload ${file.name} after ${retries} retries.`);
  113. } catch (error) {
  114. console.error(`Upload error for ${file.name}:`, error);
  115. throw error;
  116. }
  117. }
  118.  
  119. function updateStatus() {
  120. if (!statusElement) return;
  121. if (batchTotal > 0) {
  122. statusElement.textContent = `${completedCount} of ${batchTotal} files uploaded successfully`;
  123. } else {
  124. statusElement.textContent = '';
  125. }
  126. }
  127.  
  128. function processUploadQueue() {
  129. // Start as many uploads as allowed
  130. while (activeUploads < MAX_CONCURRENT_UPLOADS && uploadQueue.length > 0) {
  131. const { file, assetType } = uploadQueue.shift();
  132. activeUploads++;
  133. (async () => {
  134. try {
  135. await uploadFile(file, assetType);
  136. completedCount++;
  137. updateStatus();
  138. } catch (e) {
  139. // ignore individual errors
  140. } finally {
  141. activeUploads--;
  142. processUploadQueue();
  143. }
  144. })();
  145. }
  146. }
  147.  
  148. function handleFileSelect(files, assetType, uploadBoth = false) {
  149. if (!files || files.length === 0) {
  150. console.warn('No files selected.');
  151. return;
  152. }
  153. batchTotal = uploadBoth ? files.length * 2 : files.length;
  154. completedCount = 0;
  155. updateStatus();
  156.  
  157. for (let file of files) {
  158. if (uploadBoth) {
  159. uploadQueue.push({ file, assetType: ASSET_TYPE_TSHIRT });
  160. uploadQueue.push({ file, assetType: ASSET_TYPE_DECAL });
  161. console.log(`Queued (Both): ${file.name}`);
  162. } else {
  163. uploadQueue.push({ file, assetType });
  164. console.log(`Queued (${assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal"}): ${file.name}`);
  165. }
  166. }
  167. processUploadQueue();
  168. }
  169.  
  170. function createUploaderUI() {
  171. const container = document.createElement('div');
  172. Object.assign(container.style, {
  173. position: 'fixed',
  174. top: '10px',
  175. right: '10px',
  176. backgroundColor: '#fff',
  177. border: '2px solid #000',
  178. padding: '15px',
  179. zIndex: '10000',
  180. borderRadius: '8px',
  181. boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
  182. display: 'flex',
  183. flexDirection: 'column',
  184. gap: '10px',
  185. fontFamily: 'Arial, sans-serif',
  186. width: '220px'
  187. });
  188.  
  189. const closeBtn = document.createElement('button');
  190. closeBtn.textContent = '×';
  191. Object.assign(closeBtn.style, {
  192. position: 'absolute',
  193. top: '5px',
  194. right: '8px',
  195. background: 'transparent',
  196. border: 'none',
  197. fontSize: '16px',
  198. cursor: 'pointer',
  199. lineHeight: '1'
  200. });
  201. closeBtn.title = 'Close uploader';
  202. closeBtn.addEventListener('click', () => container.remove());
  203. container.appendChild(closeBtn);
  204.  
  205. const title = document.createElement('h3');
  206. title.textContent = 'AnnaUploader — Fast';
  207. title.style.margin = '0 0 5px 0';
  208. title.style.fontSize = '16px';
  209. container.appendChild(title);
  210.  
  211. const makeBtn = (text, onClick) => {
  212. const btn = document.createElement('button');
  213. btn.textContent = text;
  214. Object.assign(btn.style, { padding: '8px', cursor: 'pointer' });
  215. btn.addEventListener('click', onClick);
  216. return btn;
  217. };
  218.  
  219. container.appendChild(makeBtn('Upload T-Shirts', () => {
  220. const input = document.createElement('input');
  221. input.type = 'file'; input.accept = 'image/*'; input.multiple = true;
  222. input.addEventListener('change', e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT));
  223. input.click();
  224. }));
  225. container.appendChild(makeBtn('Upload Decals', () => {
  226. const input = document.createElement('input');
  227. input.type = 'file'; input.accept = 'image/*'; input.multiple = true;
  228. input.addEventListener('change', e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL));
  229. input.click();
  230. }));
  231. container.appendChild(makeBtn('Upload Both', () => {
  232. const input = document.createElement('input');
  233. input.type = 'file'; input.accept = 'image/*'; input.multiple = true;
  234. input.addEventListener('change', e => handleFileSelect(e.target.files, null, true));
  235. input.click();
  236. }));
  237. container.appendChild(makeBtn('Change ID', () => {
  238. const input = prompt("Enter your Roblox User ID or Profile URL:", USER_ID || '');
  239. if (!input) return;
  240. const urlMatch = input.match(/roblox\.com\/users\/(\d+)\/profile/i);
  241. let newId = urlMatch ? urlMatch[1] : (!isNaN(input.trim()) ? input.trim() : null);
  242. if (newId) {
  243. USER_ID = Number(newId);
  244. GM_setValue('userId', USER_ID);
  245. alert(`User ID updated to ${USER_ID}`);
  246. } else {
  247. alert("Invalid input. Please enter a numeric ID or a valid profile URL.");
  248. }
  249. }));
  250.  
  251. const pasteHint = document.createElement('div');
  252. pasteHint.textContent = 'Paste images (Ctrl+V) to upload—name & type it first!';
  253. pasteHint.style.fontSize = '12px';
  254. pasteHint.style.color = '#555';
  255. container.appendChild(pasteHint);
  256.  
  257. statusElement = document.createElement('div');
  258. statusElement.style.fontSize = '12px';
  259. statusElement.style.color = '#000';
  260. statusElement.textContent = '';
  261. container.appendChild(statusElement);
  262.  
  263. document.body.appendChild(container);
  264. }
  265.  
  266. function handlePaste(event) {
  267. const items = event.clipboardData?.items;
  268. if (!items) return;
  269. for (let item of items) {
  270. if (item.type.indexOf('image') === 0) {
  271. event.preventDefault();
  272. const blob = item.getAsFile();
  273. const now = new Date();
  274. const defaultBase = `pasted_image_${now.toISOString().replace(/[^a-z0-9]/gi, '_')}`;
  275.  
  276. let nameInput = prompt("Enter a name for the pasted image (no extension):", defaultBase);
  277. if (nameInput === null) return;
  278. nameInput = nameInput.trim() || defaultBase;
  279. const filename = nameInput.endsWith('.png') ? nameInput : `${nameInput}.png`;
  280.  
  281. let typeInput = prompt(
  282. "Upload as:\n T = T-Shirt\n D = Decal\n C = Cancel",
  283. "D"
  284. );
  285. if (!typeInput) return;
  286. typeInput = typeInput.trim().toUpperCase();
  287. let chosenType = null;
  288. if (typeInput === 'T') chosenType = ASSET_TYPE_TSHIRT;
  289. else if (typeInput === 'D') chosenType = ASSET_TYPE_DECAL;
  290. else return;
  291.  
  292. const file = new File([blob], filename, { type: blob.type });
  293. handleFileSelect([file], chosenType);
  294. break;
  295. }
  296. }
  297. }
  298.  
  299. function init() {
  300. createUploaderUI();
  301. document.addEventListener('paste', handlePaste);
  302. console.log('[Uploader] Fast mode initialized with User ID:', USER_ID);
  303. }
  304.  
  305. window.addEventListener('load', init);
  306. })();