@detiq/crawler 1.1.14 → 1.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/Dockerfile +1 -1
  2. package/dist/accessibility-tree-crawler.js +156 -0
  3. package/dist/accessibility-tree-crawler.js.map +7 -0
  4. package/dist/advanced-ui-probes.js +310 -0
  5. package/dist/advanced-ui-probes.js.map +7 -0
  6. package/dist/content-chunker.js +2 -2
  7. package/dist/content-chunker.js.map +2 -2
  8. package/dist/crawl-session-manager.js +145 -0
  9. package/dist/crawl-session-manager.js.map +7 -0
  10. package/dist/crawler.js +1377 -344
  11. package/dist/crawler.js.map +2 -2
  12. package/dist/crud-interaction.js +264 -0
  13. package/dist/crud-interaction.js.map +7 -0
  14. package/dist/dropdown-enumerator.js +230 -0
  15. package/dist/dropdown-enumerator.js.map +7 -0
  16. package/dist/filter-pagination-crawler.js +299 -0
  17. package/dist/filter-pagination-crawler.js.map +7 -0
  18. package/dist/frontier.js +5 -3
  19. package/dist/frontier.js.map +2 -2
  20. package/dist/gap-pass.js +47 -14
  21. package/dist/gap-pass.js.map +2 -2
  22. package/dist/http-engine.js +28 -15
  23. package/dist/http-engine.js.map +2 -2
  24. package/dist/index.js +15 -1
  25. package/dist/index.js.map +2 -2
  26. package/dist/job-queue.js +23 -6
  27. package/dist/job-queue.js.map +2 -2
  28. package/dist/locator-extractor.js +23 -0
  29. package/dist/locator-extractor.js.map +2 -2
  30. package/dist/multi-context-handler.js +182 -0
  31. package/dist/multi-context-handler.js.map +7 -0
  32. package/dist/nested-window-crawler.js +172 -0
  33. package/dist/nested-window-crawler.js.map +7 -0
  34. package/dist/network-action-correlator.js +209 -0
  35. package/dist/network-action-correlator.js.map +7 -0
  36. package/dist/performance-metrics-collector.js +155 -0
  37. package/dist/performance-metrics-collector.js.map +7 -0
  38. package/dist/performance-metrics.js.map +2 -2
  39. package/dist/policies/crawl-safety.js +5 -1
  40. package/dist/policies/crawl-safety.js.map +2 -2
  41. package/dist/profiles/domain-profile.js +21 -1
  42. package/dist/profiles/domain-profile.js.map +2 -2
  43. package/dist/rate-limiter.js +2 -2
  44. package/dist/rate-limiter.js.map +2 -2
  45. package/dist/screen-reader.js +1 -0
  46. package/dist/screen-reader.js.map +2 -2
  47. package/dist/service-worker-interceptor.js +136 -0
  48. package/dist/service-worker-interceptor.js.map +7 -0
  49. package/dist/session-capture.js +4 -2
  50. package/dist/session-capture.js.map +2 -2
  51. package/dist/shadow-dom-crawler.js +132 -0
  52. package/dist/shadow-dom-crawler.js.map +7 -0
  53. package/dist/sitemap.js +16 -10
  54. package/dist/sitemap.js.map +2 -2
  55. package/dist/state-graph.js +193 -0
  56. package/dist/state-graph.js.map +7 -0
  57. package/dist/stealth/human-behavior.js +36 -3
  58. package/dist/stealth/human-behavior.js.map +2 -2
  59. package/dist/url-pattern-completer.js +48 -14
  60. package/dist/url-pattern-completer.js.map +2 -2
  61. package/dist/visual-diff-verdict.js +3 -1
  62. package/dist/visual-diff-verdict.js.map +2 -2
  63. package/package.json +2 -1
  64. package/src/accessibility-tree-crawler.ts +246 -0
  65. package/src/advanced-ui-probes.ts +470 -0
  66. package/src/crawl-session-manager.ts +231 -0
  67. package/src/crawler.ts +1196 -150
  68. package/src/crud-interaction.ts +386 -0
  69. package/src/dropdown-enumerator.ts +312 -0
  70. package/src/filter-pagination-crawler.ts +500 -0
  71. package/src/gap-pass.ts +58 -16
  72. package/src/index.ts +14 -1
  73. package/src/job-queue.ts +9 -0
  74. package/src/locator-extractor.ts +45 -0
  75. package/src/multi-context-handler.ts +296 -0
  76. package/src/nested-window-crawler.ts +310 -0
  77. package/src/network-action-correlator.ts +320 -0
  78. package/src/performance-metrics-collector.ts +243 -0
  79. package/src/performance-metrics.ts +1 -1
  80. package/src/screen-reader.ts +4 -1
  81. package/src/service-worker-interceptor.ts +185 -0
  82. package/src/session-capture.ts +3 -0
  83. package/src/shadow-dom-crawler.ts +198 -0
  84. package/src/state-graph.ts +280 -0
  85. package/src/stealth/human-behavior.ts +38 -4
  86. package/src/url-pattern-completer.ts +61 -15
  87. package/tsconfig.json +1 -1
  88. package/types/accessibility-tree-crawler.d.ts +44 -0
  89. package/types/advanced-ui-probes.d.ts +47 -0
  90. package/types/crawl-session-manager.d.ts +44 -0
  91. package/types/crawler.d.ts +3 -1
  92. package/types/crud-interaction.d.ts +31 -0
  93. package/types/dropdown-enumerator.d.ts +20 -0
  94. package/types/filter-pagination-crawler.d.ts +52 -0
  95. package/types/gap-pass.d.ts +1 -0
  96. package/types/index.d.ts +14 -1
  97. package/types/job-queue.d.ts +18 -9
  98. package/types/locator-extractor.d.ts +7 -0
  99. package/types/multi-context-handler.d.ts +55 -0
  100. package/types/nested-window-crawler.d.ts +41 -0
  101. package/types/network-action-correlator.d.ts +47 -0
  102. package/types/performance-metrics-collector.d.ts +50 -0
  103. package/types/performance-metrics.d.ts +1 -2
  104. package/types/screen-reader.d.ts +1 -2
  105. package/types/service-worker-interceptor.d.ts +36 -0
  106. package/types/session-capture.d.ts +1 -1
  107. package/types/shadow-dom-crawler.d.ts +35 -0
  108. package/types/sitemap.d.ts +2 -2
  109. package/types/state-graph.d.ts +59 -0
  110. package/types/stealth/human-behavior.d.ts +1 -1
@@ -220,9 +220,35 @@ export function elementToPlaywrightLocator(element: {
220
220
  return null;
221
221
  }
222
222
 
223
+ // Patterns that indicate crawl-analysis meta-text stored as element records (not real UI elements).
224
+ // These come from accessibility-auditor, screen-reader analysis, or CSS inspector annotations.
225
+ const META_ANNOTATION_RE = /^(css animations|scroll effects detected|design tokens?\s*:|font imports?:|contrast ratio|aria-label audit|landmark|wcag|color contrast|background.*foreground.*card|--[\w-]+,\s*--)/i;
226
+
227
+ // Auth-wall element patterns — if >50% of locators match these, the page was an auth wall.
228
+ const AUTH_WALL_TEXT_RE = /sign\s*in|log\s*in|platform access required|please sign in|access required|authentication required|session expired|unauthorized/i;
229
+
230
+ /**
231
+ * Returns true if an element's text content is crawl-analysis meta-text, not a real UI element.
232
+ * These are annotations stored by the accessibility auditor, not actual DOM elements.
233
+ */
234
+ function isMetaAnnotation(el: { role?: string | null; meaning?: string | null; text?: string | null }): boolean {
235
+ const t = (el.meaning ?? el.text ?? '').trim();
236
+ if (!t) return false;
237
+ // Filter crawl-analysis annotations
238
+ if (META_ANNOTATION_RE.test(t)) return true;
239
+ // Filter very long text strings with no interactive role — these are paragraph/body copy,
240
+ // not useful UI locators. Threshold: 120 chars with no testId/label/role
241
+ const interactiveRoles = new Set(['button', 'link', 'textbox', 'combobox', 'checkbox', 'radio', 'tab', 'option', 'menuitem']);
242
+ if (t.length > 120 && !interactiveRoles.has((el.role ?? '').toLowerCase())) return true;
243
+ // Filter CSS variable dumps (contain multiple --var-name patterns)
244
+ if ((t.match(/--[\w-]+/g) ?? []).length > 3) return true;
245
+ return false;
246
+ }
247
+
223
248
  /**
224
249
  * Convert an array of DB Element records to deduplicated Playwright locators.
225
250
  * Deduplicates by key — higher confidence wins.
251
+ * Filters out crawl-analysis meta-annotations and produces an auth-wall warning when applicable.
226
252
  */
227
253
  export function elementsToPlaywrightLocators(elements: Array<{
228
254
  role?: string | null;
@@ -237,6 +263,9 @@ export function elementsToPlaywrightLocators(elements: Array<{
237
263
  const byKey = new Map<string, PlaywrightLocator>();
238
264
 
239
265
  for (const el of elements) {
266
+ // Skip crawl-analysis meta-annotations — these are not real page elements
267
+ if (isMetaAnnotation(el)) continue;
268
+
240
269
  const loc = elementToPlaywrightLocator(el);
241
270
  if (!loc) continue;
242
271
  const existing = byKey.get(loc.key);
@@ -248,6 +277,22 @@ export function elementsToPlaywrightLocators(elements: Array<{
248
277
  return Array.from(byKey.values());
249
278
  }
250
279
 
280
+ /**
281
+ * Detect whether a set of locators was extracted from an auth-wall page.
282
+ * Returns a warning message if auth-wall detected, null otherwise.
283
+ * Heuristic: >50% of locators are low-confidence text matches AND at least one matches auth-wall pattern.
284
+ */
285
+ export function detectAuthWallLocators(locators: PlaywrightLocator[]): string | null {
286
+ if (locators.length === 0) return null;
287
+ const lowCount = locators.filter(l => l.confidence === 'low').length;
288
+ const hasAuthText = locators.some(l => AUTH_WALL_TEXT_RE.test(l.description));
289
+ const highCount = locators.filter(l => l.confidence === 'high').length;
290
+ if (hasAuthText && highCount === 0 && lowCount / locators.length > 0.5) {
291
+ return 'These locators appear to be from a login/access wall, not the target page. The page requires authentication. Add credentials in Project Settings and re-crawl to get accurate locators for the protected content.';
292
+ }
293
+ return null;
294
+ }
295
+
251
296
  /**
252
297
  * Format locators as a block for injection into LLM script generation prompts.
253
298
  * Returns empty string if no locators.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Multi-context browser handling — detects and records new tabs, iframes,
3
+ * popups, and file downloads triggered during a page crawl.
4
+ * This lets ZeTa discover cross-origin flows, OAuth redirects, embedded
5
+ * third-party content, and downloadable resources that single-page HTTP
6
+ * crawlers would miss entirely.
7
+ */
8
+
9
+ import type { Page, BrowserContext } from 'playwright';
10
+
11
+ export interface NewTabRecord {
12
+ url: string;
13
+ linkText: string;
14
+ isInternal: boolean;
15
+ httpStatus?: number;
16
+ }
17
+
18
+ export interface IframeRecord {
19
+ src: string;
20
+ crossOrigin: boolean;
21
+ sandboxed: boolean;
22
+ sandboxValue: string | null;
23
+ accessible: boolean;
24
+ contentSample: string;
25
+ hasForm: boolean;
26
+ }
27
+
28
+ export interface PopupRecord {
29
+ url: string;
30
+ triggerLabel: string;
31
+ isOAuthLike: boolean;
32
+ }
33
+
34
+ export interface DownloadRecord {
35
+ filename: string;
36
+ downloadUrl: string;
37
+ triggerLabel: string;
38
+ }
39
+
40
+ export interface MultiContextResult {
41
+ newTabs: NewTabRecord[];
42
+ iframes: IframeRecord[];
43
+ popups: PopupRecord[];
44
+ downloads: DownloadRecord[];
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Internal helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ async function enumerateIframes(
52
+ page: Page,
53
+ seedUrl: string,
54
+ ): Promise<IframeRecord[]> {
55
+ const records: IframeRecord[] = [];
56
+ try {
57
+ const seedHost = new URL(seedUrl).hostname;
58
+ const iframeLocators = await page.locator('iframe').all();
59
+ const limited = iframeLocators.slice(0, 20);
60
+
61
+ for (let idx = 0; idx < limited.length; idx++) {
62
+ try {
63
+ const loc = limited[idx];
64
+ const src = (await loc.getAttribute('src')) ?? '';
65
+ const sandboxValue = await loc.getAttribute('sandbox');
66
+ const sandboxed = sandboxValue !== null;
67
+
68
+ let crossOrigin = false;
69
+ if (src) {
70
+ try {
71
+ const iframeHost = new URL(src, seedUrl).hostname;
72
+ crossOrigin = iframeHost !== seedHost;
73
+ } catch {
74
+ crossOrigin = false;
75
+ }
76
+ }
77
+
78
+ let accessible = false;
79
+ let contentSample = '';
80
+ let hasForm = false;
81
+
82
+ try {
83
+ const fl = page.frameLocator('iframe').nth(idx);
84
+ const body = await fl.locator('body').textContent({ timeout: 2000 });
85
+ if (body !== null) {
86
+ accessible = true;
87
+ contentSample = body.trim().slice(0, 300);
88
+ }
89
+ const formCount = await fl.locator('form, input, textarea, select').count();
90
+ hasForm = formCount > 0;
91
+ } catch {
92
+ // cross-origin or sandboxed — not accessible from parent frame
93
+ }
94
+
95
+ records.push({
96
+ src,
97
+ crossOrigin,
98
+ sandboxed,
99
+ sandboxValue,
100
+ accessible,
101
+ contentSample,
102
+ hasForm,
103
+ });
104
+ } catch {
105
+ // skip individual iframe errors
106
+ }
107
+ }
108
+ } catch {
109
+ // skip entirely if locator fails
110
+ }
111
+ return records;
112
+ }
113
+
114
+ async function detectPopups(page: Page, result: MultiContextResult): Promise<void> {
115
+ try {
116
+ const candidates = [
117
+ ...await page.locator('[onclick*="window.open"]').all(),
118
+ ...await page.locator('button:has-text("Open in")').all(),
119
+ ];
120
+ const limited = candidates.slice(0, 3);
121
+
122
+ for (const trigger of limited) {
123
+ try {
124
+ const triggerLabel = (await trigger.textContent())?.trim() ?? '';
125
+ const outcome = await Promise.all([
126
+ page.waitForEvent('popup', { timeout: 2000 }),
127
+ trigger.click(),
128
+ ]).catch(() => null);
129
+
130
+ if (!outcome) continue;
131
+ const popup = outcome[0] as Page;
132
+ if (!popup) continue;
133
+
134
+ const url = popup.url();
135
+ let isOAuthLike = false;
136
+ try {
137
+ const host = new URL(url).hostname;
138
+ isOAuthLike = /google|github|microsoft|okta|auth0/.test(host);
139
+ } catch {
140
+ // not a valid URL
141
+ }
142
+
143
+ result.popups.push({ url, triggerLabel, isOAuthLike });
144
+ await popup.close().catch(() => {});
145
+ } catch {
146
+ // skip this trigger
147
+ }
148
+ }
149
+ } catch {
150
+ // skip popup detection entirely
151
+ }
152
+ }
153
+
154
+ async function detectDownloads(page: Page, result: MultiContextResult): Promise<void> {
155
+ try {
156
+ const candidates = [
157
+ ...await page.locator('a[download]').all(),
158
+ ...await page.locator('button:has-text("Export")').all(),
159
+ ...await page.locator('button:has-text("Download")').all(),
160
+ ];
161
+ const limited = candidates.slice(0, 3);
162
+
163
+ for (const trigger of limited) {
164
+ try {
165
+ const triggerLabel = (await trigger.textContent())?.trim() ?? '';
166
+ const outcome = await Promise.all([
167
+ page.waitForEvent('download', { timeout: 2000 }),
168
+ trigger.click(),
169
+ ]).catch(() => null);
170
+
171
+ if (!outcome) continue;
172
+ const download = outcome[0] as unknown as import('playwright').Download;
173
+ if (!download) continue;
174
+
175
+ const filename = download.suggestedFilename();
176
+ const downloadUrl = download.url();
177
+ await download.cancel().catch(() => {});
178
+
179
+ result.downloads.push({ filename, downloadUrl, triggerLabel });
180
+ } catch {
181
+ // skip this trigger
182
+ }
183
+ }
184
+ } catch {
185
+ // skip download detection entirely
186
+ }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Exported functions
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Attaches a listener to the BrowserContext that records every new tab
195
+ * opened while the listener is active. Returns an unsubscribe function.
196
+ *
197
+ * Call this before navigating / clicking so that tabs opened during the
198
+ * crawl are captured, then call the returned function to stop listening.
199
+ */
200
+ export function attachNewTabListener(
201
+ context: BrowserContext,
202
+ seedUrl: string,
203
+ result: MultiContextResult,
204
+ ): () => void {
205
+ let seedHost = '';
206
+ try {
207
+ seedHost = new URL(seedUrl).hostname;
208
+ } catch {
209
+ // leave empty — all tabs will be treated as external
210
+ }
211
+
212
+ const handler = async (newPage: Page) => {
213
+ try {
214
+ await newPage.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
215
+ const url = newPage.url();
216
+
217
+ if (!url || url === 'about:blank') {
218
+ await newPage.close().catch(() => {});
219
+ return;
220
+ }
221
+
222
+ let isInternal = false;
223
+ let httpStatus: number | undefined;
224
+
225
+ try {
226
+ const tabHost = new URL(url).hostname;
227
+ isInternal = tabHost === seedHost;
228
+ } catch {
229
+ // non-parseable URL — treat as external
230
+ }
231
+
232
+ if (!isInternal) {
233
+ try {
234
+ const controller = new AbortController();
235
+ const timer = setTimeout(() => controller.abort(), 3000);
236
+ const resp = await fetch(url, { method: 'HEAD', signal: controller.signal });
237
+ clearTimeout(timer);
238
+ httpStatus = resp.status;
239
+ } catch {
240
+ // network unreachable or CORS — leave httpStatus undefined
241
+ }
242
+ }
243
+
244
+ // linkText is not available at context level; caller fills it if needed
245
+ result.newTabs.push({ url, linkText: '', isInternal, httpStatus });
246
+ await newPage.close().catch(() => {});
247
+ } catch {
248
+ // swallow handler errors so the listener stays alive
249
+ }
250
+ };
251
+
252
+ context.on('page', handler);
253
+ return () => context.off('page', handler);
254
+ }
255
+
256
+ /**
257
+ * Probes the current page for multi-context browser events: iframes,
258
+ * popup windows, and file downloads.
259
+ *
260
+ * New-tab tracking is intentionally excluded here — it must be set up at
261
+ * the crawler level (before navigation) using `attachNewTabListener`.
262
+ */
263
+ export async function probeMultiContext(
264
+ page: Page,
265
+ context: BrowserContext,
266
+ seedUrl: string,
267
+ ): Promise<MultiContextResult> {
268
+ void context; // reserved for future use (e.g. routing overrides)
269
+
270
+ const result: MultiContextResult = {
271
+ newTabs: [],
272
+ iframes: [],
273
+ popups: [],
274
+ downloads: [],
275
+ };
276
+
277
+ try {
278
+ result.iframes = await enumerateIframes(page, seedUrl);
279
+ } catch {
280
+ // keep empty
281
+ }
282
+
283
+ try {
284
+ await detectPopups(page, result);
285
+ } catch {
286
+ // keep empty
287
+ }
288
+
289
+ try {
290
+ await detectDownloads(page, result);
291
+ } catch {
292
+ // keep empty
293
+ }
294
+
295
+ return result;
296
+ }
@@ -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
+ }