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