AnnaUploader (Roblox Multi-File Uploader)

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

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

  1. // ==UserScript==
  2. // @name AnnaUploader (Roblox Multi-File Uploader)
  3. // @namespace https://www.guilded.gg/u/AnnaBlox
  4. // @version 5.7
  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', top:'10px', right:'10px', width:'260px',
  274. background:'#fff', border:'2px solid #000', padding:'15px',
  275. zIndex:10000, borderRadius:'8px', boxShadow:'0 4px 8px rgba(0,0,0,0.2)',
  276. display:'flex', flexDirection:'column', gap:'8px', fontFamily:'Arial'
  277. });
  278.  
  279. function btn(text, fn) {
  280. const b = document.createElement('button');
  281. b.textContent = text;
  282. Object.assign(b.style, { padding:'8px', cursor:'pointer' });
  283. b.onclick = fn;
  284. return b;
  285. }
  286.  
  287. const close = btn('×', () => c.remove());
  288. Object.assign(close.style, {
  289. position:'absolute', top:'5px', right:'8px',
  290. background:'transparent', border:'none', fontSize:'16px'
  291. });
  292. close.title = 'Close';
  293. c.appendChild(close);
  294.  
  295. const title = document.createElement('h3');
  296. title.textContent = 'AnnaUploader';
  297. title.style.margin = '0 0 5px 0';
  298. c.appendChild(title);
  299.  
  300. c.appendChild(btn('Upload T-Shirts', () => {
  301. const i = document.createElement('input');
  302. i.type='file'; i.accept='image/*'; i.multiple=true;
  303. i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT);
  304. i.click();
  305. }));
  306. c.appendChild(btn('Upload Decals', () => {
  307. const i = document.createElement('input');
  308. i.type='file'; i.accept='image/*'; i.multiple=true;
  309. i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL);
  310. i.click();
  311. }));
  312. c.appendChild(btn('Upload Both', () => {
  313. const i = document.createElement('input');
  314. i.type='file'; i.accept='image/*'; i.multiple=true;
  315. i.onchange = e => handleFileSelect(e.target.files, null, true);
  316. i.click();
  317. }));
  318.  
  319. toggleBtn = btn('Enable Mass Upload', () => {
  320. massMode = !massMode;
  321. toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload';
  322. startBtn.style.display = massMode ? 'block' : 'none';
  323. massQueue = []; batchTotal = completed = 0; updateStatus();
  324. });
  325. c.appendChild(toggleBtn);
  326.  
  327. startBtn = btn('Start Mass Upload', startMassUpload);
  328. startBtn.style.display = 'none';
  329. c.appendChild(startBtn);
  330.  
  331. const nameBtn = btn('Use default Name: Off', () => {
  332. useForcedName = !useForcedName;
  333. nameBtn.textContent = `Use default Name: ${useForcedName?'On':'Off'}`;
  334. });
  335. c.appendChild(nameBtn);
  336.  
  337. const slipBtn = btn('Slip Mode: Off', () => {
  338. useMakeUnique = !useMakeUnique;
  339. slipBtn.textContent = `Slip Mode: ${useMakeUnique?'On':'Off'}`;
  340. copiesInput.style.display = useMakeUnique ? 'block' : 'none';
  341. downloadBtn.style.display = useMakeUnique ? 'block' : 'none';
  342. if (!useMakeUnique) {
  343. useDownload = false;
  344. downloadBtn.textContent = 'Download Images: Off';
  345. }
  346. });
  347. c.appendChild(slipBtn);
  348.  
  349. copiesInput = document.createElement('input');
  350. copiesInput.type='number'; copiesInput.min='1'; copiesInput.value=uniqueCopies;
  351. copiesInput.style.width='100%'; copiesInput.style.boxSizing='border-box';
  352. copiesInput.style.display='none';
  353. copiesInput.onchange = e => {
  354. const v = parseInt(e.target.value,10);
  355. if (v>0) uniqueCopies = v;
  356. else e.target.value = uniqueCopies;
  357. };
  358. c.appendChild(copiesInput);
  359.  
  360. downloadBtn = btn('Download Images: Off', () => {
  361. useDownload = !useDownload;
  362. downloadBtn.textContent = `Download Images: ${useDownload?'On':'Off'}`;
  363. });
  364. downloadBtn.style.display = 'none';
  365. c.appendChild(downloadBtn);
  366.  
  367. c.appendChild(btn('Change ID', () => {
  368. const inp = prompt("Enter your Roblox User ID or Profile URL:", USER_ID||'');
  369. if (!inp) return;
  370. const m = inp.match(/users\/(\d+)/);
  371. const id = m ? m[1] : inp.trim();
  372. if (!isNaN(id)) {
  373. USER_ID = Number(id);
  374. GM_setValue('userId', USER_ID);
  375. alert(`User ID set to ${USER_ID}`);
  376. } else alert('Invalid input.');
  377. }));
  378.  
  379. const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
  380. if (pm) {
  381. c.appendChild(btn('Use This Profile as ID', () => {
  382. USER_ID = Number(pm[1]);
  383. GM_setValue('userId', USER_ID);
  384. alert(`User ID set to ${USER_ID}`);
  385. }));
  386. }
  387.  
  388. c.appendChild(btn('Show Logged Assets', () => {
  389. const log = loadLog();
  390. const entries = Object.entries(log);
  391. const w = window.open('', '_blank');
  392. w.document.write(`<!DOCTYPE html>
  393. <html><head><meta charset="utf-8"><title>Logged Assets</title>
  394. <style>
  395. body { font-family:Arial; padding:20px; background:#fff; color:#000; transition:background 0.3s, color 0.3s; }
  396. h1 { margin-bottom:10px; }
  397. ul { padding-left:20px; }
  398. li { margin-bottom:10px; }
  399. img { max-height:40px; border:1px solid #ccc; }
  400. .asset-name { font-size:90%; color:#333; margin-left:20px; }
  401. button { margin-bottom:10px; }
  402. </style></head><body>
  403. <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>
  404. <h1>Logged Assets</h1>
  405. ${ entries.length ? `<ul>${entries.map(([id,entry])=>`
  406. <li>
  407. <div style="display:flex;align-items:center;gap:10px;">
  408. ${ entry.image ? `<img src="${entry.image}" alt> ` : `<span>(no image)</span>` }
  409. <a href="https://create.roblox.com/store/asset/${id}" target="_blank">${id}</a> ${entry.date}
  410. </div>
  411. <div class="asset-name">${entry.name}</div>
  412. </li>`).join('') }</ul>` : `<p><em>No assets logged yet.</em></p>`}
  413. </body></html>`);
  414. w.document.close();
  415. }));
  416.  
  417. const hint = document.createElement('div');
  418. hint.textContent = 'Paste images (Ctrl+V) to queue/upload';
  419. hint.style.fontSize='12px'; hint.style.color='#555';
  420. c.appendChild(hint);
  421.  
  422. statusEl = document.createElement('div');
  423. statusEl.style.fontSize='12px'; statusEl.style.color='#000';
  424. c.appendChild(statusEl);
  425.  
  426. document.body.appendChild(c);
  427. }
  428.  
  429. function handlePaste(e) {
  430. const items = e.clipboardData?.items;
  431. if (!items) return;
  432. for (const it of items) {
  433. if (it.type.startsWith('image')) {
  434. e.preventDefault();
  435. const blob = it.getAsFile();
  436. const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_');
  437. let name = prompt('Name (no ext):', `pasted_${ts}`);
  438. if (name===null) return;
  439. name = name.trim()||`pasted_${ts}`;
  440. const filename = name.endsWith('.png')? name : `${name}.png`;
  441. let t = prompt('T=T-Shirt, D=Decal, C=Cancel','D');
  442. if (!t) return;
  443. t = t.trim().toUpperCase();
  444. const type = t==='T'? ASSET_TYPE_TSHIRT : t==='D'? ASSET_TYPE_DECAL : null;
  445. if (!type) return;
  446. handleFileSelect([new File([blob], filename, {type: blob.type})], type);
  447. break;
  448. }
  449. }
  450. }
  451.  
  452. window.addEventListener('load', () => {
  453. createUI();
  454. document.addEventListener('paste', handlePaste);
  455. scanForAssets();
  456. console.log('[AnnaUploader] v5.6.2 initialized; asset scan every ' + (SCAN_INTERVAL_MS/1000) + 's');
  457. });
  458.  
  459. })();