@mseep/dembrandt 0.19.5

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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +408 -0
  3. package/dist/index.d.ts +8 -0
  4. package/dist/index.js +532 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lib/browser.d.ts +16 -0
  7. package/dist/lib/browser.js +27 -0
  8. package/dist/lib/browser.js.map +1 -0
  9. package/dist/lib/colors.d.ts +101 -0
  10. package/dist/lib/colors.js +405 -0
  11. package/dist/lib/colors.js.map +1 -0
  12. package/dist/lib/compare.d.ts +31 -0
  13. package/dist/lib/compare.js +46 -0
  14. package/dist/lib/compare.js.map +1 -0
  15. package/dist/lib/discovery.d.ts +31 -0
  16. package/dist/lib/discovery.js +243 -0
  17. package/dist/lib/discovery.js.map +1 -0
  18. package/dist/lib/drift.d.ts +64 -0
  19. package/dist/lib/drift.js +383 -0
  20. package/dist/lib/drift.js.map +1 -0
  21. package/dist/lib/dtcg/validate.d.ts +51 -0
  22. package/dist/lib/dtcg/validate.js +1403 -0
  23. package/dist/lib/dtcg/validate.js.map +1 -0
  24. package/dist/lib/exit-codes.d.ts +29 -0
  25. package/dist/lib/exit-codes.js +26 -0
  26. package/dist/lib/exit-codes.js.map +1 -0
  27. package/dist/lib/extractors/breakpoints.d.ts +5 -0
  28. package/dist/lib/extractors/breakpoints.js +450 -0
  29. package/dist/lib/extractors/breakpoints.js.map +1 -0
  30. package/dist/lib/extractors/colors.d.ts +2 -0
  31. package/dist/lib/extractors/colors.js +657 -0
  32. package/dist/lib/extractors/colors.js.map +1 -0
  33. package/dist/lib/extractors/components.d.ts +4 -0
  34. package/dist/lib/extractors/components.js +370 -0
  35. package/dist/lib/extractors/components.js.map +1 -0
  36. package/dist/lib/extractors/index.d.ts +9 -0
  37. package/dist/lib/extractors/index.js +1257 -0
  38. package/dist/lib/extractors/index.js.map +1 -0
  39. package/dist/lib/extractors/logo.d.ts +2 -0
  40. package/dist/lib/extractors/logo.js +626 -0
  41. package/dist/lib/extractors/logo.js.map +1 -0
  42. package/dist/lib/extractors/spacing.d.ts +4 -0
  43. package/dist/lib/extractors/spacing.js +163 -0
  44. package/dist/lib/extractors/spacing.js.map +1 -0
  45. package/dist/lib/extractors/teach.d.ts +1 -0
  46. package/dist/lib/extractors/teach.js +66 -0
  47. package/dist/lib/extractors/teach.js.map +1 -0
  48. package/dist/lib/extractors/typography.d.ts +1 -0
  49. package/dist/lib/extractors/typography.js +163 -0
  50. package/dist/lib/extractors/typography.js.map +1 -0
  51. package/dist/lib/findings.d.ts +34 -0
  52. package/dist/lib/findings.js +166 -0
  53. package/dist/lib/findings.js.map +1 -0
  54. package/dist/lib/formatters/dtcg.d.ts +10 -0
  55. package/dist/lib/formatters/dtcg.js +416 -0
  56. package/dist/lib/formatters/dtcg.js.map +1 -0
  57. package/dist/lib/formatters/html.d.ts +25 -0
  58. package/dist/lib/formatters/html.js +479 -0
  59. package/dist/lib/formatters/html.js.map +1 -0
  60. package/dist/lib/formatters/markdown.d.ts +5 -0
  61. package/dist/lib/formatters/markdown.js +568 -0
  62. package/dist/lib/formatters/markdown.js.map +1 -0
  63. package/dist/lib/formatters/pdf.d.ts +12 -0
  64. package/dist/lib/formatters/pdf.js +1121 -0
  65. package/dist/lib/formatters/pdf.js.map +1 -0
  66. package/dist/lib/formatters/terminal.d.ts +6 -0
  67. package/dist/lib/formatters/terminal.js +954 -0
  68. package/dist/lib/formatters/terminal.js.map +1 -0
  69. package/dist/lib/formatters/theme.d.ts +35 -0
  70. package/dist/lib/formatters/theme.js +37 -0
  71. package/dist/lib/formatters/theme.js.map +1 -0
  72. package/dist/lib/merger.d.ts +14 -0
  73. package/dist/lib/merger.js +362 -0
  74. package/dist/lib/merger.js.map +1 -0
  75. package/dist/lib/normalize.d.ts +29 -0
  76. package/dist/lib/normalize.js +59 -0
  77. package/dist/lib/normalize.js.map +1 -0
  78. package/dist/lib/robots.d.ts +12 -0
  79. package/dist/lib/robots.js +110 -0
  80. package/dist/lib/robots.js.map +1 -0
  81. package/dist/lib/run-summary.d.ts +40 -0
  82. package/dist/lib/run-summary.js +64 -0
  83. package/dist/lib/run-summary.js.map +1 -0
  84. package/dist/lib/types.d.ts +329 -0
  85. package/dist/lib/types.js +7 -0
  86. package/dist/lib/types.js.map +1 -0
  87. package/dist/lib/version.d.ts +134 -0
  88. package/dist/lib/version.js +153 -0
  89. package/dist/lib/version.js.map +1 -0
  90. package/dist/mcp-server.d.ts +11 -0
  91. package/dist/mcp-server.js +311 -0
  92. package/dist/mcp-server.js.map +1 -0
  93. package/dist/package.json +106 -0
  94. package/dist/test/_vitest-shim.d.ts +13 -0
  95. package/dist/test/_vitest-shim.js +23 -0
  96. package/dist/test/_vitest-shim.js.map +1 -0
  97. package/dist/test/cli.test.d.ts +1 -0
  98. package/dist/test/cli.test.js +24 -0
  99. package/dist/test/cli.test.js.map +1 -0
  100. package/dist/test/colors.test.d.ts +1 -0
  101. package/dist/test/colors.test.js +64 -0
  102. package/dist/test/colors.test.js.map +1 -0
  103. package/dist/test/compare.test.d.ts +1 -0
  104. package/dist/test/compare.test.js +57 -0
  105. package/dist/test/compare.test.js.map +1 -0
  106. package/dist/test/drift.test.d.ts +1 -0
  107. package/dist/test/drift.test.js +53 -0
  108. package/dist/test/drift.test.js.map +1 -0
  109. package/dist/test/dtcg-formatter.test.d.ts +1 -0
  110. package/dist/test/dtcg-formatter.test.js +48 -0
  111. package/dist/test/dtcg-formatter.test.js.map +1 -0
  112. package/dist/test/dtcg-validate.test.d.ts +1 -0
  113. package/dist/test/dtcg-validate.test.js +2129 -0
  114. package/dist/test/dtcg-validate.test.js.map +1 -0
  115. package/dist/test/exit-codes.test.d.ts +1 -0
  116. package/dist/test/exit-codes.test.js +53 -0
  117. package/dist/test/exit-codes.test.js.map +1 -0
  118. package/dist/test/findings.test.d.ts +1 -0
  119. package/dist/test/findings.test.js +77 -0
  120. package/dist/test/findings.test.js.map +1 -0
  121. package/dist/test/html.test.d.ts +1 -0
  122. package/dist/test/html.test.js +95 -0
  123. package/dist/test/html.test.js.map +1 -0
  124. package/dist/test/markdown.test.d.ts +1 -0
  125. package/dist/test/markdown.test.js +145 -0
  126. package/dist/test/markdown.test.js.map +1 -0
  127. package/dist/test/merger.test.d.ts +1 -0
  128. package/dist/test/merger.test.js +98 -0
  129. package/dist/test/merger.test.js.map +1 -0
  130. package/dist/test/normalize.test.d.ts +1 -0
  131. package/dist/test/normalize.test.js +47 -0
  132. package/dist/test/normalize.test.js.map +1 -0
  133. package/dist/test/run-summary.test.d.ts +1 -0
  134. package/dist/test/run-summary.test.js +45 -0
  135. package/dist/test/run-summary.test.js.map +1 -0
  136. package/dist/test/version.test.d.ts +1 -0
  137. package/dist/test/version.test.js +73 -0
  138. package/dist/test/version.test.js.map +1 -0
  139. package/package.json +106 -0
@@ -0,0 +1,1257 @@
1
+ import chalk from 'chalk';
2
+ import { color } from '../formatters/theme.js';
3
+ import { discoverLinks } from '../discovery.js';
4
+ import { extractLogo, extractSiteName } from './logo.js';
5
+ import { extractColors } from './colors.js';
6
+ import { extractTypography } from './typography.js';
7
+ import { extractSpacing, extractBorderRadius, extractBorders, extractShadows } from './spacing.js';
8
+ import { extractButtonStyles, extractInputStyles, extractLinkStyles, extractBadgeStyles } from './components.js';
9
+ import { extractBreakpoints, detectIconSystem, detectFrameworks, extractGradients, extractMotion } from './breakpoints.js';
10
+ import { extractTeach } from './teach.js';
11
+ import { extractWcagPairs } from './colors.js';
12
+ import { SCHEMA_VERSION } from '../version.js';
13
+ // Gaussian noise via Box-Muller
14
+ function gaussian(mean = 0, std = 1) {
15
+ let u, v;
16
+ do {
17
+ u = Math.random();
18
+ } while (u === 0);
19
+ do {
20
+ v = Math.random();
21
+ } while (v === 0);
22
+ return mean + std * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
23
+ }
24
+ // Cubic Bézier interpolation
25
+ function bezier(t, p0, p1, p2, p3) {
26
+ const mt = 1 - t;
27
+ return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
28
+ }
29
+ // Physiological tremor: ~8-12Hz oscillation, amplitude varies with fatigue
30
+ function tremor(t, freq, amp) {
31
+ return amp * Math.sin(2 * Math.PI * freq * t) + gaussian(0, amp * 0.3);
32
+ }
33
+ // Velocity profile: ballistic phase + corrective phase (two-phase Fitts model)
34
+ // Humans move fast toward target then make fine corrections — not smooth decel
35
+ function velocityProfile(t, overshootProb = 0.3) {
36
+ const hasOvershoot = Math.random() < overshootProb;
37
+ if (t < 0.05)
38
+ return t / 0.05 * 0.2; // startup latency
39
+ if (t < 0.55)
40
+ return 0.2 + (t - 0.05) / 0.5; // ballistic acceleration
41
+ if (t < 0.72)
42
+ return 1.0 - (t - 0.55) / 0.17 * 0.5; // deceleration
43
+ if (hasOvershoot && t < 0.88)
44
+ return 0.5 + Math.sin((t - 0.72) / 0.16 * Math.PI) * 0.3; // overshoot
45
+ return 0.15 + Math.random() * 0.1; // corrective micro-movements
46
+ }
47
+ // Sleep helper
48
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
49
+ /**
50
+ * Adaptive readiness: resolve as soon as the page is actually settled — network
51
+ * quiet, web fonts loaded, and the DOM done mutating — instead of always waiting
52
+ * a fixed cap. Falls back to the cap on any failure, so the worst case matches a
53
+ * fixed wait while typical pages finish in a fraction of the time. Every error
54
+ * is swallowed: readiness is best-effort and must never abort extraction.
55
+ */
56
+ async function waitForSettled(page, capMs, quietMs = 500) {
57
+ const start = Date.now();
58
+ try {
59
+ await page.waitForLoadState("networkidle", { timeout: capMs });
60
+ }
61
+ catch { }
62
+ try {
63
+ await page.evaluate(() => document.fonts?.ready ?? null);
64
+ }
65
+ catch { }
66
+ const remaining = Math.max(250, capMs - (Date.now() - start));
67
+ try {
68
+ await page.evaluate(({ quietMs, remaining }) => new Promise((resolve) => {
69
+ const target = document.body || document.documentElement;
70
+ if (!target)
71
+ return resolve();
72
+ let quiet;
73
+ const finish = () => { try {
74
+ obs.disconnect();
75
+ }
76
+ catch { } resolve(); };
77
+ const obs = new MutationObserver(() => { clearTimeout(quiet); quiet = setTimeout(finish, quietMs); });
78
+ obs.observe(target, { childList: true, subtree: true, attributes: true, characterData: true });
79
+ quiet = setTimeout(finish, quietMs); // already quiet -> resolve after one window
80
+ setTimeout(finish, remaining); // hard cap
81
+ }), { quietMs, remaining });
82
+ }
83
+ catch { }
84
+ return Date.now() - start;
85
+ }
86
+ async function simulateHumanMouse(page) {
87
+ const vw = 1920, vh = 1080;
88
+ // Per-session behavioral fingerprint — each "user" has consistent quirks
89
+ const profile = {
90
+ handedness: Math.random() < 0.88 ? 'right' : 'left', // right-handed bias
91
+ tremFreq: 8 + Math.random() * 4, // physiological tremor 8-12Hz
92
+ tremAmp: 0.2 + Math.random() * 0.6, // tremor amplitude (fatigue)
93
+ driftBias: { x: gaussian(0, 0.15), y: gaussian(0, 0.08) }, // consistent directional drift
94
+ speedMult: 0.7 + Math.random() * 0.8, // this person moves fast or slow
95
+ overshootTendency: Math.random(), // how often they overshoot targets
96
+ attentionSpan: 0.4 + Math.random() * 0.6, // affects dwell times
97
+ fatigueRate: Math.random() * 0.3, // movements degrade over session
98
+ };
99
+ // Plausible entry: cursor was somewhere from before page load, not 0,0
100
+ // Right-handed users tend to park right-center; left-handed left-center
101
+ const entryX = profile.handedness === 'right'
102
+ ? vw * 0.5 + Math.random() * vw * 0.4
103
+ : vw * 0.1 + Math.random() * vw * 0.4;
104
+ const entryY = vh * 0.15 + Math.random() * vh * 0.5;
105
+ let cx = entryX, cy = entryY;
106
+ await page.mouse.move(cx, cy);
107
+ // Weighted zones: humans spend time in predictable areas of a webpage
108
+ const targetZones = [
109
+ { x: [60, 500], y: [15, 75], weight: 3 }, // navigation — high attention
110
+ { x: [150, 1200], y: [80, 380], weight: 4 }, // hero/above-fold — high attention
111
+ { x: [100, 900], y: [350, 720], weight: 3 }, // body content
112
+ { x: [600, 1800], y: [15, 75], weight: 1 }, // right nav / utility links
113
+ { x: [20, 200], y: [200, 900], weight: 1 }, // left sidebar / margin
114
+ { x: [800, 1400], y: [300, 700], weight: 2 }, // mid-right content
115
+ ];
116
+ const totalWeight = targetZones.reduce((s, z) => s + z.weight, 0);
117
+ function pickZone() {
118
+ let r = Math.random() * totalWeight;
119
+ for (const z of targetZones) {
120
+ r -= z.weight;
121
+ if (r <= 0)
122
+ return z;
123
+ }
124
+ return targetZones[0];
125
+ }
126
+ const sequences = 3 + Math.floor(Math.random() * 5); // 3-7 movements
127
+ let sessionTime = 0;
128
+ for (let s = 0; s < sequences; s++) {
129
+ const fatigue = 1 + profile.fatigueRate * (s / sequences); // movements get sloppier
130
+ // Occasionally abandon a movement mid-way and redirect (changed mind)
131
+ const willAbort = Math.random() < 0.12;
132
+ const zone = pickZone();
133
+ let tx = zone.x[0] + Math.random() * (zone.x[1] - zone.x[0]);
134
+ let ty = zone.y[0] + Math.random() * (zone.y[1] - zone.y[0]);
135
+ // Aborted movement: pick intermediate abort point
136
+ const abortT = willAbort ? 0.25 + Math.random() * 0.45 : 1.0;
137
+ if (willAbort) {
138
+ // Abort destination is partway toward original target, then we'll redirect
139
+ tx = cx + (tx - cx) * abortT;
140
+ ty = cy + (ty - cy) * abortT;
141
+ }
142
+ const dist = Math.hypot(tx - cx, ty - cy);
143
+ if (dist < 5)
144
+ continue; // skip negligible movements
145
+ // Two-segment path for longer distances (humans curve around obstacles mentally)
146
+ const useWaypoint = dist > 300 && Math.random() < 0.4;
147
+ const movements = useWaypoint ? [
148
+ // Waypoint slightly off the direct line
149
+ {
150
+ tx: cx + (tx - cx) * (0.3 + Math.random() * 0.25) + gaussian(0, 60),
151
+ ty: cy + (ty - cy) * (0.3 + Math.random() * 0.25) + gaussian(0, 80),
152
+ },
153
+ { tx, ty },
154
+ ] : [{ tx, ty }];
155
+ for (const { tx: etx, ty: ety } of movements) {
156
+ const segDist = Math.hypot(etx - cx, ety - cy);
157
+ // Bézier control points — asymmetric, biased by hand dominance
158
+ const lateralBias = profile.handedness === 'right' ? 1 : -1;
159
+ const cp1x = cx + (etx - cx) * (0.15 + Math.random() * 0.25) + gaussian(0, 35) * fatigue;
160
+ const cp1y = cy + (ety - cy) * (0.05 + Math.random() * 0.2) + gaussian(0, 50) * fatigue + lateralBias * gaussian(0, 15);
161
+ const cp2x = cx + (etx - cx) * (0.65 + Math.random() * 0.25) + gaussian(0, 25) * fatigue;
162
+ const cp2y = cy + (ety - cy) * (0.75 + Math.random() * 0.2) + gaussian(0, 35) * fatigue + lateralBias * gaussian(0, 10);
163
+ // Fitts's law: duration ~ a + b*log2(2D/W), simplified to distance-based
164
+ const targetWidth = 20 + Math.random() * 80; // perceived click target size
165
+ const fittsDuration = (300 + 200 * Math.log2(2 * segDist / targetWidth)) * profile.speedMult * fatigue;
166
+ const steps = Math.max(30, Math.floor(segDist * 0.18 * profile.speedMult));
167
+ let stepTime = 0;
168
+ for (let i = 1; i <= steps; i++) {
169
+ const t = i / steps;
170
+ const speed = velocityProfile(t, profile.overshootTendency);
171
+ const stepMs = (fittsDuration / steps) / Math.max(speed, 0.05);
172
+ const mx = bezier(t, cx, cp1x, cp2x, etx);
173
+ const my = bezier(t, cy, cp1y, cp2y, ety);
174
+ // Layered noise: micro-tremor + physiological oscillation + drift bias
175
+ const tSec = (sessionTime + stepTime) / 1000;
176
+ const tx_noise = tremor(tSec, profile.tremFreq, profile.tremAmp * fatigue)
177
+ + profile.driftBias.x * (1 - speed); // drift more when slow
178
+ const ty_noise = tremor(tSec + 0.37, profile.tremFreq * 0.93, profile.tremAmp * 0.7 * fatigue)
179
+ + profile.driftBias.y * (1 - speed);
180
+ await page.mouse.move(mx + tx_noise, my + ty_noise);
181
+ stepTime += stepMs;
182
+ // Attention catch: sudden freeze when "something interesting" is spotted
183
+ if (Math.random() < 0.03) {
184
+ const freezeMs = 80 + Math.random() * 300;
185
+ // During freeze: very slow drift, not absolute stillness
186
+ const freezeSteps = Math.ceil(freezeMs / 16);
187
+ for (let f = 0; f < freezeSteps; f++) {
188
+ await page.mouse.move(mx + tx_noise + gaussian(0, 0.2), my + ty_noise + gaussian(0, 0.15));
189
+ await sleep(16);
190
+ }
191
+ stepTime += freezeMs;
192
+ }
193
+ else {
194
+ await sleep(Math.max(1, stepMs));
195
+ }
196
+ }
197
+ cx = etx + gaussian(0, 1.5 * fatigue); // landing imprecision
198
+ cy = ety + gaussian(0, 1.5 * fatigue);
199
+ sessionTime += fittsDuration;
200
+ }
201
+ // Aborted: redirect to new target after brief confusion pause
202
+ if (willAbort) {
203
+ await sleep(80 + Math.random() * 250);
204
+ // Brief backward micro-movement (second-guessing)
205
+ if (Math.random() < 0.5) {
206
+ await page.mouse.move(cx - gaussian(0, 15), cy - gaussian(0, 10));
207
+ await sleep(40 + Math.random() * 80);
208
+ }
209
+ }
210
+ // Dwell: two-phase — initial landing jitter, then resting drift
211
+ const dwellMs = (150 + Math.random() * 1800) * profile.attentionSpan * fatigue;
212
+ const phase1 = dwellMs * 0.3; // landing stabilization
213
+ const phase2 = dwellMs * 0.7; // at-rest
214
+ // Phase 1: damped oscillation as hand settles (like underdamped spring)
215
+ const landingSteps = Math.ceil(phase1 / 16);
216
+ for (let d = 0; d < landingSteps; d++) {
217
+ const decay = Math.exp(-d / landingSteps * 4);
218
+ await page.mouse.move(cx + gaussian(0, 1.5 * decay), cy + gaussian(0, 1.2 * decay));
219
+ await sleep(16);
220
+ }
221
+ // Phase 2: resting — very slow Brownian drift
222
+ const restSteps = Math.ceil(phase2 / 50);
223
+ let rx = cx, ry = cy;
224
+ for (let d = 0; d < restSteps; d++) {
225
+ rx += gaussian(0, 0.3);
226
+ ry += gaussian(0, 0.2);
227
+ // Slow mean-reversion: hand drifts but not far
228
+ rx += (cx - rx) * 0.05;
229
+ ry += (cy - ry) * 0.05;
230
+ await page.mouse.move(rx, ry);
231
+ await sleep(50);
232
+ }
233
+ cx = rx;
234
+ cy = ry;
235
+ // Inter-movement gap: bimodal — short gap (quick scan) or long gap (reading)
236
+ const isReading = Math.random() < 0.35;
237
+ const gapMs = isReading
238
+ ? 800 + Math.random() * 2500 // reading pause
239
+ : 80 + Math.random() * 350; // quick scan gap
240
+ await sleep(gapMs);
241
+ sessionTime += dwellMs + gapMs;
242
+ }
243
+ }
244
+ /**
245
+ * @param {string} url
246
+ * @param {import('ora').Ora} spinner
247
+ * @param {import('playwright-core').Browser} browser
248
+ * @param {{ slow?: boolean, darkMode?: boolean, mobile?: boolean, wcag?: boolean, screenshotPath?: string, discoverLinks?: number|null, navigationTimeout?: number, stealth?: boolean, userAgent?: string, locale?: string, timezoneId?: string, acceptLanguage?: string, screenSize?: string }} [options]
249
+ * @returns {Promise<BrandingResult>}
250
+ */
251
+ export async function extractBranding(url, spinner, browser, options = {}) {
252
+ const timeoutMultiplier = options.slow ? 3 : 1;
253
+ const timeouts = [];
254
+ const degraded = []; // post-extraction stages that failed but did not abort the run
255
+ // Progress lines print only in verbose mode (the main `dembrandt <url>`
256
+ // command). Report commands (drift/init/conformance) pass no verbose flag and
257
+ // stay clean. Warnings are NOT routed through this — they always print.
258
+ const log = (...args) => { if (options.verbose)
259
+ console.log(...args); };
260
+ spinner.text = "Creating browser context...";
261
+ const locale = options.locale || "en-US";
262
+ const timezoneId = options.timezoneId || "America/New_York";
263
+ const acceptLanguage = options.acceptLanguage || `${locale},${locale.split('-')[0]};q=0.9,en;q=0.8`;
264
+ const [screenW, screenH] = options.screenSize
265
+ ? options.screenSize.split('x').map(Number)
266
+ : [1920, 1080];
267
+ // Parse "Name=value; Name2=value2" cookie string into Playwright format
268
+ const parsedCookies = options.cookie
269
+ ? options.cookie.split(";").map((c) => c.trim()).filter(Boolean).map((c) => {
270
+ const eq = c.indexOf("=");
271
+ return {
272
+ name: c.slice(0, eq).trim(),
273
+ value: c.slice(eq + 1).trim(),
274
+ url,
275
+ };
276
+ })
277
+ : [];
278
+ const extraHeaders = { "Accept-Language": acceptLanguage };
279
+ if (options.header) {
280
+ const colon = options.header.indexOf(":");
281
+ if (colon > -1) {
282
+ extraHeaders[options.header.slice(0, colon).trim()] = options.header.slice(colon + 1).trim();
283
+ }
284
+ }
285
+ const contextOptions = {
286
+ viewport: { width: screenW, height: screenH },
287
+ screen: { width: screenW, height: screenH },
288
+ userAgent: options.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
289
+ locale,
290
+ timezoneId,
291
+ extraHTTPHeaders: extraHeaders,
292
+ colorScheme: "light",
293
+ };
294
+ if (browser.browserType().name() === 'chromium') {
295
+ contextOptions.permissions = ["clipboard-read", "clipboard-write"];
296
+ }
297
+ const context = await browser.newContext(contextOptions);
298
+ if (parsedCookies.length > 0) {
299
+ await context.addCookies(parsedCookies);
300
+ }
301
+ if (options.stealth) {
302
+ const stealthLocale = locale;
303
+ await context.addInitScript(({ loc, sw, sh }) => {
304
+ Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
305
+ Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
306
+ Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
307
+ Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 0 });
308
+ Object.defineProperty(navigator, 'language', { get: () => loc });
309
+ Object.defineProperty(navigator, 'languages', { get: () => [loc, loc.split('-')[0]] });
310
+ Object.defineProperty(screen, 'width', { get: () => sw });
311
+ Object.defineProperty(screen, 'height', { get: () => sh });
312
+ Object.defineProperty(screen, 'availWidth', { get: () => sw });
313
+ Object.defineProperty(screen, 'availHeight', { get: () => sh - 40 }); // taskbar
314
+ Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
315
+ Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
316
+ Object.defineProperty(window, 'devicePixelRatio', { get: () => 1 });
317
+ Object.defineProperty(window, 'outerWidth', { get: () => sw });
318
+ Object.defineProperty(window, 'outerHeight', { get: () => sh });
319
+ // plugins/mimeTypes: headless has none, real Chrome has several
320
+ const pluginData = [
321
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
322
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
323
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
324
+ ];
325
+ const pluginArray = pluginData.map(p => {
326
+ const plugin = Object.create(Plugin.prototype);
327
+ Object.defineProperty(plugin, 'name', { get: () => p.name });
328
+ Object.defineProperty(plugin, 'filename', { get: () => p.filename });
329
+ Object.defineProperty(plugin, 'description', { get: () => p.description });
330
+ Object.defineProperty(plugin, 'length', { get: () => 0 });
331
+ return plugin;
332
+ });
333
+ Object.defineProperty(navigator, 'plugins', {
334
+ get: () => Object.assign(Object.create(PluginArray.prototype), pluginArray, { length: pluginArray.length }),
335
+ });
336
+ Object.defineProperty(navigator, 'mimeTypes', {
337
+ get: () => Object.assign(Object.create(MimeTypeArray.prototype), { length: 0 }),
338
+ });
339
+ // hasFocus: headless often returns false, real browser returns true
340
+ document.hasFocus = () => true;
341
+ // connection: expose a plausible NetworkInformation object
342
+ if (!navigator.connection) {
343
+ Object.defineProperty(navigator, 'connection', {
344
+ get: () => ({ effectiveType: '4g', rtt: 50, downlink: 10, saveData: false }),
345
+ });
346
+ }
347
+ // history.length: fresh context always 1 — nudge to plausible value
348
+ try {
349
+ Object.defineProperty(history, 'length', { get: () => 2 + Math.floor(Math.random() * 4) });
350
+ }
351
+ catch { }
352
+ window.chrome = { runtime: {}, loadTimes: () => { }, csi: () => { }, app: {} };
353
+ delete navigator.__proto__.webdriver;
354
+ delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array;
355
+ delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise;
356
+ delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol;
357
+ }, { loc: stealthLocale, sw: screenW, sh: screenH });
358
+ }
359
+ const page = await context.newPage();
360
+ // Track font requests to identify self-hosted custom fonts
361
+ const fontRequests = new Set();
362
+ const thirdPartyFontHosts = ['fonts.googleapis.com', 'fonts.gstatic.com', 'typekit.net',
363
+ 'adobe.com', 'fonts.com', 'cloud.typography.com', 'fast.fonts.net', 'use.fontawesome.com',
364
+ 'kit.fontawesome.com', 'pro.fontawesome.com'];
365
+ page.on('response', (response) => {
366
+ const resUrl = response.url();
367
+ const ct = response.headers()['content-type'] || '';
368
+ if (ct.includes('font') || resUrl.match(/\.(woff2?|ttf|otf|eot)(\?|$)/i)) {
369
+ try {
370
+ const host = new URL(resUrl).hostname;
371
+ const isThirdParty = thirdPartyFontHosts.some(h => host.includes(h));
372
+ if (!isThirdParty)
373
+ fontRequests.add(resUrl);
374
+ }
375
+ catch { }
376
+ }
377
+ });
378
+ try {
379
+ let attempts = 0;
380
+ const maxAttempts = 2;
381
+ while (attempts < maxAttempts) {
382
+ attempts++;
383
+ spinner.text = `Navigating to ${url} (attempt ${attempts}/${maxAttempts})...`;
384
+ try {
385
+ const initialUrl = url;
386
+ await page.goto(url, {
387
+ waitUntil: "domcontentloaded",
388
+ timeout: (options.navigationTimeout || 20000) * timeoutMultiplier,
389
+ });
390
+ const finalUrl = page.url();
391
+ if (initialUrl !== finalUrl) {
392
+ spinner.stop();
393
+ const initialDomain = new URL(initialUrl).hostname;
394
+ const finalDomain = new URL(finalUrl).hostname;
395
+ if (initialDomain !== finalDomain) {
396
+ console.log(color.warning(` ! Page redirected to different domain:`));
397
+ console.log(chalk.dim(` From: ${initialUrl}`));
398
+ console.log(chalk.dim(` To: ${finalUrl}`));
399
+ }
400
+ else {
401
+ console.log(color.info(` i Page redirected within same domain:`));
402
+ console.log(chalk.dim(` From: ${initialUrl}`));
403
+ console.log(chalk.dim(` To: ${finalUrl}`));
404
+ }
405
+ spinner.start();
406
+ }
407
+ spinner.stop();
408
+ log(color.success(` ✓ Page loaded`));
409
+ spinner.start("Waiting for body content to render...");
410
+ try {
411
+ await page.waitForFunction(() => document.body && document.body.children.length > 0, { timeout: (options.navigationTimeout || 20000) * timeoutMultiplier });
412
+ spinner.stop();
413
+ log(color.success(` ✓ Body content rendered`));
414
+ }
415
+ catch {
416
+ spinner.stop();
417
+ console.log(color.warning(` ! Body content timeout (continuing)`));
418
+ timeouts.push('Body content rendering');
419
+ }
420
+ spinner.start("Waiting for SPA hydration...");
421
+ const elapsed = await waitForSettled(page, 8000 * timeoutMultiplier);
422
+ spinner.stop();
423
+ log(color.success(` ✓ Hydration settled (${(elapsed / 1000).toFixed(1)}s)`));
424
+ spinner.start("Waiting for main content...");
425
+ try {
426
+ await page.waitForSelector("main, header, [data-hero], section", {
427
+ timeout: 10000 * timeoutMultiplier,
428
+ });
429
+ spinner.stop();
430
+ log(color.success(` ✓ Main content detected`));
431
+ }
432
+ catch {
433
+ spinner.stop();
434
+ console.log(color.warning(` ! Main content selector timeout (continuing)`));
435
+ timeouts.push('Main content selector');
436
+ }
437
+ if (options.stealth) {
438
+ await simulateHumanMouse(page);
439
+ }
440
+ spinner.start("Scrolling page to trigger lazy content...");
441
+ await page.evaluate(async () => {
442
+ const scrollStep = 600;
443
+ const maxHeight = Math.min(document.body.scrollHeight, 30000);
444
+ let y = 0;
445
+ while (y < maxHeight) {
446
+ y = Math.min(y + scrollStep, maxHeight);
447
+ window.scrollTo(0, y);
448
+ await new Promise(r => setTimeout(r, 100));
449
+ }
450
+ window.scrollTo(0, 0);
451
+ });
452
+ spinner.stop();
453
+ log(color.success(` ✓ Full page scrolled (lazy content triggered)`));
454
+ spinner.start("Dismissing cookie consent banners...");
455
+ const dismissed = await page.evaluate(async () => {
456
+ const selectors = [
457
+ // Generic accept patterns
458
+ 'button[id*="accept"]', 'button[class*="accept"]',
459
+ 'button[id*="agree"]', 'button[class*="agree"]',
460
+ 'button[id*="consent"]', 'button[class*="consent"]',
461
+ '[data-testid*="accept"]', '[data-testid*="agree"]',
462
+ // Common consent libraries
463
+ '#onetrust-accept-btn-handler',
464
+ '.cc-btn.cc-allow', '.cc-accept',
465
+ '[aria-label*="Accept"]', '[aria-label*="agree"]',
466
+ // EU/GDPR common patterns
467
+ 'button[data-cookiebanner]',
468
+ '.cookiebanner button', '#cookiebanner button',
469
+ '[class*="cookie"] button[class*="primary"]',
470
+ '[id*="cookie"] button[class*="primary"]',
471
+ '[class*="gdpr"] button', '[id*="gdpr"] button',
472
+ // CMP patterns
473
+ '.sp-message-open .message-button',
474
+ '#sp-cc-accept', '.optanon-allow-all',
475
+ ];
476
+ for (const sel of selectors) {
477
+ try {
478
+ const el = document.querySelector(sel);
479
+ if (el && el.offsetParent !== null) {
480
+ el.click();
481
+ return sel;
482
+ }
483
+ }
484
+ catch { }
485
+ }
486
+ return null;
487
+ });
488
+ spinner.stop();
489
+ if (dismissed) {
490
+ log(color.success(` ✓ Cookie banner dismissed (${dismissed})`));
491
+ await page.waitForTimeout(600);
492
+ }
493
+ else {
494
+ console.log(color.info(` i No cookie banner detected`));
495
+ }
496
+ spinner.start("Dismissing region/interstitial modals...");
497
+ // Defensive throughout: this runs on hostile third-party DOM, and a
498
+ // click can navigate the page (destroying the execution context). Every
499
+ // step is isolated so one failure never aborts extraction, and the
500
+ // page.evaluate call itself is guarded on the Node side.
501
+ let modalActions = [];
502
+ try {
503
+ modalActions = await page.evaluate(async () => {
504
+ const actions = [];
505
+ const MAX_CANDIDATES = 60; // bound work on pathological DOMs
506
+ const TEXT_CAP = 80; // cap before regex to avoid wasted work
507
+ const isVisible = (el) => {
508
+ try {
509
+ if (!el)
510
+ return false;
511
+ const h = el;
512
+ return (h.offsetParent !== null ||
513
+ (typeof h.getClientRects === "function" && h.getClientRects().length > 0));
514
+ }
515
+ catch {
516
+ return false;
517
+ }
518
+ };
519
+ const safeClick = (el) => {
520
+ try {
521
+ if (el && typeof el.click === "function") {
522
+ el.click();
523
+ return true;
524
+ }
525
+ }
526
+ catch { }
527
+ return false;
528
+ };
529
+ // Pass 1 — close affordances. Region/locale selectors, newsletter
530
+ // and promo popups, and app-install banners block the page and have
531
+ // no bearing on branding. Close them rather than making a choice.
532
+ const closeSelectors = [
533
+ // Region / locale modals (e.g. uk-region-modal__close)
534
+ '[data-modal-close]',
535
+ '[class*="region-modal"] [class*="close"]',
536
+ '[class*="region"][class*="modal"] button[aria-label*="close" i]',
537
+ '[class*="locale"][class*="modal"] [class*="close"]',
538
+ // Newsletter / promo / discount popups (vendor-specific)
539
+ '.klaviyo-close-form',
540
+ '.privy-close', '[class*="privy"] [class*="close"]',
541
+ '[id^="om-"] .popup-close', '[id^="om-"] [class*="close"]',
542
+ '[class*="newsletter"] [aria-label*="close" i]',
543
+ '[class*="newsletter"] [class*="close"]',
544
+ '[class*="subscribe"] [aria-label*="close" i]',
545
+ '[class*="popup"] button[aria-label*="close" i]',
546
+ '[class*="popup"] button[class*="close"]',
547
+ // App-install / smart banners (render under --mobile)
548
+ '[class*="smart-banner"] [class*="close"]',
549
+ '[class*="app-banner"] [class*="dismiss"]',
550
+ '[class*="app-banner"] [class*="close"]',
551
+ // Generic modal/dialog close affordances
552
+ '[role="dialog"] button[aria-label*="close" i]',
553
+ '[class*="modal"] button[aria-label*="close" i]',
554
+ '[class*="modal"] button[class*="close"]',
555
+ '[class*="overlay"] button[aria-label*="close" i]',
556
+ 'button[aria-label="Close" i]',
557
+ '[data-dismiss="modal"]',
558
+ ];
559
+ try {
560
+ for (const sel of closeSelectors) {
561
+ try {
562
+ const el = document.querySelector(sel);
563
+ if (el && isVisible(el) && safeClick(el)) {
564
+ actions.push(`close:${sel}`);
565
+ break;
566
+ }
567
+ }
568
+ catch { }
569
+ }
570
+ }
571
+ catch { }
572
+ // Pass 2 — age gates. Alcohol/cannabis/tobacco gates have no close
573
+ // button; they require an affirmative click. Match button text and
574
+ // scope to a visible gate container so we never hit a "No" / decline
575
+ // path that redirects away.
576
+ const affirmative = /^(yes|enter|i am over|i'?m over|over 18|over 21|18\+|21\+|enter site|i am of age|confirm.*age|agree)/i;
577
+ const decline = /\b(no|under|exit|leave|decline)\b/i;
578
+ const gateContainers = [
579
+ '[class*="age"][class*="gate"]',
580
+ '[class*="age"][class*="verif"]',
581
+ '[class*="age-check"]',
582
+ '[id*="age"][class*="modal"]',
583
+ '[role="dialog"][class*="age"]',
584
+ ];
585
+ try {
586
+ gate: for (const csel of gateContainers) {
587
+ let container = null;
588
+ try {
589
+ container = document.querySelector(csel);
590
+ }
591
+ catch { }
592
+ if (!container || !isVisible(container))
593
+ continue;
594
+ let candidates = [];
595
+ try {
596
+ candidates = Array.from(container.querySelectorAll('button, a, [role="button"], input[type="submit"], input[type="button"]')).slice(0, MAX_CANDIDATES);
597
+ }
598
+ catch { }
599
+ for (const el of candidates) {
600
+ try {
601
+ if (!isVisible(el))
602
+ continue;
603
+ const raw = el.textContent ||
604
+ el.value ||
605
+ el.getAttribute("aria-label") ||
606
+ "";
607
+ const text = String(raw).trim().slice(0, TEXT_CAP);
608
+ if (!text)
609
+ continue;
610
+ if (affirmative.test(text) && !decline.test(text)) {
611
+ if (safeClick(el)) {
612
+ actions.push(`age-gate:${csel}`);
613
+ break gate;
614
+ }
615
+ }
616
+ }
617
+ catch { }
618
+ }
619
+ }
620
+ }
621
+ catch { }
622
+ return actions;
623
+ });
624
+ }
625
+ catch (err) {
626
+ // A click navigated the page and destroyed the execution context.
627
+ // That is a successful dismissal, not a failure — note it and move on;
628
+ // the stabilization wait below absorbs the navigation.
629
+ modalActions = ["dismissed (page navigated)"];
630
+ }
631
+ spinner.stop();
632
+ if (modalActions.length > 0) {
633
+ log(color.success(` ✓ Interstitial dismissed (${modalActions.join(", ")})`));
634
+ try {
635
+ await page.waitForTimeout(600);
636
+ }
637
+ catch { }
638
+ }
639
+ else {
640
+ console.log(color.info(` i No interstitial modal detected`));
641
+ }
642
+ spinner.start("Final content stabilization...");
643
+ await waitForSettled(page, 4000 * timeoutMultiplier, 400);
644
+ spinner.stop();
645
+ log(color.success(` ✓ Page fully loaded and stable`));
646
+ spinner.start("Validating page content...");
647
+ const contentLength = await page.evaluate(() => document.body.textContent.length);
648
+ spinner.stop();
649
+ if (contentLength > 100) {
650
+ log(color.success(` ✓ Content validated: ${contentLength} chars`));
651
+ break;
652
+ }
653
+ spinner.warn(`Page seems empty (attempt ${attempts}/${maxAttempts}), retrying...`);
654
+ console.log(color.warning(` ! Content length: ${contentLength} chars (expected >100)`));
655
+ await page.waitForTimeout(3000 * timeoutMultiplier);
656
+ }
657
+ catch (err) {
658
+ if (attempts >= maxAttempts) {
659
+ console.error(` ↳ Failed after ${maxAttempts} attempts`);
660
+ console.error(` ↳ Last error: ${err.message}`);
661
+ console.error(` ↳ URL: ${url}`);
662
+ throw err;
663
+ }
664
+ spinner.warn(`Navigation failed (attempt ${attempts}/${maxAttempts}), retrying...`);
665
+ console.log(` ↳ Error: ${err.message}`);
666
+ await page.waitForTimeout(3000 * timeoutMultiplier);
667
+ }
668
+ }
669
+ spinner.stop();
670
+ // Determinism: drive every animation and transition to its final frame, then
671
+ // hold it. Animated elements (cycling hero swatches, fade-ins) otherwise
672
+ // report a different computed value on each run, producing phantom drift.
673
+ // 1ms duration + iteration-count:1 + fill-mode:forwards snaps finite and
674
+ // infinite animations to a stable end state. Opt out with keepAnimations.
675
+ if (!options.keepAnimations) {
676
+ try {
677
+ await page.addStyleTag({
678
+ content: `*, *::before, *::after {
679
+ animation-duration: 1ms !important;
680
+ animation-delay: 0ms !important;
681
+ animation-iteration-count: 1 !important;
682
+ animation-fill-mode: forwards !important;
683
+ transition-duration: 1ms !important;
684
+ transition-delay: 0ms !important;
685
+ scroll-behavior: auto !important;
686
+ }`,
687
+ });
688
+ await page.waitForTimeout(200 * timeoutMultiplier);
689
+ }
690
+ catch {
691
+ // best-effort; never block extraction on animation freezing
692
+ }
693
+ }
694
+ log(color.info("\n Extracting design tokens...\n"));
695
+ spinner.start("Analyzing design system (17 parallel tasks)...");
696
+ const [logoResult, colors, typography, spacing, borderRadius, borders, shadows, buttons, inputs, links, badges, breakpoints, iconSystem, frameworks, siteNameRaw, gradients, motion,] = await Promise.all([
697
+ extractLogo(page, url).catch(() => ({ logo: null, instances: [], favicons: [], manifest: null })),
698
+ extractColors(page).catch(() => ({ semantic: {}, palette: [], cssVariables: [], _raw: [] })),
699
+ extractTypography(page).catch(() => ({ styles: [], sources: {} })),
700
+ extractSpacing(page).catch(() => ({ scaleType: 'unknown', commonValues: [] })),
701
+ extractBorderRadius(page).catch(() => ({ values: [] })),
702
+ extractBorders(page).catch(() => ({ combinations: [] })),
703
+ extractShadows(page).catch(() => []),
704
+ extractButtonStyles(page).catch(() => []),
705
+ extractInputStyles(page).catch(() => []),
706
+ extractLinkStyles(page).catch(() => []),
707
+ extractBadgeStyles(page).catch(() => ({ all: [], byVariant: {} })),
708
+ extractBreakpoints(page).catch(() => []),
709
+ detectIconSystem(page).catch(() => []),
710
+ detectFrameworks(page).catch(() => []),
711
+ extractSiteName(page).catch(() => null),
712
+ extractGradients(page).catch(() => []),
713
+ extractMotion(page).catch(() => ({ durations: [], easings: [], byContext: {} })),
714
+ ]);
715
+ const { logo, instances: logoInstances, favicons, manifest } = logoResult;
716
+ let siteName = siteNameRaw;
717
+ spinner.stop();
718
+ // Inject manifest theme_color / background_color as high-confidence palette entries
719
+ try {
720
+ if (manifest) {
721
+ const manifestColorEntries = [
722
+ manifest.themeColor && { color: manifest.themeColor, label: 'manifest:theme_color' },
723
+ manifest.backgroundColor && { color: manifest.backgroundColor, label: 'manifest:background_color' },
724
+ ].filter(Boolean);
725
+ const rawManifestColors = manifestColorEntries.map(e => e.color);
726
+ const manifestNormMap = rawManifestColors.length ? await page.evaluate((cols) => {
727
+ const canvas = document.createElement('canvas');
728
+ canvas.width = canvas.height = 1;
729
+ const ctx = canvas.getContext('2d');
730
+ const out = {};
731
+ for (const c of cols) {
732
+ if (/^#[0-9a-f]{6}$/i.test(c)) {
733
+ out[c] = c.toLowerCase();
734
+ continue;
735
+ }
736
+ if (/^#[0-9a-f]{3}$/i.test(c)) {
737
+ out[c] = `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`.toLowerCase();
738
+ continue;
739
+ }
740
+ if (/^#[0-9a-f]{8}$/i.test(c)) {
741
+ out[c] = c.toLowerCase().slice(0, 7);
742
+ continue;
743
+ }
744
+ const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
745
+ if (m) {
746
+ out[c] = `#${parseInt(m[1]).toString(16).padStart(2, '0')}${parseInt(m[2]).toString(16).padStart(2, '0')}${parseInt(m[3]).toString(16).padStart(2, '0')}`;
747
+ continue;
748
+ }
749
+ if (ctx) {
750
+ try {
751
+ ctx.clearRect(0, 0, 1, 1);
752
+ ctx.fillStyle = 'rgba(0,0,0,0)';
753
+ ctx.fillStyle = c;
754
+ ctx.fillRect(0, 0, 1, 1);
755
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
756
+ if (a > 0) {
757
+ out[c] = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
758
+ continue;
759
+ }
760
+ }
761
+ catch { }
762
+ }
763
+ out[c] = c.toLowerCase();
764
+ }
765
+ return out;
766
+ }, rawManifestColors) : {};
767
+ for (const { color: raw, label } of manifestColorEntries) {
768
+ const normalized = manifestNormMap[raw] ?? raw.toLowerCase();
769
+ if (!colors.palette.some(c => c.normalized === normalized)) {
770
+ colors.palette.push({ color: raw, normalized, count: 10, confidence: 'high', sources: [label] });
771
+ }
772
+ else {
773
+ const existing = colors.palette.find(c => c.normalized === normalized);
774
+ if (existing) {
775
+ existing.confidence = 'high';
776
+ if (!existing.sources.includes(label))
777
+ existing.sources.push(label);
778
+ }
779
+ }
780
+ }
781
+ if (!siteName && (manifest.name || manifest.shortName)) {
782
+ siteName = manifest.name || manifest.shortName;
783
+ }
784
+ }
785
+ if (manifest) {
786
+ const parts = [
787
+ manifest.themeColor && `theme: ${manifest.themeColor}`,
788
+ manifest.backgroundColor && `bg: ${manifest.backgroundColor}`,
789
+ manifest.name && `name: "${manifest.name}"`,
790
+ ].filter(Boolean);
791
+ log(color.success(` ✓ Manifest: ${parts.join(', ')}`));
792
+ }
793
+ }
794
+ catch (e) {
795
+ degraded.push('manifest');
796
+ console.log(color.warning(' ! Manifest injection: failed (continuing)'));
797
+ }
798
+ console.log(colors.palette.length > 0 ? color.success(` ✓ Colors: ${colors.palette.length} found`) : color.warning(` ! Colors: 0 found`));
799
+ console.log(typography.styles.length > 0 ? color.success(` ✓ Typography: ${typography.styles.length} styles`) : color.warning(` ! Typography: 0 styles`));
800
+ console.log(spacing.commonValues.length > 0 ? color.success(` ✓ Spacing: ${spacing.commonValues.length} values`) : color.warning(` ! Spacing: 0 values`));
801
+ console.log(borderRadius.values.length > 0 ? color.success(` ✓ Border radius: ${borderRadius.values.length} values`) : color.warning(` ! Border radius: 0 values`));
802
+ const bordersTotal = (borders?.combinations?.length || 0);
803
+ console.log(bordersTotal > 0 ? color.success(` ✓ Borders: ${bordersTotal} combinations`) : color.warning(` ! Borders: 0 found`));
804
+ console.log(shadows.length > 0 ? color.success(` ✓ Shadows: ${shadows.length} found`) : color.warning(` ! Shadows: 0 found`));
805
+ console.log(buttons.length > 0 ? color.success(` ✓ Buttons: ${buttons.length} variants`) : color.warning(` ! Buttons: 0 variants`));
806
+ console.log(inputs.text?.length > 0 ? color.success(` ✓ Inputs: found`) : color.warning(` ! Inputs: 0 styles`));
807
+ console.log(links.length > 0 ? color.success(` ✓ Links: ${links.length} styles`) : color.warning(` ! Links: 0 styles`));
808
+ console.log(breakpoints.length > 0 ? color.success(` ✓ Breakpoints: ${breakpoints.length} detected`) : color.warning(` ! Breakpoints: 0 detected`));
809
+ console.log(iconSystem.length > 0 ? color.success(` ✓ Icon systems: ${iconSystem.length} detected`) : color.warning(` ! Icon systems: 0 detected`));
810
+ console.log(frameworks.length > 0 ? color.success(` ✓ Frameworks: ${frameworks.length} detected`) : color.warning(` ! Frameworks: 0 detected`));
811
+ console.log(gradients.length > 0 ? color.success(` ✓ Gradients: ${gradients.length} found`) : color.info(` · Gradients: 0 found`));
812
+ console.log(motion.durations.length > 0 ? color.success(` ✓ Motion: ${motion.durations.length} durations, ${motion.easings.length} easings`) : color.info(` · Motion: none detected`));
813
+ console.log();
814
+ // Hover/focus state extraction
815
+ const hoverFocusColors = [];
816
+ const interactiveStatePairs = []; // { fg, bg, state, tag } — raw rgb strings, normalized later (consumed by WCAG stage)
817
+ try {
818
+ spinner.start("Extracting hover/focus state colors...");
819
+ function splitMultiValueColors(colorValue) {
820
+ if (!colorValue)
821
+ return [];
822
+ const colorRegex = /(#[0-9a-f]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\))/gi;
823
+ const matches = colorValue.match(colorRegex) || [colorValue];
824
+ return matches.filter(c => c !== 'transparent' && c !== 'rgba(0, 0, 0, 0)' && c !== 'rgba(0,0,0,0)' && c.length > 3);
825
+ }
826
+ const interactiveElements = await page.$$(`
827
+ a, button, input, textarea, select,
828
+ [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="switch"],
829
+ [role="checkbox"], [role="radio"], [role="textbox"], [role="searchbox"], [role="combobox"],
830
+ [aria-pressed], [aria-expanded], [aria-current],
831
+ [tabindex]:not([tabindex="-1"])
832
+ `);
833
+ const sampled = interactiveElements.slice(0, 20);
834
+ for (const element of sampled) {
835
+ try {
836
+ const isVisible = await element.evaluate(el => {
837
+ const rect = el.getBoundingClientRect();
838
+ const style = getComputedStyle(el);
839
+ return rect.width > 0 && rect.height > 0 &&
840
+ style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
841
+ });
842
+ if (!isVisible)
843
+ continue;
844
+ const beforeState = await element.evaluate(el => {
845
+ function findBg(node) {
846
+ while (node && node.tagName !== 'HTML') {
847
+ const bg = getComputedStyle(node).backgroundColor;
848
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent')
849
+ return bg;
850
+ node = node.parentElement;
851
+ }
852
+ return null;
853
+ }
854
+ const computed = getComputedStyle(el);
855
+ return { color: computed.color, backgroundColor: computed.backgroundColor, resolvedBg: findBg(el), borderColor: computed.borderColor, tag: el.tagName.toLowerCase() };
856
+ });
857
+ const hovered = await element.hover({ timeout: 1000 * timeoutMultiplier }).then(() => true).catch(() => false);
858
+ await page.waitForTimeout(100 * timeoutMultiplier);
859
+ const afterHover = await element.evaluate(el => {
860
+ function findBg(node) {
861
+ while (node && node.tagName !== 'HTML') {
862
+ const bg = getComputedStyle(node).backgroundColor;
863
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent')
864
+ return bg;
865
+ node = node.parentElement;
866
+ }
867
+ return null;
868
+ }
869
+ const computed = getComputedStyle(el);
870
+ return { color: computed.color, backgroundColor: computed.backgroundColor, resolvedBg: findBg(el), borderColor: computed.borderColor };
871
+ }).catch(() => null);
872
+ if (!afterHover)
873
+ continue;
874
+ if (afterHover.color !== beforeState.color && afterHover.color !== 'rgba(0, 0, 0, 0)' && afterHover.color !== 'transparent') {
875
+ hoverFocusColors.push({ color: afterHover.color, property: 'color', state: 'hover', element: beforeState.tag });
876
+ }
877
+ if (afterHover.backgroundColor !== beforeState.backgroundColor && afterHover.backgroundColor !== 'rgba(0, 0, 0, 0)' && afterHover.backgroundColor !== 'transparent') {
878
+ hoverFocusColors.push({ color: afterHover.backgroundColor, property: 'background-color', state: 'hover', element: beforeState.tag });
879
+ }
880
+ if (afterHover.borderColor !== beforeState.borderColor) {
881
+ const hoverBorderColors = splitMultiValueColors(afterHover.borderColor);
882
+ const beforeBorderColors = splitMultiValueColors(beforeState.borderColor);
883
+ hoverBorderColors.forEach(color => {
884
+ if (!beforeBorderColors.includes(color)) {
885
+ hoverFocusColors.push({ color, property: 'border-color', state: 'hover', element: beforeState.tag });
886
+ }
887
+ });
888
+ }
889
+ // Collect hover contrast pair only if hover actually changed styles
890
+ const hoverFg = afterHover.color;
891
+ const hoverBg = afterHover.resolvedBg;
892
+ if (hovered && hoverFg && hoverBg && (hoverFg !== beforeState.color || hoverBg !== beforeState.resolvedBg)) {
893
+ interactiveStatePairs.push({ fg: hoverFg, bg: hoverBg, state: 'hover', tag: beforeState.tag });
894
+ }
895
+ if (['input', 'textarea', 'select', 'button', 'a'].includes(beforeState.tag)) {
896
+ try {
897
+ await element.focus({ timeout: 500 * timeoutMultiplier });
898
+ await page.waitForTimeout(100 * timeoutMultiplier);
899
+ const afterFocus = await element.evaluate(el => {
900
+ function findBg(node) {
901
+ while (node && node.tagName !== 'HTML') {
902
+ const bg = getComputedStyle(node).backgroundColor;
903
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent')
904
+ return bg;
905
+ node = node.parentElement;
906
+ }
907
+ return null;
908
+ }
909
+ const computed = getComputedStyle(el);
910
+ return { color: computed.color, backgroundColor: computed.backgroundColor, resolvedBg: findBg(el), borderColor: computed.borderColor, outlineColor: computed.outlineColor };
911
+ });
912
+ if (afterFocus.outlineColor && afterFocus.outlineColor !== 'rgba(0, 0, 0, 0)' && afterFocus.outlineColor !== 'transparent' && afterFocus.outlineColor !== beforeState.color) {
913
+ hoverFocusColors.push({ color: afterFocus.outlineColor, property: 'outline-color', state: 'focus', element: beforeState.tag });
914
+ }
915
+ if (afterFocus.borderColor !== beforeState.borderColor && afterFocus.borderColor !== afterHover.borderColor) {
916
+ const focusBorderColors = splitMultiValueColors(afterFocus.borderColor);
917
+ const beforeBorderColors = splitMultiValueColors(beforeState.borderColor);
918
+ focusBorderColors.forEach(color => {
919
+ if (!beforeBorderColors.includes(color)) {
920
+ hoverFocusColors.push({ color, property: 'border-color', state: 'focus', element: beforeState.tag });
921
+ }
922
+ });
923
+ }
924
+ // Collect focus contrast pair
925
+ const focusFg = afterFocus.color;
926
+ const focusBg = afterFocus.resolvedBg;
927
+ if (focusFg && focusBg && (focusFg !== beforeState.color || focusBg !== beforeState.resolvedBg)) {
928
+ interactiveStatePairs.push({ fg: focusFg, bg: focusBg, state: 'focus', tag: beforeState.tag });
929
+ }
930
+ }
931
+ catch (e) { }
932
+ }
933
+ }
934
+ catch (e) { }
935
+ }
936
+ // Collect disabled element pairs
937
+ try {
938
+ const disabledPairs = await page.evaluate(() => {
939
+ function findBg(node) {
940
+ while (node && node.tagName !== 'HTML') {
941
+ const bg = getComputedStyle(node).backgroundColor;
942
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent')
943
+ return bg;
944
+ node = node.parentElement;
945
+ }
946
+ return null;
947
+ }
948
+ const els = document.querySelectorAll('button[disabled], input[disabled], [aria-disabled="true"], [disabled]');
949
+ const pairs = [];
950
+ for (const el of Array.from(els).slice(0, 10)) {
951
+ const rect = el.getBoundingClientRect();
952
+ if (rect.width === 0 || rect.height === 0)
953
+ continue;
954
+ const s = getComputedStyle(el);
955
+ const fg = s.color;
956
+ const bg = findBg(el);
957
+ if (fg && bg)
958
+ pairs.push({ fg, bg, state: 'disabled', tag: el.tagName.toLowerCase() });
959
+ }
960
+ return pairs;
961
+ });
962
+ interactiveStatePairs.push(...disabledPairs);
963
+ }
964
+ catch (e) { }
965
+ await page.mouse.move(0, 0).catch(() => { });
966
+ // Batch-normalize hover/focus colors via browser canvas to handle oklab/oklch/lab
967
+ const rawHoverColors = [...new Set(hoverFocusColors.map(h => h.color).filter(Boolean))];
968
+ const hoverColorMap = rawHoverColors.length ? await page.evaluate((cols) => {
969
+ const canvas = document.createElement('canvas');
970
+ canvas.width = canvas.height = 1;
971
+ const ctx = canvas.getContext('2d');
972
+ const out = {};
973
+ for (const color of cols) {
974
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
975
+ if (m) {
976
+ out[color] = `#${parseInt(m[1]).toString(16).padStart(2, '0')}${parseInt(m[2]).toString(16).padStart(2, '0')}${parseInt(m[3]).toString(16).padStart(2, '0')}`;
977
+ continue;
978
+ }
979
+ if (/^#[0-9a-f]{6}$/i.test(color)) {
980
+ out[color] = color.toLowerCase();
981
+ continue;
982
+ }
983
+ if (ctx) {
984
+ try {
985
+ ctx.clearRect(0, 0, 1, 1);
986
+ ctx.fillStyle = 'rgba(0,0,0,0)';
987
+ ctx.fillStyle = color;
988
+ ctx.fillRect(0, 0, 1, 1);
989
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
990
+ if (a > 0) {
991
+ out[color] = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
992
+ continue;
993
+ }
994
+ }
995
+ catch { }
996
+ }
997
+ out[color] = color.toLowerCase();
998
+ }
999
+ return out;
1000
+ }, rawHoverColors) : {};
1001
+ hoverFocusColors.forEach(({ color, property }) => {
1002
+ const normalized = hoverColorMap[color] || color.toLowerCase();
1003
+ const isDuplicate = colors.palette.some((c) => c.normalized === normalized);
1004
+ if (!isDuplicate && color) {
1005
+ if (property !== 'background-color') {
1006
+ const hex = normalized.replace('#', '');
1007
+ const r = parseInt(hex.substring(0, 2), 16);
1008
+ const g = parseInt(hex.substring(2, 4), 16);
1009
+ const b = parseInt(hex.substring(4, 6), 16);
1010
+ const max = Math.max(r, g, b);
1011
+ const min = Math.min(r, g, b);
1012
+ const saturation = max === 0 ? 0 : (max - min) / max;
1013
+ if (saturation > 0.3)
1014
+ return;
1015
+ }
1016
+ colors.palette.push({ color, normalized, count: 1, confidence: "medium", sources: ["hover/focus"] });
1017
+ }
1018
+ });
1019
+ spinner.stop();
1020
+ console.log(hoverFocusColors.length > 0 ?
1021
+ color.success(` ✓ Hover/focus: ${hoverFocusColors.length} state colors found`) :
1022
+ color.warning(` ! Hover/focus: 0 state colors found`));
1023
+ }
1024
+ catch (e) {
1025
+ spinner.stop();
1026
+ degraded.push('hover-focus');
1027
+ console.log(color.warning(' ! Hover/focus: failed (continuing)'));
1028
+ }
1029
+ // Dark mode
1030
+ if (options.darkMode) {
1031
+ try {
1032
+ spinner.start("Extracting dark mode colors...");
1033
+ await page.evaluate(() => {
1034
+ document.documentElement.setAttribute("data-theme", "dark");
1035
+ document.documentElement.setAttribute("data-mode", "dark");
1036
+ document.body.setAttribute("data-theme", "dark");
1037
+ document.documentElement.classList.add("dark", "dark-mode", "theme-dark");
1038
+ document.body.classList.add("dark", "dark-mode", "theme-dark");
1039
+ });
1040
+ await page.emulateMedia({ colorScheme: "dark" });
1041
+ await page.waitForTimeout(500 * timeoutMultiplier);
1042
+ const darkModeColors = await extractColors(page);
1043
+ const darkModeButtons = await extractButtonStyles(page);
1044
+ const darkModeLinks = await extractLinkStyles(page);
1045
+ const mergedPalette = [...colors.palette];
1046
+ darkModeColors.palette.forEach((darkColor) => {
1047
+ const isDuplicate = mergedPalette.some((c) => c.normalized === darkColor.normalized);
1048
+ if (!isDuplicate)
1049
+ mergedPalette.push({ ...darkColor, source: "dark-mode" });
1050
+ });
1051
+ colors.palette = mergedPalette;
1052
+ Object.assign(colors.semantic, darkModeColors.semantic);
1053
+ buttons.push(...darkModeButtons.map((btn) => ({ ...btn, source: "dark-mode" })));
1054
+ links.push(...darkModeLinks.map((link) => ({ ...link, source: "dark-mode" })));
1055
+ spinner.stop();
1056
+ log(color.success(` ✓ Dark mode: +${darkModeColors.palette.length} colors`));
1057
+ }
1058
+ catch (e) {
1059
+ spinner.stop();
1060
+ degraded.push('dark-mode');
1061
+ log(color.warning(' ! Dark mode: failed (continuing)'));
1062
+ }
1063
+ }
1064
+ // Mobile viewport
1065
+ if (options.mobile) {
1066
+ try {
1067
+ spinner.start("Extracting mobile viewport colors...");
1068
+ await page.setViewportSize({ width: 390, height: 844 });
1069
+ await page.waitForTimeout(500 * timeoutMultiplier);
1070
+ const mobileColors = await extractColors(page);
1071
+ const mergedPalette = [...colors.palette];
1072
+ mobileColors.palette.forEach((mobileColor) => {
1073
+ const isDuplicate = mergedPalette.some((c) => c.normalized === mobileColor.normalized);
1074
+ if (!isDuplicate)
1075
+ mergedPalette.push({ ...mobileColor, source: "mobile" });
1076
+ });
1077
+ colors.palette = mergedPalette;
1078
+ spinner.stop();
1079
+ log(color.success(` ✓ Mobile: +${mobileColors.palette.length} colors`));
1080
+ }
1081
+ catch (e) {
1082
+ spinner.stop();
1083
+ degraded.push('mobile');
1084
+ log(color.warning(' ! Mobile: failed (continuing)'));
1085
+ }
1086
+ }
1087
+ spinner.stop();
1088
+ console.log();
1089
+ log(color.success.bold("✓ Brand extraction complete!"));
1090
+ if (timeouts.length > 0 && !options.slow) {
1091
+ console.log();
1092
+ console.log(color.warning(`! ${timeouts.length} timeout(s) occurred during extraction:`));
1093
+ timeouts.forEach(t => console.log(chalk.dim(` • ${t}`)));
1094
+ console.log();
1095
+ console.log(color.info(`💡 Tip: Try running with ${chalk.bold('--slow')} flag for more reliable results on slow-loading sites`));
1096
+ }
1097
+ let wcag = [];
1098
+ if (options.wcag) {
1099
+ spinner.start("Analyzing WCAG contrast pairs...");
1100
+ try {
1101
+ const { relativeLuminance } = await import('../colors.js');
1102
+ function calcPair(fgRaw, bgRaw, extra = {}) {
1103
+ const toHex = (c) => {
1104
+ const m = c && c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
1105
+ if (!m)
1106
+ return null;
1107
+ return `#${parseInt(m[1]).toString(16).padStart(2, '0')}${parseInt(m[2]).toString(16).padStart(2, '0')}${parseInt(m[3]).toString(16).padStart(2, '0')}`;
1108
+ };
1109
+ const fg = toHex(fgRaw) || fgRaw;
1110
+ const bg = toHex(bgRaw) || bgRaw;
1111
+ if (!fg || !bg || fg === bg)
1112
+ return null;
1113
+ const l1 = relativeLuminance(fg);
1114
+ const l2 = relativeLuminance(bg);
1115
+ if (l1 === null || l2 === null)
1116
+ return null;
1117
+ const lighter = Math.max(l1, l2);
1118
+ const darker = Math.min(l1, l2);
1119
+ const ratio = Math.round((lighter + 0.05) / (darker + 0.05) * 100) / 100;
1120
+ return { fg, bg, ratio, aa: ratio >= 4.5, aaLarge: ratio >= 3, aaa: ratio >= 7, ...extra };
1121
+ }
1122
+ wcag = await extractWcagPairs(page);
1123
+ // Deduplicate and score interactive state pairs
1124
+ const seenState = new Set();
1125
+ for (const { fg, bg, state, tag } of interactiveStatePairs) {
1126
+ const key = `${state}/${fg}/${bg}`;
1127
+ if (seenState.has(key))
1128
+ continue;
1129
+ seenState.add(key);
1130
+ const pair = calcPair(fg, bg, { state, tag, source: 'state' });
1131
+ if (pair)
1132
+ wcag.push(pair);
1133
+ }
1134
+ spinner.stop();
1135
+ const staticPassing = wcag.filter(p => !p.source && p.aa).length;
1136
+ const staticTotal = wcag.filter(p => !p.source).length;
1137
+ const statesFailing = wcag.filter(p => p.source === 'state' && !p.aa).length;
1138
+ log(color.success(` ✓ WCAG: ${staticPassing}/${staticTotal} pairs pass AA`) +
1139
+ (statesFailing ? color.warning(` · ${statesFailing} state pair(s) fail`) : ''));
1140
+ }
1141
+ catch {
1142
+ spinner.stop();
1143
+ }
1144
+ }
1145
+ // Self-hosted font files, deduped and sorted. fontRequests is a Set filled
1146
+ // in network-arrival order, which varies run-to-run; sorting keeps the
1147
+ // extraction deterministic so the drift gate doesn't report phantom changes.
1148
+ const fontFiles = [...new Set([...fontRequests].map(u => u.split('/').pop().split('?')[0]))].sort();
1149
+ const result = {
1150
+ url: page.url(),
1151
+ extractedAt: new Date().toISOString(),
1152
+ meta: {
1153
+ dembrandtVersion: options._version || null,
1154
+ schemaVersion: SCHEMA_VERSION,
1155
+ flags: {
1156
+ ...(options.stealth && { stealth: true }),
1157
+ ...(options.darkMode && { darkMode: true }),
1158
+ ...(options.mobile && { mobile: true }),
1159
+ ...(options.slow && { slow: true }),
1160
+ ...(options.userAgent && { userAgent: options.userAgent }),
1161
+ ...(options.locale && { locale: options.locale }),
1162
+ ...(options.timezoneId && { timezone: options.timezoneId }),
1163
+ ...(options.acceptLanguage && { acceptLanguage: options.acceptLanguage }),
1164
+ ...(options.screenSize && { screenSize: options.screenSize }),
1165
+ },
1166
+ },
1167
+ siteName,
1168
+ logo,
1169
+ logoInstances,
1170
+ favicons,
1171
+ ...(manifest ? { manifest } : {}),
1172
+ colors,
1173
+ typography: {
1174
+ ...typography,
1175
+ sources: {
1176
+ ...typography.sources,
1177
+ // Sort: fontRequests is filled in network-arrival order, which differs
1178
+ // run-to-run and otherwise surfaces as phantom drift.
1179
+ selfHostedFonts: fontFiles,
1180
+ customFonts: typography.sources?.customFonts?.length
1181
+ ? [...typography.sources.customFonts].sort()
1182
+ : fontFiles,
1183
+ }
1184
+ },
1185
+ spacing,
1186
+ borderRadius,
1187
+ borders,
1188
+ shadows,
1189
+ gradients,
1190
+ motion,
1191
+ components: { buttons, inputs, links, badges },
1192
+ breakpoints,
1193
+ iconSystem,
1194
+ frameworks,
1195
+ ...(options.wcag ? { wcag } : {}),
1196
+ };
1197
+ let isCanvasOnly = false;
1198
+ try {
1199
+ isCanvasOnly = await page.evaluate(() => {
1200
+ const canvases = document.querySelectorAll("canvas");
1201
+ const hasRealContent = document.body.textContent.trim().length > 200;
1202
+ const hasManyCanvases = canvases.length > 3;
1203
+ const hasWebGL = Array.from(canvases).some((c) => {
1204
+ const ctx = c.getContext("webgl") || c.getContext("webgl2");
1205
+ return !!ctx;
1206
+ });
1207
+ return hasManyCanvases && hasWebGL && !hasRealContent;
1208
+ });
1209
+ }
1210
+ catch {
1211
+ isCanvasOnly = false;
1212
+ }
1213
+ if (isCanvasOnly) {
1214
+ result.note = "This website uses canvas/WebGL rendering. Design system cannot be extracted from DOM.";
1215
+ result.isCanvasOnly = true;
1216
+ }
1217
+ if (options.screenshotPath) {
1218
+ try {
1219
+ await page.screenshot({ path: options.screenshotPath, fullPage: false });
1220
+ }
1221
+ catch (e) {
1222
+ degraded.push('screenshot');
1223
+ }
1224
+ }
1225
+ // Internal, opt-in: raw :root tokens + interactive-state styles → sidecar.
1226
+ if (options.teach) {
1227
+ try {
1228
+ result._teach = await extractTeach(page);
1229
+ }
1230
+ catch {
1231
+ result._teach = null;
1232
+ }
1233
+ }
1234
+ if (options.includeRawColors) {
1235
+ result.colors.rawColors = colors._raw || [];
1236
+ }
1237
+ if (options.discoverLinks) {
1238
+ try {
1239
+ result._discoveredLinks = await discoverLinks(page, page.url(), options.discoverLinks);
1240
+ }
1241
+ catch {
1242
+ result._discoveredLinks = [];
1243
+ }
1244
+ }
1245
+ if (degraded.length)
1246
+ result.meta.degraded = degraded;
1247
+ return result;
1248
+ }
1249
+ catch (error) {
1250
+ spinner.fail("Extraction failed");
1251
+ console.error(` ↳ Error during extraction: ${error.message}`);
1252
+ console.error(` ↳ URL: ${url}`);
1253
+ console.error(` ↳ Stage: ${spinner.text || "unknown"}`);
1254
+ throw error;
1255
+ }
1256
+ }
1257
+ //# sourceMappingURL=index.js.map