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