@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.
- package/LICENSE +21 -0
- package/README.md +408 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +532 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/browser.d.ts +16 -0
- package/dist/lib/browser.js +27 -0
- package/dist/lib/browser.js.map +1 -0
- package/dist/lib/colors.d.ts +101 -0
- package/dist/lib/colors.js +405 -0
- package/dist/lib/colors.js.map +1 -0
- package/dist/lib/compare.d.ts +31 -0
- package/dist/lib/compare.js +46 -0
- package/dist/lib/compare.js.map +1 -0
- package/dist/lib/discovery.d.ts +31 -0
- package/dist/lib/discovery.js +243 -0
- package/dist/lib/discovery.js.map +1 -0
- package/dist/lib/drift.d.ts +64 -0
- package/dist/lib/drift.js +383 -0
- package/dist/lib/drift.js.map +1 -0
- package/dist/lib/dtcg/validate.d.ts +51 -0
- package/dist/lib/dtcg/validate.js +1403 -0
- package/dist/lib/dtcg/validate.js.map +1 -0
- package/dist/lib/exit-codes.d.ts +29 -0
- package/dist/lib/exit-codes.js +26 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/lib/extractors/breakpoints.d.ts +5 -0
- package/dist/lib/extractors/breakpoints.js +450 -0
- package/dist/lib/extractors/breakpoints.js.map +1 -0
- package/dist/lib/extractors/colors.d.ts +2 -0
- package/dist/lib/extractors/colors.js +657 -0
- package/dist/lib/extractors/colors.js.map +1 -0
- package/dist/lib/extractors/components.d.ts +4 -0
- package/dist/lib/extractors/components.js +370 -0
- package/dist/lib/extractors/components.js.map +1 -0
- package/dist/lib/extractors/index.d.ts +9 -0
- package/dist/lib/extractors/index.js +1257 -0
- package/dist/lib/extractors/index.js.map +1 -0
- package/dist/lib/extractors/logo.d.ts +2 -0
- package/dist/lib/extractors/logo.js +626 -0
- package/dist/lib/extractors/logo.js.map +1 -0
- package/dist/lib/extractors/spacing.d.ts +4 -0
- package/dist/lib/extractors/spacing.js +163 -0
- package/dist/lib/extractors/spacing.js.map +1 -0
- package/dist/lib/extractors/teach.d.ts +1 -0
- package/dist/lib/extractors/teach.js +66 -0
- package/dist/lib/extractors/teach.js.map +1 -0
- package/dist/lib/extractors/typography.d.ts +1 -0
- package/dist/lib/extractors/typography.js +163 -0
- package/dist/lib/extractors/typography.js.map +1 -0
- package/dist/lib/findings.d.ts +34 -0
- package/dist/lib/findings.js +166 -0
- package/dist/lib/findings.js.map +1 -0
- package/dist/lib/formatters/dtcg.d.ts +10 -0
- package/dist/lib/formatters/dtcg.js +416 -0
- package/dist/lib/formatters/dtcg.js.map +1 -0
- package/dist/lib/formatters/html.d.ts +25 -0
- package/dist/lib/formatters/html.js +479 -0
- package/dist/lib/formatters/html.js.map +1 -0
- package/dist/lib/formatters/markdown.d.ts +5 -0
- package/dist/lib/formatters/markdown.js +568 -0
- package/dist/lib/formatters/markdown.js.map +1 -0
- package/dist/lib/formatters/pdf.d.ts +12 -0
- package/dist/lib/formatters/pdf.js +1121 -0
- package/dist/lib/formatters/pdf.js.map +1 -0
- package/dist/lib/formatters/terminal.d.ts +6 -0
- package/dist/lib/formatters/terminal.js +954 -0
- package/dist/lib/formatters/terminal.js.map +1 -0
- package/dist/lib/formatters/theme.d.ts +35 -0
- package/dist/lib/formatters/theme.js +37 -0
- package/dist/lib/formatters/theme.js.map +1 -0
- package/dist/lib/merger.d.ts +14 -0
- package/dist/lib/merger.js +362 -0
- package/dist/lib/merger.js.map +1 -0
- package/dist/lib/normalize.d.ts +29 -0
- package/dist/lib/normalize.js +59 -0
- package/dist/lib/normalize.js.map +1 -0
- package/dist/lib/robots.d.ts +12 -0
- package/dist/lib/robots.js +110 -0
- package/dist/lib/robots.js.map +1 -0
- package/dist/lib/run-summary.d.ts +40 -0
- package/dist/lib/run-summary.js +64 -0
- package/dist/lib/run-summary.js.map +1 -0
- package/dist/lib/types.d.ts +329 -0
- package/dist/lib/types.js +7 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/lib/version.d.ts +134 -0
- package/dist/lib/version.js +153 -0
- package/dist/lib/version.js.map +1 -0
- package/dist/mcp-server.d.ts +11 -0
- package/dist/mcp-server.js +311 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/package.json +106 -0
- package/dist/test/_vitest-shim.d.ts +13 -0
- package/dist/test/_vitest-shim.js +23 -0
- package/dist/test/_vitest-shim.js.map +1 -0
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +24 -0
- package/dist/test/cli.test.js.map +1 -0
- package/dist/test/colors.test.d.ts +1 -0
- package/dist/test/colors.test.js +64 -0
- package/dist/test/colors.test.js.map +1 -0
- package/dist/test/compare.test.d.ts +1 -0
- package/dist/test/compare.test.js +57 -0
- package/dist/test/compare.test.js.map +1 -0
- package/dist/test/drift.test.d.ts +1 -0
- package/dist/test/drift.test.js +53 -0
- package/dist/test/drift.test.js.map +1 -0
- package/dist/test/dtcg-formatter.test.d.ts +1 -0
- package/dist/test/dtcg-formatter.test.js +48 -0
- package/dist/test/dtcg-formatter.test.js.map +1 -0
- package/dist/test/dtcg-validate.test.d.ts +1 -0
- package/dist/test/dtcg-validate.test.js +2129 -0
- package/dist/test/dtcg-validate.test.js.map +1 -0
- package/dist/test/exit-codes.test.d.ts +1 -0
- package/dist/test/exit-codes.test.js +53 -0
- package/dist/test/exit-codes.test.js.map +1 -0
- package/dist/test/findings.test.d.ts +1 -0
- package/dist/test/findings.test.js +77 -0
- package/dist/test/findings.test.js.map +1 -0
- package/dist/test/html.test.d.ts +1 -0
- package/dist/test/html.test.js +95 -0
- package/dist/test/html.test.js.map +1 -0
- package/dist/test/markdown.test.d.ts +1 -0
- package/dist/test/markdown.test.js +145 -0
- package/dist/test/markdown.test.js.map +1 -0
- package/dist/test/merger.test.d.ts +1 -0
- package/dist/test/merger.test.js +98 -0
- package/dist/test/merger.test.js.map +1 -0
- package/dist/test/normalize.test.d.ts +1 -0
- package/dist/test/normalize.test.js +47 -0
- package/dist/test/normalize.test.js.map +1 -0
- package/dist/test/run-summary.test.d.ts +1 -0
- package/dist/test/run-summary.test.js +45 -0
- package/dist/test/run-summary.test.js.map +1 -0
- package/dist/test/version.test.d.ts +1 -0
- package/dist/test/version.test.js +73 -0
- package/dist/test/version.test.js.map +1 -0
- 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
|