AnnaUploader (Roblox Multi-File Uploader)

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

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

  1. // ==UserScript==
  2. // @name AnnaUploader (Roblox Multi-File Uploader)
  3. // @namespace https://www.guilded.gg/u/AnnaBlox
  4. // @version 4.8
  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 = "Uploaded Using AnnaUploader";
  24.  
  25. // Stored settings
  26. let USER_ID = GM_getValue('userId', null);
  27. let useForcedName = false; // toggle: false => use file names, true => use FORCED_NAME
  28.  
  29. // Mass-upload state
  30. let massMode = false;
  31. let massQueue = [];
  32.  
  33. let csrfToken = null;
  34. let batchTotal = 0;
  35. let completed = 0;
  36. let statusEl = null;
  37. let toggleBtn = null;
  38. let startBtn = null;
  39.  
  40. async function fetchCSRFToken() {
  41. const resp = await fetch(ROBLOX_UPLOAD_URL, {
  42. method: 'POST',
  43. credentials: 'include',
  44. headers: {'Content-Type':'application/json'},
  45. body: JSON.stringify({})
  46. });
  47. if (resp.status === 403) {
  48. const tok = resp.headers.get('x-csrf-token');
  49. if (tok) {
  50. csrfToken = tok;
  51. console.log('[CSRF] fetched:', tok);
  52. return tok;
  53. }
  54. }
  55. throw new Error('Cannot fetch CSRF token');
  56. }
  57.  
  58. async function uploadFile(file, assetType, retries = 0, forceName = false) {
  59. if (!csrfToken) await fetchCSRFToken();
  60. const displayName = forceName ? FORCED_NAME : file.name.split('.')[0];
  61. const fd = new FormData();
  62. fd.append('fileContent', file, file.name);
  63. fd.append('request', JSON.stringify({
  64. displayName,
  65. description: FORCED_NAME,
  66. assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal",
  67. creationContext: { creator: { userId: USER_ID }, expectedPrice: 0 }
  68. }));
  69.  
  70. try {
  71. const resp = await fetch(ROBLOX_UPLOAD_URL, {
  72. method: 'POST',
  73. credentials: 'include',
  74. headers: { 'x-csrf-token': csrfToken },
  75. body: fd
  76. });
  77. if (resp.ok) {
  78. console.log(`✅ ${displayName}`);
  79. completed++;
  80. updateStatus();
  81. return;
  82. }
  83.  
  84. const txt = await resp.text();
  85. let json; try { json = JSON.parse(txt); } catch{}
  86. const badName = resp.status===400 && json?.message?.includes('moderated');
  87. if (badName && retries < MAX_RETRIES && !forceName) {
  88. await new Promise(r=>setTimeout(r, UPLOAD_RETRY_DELAY));
  89. return uploadFile(file, assetType, retries+1, true);
  90. }
  91. if (resp.status===403 && retries<MAX_RETRIES) {
  92. csrfToken = null;
  93. await new Promise(r=>setTimeout(r, UPLOAD_RETRY_DELAY));
  94. return uploadFile(file, assetType, retries+1, forceName);
  95. }
  96.  
  97. console.error(`❌ ${file.name}: [${resp.status}]`, txt);
  98. } catch(e) {
  99. console.error('Upload error', e);
  100. } finally {
  101. // even on error, count as “done” so status moves
  102. if (!resp?.ok) {
  103. completed++;
  104. updateStatus();
  105. }
  106. }
  107. }
  108.  
  109. function updateStatus() {
  110. if (!statusEl) return;
  111. if (batchTotal > 0) {
  112. statusEl.textContent = `${completed} of ${batchTotal} processed`;
  113. } else {
  114. statusEl.textContent = massMode
  115. ? `${massQueue.length} items queued`
  116. : '';
  117. }
  118. }
  119.  
  120. function handleFileSelect(files, assetType, both=false) {
  121. if (!files || files.length===0) return;
  122. if (massMode) {
  123. for (let f of files) {
  124. if (both) {
  125. massQueue.push({f,type:ASSET_TYPE_TSHIRT});
  126. massQueue.push({f,type:ASSET_TYPE_DECAL});
  127. } else {
  128. massQueue.push({f,type:assetType});
  129. }
  130. }
  131. updateStatus();
  132. return;
  133. }
  134.  
  135. // immediate parallel upload
  136. const tasks = [];
  137. batchTotal = both ? files.length*2 : files.length;
  138. completed = 0;
  139. updateStatus();
  140. for (let f of files) {
  141. if (both) {
  142. tasks.push(uploadFile(f, ASSET_TYPE_TSHIRT, 0, useForcedName));
  143. tasks.push(uploadFile(f, ASSET_TYPE_DECAL, 0, useForcedName));
  144. } else {
  145. tasks.push(uploadFile(f, assetType, 0, useForcedName));
  146. }
  147. }
  148. Promise.all(tasks).then(()=>console.log('[Uploader] done'));
  149. }
  150.  
  151. function startMassUpload() {
  152. if (massQueue.length===0) return alert('Nothing queued!');
  153. batchTotal = massQueue.length;
  154. completed = 0;
  155. updateStatus();
  156.  
  157. const tasks = massQueue.map(item => uploadFile(item.f, item.type, 0, useForcedName));
  158. massQueue = [];
  159. updateStatus();
  160. Promise.all(tasks).then(()=>{
  161. alert('Mass upload complete!');
  162. toggleBtn.textContent = 'Enable Mass Upload';
  163. massMode = false;
  164. startBtn.style.display = 'none';
  165. });
  166. }
  167.  
  168. function createUploaderUI() {
  169. const c = document.createElement('div');
  170. Object.assign(c.style, {
  171. position:'fixed', top:'10px', right:'10px',
  172. background:'#fff', border:'2px solid #000', padding:'15px',
  173. zIndex:'10000', borderRadius:'8px', boxShadow:'0 4px 8px rgba(0,0,0,0.2)',
  174. display:'flex', flexDirection:'column', gap:'8px', fontFamily:'Arial', width:'240px'
  175. });
  176.  
  177. // Close button
  178. const close = document.createElement('button');
  179. close.textContent = '×';
  180. Object.assign(close.style, {
  181. position: 'absolute', top: '5px', right: '8px',
  182. background: 'transparent', border: 'none', fontSize: '16px', cursor: 'pointer'
  183. });
  184. close.title = 'Close';
  185. close.onclick = () => c.remove();
  186. c.appendChild(close);
  187.  
  188. // Title
  189. const title = document.createElement('h3');
  190. title.textContent = 'AnnaUploader';
  191. title.style.margin = '0 0 5px 0';
  192. title.style.fontSize = '16px';
  193. c.appendChild(title);
  194.  
  195. // Buttons factory
  196. const makeBtn = (txt, fn) => {
  197. const b = document.createElement('button');
  198. b.textContent = txt;
  199. Object.assign(b.style, { padding: '8px', cursor: 'pointer' });
  200. b.onclick = fn;
  201. return b;
  202. };
  203.  
  204. // Upload controls
  205. c.appendChild(makeBtn('Upload T-Shirts', () => {
  206. const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.multiple = true;
  207. inp.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT);
  208. inp.click();
  209. }));
  210. c.appendChild(makeBtn('Upload Decals', () => {
  211. const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.multiple = true;
  212. inp.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL);
  213. inp.click();
  214. }));
  215. c.appendChild(makeBtn('Upload Both', () => {
  216. const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.multiple = true;
  217. inp.onchange = e => handleFileSelect(e.target.files, null, true);
  218. inp.click();
  219. }));
  220.  
  221. // Mass-upload toggle
  222. toggleBtn = makeBtn('Enable Mass Upload', () => {
  223. massMode = !massMode;
  224. toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload';
  225. startBtn.style.display = massMode ? 'block' : 'none';
  226. massQueue = [];
  227. batchTotal = 0; completed = 0;
  228. updateStatus();
  229. });
  230. c.appendChild(toggleBtn);
  231.  
  232. // Start button
  233. startBtn = makeBtn('Start Mass Upload', startMassUpload);
  234. startBtn.style.display = 'none';
  235. c.appendChild(startBtn);
  236.  
  237. // Forced name toggle
  238. const nameToggleBtn = makeBtn(`Use default Name: Off`, () => {
  239. useForcedName = !useForcedName;
  240. nameToggleBtn.textContent = `Use default Name: ${useForcedName ? 'On' : 'Off'}`;
  241. });
  242. c.appendChild(nameToggleBtn);
  243.  
  244. // Change ID
  245. c.appendChild(makeBtn('Change ID', () => {
  246. const inp = prompt("Enter your Roblox User ID or Profile URL:", USER_ID || '');
  247. if (!inp) return;
  248. const m = inp.match(/users\/(\d+)/);
  249. const id = m ? m[1] : inp.trim();
  250. if (!isNaN(id)) {
  251. USER_ID = Number(id);
  252. GM_setValue('userId', USER_ID);
  253. alert(`User ID set to ${USER_ID}`);
  254. } else alert('Invalid input.');
  255. }));
  256.  
  257. // Profile shortcut
  258. const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
  259. if (pm) {
  260. c.appendChild(makeBtn('Use This Profile as ID', () => {
  261. USER_ID = Number(pm[1]);
  262. GM_setValue('userId', USER_ID);
  263. alert(`User ID set to ${USER_ID}`);
  264. }));
  265. }
  266.  
  267. // Paste hint & status
  268. const hint = document.createElement('div');
  269. hint.textContent = 'Paste images (Ctrl+V) to queue/upload';
  270. hint.style.fontSize = '12px'; hint.style.color = '#555';
  271. c.appendChild(hint);
  272.  
  273. statusEl = document.createElement('div');
  274. statusEl.style.fontSize = '12px'; statusEl.style.color = '#000';
  275. c.appendChild(statusEl);
  276.  
  277. document.body.appendChild(c);
  278. }
  279.  
  280. function handlePaste(e) {
  281. const items = e.clipboardData?.items;
  282. if (!items) return;
  283. for (let it of items) {
  284. if (it.type.startsWith('image')) {
  285. e.preventDefault();
  286. const blob = it.getAsFile();
  287. const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_');
  288. let name = prompt('Name (no ext):', `pasted_${ts}`);
  289. if (name === null) return;
  290. name = name.trim() || `pasted_${ts}`;
  291. const filename = name.endsWith('.png') ? name : `${name}.png`;
  292. let t = prompt('T=T-Shirt, D=Decal, C=Cancel','D');
  293. if (!t) return;
  294. t = t.trim().toUpperCase();
  295. let type = null;
  296. if (t === 'T') type = ASSET_TYPE_TSHIRT;
  297. else if (t === 'D') type = ASSET_TYPE_DECAL;
  298. else return;
  299. const file = new File([blob], filename, {type: blob.type});
  300. handleFileSelect([file], type);
  301. break;
  302. }
  303. }
  304. }
  305.  
  306. window.addEventListener('load', () => {
  307. createUploaderUI();
  308. document.addEventListener('paste', handlePaste);
  309. console.log('[AnnaUploader] initialized, massMode=', massMode, 'useForcedName=', useForcedName);
  310. });
  311.  
  312. })();