AnnaUploader (Roblox Multi-File Uploader)

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

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

  1. // ==UserScript==
  2. // @name AnnaUploader (Roblox Multi-File Uploader)
  3. // @namespace https://github.com/AnnaRoblox
  4. // @version 5.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. // @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. const txt = await resp.text();
  128. let json; try { json = JSON.parse(txt); } catch {}
  129. if (resp.ok && json.assetId) {
  130. logAsset(json.assetId, null, displayName);
  131. completed++;
  132. updateStatus();
  133. return;
  134. }
  135. // If name-too-long error, retry with default name
  136. if (json?.message === 'Asset name length is invalid.' && !forceName && retries < 5) {
  137. console.warn('[Upload] name too long, retrying with default name');
  138. return uploadFile(file, assetType, retries + 1, true);
  139. }
  140. // Moderation error: try forced name
  141. if (resp.status === 400 && json?.message?.includes('moderated') && retries < 5) {
  142. return uploadFile(file, assetType, retries + 1, true);
  143. }
  144. // CSRF expired: retry
  145. if (resp.status === 403 && retries < 5) {
  146. csrfToken = null;
  147. return uploadFile(file, assetType, retries + 1, forceName);
  148. }
  149. console.error(`[Upload] failed ${file.name} [${resp.status}]`, txt);
  150. } catch (e) {
  151. console.error('[Upload] error', e);
  152. } finally {
  153. if (completed < batchTotal) {
  154. completed++;
  155. updateStatus();
  156. }
  157. }
  158. }
  159.  
  160. // Slip Mode: subtly randomize ALL non-transparent pixels by ±1 per channel
  161. function makeUniqueFile(file, origBase, copyIndex) {
  162. return new Promise(resolve => {
  163. const img = new Image();
  164. img.onload = () => {
  165. const canvas = document.createElement('canvas');
  166. canvas.width = img.width;
  167. canvas.height = img.height;
  168. const ctx = canvas.getContext('2d');
  169. ctx.drawImage(img, 0, 0);
  170.  
  171. const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  172. const data = imageData.data;
  173. for (let i = 0; i < data.length; i += 4) {
  174. // Only tweak non-transparent pixels
  175. if (data[i + 3] !== 0) {
  176. // delta is either -1 or +1, randomly
  177. const delta = Math.random() < 0.5 ? -1 : 1;
  178. data[i] = Math.min(255, Math.max(0, data[i] + delta));
  179. data[i+1] = Math.min(255, Math.max(0, data[i+1] + delta));
  180. data[i+2] = Math.min(255, Math.max(0, data[i+2] + delta));
  181. }
  182. }
  183. ctx.putImageData(imageData, 0, 0);
  184.  
  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. c.appendChild(btn('Change ID', () => {
  391. const inp = prompt("Enter your Roblox User ID or Profile URL:", USER_ID || '');
  392. if (!inp) return;
  393. const m = inp.match(/users\/(\d+)/);
  394. const id = m ? m[1] : inp.trim();
  395. if (!isNaN(id)) {
  396. USER_ID = Number(id);
  397. GM_setValue('userId', USER_ID);
  398. alert(`User ID set to ${USER_ID}`);
  399. } else alert('Invalid input.');
  400. }));
  401.  
  402. const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
  403. if (pm) {
  404. c.appendChild(btn('Use This Profile as ID', () => {
  405. USER_ID = Number(pm[1]);
  406. GM_setValue('userId', USER_ID);
  407. alert(`User ID set to ${USER_ID}`);
  408. }));
  409. }
  410.  
  411. c.appendChild(btn('Show Logged Assets', () => {
  412. const log = loadLog();
  413. const entries = Object.entries(log);
  414. const w = window.open('', '_blank');
  415. w.document.write(`<!DOCTYPE html>
  416. <html><head><meta charset="utf-8"><title>Logged Assets</title>
  417. <style>
  418. body { font-family:Arial; padding:20px; background:#000; color:#fff; transition:background 0.3s, color 0.3s; }
  419. h1 { margin-bottom:10px; }
  420. ul { padding-left:20px; }
  421. li { margin-bottom:10px; }
  422. img { max-height:40px; border:1px solid #fff; }
  423. .asset-name { font-size:90%; color:#ccc; margin-left:20px; }
  424. button { margin-bottom:10px; color:#fff; background:#222; border:1px solid #555; padding:5px 10px; border-radius:4px; }
  425. </style></head><body>
  426. <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>
  427. <h1>Logged Assets</h1>
  428. ${ entries.length ? `<ul>${entries.map(([id,entry])=>
  429. `<li>
  430. <div style="display:flex;align-items:center;gap:10px;">
  431. ${ entry.image ? `<img src="${entry.image}" alt>` : `<span>(no image)</span>` }
  432. <a href="https://create.roblox.com/store/asset/${id}" target="_blank" style="color:#4af;">${id}</a> ${entry.date}
  433. </div>
  434. <div class="asset-name">${entry.name}</div>
  435. </li>`).join('') }</ul>` : `<p><em>No assets logged yet.</em></p>`}
  436. </body></html>`);
  437. w.document.close();
  438. }));
  439.  
  440. const hint = document.createElement('div');
  441. hint.textContent = 'Paste images (Ctrl+V) to queue/upload';
  442. hint.style.fontSize = '12px'; hint.style.color = '#aaa';
  443. c.appendChild(hint);
  444.  
  445. statusEl = document.createElement('div');
  446. statusEl.style.fontSize = '12px'; statusEl.style.color = '#fff';
  447. c.appendChild(statusEl);
  448.  
  449. document.body.appendChild(c);
  450. }
  451.  
  452. function handlePaste(e) {
  453. const items = e.clipboardData?.items;
  454. if (!items) return;
  455. for (const it of items) {
  456. if (it.type.startsWith('image')) {
  457. e.preventDefault();
  458. const blob = it.getAsFile();
  459. const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_');
  460. let name = prompt('Name (no ext):', `pasted_${ts}`);
  461. if (name===null) return;
  462. name = name.trim()||`pasted_${ts}`;
  463. const filename = name.endsWith('.png')? name : `${name}.png`;
  464. let t = prompt('T=T-Shirt, D=Decal, C=Cancel','D');
  465. if (!t) return;
  466. t = t.trim().toUpperCase();
  467. const type = t==='T'? ASSET_TYPE_TSHIRT : t==='D'? ASSET_TYPE_DECAL : null;
  468. if (!type) return;
  469. handleFileSelect([new File([blob], filename, {type: blob.type})], type);
  470. break;
  471. }
  472. }
  473. }
  474.  
  475. window.addEventListener('load', () => {
  476. createUI();
  477. document.addEventListener('paste', handlePaste);
  478. scanForAssets();
  479. console.log('[AnnaUploader] initialized; asset scan every ' + (SCAN_INTERVAL_MS/1000) + 's');
  480. });
  481.  
  482. })();