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.

Dette scriptet burde ikke installeres direkte. Det er et bibliotek for andre script å inkludere med det nye metadirektivet // @require https://update.greatest.deepsurf.us/scripts/572682/1791305/Sketchfab%20Model%20Downloader%20%28View-Only%20Fix%29.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
    }

})();