// ==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