AnnaUploader (Roblox Multi-File Uploader)

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

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

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