ncikkis sketchfab downloader

Attempts model download via runtime code modification using user-provided geometry patch regex. Requires JSZip/FileSaver.

  1. // ==UserScript==
  2. // @name ncikkis sketchfab downloader
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.1 // Implemented user-provided regex for Geometry Capture
  5. // @description Attempts model download via runtime code modification using user-provided geometry patch regex. Requires JSZip/FileSaver.
  6. // @author ncikkis
  7. // @match https://sketchfab.com/*
  8. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
  9. // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
  10. // @run-at document-start
  11. // @grant unsafeWindow
  12. // @grant GM_download
  13. // @grant GM_xmlhttpRequest
  14. // @namespace https://greasyfork.org/users/956968 // Original namespace kept for reference to technique origin
  15. // @license MIT
  16. // ==/UserScript==
  17.  
  18. (async function() {
  19. 'use strict';
  20. const LOG_PREFIX = '[ncikkis_SFD_v2.1]:';
  21.  
  22. // --- Global Storage & Capture Functions (Same as v1.9) ---
  23. let capturedGeometries = new Map();
  24. let capturedTextures = new Map();
  25. let capturedGeometryKeys = new Set();
  26. let geomCounter = 0;
  27. let textureCounter = 0;
  28. let downloadButtonAdded = false;
  29.  
  30. console.log(`${LOG_PREFIX} Initializing. Waiting for Sketchfab JS...`);
  31.  
  32. unsafeWindow.captureGeometry = function(geomObj) {
  33. // De-duplication and parsing logic remains the same as v1.9
  34. // Added check: if 'this' is passed, try accessing potential geometry properties from it
  35. if (geomObj === unsafeWindow || !geomObj) {
  36. console.warn(`${LOG_PREFIX} captureGeometry called with invalid object:`, geomObj);
  37. return; // Don't process window or null/undefined
  38. }
  39.  
  40. // Attempt to access geometry data assuming geomObj might be 'this' from the hooked function
  41. const attributes = geomObj._attributes || geomObj.attributes; // Try common property names
  42. const primitives = geomObj._primitives || geomObj.primitives;
  43.  
  44. if (!primitives || !attributes?.Vertex?._elements) {
  45. //console.warn(`${LOG_PREFIX} Skipping capture: Missing primitives or vertex data in object:`, geomObj);
  46. return;
  47. }
  48. // Check object reference duplication first
  49. if (geomObj.__captured__) return;
  50.  
  51. // Content-based de-duplication check
  52. try {
  53. const vertexCount = attributes.Vertex._elements.length;
  54. let totalIndexCount = 0;
  55. primitives?.forEach(p => { totalIndexCount += p?.indices?._elements?.length || 0; });
  56. if (vertexCount === 0 || totalIndexCount === 0) return;
  57. const geometryKey = `${vertexCount}_${totalIndexCount}`;
  58. if (capturedGeometryKeys.has(geometryKey)) return;
  59. capturedGeometryKeys.add(geometryKey); geomObj.__captured__ = true;
  60. } catch (e) { console.error(`${LOG_PREFIX} De-dup check error:`, e); geomObj.__captured__ = true; return; }
  61.  
  62. geomCounter++; const geomId = `geom_${geomCounter}`; const modelName = `model_${geomCounter}`;
  63. console.log(`${LOG_PREFIX} Capturing NEW Geometry: ${modelName} (Key: ${capturedGeometryKeys.size})`);
  64.  
  65. try {
  66. // Pass the potentially correct object containing _attributes and _primitives to parsing
  67. const parsed = parseGeometry(geomObj); // Assume parseGeometry handles variations if needed
  68. if (!parsed) { throw new Error("Parsing failed"); }
  69. const objStr = generateOBJ(parsed, modelName); capturedGeometries.set(geomId, { name: modelName, objData: objStr });
  70. } catch(e) { console.error(`${LOG_PREFIX} Error processing geometry ${modelName}:`, e); }
  71. };
  72.  
  73. unsafeWindow.captureTextureURL = function(imageInfo, imageModel) {
  74. // Same URL capture logic as v1.9/2.0
  75. if (!imageModel?.attributes?.images) { return imageInfo; }
  76. try {
  77. const originalUrl = imageInfo.url; const filename_image = imageModel.attributes.name || `texture_${textureCounter++}`;
  78. let bestUrl = originalUrl; let max_size = imageInfo.size || 0;
  79. imageModel.attributes.images.forEach(img => { if (img?.url && img.size > max_size) { max_size = img.size; bestUrl = img.url; } });
  80. if (!capturedTextures.has(bestUrl)) { console.log(`${LOG_PREFIX} Capturing Texture URL: ${filename_image} -> ${bestUrl}`); capturedTextures.set(bestUrl, { name: filename_image, url: bestUrl }); }
  81. const bestImageInfo = imageModel.attributes.images.find(img => img.url === bestUrl) || imageInfo; return bestImageInfo;
  82. } catch (e) { console.error(`${LOG_PREFIX} Error in captureTextureURL:`, e); return imageInfo; }
  83. };
  84.  
  85. // --- Geometry Parsing and OBJ Generation (Same as v1.9/2.0) ---
  86. function parseGeometry(geomObj) {
  87. const primitives = []; const sourcePrimitives = geomObj._primitives || geomObj.primitives;
  88. if (sourcePrimitives && Array.isArray(sourcePrimitives)) { sourcePrimitives.forEach(p => { if (p?.indices?._elements) { primitives.push({ mode: p.mode, indices: p.indices._elements }); } }); }
  89. const attributes = geomObj._attributes || geomObj.attributes; if (!attributes?.Vertex?._elements) return null;
  90. let uvElements = []; for(let i=0; i<=8; i++) { if(attributes[`TexCoord${i}`]?._elements) { uvElements = attributes[`TexCoord${i}`]._elements; break; } }
  91. return { vertex: attributes.Vertex._elements, normal: attributes.Normal?._elements || [], uv: uvElements, primitives: primitives };
  92. }
  93. function generateOBJ(parsedData, modelName) { /* ... Same OBJ generation code ... */
  94. let objStr = `# Generated by ncikkis Sketchfab Downloader v2.1\n`; objStr += `o ${modelName}\n`;
  95. for (let i = 0; i < parsedData.vertex.length; i += 3) { objStr += `v ${parsedData.vertex[i]} ${parsedData.vertex[i+1]} ${parsedData.vertex[i+2]}\n`; }
  96. 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`; } }
  97. 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`; } }
  98. objStr += `s 1\n`;
  99. for (const primitive of parsedData.primitives) { const indices = primitive.indices; if (primitive.mode === 4) { 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) { 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;
  100. }
  101.  
  102. // --- Download Button and Packaging Logic (Same as v1.9/2.0) ---
  103. function addDownloadButton() { /* ... Same button add logic ... */
  104. if (downloadButtonAdded) return; const titleBar = document.querySelector('.titlebar') || document.querySelector('.viewer-header');
  105. 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.`);
  106. } else { console.log(`${LOG_PREFIX} Title bar not found, retrying button add later...`); setTimeout(addDownloadButton, 2000); }
  107. }
  108. async function initiateDownloadPackage() { /* ... Same zip and download logic ... */
  109. if (capturedGeometries.size === 0 && capturedTextures.size === 0) { alert("ncikkis Downloader: No geometry or textures captured."); return; }
  110. const zip = new JSZip(); const modelFolder = zip.folder('model'); console.log(`${LOG_PREFIX} Preparing download package...`);
  111. if (capturedGeometries.size > 0) { console.log(`${LOG_PREFIX} Adding ${capturedGeometries.size} geometry file(s)...`); capturedGeometries.forEach((geomInfo) => { modelFolder.file(`${geomInfo.name}.obj`, geomInfo.objData); }); } else { console.warn(`${LOG_PREFIX} No geometry captured.`); }
  112. if (capturedTextures.size > 0) {
  113. console.log(`${LOG_PREFIX} Fetching ${capturedTextures.size} texture file(s)...`); const texturePromises = [];
  114. capturedTextures.forEach((texInfo) => { const promise = new Promise((resolve, reject) => { GM_download({ url: texInfo.url, name: `temp_${texInfo.name}`, responseType: 'blob', onload: (r) => { if (r.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}`, r.response); resolve(); } else { reject(new Error(`No blob ${texInfo.name}`)); } }, onerror: reject, ontimeout: reject }); }); texturePromises.push(promise); });
  115. try { await Promise.all(texturePromises); console.log(`${LOG_PREFIX} Texture downloads complete.`); } catch (error) { console.error(`${LOG_PREFIX} Texture fetching error:`, error); alert(`ncikkis Downloader: Error downloading textures.`); }
  116. } else { console.warn(`${LOG_PREFIX} No textures captured.`); }
  117. try { console.log(`${LOG_PREFIX} Generating zip...`); 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 saving initiated: ${filename}`);
  118. } catch (e) { console.error(`${LOG_PREFIX} Zip generation error:`, e); alert(`ncikkis Downloader: Error generating zip.`); }
  119. }
  120.  
  121. // --- Script Interception and Patching ---
  122. const patchPoints = [
  123. { // Capture Geometry Object - USER PROVIDED REGEX
  124. regex: /(drawGeometry:\s*function\(\)\{.*?e\s*=\s*t.getLastProgramApplied\(\);)/g, // User pattern (non-greedy .*?)
  125. // Inject AFTER the matched block, assuming 'this' is the geometry object context
  126. injection: (match, p1) => `${p1} window.captureGeometry(this);`,
  127. name: "Geometry Capture"
  128. },
  129. { // Capture Texture URL/Info (Unchanged)
  130. regex: /getResourceImage:function\((\w+),(\w+)\)\{/g,
  131. injection: (match, p1, p2) => `${match} ${p1} = window.captureTextureURL(${p1}, this._imageModel);`,
  132. name: "Texture URL Capture"
  133. }
  134. ];
  135.  
  136. function patchScript(scriptText, scriptUrl) {
  137. // Same patching logic as v2.0
  138. let modifiedText = scriptText; let patchesApplied = 0; console.log(`${LOG_PREFIX} Patching script: ${scriptUrl}`);
  139. patchPoints.forEach(patch => {
  140. let matchFound = false; let iteration = 0; const maxIterations = 1000;
  141. try { const regex = new RegExp(patch.regex.source, patch.regex.flags.includes('g') ? patch.regex.flags : patch.regex.flags + 'g');
  142. modifiedText = modifiedText.replace(regex, (...args) => {
  143. matchFound = true; patchesApplied++; iteration++; if(iteration > maxIterations) {console.error("Max iterations for patch:", patch.name); return args[0];}
  144. const originalMatch = args[0]; if (typeof patch.injection === 'function') { return patch.injection(...args); } else { return originalMatch + patch.injection; } });
  145. } catch (e) { console.error(`${LOG_PREFIX} Regex error patch '${patch.name}':`, e); }
  146. if (matchFound) { console.log(`${LOG_PREFIX} Patch '${patch.name}' applied.`); } else { console.warn(`${LOG_PREFIX} Patch '${patch.name}' FAILED - pattern not found.`); } });
  147. if (patchesApplied > 0) { console.log(`${LOG_PREFIX} Total patches applied: ${patchesApplied}.`); return modifiedText; }
  148. else { console.warn(`${LOG_PREFIX} No patches applied to script: ${scriptUrl}.`); return scriptText; }
  149. }
  150.  
  151. // --- Script Interception Loader (Same as v1.9/2.0) ---
  152. (() => { /* ... Same MutationObserver/interception logic ... */
  153. const observer = new MutationObserver((mutations) => {
  154. mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => {
  155. if (node.tagName === 'SCRIPT' && node.src && (node.src.includes('web/dist/') || node.src.includes('standaloneViewer') || node.src.includes('/viewer/'))) {
  156. node.async = false; node.defer = false; node.removeAttribute('integrity'); node.type = 'text/plain'; console.log(`${LOG_PREFIX} Intercepted Sketchfab script: ${node.src}`);
  157. GM_xmlhttpRequest({ method: "GET", url: node.src,
  158. onload: function(response) {
  159. 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); }
  160. else { console.error(`${LOG_PREFIX} Failed fetch script ${node.src}. Status: ${response.status}`); } },
  161. onerror: function(error) { console.error(`${LOG_PREFIX} Error fetching script ${node.src}:`, error); }
  162. }); node.remove(); } }); }); });
  163. observer.observe(document, { childList: true, subtree: true }); console.log(`${LOG_PREFIX} MutationObserver active.`);
  164. setTimeout(addDownloadButton, 7000); // Fallback button add
  165. })();
  166.  
  167. })(); // End of IIFE