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.5
  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. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
  13. // @license MIT
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. const ROBLOX_UPLOAD_URL = "https://apis.roblox.com/assets/user-auth/v1/assets";
  20. const ASSET_TYPE_TSHIRT = 11;
  21. const ASSET_TYPE_DECAL = 13;
  22. const FORCED_NAME = "Uploaded Using AnnaUploader";
  23.  
  24. const STORAGE_KEY = 'annaUploaderAssetLog';
  25. const SCAN_INTERVAL_MS = 10_000;
  26.  
  27. let USER_ID = GM_getValue('userId', null);
  28. let useForcedName = false;
  29. let useMakeUnique = false;
  30. let uniqueCopies = 1;
  31. let useDownload = false;
  32.  
  33. let massMode = false;
  34. let massQueue = [];
  35. let batchTotal = 0;
  36. let completed = 0;
  37.  
  38. let csrfToken = null;
  39. let statusEl, toggleBtn, startBtn, copiesInput, downloadBtn;
  40.  
  41. // Utility: extract base name without extension
  42. function baseName(filename) {
  43. return filename.replace(/\.[^/.]+$/, '');
  44. }
  45.  
  46. function loadLog() {
  47. const raw = GM_getValue(STORAGE_KEY, '{}');
  48. try { return JSON.parse(raw); }
  49. catch { return {}; }
  50. }
  51.  
  52. function saveLog(log) {
  53. GM_setValue(STORAGE_KEY, JSON.stringify(log));
  54. }
  55.  
  56. function logAsset(id, imageURL, name) {
  57. const log = loadLog();
  58. log[id] = {
  59. date: new Date().toISOString(),
  60. image: imageURL || log[id]?.image || null,
  61. name: name || log[id]?.name || '(unknown)'
  62. };
  63. saveLog(log);
  64. console.log(`[AssetLogger] logged asset ${id} at ${log[id].date}, name: ${log[id].name}, image: ${log[id].image || "none"}`);
  65. }
  66.  
  67. function scanForAssets() {
  68. console.log('[AssetLogger] scanning for assets…');
  69. document.querySelectorAll('[href]').forEach(el => {
  70. let m = el.href.match(/(?:https?:\/\/create\.roblox\.com)?\/store\/asset\/(\d+)/)
  71. || el.href.match(/\/dashboard\/creations\/store\/(\d+)\/configure/);
  72. if (m) {
  73. const id = m[1];
  74. let image = null;
  75. const container = el.closest('*');
  76. const img = container?.querySelector('img');
  77. if (img?.src) image = img.src;
  78. let name = null;
  79. const nameEl = container?.querySelector('span.MuiTypography-root');
  80. if (nameEl) name = nameEl.textContent.trim();
  81. logAsset(id, image, name);
  82. }
  83. });
  84. }
  85. setInterval(scanForAssets, SCAN_INTERVAL_MS);
  86.  
  87. async function fetchCSRFToken() {
  88. const resp = await fetch(ROBLOX_UPLOAD_URL, {
  89. method: 'POST',
  90. credentials: 'include',
  91. headers: { 'Content-Type': 'application/json' },
  92. body: JSON.stringify({})
  93. });
  94. if (resp.status === 403) {
  95. const tok = resp.headers.get('x-csrf-token');
  96. if (tok) { csrfToken = tok; console.log('[CSRF] token fetched'); return tok; }
  97. }
  98. throw new Error('Cannot fetch CSRF token');
  99. }
  100.  
  101. function updateStatus() {
  102. if (!statusEl) return;
  103. if (batchTotal > 0) {
  104. statusEl.textContent = `${completed} of ${batchTotal} processed`;
  105. } else {
  106. statusEl.textContent = massMode ? `${massQueue.length} queued` : '';
  107. }
  108. }
  109.  
  110. async function uploadFile(file, assetType, retries = 0, forceName = false) {
  111. if (!csrfToken) await fetchCSRFToken();
  112. const displayName = forceName ? FORCED_NAME : baseName(file.name);
  113. const fd = new FormData();
  114. fd.append('fileContent', file, file.name);
  115. fd.append('request', JSON.stringify({
  116. displayName,
  117. description: FORCED_NAME,
  118. assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal",
  119. creationContext: { creator: { userId: USER_ID }, expectedPrice: 0 }
  120. }));
  121. try {
  122. const resp = await fetch(ROBLOX_UPLOAD_URL, {
  123. method: 'POST', credentials: 'include',
  124. headers: { 'x-csrf-token': csrfToken },
  125. body: fd
  126. });
  127. if (resp.ok) {
  128. const result = await resp.json();
  129. if (result.assetId) logAsset(result.assetId, null, displayName);
  130. completed++;
  131. updateStatus();
  132. return;
  133. }
  134. const txt = await resp.text();
  135. let json; try { json = JSON.parse(txt); } catch {}
  136. if (resp.status === 400 && json?.message?.includes('moderated') && retries < 5) {
  137. return uploadFile(file, assetType, retries+1, true);
  138. }
  139. if (resp.status === 403 && retries < 5) {
  140. csrfToken = null;
  141. return uploadFile(file, assetType, retries+1, forceName);
  142. }
  143. console.error(`[Upload] failed ${file.name} [${resp.status}]`, txt);
  144. } catch (e) {
  145. console.error('[Upload] error', e);
  146. } finally {
  147. if (completed < batchTotal) {
  148. completed++;
  149. updateStatus();
  150. }
  151. }
  152. }
  153.  
  154. function makeUniqueFile(file, origBase, copyIndex) {
  155. return new Promise(resolve => {
  156. const img = new Image();
  157. img.onload = () => {
  158. const canvas = document.createElement('canvas');
  159. canvas.width = img.width;
  160. canvas.height = img.height;
  161. const ctx = canvas.getContext('2d');
  162. ctx.drawImage(img, 0, 0);
  163. const x = Math.floor(Math.random() * canvas.width);
  164. const y = Math.floor(Math.random() * canvas.height);
  165. ctx.fillStyle = `rgba(${Math.random()*255|0},${Math.random()*255|0},${Math.random()*255|0},1)`;
  166. ctx.fillRect(x, y, 1, 1);
  167. canvas.toBlob(blob => {
  168. // rename to include copy index
  169. const ext = file.name.split('.').pop();
  170. const newName = `${origBase}_variant${copyIndex}.${ext}`;
  171. resolve(new File([blob], newName, { type: file.type }));
  172. }, file.type);
  173. };
  174. img.src = URL.createObjectURL(file);
  175. });
  176. }
  177.  
  178. async function handleFileSelect(files, assetType, both = false) {
  179. if (!files?.length) return;
  180.  
  181. // Map of baseName -> array of Files
  182. const downloadsMap = {};
  183.  
  184. const copies = useMakeUnique ? uniqueCopies : 1;
  185. batchTotal = files.length * (both ? 2 : 1) * copies;
  186. completed = 0;
  187. updateStatus();
  188.  
  189. const tasks = [];
  190.  
  191. for (const original of files) {
  192. const origBase = baseName(original.name);
  193. downloadsMap[origBase] = [];
  194.  
  195. for (let i = 1; i <= copies; i++) {
  196. const filePromise = useMakeUnique
  197. ? makeUniqueFile(original, origBase, i)
  198. : Promise.resolve(new File([original], `${origBase}_copy${i}.${original.name.split('.').pop()}`, { type: original.type }));
  199.  
  200. const fileTask = filePromise.then(toUse => {
  201. // collect for download if needed
  202. if (useMakeUnique && useDownload) downloadsMap[origBase].push(toUse);
  203.  
  204. // enqueue upload for TShirt/Decal
  205. if (both) {
  206. tasks.push(uploadFile(toUse, ASSET_TYPE_TSHIRT, 0, useForcedName));
  207. tasks.push(uploadFile(toUse, ASSET_TYPE_DECAL, 0, useForcedName));
  208. } else {
  209. tasks.push(uploadFile(toUse, assetType, 0, useForcedName));
  210. }
  211. });
  212.  
  213. await fileTask;
  214. }
  215. }
  216.  
  217. // wait for all uploads
  218. Promise.all(tasks).then(() => {
  219. console.log('[Uploader] batch done');
  220. scanForAssets();
  221.  
  222. // after uploads, trigger zips per original
  223. if (useMakeUnique && useDownload) {
  224. for (const [origBase, fileList] of Object.entries(downloadsMap)) {
  225. if (!fileList.length) continue;
  226. const zip = new JSZip();
  227. fileList.forEach(f => zip.file(f.name, f));
  228. zip.generateAsync({ type: 'blob' }).then(blob => {
  229. const url = URL.createObjectURL(blob);
  230. const a = document.createElement('a');
  231. a.href = url;
  232. a.download = `${origBase}.zip`;
  233. document.body.appendChild(a);
  234. a.click();
  235. document.body.removeChild(a);
  236. URL.revokeObjectURL(url);
  237. });
  238. }
  239. }
  240. });
  241. }
  242.  
  243. function startMassUpload() {
  244. if (!massQueue.length) return alert('Nothing queued!');
  245. batchTotal = massQueue.length;
  246. completed = 0;
  247. updateStatus();
  248.  
  249. const tasks = massQueue.map(item => uploadFile(item.f, item.type, 0, useForcedName));
  250. massQueue = [];
  251.  
  252. Promise.all(tasks).then(() => {
  253. alert('Mass upload complete!');
  254. massMode = false;
  255. toggleBtn.textContent = 'Enable Mass Upload';
  256. startBtn.style.display = 'none';
  257. scanForAssets();
  258. // note: mass-mode download for slip is not supported by design
  259. });
  260. }
  261.  
  262. function createUI() {
  263. const c = document.createElement('div');
  264. Object.assign(c.style, {
  265. position:'fixed', top:'10px', right:'10px', width:'260px',
  266. background:'#fff', border:'2px solid #000', padding:'15px',
  267. zIndex:10000, borderRadius:'8px', boxShadow:'0 4px 8px rgba(0,0,0,0.2)',
  268. display:'flex', flexDirection:'column', gap:'8px', fontFamily:'Arial'
  269. });
  270.  
  271. function btn(text, fn) {
  272. const b = document.createElement('button');
  273. b.textContent = text;
  274. Object.assign(b.style, { padding:'8px', cursor:'pointer' });
  275. b.onclick = fn;
  276. return b;
  277. }
  278.  
  279. const close = btn('×', () => c.remove());
  280. Object.assign(close.style, {
  281. position:'absolute', top:'5px', right:'8px',
  282. background:'transparent', border:'none', fontSize:'16px'
  283. });
  284. close.title = 'Close';
  285. c.appendChild(close);
  286.  
  287. const title = document.createElement('h3');
  288. title.textContent = 'AnnaUploader';
  289. title.style.margin = '0 0 5px 0';
  290. c.appendChild(title);
  291.  
  292. c.appendChild(btn('Upload T-Shirts', () => {
  293. const i = document.createElement('input');
  294. i.type='file'; i.accept='image/*'; i.multiple=true;
  295. i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT);
  296. i.click();
  297. }));
  298. c.appendChild(btn('Upload Decals', () => {
  299. const i = document.createElement('input');
  300. i.type='file'; i.accept='image/*'; i.multiple=true;
  301. i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL);
  302. i.click();
  303. }));
  304. c.appendChild(btn('Upload Both', () => {
  305. const i = document.createElement('input');
  306. i.type='file'; i.accept='image/*'; i.multiple=true;
  307. i.onchange = e => handleFileSelect(e.target.files, null, true);
  308. i.click();
  309. }));
  310.  
  311. toggleBtn = btn('Enable Mass Upload', () => {
  312. massMode = !massMode;
  313. toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload';
  314. startBtn.style.display = massMode ? 'block' : 'none';
  315. massQueue = []; batchTotal = completed = 0; updateStatus();
  316. });
  317. c.appendChild(toggleBtn);
  318.  
  319. startBtn = btn('Start Mass Upload', startMassUpload);
  320. startBtn.style.display = 'none';
  321. c.appendChild(startBtn);
  322.  
  323. const nameBtn = btn('Use default Name: Off', () => {
  324. useForcedName = !useForcedName;
  325. nameBtn.textContent = `Use default Name: ${useForcedName?'On':'Off'}`;
  326. });
  327. c.appendChild(nameBtn);
  328.  
  329. const slipBtn = btn('Slip Mode: Off', () => {
  330. useMakeUnique = !useMakeUnique;
  331. slipBtn.textContent = `Slip Mode: ${useMakeUnique?'On':'Off'}`;
  332. copiesInput.style.display = useMakeUnique ? 'block' : 'none';
  333. downloadBtn.style.display = useMakeUnique ? 'block' : 'none';
  334. if (!useMakeUnique) {
  335. useDownload = false;
  336. downloadBtn.textContent = 'Download Images: Off';
  337. }
  338. });
  339. c.appendChild(slipBtn);
  340.  
  341. copiesInput = document.createElement('input');
  342. copiesInput.type='number'; copiesInput.min='1'; copiesInput.value=uniqueCopies;
  343. copiesInput.style.width='100%'; copiesInput.style.boxSizing='border-box';
  344. copiesInput.style.display='none';
  345. copiesInput.onchange = e => {
  346. const v = parseInt(e.target.value,10);
  347. if (v>0) uniqueCopies = v;
  348. else e.target.value = uniqueCopies;
  349. };
  350. c.appendChild(copiesInput);
  351.  
  352. downloadBtn = btn('Download Images: Off', () => {
  353. useDownload = !useDownload;
  354. downloadBtn.textContent = `Download Images: ${useDownload?'On':'Off'}`;
  355. });
  356. downloadBtn.style.display = 'none';
  357. c.appendChild(downloadBtn);
  358.  
  359. c.appendChild(btn('Change ID', () => {
  360. const inp = prompt("Enter your Roblox User ID or Profile URL:", USER_ID||'');
  361. if (!inp) return;
  362. const m = inp.match(/users\/(\d+)/);
  363. const id = m ? m[1] : inp.trim();
  364. if (!isNaN(id)) {
  365. USER_ID = Number(id);
  366. GM_setValue('userId', USER_ID);
  367. alert(`User ID set to ${USER_ID}`);
  368. } else alert('Invalid input.');
  369. }));
  370.  
  371. const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
  372. if (pm) {
  373. c.appendChild(btn('Use This Profile as ID', () => {
  374. USER_ID = Number(pm[1]);
  375. GM_setValue('userId', USER_ID);
  376. alert(`User ID set to ${USER_ID}`);
  377. }));
  378. }
  379.  
  380. c.appendChild(btn('Show Logged Assets', () => {
  381. const log = loadLog();
  382. const entries = Object.entries(log);
  383. const w = window.open('', '_blank');
  384. w.document.write(`<!DOCTYPE html>
  385. <html><head><meta charset="utf-8"><title>Logged Assets</title>
  386. <style>
  387. body { font-family:Arial; padding:20px; background:#fff; color:#000; transition:background 0.3s, color 0.3s; }
  388. h1 { margin-bottom:10px; }
  389. ul { padding-left:20px; }
  390. li { margin-bottom:10px; }
  391. img { max-height:40px; border:1px solid #ccc; }
  392. .asset-name { font-size:90%; color:#333; margin-left:20px; }
  393. button { margin-bottom:10px; }
  394. </style></head><body>
  395. <button onclick="document.body.style.background=(document.body.style.background==='black'?'white':'black');document.body.style.color=(document.body.style.color==='white'?'black':'white');document.querySelectorAll('img').forEach(i=>i.style.border=(document.body.style.background==='black'?'1px solid #fff':'1px solid #ccc'));">Toggle Background</button>
  396. <h1>Logged Assets</h1>
  397. ${ entries.length ? `<ul>${entries.map(([id,entry])=>`
  398. <li>
  399. <div style="display:flex;align-items:center;gap:10px;">
  400. ${ entry.image ? `<img src="${entry.image}" alt> ` : `<span>(no image)</span>` }
  401. <a href="https://create.roblox.com/store/asset/${id}" target="_blank">${id}</a> ${entry.date}
  402. </div>
  403. <div class="asset-name">${entry.name}</div>
  404. </li>`).join('') }</ul>` : `<p><em>No assets logged yet.</em></p>`}
  405. </body></html>`);
  406. w.document.close();
  407. }));
  408.  
  409. const hint = document.createElement('div');
  410. hint.textContent = 'Paste images (Ctrl+V) to queue/upload';
  411. hint.style.fontSize='12px'; hint.style.color='#555';
  412. c.appendChild(hint);
  413.  
  414. statusEl = document.createElement('div');
  415. statusEl.style.fontSize='12px'; statusEl.style.color='#000';
  416. c.appendChild(statusEl);
  417.  
  418. document.body.appendChild(c);
  419. }
  420.  
  421. function handlePaste(e) {
  422. const items = e.clipboardData?.items;
  423. if (!items) return;
  424. for (const it of items) {
  425. if (it.type.startsWith('image')) {
  426. e.preventDefault();
  427. const blob = it.getAsFile();
  428. const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_');
  429. let name = prompt('Name (no ext):', `pasted_${ts}`);
  430. if (name===null) return;
  431. name = name.trim()||`pasted_${ts}`;
  432. const filename = name.endsWith('.png')? name : `${name}.png`;
  433. let t = prompt('T=T-Shirt, D=Decal, C=Cancel','D');
  434. if (!t) return;
  435. t = t.trim().toUpperCase();
  436. const type = t==='T'? ASSET_TYPE_TSHIRT : t==='D'? ASSET_TYPE_DECAL : null;
  437. if (!type) return;
  438. handleFileSelect([new File([blob], filename, {type: blob.type})], type);
  439. break;
  440. }
  441. }
  442. }
  443.  
  444. window.addEventListener('load', () => {
  445. createUI();
  446. document.addEventListener('paste', handlePaste);
  447. scanForAssets();
  448. console.log('[AnnaUploader] v5.5 initialized; asset scan every ' + (SCAN_INTERVAL_MS/1000) + 's');
  449. });
  450.  
  451. })();