ncikkis sketchfab downloader

Attempts model download via runtime code modification. Enhanced geometry de-duplication. Requires JSZip/FileSaver.

当前为 2025-05-03 提交的版本,查看 最新版本

// ==UserScript==
// @name         ncikkis sketchfab downloader
// @namespace    http://tampermonkey.net/
// @version      1.9 // Added content-based geometry de-duplication check
// @description  Attempts model download via runtime code modification. Enhanced geometry de-duplication. Requires JSZip/FileSaver.
// @author       ncikkis
// @match        https://sketchfab.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @run-at       document-start
// @grant        unsafeWindow
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @namespace    https://gf.qytechs.cn/users/956968 // Original namespace kept for reference to technique origin
// @license      MIT
// ==/UserScript==

(async function() {
    'use strict';
    const LOG_PREFIX = '[ncikkis_SFD_v1.9]:';

    // --- Global Storage ---
    let capturedGeometries = new Map(); // key: geomId, value: { name: string, objData: string }
    let capturedTextures = new Map(); // key: textureUrl, value: { name: string, url: string }
    let capturedGeometryKeys = new Set(); // Stores "vertexCount_indexCount" keys to prevent duplicates
    let geomCounter = 0;
    let textureCounter = 0;
    let downloadButtonAdded = false;

    console.log(`${LOG_PREFIX} Initializing. Waiting for Sketchfab JS...`);

    // --- Core Capture Functions (attached to window for injection) ---
    unsafeWindow.captureGeometry = function(geomObj) {
        // Basic filtering
        if (!geomObj || !geomObj._primitives || !geomObj._attributes?.Vertex?._elements) {
            return;
        }

        // --- Enhanced De-duplication ---
        // Check 1: Object reference flag (quick check)
         if(geomObj.__captured__) return;

         // Check 2: Content-based key (vertex count + index count)
         try {
             const vertexCount = geomObj._attributes.Vertex._elements.length;
             let totalIndexCount = 0;
             if (geomObj._primitives && Array.isArray(geomObj._primitives)) {
                 geomObj._primitives.forEach(p => {
                     if (p && p.indices?._elements) {
                         totalIndexCount += p.indices._elements.length;
                     }
                 });
             }
             // Only proceed if we have actual geometry
             if (vertexCount === 0 || totalIndexCount === 0) return;

             const geometryKey = `${vertexCount}_${totalIndexCount}`;

             if (capturedGeometryKeys.has(geometryKey)) {
                 //console.log(`${LOG_PREFIX} Skipping duplicate geometry key: ${geometryKey}`);
                 return; // Skip if key already processed
             }
             // Mark this key and object reference as captured
             capturedGeometryKeys.add(geometryKey);
             geomObj.__captured__ = true; // Mark object reference as well

         } catch (e) {
             console.error(`${LOG_PREFIX} Error during geometry de-duplication check:`, e, geomObj);
             // Fallback: mark object ref anyway to prevent potential infinite loops if error is persistent
             geomObj.__captured__ = true;
             return; // Don't process if key calculation failed
         }
         // --- End De-duplication ---


        geomCounter++;
        const geomId = `geom_${geomCounter}`;
        const modelName = `model_${geomCounter}`;
        console.log(`${LOG_PREFIX} Capturing NEW Geometry: ${modelName} (Key: ${capturedGeometryKeys.size})`, geomObj);

        try {
             const parsed = parseGeometry(geomObj);
             if (!parsed) { throw new Error("Parsing failed"); }
             const objStr = generateOBJ(parsed, modelName);
             capturedGeometries.set(geomId, { name: modelName, objData: objStr });
             console.log(`${LOG_PREFIX} Stored OBJ data for ${modelName}`);
        } catch(e) {
             console.error(`${LOG_PREFIX} Error processing geometry ${modelName}:`, e);
        }
    };

    unsafeWindow.captureTextureURL = function(imageInfo, imageModel) {
        // Same implementation as v1.8
         if (!imageModel?.attributes?.images) { return imageInfo; }
         try {
            const originalUrl = imageInfo.url;
            const filename_image = imageModel.attributes.name || `texture_${textureCounter++}`;
            let bestUrl = originalUrl;
            let max_size = imageInfo.size || 0;
            imageModel.attributes.images.forEach(img => {
                if (img?.url && img.size > max_size) {
                    max_size = img.size;
                    bestUrl = img.url;
                }
            });
            if (!capturedTextures.has(bestUrl)) {
                console.log(`${LOG_PREFIX} Capturing Texture URL: ${filename_image} -> ${bestUrl}`);
                 capturedTextures.set(bestUrl, { name: filename_image, url: bestUrl });
            }
             const bestImageInfo = imageModel.attributes.images.find(img => img.url === bestUrl) || imageInfo;
             return bestImageInfo;
         } catch (e) { console.error(`${LOG_PREFIX} Error in captureTextureURL:`, e); return imageInfo; }
    };


     // --- Geometry Parsing and OBJ Generation ---
     function parseGeometry(geomObj) {
         // Same implementation as v1.8
         const primitives = [];
         if (geomObj._primitives && Array.isArray(geomObj._primitives)) {
             geomObj._primitives.forEach(p => { if (p?.indices?._elements) { primitives.push({ mode: p.mode, indices: p.indices._elements }); } });
         }
         const attr = geomObj._attributes;
         if (!attr?.Vertex?._elements) return null;
         let uvElements = [];
         for(let i=0; i<=8; i++) { if(attr[`TexCoord${i}`]?._elements) { uvElements = attr[`TexCoord${i}`]._elements; /* console.log(`${LOG_PREFIX} Using TexCoord${i}`); */ break; } }
         return { vertex: attr.Vertex._elements, normal: attr.Normal?._elements || [], uv: uvElements, primitives: primitives };
     }

     function generateOBJ(parsedData, modelName) {
         // Same implementation as v1.8
         let objStr = `# Generated by ncikkis Sketchfab Downloader v1.9\n`;
         objStr += `o ${modelName}\n`;
         for (let i = 0; i < parsedData.vertex.length; i += 3) { objStr += `v ${parsedData.vertex[i]} ${parsedData.vertex[i+1]} ${parsedData.vertex[i+2]}\n`; }
         const hasNormals = parsedData.normal.length > 0;
         if (hasNormals) { for (let i = 0; i < parsedData.normal.length; i += 3) { objStr += `vn ${parsedData.normal[i]} ${parsedData.normal[i+1]} ${parsedData.normal[i+2]}\n`; } }
         const hasUVs = parsedData.uv.length > 0;
         if (hasUVs) { for (let i = 0; i < parsedData.uv.length; i += 2) { objStr += `vt ${parsedData.uv[i]} ${1.0 - parsedData.uv[i+1]}\n`; } }
         objStr += `s 1\n`;
         for (const primitive of parsedData.primitives) {
             const indices = primitive.indices;
             if (primitive.mode === 4) { // TRIANGLES
                 for (let i = 0; i < indices.length; i += 3) { objStr += `f`; for (let j = 0; j < 3; j++) { const idx = indices[i + j] + 1; objStr += ` ${idx}`; if (hasUVs || hasNormals) { objStr += `/`; if (hasUVs) objStr += idx; if (hasNormals) objStr += `/${idx}`; } } objStr += `\n`; }
             } else if (primitive.mode === 5) { // TRIANGLE_STRIP
                 for (let i = 0; i + 2 < indices.length; i++) { objStr += `f`; const order = (i % 2 === 0) ? [0, 1, 2] : [0, 2, 1]; for (let j = 0; j < 3; j++) { const idx = indices[i + order[j]] + 1; objStr += ` ${idx}`; if (hasUVs || hasNormals) { objStr += `/`; if (hasUVs) objStr += idx; if (hasNormals) objStr += `/${idx}`; } } objStr += `\n`; }
             } else { console.warn(`${LOG_PREFIX} Unsupported primitive mode: ${primitive.mode}`); }
         }
         return objStr;
     }

    // --- Download Button and Packaging Logic ---
    function addDownloadButton() {
        // Same implementation as v1.8
        if (downloadButtonAdded) return;
        const titleBar = document.querySelector('.titlebar') || document.querySelector('.viewer-header');
        if (titleBar) {
            console.log(`${LOG_PREFIX} Adding download button...`);
            const btn = document.createElement("a");
            btn.innerHTML = "DOWNLOAD ZIP"; /* Styles... */ btn.style.backgroundColor = "#1caad9"; btn.style.color = "white"; btn.style.padding = "8px";
            btn.style.borderRadius = "4px"; btn.style.cursor = "pointer"; btn.style.marginLeft = "10px"; btn.style.textDecoration = "none";
            btn.style.fontSize = "12px"; btn.style.fontWeight = "bold"; btn.onmouseover = () => btn.style.backgroundColor = "#1c88bb";
            btn.onmouseout = () => btn.style.backgroundColor = "#1caad9"; btn.addEventListener("click", initiateDownloadPackage, false);
            titleBar.appendChild(btn); downloadButtonAdded = true; console.log(`${LOG_PREFIX} Download button added.`);
        } else { console.log(`${LOG_PREFIX} Title bar not found, retrying button add later...`); setTimeout(addDownloadButton, 2000); }
    }

    async function initiateDownloadPackage() {
        // Same implementation as v1.8
         if (capturedGeometries.size === 0 && capturedTextures.size === 0) { alert("ncikkis Downloader: No geometry or textures captured."); return; }
         const zip = new JSZip(); const modelFolder = zip.folder('model');
         console.log(`${LOG_PREFIX} Preparing download package...`);
         if (capturedGeometries.size > 0) { console.log(`${LOG_PREFIX} Adding ${capturedGeometries.size} geometry file(s) to zip...`); capturedGeometries.forEach((geomInfo) => { modelFolder.file(`${geomInfo.name}.obj`, geomInfo.objData); }); }
         else { console.warn(`${LOG_PREFIX} No geometry captured.`); }
         if (capturedTextures.size > 0) {
             console.log(`${LOG_PREFIX} Fetching ${capturedTextures.size} texture file(s)...`); const texturePromises = [];
             capturedTextures.forEach((texInfo) => {
                  const promise = new Promise((resolve, reject) => {
                       console.log(`${LOG_PREFIX} Requesting texture blob for: ${texInfo.name}`);
                       GM_download({ url: texInfo.url, name: `temp_${texInfo.name}`, responseType: 'blob',
                           onload: (response) => {
                               if (response.response) { let safeName = texInfo.name.replace(/[^a-zA-Z0-9_.-]/g, '_'); if (!/\.(png|jpg|jpeg|webp)$/i.test(safeName)) safeName += '.png'; modelFolder.file(`textures/${safeName}`, response.response); resolve(); }
                               else { console.error(`${LOG_PREFIX} GM_download success but no blob received for ${texInfo.name}`); reject(new Error(`No blob for ${texInfo.name}`)); } },
                           onerror: (err) => { console.error(`${LOG_PREFIX} GM_download error for ${texInfo.name} (${texInfo.url}):`, err); reject(err); },
                           ontimeout: () => { console.error(`${LOG_PREFIX} GM_download timeout for ${texInfo.name} (${texInfo.url})`); reject(new Error(`Timeout for ${texInfo.name}`)); }
                       }); }); texturePromises.push(promise); });
             try { await Promise.all(texturePromises); console.log(`${LOG_PREFIX} All texture downloads attempted.`); }
             catch (error) { console.error(`${LOG_PREFIX} Error(s) occurred during texture fetching:`, error); alert(`ncikkis Downloader: Error downloading textures. Zip may be incomplete.`); }
         } else { console.warn(`${LOG_PREFIX} No textures captured.`); }
         try {
             console.log(`${LOG_PREFIX} Generating zip file...`); const zipBlob = await zip.generateAsync({ type: "blob" });
             let filename = "sketchfab_download.zip"; try { filename = document.querySelector('.model-name__label')?.textContent?.trim()?.replace(/[^a-zA-Z0-9_-]/g, '_') + ".zip" || filename; } catch (_) {}
             saveAs(zipBlob, filename); console.log(`${LOG_PREFIX} Zip file saving initiated: ${filename}`);
         } catch (e) { console.error(`${LOG_PREFIX} Error generating or saving zip file:`, e); alert(`ncikkis Downloader: Error generating zip file.`); }
    }

    // --- Script Interception and Patching ---
    // Same implementation as v1.8
    const patchPoints = [
         { regex: /(drawGeometry\(.*?,(.*?)\))/g, injection: (match, p1, p2) => `${p1}; window.captureGeometry(${p2});`, name: "Geometry Capture" },
         { regex: /getResourceImage:function\((\w+),(\w+)\)\{/g, injection: (match, p1, p2) => `${match} ${p1} = window.captureTextureURL(${p1}, this._imageModel);`, name: "Texture URL Capture" }
    ];
    function patchScript(scriptText, scriptUrl) { /* ... Same logic as v1.8 ... */
         let modifiedText = scriptText; let patchesApplied = 0; console.log(`${LOG_PREFIX} Patching script: ${scriptUrl}`);
         patchPoints.forEach(patch => {
             let matchFound = false; let iteration = 0; const maxIterations = 1000; // Prevent infinite loops
             modifiedText = modifiedText.replace(patch.regex, (...args) => {
                 matchFound = true; patchesApplied++; iteration++; if(iteration > maxIterations) throw new Error("Max iterations reached in replace");
                 const originalMatch = args[0]; if (typeof patch.injection === 'function') { return patch.injection(...args); }
                 else { return originalMatch + patch.injection; }
             }); // Basic replace, might need /g flag handling if multiple replacements expected per regex
             if (matchFound) { console.log(`${LOG_PREFIX} Patch '${patch.name}' applied.`); }
             else { console.warn(`${LOG_PREFIX} Patch '${patch.name}' FAILED - pattern not found.`); } });
         if (patchesApplied > 0) { console.log(`${LOG_PREFIX} Total patches applied: ${patchesApplied}.`); return modifiedText; }
         else { console.warn(`${LOG_PREFIX} No patches applied to script: ${scriptUrl}.`); return scriptText; }
    }
    (() => { /* ... Same MutationObserver/interception logic as v1.8 ... */
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => {
                if (node.tagName === 'SCRIPT' && node.src && (node.src.includes('web/dist/') || node.src.includes('standaloneViewer') || node.src.includes('/viewer/'))) {
                    node.async = false; node.defer = false; node.removeAttribute('integrity'); node.type = 'text/plain';
                    console.log(`${LOG_PREFIX} Intercepted Sketchfab script: ${node.src}`);
                    GM_xmlhttpRequest({ method: "GET", url: node.src,
                        onload: function(response) {
                            if (response.status === 200) {
                                console.log(`${LOG_PREFIX} Fetched script for patching.`); const patchedText = patchScript(response.responseText, node.src);
                                const newScript = document.createElement('script'); newScript.type = "text/javascript"; newScript.textContent = patchedText;
                                (document.head || document.documentElement).appendChild(newScript); console.log(`${LOG_PREFIX} Injected patched script.`);
                                setTimeout(addDownloadButton, 1500);
                            } else { console.error(`${LOG_PREFIX} Failed fetch script ${node.src}. Status: ${response.status}`); } },
                        onerror: function(error) { console.error(`${LOG_PREFIX} Error fetching script ${node.src}:`, error); }
                    }); node.remove(); } }); }); });
        observer.observe(document, { childList: true, subtree: true }); console.log(`${LOG_PREFIX} MutationObserver active.`);
        setTimeout(addDownloadButton, 7000); // Fallback button add
    })();

})(); // End of IIFE

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址