AnnaUploader (Roblox Multi-File Uploader)

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