webgl-forensics 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/forensics.yml +44 -0
- package/CLAUDE_CODE.md +49 -0
- package/MASTER_PROMPT.md +202 -0
- package/README.md +579 -0
- package/SKILL.md +1256 -0
- package/package.json +53 -0
- package/project summary.md +33 -0
- package/puppeteer-runner.js +486 -0
- package/scripts/00-tech-stack-detect.js +185 -0
- package/scripts/01-source-map-extractor.js +73 -0
- package/scripts/02-interaction-model.js +129 -0
- package/scripts/03-responsive-analysis.js +115 -0
- package/scripts/04-page-transitions.js +128 -0
- package/scripts/05-loading-sequence.js +124 -0
- package/scripts/06-audio-extraction.js +115 -0
- package/scripts/07-accessibility-reduced-motion.js +133 -0
- package/scripts/08-complexity-scorer.js +138 -0
- package/scripts/09-visual-diff-validator.js +113 -0
- package/scripts/10-webgpu-extractor.js +248 -0
- package/scripts/11-scroll-screenshot-grid.js +76 -0
- package/scripts/12-network-waterfall.js +151 -0
- package/scripts/13-react-fiber-walker.js +285 -0
- package/scripts/14-shader-hotpatch.js +349 -0
- package/scripts/15-multipage-crawler.js +221 -0
- package/scripts/16-gsap-timeline-recorder.js +335 -0
- package/scripts/17-r3f-fiber-serializer.js +316 -0
- package/scripts/18-font-extractor.js +290 -0
- package/scripts/19-design-token-export.js +85 -0
- package/scripts/20-timeline-visualizer.js +61 -0
- package/scripts/21-lighthouse-audit.js +35 -0
- package/scripts/22-sitemap-crawler.js +128 -0
- package/scripts/23-scaffold-generator.js +451 -0
- package/scripts/24-ai-reconstruction.js +226 -0
- package/scripts/25-shader-annotator.js +226 -0
- package/tui.js +196 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCRIPT 10 — WebGPU Extractor
|
|
3
|
+
* webgl-forensics skill | Enhancement #7
|
|
4
|
+
*
|
|
5
|
+
* PURPOSE:
|
|
6
|
+
* Detect and extract WebGPU compute shaders, render pipelines, and
|
|
7
|
+
* bind group layouts from sites using the WebGPU API (next-gen graphics).
|
|
8
|
+
*
|
|
9
|
+
* WebGPU is the successor to WebGL. Sites using Three.js WebGPURenderer,
|
|
10
|
+
* Babylon.js WebGPU mode, or custom WebGPU code will be captured here.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT EXTRACTS:
|
|
13
|
+
* - Whether WebGPU is supported + preferred adapter info
|
|
14
|
+
* - Detected WebGPU renderer (Three.js WebGPURenderer, Babylon, custom)
|
|
15
|
+
* - WGSL shader source (the WebGPU shader language, like GLSL for WebGPU)
|
|
16
|
+
* - Pipeline descriptors (vertex, fragment, compute stages)
|
|
17
|
+
* - Bind group layouts (how data is passed to shaders)
|
|
18
|
+
* - GPU device limits and features
|
|
19
|
+
*
|
|
20
|
+
* RUN VIA:
|
|
21
|
+
* evaluate_script — run after page loads, before/after navigating
|
|
22
|
+
*
|
|
23
|
+
* NOTE:
|
|
24
|
+
* WebGPU is still experimental in 2026. Most sites still use WebGL.
|
|
25
|
+
* Run this script when 00-tech-stack-detect.js reports:
|
|
26
|
+
* - Three.js >= r163 (WebGPURenderer was added in r163)
|
|
27
|
+
* - Babylon.js 6.x+ in WebGPU mode
|
|
28
|
+
* - Any custom WebGPU code
|
|
29
|
+
*/
|
|
30
|
+
(async () => {
|
|
31
|
+
const result = {
|
|
32
|
+
meta: {
|
|
33
|
+
url: window.location.href,
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
webgpuSupported: typeof navigator.gpu !== 'undefined',
|
|
36
|
+
webglFallback: typeof WebGLRenderingContext !== 'undefined',
|
|
37
|
+
},
|
|
38
|
+
adapter: null,
|
|
39
|
+
device: null,
|
|
40
|
+
detectedRenderer: null,
|
|
41
|
+
pipelines: [],
|
|
42
|
+
shaders: [],
|
|
43
|
+
bindGroupLayouts: [],
|
|
44
|
+
computeShaders: [],
|
|
45
|
+
recommendations: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ═══════════════════════════════════════════════
|
|
49
|
+
// GUARD — WebGPU detection
|
|
50
|
+
// ═══════════════════════════════════════════════
|
|
51
|
+
if (!navigator.gpu) {
|
|
52
|
+
result.status = 'WebGPU not supported in this browser';
|
|
53
|
+
result.recommendations.push('This browser does not support WebGPU. Use Chrome 113+ or Edge 113+.');
|
|
54
|
+
result.recommendations.push('For cloning: this site likely uses WebGL fallback — run scripts 00 and 14 instead.');
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ═══════════════════════════════════════════════
|
|
59
|
+
// STEP 1 — Request adapter info (read-only, no permission needed)
|
|
60
|
+
// ═══════════════════════════════════════════════
|
|
61
|
+
try {
|
|
62
|
+
const adapter = await navigator.gpu.requestAdapter({
|
|
63
|
+
powerPreference: 'high-performance',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (adapter) {
|
|
67
|
+
const info = await adapter.requestAdapterInfo?.();
|
|
68
|
+
result.adapter = {
|
|
69
|
+
vendor: info?.vendor || 'unknown',
|
|
70
|
+
architecture: info?.architecture || 'unknown',
|
|
71
|
+
device: info?.device || 'unknown',
|
|
72
|
+
description: info?.description || 'unknown',
|
|
73
|
+
isFallbackAdapter: adapter.isFallbackAdapter,
|
|
74
|
+
features: [...(adapter.features?.values() || [])],
|
|
75
|
+
limits: {
|
|
76
|
+
maxTextureDimension2D: adapter.limits?.maxTextureDimension2D,
|
|
77
|
+
maxBufferSize: adapter.limits?.maxBufferSize,
|
|
78
|
+
maxComputeWorkgroupSizeX: adapter.limits?.maxComputeWorkgroupSizeX,
|
|
79
|
+
maxComputeInvocationsPerWorkgroup: adapter.limits?.maxComputeInvocationsPerWorkgroup,
|
|
80
|
+
maxBindGroups: adapter.limits?.maxBindGroups,
|
|
81
|
+
maxSampledTexturesPerShaderStage: adapter.limits?.maxSampledTexturesPerShaderStage,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
result.adapter = { error: e.message };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ═══════════════════════════════════════════════
|
|
90
|
+
// STEP 2 — Detect WebGPU renderer type
|
|
91
|
+
// ═══════════════════════════════════════════════
|
|
92
|
+
function detectWebGPURenderer() {
|
|
93
|
+
// Three.js WebGPURenderer (r163+)
|
|
94
|
+
if (window.THREE) {
|
|
95
|
+
if (window.THREE.WebGPURenderer) {
|
|
96
|
+
return { name: 'Three.js WebGPURenderer', version: window.THREE.REVISION, detected: true };
|
|
97
|
+
}
|
|
98
|
+
// Check if any existing renderer is WebGPU-based
|
|
99
|
+
if (window.__threeRenderer?.isWebGPURenderer) {
|
|
100
|
+
return { name: 'Three.js WebGPURenderer (active)', version: window.THREE.REVISION, detected: true };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Babylon.js WebGPU
|
|
105
|
+
if (window.BABYLON) {
|
|
106
|
+
const engine = window.BABYLON.Engine?.LastCreatedEngine;
|
|
107
|
+
if (engine?.constructor?.name?.includes('WebGPU')) {
|
|
108
|
+
return { name: 'Babylon.js WebGPU Engine', version: window.BABYLON.Engine?.Version, detected: true };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// wgpu-matrix or raw WebGPU
|
|
113
|
+
if (window.GPUDevice || document.querySelector('[data-webgpu]')) {
|
|
114
|
+
return { name: 'Custom WebGPU', detected: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check canvas context types
|
|
118
|
+
for (const canvas of document.querySelectorAll('canvas')) {
|
|
119
|
+
try {
|
|
120
|
+
// Don't actually request context — just check if it was already created
|
|
121
|
+
const ctx = canvas._gpuContext;
|
|
122
|
+
if (ctx) return { name: 'Custom WebGPU (canvas detected)', detected: true };
|
|
123
|
+
} catch(e) {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { name: null, detected: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
result.detectedRenderer = detectWebGPURenderer();
|
|
130
|
+
|
|
131
|
+
// ═══════════════════════════════════════════════
|
|
132
|
+
// STEP 3 — INTERCEPT WebGPU pipeline creation
|
|
133
|
+
// (Monkey-patch for future calls on this page)
|
|
134
|
+
// ═══════════════════════════════════════════════
|
|
135
|
+
if (!window.__webgpuIntercepted) {
|
|
136
|
+
window.__webgpuIntercepted = true;
|
|
137
|
+
window.__webgpuCaptured = {
|
|
138
|
+
shaderModules: [],
|
|
139
|
+
pipelines: [],
|
|
140
|
+
computePipelines: [],
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Intercept GPUDevice.createShaderModule (captures WGSL source)
|
|
144
|
+
const origCreateShaderModule = GPUDevice.prototype.createShaderModule;
|
|
145
|
+
GPUDevice.prototype.createShaderModule = function(descriptor) {
|
|
146
|
+
if (descriptor?.code) {
|
|
147
|
+
window.__webgpuCaptured.shaderModules.push({
|
|
148
|
+
label: descriptor.label || `shader_${window.__webgpuCaptured.shaderModules.length}`,
|
|
149
|
+
code: descriptor.code,
|
|
150
|
+
capturedAt: Date.now(),
|
|
151
|
+
});
|
|
152
|
+
console.log(`🔬 [webgl-forensics] Captured WGSL shader: ${descriptor.label || 'unlabeled'}`);
|
|
153
|
+
}
|
|
154
|
+
return origCreateShaderModule.apply(this, arguments);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Intercept createRenderPipeline
|
|
158
|
+
const origCreateRenderPipeline = GPUDevice.prototype.createRenderPipeline;
|
|
159
|
+
GPUDevice.prototype.createRenderPipeline = function(descriptor) {
|
|
160
|
+
window.__webgpuCaptured.pipelines.push({
|
|
161
|
+
label: descriptor?.label,
|
|
162
|
+
vertexEntry: descriptor?.vertex?.entryPoint,
|
|
163
|
+
fragmentEntry: descriptor?.fragment?.entryPoint,
|
|
164
|
+
topology: descriptor?.primitive?.topology || 'triangle-list',
|
|
165
|
+
cullMode: descriptor?.primitive?.cullMode || 'none',
|
|
166
|
+
depthWriteEnabled: descriptor?.depthStencil?.depthWriteEnabled,
|
|
167
|
+
});
|
|
168
|
+
return origCreateRenderPipeline.apply(this, arguments);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Intercept createComputePipeline
|
|
172
|
+
const origCreateComputePipeline = GPUDevice.prototype.createComputePipeline;
|
|
173
|
+
GPUDevice.prototype.createComputePipeline = function(descriptor) {
|
|
174
|
+
window.__webgpuCaptured.computePipelines.push({
|
|
175
|
+
label: descriptor?.label,
|
|
176
|
+
computeEntry: descriptor?.compute?.entryPoint,
|
|
177
|
+
});
|
|
178
|
+
return origCreateComputePipeline.apply(this, arguments);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ═══════════════════════════════════════════════
|
|
183
|
+
// STEP 4 — Collect already-captured data
|
|
184
|
+
// ═══════════════════════════════════════════════
|
|
185
|
+
if (window.__webgpuCaptured) {
|
|
186
|
+
result.shaders = window.__webgpuCaptured.shaderModules.map(s => ({
|
|
187
|
+
label: s.label,
|
|
188
|
+
wgsl: s.code.slice(0, 3000), // first 3000 chars
|
|
189
|
+
length: s.code.length,
|
|
190
|
+
hasComputeStage: s.code.includes('@compute'),
|
|
191
|
+
hasVertexStage: s.code.includes('@vertex'),
|
|
192
|
+
hasFragmentStage: s.code.includes('@fragment'),
|
|
193
|
+
uniforms: [...s.code.matchAll(/@group\((\d+)\) @binding\((\d+)\) var(?:<uniform>)? (\w+)/g)]
|
|
194
|
+
.map(m => ({ group: m[1], binding: m[2], name: m[3] })),
|
|
195
|
+
storageBuffers: [...s.code.matchAll(/@group\((\d+)\) @binding\((\d+)\) var<storage/g)]
|
|
196
|
+
.map(m => ({ group: m[1], binding: m[2] })),
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
result.pipelines = window.__webgpuCaptured.pipelines;
|
|
200
|
+
result.computeShaders = window.__webgpuCaptured.computePipelines;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ═══════════════════════════════════════════════
|
|
204
|
+
// STEP 5 — Three.js WebGPURenderer specific extraction
|
|
205
|
+
// ═══════════════════════════════════════════════
|
|
206
|
+
if (window.THREE?.WebGPURenderer && window.__threeRenderer?.isWebGPURenderer) {
|
|
207
|
+
const renderer = window.__threeRenderer;
|
|
208
|
+
try {
|
|
209
|
+
result.threeWebGPU = {
|
|
210
|
+
isWebGPURenderer: true,
|
|
211
|
+
backend: renderer.backend?.constructor?.name,
|
|
212
|
+
info: {
|
|
213
|
+
memory: renderer.info?.memory,
|
|
214
|
+
render: renderer.info?.render,
|
|
215
|
+
},
|
|
216
|
+
// Three.js WebGPU uses a node-based material system
|
|
217
|
+
nodeBuilderCache: renderer._nodeBuilderCache
|
|
218
|
+
? Object.keys(renderer._nodeBuilderCache).length + ' cached nodes'
|
|
219
|
+
: null,
|
|
220
|
+
};
|
|
221
|
+
} catch(e) {
|
|
222
|
+
result.threeWebGPU = { error: e.message };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ═══════════════════════════════════════════════
|
|
227
|
+
// STEP 6 — Recommendations
|
|
228
|
+
// ═══════════════════════════════════════════════
|
|
229
|
+
if (!result.detectedRenderer.detected) {
|
|
230
|
+
result.recommendations.push('No WebGPU renderer detected. This site likely uses WebGL — run script 14 for shader extraction.');
|
|
231
|
+
} else {
|
|
232
|
+
result.recommendations.push(`Detected: ${result.detectedRenderer.name}`);
|
|
233
|
+
if (result.shaders.length === 0) {
|
|
234
|
+
result.recommendations.push('No WGSL shaders captured yet. The intercept is now installed — hard-reload the page to capture shader compilation.');
|
|
235
|
+
} else {
|
|
236
|
+
result.recommendations.push(`Captured ${result.shaders.length} WGSL shader modules.`);
|
|
237
|
+
}
|
|
238
|
+
if (result.shaders.some(s => s.hasComputeStage)) {
|
|
239
|
+
result.recommendations.push('⚡ Compute shaders found — this site does GPU-side computation (particles, physics, image processing).');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
result.status = result.detectedRenderer.detected
|
|
244
|
+
? `WebGPU detected: ${result.detectedRenderer.name}`
|
|
245
|
+
: 'WebGPU supported by browser but not used by this site';
|
|
246
|
+
|
|
247
|
+
return result;
|
|
248
|
+
})()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SCRIPT 11 — Auto-Scroll Screenshot Grid
|
|
4
|
+
* webgl-forensics skill | Enhancement #9
|
|
5
|
+
*
|
|
6
|
+
* STANDALONE USAGE (no browser DevTools needed):
|
|
7
|
+
* node puppeteer-runner.js https://cappen.com
|
|
8
|
+
*
|
|
9
|
+
* EVALUATE_SCRIPT MODE:
|
|
10
|
+
* Paste into Chrome console or evaluate_script tool
|
|
11
|
+
* Results: returns scroll positions for use with take_screenshot tool
|
|
12
|
+
*
|
|
13
|
+
* Purpose: Generate a complete visual map of the site by:
|
|
14
|
+
* 1. Getting total page height
|
|
15
|
+
* 2. Calculating 10 scroll positions (0%, 10%, 20%... 100%)
|
|
16
|
+
* 3. Returning the scroll positions for sequential screenshot capture
|
|
17
|
+
*
|
|
18
|
+
* In evaluate_script mode — returns positions only (screenshots are manual).
|
|
19
|
+
* In puppeteer mode — auto-scrolls and screenshots.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ════════════════════════════════════════════════
|
|
23
|
+
// BROWSER CONSOLE / evaluate_script VERSION
|
|
24
|
+
// ════════════════════════════════════════════════
|
|
25
|
+
const BROWSER_IIFE = `
|
|
26
|
+
(() => {
|
|
27
|
+
const totalHeight = document.documentElement.scrollHeight;
|
|
28
|
+
const viewportHeight = window.innerHeight;
|
|
29
|
+
const steps = 10;
|
|
30
|
+
|
|
31
|
+
const positions = [];
|
|
32
|
+
for (let i = 0; i <= steps; i++) {
|
|
33
|
+
const pct = i / steps;
|
|
34
|
+
const y = Math.round(pct * (totalHeight - viewportHeight));
|
|
35
|
+
positions.push({
|
|
36
|
+
step: i,
|
|
37
|
+
percentage: Math.round(pct * 100) + '%',
|
|
38
|
+
scrollY: y,
|
|
39
|
+
instructions: [
|
|
40
|
+
\`window.scrollTo(0, \${y})\`,
|
|
41
|
+
\`take_screenshot → save as "scroll-\${Math.round(pct * 100)}pct.png"\`,
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log('📸 SCREENSHOT GRID PLAN:');
|
|
47
|
+
console.table(positions.map(p => ({ pct: p.percentage, scrollY: p.scrollY })));
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
totalPageHeight: totalHeight + 'px',
|
|
51
|
+
viewportHeight: viewportHeight + 'px',
|
|
52
|
+
scrollableArea: (totalHeight - viewportHeight) + 'px',
|
|
53
|
+
screenshotCount: steps + 1,
|
|
54
|
+
positions,
|
|
55
|
+
instructions: [
|
|
56
|
+
'For each position:',
|
|
57
|
+
'1. Call: window.scrollTo(0, {scrollY})',
|
|
58
|
+
'2. Wait 500ms for animations to settle',
|
|
59
|
+
'3. Call: take_screenshot and save with the step name',
|
|
60
|
+
'Or use evaluate_script with: window.scrollTo(0, {y}); then take_screenshot',
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
})()
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
// If running in Node.js via puppeteer, export the logic
|
|
67
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
68
|
+
module.exports = { BROWSER_IIFE };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Print the script for use in browser console
|
|
72
|
+
if (typeof window === 'undefined') {
|
|
73
|
+
console.log('Paste the following into evaluate_script or browser console:');
|
|
74
|
+
console.log('─'.repeat(60));
|
|
75
|
+
console.log(BROWSER_IIFE);
|
|
76
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCRIPT 12 — Network Waterfall & CDN Fingerprinting
|
|
3
|
+
* webgl-forensics skill | Enhancement #2
|
|
4
|
+
*
|
|
5
|
+
* Run via: evaluate_script (paste entire IIFE)
|
|
6
|
+
* Purpose: Map the network delivery architecture:
|
|
7
|
+
* - CDN provider identification from headers
|
|
8
|
+
* - Critical rendering path (what blocks first paint)
|
|
9
|
+
* - External origin analysis
|
|
10
|
+
* - Cache strategy
|
|
11
|
+
* - Assets worth replicating vs. avoiding
|
|
12
|
+
*/
|
|
13
|
+
(() => {
|
|
14
|
+
const result = {
|
|
15
|
+
url: window.location.href,
|
|
16
|
+
delivery: {},
|
|
17
|
+
criticalPath: [],
|
|
18
|
+
externalOrigins: {},
|
|
19
|
+
heaviestByType: {},
|
|
20
|
+
performanceBudget: {},
|
|
21
|
+
recommendations: [],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ═══ RESOURCE TIMING API ═══
|
|
25
|
+
const resources = performance.getEntriesByType('resource');
|
|
26
|
+
|
|
27
|
+
if (!resources.length) {
|
|
28
|
+
return { error: 'No resource timing data. Try running after full page load.' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ═══ CDN FINGERPRINTING ═══
|
|
32
|
+
// Can't read response headers in JS (CORS), but can infer from resource URLs
|
|
33
|
+
const cdnPatterns = [
|
|
34
|
+
{ name: 'Vercel Edge', pattern: /vercel\.app|vercel\.com|v\.cdn\.vercel\.com/i },
|
|
35
|
+
{ name: 'Cloudflare', pattern: /cloudflare|workers\.dev|pages\.dev/i },
|
|
36
|
+
{ name: 'Fastly', pattern: /fastly\.net/i },
|
|
37
|
+
{ name: 'AWS CloudFront', pattern: /cloudfront\.net/i },
|
|
38
|
+
{ name: 'Netlify', pattern: /netlify\.app|netlify\.com/i },
|
|
39
|
+
{ name: 'Akamai', pattern: /akamai\.net|akamaized\.net/i },
|
|
40
|
+
{ name: 'jsDelivr', pattern: /jsdelivr\.net/i },
|
|
41
|
+
{ name: 'UNPKG', pattern: /unpkg\.com/i },
|
|
42
|
+
{ name: 'Google CDN', pattern: /googleapis\.com|gstatic\.com/i },
|
|
43
|
+
{ name: 'Bunny CDN', pattern: /b-cdn\.net|bunnycdn\.com/i },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const detectedCDNs = new Set();
|
|
47
|
+
resources.forEach(r => {
|
|
48
|
+
cdnPatterns.forEach(({ name, pattern }) => {
|
|
49
|
+
if (pattern.test(r.name)) detectedCDNs.add(name);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Check host itself
|
|
54
|
+
cdnPatterns.forEach(({ name, pattern }) => {
|
|
55
|
+
if (pattern.test(window.location.hostname)) detectedCDNs.add(name + ' (host)');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
result.delivery.cdns = [...detectedCDNs];
|
|
59
|
+
result.delivery.protocol = performance.getEntriesByType('navigation')[0]?.nextHopProtocol || 'unknown';
|
|
60
|
+
result.delivery.serviceWorker = !!navigator.serviceWorker?.controller;
|
|
61
|
+
|
|
62
|
+
// ═══ RESOURCE BREAKDOWN ═══
|
|
63
|
+
const byType = {};
|
|
64
|
+
resources.forEach(r => {
|
|
65
|
+
const type = r.initiatorType;
|
|
66
|
+
if (!byType[type]) byType[type] = { count: 0, totalKB: 0, items: [] };
|
|
67
|
+
byType[type].count++;
|
|
68
|
+
byType[type].totalKB += (r.transferSize / 1024);
|
|
69
|
+
byType[type].items.push({
|
|
70
|
+
name: r.name.split('/').pop().slice(0, 50),
|
|
71
|
+
size: (r.transferSize / 1024).toFixed(1) + 'KB',
|
|
72
|
+
duration: r.duration.toFixed(0) + 'ms',
|
|
73
|
+
cached: r.transferSize === 0,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Sort by size, keep top 5 per type
|
|
78
|
+
Object.keys(byType).forEach(type => {
|
|
79
|
+
byType[type].totalKB = byType[type].totalKB.toFixed(1) + 'KB';
|
|
80
|
+
byType[type].items = byType[type].items
|
|
81
|
+
.sort((a, b) => parseFloat(b.size) - parseFloat(a.size))
|
|
82
|
+
.slice(0, 5);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
result.heaviestByType = byType;
|
|
86
|
+
|
|
87
|
+
// ═══ CRITICAL RENDERING PATH ═══
|
|
88
|
+
// Resources loaded before first paint are render-blocking
|
|
89
|
+
const fcp = performance.getEntriesByType('paint').find(p => p.name === 'first-contentful-paint');
|
|
90
|
+
const fcpTime = fcp?.startTime || Infinity;
|
|
91
|
+
|
|
92
|
+
result.criticalPath = resources
|
|
93
|
+
.filter(r => r.startTime < fcpTime && r.transferSize > 0)
|
|
94
|
+
.sort((a, b) => a.startTime - b.startTime)
|
|
95
|
+
.slice(0, 15)
|
|
96
|
+
.map(r => ({
|
|
97
|
+
name: r.name.split('/').pop().slice(0, 60),
|
|
98
|
+
type: r.initiatorType,
|
|
99
|
+
startTime: r.startTime.toFixed(0) + 'ms',
|
|
100
|
+
duration: r.duration.toFixed(0) + 'ms',
|
|
101
|
+
size: (r.transferSize / 1024).toFixed(1) + 'KB',
|
|
102
|
+
renderBlocking: r.renderBlockingStatus === 'blocking' || r.initiatorType === 'link',
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
// ═══ EXTERNAL ORIGINS ═══
|
|
106
|
+
const origins = {};
|
|
107
|
+
resources.forEach(r => {
|
|
108
|
+
try {
|
|
109
|
+
const origin = new URL(r.name).origin;
|
|
110
|
+
if (origin !== window.location.origin) {
|
|
111
|
+
if (!origins[origin]) origins[origin] = { count: 0, totalKB: 0 };
|
|
112
|
+
origins[origin].count++;
|
|
113
|
+
origins[origin].totalKB += r.transferSize / 1024;
|
|
114
|
+
}
|
|
115
|
+
} catch(e) {}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
result.externalOrigins = Object.fromEntries(
|
|
119
|
+
Object.entries(origins).sort((a, b) => b[1].totalKB - a[1].totalKB)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// ═══ PERFORMANCE BUDGET CHECK ═══
|
|
123
|
+
const totalJS = resources.filter(r => r.initiatorType === 'script')
|
|
124
|
+
.reduce((acc, r) => acc + r.transferSize, 0) / 1024;
|
|
125
|
+
const totalCSS = resources.filter(r => r.initiatorType === 'link' || r.initiatorType === 'css')
|
|
126
|
+
.reduce((acc, r) => acc + r.transferSize, 0) / 1024;
|
|
127
|
+
const totalImages = resources.filter(r => r.initiatorType === 'img')
|
|
128
|
+
.reduce((acc, r) => acc + r.transferSize, 0) / 1024;
|
|
129
|
+
const total3D = resources.filter(r => /\.(glb|gltf|obj|fbx|draco|basis)/i.test(r.name))
|
|
130
|
+
.reduce((acc, r) => acc + r.transferSize, 0) / 1024;
|
|
131
|
+
const totalFonts = resources.filter(r => r.initiatorType === 'font' || /\.(woff2?|ttf|otf)/i.test(r.name))
|
|
132
|
+
.reduce((acc, r) => acc + r.transferSize, 0) / 1024;
|
|
133
|
+
|
|
134
|
+
result.performanceBudget = {
|
|
135
|
+
javascript: { actual: totalJS.toFixed(1) + 'KB', budget: '500KB', over: totalJS > 500 },
|
|
136
|
+
css: { actual: totalCSS.toFixed(1) + 'KB', budget: '100KB', over: totalCSS > 100 },
|
|
137
|
+
images: { actual: totalImages.toFixed(1) + 'KB', budget: '1000KB', over: totalImages > 1000 },
|
|
138
|
+
fonts: { actual: totalFonts.toFixed(1) + 'KB', budget: '150KB', over: totalFonts > 150 },
|
|
139
|
+
'3dAssets': { actual: total3D.toFixed(1) + 'KB', budget: '2000KB', over: total3D > 2000 },
|
|
140
|
+
total: ((totalJS + totalCSS + totalImages + totalFonts + total3D)).toFixed(1) + 'KB',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ═══ CLONE RECOMMENDATIONS ═══
|
|
144
|
+
if (totalJS > 1000) result.recommendations.push('⚠️ JS bundle > 1MB — investigate with source map extractor to find what\'s large');
|
|
145
|
+
if (total3D > 0) result.recommendations.push(`✅ 3D assets found (${total3D.toFixed(1)}KB) — download and optimize for clone`);
|
|
146
|
+
if (totalFonts > 300) result.recommendations.push('⚠️ Heavy fonts — consider subsetting or using variable fonts in clone');
|
|
147
|
+
if (result.delivery.serviceWorker) result.recommendations.push('🔧 Service Worker detected — implement for offline functionality in clone');
|
|
148
|
+
if (detectedCDNs.size === 0) result.recommendations.push('ℹ️ No major CDN detected — self-hosted or unknown delivery');
|
|
149
|
+
|
|
150
|
+
return result;
|
|
151
|
+
})()
|