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 5.1
  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; // false => use file names, true => use FORCED_NAME
  28. let useMakeUnique = false; // Slip Mode: tweak a random pixel for uniqueness
  29. let uniqueCopies = 1; // number of unique copies when Slip Mode is on
  30.  
  31. // Mass-upload state
  32. let massMode = false;
  33. let massQueue = [];
  34.  
  35. let csrfToken = null;
  36. let batchTotal = 0;
  37. let completed = 0;
  38. let statusEl = null;
  39. let toggleBtn = null;
  40. let startBtn = null;
  41. let copiesInput = null;
  42.  
  43. // Fetch CSRF token
  44. async function fetchCSRFToken() {
  45. const resp = await fetch(ROBLOX_UPLOAD_URL, {
  46. method: 'POST',
  47. credentials: 'include',
  48. headers: {'Content-Type':'application/json'},
  49. body: JSON.stringify({})
  50. });
  51. if (resp.status === 403) {
  52. const tok = resp.headers.get('x-csrf-token');
  53. if (tok) {
  54. csrfToken = tok;
  55. console.log('[CSRF] fetched:', tok);
  56. return tok;
  57. }
  58. }
  59. throw new Error('Cannot fetch CSRF token');
  60. }
  61.  
  62. // Make a file unique by altering one random pixel
  63. function makeUniqueFile(file) {
  64. return new Promise(resolve => {
  65. const img = new Image();
  66. img.onload = function() {
  67. const canvas = document.createElement('canvas');
  68. canvas.width = img.width;
  69. canvas.height = img.height;
  70. const ctx = canvas.getContext('2d');
  71. ctx.drawImage(img, 0, 0);
  72. const x = Math.floor(Math.random() * canvas.width);
  73. const y = Math.floor(Math.random() * canvas.height);
  74. const r = Math.floor(Math.random() * 256);
  75. const g = Math.floor(Math.random() * 256);
  76. const b = Math.floor(Math.random() * 256);
  77. ctx.fillStyle = `rgba(${r},${g},${b},1)`;
  78. ctx.fillRect(x, y, 1, 1);
  79. canvas.toBlob(blob => {
  80. const newFile = new File([blob], file.name, { type: file.type });
  81. resolve(newFile);
  82. }, file.type);
  83. };
  84. img.src = URL.createObjectURL(file);
  85. });
  86. }
  87.  
  88. // Core upload with retries & forced-name
  89. async function uploadFile(file, assetType, retries = 0, forceName = false) {
  90. if (!csrfToken) await fetchCSRFToken();
  91. const displayName = forceName ? FORCED_NAME : file.name.split('.')[0];
  92. const fd = new FormData();
  93. fd.append('fileContent', file, file.name);
  94. fd.append('request', JSON.stringify({
  95. displayName,
  96. description: FORCED_NAME,
  97. assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal",
  98. creationContext: { creator: { userId: USER_ID }, expectedPrice: 0 }
  99. }));
  100. try {
  101. const resp = await fetch(ROBLOX_UPLOAD_URL, {
  102. method: 'POST',
  103. credentials: 'include',
  104. headers: { 'x-csrf-token': csrfToken },
  105. body: fd
  106. });
  107. if (resp.ok) {
  108. console.log(`✅ ${displayName}`);
  109. completed++;
  110. updateStatus();
  111. return;
  112. }
  113. const txt = await resp.text();
  114. let json;
  115. try { json = JSON.parse(txt); } catch {}
  116. const badName = resp.status === 400 && json?.message?.includes('moderated');
  117. if (badName && retries < MAX_RETRIES && !forceName) {
  118. await new Promise(r => setTimeout(r, UPLOAD_RETRY_DELAY));
  119. return uploadFile(file, assetType, retries + 1, true);
  120. }
  121. if (resp.status === 403 && retries < MAX_RETRIES) {
  122. csrfToken = null;
  123. await new Promise(r => setTimeout(r, UPLOAD_RETRY_DELAY));
  124. return uploadFile(file, assetType, retries + 1, forceName);
  125. }
  126. console.error(`❌ ${file.name}: [${resp.status}]`, txt);
  127. } catch (e) {
  128. console.error('Upload error', e);
  129. } finally {
  130. if (completed < batchTotal) {
  131. completed++;
  132. updateStatus();
  133. }
  134. }
  135. }
  136.  
  137. // Update status text
  138. function updateStatus() {
  139. if (!statusEl) return;
  140. if (batchTotal > 0) {
  141. statusEl.textContent = `${completed} of ${batchTotal} processed`;
  142. } else {
  143. statusEl.textContent = massMode
  144. ? `${massQueue.length} items queued`
  145. : '';
  146. }
  147. }
  148.  
  149. // Handle file select / queuing / immediate upload
  150. async function handleFileSelect(files, assetType, both = false) {
  151. if (!files || files.length === 0) return;
  152. const copies = useMakeUnique ? uniqueCopies : 1;
  153.  
  154. // Mass mode: just queue
  155. if (massMode) {
  156. for (let f of files) {
  157. for (let i = 0; i < copies; i++) {
  158. const toUse = useMakeUnique ? await makeUniqueFile(f) : f;
  159. if (both) {
  160. massQueue.push({ f: toUse, type: ASSET_TYPE_TSHIRT });
  161. massQueue.push({ f: toUse, type: ASSET_TYPE_DECAL });
  162. } else {
  163. massQueue.push({ f: toUse, type: assetType });
  164. }
  165. }
  166. }
  167. updateStatus();
  168. return;
  169. }
  170.  
  171. // Immediate: kick off uploads
  172. batchTotal = files.length * (both ? 2 : 1) * copies;
  173. completed = 0;
  174. updateStatus();
  175.  
  176. const tasks = [];
  177. for (let f of files) {
  178. for (let i = 0; i < copies; i++) {
  179. const toUse = useMakeUnique ? await makeUniqueFile(f) : f;
  180. if (both) {
  181. tasks.push(uploadFile(toUse, ASSET_TYPE_TSHIRT, 0, useForcedName));
  182. tasks.push(uploadFile(toUse, ASSET_TYPE_DECAL, 0, useForcedName));
  183. } else {
  184. tasks.push(uploadFile(toUse, assetType, 0, useForcedName));
  185. }
  186. }
  187. }
  188. Promise.all(tasks).then(() => console.log('[Uploader] done'));
  189. }
  190.  
  191. // Start mass-upload processing
  192. function startMassUpload() {
  193. if (massQueue.length === 0) return alert('Nothing queued!');
  194. batchTotal = massQueue.length;
  195. completed = 0;
  196. updateStatus();
  197.  
  198. const tasks = massQueue.map(item =>
  199. uploadFile(item.f, item.type, 0, useForcedName)
  200. );
  201. massQueue = [];
  202. updateStatus();
  203. Promise.all(tasks).then(() => {
  204. alert('Mass upload complete!');
  205. toggleBtn.textContent = 'Enable Mass Upload';
  206. massMode = false;
  207. startBtn.style.display = 'none';
  208. });
  209. }
  210.  
  211. // Build the UI panel
  212. function createUploaderUI() {
  213. const c = document.createElement('div');
  214. Object.assign(c.style, {
  215. position:'fixed', top:'10px', right:'10px',
  216. background:'#fff', border:'2px solid #000', padding:'15px',
  217. zIndex:'10000', borderRadius:'8px', boxShadow:'0 4px 8px rgba(0,0,0,0.2)',
  218. display:'flex', flexDirection:'column', gap:'8px', fontFamily:'Arial', width:'260px'
  219. });
  220.  
  221. // Close button
  222. const close = document.createElement('button');
  223. close.textContent = '×';
  224. Object.assign(close.style, {
  225. position:'absolute', top:'5px', right:'8px',
  226. background:'transparent', border:'none', fontSize:'16px', cursor:'pointer'
  227. });
  228. close.title = 'Close';
  229. close.onclick = () => c.remove();
  230. c.appendChild(close);
  231.  
  232. // Title
  233. const title = document.createElement('h3');
  234. title.textContent = 'AnnaUploader';
  235. title.style.margin = '0 0 5px 0';
  236. title.style.fontSize = '16px';
  237. c.appendChild(title);
  238.  
  239. // Button factory
  240. const makeBtn = (txt, fn) => {
  241. const b = document.createElement('button');
  242. b.textContent = txt;
  243. Object.assign(b.style, { padding:'8px', cursor:'pointer' });
  244. b.onclick = fn;
  245. return b;
  246. };
  247.  
  248. // Upload T-Shirts
  249. c.appendChild(makeBtn('Upload T-Shirts', () => {
  250. const inp = document.createElement('input');
  251. inp.type='file';
  252. inp.accept='image/*';
  253. inp.multiple=true;
  254. inp.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT);
  255. inp.click();
  256. }));
  257.  
  258. // Upload Decals
  259. c.appendChild(makeBtn('Upload Decals', () => {
  260. const inp = document.createElement('input');
  261. inp.type='file';
  262. inp.accept='image/*';
  263. inp.multiple=true;
  264. inp.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL);
  265. inp.click();
  266. }));
  267.  
  268. // Upload Both
  269. c.appendChild(makeBtn('Upload Both', () => {
  270. const inp = document.createElement('input');
  271. inp.type='file';
  272. inp.accept='image/*';
  273. inp.multiple=true;
  274. inp.onchange = e => handleFileSelect(e.target.files, null, true);
  275. inp.click();
  276. }));
  277.  
  278. // Mass-upload toggle
  279. toggleBtn = makeBtn('Enable Mass Upload', () => {
  280. massMode = !massMode;
  281. toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload';
  282. startBtn.style.display = massMode ? 'block' : 'none';
  283. massQueue = [];
  284. batchTotal = 0;
  285. completed = 0;
  286. updateStatus();
  287. });
  288. c.appendChild(toggleBtn);
  289.  
  290. // Start mass-upload
  291. startBtn = makeBtn('Start Mass Upload', startMassUpload);
  292. startBtn.style.display = 'none';
  293. c.appendChild(startBtn);
  294.  
  295. // Forced-name toggle
  296. const nameToggleBtn = makeBtn(`Use default Name: Off`, () => {
  297. useForcedName = !useForcedName;
  298. nameToggleBtn.textContent = `Use default Name: ${useForcedName ? 'On' : 'Off'}`;
  299. });
  300. c.appendChild(nameToggleBtn);
  301.  
  302. // Slip Mode toggle
  303. const uniqueToggleBtn = makeBtn(`Slip Mode: Off`, () => {
  304. useMakeUnique = !useMakeUnique;
  305. uniqueToggleBtn.textContent = `Slip Mode: ${useMakeUnique ? 'On' : 'Off'}`;
  306. copiesInput.style.display = useMakeUnique ? 'block' : 'none';
  307. });
  308. c.appendChild(uniqueToggleBtn);
  309.  
  310. // Copies input
  311. copiesInput = document.createElement('input');
  312. copiesInput.type = 'number';
  313. copiesInput.min = '1';
  314. copiesInput.value = uniqueCopies;
  315. copiesInput.title = 'Number of unique copies';
  316. copiesInput.style.width = '100%';
  317. copiesInput.style.boxSizing = 'border-box';
  318. copiesInput.style.display = 'none';
  319. copiesInput.onchange = e => {
  320. const v = parseInt(e.target.value, 10);
  321. if (!isNaN(v) && v > 0) uniqueCopies = v;
  322. else e.target.value = uniqueCopies;
  323. };
  324. c.appendChild(copiesInput);
  325.  
  326. // Change user ID
  327. c.appendChild(makeBtn('Change ID', () => {
  328. const inp = prompt("Enter your Roblox User ID or Profile URL:", USER_ID || '');
  329. if (!inp) return;
  330. const m = inp.match(/users\/(\d+)/);
  331. const id = m ? m[1] : inp.trim();
  332. if (!isNaN(id)) {
  333. USER_ID = Number(id);
  334. GM_setValue('userId', USER_ID);
  335. alert(`User ID set to ${USER_ID}`);
  336. } else alert('Invalid input.');
  337. }));
  338.  
  339. // Use profile shortcut
  340. const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
  341. if (pm) {
  342. c.appendChild(makeBtn('Use This Profile as ID', () => {
  343. USER_ID = Number(pm[1]);
  344. GM_setValue('userId', USER_ID);
  345. alert(`User ID set to ${USER_ID}`);
  346. }));
  347. }
  348.  
  349. // Hint & status
  350. const hint = document.createElement('div');
  351. hint.textContent = 'Paste images (Ctrl+V) to queue/upload';
  352. hint.style.fontSize = '12px';
  353. hint.style.color = '#555';
  354. c.appendChild(hint);
  355.  
  356. statusEl = document.createElement('div');
  357. statusEl.style.fontSize = '12px';
  358. statusEl.style.color = '#000';
  359. c.appendChild(statusEl);
  360.  
  361. document.body.appendChild(c);
  362. }
  363.  
  364. // Handle paste
  365. function handlePaste(e) {
  366. const items = e.clipboardData?.items;
  367. if (!items) return;
  368. for (let it of items) {
  369. if (it.type.startsWith('image')) {
  370. e.preventDefault();
  371. const blob = it.getAsFile();
  372. const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_');
  373. let name = prompt('Name (no ext):', `pasted_${ts}`);
  374. if (name === null) return;
  375. name = name.trim() || `pasted_${ts}`;
  376. const filename = name.endsWith('.png') ? name : `${name}.png`;
  377. let t = prompt('T=T-Shirt, D=Decal, C=Cancel','D');
  378. if (!t) return;
  379. t = t.trim().toUpperCase();
  380. let type = null;
  381. if (t === 'T') type = ASSET_TYPE_TSHIRT;
  382. else if (t === 'D') type = ASSET_TYPE_DECAL;
  383. else return;
  384. const file = new File([blob], filename, {type: blob.type});
  385. handleFileSelect([file], type);
  386. break;
  387. }
  388. }
  389. }
  390.  
  391. // Init
  392. window.addEventListener('load', () => {
  393. createUploaderUI();
  394. document.addEventListener('paste', handlePaste);
  395. console.log('[AnnaUploader] initialized, massMode=', massMode,
  396. 'useForcedName=', useForcedName,
  397. 'useMakeUnique=', useMakeUnique,
  398. 'uniqueCopies=', uniqueCopies);
  399. });
  400.  
  401. })();