design-clone 2.1.0 → 2.3.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.
Files changed (177) hide show
  1. package/README.md +13 -34
  2. package/SKILL.md +69 -45
  3. package/bin/cli.js +22 -4
  4. package/bin/commands/clone-site.js +31 -171
  5. package/bin/commands/help.js +19 -6
  6. package/bin/commands/init.js +9 -86
  7. package/bin/commands/uninstall.js +105 -0
  8. package/bin/commands/update.js +70 -0
  9. package/bin/commands/verify.js +7 -14
  10. package/bin/utils/paths.js +28 -0
  11. package/bin/utils/validate.js +2 -22
  12. package/bin/utils/version.js +23 -0
  13. package/docs/code-standards.md +789 -0
  14. package/docs/codebase-summary.md +533 -286
  15. package/docs/index.md +74 -0
  16. package/docs/project-overview-pdr.md +797 -0
  17. package/docs/system-architecture.md +718 -0
  18. package/package.json +14 -17
  19. package/src/ai/prompts/design-tokens/basic.md +80 -0
  20. package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
  21. package/src/ai/prompts/design-tokens/section.md +48 -0
  22. package/src/ai/prompts/design-tokens/with-css.md +87 -0
  23. package/src/ai/prompts/structure-analysis/basic.md +55 -0
  24. package/src/ai/prompts/structure-analysis/with-context.md +59 -0
  25. package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
  26. package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
  27. package/src/ai/prompts/ux-audit/aggregation.md +42 -0
  28. package/src/ai/prompts/ux-audit/desktop.md +92 -0
  29. package/src/ai/prompts/ux-audit/mobile.md +93 -0
  30. package/src/ai/prompts/ux-audit/tablet.md +92 -0
  31. package/src/core/animation/animation-extractor-ast.js +183 -0
  32. package/src/core/animation/animation-extractor-output.js +152 -0
  33. package/src/core/animation/animation-extractor.js +178 -0
  34. package/src/core/animation/state-capture-detection.js +200 -0
  35. package/src/core/animation/state-capture.js +193 -0
  36. package/src/core/capture/browser-context-pool.js +96 -0
  37. package/src/core/capture/multi-page-screenshot-page.js +110 -0
  38. package/src/core/capture/multi-page-screenshot.js +208 -0
  39. package/src/core/capture/screenshot-extraction.js +186 -0
  40. package/src/core/capture/screenshot-helpers.js +175 -0
  41. package/src/core/capture/screenshot-orchestrator.js +174 -0
  42. package/src/core/capture/screenshot-viewport.js +93 -0
  43. package/src/core/capture/screenshot.js +192 -0
  44. package/src/core/content/content-counter-dom.js +191 -0
  45. package/src/core/content/content-counter.js +76 -0
  46. package/src/core/css/breakpoint-detector.js +66 -0
  47. package/src/core/css/chromium-defaults.json +23 -0
  48. package/src/core/css/computed-style-extractor.js +102 -0
  49. package/src/core/css/css-chunker.js +103 -0
  50. package/src/core/css/filter-css-dead-code.js +120 -0
  51. package/src/core/css/filter-css-html-analyzer.js +110 -0
  52. package/src/core/css/filter-css-selector-matcher.js +172 -0
  53. package/src/core/css/filter-css.js +206 -0
  54. package/src/core/css/merge-css-atrule-processor.js +158 -0
  55. package/src/core/css/merge-css-file-io.js +68 -0
  56. package/src/core/css/merge-css.js +148 -0
  57. package/src/core/detection/framework-detector-routing.js +68 -0
  58. package/src/core/detection/framework-detector-signals.js +65 -0
  59. package/src/core/detection/framework-detector.js +198 -0
  60. package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
  61. package/src/core/dimension/dimension-extractor.js +317 -0
  62. package/src/core/dimension/dimension-output-ai-summary.js +111 -0
  63. package/src/core/dimension/dimension-output.js +173 -0
  64. package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
  65. package/src/core/dimension/dom-tree-analyzer.js +191 -0
  66. package/src/core/discovery/app-state-snapshot-capture.js +195 -0
  67. package/src/core/discovery/app-state-snapshot-utils.js +178 -0
  68. package/src/core/discovery/app-state-snapshot.js +131 -0
  69. package/src/core/discovery/discover-pages-routes.js +84 -0
  70. package/src/core/discovery/discover-pages-utils.js +177 -0
  71. package/src/core/discovery/discover-pages.js +191 -0
  72. package/src/core/html/html-extractor-inline-styler.js +70 -0
  73. package/src/core/html/html-extractor.js +147 -0
  74. package/src/core/html/semantic-enhancer-mappings.js +200 -0
  75. package/src/core/html/semantic-enhancer-page.js +148 -0
  76. package/src/core/html/semantic-enhancer.js +135 -0
  77. package/src/core/links/rewrite-links-css-rewriter.js +53 -0
  78. package/src/core/links/rewrite-links.js +173 -0
  79. package/src/core/media/asset-validator.js +118 -0
  80. package/src/core/media/extract-assets-downloader.js +187 -0
  81. package/src/core/media/extract-assets-page-scraper.js +115 -0
  82. package/src/core/media/extract-assets.js +159 -0
  83. package/src/core/media/video-capture-convert.js +200 -0
  84. package/src/core/media/video-capture.js +201 -0
  85. package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
  86. package/src/core/section/section-cropper-helpers.js +43 -0
  87. package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
  88. package/src/core/section/section-detector-strategies.js +139 -0
  89. package/src/core/section/section-detector-utils.js +100 -0
  90. package/src/core/section/section-detector.js +88 -0
  91. package/src/core/tests/test-section-cropper.js +2 -2
  92. package/src/core/tests/test-section-detector.js +2 -2
  93. package/src/post-process/enhance-assets.js +29 -4
  94. package/src/post-process/fetch-images-unsplash-client.js +123 -0
  95. package/src/post-process/fetch-images.js +60 -263
  96. package/src/post-process/inject-gosnap.js +88 -0
  97. package/src/post-process/inject-icons-svg-replacer.js +76 -0
  98. package/src/post-process/inject-icons.js +47 -200
  99. package/src/route-discoverers/base-discoverer-utils.js +137 -0
  100. package/src/route-discoverers/base-discoverer.js +29 -118
  101. package/src/route-discoverers/index.js +1 -1
  102. package/src/shared/config.js +38 -0
  103. package/src/shared/error-codes.js +31 -0
  104. package/src/shared/viewports.js +46 -0
  105. package/src/utils/browser.js +0 -7
  106. package/src/utils/helpers.js +4 -0
  107. package/src/utils/log.js +12 -0
  108. package/src/utils/playwright-loader.js +76 -0
  109. package/src/utils/playwright.js +3 -69
  110. package/src/utils/progress.js +32 -0
  111. package/src/verification/generate-audit-report-css-fixes.js +52 -0
  112. package/src/verification/generate-audit-report-sections.js +158 -0
  113. package/src/verification/generate-audit-report.js +5 -281
  114. package/src/verification/quality-scorer.js +92 -0
  115. package/src/verification/verify-footer-checks.js +103 -0
  116. package/src/verification/verify-footer-helpers.js +178 -0
  117. package/src/verification/verify-footer.js +23 -381
  118. package/src/verification/verify-header-checks.js +104 -0
  119. package/src/verification/verify-header-helpers.js +156 -0
  120. package/src/verification/verify-header.js +23 -365
  121. package/src/verification/verify-layout-report.js +101 -0
  122. package/src/verification/verify-layout.js +13 -259
  123. package/src/verification/verify-menu-checks.js +104 -0
  124. package/src/verification/verify-menu-helpers.js +112 -0
  125. package/src/verification/verify-menu.js +17 -285
  126. package/src/verification/verify-slider-checks.js +115 -0
  127. package/src/verification/verify-slider-constants.js +65 -0
  128. package/src/verification/verify-slider-helpers.js +164 -0
  129. package/src/verification/verify-slider.js +23 -414
  130. package/.env.example +0 -14
  131. package/docs/basic-clone.md +0 -63
  132. package/docs/cli-reference.md +0 -316
  133. package/docs/design-clone-architecture.md +0 -492
  134. package/docs/pixel-perfect.md +0 -117
  135. package/docs/project-roadmap.md +0 -382
  136. package/docs/troubleshooting.md +0 -170
  137. package/requirements.txt +0 -5
  138. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  139. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  140. package/src/ai/analyze-structure.py +0 -375
  141. package/src/ai/extract-design-tokens.py +0 -782
  142. package/src/ai/prompts/__init__.py +0 -2
  143. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  144. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  145. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  146. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  147. package/src/ai/prompts/design_tokens.py +0 -316
  148. package/src/ai/prompts/structure_analysis.py +0 -592
  149. package/src/ai/prompts/ux_audit.py +0 -198
  150. package/src/ai/ux-audit.js +0 -596
  151. package/src/core/animation-extractor.js +0 -526
  152. package/src/core/app-state-snapshot.js +0 -511
  153. package/src/core/content-counter.js +0 -342
  154. package/src/core/design-tokens.js +0 -103
  155. package/src/core/dimension-extractor.js +0 -438
  156. package/src/core/dimension-output.js +0 -305
  157. package/src/core/discover-pages.js +0 -542
  158. package/src/core/dom-tree-analyzer.js +0 -298
  159. package/src/core/extract-assets.js +0 -468
  160. package/src/core/filter-css.js +0 -499
  161. package/src/core/framework-detector.js +0 -538
  162. package/src/core/html-extractor.js +0 -212
  163. package/src/core/merge-css.js +0 -407
  164. package/src/core/multi-page-screenshot.js +0 -380
  165. package/src/core/rewrite-links.js +0 -226
  166. package/src/core/screenshot.js +0 -701
  167. package/src/core/section-detector.js +0 -386
  168. package/src/core/semantic-enhancer.js +0 -492
  169. package/src/core/state-capture.js +0 -598
  170. package/src/core/video-capture.js +0 -546
  171. package/src/utils/__init__.py +0 -16
  172. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  173. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  174. package/src/utils/env.py +0 -134
  175. /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
  176. /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
  177. /package/src/core/{page-readiness.js → page-prep/page-readiness.js} +0 -0
@@ -1,701 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Multi-viewport screenshot capture for design cloning
4
- *
5
- * Usage:
6
- * node screenshot.js --url https://example.com --output ./analysis
7
- *
8
- * Options:
9
- * --url Target website URL (required)
10
- * --output Output directory for screenshots (required)
11
- * --viewports Comma-separated viewport names: desktop,tablet,mobile (default: all)
12
- * --full-page Capture full page height (default: true)
13
- * --max-size Max file size in MB before compression (default: 5)
14
- * --headless Run in headless mode (default: false)
15
- * --scroll-delay Pause time in ms between scroll steps (default: 1500)
16
- * --close Close browser after capture (default: false)
17
- * --extract-html Extract cleaned HTML (default: false)
18
- * --extract-css Extract all CSS from page (default: false)
19
- * --filter-unused Filter CSS to remove unused selectors (default: true)
20
- * --capture-hover Capture hover state screenshots and CSS (default: false)
21
- * --video Record scroll preview video (default: false)
22
- * --video-format Video format: webm, mp4, gif (default: webm)
23
- * --video-duration Video duration in ms (default: 12000)
24
- * --section-mode Enable section-based capture for AI analysis (default: false)
25
- * --no-semantic Disable WordPress semantic HTML enhancement (default: false)
26
- */
27
-
28
- import path from 'path';
29
- import fs from 'fs/promises';
30
-
31
- // Import modules
32
- import { filterCssFile } from './filter-css.js';
33
- import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from '../utils/browser.js';
34
-
35
- // Import extracted modules
36
- import { waitForDomStable, waitForFontsLoaded, waitForStylesStable, waitForPageReady } from './page-readiness.js';
37
- import { dismissCookieBanner } from './cookie-handler.js';
38
- import { forceLazyImages, forceAnimatedElementsVisible, triggerLazyLoad, waitForAllImages, LAZY_LOAD_MAX_ITERATIONS } from './lazy-loader.js';
39
- import { extractCleanHtml, extractAndEnhanceHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-extractor.js';
40
- import { extractContentCounts, generateContentSummary } from './content-counter.js';
41
- import { extractAllCss, MAX_CSS_SIZE } from './css-extractor.js';
42
- import { extractComponentDimensions } from './dimension-extractor.js';
43
- import { buildDimensionsOutput, generateAISummary } from './dimension-output.js';
44
- import { extractDOMHierarchy } from './dom-tree-analyzer.js';
45
- import { extractAnimations, generateAnimationsCss, generateAnimationTokens } from './animation-extractor.js';
46
- import { captureAllHoverStates, generateHoverCss } from './state-capture.js';
47
- import { captureVideo, hasFfmpeg, FFMPEG_REQUIRED_FORMATS } from './video-capture.js';
48
-
49
- // Try to import Sharp for compression
50
- let sharp = null;
51
- try {
52
- sharp = (await import('sharp')).default;
53
- } catch {
54
- // Sharp not available
55
- }
56
-
57
- // Constants
58
- const VIEWPORTS = {
59
- desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
60
- tablet: { width: 768, height: 1024, deviceScaleFactor: 1 },
61
- mobile: { width: 375, height: 812, deviceScaleFactor: 2 }
62
- };
63
-
64
- const VIEWPORT_SETTLE_DELAY = 1500;
65
- const NETWORK_IDLE_TIMEOUT = 8000;
66
- const DEFAULT_SCROLL_DELAY = 1500;
67
-
68
- /**
69
- * Compress image if it exceeds max size
70
- */
71
- async function compressIfNeeded(filePath, maxSizeMB = 5) {
72
- const stats = await fs.stat(filePath);
73
- const originalSize = stats.size;
74
- const maxBytes = maxSizeMB * 1024 * 1024;
75
-
76
- if (originalSize <= maxBytes || !sharp) {
77
- return { compressed: false, originalSize, finalSize: originalSize };
78
- }
79
-
80
- try {
81
- const buffer = await fs.readFile(filePath);
82
- const meta = await sharp(buffer).metadata();
83
-
84
- const newWidth = Math.round(meta.width * 0.85);
85
- let output = await sharp(buffer)
86
- .resize(newWidth)
87
- .png({ quality: 80, compressionLevel: 9 })
88
- .toBuffer();
89
-
90
- if (output.length > maxBytes) {
91
- const smallerWidth = Math.round(meta.width * 0.7);
92
- output = await sharp(buffer)
93
- .resize(smallerWidth)
94
- .png({ quality: 70, compressionLevel: 9 })
95
- .toBuffer();
96
- }
97
-
98
- await fs.writeFile(filePath, output);
99
- return { compressed: true, originalSize, finalSize: output.length };
100
- } catch (err) {
101
- return { compressed: false, originalSize, finalSize: originalSize, error: err.message };
102
- }
103
- }
104
-
105
- /**
106
- * Capture screenshot for a single viewport
107
- */
108
- async function captureViewport(page, viewport, outputPath, fullPage = true, maxSize = 5, scrollDelay = DEFAULT_SCROLL_DELAY) {
109
- await page.setViewportSize(VIEWPORTS[viewport]);
110
- await new Promise(r => setTimeout(r, VIEWPORT_SETTLE_DELAY));
111
- await waitForDomStable(page, 300, 5000);
112
- await waitForFontsLoaded(page, 3000);
113
- await waitForStylesStable(page, 200, 2000);
114
-
115
- const componentDimensions = await extractComponentDimensions(page, viewport);
116
-
117
- // Extract DOM hierarchy (desktop only for efficiency)
118
- let domHierarchy = null;
119
- if (viewport === 'desktop') {
120
- try {
121
- domHierarchy = await extractDOMHierarchy(page, { maxDepth: 8 });
122
- } catch (err) {
123
- console.error(`[WARN] DOM hierarchy extraction failed: ${err.message}`);
124
- }
125
- }
126
-
127
- const lazyStats = await forceLazyImages(page);
128
- const scrollInfo = await triggerLazyLoad(page, LAZY_LOAD_MAX_ITERATIONS, scrollDelay);
129
- await forceLazyImages(page);
130
- const imageStats = await waitForAllImages(page, 15000);
131
-
132
- try {
133
- await page.waitForLoadState('networkidle', { timeout: NETWORK_IDLE_TIMEOUT });
134
- } catch {
135
- // Timeout ok
136
- }
137
-
138
- await new Promise(r => setTimeout(r, 2000));
139
- await waitForDomStable(page, 300, 3000);
140
- await waitForFontsLoaded(page, 2000);
141
- const animStats = await forceAnimatedElementsVisible(page);
142
- await new Promise(r => setTimeout(r, 300));
143
-
144
- await page.evaluate(() => {
145
- window.scrollTo(0, 0);
146
- document.documentElement.scrollTop = 0;
147
- document.body.scrollTop = 0;
148
- });
149
- await new Promise(r => setTimeout(r, 500));
150
-
151
- await page.screenshot({ path: outputPath, type: 'png', fullPage: fullPage });
152
- const compression = await compressIfNeeded(outputPath, maxSize);
153
-
154
- return {
155
- viewport,
156
- path: path.resolve(outputPath),
157
- dimensions: VIEWPORTS[viewport],
158
- componentDimensions,
159
- domHierarchy, // DOM hierarchy (desktop only)
160
- scrollInfo,
161
- imageStats,
162
- size: compression.finalSize,
163
- compressed: compression.compressed
164
- };
165
- }
166
-
167
- /**
168
- * Main capture function
169
- */
170
- async function captureMultiViewport() {
171
- const args = parseArgs(process.argv.slice(2));
172
-
173
- if (!args.url) {
174
- outputError(new Error('--url is required'));
175
- process.exit(1);
176
- }
177
- if (!args.output) {
178
- outputError(new Error('--output directory is required'));
179
- process.exit(1);
180
- }
181
-
182
- const requestedViewports = args.viewports
183
- ? args.viewports.split(',').map(v => v.trim().toLowerCase())
184
- : ['desktop', 'tablet', 'mobile'];
185
- const fullPage = args['full-page'] !== 'false';
186
- const maxSize = args['max-size'] ? parseFloat(args['max-size']) : 5;
187
- const scrollDelay = args['scroll-delay'] ? parseInt(args['scroll-delay'], 10) : DEFAULT_SCROLL_DELAY;
188
- const extractHtml = args['extract-html'] === 'true';
189
- const extractCss = args['extract-css'] === 'true';
190
- const filterUnused = args['filter-unused'] !== 'false';
191
- const captureHover = args['capture-hover'] === 'true';
192
- const captureVideoFlag = args['video'] === 'true';
193
- const videoFormat = args['video-format'] || 'webm';
194
- const videoDuration = args['video-duration']
195
- ? parseInt(args['video-duration'], 10)
196
- : 12000;
197
- const sectionMode = args['section-mode'] === 'true';
198
-
199
- for (const vp of requestedViewports) {
200
- if (!VIEWPORTS[vp]) {
201
- outputError(new Error(`Invalid viewport: ${vp}. Valid: desktop, tablet, mobile`));
202
- process.exit(1);
203
- }
204
- }
205
-
206
- try {
207
- await fs.mkdir(args.output, { recursive: true });
208
-
209
- const cliHeadless = args.headless === 'true';
210
- const getHeadlessForViewport = (viewport) => viewport === 'desktop' ? true : cliHeadless;
211
-
212
- let currentHeadless = null;
213
- let browser = null;
214
- let page = null;
215
- let cookieResult = null;
216
-
217
- const initBrowser = async (headless, navigateUrl = null) => {
218
- if (browser && currentHeadless !== headless) {
219
- await closeBrowser();
220
- browser = null;
221
- page = null;
222
- }
223
-
224
- if (!browser) {
225
- browser = await getBrowser({
226
- headless,
227
- args: headless ? [] : ['--start-maximized', '--window-position=0,0']
228
- });
229
- page = await getPage(browser);
230
- currentHeadless = headless;
231
-
232
- if (navigateUrl) {
233
- await page.setViewportSize(VIEWPORTS.desktop);
234
- await page.goto(navigateUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
235
- await new Promise(r => setTimeout(r, 3000));
236
- cookieResult = await dismissCookieBanner(page);
237
- await waitForPageReady(page);
238
- }
239
- }
240
- return { browser, page };
241
- };
242
-
243
- const firstViewportHeadless = getHeadlessForViewport(requestedViewports[0]);
244
- await initBrowser(firstViewportHeadless, args.url);
245
-
246
- // Extract HTML/CSS
247
- let extraction = null;
248
- const extractionWarnings = [];
249
-
250
- if (extractHtml || extractCss) {
251
- extraction = { html: null, css: null, warnings: [] };
252
-
253
- // Extract content counts BEFORE cleaning HTML (to count hidden items too)
254
- if (extractHtml) {
255
- try {
256
- const contentCounts = await extractContentCounts(page);
257
- const countsPath = path.join(args.output, 'content-counts.json');
258
- await fs.writeFile(countsPath, JSON.stringify(contentCounts, null, 2), 'utf-8');
259
-
260
- // Generate summary for structure analysis
261
- const contentSummary = generateContentSummary(contentCounts);
262
- const summaryPath = path.join(args.output, 'content-summary.md');
263
- await fs.writeFile(summaryPath, contentSummary, 'utf-8');
264
-
265
- extraction.contentCounts = {
266
- path: path.resolve(countsPath),
267
- summaryPath: path.resolve(summaryPath),
268
- summary: contentCounts.summary
269
- };
270
-
271
- if (process.stderr.isTTY) {
272
- console.error(`[INFO] Content counts: ${contentCounts.grids.total} grids, ${contentCounts.repeatedItems.total} items`);
273
- }
274
- } catch (error) {
275
- extractionWarnings.push(`Content counting failed: ${error.message}`);
276
- }
277
- }
278
-
279
- if (extractHtml) {
280
- try {
281
- // Use semantic enhancement unless --no-semantic flag is set
282
- const enhanceSemantic = args['no-semantic'] !== 'true';
283
- const htmlResult = enhanceSemantic
284
- ? await extractAndEnhanceHtml(page, { enhanceSemantic: true })
285
- : await extractCleanHtml(page, JS_FRAMEWORK_PATTERNS);
286
-
287
- const html = htmlResult.html;
288
- const htmlSize = Buffer.byteLength(html, 'utf-8');
289
-
290
- if (htmlSize > MAX_HTML_SIZE) {
291
- throw new Error(`HTML size exceeds ${MAX_HTML_SIZE / 1024 / 1024}MB limit`);
292
- }
293
-
294
- const htmlPath = path.join(args.output, 'source.html');
295
- await fs.writeFile(htmlPath, html, 'utf-8');
296
- extraction.html = {
297
- path: path.resolve(htmlPath),
298
- size: htmlSize,
299
- elementCount: htmlResult.elementCount,
300
- semanticEnhanced: enhanceSemantic,
301
- semanticStats: htmlResult.semanticStats || null
302
- };
303
- if (htmlResult.warnings.length > 0) extractionWarnings.push(...htmlResult.warnings);
304
- } catch (error) {
305
- extraction.html = { error: error.message, failed: true };
306
- extractionWarnings.push(`HTML extraction failed: ${error.message}`);
307
- }
308
- }
309
-
310
- if (extractCss) {
311
- try {
312
- const cssData = await extractAllCss(page, args.url);
313
- const rawCss = cssData.cssBlocks.map(b => `/* Source: ${b.source} */\n${b.css}`).join('\n\n');
314
- const cssSize = Buffer.byteLength(rawCss, 'utf-8');
315
-
316
- if (cssSize > MAX_CSS_SIZE) {
317
- throw new Error(`CSS size exceeds ${MAX_CSS_SIZE / 1024 / 1024}MB limit`);
318
- }
319
-
320
- const rawCssPath = path.join(args.output, 'source-raw.css');
321
- await fs.writeFile(rawCssPath, rawCss, 'utf-8');
322
-
323
- extraction.css = {
324
- path: path.resolve(rawCssPath),
325
- size: cssSize,
326
- blocks: cssData.cssBlocks.length,
327
- totalRules: cssData.totalRules,
328
- corsBlocked: cssData.corsBlocked,
329
- computedStyles: cssData.computedStyles
330
- };
331
-
332
- if (Object.keys(cssData.computedStyles).length > 0) {
333
- const stylesPath = path.join(args.output, 'computed-styles.json');
334
- await fs.writeFile(stylesPath, JSON.stringify(cssData.computedStyles, null, 2));
335
- }
336
-
337
- if (cssData.warnings.length > 0) extractionWarnings.push(...cssData.warnings);
338
- if (cssData.corsBlocked.length > 0) extractionWarnings.push(`${cssData.corsBlocked.length} CORS-blocked stylesheets`);
339
- } catch (error) {
340
- extraction.css = { error: error.message, failed: true };
341
- extractionWarnings.push(`CSS extraction failed: ${error.message}`);
342
- }
343
- }
344
-
345
- // Filter CSS
346
- if (filterUnused && extraction?.html?.path && extraction?.css?.path && !extraction.html.failed && !extraction.css.failed) {
347
- try {
348
- const filteredCssPath = path.join(args.output, 'source.css');
349
- const filterResult = await filterCssFile(extraction.html.path, extraction.css.path, filteredCssPath, false, args.output);
350
- extraction.filtered = {
351
- path: filterResult.output.path,
352
- size: filterResult.output.size,
353
- reduction: filterResult.stats.reduction,
354
- stats: { totalRules: filterResult.stats.totalRules, keptRules: filterResult.stats.keptRules, removedRules: filterResult.stats.removedRules }
355
- };
356
- if (process.stderr.isTTY) console.error(`[INFO] CSS filtered: ${filterResult.stats.reduction} reduction`);
357
- } catch (error) {
358
- extraction.filtered = { error: error.message, failed: true };
359
- extractionWarnings.push(`CSS filtering failed: ${error.message}`);
360
- }
361
- }
362
-
363
- // Extract animations (enabled by default with CSS extraction)
364
- const extractAnimationsFlag = args['extract-animations'] !== 'false';
365
- if (extractCss && extractAnimationsFlag && extraction?.css?.path && !extraction.css.failed) {
366
- try {
367
- const rawCss = await fs.readFile(extraction.css.path, 'utf-8');
368
- const animData = await extractAnimations(rawCss);
369
-
370
- if (!animData.error) {
371
- // Write animations.css
372
- const animCss = generateAnimationsCss(animData);
373
- const animPath = path.join(args.output, 'animations.css');
374
- await fs.writeFile(animPath, animCss, 'utf-8');
375
-
376
- // Generate animation tokens
377
- const animTokens = generateAnimationTokens(animData);
378
-
379
- // Write animation-tokens.json
380
- const animTokensPath = path.join(args.output, 'animation-tokens.json');
381
- await fs.writeFile(animTokensPath, JSON.stringify({
382
- keyframes: animData.keyframes,
383
- transitions: animData.transitions,
384
- animatedElements: animData.animatedElements,
385
- summary: animTokens
386
- }, null, 2), 'utf-8');
387
-
388
- extraction.animations = {
389
- path: path.resolve(animPath),
390
- tokensPath: path.resolve(animTokensPath),
391
- keyframeCount: animTokens.keyframeCount,
392
- transitionCount: animTokens.transitions,
393
- animatedElementCount: animTokens.animatedElements,
394
- tokens: animTokens
395
- };
396
-
397
- if (process.stderr.isTTY) {
398
- console.error(`[INFO] Animations: ${animTokens.keyframeCount} keyframes, ${animTokens.transitions} transitions`);
399
- }
400
- } else {
401
- extraction.animations = { error: animData.error, failed: true };
402
- extractionWarnings.push(`Animation extraction failed: ${animData.error}`);
403
- }
404
- } catch (error) {
405
- extraction.animations = { error: error.message, failed: true };
406
- extractionWarnings.push(`Animation extraction failed: ${error.message}`);
407
- }
408
- }
409
-
410
- extraction.warnings = extractionWarnings;
411
- if (extractionWarnings.length > 0 && process.stderr.isTTY) {
412
- extractionWarnings.forEach(w => console.error(`[WARN] ${w}`));
413
- }
414
- }
415
-
416
- // Capture hover states (requires headless mode per Playwright #5255)
417
- let hoverResult = null;
418
- if (captureHover) {
419
- try {
420
- // Try headed mode first, fallback to headless (per validation decision)
421
- const wasHeadless = currentHeadless;
422
- let hoverCaptureSuccess = false;
423
-
424
- // Attempt headed mode first
425
- if (!wasHeadless) {
426
- try {
427
- const cssContent = extraction?.css?.path
428
- ? await fs.readFile(extraction.css.path, 'utf-8')
429
- : null;
430
- hoverResult = await captureAllHoverStates(page, cssContent, args.output);
431
- hoverCaptureSuccess = hoverResult.captured > 0;
432
- } catch (headedError) {
433
- if (process.stderr.isTTY) {
434
- console.error(`[WARN] Headed hover capture failed, switching to headless: ${headedError.message}`);
435
- }
436
- }
437
- }
438
-
439
- // Fallback to headless if headed failed or was already headless
440
- if (!hoverCaptureSuccess) {
441
- if (!currentHeadless) {
442
- await initBrowser(true, args.url);
443
- }
444
-
445
- const cssContent = extraction?.css?.path
446
- ? await fs.readFile(extraction.css.path, 'utf-8')
447
- : null;
448
- hoverResult = await captureAllHoverStates(page, cssContent, args.output);
449
- }
450
-
451
- // Generate hover.css from captured diffs
452
- if (hoverResult && hoverResult.elements && hoverResult.captured > 0) {
453
- const hoverCss = generateHoverCss(hoverResult.elements);
454
- const hoverCssPath = path.join(args.output, 'hover.css');
455
- await fs.writeFile(hoverCssPath, hoverCss, 'utf-8');
456
- hoverResult.generatedCss = path.resolve(hoverCssPath);
457
- }
458
-
459
- if (process.stderr.isTTY && hoverResult) {
460
- console.error(`[INFO] Hover states: ${hoverResult.captured}/${hoverResult.detected} captured`);
461
- }
462
-
463
- // Restore browser mode for viewport captures if needed
464
- if (!wasHeadless && currentHeadless && requestedViewports.some(v => !getHeadlessForViewport(v))) {
465
- await initBrowser(false, args.url);
466
- }
467
- } catch (error) {
468
- if (process.stderr.isTTY) {
469
- console.error(`[WARN] Hover capture failed: ${error.message}`);
470
- }
471
- hoverResult = { error: error.message, failed: true };
472
- }
473
- }
474
-
475
- // Capture viewports
476
- const screenshots = [];
477
- const browserRestarts = [];
478
- for (const viewport of requestedViewports) {
479
- const viewportHeadless = getHeadlessForViewport(viewport);
480
- if (currentHeadless !== viewportHeadless) {
481
- browserRestarts.push({ viewport, from: currentHeadless ? 'headless' : 'headed', to: viewportHeadless ? 'headless' : 'headed' });
482
- if (process.stderr.isTTY) console.error(`[INFO] Switching to ${viewportHeadless ? 'headless' : 'headed'} for ${viewport}`);
483
- await initBrowser(viewportHeadless, args.url);
484
- }
485
-
486
- const outputPath = path.join(args.output, `${viewport}.png`);
487
- const result = await captureViewport(page, viewport, outputPath, fullPage, maxSize, scrollDelay);
488
- screenshots.push(result);
489
- }
490
-
491
- // Capture video (opt-in, after screenshots)
492
- let videoResult = null;
493
- if (captureVideoFlag) {
494
- try {
495
- // Check if ffmpeg is needed but not available
496
- if (FFMPEG_REQUIRED_FORMATS.includes(videoFormat)) {
497
- const hasFf = await hasFfmpeg();
498
- if (!hasFf && process.stderr.isTTY) {
499
- console.error(`[WARN] ffmpeg not found. Will output WebM instead of ${videoFormat}`);
500
- console.error('[WARN] Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg');
501
- }
502
- }
503
-
504
- // Use desktop viewport for video
505
- await page.setViewportSize(VIEWPORTS.desktop);
506
- await new Promise(r => setTimeout(r, 1000));
507
-
508
- if (process.stderr.isTTY) {
509
- console.error(`[INFO] Recording video (${videoDuration / 1000}s)...`);
510
- }
511
-
512
- videoResult = await captureVideo(page, args.output, {
513
- format: videoFormat,
514
- duration: videoDuration,
515
- filename: 'preview'
516
- });
517
-
518
- if (process.stderr.isTTY) {
519
- const outputFormat = videoResult.output.split('.').pop();
520
- console.error(`[INFO] Video saved: ${outputFormat} (${(videoResult.duration / 1000).toFixed(1)}s)`);
521
- }
522
- } catch (error) {
523
- if (process.stderr.isTTY) {
524
- console.error(`[WARN] Video capture failed: ${error.message}`);
525
- }
526
- videoResult = { error: error.message, failed: true };
527
- }
528
- }
529
-
530
- // Build dimension output
531
- const allViewportDimensions = {};
532
- for (const screenshot of screenshots) {
533
- if (screenshot.componentDimensions) {
534
- allViewportDimensions[screenshot.viewport] = screenshot.componentDimensions;
535
- }
536
- }
537
-
538
- const dimensionsOutput = buildDimensionsOutput(allViewportDimensions, args.url);
539
- const dimensionsPath = path.join(args.output, 'component-dimensions.json');
540
- await fs.writeFile(dimensionsPath, JSON.stringify(dimensionsOutput, null, 2));
541
-
542
- const aiSummary = generateAISummary(dimensionsOutput);
543
- const summaryPath = path.join(args.output, 'dimensions-summary.json');
544
- await fs.writeFile(summaryPath, JSON.stringify(aiSummary, null, 2));
545
-
546
- // Write DOM hierarchy if available (from desktop capture)
547
- const desktopScreenshot = screenshots.find(s => s.viewport === 'desktop');
548
- let hierarchyPath = null;
549
- if (desktopScreenshot?.domHierarchy) {
550
- hierarchyPath = path.join(args.output, 'dom-hierarchy.json');
551
- await fs.writeFile(hierarchyPath, JSON.stringify(desktopScreenshot.domHierarchy, null, 2));
552
-
553
- if (process.stderr.isTTY) {
554
- const stats = desktopScreenshot.domHierarchy.stats || {};
555
- console.error(`[INFO] DOM hierarchy: ${stats.totalNodes || 0} nodes, ${stats.landmarkCount || 0} landmarks, ${stats.extractionTimeMs || 0}ms`);
556
- }
557
- }
558
-
559
- // Section-based capture (for improved AI analysis)
560
- let sectionResult = null;
561
- if (sectionMode && desktopScreenshot) {
562
- try {
563
- // Lazy import section modules
564
- const { detectSections } = await import('./section-detector.js');
565
- const { cropSections } = await import('./section-cropper.js');
566
-
567
- // Reset to desktop viewport for section detection
568
- await page.setViewportSize(VIEWPORTS.desktop);
569
- await new Promise(r => setTimeout(r, 500));
570
-
571
- if (process.stderr.isTTY) {
572
- console.error('[INFO] Detecting sections...');
573
- }
574
-
575
- const sections = await detectSections(page, {
576
- padding: 40,
577
- minSections: 3,
578
- fallbackToViewport: true
579
- });
580
-
581
- if (process.stderr.isTTY) {
582
- console.error(`[INFO] Found ${sections.length} sections, cropping...`);
583
- }
584
-
585
- const croppedResult = await cropSections(
586
- desktopScreenshot.path,
587
- sections,
588
- args.output
589
- );
590
-
591
- sectionResult = {
592
- enabled: true,
593
- count: croppedResult.sections.length,
594
- skipped: croppedResult.skipped.length,
595
- sections: croppedResult.sections.map(s => ({
596
- index: s.index,
597
- name: s.name,
598
- path: s.relativePath,
599
- bounds: s.bounds,
600
- role: s.role
601
- })),
602
- directory: croppedResult.directory,
603
- summary: croppedResult.summary
604
- };
605
-
606
- if (process.stderr.isTTY) {
607
- console.error(`[INFO] Sections: ${croppedResult.sections.length} cropped, ${croppedResult.skipped.length} skipped`);
608
- }
609
- } catch (err) {
610
- sectionResult = { enabled: true, error: err.message };
611
- if (process.stderr.isTTY) {
612
- console.error(`[WARN] Section processing failed: ${err.message}`);
613
- }
614
- }
615
- }
616
-
617
- const totalContainers = Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.containers?.length || 0), 0);
618
- const totalCards = Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.cards?.length || 0), 0);
619
- const totalGrids = Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.gridLayouts?.length || 0), 0);
620
-
621
- if (process.stderr.isTTY) {
622
- console.error(`[INFO] Extracted: ${totalContainers} containers, ${totalCards} card groups, ${totalGrids} grid layouts`);
623
- }
624
-
625
- const result = {
626
- success: true,
627
- url: args.url,
628
- outputDir: path.resolve(args.output),
629
- cookieHandling: cookieResult,
630
- extraction,
631
- hoverStates: hoverResult && !hoverResult.failed ? {
632
- directory: hoverResult.directory,
633
- detected: hoverResult.detected,
634
- captured: hoverResult.captured,
635
- summaryPath: hoverResult.summaryPath,
636
- generatedCss: hoverResult.generatedCss
637
- } : (hoverResult?.error ? { error: hoverResult.error } : undefined),
638
- video: videoResult && !videoResult.failed ? {
639
- path: videoResult.output,
640
- format: videoResult.output.split('.').pop(),
641
- duration: videoResult.duration,
642
- pageHeight: videoResult.pageHeight,
643
- webm: videoResult.webm,
644
- mp4: videoResult.mp4,
645
- gif: videoResult.gif,
646
- conversionError: videoResult.conversionError
647
- } : (videoResult?.error ? { error: videoResult.error } : undefined),
648
- componentDimensions: {
649
- full: path.resolve(dimensionsPath),
650
- summary: path.resolve(summaryPath),
651
- viewports: Object.keys(dimensionsOutput.viewports),
652
- stats: { containers: totalContainers, cards: totalCards, gridLayouts: totalGrids,
653
- typography: Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.typography?.length || 0), 0) }
654
- },
655
- domHierarchy: desktopScreenshot?.domHierarchy ? {
656
- path: path.resolve(hierarchyPath),
657
- stats: desktopScreenshot.domHierarchy.stats
658
- } : undefined,
659
- sections: sectionResult,
660
- screenshots,
661
- browserRestarts: browserRestarts.length > 0 ? browserRestarts : undefined,
662
- scrollDelay,
663
- totalSize: screenshots.reduce((sum, s) => sum + s.size, 0),
664
- capturedAt: new Date().toISOString()
665
- };
666
-
667
- outputJSON(result);
668
-
669
- if (args.close === 'true') {
670
- await closeBrowser();
671
- } else {
672
- await disconnectBrowser();
673
- }
674
-
675
- process.exit(0);
676
- } catch (error) {
677
- outputError(error);
678
- process.exit(1);
679
- } finally {
680
- try { await closeBrowser(); } catch { /* ignore */ }
681
- }
682
- }
683
-
684
- // Export for module use
685
- export {
686
- captureViewport,
687
- VIEWPORTS,
688
- VIEWPORT_SETTLE_DELAY,
689
- DEFAULT_SCROLL_DELAY,
690
- compressIfNeeded
691
- };
692
-
693
- // Run if called directly (not imported as module)
694
- const isMainModule = process.argv[1] && (
695
- process.argv[1].endsWith('screenshot.js') ||
696
- process.argv[1].includes('screenshot')
697
- );
698
-
699
- if (isMainModule) {
700
- captureMultiViewport();
701
- }