@detiq/crawler 1.1.13 → 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 +1071 -142
- 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/http-engine.ts +29 -15
- package/src/index.ts +14 -1
- package/src/job-queue.ts +3 -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/sitemap.ts +17 -10
- 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,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nested window / browsing-context crawler — discovers and records frames,
|
|
3
|
+
* embedded apps, and popup windows that are already open in the browser
|
|
4
|
+
* context at the time of a crawl step.
|
|
5
|
+
*
|
|
6
|
+
* Complements multi-context-handler.ts: that module handles proactive popup
|
|
7
|
+
* interception via context.on('page'); this module snapshots what is already
|
|
8
|
+
* present in the frame tree and context page list.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Page, BrowserContext } from 'playwright';
|
|
12
|
+
|
|
13
|
+
export type BrowsingContextType = 'page' | 'frame' | 'popup' | 'embedded-app';
|
|
14
|
+
|
|
15
|
+
export interface BrowsingContextNode {
|
|
16
|
+
id: string;
|
|
17
|
+
type: BrowsingContextType;
|
|
18
|
+
url: string;
|
|
19
|
+
parentId: string | null;
|
|
20
|
+
depth: number;
|
|
21
|
+
title: string;
|
|
22
|
+
interactiveCount: number;
|
|
23
|
+
accessible: boolean;
|
|
24
|
+
isEmbeddedApp: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NestedCrawlResult {
|
|
28
|
+
contextTree: BrowsingContextNode[];
|
|
29
|
+
totalContextsFound: number;
|
|
30
|
+
maxDepthReached: number;
|
|
31
|
+
inaccessibleCount: number;
|
|
32
|
+
frameUrls: string[];
|
|
33
|
+
embeddedAppUrls: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Internal helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Deterministic 12-char hex id derived from context type + URL via FNV-1a.
|
|
42
|
+
* No external deps — algorithm implemented inline.
|
|
43
|
+
*/
|
|
44
|
+
function generateContextId(type: string, url: string): string {
|
|
45
|
+
let h = 0x811c9dc5;
|
|
46
|
+
const s = type + url;
|
|
47
|
+
for (let i = 0; i < s.length; i++) {
|
|
48
|
+
h ^= s.charCodeAt(i);
|
|
49
|
+
h = (h * 0x01000193) >>> 0;
|
|
50
|
+
}
|
|
51
|
+
return h.toString(16).padStart(8, '0').slice(0, 12);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns the frame depth by walking the parentFrame() chain.
|
|
56
|
+
* The main frame has depth 0.
|
|
57
|
+
*/
|
|
58
|
+
function computeFrameDepth(frame: import('playwright').Frame): number {
|
|
59
|
+
let depth = 0;
|
|
60
|
+
let current = frame.parentFrame();
|
|
61
|
+
while (current !== null) {
|
|
62
|
+
depth++;
|
|
63
|
+
current = current.parentFrame();
|
|
64
|
+
}
|
|
65
|
+
return depth;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Walks all frames attached to the page and returns a BrowsingContextNode
|
|
70
|
+
* for each reachable, non-trivial frame up to maxDepth.
|
|
71
|
+
*/
|
|
72
|
+
async function crawlFrameTree(
|
|
73
|
+
page: any,
|
|
74
|
+
maxDepth = 4,
|
|
75
|
+
): Promise<BrowsingContextNode[]> {
|
|
76
|
+
const nodes: BrowsingContextNode[] = [];
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const frames = page.frames();
|
|
80
|
+
const limited = frames.slice(0, 200);
|
|
81
|
+
|
|
82
|
+
for (const frame of limited) {
|
|
83
|
+
try {
|
|
84
|
+
const url = frame.url();
|
|
85
|
+
|
|
86
|
+
// Skip blank / synthetic frames
|
|
87
|
+
if (
|
|
88
|
+
!url ||
|
|
89
|
+
url === 'about:blank' ||
|
|
90
|
+
url.startsWith('javascript:') ||
|
|
91
|
+
url.startsWith('data:')
|
|
92
|
+
) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const depth = computeFrameDepth(frame);
|
|
97
|
+
if (depth > maxDepth) continue;
|
|
98
|
+
|
|
99
|
+
const parentFrame = frame.parentFrame();
|
|
100
|
+
const parentId = parentFrame
|
|
101
|
+
? generateContextId('frame', parentFrame.url())
|
|
102
|
+
: null;
|
|
103
|
+
|
|
104
|
+
let title = '';
|
|
105
|
+
let interactiveCount = 0;
|
|
106
|
+
let accessible = false;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
title = await frame.title();
|
|
110
|
+
interactiveCount = await frame
|
|
111
|
+
.locator('button, a[href], input, select')
|
|
112
|
+
.count();
|
|
113
|
+
accessible = true;
|
|
114
|
+
} catch {
|
|
115
|
+
// cross-origin or sandboxed — treat as inaccessible
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
nodes.push({
|
|
119
|
+
id: generateContextId('frame', url),
|
|
120
|
+
type: 'frame',
|
|
121
|
+
url,
|
|
122
|
+
parentId,
|
|
123
|
+
depth,
|
|
124
|
+
title,
|
|
125
|
+
interactiveCount,
|
|
126
|
+
accessible,
|
|
127
|
+
isEmbeddedApp: false,
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
// skip individual frame errors
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// skip entirely if frames() fails
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return nodes;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Evaluates the page DOM to identify iframes that look like full embedded
|
|
142
|
+
* applications (large viewport coverage, or app-class/id/src signals).
|
|
143
|
+
* Returns up to 5 absolute URLs.
|
|
144
|
+
*/
|
|
145
|
+
async function detectEmbeddedApps(page: any): Promise<string[]> {
|
|
146
|
+
try {
|
|
147
|
+
const srcs = await (page.evaluate as any)(() => {
|
|
148
|
+
return Array.from(document.querySelectorAll('iframe'))
|
|
149
|
+
.filter((f) => {
|
|
150
|
+
const r = f.getBoundingClientRect();
|
|
151
|
+
const vw = window.innerWidth;
|
|
152
|
+
const vh = window.innerHeight;
|
|
153
|
+
const isLarge = r.width >= vw * 0.7 && r.height >= vh * 0.7;
|
|
154
|
+
const hasAppClass = /embed|dashboard|app-frame|portal/i.test(
|
|
155
|
+
f.className + f.id + (f.getAttribute('src') ?? ''),
|
|
156
|
+
);
|
|
157
|
+
return isLarge || hasAppClass;
|
|
158
|
+
})
|
|
159
|
+
.map((f) => f.src)
|
|
160
|
+
.filter(Boolean);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const baseUrl = page.url();
|
|
164
|
+
const resolved: string[] = [];
|
|
165
|
+
|
|
166
|
+
for (const src of srcs.slice(0, 5)) {
|
|
167
|
+
try {
|
|
168
|
+
resolved.push(new URL(src, baseUrl).href);
|
|
169
|
+
} catch {
|
|
170
|
+
// skip unparseable src
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return resolved;
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Snapshots all pages currently open in the context that are not the seed
|
|
182
|
+
* URL and records them as popup nodes at depth 1.
|
|
183
|
+
*
|
|
184
|
+
* Proactive interception (context.on('page')) is handled separately by
|
|
185
|
+
* attachNewTabListener in multi-context-handler.ts.
|
|
186
|
+
*/
|
|
187
|
+
async function crawlPopupChain(
|
|
188
|
+
context: BrowserContext,
|
|
189
|
+
seedUrl: string,
|
|
190
|
+
): Promise<BrowsingContextNode[]> {
|
|
191
|
+
const nodes: BrowsingContextNode[] = [];
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const pages = context.pages();
|
|
195
|
+
|
|
196
|
+
for (const pg of pages) {
|
|
197
|
+
try {
|
|
198
|
+
const url = pg.url();
|
|
199
|
+
|
|
200
|
+
if (!url || url === 'about:blank' || url === seedUrl) continue;
|
|
201
|
+
|
|
202
|
+
let title = '';
|
|
203
|
+
let interactiveCount = 0;
|
|
204
|
+
let accessible = false;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
title = await pg.title();
|
|
208
|
+
interactiveCount = await pg
|
|
209
|
+
.locator('button, a[href], input, select')
|
|
210
|
+
.count();
|
|
211
|
+
accessible = true;
|
|
212
|
+
} catch {
|
|
213
|
+
// page may be closed or cross-origin restricted
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
nodes.push({
|
|
217
|
+
id: generateContextId('popup', url),
|
|
218
|
+
type: 'popup',
|
|
219
|
+
url,
|
|
220
|
+
parentId: null, // chain depth not reliably trackable for existing pages
|
|
221
|
+
depth: 1,
|
|
222
|
+
title,
|
|
223
|
+
interactiveCount,
|
|
224
|
+
accessible,
|
|
225
|
+
isEmbeddedApp: false,
|
|
226
|
+
});
|
|
227
|
+
} catch {
|
|
228
|
+
// skip individual page errors
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
// skip if context.pages() fails
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return nodes;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Exported entry point
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Discovers all nested browsing contexts reachable from the current page:
|
|
244
|
+
* sub-frames, embedded applications, and already-open popup pages.
|
|
245
|
+
*
|
|
246
|
+
* All failures are non-fatal — errors are caught and an empty result is
|
|
247
|
+
* returned so the caller's crawl step can continue uninterrupted.
|
|
248
|
+
*/
|
|
249
|
+
export async function crawlNestedContexts(
|
|
250
|
+
page: any,
|
|
251
|
+
context: BrowserContext | null,
|
|
252
|
+
seedUrl: string,
|
|
253
|
+
opts?: { maxFrameDepth?: number; maxPopupDepth?: number },
|
|
254
|
+
): Promise<NestedCrawlResult> {
|
|
255
|
+
const empty: NestedCrawlResult = {
|
|
256
|
+
contextTree: [],
|
|
257
|
+
totalContextsFound: 0,
|
|
258
|
+
maxDepthReached: 0,
|
|
259
|
+
inaccessibleCount: 0,
|
|
260
|
+
frameUrls: [],
|
|
261
|
+
embeddedAppUrls: [],
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const maxFrameDepth = opts?.maxFrameDepth ?? 4;
|
|
266
|
+
|
|
267
|
+
// Run independent discovery passes concurrently
|
|
268
|
+
const [frameNodes, embeddedAppUrls, popupNodes] = await Promise.all([
|
|
269
|
+
crawlFrameTree(page, maxFrameDepth).catch((): BrowsingContextNode[] => []),
|
|
270
|
+
detectEmbeddedApps(page).catch((): string[] => []),
|
|
271
|
+
context
|
|
272
|
+
? crawlPopupChain(context, seedUrl).catch((): BrowsingContextNode[] => [])
|
|
273
|
+
: Promise.resolve<BrowsingContextNode[]>([]),
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
// Promote frame nodes that match an embedded-app URL
|
|
277
|
+
const embeddedSet = new Set(embeddedAppUrls);
|
|
278
|
+
for (const node of frameNodes) {
|
|
279
|
+
if (embeddedSet.has(node.url)) {
|
|
280
|
+
node.isEmbeddedApp = true;
|
|
281
|
+
node.type = 'embedded-app';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const contextTree: BrowsingContextNode[] = [...frameNodes, ...popupNodes];
|
|
286
|
+
|
|
287
|
+
// Deduplicate by id (same URL appearing in both lists)
|
|
288
|
+
const seen = new Set<string>();
|
|
289
|
+
const deduped = contextTree.filter((n) => {
|
|
290
|
+
if (seen.has(n.id)) return false;
|
|
291
|
+
seen.add(n.id);
|
|
292
|
+
return true;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const maxDepthReached = deduped.reduce((max, n) => Math.max(max, n.depth), 0);
|
|
296
|
+
const inaccessibleCount = deduped.filter((n) => !n.accessible).length;
|
|
297
|
+
const frameUrls = [...new Set(frameNodes.map((n) => n.url))];
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
contextTree: deduped,
|
|
301
|
+
totalContextsFound: deduped.length,
|
|
302
|
+
maxDepthReached,
|
|
303
|
+
inaccessibleCount,
|
|
304
|
+
frameUrls,
|
|
305
|
+
embeddedAppUrls,
|
|
306
|
+
};
|
|
307
|
+
} catch {
|
|
308
|
+
return empty;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -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
|
+
}
|