AnnaUploader (Roblox Multi-File Uploader) with Group Support

allows you to upload multiple T-Shirts/Decals easily with AnnaUploader, now supporting groups

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

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