AnnaUploader (Roblox Multi-File Uploader)

allows you to upload multiple T-Shirts/Decals easily with AnnaUploader; retries with default name on name-too-long errors

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

  1. // ==UserScript==
  2. // @name AnnaUploader (Roblox Multi-File Uploader)
  3. // @namespace https://www.guilded.gg/u/AnnaBlox
  4. // @version 5.6
  5. // @description allows you to upload multiple T-Shirts/Decals easily with AnnaUploader; retries with default name on name-too-long errors
  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. function makeUniqueFile(file, origBase, copyIndex) {
  161. return new Promise(resolve => {
  162. const img = new Image();
  163. img.onload = () => {
  164. const canvas = document.createElement('canvas');
  165. canvas.width = img.width;
  166. canvas.height = img.height;
  167. const ctx = canvas.getContext('2d');
  168. ctx.drawImage(img, 0, 0);
  169. const x = Math.floor(Math.random() * canvas.width);
  170. const y = Math.floor(Math.random() * canvas.height);
  171. ctx.fillStyle = `rgba(${Math.random()*255|0},${Math.random()*255|0},${Math.random()*255|0},1)`;
  172. ctx.fillRect(x, y, 1, 1);
  173. canvas.toBlob(blob => {
  174. const ext = file.name.split('.').pop();
  175. const newName = `${origBase}_variant${copyIndex}.${ext}`;
  176. resolve(new File([blob], newName, { type: file.type }));
  177. }, file.type);
  178. };
  179. img.src = URL.createObjectURL(file);
  180. });
  181. }
  182.  
  183. async function handleFileSelect(files, assetType, both = false) {
  184. if (!files?.length) return;
  185.  
  186. const downloadsMap = {};
  187. const copies = useMakeUnique ? uniqueCopies : 1;
  188. batchTotal = files.length * (both ? 2 : 1) * copies;
  189. completed = 0;
  190. updateStatus();
  191.  
  192. const tasks = [];
  193.  
  194. for (const original of files) {
  195. const origBase = baseName(original.name);
  196. downloadsMap[origBase] = [];
  197.  
  198. for (let i = 1; i <= copies; i++) {
  199. const filePromise = useMakeUnique
  200. ? makeUniqueFile(original, origBase, i)
  201. : Promise.resolve(new File([original], `${origBase}_copy${i}.${original.name.split('.').pop()}`, { type: original.type }));
  202.  
  203. const fileTask = filePromise.then(toUse => {
  204. if (useMakeUnique && useDownload) downloadsMap[origBase].push(toUse);
  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. Promise.all(tasks).then(() => {
  218. console.log('[Uploader] batch done');
  219. scanForAssets();
  220. if (useMakeUnique && useDownload) {
  221. for (const [origBase, fileList] of Object.entries(downloadsMap)) {
  222. if (!fileList.length) continue;
  223. const zip = new JSZip();
  224. fileList.forEach(f => zip.file(f.name, f));
  225. zip.generateAsync({ type: 'blob' }).then(blob => {
  226. const url = URL.createObjectURL(blob);
  227. const a = document.createElement('a');
  228. a.href = url;
  229. a.download = `${origBase}.zip`;
  230. document.body.appendChild(a);
  231. a.click();
  232. document.body.removeChild(a);
  233. URL.revokeObjectURL(url);
  234. });
  235. }
  236. }
  237. });
  238. }
  239.  
  240. function startMassUpload() {
  241. if (!massQueue.length) return alert('Nothing queued!');
  242. batchTotal = massQueue.length;
  243. completed = 0;
  244. updateStatus();
  245.  
  246. const tasks = massQueue.map(item => uploadFile(item.f, item.type, 0, useForcedName));
  247. massQueue = [];
  248.  
  249. Promise.all(tasks).then(() => {
  250. alert('Mass upload complete!');
  251. massMode = false;
  252. toggleBtn.textContent = 'Enable Mass Upload';
  253. startBtn.style.display = 'none';
  254. scanForAssets();
  255. });
  256. }
  257.  
  258. function createUI() {
  259. const c = document.createElement('div');
  260. Object.assign(c.style, {
  261. position:'fixed', top:'10px', right:'10px', width:'260px',
  262. background:'#fff', border:'2px solid #000', padding:'15px',
  263. zIndex:10000, borderRadius:'8px', boxShadow:'0 4px 8px rgba(0,0,0,0.2)',
  264. display:'flex', flexDirection:'column', gap:'8px', fontFamily:'Arial'
  265. });
  266.  
  267. function btn(text, fn) {
  268. const b = document.createElement('button');
  269. b.textContent = text;
  270. Object.assign(b.style, { padding:'8px', cursor:'pointer' });
  271. b.onclick = fn;
  272. return b;
  273. }
  274.  
  275. const close = btn('×', () => c.remove());
  276. Object.assign(close.style, {
  277. position:'absolute', top:'5px', right:'8px',
  278. background:'transparent', border:'none', fontSize:'16px'
  279. });
  280. close.title = 'Close';
  281. c.appendChild(close);
  282.  
  283. const title = document.createElement('h3');
  284. title.textContent = 'AnnaUploader';
  285. title.style.margin = '0 0 5px 0';
  286. c.appendChild(title);
  287.  
  288. c.appendChild(btn('Upload T-Shirts', () => {
  289. const i = document.createElement('input');
  290. i.type='file'; i.accept='image/*'; i.multiple=true;
  291. i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT);
  292. i.click();
  293. }));
  294. c.appendChild(btn('Upload Decals', () => {
  295. const i = document.createElement('input');
  296. i.type='file'; i.accept='image/*'; i.multiple=true;
  297. i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL);
  298. i.click();
  299. }));
  300. c.appendChild(btn('Upload Both', () => {
  301. const i = document.createElement('input');
  302. i.type='file'; i.accept='image/*'; i.multiple=true;
  303. i.onchange = e => handleFileSelect(e.target.files, null, true);
  304. i.click();
  305. }));
  306.  
  307. toggleBtn = btn('Enable Mass Upload', () => {
  308. massMode = !massMode;
  309. toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload';
  310. startBtn.style.display = massMode ? 'block' : 'none';
  311. massQueue = []; batchTotal = completed = 0; updateStatus();
  312. });
  313. c.appendChild(toggleBtn);
  314.  
  315. startBtn = btn('Start Mass Upload', startMassUpload);
  316. startBtn.style.display = 'none';
  317. c.appendChild(startBtn);
  318.  
  319. const nameBtn = btn('Use default Name: Off', () => {
  320. useForcedName = !useForcedName;
  321. nameBtn.textContent = `Use default Name: ${useForcedName?'On':'Off'}`;
  322. });
  323. c.appendChild(nameBtn);
  324.  
  325. const slipBtn = btn('Slip Mode: Off', () => {
  326. useMakeUnique = !useMakeUnique;
  327. slipBtn.textContent = `Slip Mode: ${useMakeUnique?'On':'Off'}`;
  328. copiesInput.style.display = useMakeUnique ? 'block' : 'none';
  329. downloadBtn.style.display = useMakeUnique ? 'block' : 'none';
  330. if (!useMakeUnique) {
  331. useDownload = false;
  332. downloadBtn.textContent = 'Download Images: Off';
  333. }
  334. });
  335. c.appendChild(slipBtn);
  336.  
  337. copiesInput = document.createElement('input');
  338. copiesInput.type='number'; copiesInput.min='1'; copiesInput.value=uniqueCopies;
  339. copiesInput.style.width='100%'; copiesInput.style.boxSizing='border-box';
  340. copiesInput.style.display='none';
  341. copiesInput.onchange = e => {
  342. const v = parseInt(e.target.value,10);
  343. if (v>0) uniqueCopies = v;
  344. else e.target.value = uniqueCopies;
  345. };
  346. c.appendChild(copiesInput);
  347.  
  348. downloadBtn = btn('Download Images: Off', () => {
  349. useDownload = !useDownload;
  350. downloadBtn.textContent = `Download Images: ${useDownload?'On':'Off'}`;
  351. });
  352. downloadBtn.style.display = 'none';
  353. c.appendChild(downloadBtn);
  354.  
  355. c.appendChild(btn('Change ID', () => {
  356. const inp = prompt("Enter your Roblox User ID or Profile URL:", USER_ID||'');
  357. if (!inp) return;
  358. const m = inp.match(/users\/(\d+)/);
  359. const id = m ? m[1] : inp.trim();
  360. if (!isNaN(id)) {
  361. USER_ID = Number(id);
  362. GM_setValue('userId', USER_ID);
  363. alert(`User ID set to ${USER_ID}`);
  364. } else alert('Invalid input.');
  365. }));
  366.  
  367. const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
  368. if (pm) {
  369. c.appendChild(btn('Use This Profile as ID', () => {
  370. USER_ID = Number(pm[1]);
  371. GM_setValue('userId', USER_ID);
  372. alert(`User ID set to ${USER_ID}`);
  373. }));
  374. }
  375.  
  376. c.appendChild(btn('Show Logged Assets', () => {
  377. const log = loadLog();
  378. const entries = Object.entries(log);
  379. const w = window.open('', '_blank');
  380. w.document.write(`<!DOCTYPE html>
  381. <html><head><meta charset="utf-8"><title>Logged Assets</title>
  382. <style>
  383. body { font-family:Arial; padding:20px; background:#fff; color:#000; transition:background 0.3s, color 0.3s; }
  384. h1 { margin-bottom:10px; }
  385. ul { padding-left:20px; }
  386. li { margin-bottom:10px; }
  387. img { max-height:40px; border:1px solid #ccc; }
  388. .asset-name { font-size:90%; color:#333; margin-left:20px; }
  389. button { margin-bottom:10px; }
  390. </style></head><body>
  391. <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>
  392. <h1>Logged Assets</h1>
  393. ${ entries.length ? `<ul>${entries.map(([id,entry])=>`
  394. <li>
  395. <div style="display:flex;align-items:center;gap:10px;">
  396. ${ entry.image ? `<img src="${entry.image}" alt> ` : `<span>(no image)</span>` }
  397. <a href="https://create.roblox.com/store/asset/${id}" target="_blank">${id}</a> ${entry.date}
  398. </div>
  399. <div class="asset-name">${entry.name}</div>
  400. </li>`).join('') }</ul>` : `<p><em>No assets logged yet.</em></p>`}
  401. </body></html>`);
  402. w.document.close();
  403. }));
  404.  
  405. const hint = document.createElement('div');
  406. hint.textContent = 'Paste images (Ctrl+V) to queue/upload';
  407. hint.style.fontSize='12px'; hint.style.color='#555';
  408. c.appendChild(hint);
  409.  
  410. statusEl = document.createElement('div');
  411. statusEl.style.fontSize='12px'; statusEl.style.color='#000';
  412. c.appendChild(statusEl);
  413.  
  414. document.body.appendChild(c);
  415. }
  416.  
  417. function handlePaste(e) {
  418. const items = e.clipboardData?.items;
  419. if (!items) return;
  420. for (const it of items) {
  421. if (it.type.startsWith('image')) {
  422. e.preventDefault();
  423. const blob = it.getAsFile();
  424. const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_');
  425. let name = prompt('Name (no ext):', `pasted_${ts}`);
  426. if (name===null) return;
  427. name = name.trim()||`pasted_${ts}`;
  428. const filename = name.endsWith('.png')? name : `${name}.png`;
  429. let t = prompt('T=T-Shirt, D=Decal, C=Cancel','D');
  430. if (!t) return;
  431. t = t.trim().toUpperCase();
  432. const type = t==='T'? ASSET_TYPE_TSHIRT : t==='D'? ASSET_TYPE_DECAL : null;
  433. if (!type) return;
  434. handleFileSelect([new File([blob], filename, {type: blob.type})], type);
  435. break;
  436. }
  437. }
  438. }
  439.  
  440. window.addEventListener('load', () => {
  441. createUI();
  442. document.addEventListener('paste', handlePaste);
  443. scanForAssets();
  444. console.log('[AnnaUploader] v5.6 initialized; asset scan every ' + (SCAN_INTERVAL_MS/1000) + 's');
  445. });
  446.  
  447. })();