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
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webgl-forensics",
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "The definitive 24-phase reverse engineering engine for WebGL, Three.js, GSAP, and animated websites. Clone any site.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"webgl-forensics": "puppeteer-runner.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"forensics": "node puppeteer-runner.js",
|
|
10
|
+
"test": "node puppeteer-runner.js https://example.com --dry-run",
|
|
11
|
+
"lint": "node -e \"require('./puppeteer-runner.js')\" 2>&1 || true"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"webgl",
|
|
15
|
+
"three.js",
|
|
16
|
+
"gsap",
|
|
17
|
+
"forensics",
|
|
18
|
+
"reverse-engineering",
|
|
19
|
+
"website-cloning",
|
|
20
|
+
"shader-extraction",
|
|
21
|
+
"scroll-animation",
|
|
22
|
+
"puppeteer",
|
|
23
|
+
"r3f",
|
|
24
|
+
"react-three-fiber",
|
|
25
|
+
"design-tokens",
|
|
26
|
+
"framer-motion",
|
|
27
|
+
"lenis"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"chalk": "^5.6.2",
|
|
31
|
+
"inquirer": "^9.3.8",
|
|
32
|
+
"lighthouse": "^13.0.3",
|
|
33
|
+
"ora": "^8.2.0",
|
|
34
|
+
"puppeteer": "^22.0.0",
|
|
35
|
+
"puppeteer-screen-recorder": "^3.0.6"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"author": "404kidwiz",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"homepage": "https://github.com/404kidwiz/webgl-forensics#readme",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/404kidwiz/webgl-forensics.git"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/404kidwiz/webgl-forensics/issues"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# WebGL Forensics (v3.1)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
WebGL Forensics is the definitive reverse engineering engine for WebGL, Three.js, GSAP, and complex animated websites. It acts as an automated "site cloner" and forensics tool for developers who want to understand or reconstruct high-end Awwwards-style sites. It leverages Puppeteer to seamlessly analyze page payloads, intercept WebGL shaders, capture GSAP timelines, and serialize React-Three-Fiber (R3F) fiber trees.
|
|
5
|
+
|
|
6
|
+
With the release of v3.1, WebGL Forensics shifts from merely being an analysis tool into being an **actionable scaffolding engine** that can auto-generate the foundation of a modern Next.js 15 site from the extracted design, animation, and 3D data.
|
|
7
|
+
|
|
8
|
+
## Value Proposition
|
|
9
|
+
Instead of manually inspecting the network tab for shaders or trying to guess easing curves for scroll animations, developers can run this CLI and instantly get:
|
|
10
|
+
1. **Design Tokens**: Standardized CSS variables extracted from the target DOM.
|
|
11
|
+
2. **Animation Signatures**: Hard data on `gsap.to()` calls including durations and triggers.
|
|
12
|
+
3. **3D Assets & Logic**: GLSL shaders (vertex + fragment) and the physical tree structure of Three.js scenes.
|
|
13
|
+
4. **Instant Scaffolding**: Automatically built `Next.js` setups integrating `lenis`, `gsap`, and `@react-three/fiber` wired specifically to replicate the target site.
|
|
14
|
+
5. **AI Guides**: Formatted system prompts designed to assist Claude/Gemini in reconstructing the exact UI and Logic based on forensic data.
|
|
15
|
+
|
|
16
|
+
## Core v3.1 Additions
|
|
17
|
+
- **Sitemap Crawler** (`22-sitemap-crawler.js`): Intelligent crawler prioritizing links found in `/sitemap.xml` with automatic DOM-scraping fallback.
|
|
18
|
+
- **Next.js Scaffold Generator** (`23-scaffold-generator.js`): Parses forensic JSON and auto-generates a boilerplate React framework folder pre-configured with the exact libraries detected (e.g. framer-motion, R3F, gsap).
|
|
19
|
+
- **AI Reconstruction** (`24-ai-reconstruction.js`): Automatically packages forensic metadata and GLSL dumps into a Claude-ready "Here is a website, write the code to clone it" prompt.
|
|
20
|
+
- **Shader Annotator** (`25-shader-annotator.js`): Identifies common GLSL patterns offline and passes ambiguous shaders to local or remote AI for mathematical annotation.
|
|
21
|
+
- **TUI Dashboard** (`tui.js`): Terminal user interface using Ink/Chalk with real-time feedback on forensics progress instead of raw logs.
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
```bash
|
|
25
|
+
# Full automated run with scaffolding and AI prep
|
|
26
|
+
npx webgl-forensics https://example.com --scaffold --ai-report --screenshots --sitemap
|
|
27
|
+
|
|
28
|
+
# Interactive CLI mode
|
|
29
|
+
npx webgl-forensics --interactive
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Architecture Notes
|
|
33
|
+
The engine runs scripts consecutively in a headless browser context by injecting them into the DOM (`page.evaluate()`) to access core objects like `window.__THREE__`, `window.gsap`, or `WebGLRenderingContext.prototype.shaderSource`. Data is aggregated into a central `forensics-report-[domain].json` which drives the secondary AI and scaffolding steps.
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* webgl-forensics — Standalone Puppeteer Runner v3.1
|
|
4
|
+
* The definitive 24-phase forensics engine for WebGL, GSAP, R3F, and animated sites.
|
|
5
|
+
*
|
|
6
|
+
* USAGE:
|
|
7
|
+
* npx webgl-forensics [URL] [options]
|
|
8
|
+
* node puppeteer-runner.js [URL] [options]
|
|
9
|
+
*
|
|
10
|
+
* ─── FLAGS ────────────────────────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* v3.1 (new):
|
|
13
|
+
* --interactive Interactive TUI — configure run from terminal menu
|
|
14
|
+
* --scaffold Generate a Next.js 15 starter project from forensics
|
|
15
|
+
* --ai-report Generate AI Claude/Gemini reconstruction report (needs API key)
|
|
16
|
+
* --annotate-shaders AI-annotate extracted GLSL shaders (needs API key)
|
|
17
|
+
* --sitemap Use sitemap.xml for page discovery instead of DOM crawl
|
|
18
|
+
*
|
|
19
|
+
* v3.0:
|
|
20
|
+
* --compare [URL] Diff against a local clone URL
|
|
21
|
+
* --record Record an MP4 of the scroll session
|
|
22
|
+
* --download-assets Download GLB, WOFF2, HDR assets
|
|
23
|
+
* --format [json|html] Output format (default: json)
|
|
24
|
+
* --history Persist run metrics to local JSON history
|
|
25
|
+
*
|
|
26
|
+
* v2.0:
|
|
27
|
+
* --focus [3d|animations|scroll|layout|all]
|
|
28
|
+
* --output [path] Output directory (default: ./forensics-output)
|
|
29
|
+
* --headless / --no-headless Browser mode
|
|
30
|
+
* --screenshots Capture scroll screenshots
|
|
31
|
+
* --multipage Crawl all internal pages
|
|
32
|
+
* --timeout [ms] Navigation timeout (default: 30000)
|
|
33
|
+
* --dry-run Show what would run without fetching
|
|
34
|
+
*
|
|
35
|
+
* ──────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const path = require('path');
|
|
41
|
+
const fs = require('fs');
|
|
42
|
+
|
|
43
|
+
// ═══════════════════════════════════════════════════════
|
|
44
|
+
// OPTIONAL V3 DEPS — graceful fallback if not installed
|
|
45
|
+
// ═══════════════════════════════════════════════════════
|
|
46
|
+
let PuppeteerScreenRecorder;
|
|
47
|
+
try { PuppeteerScreenRecorder = require('puppeteer-screen-recorder').PuppeteerScreenRecorder; } catch {}
|
|
48
|
+
|
|
49
|
+
let runLighthouseAudit;
|
|
50
|
+
try { runLighthouseAudit = require('./scripts/21-lighthouse-audit.js'); } catch {}
|
|
51
|
+
|
|
52
|
+
let generateTimelineHtml;
|
|
53
|
+
try { generateTimelineHtml = require('./scripts/20-timeline-visualizer.js'); } catch {}
|
|
54
|
+
|
|
55
|
+
// V3.1 modules
|
|
56
|
+
let generateScaffold;
|
|
57
|
+
try { generateScaffold = require('./scripts/23-scaffold-generator.js'); } catch {}
|
|
58
|
+
|
|
59
|
+
let generateReconstructionReport;
|
|
60
|
+
try { generateReconstructionReport = require('./scripts/24-ai-reconstruction.js'); } catch {}
|
|
61
|
+
|
|
62
|
+
let annotateShaders;
|
|
63
|
+
try { annotateShaders = require('./scripts/25-shader-annotator.js'); } catch {}
|
|
64
|
+
|
|
65
|
+
let tuiModule;
|
|
66
|
+
try { tuiModule = require('./tui.js'); } catch {}
|
|
67
|
+
|
|
68
|
+
// ═══════════════════════════════════════════════════════
|
|
69
|
+
// ARG PARSING
|
|
70
|
+
// ═══════════════════════════════════════════════════════
|
|
71
|
+
const rawArgs = process.argv.slice(2);
|
|
72
|
+
let targetUrl = rawArgs.find(a => a.startsWith('http'));
|
|
73
|
+
|
|
74
|
+
function argVal(flag, def, args = rawArgs) {
|
|
75
|
+
const idx = args.indexOf(flag);
|
|
76
|
+
return idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--') ? args[idx + 1] : def;
|
|
77
|
+
}
|
|
78
|
+
function hasFlag(flag) { return rawArgs.includes(flag); }
|
|
79
|
+
|
|
80
|
+
let opts = {
|
|
81
|
+
focus: argVal('--focus', 'all'),
|
|
82
|
+
output: argVal('--output', './forensics-output'),
|
|
83
|
+
headless: !hasFlag('--no-headless'),
|
|
84
|
+
screenshots: hasFlag('--screenshots'),
|
|
85
|
+
multipage: hasFlag('--multipage'),
|
|
86
|
+
sitemap: hasFlag('--sitemap'),
|
|
87
|
+
timeout: parseInt(argVal('--timeout', '30000'), 10),
|
|
88
|
+
dryRun: hasFlag('--dry-run'),
|
|
89
|
+
interactive: hasFlag('--interactive'),
|
|
90
|
+
// V3.0
|
|
91
|
+
compareUrl: argVal('--compare', null),
|
|
92
|
+
record: hasFlag('--record'),
|
|
93
|
+
downloadAssets: hasFlag('--download-assets'),
|
|
94
|
+
formatHtml: argVal('--format', 'json') === 'html',
|
|
95
|
+
history: hasFlag('--history'),
|
|
96
|
+
// V3.1
|
|
97
|
+
scaffold: hasFlag('--scaffold'),
|
|
98
|
+
aiReport: hasFlag('--ai-report'),
|
|
99
|
+
annotateShaders: hasFlag('--annotate-shaders'),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ═══════════════════════════════════════════════════════
|
|
103
|
+
// HELP / DRY-RUN
|
|
104
|
+
// ═══════════════════════════════════════════════════════
|
|
105
|
+
if (!targetUrl && !opts.interactive && !opts.dryRun) {
|
|
106
|
+
console.log(`
|
|
107
|
+
╔══════════════════════════════════════════════════════════╗
|
|
108
|
+
║ webgl-forensics v3.1 — The Site Reverse-Engineering CLI ║
|
|
109
|
+
╚══════════════════════════════════════════════════════════╝
|
|
110
|
+
|
|
111
|
+
BASIC: npx webgl-forensics https://site.com
|
|
112
|
+
INTERACTIVE: npx webgl-forensics --interactive
|
|
113
|
+
COMPARE: npx webgl-forensics https://site.com --compare http://localhost:3000
|
|
114
|
+
SCAFFOLD: npx webgl-forensics https://site.com --scaffold --ai-report
|
|
115
|
+
SHADERS: npx webgl-forensics https://site.com --annotate-shaders --no-headless
|
|
116
|
+
FULL RUN: npx webgl-forensics https://site.com --scaffold --ai-report --screenshots --sitemap
|
|
117
|
+
|
|
118
|
+
Run with --interactive for an interactive configuration menu.
|
|
119
|
+
Run with --dry-run to preview phases without fetching.
|
|
120
|
+
`);
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (opts.dryRun) {
|
|
125
|
+
console.log('📋 DRY RUN — would analyze:', targetUrl || '(no URL given)');
|
|
126
|
+
console.log(' Focus:', opts.focus);
|
|
127
|
+
console.log(' Scaffold:', opts.scaffold);
|
|
128
|
+
console.log(' AI Report:', opts.aiReport);
|
|
129
|
+
console.log(' Sitemap:', opts.sitemap);
|
|
130
|
+
console.log(' Compare:', opts.compareUrl || 'None');
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ═══════════════════════════════════════════════════════
|
|
135
|
+
// SCRIPT LOADER
|
|
136
|
+
// ═══════════════════════════════════════════════════════
|
|
137
|
+
const SCRIPTS_DIR = path.join(__dirname, 'scripts');
|
|
138
|
+
|
|
139
|
+
function loadScript(filename) {
|
|
140
|
+
const p = path.join(SCRIPTS_DIR, filename);
|
|
141
|
+
if (!fs.existsSync(p)) throw new Error(`Script not found: ${p}`);
|
|
142
|
+
return fs.readFileSync(p, 'utf-8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getPhases(focus) {
|
|
146
|
+
const all = [
|
|
147
|
+
{ name: 'loading-sequence', script: '05-loading-sequence.js', focus: ['all', '3d', 'animations'] },
|
|
148
|
+
{ name: 'tech-stack', script: '00-tech-stack-detect.js', focus: ['all', '3d', 'animations', 'scroll', 'layout'] },
|
|
149
|
+
{ name: 'source-maps', script: '01-source-map-extractor.js', focus: ['all'] },
|
|
150
|
+
{ name: 'sitemap-crawler', script: '22-sitemap-crawler.js', focus: ['all', 'layout'] },
|
|
151
|
+
{ name: 'network-waterfall', script: '12-network-waterfall.js', focus: ['all', 'layout'] },
|
|
152
|
+
{ name: 'react-fiber-walker', script: '13-react-fiber-walker.js', focus: ['all', '3d'] },
|
|
153
|
+
{ name: 'r3f-serializer', script: '17-r3f-fiber-serializer.js', focus: ['all', '3d'] },
|
|
154
|
+
{ name: 'shader-hotpatch', script: '14-shader-hotpatch.js', focus: ['all', '3d'] },
|
|
155
|
+
{ name: 'webgpu-extractor', script: '10-webgpu-extractor.js', focus: ['all', '3d'] },
|
|
156
|
+
{ name: 'gsap-timeline', script: '16-gsap-timeline-recorder.js', focus: ['all', 'animations', 'scroll'] },
|
|
157
|
+
{ name: 'interaction-model', script: '02-interaction-model.js', focus: ['all', 'animations'] },
|
|
158
|
+
{ name: 'page-transitions', script: '04-page-transitions.js', focus: ['all', 'animations'] },
|
|
159
|
+
{ name: 'font-extractor', script: '18-font-extractor.js', focus: ['all', 'layout'] },
|
|
160
|
+
{ name: 'design-token-export', script: '19-design-token-export.js', focus: ['all', 'layout'] },
|
|
161
|
+
{ name: 'responsive-analysis', script: '03-responsive-analysis.js', focus: ['all', 'layout'] },
|
|
162
|
+
{ name: 'audio-extraction', script: '06-audio-extraction.js', focus: ['all'] },
|
|
163
|
+
{ name: 'accessibility', script: '07-accessibility-reduced-motion.js', focus: ['all'] },
|
|
164
|
+
{ name: 'complexity-scorer', script: '08-complexity-scorer.js', focus: ['all'] },
|
|
165
|
+
{ name: 'visual-diff', script: '09-visual-diff-validator.js', focus: ['all'] },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
// Plugin discovery
|
|
169
|
+
if (fs.existsSync(SCRIPTS_DIR)) {
|
|
170
|
+
for (const f of fs.readdirSync(SCRIPTS_DIR)) {
|
|
171
|
+
if (f.startsWith('custom-') && f.endsWith('.js')) {
|
|
172
|
+
all.push({ name: f.replace('.js', ''), script: f, focus: ['all'] });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return focus === 'all' ? all : all.filter(p => p.focus.includes(focus));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ═══════════════════════════════════════════════════════
|
|
181
|
+
// HISTORY
|
|
182
|
+
// ═══════════════════════════════════════════════════════
|
|
183
|
+
function writeHistory(report, outputDir) {
|
|
184
|
+
if (!opts.history) return;
|
|
185
|
+
const histFile = path.join(outputDir, 'run-history.json');
|
|
186
|
+
let history = [];
|
|
187
|
+
if (fs.existsSync(histFile)) {
|
|
188
|
+
try { history = JSON.parse(fs.readFileSync(histFile, 'utf8')); } catch {}
|
|
189
|
+
}
|
|
190
|
+
history.push({
|
|
191
|
+
url: report.meta.url,
|
|
192
|
+
timestamp: report.meta.timestamp,
|
|
193
|
+
complexity: report.meta.phases['complexity-scorer']?.score || 0,
|
|
194
|
+
tech: Object.keys(report.meta.phases['tech-stack']?.detected || {}).filter(k => report.meta.phases['tech-stack'].detected[k]),
|
|
195
|
+
});
|
|
196
|
+
fs.writeFileSync(histFile, JSON.stringify(history, null, 2));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ═══════════════════════════════════════════════════════
|
|
200
|
+
// DIFF
|
|
201
|
+
// ═══════════════════════════════════════════════════════
|
|
202
|
+
function calculateDiff(source, clone) {
|
|
203
|
+
const scoreDiff = Math.abs(
|
|
204
|
+
(source.meta.phases['complexity-scorer']?.score || 0) -
|
|
205
|
+
(clone.meta.phases['complexity-scorer']?.score || 0)
|
|
206
|
+
);
|
|
207
|
+
const srcTech = Object.keys(source.meta.phases['tech-stack']?.detected || {}).filter(k => source.meta.phases['tech-stack'].detected[k]);
|
|
208
|
+
const clnTech = Object.keys(clone.meta.phases['tech-stack']?.detected || {}).filter(k => clone.meta.phases['tech-stack'].detected[k]);
|
|
209
|
+
return {
|
|
210
|
+
fidelityScore: Math.max(0, 100 - scoreDiff * 0.5),
|
|
211
|
+
techOverlap: srcTech.filter(t => clnTech.includes(t)),
|
|
212
|
+
missingInClone: srcTech.filter(t => !clnTech.includes(t)),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ═══════════════════════════════════════════════════════
|
|
217
|
+
// MAIN ANALYZER
|
|
218
|
+
// ═══════════════════════════════════════════════════════
|
|
219
|
+
async function analyzeUrl(url, browser, isCompare = false, renderer = null) {
|
|
220
|
+
const domain = new URL(url).hostname.replace(/[^a-z0-9]/gi, '-');
|
|
221
|
+
const ts = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
|
222
|
+
const dirName = isCompare ? `compare-${domain}-${ts}` : `${domain}-${ts}`;
|
|
223
|
+
const outputDir = path.resolve(opts.output, dirName);
|
|
224
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
225
|
+
|
|
226
|
+
const log = renderer
|
|
227
|
+
? (msg) => renderer.log(msg)
|
|
228
|
+
: (msg) => console.log(`[${new Date().toLocaleTimeString()}] [${isCompare ? 'CLONE' : 'SRC'}] ${msg}`);
|
|
229
|
+
|
|
230
|
+
log(`Analyzing: ${url}`);
|
|
231
|
+
const page = await browser.newPage();
|
|
232
|
+
|
|
233
|
+
// VIDEO RECORDING
|
|
234
|
+
let recorder;
|
|
235
|
+
if (opts.record && PuppeteerScreenRecorder && !isCompare) {
|
|
236
|
+
log('Starting video recording...');
|
|
237
|
+
recorder = new PuppeteerScreenRecorder(page, { format: 'mp4' });
|
|
238
|
+
await recorder.start(path.join(outputDir, 'scroll-recording.mp4'));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ASSET DOWNLOAD INTERCEPTOR
|
|
242
|
+
if (opts.downloadAssets) {
|
|
243
|
+
const assetsDir = path.join(outputDir, 'assets');
|
|
244
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
245
|
+
page.on('response', async (response) => {
|
|
246
|
+
const rUrl = response.url();
|
|
247
|
+
const ext = rUrl.split('.').pop()?.split('?')[0]?.toLowerCase();
|
|
248
|
+
if (['glb', 'gltf', 'woff2', 'hdr', 'ttf', 'exr'].includes(ext)) {
|
|
249
|
+
try {
|
|
250
|
+
const buf = await response.buffer();
|
|
251
|
+
const fname = rUrl.split('/').pop().split('?')[0];
|
|
252
|
+
fs.writeFileSync(path.join(assetsDir, fname), buf);
|
|
253
|
+
log(`Downloaded: ${fname}`);
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// SHADER HOTPATCH (pre-navigation)
|
|
260
|
+
try {
|
|
261
|
+
const shaderScript = loadScript('14-shader-hotpatch.js');
|
|
262
|
+
await page.evaluateOnNewDocument(shaderScript);
|
|
263
|
+
} catch {}
|
|
264
|
+
|
|
265
|
+
log('Navigating...');
|
|
266
|
+
await page.goto(url, { waitUntil: 'networkidle2', timeout: opts.timeout });
|
|
267
|
+
await page.waitForTimeout(3000);
|
|
268
|
+
|
|
269
|
+
const report = {
|
|
270
|
+
meta: { url, domain, timestamp: new Date().toISOString(), focus: opts.focus, phases: {} },
|
|
271
|
+
errors: [],
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// ── RUN ALL PHASES ──────────────────────────────────
|
|
275
|
+
const phases = getPhases(opts.focus);
|
|
276
|
+
for (const phase of phases) {
|
|
277
|
+
if (renderer) renderer.start(phase.name, `Running ${phase.name}...`);
|
|
278
|
+
try {
|
|
279
|
+
const script = loadScript(phase.script);
|
|
280
|
+
const result = await page.evaluate(new Function(`
|
|
281
|
+
try { return eval(${JSON.stringify(script)}) }
|
|
282
|
+
catch(e) { return { error: e.message, phase: '${phase.name}' }; }
|
|
283
|
+
`));
|
|
284
|
+
report.meta.phases[phase.name] = result;
|
|
285
|
+
fs.writeFileSync(path.join(outputDir, `${phase.name}.json`), JSON.stringify(result, null, 2));
|
|
286
|
+
|
|
287
|
+
const detail = getPhaseDetail(phase.name, result);
|
|
288
|
+
if (renderer) renderer.succeed(phase.name, detail);
|
|
289
|
+
else log(`✓ ${phase.name}${detail ? ` — ${detail}` : ''}`);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
report.errors.push(`Phase ${phase.name}: ${err.message}`);
|
|
292
|
+
if (renderer) renderer.fail(phase.name, err.message);
|
|
293
|
+
else log(`✗ ${phase.name} failed: ${err.message}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── LIGHTHOUSE ─────────────────────────────────────
|
|
298
|
+
if (opts.lighthouse !== false && runLighthouseAudit) {
|
|
299
|
+
log('Running Lighthouse audit...');
|
|
300
|
+
try {
|
|
301
|
+
const wsPort = new URL(browser.wsEndpoint()).port;
|
|
302
|
+
const lhResult = await runLighthouseAudit(url, wsPort);
|
|
303
|
+
report.meta.phases['lighthouse'] = lhResult;
|
|
304
|
+
fs.writeFileSync(path.join(outputDir, 'lighthouse.json'), JSON.stringify(lhResult, null, 2));
|
|
305
|
+
} catch (e) {
|
|
306
|
+
log(`Lighthouse failed: ${e.message}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── SCROLL (screenshots / recording) ───────────────
|
|
311
|
+
if (opts.screenshots || (opts.record && !isCompare)) {
|
|
312
|
+
const totalH = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
313
|
+
const viewH = 900;
|
|
314
|
+
const steps = 10;
|
|
315
|
+
const ssDir = path.join(outputDir, 'screenshots');
|
|
316
|
+
if (opts.screenshots) fs.mkdirSync(ssDir, { recursive: true });
|
|
317
|
+
|
|
318
|
+
for (let i = 0; i <= steps; i++) {
|
|
319
|
+
const pct = i / steps;
|
|
320
|
+
await page.evaluate(y => window.scrollTo({ top: y, behavior: 'smooth' }), Math.round(pct * (totalH - viewH)));
|
|
321
|
+
await page.waitForTimeout(1000);
|
|
322
|
+
if (opts.screenshots) {
|
|
323
|
+
await page.screenshot({ path: path.join(ssDir, `scroll-${Math.round(pct * 100)}pct.png`) });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
|
327
|
+
await page.waitForTimeout(1000);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (recorder) await recorder.stop();
|
|
331
|
+
|
|
332
|
+
// ── HTML TIMELINE ───────────────────────────────────
|
|
333
|
+
if (opts.formatHtml && generateTimelineHtml) {
|
|
334
|
+
const html = generateTimelineHtml(report.meta.phases['gsap-timeline']?.keyTimelines || []);
|
|
335
|
+
fs.writeFileSync(path.join(outputDir, 'timeline.html'), html);
|
|
336
|
+
log('Generated timeline.html');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── V3.1: SHADER ANNOTATION ─────────────────────────
|
|
340
|
+
if (opts.annotateShaders && annotateShaders) {
|
|
341
|
+
log('Annotating shaders with AI...');
|
|
342
|
+
try {
|
|
343
|
+
const shaderData = report.meta.phases['shader-hotpatch'];
|
|
344
|
+
const annotations = await annotateShaders(shaderData);
|
|
345
|
+
report.meta.phases['shader-annotations'] = annotations;
|
|
346
|
+
fs.writeFileSync(path.join(outputDir, 'shader-annotations.json'), JSON.stringify(annotations, null, 2));
|
|
347
|
+
log(`Annotated ${annotations.length} shader(s)`);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
log(`Shader annotation failed: ${e.message}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── V3.1: SCAFFOLD ──────────────────────────────────
|
|
354
|
+
if (opts.scaffold && generateScaffold && !isCompare) {
|
|
355
|
+
log('Generating Next.js scaffold...');
|
|
356
|
+
try {
|
|
357
|
+
const result = generateScaffold(report, outputDir);
|
|
358
|
+
report.meta.scaffold = result;
|
|
359
|
+
log(`Scaffold generated: ${result.filesGenerated} files → ${result.scaffoldDir}`);
|
|
360
|
+
} catch (e) {
|
|
361
|
+
log(`Scaffold failed: ${e.message}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── V3.1: AI RECONSTRUCTION REPORT ─────────────────
|
|
366
|
+
if (opts.aiReport && generateReconstructionReport && !isCompare) {
|
|
367
|
+
log('Generating AI reconstruction report...');
|
|
368
|
+
try {
|
|
369
|
+
const md = await generateReconstructionReport(report);
|
|
370
|
+
const mdPath = path.join(outputDir, 'reconstruction-guide.md');
|
|
371
|
+
fs.writeFileSync(mdPath, md);
|
|
372
|
+
log(`AI report saved: reconstruction-guide.md`);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
log(`AI report failed: ${e.message}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── WRITE MASTER REPORT ─────────────────────────────
|
|
379
|
+
fs.writeFileSync(path.join(outputDir, 'forensics-report.json'), JSON.stringify(report, null, 2));
|
|
380
|
+
await page.close();
|
|
381
|
+
writeHistory(report, opts.output);
|
|
382
|
+
|
|
383
|
+
return { report, outputDir };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function getPhaseDetail(name, result) {
|
|
387
|
+
if (!result || result.error) return result?.error || '';
|
|
388
|
+
switch (name) {
|
|
389
|
+
case 'tech-stack': return Object.keys(result.detected || {}).filter(k => result.detected[k]).join(', ') || '';
|
|
390
|
+
case 'gsap-timeline': return `${result.tweens?.length || 0} tweens, ${result.scrollTriggers?.length || 0} STs`;
|
|
391
|
+
case 'react-fiber-walker': return `${result.componentCount || 0} components`;
|
|
392
|
+
case 'r3f-serializer': return result.canvasCount ? `${result.canvasCount} canvas(es)` : '';
|
|
393
|
+
case 'complexity-scorer': return result.score ? `score: ${result.score}/100` : '';
|
|
394
|
+
case 'sitemap-crawler': return result.sitemapFound ? `${result.data?.total || 0} URLs` : 'no sitemap (DOM fallback)';
|
|
395
|
+
case 'font-extractor': return result.webFonts?.length ? `${result.webFonts.length} fonts` : '';
|
|
396
|
+
case 'design-token-export': return result.cssVariables?.length ? `${result.cssVariables.length} vars` : '';
|
|
397
|
+
default: return '';
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ═══════════════════════════════════════════════════════
|
|
402
|
+
// ENTRY POINT
|
|
403
|
+
// ═══════════════════════════════════════════════════════
|
|
404
|
+
async function run() {
|
|
405
|
+
// ── INTERACTIVE MODE ─────────────────────────────────
|
|
406
|
+
if (opts.interactive && tuiModule) {
|
|
407
|
+
const { promptConfig, createPhaseRenderer, loadModules } = tuiModule;
|
|
408
|
+
const { chalk, ora, inquirer } = await loadModules();
|
|
409
|
+
const config = await promptConfig(inquirer, chalk);
|
|
410
|
+
|
|
411
|
+
// Merge TUI config into opts
|
|
412
|
+
Object.assign(opts, config);
|
|
413
|
+
targetUrl = config.url;
|
|
414
|
+
|
|
415
|
+
// Override opts with TUI selections
|
|
416
|
+
opts.output = config.output;
|
|
417
|
+
|
|
418
|
+
let puppeteer;
|
|
419
|
+
try { puppeteer = require('puppeteer'); }
|
|
420
|
+
catch { console.error('Puppeteer not installed.'); process.exit(1); }
|
|
421
|
+
|
|
422
|
+
const browser = await puppeteer.launch({
|
|
423
|
+
headless: opts.headless ? 'new' : false,
|
|
424
|
+
defaultViewport: { width: 1440, height: 900, deviceScaleFactor: 2 },
|
|
425
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--enable-webgl', '--enable-webgl2', '--use-gl=desktop', '--enable-unsafe-webgpu'],
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const renderer = createPhaseRenderer(chalk, ora);
|
|
429
|
+
const { report, outputDir } = await analyzeUrl(targetUrl, browser, false, renderer);
|
|
430
|
+
|
|
431
|
+
if (opts.compareUrl) {
|
|
432
|
+
const { report: cloneReport, outputDir: cloneDir } = await analyzeUrl(opts.compareUrl, browser, true, renderer);
|
|
433
|
+
const diff = calculateDiff(report, cloneReport);
|
|
434
|
+
fs.writeFileSync(path.join(outputDir, 'comparison-diff.json'), JSON.stringify(diff, null, 2));
|
|
435
|
+
renderer.log(`🏆 Fidelity Score: ${diff.fidelityScore.toFixed(1)}/100`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
renderer.complete(report, outputDir, chalk);
|
|
439
|
+
await browser.close();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── CLI MODE ─────────────────────────────────────────
|
|
444
|
+
let puppeteer;
|
|
445
|
+
try { puppeteer = require('puppeteer'); }
|
|
446
|
+
catch { console.error('❌ Puppeteer not installed. Run: npm install puppeteer'); process.exit(1); }
|
|
447
|
+
|
|
448
|
+
console.log(`\n╔══════════════════════════════════╗`);
|
|
449
|
+
console.log(`║ webgl-forensics v3.1 — Running ║`);
|
|
450
|
+
console.log(`╚══════════════════════════════════╝\n`);
|
|
451
|
+
|
|
452
|
+
const browser = await puppeteer.launch({
|
|
453
|
+
headless: opts.headless ? 'new' : false,
|
|
454
|
+
defaultViewport: { width: 1440, height: 900, deviceScaleFactor: 2 },
|
|
455
|
+
args: [
|
|
456
|
+
'--no-sandbox', '--disable-setuid-sandbox',
|
|
457
|
+
'--enable-webgl', '--enable-webgl2', '--use-gl=desktop',
|
|
458
|
+
'--enable-features=WebGL2ComputeContext',
|
|
459
|
+
'--enable-unsafe-webgpu', '--enable-features=WebGPU',
|
|
460
|
+
],
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const { report: sourceReport, outputDir: sourceDir } = await analyzeUrl(targetUrl, browser, false);
|
|
464
|
+
|
|
465
|
+
if (opts.compareUrl) {
|
|
466
|
+
console.log(`\n⚖️ COMPARING WITH: ${opts.compareUrl}`);
|
|
467
|
+
const { report: cloneReport } = await analyzeUrl(opts.compareUrl, browser, true);
|
|
468
|
+
const diff = calculateDiff(sourceReport, cloneReport);
|
|
469
|
+
fs.writeFileSync(path.join(sourceDir, 'comparison-diff.json'), JSON.stringify(diff, null, 2));
|
|
470
|
+
console.log(`\n🏆 FIDELITY SCORE: ${diff.fidelityScore.toFixed(1)}/100`);
|
|
471
|
+
console.log(`✅ Tech Overlap: ${diff.techOverlap.join(', ') || 'None'}`);
|
|
472
|
+
console.log(`❌ Missing in clone: ${diff.missingInClone.join(', ') || 'None'}`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
console.log(`\n✅ Done! Output: ${sourceDir}`);
|
|
476
|
+
if (sourceReport.meta.scaffold) {
|
|
477
|
+
console.log(`🏗️ Scaffold: ${sourceReport.meta.scaffold.scaffoldDir} (${sourceReport.meta.scaffold.filesGenerated} files)`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
await browser.close();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
run().catch(err => {
|
|
484
|
+
console.error('❌ Fatal:', err.message);
|
|
485
|
+
process.exit(1);
|
|
486
|
+
});
|