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.0
  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. let useMakeUnique = false; // toggle: false => upload as-is, true => 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; // UI element for copies
  42.  
  43. // Fetch CSRF token from Roblox
  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. // Modify one random pixel to a random color to force uniqueness
  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 logic with retry & forced-name handling
  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. let resp;
  101. try {
  102. resp = await fetch(ROBLOX_UPLOAD_URL, {
  103. method: 'POST',
  104. credentials: 'include',
  105. headers: { 'x-csrf-token': csrfToken },
  106. body: fd
  107. });
  108. if (resp.ok) {
  109. console.log(`✅ ${displayName}`);
  110. completed++;
  111. updateStatus();
  112. return;
  113. }
  114. const txt = await resp.text();
  115. let json; 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 (!resp?.ok) {
  131. completed++;
  132. updateStatus();
  133. }
  134. }
  135. }
  136.  
  137. // Update on-screen status
  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 selection / 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. // Queuing for mass-upload
  154. if (massMode) {
  155. for (let f of files) {
  156. for (let i = 0; i < copies; i++) {
  157. const toUse = useMakeUnique ? await makeUniqueFile(f) : f;
  158. if (both) {
  159. massQueue.push({ f: toUse, type: ASSET_TYPE_TSHIRT });
  160. massQueue.push({ f: toUse, type: ASSET_TYPE_DECAL });
  161. } else {
  162. massQueue.push({ f: toUse, type: assetType });
  163. }
  164. }
  165. }
  166. updateStatus();
  167. return;
  168. }
  169. // Immediate parallel upload
  170. const tasks = [];
  171. batchTotal = files.length * (both ? 2 : 1) * copies;
  172. completed = 0;
  173. updateStatus();
  174. for (let f of files) {
  175. for (let i = 0; i < copies; i++) {
  176. const toUse = useMakeUnique ? await makeUniqueFile(f) : f;
  177. if (both) {
  178. tasks.push(uploadFile(toUse, ASSET_TYPE_TSHIRT, 0, useForcedName));
  179. tasks.push(uploadFile(toUse, ASSET_TYPE_DECAL, 0, useForcedName));
  180. } else {
  181. tasks.push(uploadFile(toUse, assetType, 0, useForcedName));
  182. }
  183. }
  184. }
  185. Promise.all(tasks).then(() => console.log('[Uploader] done'));
  186. }
  187.  
  188. // Process the queued mass-upload items
  189. function startMassUpload() {
  190. if (massQueue.length === 0) return alert('Nothing queued!');
  191. batchTotal = massQueue.length;
  192. completed = 0;
  193. updateStatus();
  194.  
  195. const tasks = massQueue.map(item =>
  196. uploadFile(item.f, item.type, 0, useForcedName)
  197. );
  198. massQueue = [];
  199. updateStatus();
  200. Promise.all(tasks).then(() => {
  201. alert('Mass upload complete!');
  202. toggleBtn.textContent = 'Enable Mass Upload';
  203. massMode = false;
  204. startBtn.style.display = 'none';
  205. });
  206. }
  207.  
  208. // Build the little UI panel
  209. function createUploaderUI() {
  210. const c = document.createElement('div');
  211. Object.assign(c.style, {
  212. position:'fixed', top:'10px', right:'10px',
  213. background:'#fff', border:'2px solid #000', padding:'15px',
  214. zIndex:'10000', borderRadius:'8px', boxShadow:'0 4px 8px rgba(0,0,0,0.2)',
  215. display:'flex', flexDirection:'column', gap:'8px', fontFamily:'Arial', width:'260px'
  216. });
  217.  
  218. // Close
  219. const close = document.createElement('button');
  220. close.textContent = '×';
  221. Object.assign(close.style, {
  222. position:'absolute', top:'5px', right:'8px',
  223. background:'transparent', border:'none', fontSize:'16px', cursor:'pointer'
  224. });
  225. close.title = 'Close';
  226. close.onclick = () => c.remove();
  227. c.appendChild(close);
  228.  
  229. // Title
  230. const title = document.createElement('h3');
  231. title.textContent = 'AnnaUploader';
  232. title.style.margin = '0 0 5px 0';
  233. title.style.fontSize = '16px';
  234. c.appendChild(title);
  235.  
  236. // Button factory
  237. const makeBtn = (txt, fn) => {
  238. const b = document.createElement('button');
  239. b.textContent = txt;
  240. Object.assign(b.style, { padding:'8px', cursor:'pointer' });
  241. b.onclick = fn;
  242. return b;
  243. };
  244.  
  245. // Upload buttons
  246. c.appendChild(makeBtn('Upload T-Shirts', () => {
  247. const inp = document.createElement('input'); inp.type='file'; inp.accept='image/*'; inp.multiple=true;
  248. inp.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT);
  249. inp.click();
  250. }));
  251. c.appendChild(makeBtn('Upload Decals', () => {
  252. const inp = document.createElement('input'); inp.type='file'; inp.accept='image/*'; inp.multiple=true;
  253. inp.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL);
  254. inp.click();
  255. }));
  256. c.appendChild(makeBtn('Upload Both', () => {
  257. const inp = document.createElement('input'); inp.type='file'; inp.accept='image/*'; inp.multiple=true;
  258. inp.onchange = e => handleFileSelect(e.target.files, null, true);
  259. inp.click();
  260. }));
  261.  
  262. // Mass-upload toggle
  263. toggleBtn = makeBtn('Enable Mass Upload', () => {
  264. massMode = !massMode;
  265. toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload';
  266. startBtn.style.display = massMode ? 'block' : 'none';
  267. massQueue = [];
  268. batchTotal = 0; completed = 0;
  269. updateStatus();
  270. });
  271. c.appendChild(toggleBtn);
  272.  
  273. // Start mass-upload
  274. startBtn = makeBtn('Start Mass Upload', startMassUpload);
  275. startBtn.style.display = 'none';
  276. c.appendChild(startBtn);
  277.  
  278. // Forced-name toggle
  279. const nameToggleBtn = makeBtn(`Use default Name: Off`, () => {
  280. useForcedName = !useForcedName;
  281. nameToggleBtn.textContent = `Use default Name: ${useForcedName ? 'On' : 'Off'}`;
  282. });
  283. c.appendChild(nameToggleBtn);
  284.  
  285. // Make-unique (Slip Mode) toggle
  286. const uniqueToggleBtn = makeBtn(`Slip Mode: Off`, () => {
  287. useMakeUnique = !useMakeUnique;
  288. uniqueToggleBtn.textContent = `Slip Mode: ${useMakeUnique ? 'On' : 'Off'}`;
  289. // Show/hide copies input when slip mode toggles
  290. copiesInput.style.display = useMakeUnique ? 'block' : 'none';
  291. });
  292. c.appendChild(uniqueToggleBtn);
  293.  
  294. // Unique copies input (only visible when slip mode is on)
  295. copiesInput = document.createElement('input');
  296. copiesInput.type = 'number';
  297. copiesInput.min = '1';
  298. copiesInput.value = uniqueCopies;
  299. copiesInput.title = 'Number of unique copies';
  300. copiesInput.style.width = '100%';
  301. copiesInput.style.boxSizing = 'border-box';
  302. copiesInput.style.display = 'none';
  303. copiesInput.onchange = e => {
  304. const v = parseInt(e.target.value, 10);
  305. if (!isNaN(v) && v > 0) uniqueCopies = v;
  306. else e.target.value = uniqueCopies;
  307. };
  308. c.appendChild(copiesInput);
  309.  
  310. // Change user ID
  311. c.appendChild(makeBtn('Change ID', () => {
  312. const inp = prompt("Enter your Roblox User ID or Profile URL:", USER_ID || '');
  313. if (!inp) return;
  314. const m = inp.match(/users\/(\d+)/);
  315. const id = m ? m[1] : inp.trim();
  316. if (!isNaN(id)) {
  317. USER_ID = Number(id);
  318. GM_setValue('userId', USER_ID);
  319. alert(`User ID set to ${USER_ID}`);
  320. } else alert('Invalid input.');
  321. }));
  322.  
  323. // Use profile shortcut
  324. const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
  325. if (pm) {
  326. c.appendChild(makeBtn('Use This Profile as ID', () => {
  327. USER_ID = Number(pm[1]);
  328. GM_setValue('userId', USER_ID);
  329. alert(`User ID set to ${USER_ID}`);
  330. }));
  331. }
  332.  
  333. // Paste hint & status
  334. const hint = document.createElement('div');
  335. hint.textContent = 'Paste images (Ctrl+V) to queue/upload';
  336. hint.style.fontSize = '12px'; hint.style.color = '#555';
  337. c.appendChild(hint);
  338.  
  339. statusEl = document.createElement('div');
  340. statusEl.style.fontSize = '12px';
  341. statusEl.style.color = '#000';
  342. c.appendChild(statusEl);
  343.  
  344. document.body.appendChild(c);
  345. }
  346.  
  347. // Handle clipboard paste uploads
  348. function handlePaste(e) {
  349. const items = e.clipboardData?.items;
  350. if (!items) return;
  351. for (let it of items) {
  352. if (it.type.startsWith('image')) {
  353. e.preventDefault();
  354. const blob = it.getAsFile();
  355. const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_');
  356. let name = prompt('Name (no ext):', `pasted_${ts}`);
  357. if (name === null) return;
  358. name = name.trim() || `pasted_${ts}`;
  359. const filename = name.endsWith('.png') ? name : `${name}.png`;
  360. let t = prompt('T=T-Shirt, D=Decal, C=Cancel','D');
  361. if (!t) return;
  362. t = t.trim().toUpperCase();
  363. let type = null;
  364. if (t === 'T') type = ASSET_TYPE_TSHIRT;
  365. else if (t === 'D') type = ASSET_TYPE_DECAL;
  366. else return;
  367. const file = new File([blob], filename, {type: blob.type});
  368. handleFileSelect([file], type);
  369. break;
  370. }
  371. }
  372. }
  373.  
  374. // Initialize on page load
  375. window.addEventListener('load', () => {
  376. createUploaderUI();
  377. document.addEventListener('paste', handlePaste);
  378. console.log('[AnnaUploader] initialized, massMode=', massMode, 'useForcedName=', useForcedName, 'useMakeUnique=', useMakeUnique, 'uniqueCopies=', uniqueCopies);
  379. });
  380.  
  381. })();