Greasy Fork is available in English.
A GUI for extracting rooms, inventions, and downloading blobs on rec.net
// ==UserScript==
// @name Rec.Net Data Extractor
// @namespace http://tampermonkey.net/
// @version 1.2
// @description A GUI for extracting rooms, inventions, and downloading blobs on rec.net
// @author VT
// @match *://rec.net/*
// @match *://*.rec.net/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @icon https://www.google.com/s2/favicons?sz=64&domain=rec.net
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
function initGUI() {
if (document.getElementById('recnet-extractor-gui-host')) return;
const host = document.createElement('div');
host.id = 'recnet-extractor-gui-host';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const styles = `
* { box-sizing: border-box; }
/* The main floating toggle button */
#main-toggle-btn {
position: fixed; bottom: 20px; right: 20px;
background-color: #2563eb; color: white; border: none; border-radius: 50px;
padding: 12px 20px; font-weight: 600; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
transition: background-color 0.2s, transform 0.2s;
}
#main-toggle-btn:hover { background-color: #1d4ed8; transform: scale(1.05); }
/* Main GUI Container */
#gui-container {
position: fixed; bottom: 75px; right: 20px; width: 360px;
max-height: calc(100vh - 100px); background-color: #1f2937;
color: #f3f4f6; border: 1px solid #374151; border-radius: 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.5); display: none; /* Hidden by default */
flex-direction: column; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
z-index: 999999;
}
#gui-header {
background-color: #111827; padding: 12px 16px; display: flex;
justify-content: space-between; align-items: center; border-bottom: 1px solid #374151;
}
#gui-header h3 { margin: 0; font-size: 16px; color: #60a5fa; font-weight: 600; }
#close-btn {
background: transparent; color: #9ca3af; border: none;
font-size: 20px; cursor: pointer; line-height: 1; outline: none;
}
#close-btn:hover { color: white; }
#gui-body { padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
.section { background-color: #374151; padding: 12px; border-radius: 8px; }
.section h4 { margin: 0 0 10px 0; font-size: 12px; text-transform: uppercase; color: #d1d5db; letter-spacing: 0.05em; }
.btn-group { display: flex; flex-direction: column; gap: 8px; }
.btn {
width: 100%; padding: 8px 12px; border: none; border-radius: 6px;
font-weight: 600; cursor: pointer; font-size: 13px; color: white; transition: background-color 0.2s;
}
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-blue { background-color: #2563eb; } .btn-blue:hover:not(:disabled) { background-color: #1d4ed8; }
.btn-indigo { background-color: #4f46e5; } .btn-indigo:hover:not(:disabled) { background-color: #4338ca; }
.btn-purple { background-color: #7c3aed; } .btn-purple:hover:not(:disabled) { background-color: #6d28d9; }
.btn-green { background-color: #16a34a; } .btn-green:hover:not(:disabled) { background-color: #15803d; }
.text-input {
width: 100%; padding: 8px 10px; background-color: #1f2937; border: 1px solid #4b5563;
color: white; border-radius: 6px; margin-bottom: 8px; font-size: 13px;
}
.text-input:focus { outline: 2px solid #3b82f6; border-color: transparent; }
.checkbox-label { display: flex; align-items: center; font-size: 13px; color: #d1d5db; margin-bottom: 10px; cursor: pointer; }
.checkbox-label input { margin-right: 6px; cursor: pointer; }
.file-label { display: block; font-size: 12px; color: #9ca3af; margin-bottom: 4px; }
.file-input { width: 100%; font-size: 12px; color: #d1d5db; margin-bottom: 12px; }
.file-input::file-selector-button {
background-color: #4b5563; color: white; border: none; padding: 4px 8px;
border-radius: 4px; cursor: pointer; margin-right: 8px; transition: 0.2s;
}
.file-input::file-selector-button:hover { background-color: #6b7280; }
.log-area {
width: 100%; height: 100px; background-color: #030712; color: #9ca3af;
border: 1px solid #4b5563; border-radius: 6px; padding: 8px;
font-family: monospace; font-size: 11px; resize: vertical; outline: none;
}
.progress-section { margin-top: 12px; }
.progress-section.hidden { display: none; }
.progress-header { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; color: #d1d5db; }
.progress-track { width: 100%; background-color: #4b5563; border-radius: 999px; height: 8px; overflow: hidden; }
.progress-fill { background-color: #3b82f6; height: 100%; width: 0%; transition: width 0.3s; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 3px; }
`;
const html = `
<button id="main-toggle-btn">Open Extractor</button>
<div id="gui-container">
<div id="gui-header">
<h3>Rec.Net Extractor</h3>
<button id="close-btn" title="Close">×</button>
</div>
<div id="gui-body">
<div class="section">
<h4>Your Data Exports</h4>
<div class="btn-group">
<button id="btn-my-rooms" class="btn btn-blue">Export Owned Rooms</button>
<button id="btn-my-inventions" class="btn btn-blue">Export My Inventions</button>
<button id="btn-my-images" class="btn btn-blue">Export Saved Images</button>
</div>
</div>
<div class="section">
<h4>Search Inventions</h4>
<input type="text" id="inp-inv-query" placeholder="Invention search query..." class="text-input">
<label class="checkbox-label">
<input type="checkbox" id="chk-inv-auth" checked> Use Authentication
</label>
<button id="btn-search-inv" class="btn btn-indigo">Search Inventions</button>
</div>
<div class="section">
<h4>Search Rooms</h4>
<input type="text" id="inp-room-query" placeholder="Room search query..." class="text-input">
<label class="checkbox-label" title="Filters out partial matches that rec.net normally allows">
<input type="checkbox" id="chk-room-strict" checked> Strict Name Match
</label>
<button id="btn-search-rooms" class="btn btn-purple">Search Rooms</button>
</div>
<div class="section">
<h4>Blob Extractor</h4>
<label class="file-label">Upload Inventions JSON</label>
<input type="file" id="file-inventions" accept=".json" class="file-input">
<label class="file-label">Upload Rooms/Saves JSON</label>
<input type="file" id="file-rooms" accept=".json" class="file-input">
<button id="btn-extract-blobs" class="btn btn-green">Extract & Download Blobs</button>
<div id="progressSection" class="progress-section hidden">
<div class="progress-header">
<span id="statusText">Preparing...</span>
<span id="progressCount">0 / 0</span>
</div>
<div class="progress-track">
<div id="progressBar" class="progress-fill"></div>
</div>
</div>
</div>
<div class="section">
<h4>Terminal</h4>
<textarea id="logArea" readonly class="log-area" placeholder="Awaiting user action..."></textarea>
</div>
</div>
</div>
`;
shadow.innerHTML = `<style>${styles}</style>${html}`;
bindLogic(shadow);
}
function bindLogic(shadow) {
const logArea = shadow.getElementById('logArea');
const guiContainer = shadow.getElementById('gui-container');
const mainToggleBtn = shadow.getElementById('main-toggle-btn');
const closeBtn = shadow.getElementById('close-btn');
let isOpen = false;
mainToggleBtn.addEventListener('click', () => {
isOpen = !isOpen;
guiContainer.style.display = isOpen ? 'flex' : 'none';
});
closeBtn.addEventListener('click', () => {
isOpen = false;
guiContainer.style.display = 'none';
});
function logMsg(msg) {
console.log("[RecNet Extractor] " + msg);
if (logArea) {
logArea.value += msg + '\n';
logArea.scrollTop = logArea.scrollHeight;
}
}
function triggerDownload(blob, filename) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function getJsonSecurely(response) {
const text = await response.text();
const fixedText = text.replace(/:(\s*)(\d{15,})/g, ':"$2"');
return JSON.parse(fixedText);
}
async function fetchWithRetry(url, options) {
while (true) {
const response = await fetch(url, options);
if (response.status === 429) {
logMsg(`Rate limited (429). Waiting 10s...`);
await wait(10000);
continue;
}
return response;
}
}
function getSession() {
try {
const session = JSON.parse(localStorage.getItem("na_current_user_session"));
if (!session || !session.accessToken) {
logMsg("Auth token not found. Make sure you are logged into rec.net.");
return null;
}
return session;
} catch (e) {
logMsg("Error parsing session. Ensure you are logged in.");
return null;
}
}
shadow.getElementById('btn-my-rooms').addEventListener('click', async (e) => {
const btn = e.target;
const session = getSession();
if (!session) return;
btn.disabled = true;
const headers = { "accept": "application/json, text/plain, */*", "authorization": "Bearer " + session.accessToken, "cache-control": "no-cache", "pragma": "no-cache" };
const finalData = { Rooms: {}, Saves: {} };
try {
logMsg("Fetching owned rooms list...");
const ownedRoomsResponse = await fetchWithRetry("https://rooms.rec.net/rooms/ownedby/me", { headers });
if (!ownedRoomsResponse.ok) throw new Error("Failed to fetch owned rooms");
const ownedRooms = await getJsonSecurely(ownedRoomsResponse);
for (const basicRoom of ownedRooms) {
const roomId = basicRoom.RoomId;
logMsg(`Fetching details for Room: ${basicRoom.FriendlyName || basicRoom.Name} (${roomId})...`);
const detailResponse = await fetchWithRetry(`https://rooms.rec.net/rooms/${roomId}?include=1`, { headers });
if (!detailResponse.ok) continue;
const roomDetails = await getJsonSecurely(detailResponse);
finalData.Rooms[roomId] = roomDetails;
if (roomDetails.SubRooms && Array.isArray(roomDetails.SubRooms)) {
for (const subRoom of roomDetails.SubRooms) {
const subRoomId = subRoom.SubRoomId;
logMsg(` Fetching saves for SubRoom: ${subRoom.Name} (${subRoomId})...`);
const savesResponse = await fetchWithRetry(`https://rooms.rec.net/rooms/${roomId}/subrooms/${subRoomId}/saves?skip=0&take=100`, { headers });
if (savesResponse.ok) finalData.Saves[subRoomId] = await getJsonSecurely(savesResponse);
}
}
}
triggerDownload(new Blob([JSON.stringify(finalData, null, 2)], { type: 'application/json' }), 'myrooms.json');
logMsg("Success! Compiled data saved to myrooms.json");
} catch (err) { logMsg("Error: " + err.message); } finally { btn.disabled = false; }
});
shadow.getElementById('btn-my-images').addEventListener('click', async (e) => {
const btn = e.target;
const session = getSession();
if (!session) return;
const progressSection = shadow.getElementById('progressSection');
const progressBar = shadow.getElementById('progressBar');
const progressCount = shadow.getElementById('progressCount');
const statusText = shadow.getElementById('statusText');
btn.disabled = true;
progressSection.classList.remove('hidden');
try {
logMsg("Fetching saved images list...");
statusText.innerText = "Fetching list...";
statusText.style.color = "#d1d5db";
const listResponse = await fetch("https://api.rec.net/api/images/v1/listsaved", {
"headers": {
"accept": "application/json, text/plain, */*",
"authorization": "Bearer " + session.accessToken,
"cache-control": "no-cache",
"pragma": "no-cache"
},
"referrer": "https://rec.net/",
"method": "GET",
"mode": "cors",
"credentials": "include"
});
if (!listResponse.ok) throw new Error(`HTTP error! status: ${listResponse.status}`);
const data = await listResponse.json();
if (!data.Images || data.Images.length === 0) {
statusText.innerText = "Done (No images)";
return logMsg("No saved images found on this account.");
}
const totalItems = data.Images.length;
logMsg(`Found ${totalItems} saved images. Starting downloads...`);
statusText.innerText = "Downloading images...";
const itemsToFetch = data.Images.map(filename => ({
url: `https://img.rec.net/${filename}`,
filename: filename
}));
const fetchResults = await fetchWithConcurrency(itemsToFetch, 10, (completed, total) => {
progressBar.style.width = `${Math.round((completed / total) * 100)}%`;
progressCount.innerText = `${completed} / ${total}`;
});
logMsg("Image downloads complete. Zipping files...");
statusText.innerText = "Compressing to ZIP...";
const ZipClass = typeof window.JSZip !== 'undefined' ? window.JSZip : JSZip;
const zip = new ZipClass();
const imgFolder = zip.folder("saved_images");
let successCount = 0;
fetchResults.forEach(res => {
if (res.success && res.blob) {
imgFolder.file(res.item.filename, res.blob);
successCount++;
}
});
if (successCount === 0) throw new Error("All image downloads failed.");
logMsg(`Successfully packed ${successCount}/${totalItems} images. Generating zip...`);
const zipBlob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 5 } }, (metadata) => {
progressBar.style.width = `${metadata.percent}%`;
statusText.innerText = `Zipping: ${Math.round(metadata.percent)}%`;
});
logMsg("ZIP generated! Triggering download...");
triggerDownload(zipBlob, 'recnet_saved_images.zip');
statusText.innerText = "Finished!";
statusText.style.color = "#4ade80";
} catch (err) {
logMsg(`[ERROR] ${err.message}`);
statusText.innerText = "Error occurred.";
statusText.style.color = "#f87171";
} finally {
btn.disabled = false;
}
});
shadow.getElementById('btn-my-inventions').addEventListener('click', async (e) => {
const btn = e.target;
const session = getSession();
if (!session) return;
btn.disabled = true;
try {
logMsg("Fetching your inventions...");
const response = await fetch("https://api.rec.net/api/inventions/v2/mine?take=65536", {
"headers": { "accept": "application/json, text/plain, */*", "authorization": "Bearer " + session.accessToken, "cache-control": "no-cache", "pragma": "no-cache" },
"referrer": "https://rec.net/", "method": "GET", "mode": "cors", "credentials": "include"
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
triggerDownload(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), 'my_inventions.json');
logMsg(`Success! Downloaded ${data.length || 'all'} inventions.`);
} catch (err) { logMsg("Error: " + err.message); } finally { btn.disabled = false; }
});
shadow.getElementById('btn-search-inv').addEventListener('click', async (e) => {
const btn = e.target;
const query = shadow.getElementById('inp-inv-query').value.trim();
if (!query) return logMsg("Search query empty!");
const useAuth = shadow.getElementById('chk-inv-auth').checked;
btn.disabled = true;
logMsg(`Searching inventions for: "${query}" (Auth: ${useAuth})...`);
try {
let url = "";
let headers = { "accept": "application/json, text/plain, */*", "cache-control": "no-cache", "pragma": "no-cache" };
if (useAuth) {
const session = getSession();
if (!session) { btn.disabled = false; return; }
headers["authorization"] = "Bearer " + session.accessToken;
url = `https://api.rec.net/api/inventions/v2/search?value=${encodeURIComponent(query)}&take=65536`;
} else {
url = `https://apim.rec.net/apis/api/inventions/v2/search?value=${encodeURIComponent(query)}&take=65536`;
}
const response = await fetch(url, { headers: headers, referrer: "https://rec.net/", method: "GET", mode: "cors" });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
triggerDownload(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), `search_results_${query}.json`);
logMsg(`Success! Downloaded ${data.length || 0} inventions matching "${query}".`);
} catch (err) { logMsg("Error: " + err.message); } finally { btn.disabled = false; }
});
shadow.getElementById('btn-search-rooms').addEventListener('click', async (e) => {
const btn = e.target;
const query = shadow.getElementById('inp-room-query').value.trim();
if (!query) return logMsg("Search query empty!");
const strictMatch = shadow.getElementById('chk-room-strict').checked;
const session = getSession();
if (!session) return;
btn.disabled = true;
const headers = { "accept": "application/json, text/plain, */*", "authorization": "Bearer " + session.accessToken, "cache-control": "no-cache", "pragma": "no-cache" };
const finalData = { Rooms: {}, Saves: {} };
try {
logMsg(`Searching for rooms matching: "${query}"...`);
const searchResponse = await fetchWithRetry(`https://rooms.rec.net/rooms/search?query=${encodeURIComponent(query)}`, { headers });
if (!searchResponse.ok) throw new Error("Failed to search rooms");
const searchData = await getJsonSecurely(searchResponse);
let rawResults = searchData.Results || [];
if (strictMatch) {
rawResults = rawResults.filter(room => room.Name.toLowerCase().includes(query.toLowerCase()));
}
logMsg(`Found ${rawResults.length} rooms to fetch.`);
for (const basicRoom of rawResults) {
const roomId = basicRoom.RoomId;
logMsg(`Fetching details for Room: ${basicRoom.Name} (${roomId})...`);
const detailResponse = await fetchWithRetry(`https://rooms.rec.net/rooms/${roomId}?include=1`, { headers });
if (!detailResponse.ok) continue;
const roomDetails = await getJsonSecurely(detailResponse);
finalData.Rooms[roomId] = roomDetails;
if (roomDetails.SubRooms && Array.isArray(roomDetails.SubRooms)) {
for (const subRoom of roomDetails.SubRooms) {
const subRoomId = subRoom.SubRoomId;
logMsg(` Fetching saves for SubRoom: ${subRoom.Name} (${subRoomId})...`);
const savesResponse = await fetchWithRetry(`https://rooms.rec.net/rooms/${roomId}/subrooms/${subRoomId}/saves?skip=0&take=100`, { headers });
if (savesResponse.ok) finalData.Saves[subRoomId] = await getJsonSecurely(savesResponse);
}
}
}
triggerDownload(new Blob([JSON.stringify(finalData, null, 2)], { type: 'application/json' }), `search_results_rooms_${query}.json`);
logMsg(`Success! Compiled data for ${rawResults.length} rooms saved.`);
} catch (err) { logMsg("Error: " + err.message); } finally { btn.disabled = false; }
});
function readFileAsync(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.onerror = e => reject(e);
reader.readAsText(file);
});
}
async function fetchWithConcurrency(items, limit, onProgress) {
let index = 0, active = 0, completed = 0;
const results = [];
return new Promise((resolve) => {
function next() {
if (index >= items.length && active === 0) return resolve(results);
while (active < limit && index < items.length) {
const i = index++;
const item = items[i];
active++;
fetch(item.url)
.then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.blob(); })
.then(blob => { results[i] = { success: true, item, blob }; })
.catch(err => { results[i] = { success: false, item, error: err.message }; logMsg(`[ERROR] Failed to fetch ${item.filename}: ${err.message}`); })
.finally(() => { active--; completed++; onProgress(completed, items.length); next(); });
}
}
next();
});
}
shadow.getElementById('btn-extract-blobs').addEventListener('click', async (e) => {
const btn = e.target;
const inventionsInput = shadow.getElementById('file-inventions');
const roomsInput = shadow.getElementById('file-rooms');
if (!inventionsInput.files[0] && !roomsInput.files[0]) return logMsg("Please select at least one JSON file first.");
const progressSection = shadow.getElementById('progressSection');
const progressBar = shadow.getElementById('progressBar');
const progressCount = shadow.getElementById('progressCount');
const statusText = shadow.getElementById('statusText');
btn.disabled = true;
progressSection.classList.remove('hidden');
const uniqueDownloads = new Map();
try {
if (inventionsInput.files[0]) {
logMsg("Reading Inventions JSON...");
const json = JSON.parse(await readFileAsync(inventionsInput.files[0]));
let items = Array.isArray(json) ? json : (json.Results || []);
items.forEach(inv => {
if (inv.CurrentVersion && inv.CurrentVersion.BlobName) {
const filename = inv.CurrentVersion.BlobName;
uniqueDownloads.set(filename, { url: `https://cdn.rec.net/invention/${filename}`, filename: filename, folder: 'inventions' });
}
});
}
if (roomsInput.files[0]) {
logMsg("Reading Rooms JSON...");
const json = JSON.parse(await readFileAsync(roomsInput.files[0]));
if (json.Saves) {
Object.values(json.Saves).forEach(saveObj => {
if (saveObj.Results && Array.isArray(saveObj.Results)) {
saveObj.Results.forEach(res => {
if (res.DataBlob) {
const filename = res.DataBlob;
uniqueDownloads.set(filename, { url: `https://cdn.rec.net/room/${filename}`, filename: filename, folder: 'rooms' });
}
});
}
});
}
}
const itemsToFetch = Array.from(uniqueDownloads.values());
const totalItems = itemsToFetch.length;
if (totalItems === 0) {
statusText.innerText = "Done (No files)";
return logMsg("No valid blobs found in the provided JSON files.");
}
logMsg(`Starting download of ${totalItems} unique blobs...`);
statusText.innerText = "Downloading from CDN...";
const fetchResults = await fetchWithConcurrency(itemsToFetch, 10, (completed, total) => {
progressBar.style.width = `${Math.round((completed / total) * 100)}%`;
progressCount.innerText = `${completed} / ${total}`;
});
logMsg("Download phase complete. Zipping files...");
statusText.innerText = "Compressing to ZIP...";
const ZipClass = typeof window.JSZip !== 'undefined' ? window.JSZip : JSZip;
const zip = new ZipClass();
const invFolder = zip.folder("inventions");
const roomFolder = zip.folder("rooms");
let successCount = 0;
fetchResults.forEach(res => {
if (res.success && res.blob) {
(res.item.folder === 'inventions' ? invFolder : roomFolder).file(res.item.filename, res.blob);
successCount++;
}
});
if (successCount === 0) throw new Error("All blob downloads failed.");
logMsg(`Successfully packed ${successCount}/${totalItems} files. Generating zip...`);
const zipBlob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 5 } }, (metadata) => {
progressBar.style.width = `${metadata.percent}%`;
statusText.innerText = `Zipping: ${Math.round(metadata.percent)}%`;
});
logMsg("ZIP generated! Triggering download...");
triggerDownload(zipBlob, 'recnet_blobs_export.zip');
statusText.innerText = "Finished!";
statusText.style.color = "#4ade80";
} catch (error) {
logMsg(`[FATAL ERROR] ${error.message}`);
statusText.innerText = "Error occurred.";
statusText.style.color = "#f87171";
} finally {
btn.disabled = false;
}
});
}
if (document.body) { initGUI(); }
else { window.addEventListener('DOMContentLoaded', initGUI); }
})();