@detiq/crawler 1.1.14 → 1.1.15
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/Dockerfile +1 -1
- package/dist/accessibility-tree-crawler.js +156 -0
- package/dist/accessibility-tree-crawler.js.map +7 -0
- package/dist/advanced-ui-probes.js +310 -0
- package/dist/advanced-ui-probes.js.map +7 -0
- package/dist/content-chunker.js +2 -2
- package/dist/content-chunker.js.map +2 -2
- package/dist/crawl-session-manager.js +145 -0
- package/dist/crawl-session-manager.js.map +7 -0
- package/dist/crawler.js +1142 -263
- package/dist/crawler.js.map +2 -2
- package/dist/crud-interaction.js +264 -0
- package/dist/crud-interaction.js.map +7 -0
- package/dist/dropdown-enumerator.js +230 -0
- package/dist/dropdown-enumerator.js.map +7 -0
- package/dist/filter-pagination-crawler.js +299 -0
- package/dist/filter-pagination-crawler.js.map +7 -0
- package/dist/frontier.js +5 -3
- package/dist/frontier.js.map +2 -2
- package/dist/gap-pass.js +47 -14
- package/dist/gap-pass.js.map +2 -2
- package/dist/http-engine.js +28 -15
- package/dist/http-engine.js.map +2 -2
- package/dist/index.js +15 -1
- package/dist/index.js.map +2 -2
- package/dist/job-queue.js +12 -5
- package/dist/job-queue.js.map +2 -2
- package/dist/locator-extractor.js +23 -0
- package/dist/locator-extractor.js.map +2 -2
- package/dist/multi-context-handler.js +182 -0
- package/dist/multi-context-handler.js.map +7 -0
- package/dist/nested-window-crawler.js +172 -0
- package/dist/nested-window-crawler.js.map +7 -0
- package/dist/network-action-correlator.js +209 -0
- package/dist/network-action-correlator.js.map +7 -0
- package/dist/performance-metrics-collector.js +155 -0
- package/dist/performance-metrics-collector.js.map +7 -0
- package/dist/performance-metrics.js.map +2 -2
- package/dist/policies/crawl-safety.js +5 -1
- package/dist/policies/crawl-safety.js.map +2 -2
- package/dist/profiles/domain-profile.js +21 -1
- package/dist/profiles/domain-profile.js.map +2 -2
- package/dist/rate-limiter.js +2 -2
- package/dist/rate-limiter.js.map +2 -2
- package/dist/screen-reader.js +1 -0
- package/dist/screen-reader.js.map +2 -2
- package/dist/service-worker-interceptor.js +136 -0
- package/dist/service-worker-interceptor.js.map +7 -0
- package/dist/session-capture.js +4 -2
- package/dist/session-capture.js.map +2 -2
- package/dist/shadow-dom-crawler.js +132 -0
- package/dist/shadow-dom-crawler.js.map +7 -0
- package/dist/sitemap.js +16 -10
- package/dist/sitemap.js.map +2 -2
- package/dist/state-graph.js +193 -0
- package/dist/state-graph.js.map +7 -0
- package/dist/stealth/human-behavior.js +36 -3
- package/dist/stealth/human-behavior.js.map +2 -2
- package/dist/url-pattern-completer.js +48 -14
- package/dist/url-pattern-completer.js.map +2 -2
- package/dist/visual-diff-verdict.js +3 -1
- package/dist/visual-diff-verdict.js.map +2 -2
- package/package.json +2 -1
- package/src/accessibility-tree-crawler.ts +246 -0
- package/src/advanced-ui-probes.ts +470 -0
- package/src/crawl-session-manager.ts +231 -0
- package/src/crawler.ts +1050 -134
- package/src/crud-interaction.ts +386 -0
- package/src/dropdown-enumerator.ts +312 -0
- package/src/filter-pagination-crawler.ts +500 -0
- package/src/gap-pass.ts +58 -16
- package/src/index.ts +14 -1
- package/src/locator-extractor.ts +45 -0
- package/src/multi-context-handler.ts +296 -0
- package/src/nested-window-crawler.ts +310 -0
- package/src/network-action-correlator.ts +320 -0
- package/src/performance-metrics-collector.ts +243 -0
- package/src/performance-metrics.ts +1 -1
- package/src/screen-reader.ts +4 -1
- package/src/service-worker-interceptor.ts +185 -0
- package/src/session-capture.ts +3 -0
- package/src/shadow-dom-crawler.ts +198 -0
- package/src/state-graph.ts +280 -0
- package/src/stealth/human-behavior.ts +38 -4
- package/src/url-pattern-completer.ts +61 -15
- package/tsconfig.json +1 -1
- package/types/accessibility-tree-crawler.d.ts +44 -0
- package/types/advanced-ui-probes.d.ts +47 -0
- package/types/crawl-session-manager.d.ts +44 -0
- package/types/crawler.d.ts +3 -1
- package/types/crud-interaction.d.ts +31 -0
- package/types/dropdown-enumerator.d.ts +20 -0
- package/types/filter-pagination-crawler.d.ts +52 -0
- package/types/gap-pass.d.ts +1 -0
- package/types/index.d.ts +14 -1
- package/types/job-queue.d.ts +18 -9
- package/types/locator-extractor.d.ts +7 -0
- package/types/multi-context-handler.d.ts +55 -0
- package/types/nested-window-crawler.d.ts +41 -0
- package/types/network-action-correlator.d.ts +47 -0
- package/types/performance-metrics-collector.d.ts +50 -0
- package/types/performance-metrics.d.ts +1 -2
- package/types/screen-reader.d.ts +1 -2
- package/types/service-worker-interceptor.d.ts +36 -0
- package/types/session-capture.d.ts +1 -1
- package/types/shadow-dom-crawler.d.ts +35 -0
- package/types/sitemap.d.ts +2 -2
- package/types/state-graph.d.ts +59 -0
- package/types/stealth/human-behavior.d.ts +1 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network-action correlator — captures XHR/fetch requests that occur during
|
|
3
|
+
* a named UI action (click, form submit, navigation) and maps them to API
|
|
4
|
+
* endpoint contracts.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const correlator = createCorrelator();
|
|
8
|
+
* const detach = attachNetworkInterceptor(page, correlator);
|
|
9
|
+
* correlator.startWindow('Submit login form');
|
|
10
|
+
* await page.click('#login-btn');
|
|
11
|
+
* const correlation = correlator.endWindow();
|
|
12
|
+
* detach();
|
|
13
|
+
* const result = correlator.getResult();
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Page, Request, Response } from 'playwright';
|
|
17
|
+
|
|
18
|
+
export interface NetworkRequestRecord {
|
|
19
|
+
url: string;
|
|
20
|
+
urlPattern: string;
|
|
21
|
+
method: string;
|
|
22
|
+
bodySchema: Record<string, string> | null;
|
|
23
|
+
status: number | null;
|
|
24
|
+
responseSchema: Record<string, string> | null;
|
|
25
|
+
isGraphQL: boolean;
|
|
26
|
+
graphQLOperation?: string;
|
|
27
|
+
resourceType: string;
|
|
28
|
+
correlatedActionLabel?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ActionCorrelation {
|
|
32
|
+
actionLabel: string;
|
|
33
|
+
requests: NetworkRequestRecord[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NetworkCorrelationResult {
|
|
37
|
+
correlations: ActionCorrelation[];
|
|
38
|
+
uniqueEndpoints: string[];
|
|
39
|
+
graphqlOperations: string[];
|
|
40
|
+
totalRequestsCaptured: number;
|
|
41
|
+
coverage: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Internal helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function extractUrlPattern(url: string): string {
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(url);
|
|
51
|
+
const path = u.pathname
|
|
52
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':uuid')
|
|
53
|
+
.replace(/\/[0-9a-f]{24}(\/|$)/g, '/:mongoId$1')
|
|
54
|
+
.replace(/\/\d{6,}(\/|$)/g, '/:id$1')
|
|
55
|
+
.replace(/\/\d{1,5}(\/|$)/g, '/:id$1');
|
|
56
|
+
return path;
|
|
57
|
+
} catch {
|
|
58
|
+
return url.slice(0, 80);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractBodySchema(bodyText: string): Record<string, string> | null {
|
|
63
|
+
if (!bodyText || bodyText.length > 50_000) return null;
|
|
64
|
+
let parsed: unknown;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(bodyText);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const schema: Record<string, string> = {};
|
|
72
|
+
let keyCount = 0;
|
|
73
|
+
|
|
74
|
+
function traverse(value: unknown, prefix: string, depth: number): void {
|
|
75
|
+
if (keyCount >= 30 || depth > 3) return;
|
|
76
|
+
if (value === null || value === undefined) return;
|
|
77
|
+
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
schema[prefix] = 'array';
|
|
80
|
+
keyCount++;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof value === 'object') {
|
|
85
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
86
|
+
if (keyCount >= 30) break;
|
|
87
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
88
|
+
if (v === null) {
|
|
89
|
+
schema[key] = 'null';
|
|
90
|
+
keyCount++;
|
|
91
|
+
} else if (Array.isArray(v)) {
|
|
92
|
+
schema[key] = 'array';
|
|
93
|
+
keyCount++;
|
|
94
|
+
} else if (typeof v === 'object') {
|
|
95
|
+
traverse(v, key, depth + 1);
|
|
96
|
+
} else {
|
|
97
|
+
schema[key] = typeof v;
|
|
98
|
+
keyCount++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Primitive at top level
|
|
105
|
+
if (prefix) {
|
|
106
|
+
schema[prefix] = typeof value;
|
|
107
|
+
keyCount++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
traverse(parsed, '', 0);
|
|
112
|
+
return keyCount > 0 ? schema : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ANALYTICS_DOMAINS = [
|
|
116
|
+
'google-analytics',
|
|
117
|
+
'doubleclick',
|
|
118
|
+
'hotjar',
|
|
119
|
+
'mixpanel',
|
|
120
|
+
'segment',
|
|
121
|
+
'sentry',
|
|
122
|
+
'datadog',
|
|
123
|
+
'newrelic',
|
|
124
|
+
'amplitude',
|
|
125
|
+
'fullstory',
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const API_PATH_PATTERNS = [
|
|
129
|
+
'/api/',
|
|
130
|
+
'/graphql',
|
|
131
|
+
'/v1/',
|
|
132
|
+
'/v2/',
|
|
133
|
+
'/rest/',
|
|
134
|
+
'/query',
|
|
135
|
+
'/rpc',
|
|
136
|
+
'/trpc',
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const EXCLUDED_RESOURCE_TYPES = new Set(['image', 'font', 'stylesheet', 'media', 'websocket']);
|
|
140
|
+
|
|
141
|
+
function isApiRequest(url: string, resourceType: string): boolean {
|
|
142
|
+
if (EXCLUDED_RESOURCE_TYPES.has(resourceType)) return false;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const hostname = new URL(url).hostname;
|
|
146
|
+
if (ANALYTICS_DOMAINS.some((d) => hostname.includes(d))) return false;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (resourceType === 'xhr' || resourceType === 'fetch') return true;
|
|
152
|
+
|
|
153
|
+
const lower = url.toLowerCase();
|
|
154
|
+
return API_PATH_PATTERNS.some((p) => lower.includes(p));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Correlator
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
interface NetworkCorrelator {
|
|
162
|
+
startWindow(actionLabel: string): void;
|
|
163
|
+
recordRequest(rec: NetworkRequestRecord): void;
|
|
164
|
+
endWindow(): ActionCorrelation;
|
|
165
|
+
getResult(): NetworkCorrelationResult;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createCorrelator(): NetworkCorrelator {
|
|
169
|
+
let currentLabel: string | null = null;
|
|
170
|
+
let currentRequests: NetworkRequestRecord[] = [];
|
|
171
|
+
const correlations: ActionCorrelation[] = [];
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
startWindow(actionLabel: string): void {
|
|
175
|
+
currentLabel = actionLabel;
|
|
176
|
+
currentRequests = [];
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
recordRequest(rec: NetworkRequestRecord): void {
|
|
180
|
+
if (currentLabel === null) return;
|
|
181
|
+
currentRequests.push({ ...rec, correlatedActionLabel: currentLabel });
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
endWindow(): ActionCorrelation {
|
|
185
|
+
const label = currentLabel ?? '(unknown)';
|
|
186
|
+
const correlation: ActionCorrelation = {
|
|
187
|
+
actionLabel: label,
|
|
188
|
+
requests: currentRequests,
|
|
189
|
+
};
|
|
190
|
+
correlations.push(correlation);
|
|
191
|
+
currentLabel = null;
|
|
192
|
+
currentRequests = [];
|
|
193
|
+
return correlation;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
getResult(): NetworkCorrelationResult {
|
|
197
|
+
const uniqueEndpoints = [
|
|
198
|
+
...new Set(
|
|
199
|
+
correlations.flatMap((c) => c.requests.map((r) => r.urlPattern)),
|
|
200
|
+
),
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
const graphqlOperations = [
|
|
204
|
+
...new Set(
|
|
205
|
+
correlations
|
|
206
|
+
.flatMap((c) => c.requests)
|
|
207
|
+
.filter((r) => r.isGraphQL && r.graphQLOperation)
|
|
208
|
+
.map((r) => r.graphQLOperation as string),
|
|
209
|
+
),
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const totalRequestsCaptured = correlations.reduce(
|
|
213
|
+
(sum, c) => sum + c.requests.length,
|
|
214
|
+
0,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const coverage =
|
|
218
|
+
correlations.length === 0
|
|
219
|
+
? 0
|
|
220
|
+
: correlations.filter((c) => c.requests.length >= 1).length /
|
|
221
|
+
correlations.length;
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
correlations,
|
|
225
|
+
uniqueEndpoints,
|
|
226
|
+
graphqlOperations,
|
|
227
|
+
totalRequestsCaptured,
|
|
228
|
+
coverage,
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Page interceptor
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export function attachNetworkInterceptor(
|
|
239
|
+
page: Page,
|
|
240
|
+
correlator: ReturnType<typeof createCorrelator>,
|
|
241
|
+
): () => void {
|
|
242
|
+
const pendingRequests = new Map<string, { bodyText: string; resourceType: string }>();
|
|
243
|
+
|
|
244
|
+
const onRequest = (req: Request): void => {
|
|
245
|
+
const url = req.url();
|
|
246
|
+
const resourceType = req.resourceType();
|
|
247
|
+
if (!isApiRequest(url, resourceType)) return;
|
|
248
|
+
let bodyText = '';
|
|
249
|
+
try {
|
|
250
|
+
bodyText = req.postData() ?? '';
|
|
251
|
+
} catch {
|
|
252
|
+
// postData() may throw on non-POST requests in some Playwright versions
|
|
253
|
+
}
|
|
254
|
+
pendingRequests.set(url, { bodyText, resourceType });
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const onResponse = async (res: Response): Promise<void> => {
|
|
258
|
+
const url = res.url();
|
|
259
|
+
const pending = pendingRequests.get(url);
|
|
260
|
+
if (!pending) return;
|
|
261
|
+
pendingRequests.delete(url);
|
|
262
|
+
|
|
263
|
+
const method = res.request().method();
|
|
264
|
+
|
|
265
|
+
let bodySchema: Record<string, string> | null = null;
|
|
266
|
+
try {
|
|
267
|
+
bodySchema = extractBodySchema(pending.bodyText);
|
|
268
|
+
} catch {
|
|
269
|
+
// ignore schema extraction failures
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let responseSchema: Record<string, string> | null = null;
|
|
273
|
+
let isGraphQL = false;
|
|
274
|
+
let graphQLOperation: string | undefined;
|
|
275
|
+
|
|
276
|
+
const contentType = res.headers()['content-type'] ?? '';
|
|
277
|
+
if (contentType.includes('application/json')) {
|
|
278
|
+
try {
|
|
279
|
+
const respText = await res.text();
|
|
280
|
+
if (respText.length < 20_000) {
|
|
281
|
+
responseSchema = extractBodySchema(respText);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// response body may no longer be available
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Detect GraphQL from request body
|
|
289
|
+
try {
|
|
290
|
+
const bodyParsed = JSON.parse(pending.bodyText) as Record<string, unknown>;
|
|
291
|
+
if (typeof bodyParsed?.operationName === 'string') {
|
|
292
|
+
isGraphQL = true;
|
|
293
|
+
graphQLOperation = bodyParsed.operationName;
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
// not JSON or no operationName
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const rec: NetworkRequestRecord = {
|
|
300
|
+
url,
|
|
301
|
+
urlPattern: extractUrlPattern(url),
|
|
302
|
+
method,
|
|
303
|
+
bodySchema,
|
|
304
|
+
status: res.status(),
|
|
305
|
+
responseSchema,
|
|
306
|
+
isGraphQL,
|
|
307
|
+
graphQLOperation,
|
|
308
|
+
resourceType: pending.resourceType,
|
|
309
|
+
};
|
|
310
|
+
correlator.recordRequest(rec);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
page.on('request', onRequest);
|
|
314
|
+
page.on('response', onResponse);
|
|
315
|
+
|
|
316
|
+
return () => {
|
|
317
|
+
page.off('request', onRequest);
|
|
318
|
+
page.off('response', onResponse);
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance metrics collector — captures Core Web Vitals and navigation timing
|
|
3
|
+
* for pages visited during crawl runs.
|
|
4
|
+
* Collects LCP, CLS, INP, TTFB, long tasks, and slow resources via PerformanceObserver
|
|
5
|
+
* and the Navigation/Resource Timing APIs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Page } from 'playwright';
|
|
9
|
+
|
|
10
|
+
export type CwvScore = 'good' | 'needs-improvement' | 'poor';
|
|
11
|
+
|
|
12
|
+
export interface LcpMetric {
|
|
13
|
+
value: number;
|
|
14
|
+
score: CwvScore;
|
|
15
|
+
elementTag: string | null;
|
|
16
|
+
url: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ClsMetric {
|
|
20
|
+
value: number;
|
|
21
|
+
score: CwvScore;
|
|
22
|
+
shiftCount: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InpMetric {
|
|
26
|
+
p75: number;
|
|
27
|
+
score: CwvScore;
|
|
28
|
+
sampleCount: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface NavTimingMetric {
|
|
32
|
+
ttfb: number;
|
|
33
|
+
ttfbScore: CwvScore;
|
|
34
|
+
domContentLoaded: number;
|
|
35
|
+
domComplete: number;
|
|
36
|
+
loadComplete: number;
|
|
37
|
+
transferSizeBytes: number;
|
|
38
|
+
protocol: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SlowResource {
|
|
42
|
+
path: string;
|
|
43
|
+
durationMs: number;
|
|
44
|
+
transferSizeBytes: number;
|
|
45
|
+
initiatorType: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PerformanceMetricsResult {
|
|
49
|
+
lcp: LcpMetric | null;
|
|
50
|
+
cls: ClsMetric | null;
|
|
51
|
+
inp: InpMetric | null;
|
|
52
|
+
navTiming: NavTimingMetric | null;
|
|
53
|
+
slowResources: SlowResource[];
|
|
54
|
+
longTaskCount: number;
|
|
55
|
+
captureSuccess: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Observer injection — call before page navigation via addInitScript
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export async function injectPerformanceObservers(page: Page): Promise<void> {
|
|
63
|
+
await page.addInitScript(() => {
|
|
64
|
+
(window as any).__zetaPerf = { lcp: null, cls: 0, clsCount: 0, inp: [], longTaskCount: 0 };
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
new PerformanceObserver(list => {
|
|
68
|
+
const entries = list.getEntries();
|
|
69
|
+
const last = entries[entries.length - 1] as any;
|
|
70
|
+
if (last) {
|
|
71
|
+
(window as any).__zetaPerf.lcp = {
|
|
72
|
+
value: last.startTime,
|
|
73
|
+
elementTag: last.element?.tagName || null,
|
|
74
|
+
url: last.url || null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}).observe({ entryTypes: ['largest-contentful-paint'] });
|
|
78
|
+
} catch {}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
new PerformanceObserver(list => {
|
|
82
|
+
for (const entry of list.getEntries() as any[]) {
|
|
83
|
+
if (!entry.hadRecentInput) {
|
|
84
|
+
(window as any).__zetaPerf.cls += entry.value;
|
|
85
|
+
(window as any).__zetaPerf.clsCount++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}).observe({ entryTypes: ['layout-shift'] });
|
|
89
|
+
} catch {}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
new PerformanceObserver(list => {
|
|
93
|
+
for (const entry of list.getEntries() as any[]) {
|
|
94
|
+
(window as any).__zetaPerf.inp.push(entry.duration);
|
|
95
|
+
}
|
|
96
|
+
}).observe({ entryTypes: ['event'], durationThreshold: 40 } as any);
|
|
97
|
+
} catch {}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
new PerformanceObserver(list => {
|
|
101
|
+
(window as any).__zetaPerf.longTaskCount += list.getEntries().length;
|
|
102
|
+
}).observe({ entryTypes: ['longtask'] });
|
|
103
|
+
} catch {}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Scoring helpers
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
function scoreLcp(value: number): CwvScore {
|
|
112
|
+
if (value <= 2500) return 'good';
|
|
113
|
+
if (value <= 4000) return 'needs-improvement';
|
|
114
|
+
return 'poor';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scoreCls(value: number): CwvScore {
|
|
118
|
+
if (value <= 0.1) return 'good';
|
|
119
|
+
if (value <= 0.25) return 'needs-improvement';
|
|
120
|
+
return 'poor';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function scoreInp(p75: number): CwvScore {
|
|
124
|
+
if (p75 <= 200) return 'good';
|
|
125
|
+
if (p75 <= 500) return 'needs-improvement';
|
|
126
|
+
return 'poor';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function scoreTtfb(value: number): CwvScore {
|
|
130
|
+
if (value <= 800) return 'good';
|
|
131
|
+
if (value <= 1800) return 'needs-improvement';
|
|
132
|
+
return 'poor';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Internal collector
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async function collectMetrics(page: Page): Promise<PerformanceMetricsResult> {
|
|
140
|
+
const rawMetrics = await page.evaluate(() => {
|
|
141
|
+
const p = (window as any).__zetaPerf || {};
|
|
142
|
+
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
|
|
143
|
+
const resources = performance.getEntriesByType('resource')
|
|
144
|
+
.filter(r => r.duration > 500)
|
|
145
|
+
.sort((a, b) => b.duration - a.duration)
|
|
146
|
+
.slice(0, 10)
|
|
147
|
+
.map(r => ({
|
|
148
|
+
path: (() => { try { return new URL(r.name).pathname; } catch { return r.name.slice(0, 80); } })(),
|
|
149
|
+
durationMs: Math.round(r.duration),
|
|
150
|
+
transferSizeBytes: (r as any).transferSize || 0,
|
|
151
|
+
initiatorType: (r as any).initiatorType || '',
|
|
152
|
+
}));
|
|
153
|
+
const inpValues = p.inp || [];
|
|
154
|
+
const p75 = inpValues.length > 0
|
|
155
|
+
? inpValues.sort((a: number, b: number) => a - b)[Math.floor(inpValues.length * 0.75)]
|
|
156
|
+
: 0;
|
|
157
|
+
return {
|
|
158
|
+
lcp: p.lcp || null,
|
|
159
|
+
cls: p.cls ?? null,
|
|
160
|
+
clsCount: p.clsCount ?? 0,
|
|
161
|
+
inpP75: p75,
|
|
162
|
+
inpCount: inpValues.length,
|
|
163
|
+
longTaskCount: p.longTaskCount || 0,
|
|
164
|
+
nav: nav ? {
|
|
165
|
+
ttfb: Math.round(nav.responseStart - nav.requestStart),
|
|
166
|
+
domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
|
|
167
|
+
domComplete: Math.round(nav.domComplete - nav.startTime),
|
|
168
|
+
loadComplete: Math.round(nav.loadEventEnd - nav.startTime),
|
|
169
|
+
transferSizeBytes: (nav as any).transferSize || 0,
|
|
170
|
+
protocol: (nav as any).nextHopProtocol || '',
|
|
171
|
+
} : null,
|
|
172
|
+
resources,
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const lcp: LcpMetric | null = rawMetrics.lcp
|
|
177
|
+
? {
|
|
178
|
+
value: rawMetrics.lcp.value,
|
|
179
|
+
score: scoreLcp(rawMetrics.lcp.value),
|
|
180
|
+
elementTag: rawMetrics.lcp.elementTag,
|
|
181
|
+
url: rawMetrics.lcp.url,
|
|
182
|
+
}
|
|
183
|
+
: null;
|
|
184
|
+
|
|
185
|
+
const cls: ClsMetric | null = rawMetrics.cls !== null
|
|
186
|
+
? {
|
|
187
|
+
value: rawMetrics.cls,
|
|
188
|
+
score: scoreCls(rawMetrics.cls),
|
|
189
|
+
shiftCount: rawMetrics.clsCount,
|
|
190
|
+
}
|
|
191
|
+
: null;
|
|
192
|
+
|
|
193
|
+
const inp: InpMetric | null = rawMetrics.inpCount > 0
|
|
194
|
+
? {
|
|
195
|
+
p75: rawMetrics.inpP75,
|
|
196
|
+
score: scoreInp(rawMetrics.inpP75),
|
|
197
|
+
sampleCount: rawMetrics.inpCount,
|
|
198
|
+
}
|
|
199
|
+
: null;
|
|
200
|
+
|
|
201
|
+
const navTiming: NavTimingMetric | null = rawMetrics.nav
|
|
202
|
+
? {
|
|
203
|
+
ttfb: rawMetrics.nav.ttfb,
|
|
204
|
+
ttfbScore: scoreTtfb(rawMetrics.nav.ttfb),
|
|
205
|
+
domContentLoaded: rawMetrics.nav.domContentLoaded,
|
|
206
|
+
domComplete: rawMetrics.nav.domComplete,
|
|
207
|
+
loadComplete: rawMetrics.nav.loadComplete,
|
|
208
|
+
transferSizeBytes: rawMetrics.nav.transferSizeBytes,
|
|
209
|
+
protocol: rawMetrics.nav.protocol,
|
|
210
|
+
}
|
|
211
|
+
: null;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
lcp,
|
|
215
|
+
cls,
|
|
216
|
+
inp,
|
|
217
|
+
navTiming,
|
|
218
|
+
slowResources: rawMetrics.resources,
|
|
219
|
+
longTaskCount: rawMetrics.longTaskCount,
|
|
220
|
+
captureSuccess: true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Public API
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
const emptyResult: Omit<PerformanceMetricsResult, 'captureSuccess'> = {
|
|
229
|
+
lcp: null,
|
|
230
|
+
cls: null,
|
|
231
|
+
inp: null,
|
|
232
|
+
navTiming: null,
|
|
233
|
+
slowResources: [],
|
|
234
|
+
longTaskCount: 0,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export async function collectPagePerformance(page: Page): Promise<PerformanceMetricsResult> {
|
|
238
|
+
try {
|
|
239
|
+
return await collectMetrics(page);
|
|
240
|
+
} catch {
|
|
241
|
+
return { ...emptyResult, captureSuccess: false };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -40,7 +40,7 @@ export interface PagePerfMetrics {
|
|
|
40
40
|
measuredAt: string;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export async function collectPerfMetrics(page:
|
|
43
|
+
export async function collectPerfMetrics(page: any, url: string): Promise<PagePerfMetrics> {
|
|
44
44
|
const measuredAt = new Date().toISOString();
|
|
45
45
|
|
|
46
46
|
// Inject PerformanceObserver for LCP + CLS, wait up to 3s
|
package/src/screen-reader.ts
CHANGED
|
@@ -15,8 +15,11 @@ export interface RawElement {
|
|
|
15
15
|
expectedData?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export async function readScreen(page:
|
|
18
|
+
export async function readScreen(page: any): Promise<RawElement[]> {
|
|
19
19
|
return page.evaluate(() => {
|
|
20
|
+
// esbuild injects __name() for named arrow-function constants (e.g. `const priority = ...`).
|
|
21
|
+
// Polyfill prevents ReferenceError when the serialized callback runs in the browser.
|
|
22
|
+
if (typeof (globalThis as any).__name === 'undefined') (globalThis as any).__name = (fn: any) => fn;
|
|
20
23
|
const out: { role: string; label: string; selectorHint: string; expectedData?: string }[] = [];
|
|
21
24
|
const seen = new Set<string>();
|
|
22
25
|
|