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