Rec.Net Data Extractor

A GUI for extracting rooms, inventions, and downloading blobs on rec.net

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Rec.Net Data Extractor
// @namespace    http://tampermonkey.net/
// @version      1.3
// @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; }

            #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); }

            #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;
                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; }
            .btn-teal { background-color: #0d9488; } .btn-teal:hover:not(:disabled) { background-color: #0f766e; }

            .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>Extract Specific Item</h4>
                        <div style="display: flex; gap: 8px; margin-bottom: 8px;">
                            <input type="text" id="inp-specific-id" placeholder="Name or ID..." class="text-input" style="margin-bottom: 0;">
                            <button id="btn-get-url" class="btn btn-blue" style="width: auto; white-space: nowrap; padding: 8px 12px;">From URL</button>
                        </div>
                        <div style="display: flex; gap: 12px; margin-bottom: 10px; color: #d1d5db; font-size: 13px;">
                            <label class="checkbox-label" style="margin: 0;"><input type="radio" name="specific-type" value="room" checked> Room</label>
                            <label class="checkbox-label" style="margin: 0;"><input type="radio" name="specific-type" value="invention"> Invention</label>
                        </div>
                        <button id="btn-extract-specific" class="btn btn-teal">Extract Item Data</button>
                    </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-get-url').addEventListener('click', () => {
            const path = window.location.pathname;
            const inpSpecific = shadow.getElementById('inp-specific-id');

            if (path.startsWith('/room/')) {
                inpSpecific.value = path.split('/room/')[1].split('?')[0];
                shadow.querySelector('input[name="specific-type"][value="room"]').checked = true;
                logMsg(`Extracted room name from URL: ${inpSpecific.value}`);
            } else if (path.startsWith('/invention/')) {
                inpSpecific.value = path.split('/invention/')[1].split('?')[0];
                shadow.querySelector('input[name="specific-type"][value="invention"]').checked = true;
                logMsg(`Extracted invention ID from URL: ${inpSpecific.value}`);
            } else {
                logMsg("Could not detect a /room/ or /invention/ in the current URL.");
            }
        });

        shadow.getElementById('btn-extract-specific').addEventListener('click', async (e) => {
            const btn = e.target;
            const query = shadow.getElementById('inp-specific-id').value.trim();
            if (!query) return logMsg("Please enter an ID or Name first.");

            const isRoom = shadow.querySelector('input[name="specific-type"][value="room"]').checked;
            const session = getSession();

            let headers = { "accept": "application/json, text/plain, */*", "cache-control": "no-cache", "pragma": "no-cache" };
            if (session) headers["authorization"] = "Bearer " + session.accessToken;

            btn.disabled = true;
            try {
                if (isRoom) {
                    logMsg(`Resolving Room: ${query}...`);
                    let roomId = query;

                    if (!/^\d+$/.test(query)) {
                        const nameRes = await fetchWithRetry(`https://rooms.rec.net/rooms?name=${encodeURIComponent(query)}`, { headers });
                        if (!nameRes.ok) throw new Error("Could not find room by name. Check spelling.");
                        const nameData = await getJsonSecurely(nameRes);
                        if (!nameData || !nameData.RoomId) throw new Error("Room data not found from name.");
                        roomId = nameData.RoomId;
                        logMsg(`Resolved room name to ID: ${roomId}`);
                    }

                    const finalData = { Rooms: {}, Saves: {} };
                    const detailResponse = await fetchWithRetry(`https://rooms.rec.net/rooms/${roomId}?include=1`, { headers });
                    if (!detailResponse.ok) throw new Error("Failed to fetch room details");

                    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}...`);
                            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' }), `room_${query}.json`);
                    logMsg(`Success! Saved room data.`);
                } else {
                    logMsg(`Fetching invention data for ID: ${query}...`);
                    const invRes = await fetchWithRetry(`https://api.rec.net/api/inventions/v2/${encodeURIComponent(query)}`, {
                        headers: headers, referrer: "https://rec.net/", method: "GET", mode: "cors"
                    });

                    if (!invRes.ok) throw new Error(`HTTP error! status: ${invRes.status}`);
                    const invData = await getJsonSecurely(invRes);

                    triggerDownload(new Blob([JSON.stringify(invData, null, 2)], { type: 'application/json' }), `invention_${query}.json`);
                    logMsg(`Success! Saved invention data.`);
                }
            } catch (err) {
                logMsg("Error: " + err.message);
            } finally {
                btn.disabled = false;
            }
        });

        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); }

})();