Sketchfab Model Downloader (View-Only Fix)

Captures Sketchfab's actual model package (zip/osgjs/glb) from network traffic and exports as a proper .glb or .obj file you can open in Blender, Maya, etc.

Este script no debería instalarse directamente. Es una biblioteca que utilizan otros scripts mediante la meta-directiva de inclusión // @require https://update.greasyfork.org/scripts/572682/1791305/Sketchfab%20Model%20Downloader%20%28View-Only%20Fix%29.js

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Sketchfab Model Downloader (View-Only Fix)
// @namespace    https://sketchfab.com/
// @version      4.0
// @description  Captures Sketchfab's actual model package (zip/osgjs/glb) from network traffic and exports as a proper .glb or .obj file you can open in Blender, Maya, etc.
// @author       UserScript
// @match        https://sketchfab.com/3d-models/*
// @match        https://sketchfab.com/models/*
// @match        https://sketchfab.com/show/*
// @grant        GM_download
// @grant        GM_addStyle
// @grant        unsafeWindow
// @run-at       document-start
// @connect      *
// ==/UserScript==

(function () {
    'use strict';

    /* ══════════════════════════════════════════════════════
       STATE — we collect every promising response by URL
    ══════════════════════════════════════════════════════ */
    const files = new Map();   // url → { url, buf, size, label, ext }
    let statusMsg = 'Waiting — let the viewer load the model…';

    /* ══════════════════════════════════════════════════════
       WHAT TO INTERCEPT
       Sketchfab's viewer fetches:
         • model_file          — a ZIP containing osgjs + geometry bins
         • file.osgjs.gz       — compressed scene graph
         • model_file_geo*.bin — geometry chunks
         • *.glb               — binary glTF (newer models)
         • *.bin               — binary buffers referenced by glTF
    ══════════════════════════════════════════════════════ */
    function isWanted(url) {
        if (!url) return false;
        const u = url.toLowerCase();
        // Explicit model extensions
        if (/\.(glb|gltf|osgjs|osgb|obj|fbx|stl)($|\?|\.gz)/.test(u)) return true;
        // Sketchfab CDN model paths
        if (/sketchfab\.com.*(model_file|file\.osgjs|geometry|mesh|_geo\b)/.test(u)) return true;
        if (/media\.sketchfab\.com|cdn\.sketchfab\.com/.test(u) && /\.(zip|bin|gz)($|\?)/.test(u)) return true;
        // Large binary blobs from sketchfab domains
        if (/sketchfab\.com/.test(u) && /\.(zip|bin\.gz|osgjs\.gz)($|\?)/.test(u)) return true;
        return false;
    }

    function extFor(url, buf) {
        const u = url.toLowerCase().split('?')[0];
        // Check GLB magic bytes: "glTF" = 0x46546C67
        if (buf && buf.byteLength >= 4) {
            const v = new DataView(buf);
            if (v.getUint32(0, true) === 0x46546C67) return 'glb';
        }
        // Check ZIP magic: PK\x03\x04
        if (buf && buf.byteLength >= 4) {
            const v = new DataView(buf);
            if (v.getUint32(0, true) === 0x04034B50) return 'zip';
        }
        // Check gzip magic: 0x1f8b
        if (buf && buf.byteLength >= 2) {
            const v = new DataView(buf);
            if (v.getUint16(0, true) === 0x8B1F) return 'gz';
        }
        if (u.endsWith('.glb')) return 'glb';
        if (u.endsWith('.gltf')) return 'gltf';
        if (u.endsWith('.zip')) return 'zip';
        if (u.endsWith('.gz') || u.endsWith('.osgjs.gz')) return 'gz';
        if (u.endsWith('.osgjs')) return 'osgjs';
        if (u.endsWith('.obj')) return 'obj';
        if (u.endsWith('.fbx')) return 'fbx';
        return 'bin';
    }

    function labelFor(url, ext) {
        if (ext === 'glb') return '✅ GLB (ready to import)';
        if (ext === 'gltf') return '✅ glTF JSON';
        if (ext === 'zip') return '📦 Model ZIP (extract → Blender)';
        if (ext === 'gz') return '📦 Compressed model (rename to .zip)';
        if (ext === 'osgjs') return '📄 OSGJS scene (Blender plugin needed)';
        if (ext === 'obj') return '✅ OBJ (ready to import)';
        if (ext === 'fbx') return '✅ FBX (ready to import)';
        return '⚠️ Binary chunk';
    }

    function addFile(url, buf) {
        if (files.has(url)) return;
        const ext = extFor(url, buf);
        files.set(url, {
            url,
            buf,
            size: buf.byteLength,
            ext,
            label: labelFor(url, ext),
            name: makeName(url, ext)
        });
        setStatus(`Captured ${files.size} file(s) — click a file to download`);
        refreshPanel();
    }

    function makeName(url, ext) {
        const title = document.title.replace(/\s*[-|]\s*Sketchfab.*/i, '').trim()
            .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') || 'sketchfab_model';
        const urlPart = url.split('/').pop().split('?')[0].replace(/[^a-z0-9._-]/gi, '_');
        return `${title}__${urlPart}`.substring(0, 120) + '.' + ext;
    }

    /* ══════════════════════════════════════════════════════
       XHR INTERCEPT — patch at document-start
    ══════════════════════════════════════════════════════ */
    const NativeXHR = unsafeWindow.XMLHttpRequest;
    function PatchedXHR() {
        const xhr = new NativeXHR();
        let _url = '';
        const origOpen = xhr.open.bind(xhr);
        xhr.open = function (method, url) {
            _url = String(url);
            // Force arraybuffer response for wanted URLs so we can copy the bytes
            if (isWanted(_url)) {
                origOpen.apply(xhr, arguments);
                xhr.responseType = 'arraybuffer';
                return;
            }
            return origOpen.apply(xhr, arguments);
        };
        xhr.addEventListener('load', function () {
            if (isWanted(_url) && xhr.response instanceof ArrayBuffer && xhr.response.byteLength > 500) {
                addFile(_url, xhr.response.slice(0));
            }
        });
        return xhr;
    }
    PatchedXHR.prototype = NativeXHR.prototype;
    unsafeWindow.XMLHttpRequest = PatchedXHR;

    /* ══════════════════════════════════════════════════════
       FETCH INTERCEPT
    ══════════════════════════════════════════════════════ */
    const origFetch = unsafeWindow.fetch;
    unsafeWindow.fetch = async function (input, init) {
        const url = typeof input === 'string' ? input : (input?.url || String(input));
        const resp = await origFetch.apply(this, arguments);
        if (isWanted(url)) {
            try {
                const clone = resp.clone();
                clone.arrayBuffer().then(buf => {
                    if (buf.byteLength > 500) addFile(url, buf.slice(0));
                }).catch(() => {});
            } catch (_) {}
        }
        return resp;
    };

    /* ══════════════════════════════════════════════════════
       SERVICE WORKER / OSGJS JSON SNIFF
       The viewer also embeds the model URL in a JSON config.
       We scan the page DOM for it.
    ══════════════════════════════════════════════════════ */
    function scanDOM() {
        // Look for model URLs embedded in page scripts or data attributes
        const patterns = [
            /["'](https?:\/\/[^"']+\.(glb|osgjs|zip|gz|fbx|obj|bin)(\?[^"']*)?)['"]/gi
        ];
        const html = document.documentElement.innerHTML;
        for (const re of patterns) {
            let m;
            while ((m = re.exec(html)) !== null) {
                const url = m[1];
                if (isWanted(url) && !files.has(url)) {
                    // Try fetching it directly
                    origFetch(url, { credentials: 'include' })
                        .then(r => r.arrayBuffer())
                        .then(buf => { if (buf.byteLength > 500) addFile(url, buf); })
                        .catch(() => {});
                }
            }
        }
    }

    /* ══════════════════════════════════════════════════════
       DOWNLOAD
    ══════════════════════════════════════════════════════ */
    function downloadFile(entry) {
        const blob = new Blob([entry.buf], { type: 'application/octet-stream' });
        const blobUrl = URL.createObjectURL(blob);
        setStatus('Downloading ' + entry.name + '…');
        GM_download({
            url: blobUrl,
            name: entry.name,
            onload() { URL.revokeObjectURL(blobUrl); setStatus('✅ Saved: ' + entry.name); },
            onerror() {
                const a = document.createElement('a');
                a.href = blobUrl; a.download = entry.name;
                document.body.appendChild(a); a.click();
                document.body.removeChild(a);
                setTimeout(() => URL.revokeObjectURL(blobUrl), 4000);
                setStatus('✅ Saved (fallback): ' + entry.name);
            }
        });
    }

    /* ══════════════════════════════════════════════════════
       HELPERS
    ══════════════════════════════════════════════════════ */
    function fmtSize(b) {
        if (b > 1048576) return (b / 1048576).toFixed(1) + ' MB';
        if (b > 1024) return (b / 1024).toFixed(1) + ' KB';
        return b + ' B';
    }
    function setStatus(msg) {
        statusMsg = msg;
        const el = document.getElementById('sfm-status-txt');
        if (el) el.textContent = msg;
    }

    /* ══════════════════════════════════════════════════════
       UI
    ══════════════════════════════════════════════════════ */
    GM_addStyle(`
        #sfm-fab {
            position:fixed; bottom:22px; right:22px; z-index:2147483647;
            width:48px; height:48px; border-radius:50%;
            background:#0F1117; border:2px solid #6366F1;
            box-shadow:0 0 0 4px rgba(99,102,241,.18);
            cursor:pointer; display:flex; align-items:center; justify-content:center;
            transition:transform .18s;
            font-family:system-ui,sans-serif;
        }
        #sfm-fab:hover{transform:scale(1.1);}
        #sfm-badge{
            position:absolute; top:-4px; right:-4px;
            background:#10B981; color:#fff; font-size:9px; font-weight:700;
            border-radius:10px; min-width:17px; height:17px; padding:0 4px;
            display:none; align-items:center; justify-content:center;
        }
        #sfm-panel{
            position:fixed; bottom:80px; right:22px; z-index:2147483647;
            width:390px; background:#0D1117; border:1px solid #21262D;
            border-radius:14px; font-family:system-ui,-apple-system,sans-serif;
            color:#C9D1D9; box-shadow:0 24px 64px rgba(0,0,0,.75);
            display:none; flex-direction:column; overflow:hidden;
        }
        #sfm-panel.open{display:flex;}
        #sfm-head{
            padding:11px 14px; background:#161B22; border-bottom:1px solid #21262D;
            display:flex; align-items:center; gap:8px;
        }
        #sfm-head h3{margin:0;font-size:13px;font-weight:600;color:#818CF8;flex:1;}
        #sfm-head button{background:none;border:none;color:#6E7681;cursor:pointer;font-size:14px;padding:0;border-radius:4px;}
        #sfm-head button:hover{color:#C9D1D9;}
        #sfm-status{
            padding:7px 14px; font-size:11px; color:#6E7681;
            background:#0D1117; border-bottom:1px solid #161B22;
            display:flex; align-items:center; gap:6px;
        }
        .sfm-pulse{width:6px;height:6px;border-radius:50%;background:#10B981;flex-shrink:0;animation:sfpulse 1.4s infinite;}
        @keyframes sfpulse{0%,100%{opacity:1;}50%{opacity:.2;}}
        #sfm-list{max-height:300px;overflow-y:auto;padding:6px 0;}
        #sfm-list::-webkit-scrollbar{width:3px;}
        #sfm-list::-webkit-scrollbar-thumb{background:#21262D;border-radius:2px;}
        .sfm-empty{padding:28px 16px;text-align:center;color:#444C56;font-size:12px;line-height:1.6;}
        .sfm-empty strong{display:block;font-size:13px;color:#6E7681;margin-bottom:5px;}
        .sfm-row{
            display:flex;align-items:center;gap:10px;padding:9px 14px;
            border-bottom:1px solid #161B22;transition:background .13s;cursor:default;
        }
        .sfm-row:last-child{border-bottom:none;}
        .sfm-row:hover{background:#161B22;}
        .sfm-ext{
            font-size:9px;font-weight:700;letter-spacing:.07em;padding:3px 7px;
            border-radius:5px;text-align:center;min-width:34px;flex-shrink:0;
        }
        .ext-glb{background:#0D2B1F;color:#10B981;}
        .ext-gltf{background:#0D2B1F;color:#34D399;}
        .ext-obj{background:#0F1F0F;color:#4ADE80;}
        .ext-fbx{background:#0F1F0F;color:#86EFAC;}
        .ext-zip{background:#1E1A0D;color:#FBBF24;}
        .ext-gz{background:#1E1A0D;color:#FCD34D;}
        .ext-bin{background:#1A1118;color:#A78BFA;}
        .ext-other{background:#21262D;color:#8B949E;}
        .sfm-info{flex:1;overflow:hidden;}
        .sfm-lbl{font-size:12px;font-weight:500;color:#C9D1D9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
        .sfm-sub{font-size:10.5px;color:#6E7681;margin-top:1px;}
        .sfm-dl{
            width:30px;height:30px;border-radius:7px;border:1px solid #30363D;
            background:transparent;color:#6E7681;cursor:pointer;
            display:flex;align-items:center;justify-content:center;
            transition:all .14s;flex-shrink:0;
        }
        .sfm-dl:hover{background:#6366F1;border-color:#6366F1;color:#fff;}
        .sfm-dl.done{background:#10B981;border-color:#10B981;color:#fff;}
        #sfm-foot{
            padding:9px 14px;border-top:1px solid #21262D;
            display:flex;gap:8px;
        }
        #sfm-foot button{
            flex:1;padding:8px;border-radius:8px;border:1px solid #30363D;
            background:transparent;color:#8B949E;font-size:11.5px;font-weight:500;
            cursor:pointer;transition:all .14s;
        }
        #sfm-foot button:hover{background:#161B22;color:#C9D1D9;}
        #sfm-scan-btn{}
        #sfm-hint{
            padding:9px 14px;background:#13100A;border-top:1px solid #2D2200;
            font-size:10.5px;color:#7C6A3A;line-height:1.55;
        }
        #sfm-hint b{color:#FBBF24;display:block;margin-bottom:2px;}
    `);

    function extClass(ext) {
        return ['glb','gltf','obj','fbx','zip','gz','bin'].includes(ext) ? 'ext-'+ext : 'ext-other';
    }

    function buildUI() {
        const fab = document.createElement('div');
        fab.id = 'sfm-fab';
        fab.innerHTML = `<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#818CF8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span id="sfm-badge"></span>`;
        fab.addEventListener('click', () => {
            const p = document.getElementById('sfm-panel');
            p.classList.toggle('open');
        });
        document.body.appendChild(fab);

        const panel = document.createElement('div');
        panel.id = 'sfm-panel';
        panel.innerHTML = `
            <div id="sfm-head">
                <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#818CF8" stroke-width="2.5" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
                <h3>Sketchfab Model Capture</h3>
                <button id="sfm-x">✕</button>
            </div>
            <div id="sfm-status"><span class="sfm-pulse"></span><span id="sfm-status-txt">${statusMsg}</span></div>
            <div id="sfm-list"><div class="sfm-empty"><strong>No files detected yet</strong>Let the 3D viewer fully load,<br>then rotate the model around.</div></div>
            <div id="sfm-foot"><button id="sfm-scan-btn">🔍 Scan Page</button></div>
            <div id="sfm-hint"><b>💡 Tips for best results:</b>
                Let the viewer finish loading. Rotate the model fully so all geometry streams in.
                <br>• <b>.glb</b> / <b>.obj</b> / <b>.fbx</b> → open directly in Blender, Maya, etc.
                <br>• <b>.zip</b> / <b>.gz</b> → extract, open the .osgjs in Blender with the <a href="https://github.com/cedricpinson/osgjs-blender" target="_blank" style="color:#6366F1">OSGJS importer</a></div>
        `;
        document.body.appendChild(panel);
        document.getElementById('sfm-x').addEventListener('click', () => panel.classList.remove('open'));
        document.getElementById('sfm-scan-btn').addEventListener('click', () => { scanDOM(); setStatus('Page scanned — check for new files above'); });
    }

    function refreshPanel() {
        const list = document.getElementById('sfm-list');
        const badge = document.getElementById('sfm-badge');
        if (!list) return;

        if (badge) {
            badge.style.display = files.size > 0 ? 'flex' : 'none';
            badge.textContent = files.size;
        }

        if (files.size === 0) {
            list.innerHTML = `<div class="sfm-empty"><strong>No files detected yet</strong>Let the 3D viewer fully load,<br>then rotate the model around.</div>`;
            return;
        }

        list.innerHTML = '';
        // Sort: GLB/OBJ/FBX first (most useful), then zip, then others
        const priority = { glb:0, gltf:1, obj:2, fbx:3, zip:4, gz:5, osgjs:6, bin:7 };
        const sorted = [...files.values()].sort((a,b) => (priority[a.ext]||9) - (priority[b.ext]||9));

        for (const entry of sorted) {
            const row = document.createElement('div');
            row.className = 'sfm-row';
            row.innerHTML = `
                <span class="sfm-ext ${extClass(entry.ext)}">${entry.ext.toUpperCase()}</span>
                <div class="sfm-info">
                    <div class="sfm-lbl" title="${entry.label}">${entry.label}</div>
                    <div class="sfm-sub">${fmtSize(entry.size)}</div>
                </div>
                <button class="sfm-dl" title="Download ${entry.name}">
                    <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
                </button>`;
            const btn = row.querySelector('.sfm-dl');
            btn.addEventListener('click', () => {
                downloadFile(entry);
                btn.classList.add('done');
                btn.innerHTML = '✓';
            });
            list.appendChild(row);
        }
    }

    /* ══════════════════════════════════════════════════════
       BOOT
    ══════════════════════════════════════════════════════ */
    function init() {
        buildUI();
        // Scan DOM after page settles
        setTimeout(scanDOM, 3000);
        setTimeout(scanDOM, 7000);
        // Watch for dynamically injected scripts
        new MutationObserver(scanDOM).observe(document.documentElement, { childList: true, subtree: true });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();