@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.
Files changed (112) 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 +1142 -263
  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 +12 -5
  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 +1071 -142
  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/http-engine.ts +29 -15
  73. package/src/index.ts +14 -1
  74. package/src/job-queue.ts +3 -1
  75. package/src/locator-extractor.ts +45 -0
  76. package/src/multi-context-handler.ts +296 -0
  77. package/src/nested-window-crawler.ts +310 -0
  78. package/src/network-action-correlator.ts +320 -0
  79. package/src/performance-metrics-collector.ts +243 -0
  80. package/src/performance-metrics.ts +1 -1
  81. package/src/screen-reader.ts +4 -1
  82. package/src/service-worker-interceptor.ts +185 -0
  83. package/src/session-capture.ts +3 -0
  84. package/src/shadow-dom-crawler.ts +198 -0
  85. package/src/sitemap.ts +17 -10
  86. package/src/state-graph.ts +280 -0
  87. package/src/stealth/human-behavior.ts +38 -4
  88. package/src/url-pattern-completer.ts +61 -15
  89. package/tsconfig.json +1 -1
  90. package/types/accessibility-tree-crawler.d.ts +44 -0
  91. package/types/advanced-ui-probes.d.ts +47 -0
  92. package/types/crawl-session-manager.d.ts +44 -0
  93. package/types/crawler.d.ts +3 -1
  94. package/types/crud-interaction.d.ts +31 -0
  95. package/types/dropdown-enumerator.d.ts +20 -0
  96. package/types/filter-pagination-crawler.d.ts +52 -0
  97. package/types/gap-pass.d.ts +1 -0
  98. package/types/index.d.ts +14 -1
  99. package/types/job-queue.d.ts +18 -9
  100. package/types/locator-extractor.d.ts +7 -0
  101. package/types/multi-context-handler.d.ts +55 -0
  102. package/types/nested-window-crawler.d.ts +41 -0
  103. package/types/network-action-correlator.d.ts +47 -0
  104. package/types/performance-metrics-collector.d.ts +50 -0
  105. package/types/performance-metrics.d.ts +1 -2
  106. package/types/screen-reader.d.ts +1 -2
  107. package/types/service-worker-interceptor.d.ts +36 -0
  108. package/types/session-capture.d.ts +1 -1
  109. package/types/shadow-dom-crawler.d.ts +35 -0
  110. package/types/sitemap.d.ts +2 -2
  111. package/types/state-graph.d.ts +59 -0
  112. package/types/stealth/human-behavior.d.ts +1 -1
@@ -70,6 +70,18 @@ const SKIP_PATH = _skipPathExtra.length > 0
70
70
  ? new RegExp(`/(terms|privacy|dpa|legal|cookie|changelog|blog|help|docs|about|contact|careers|press|imprint|gdpr|compliance|acceptable-use|security-policy|accessibility|${_skipPathExtra.join('|')})([-/]|$)`, 'i')
71
71
  : /\/(terms|privacy|dpa|legal|cookie|changelog|blog|help|docs|about|contact|careers|press|imprint|gdpr|compliance|acceptable-use|security-policy|accessibility)([-/]|$)/i;
72
72
 
73
+ async function getHttpConfig() {
74
+ const { getSystemConfig } = await import('@detiq/core');
75
+ const cfg = await getSystemConfig('crawl_tuning');
76
+ return {
77
+ retryLimit: parseInt(String(cfg?.httpRetryLimit ?? '5'), 10),
78
+ backoffCapMs: parseInt(String(cfg?.httpBackoffCapMs ?? '60000'), 10),
79
+ retry5xx: parseInt(String(cfg?.http5xxRetryLimit ?? '3'), 10),
80
+ timeoutMs: parseInt(String(cfg?.httpTimeoutMs ?? '15000'), 10),
81
+ concurrency: parseInt(String(cfg?.httpConcurrency ?? '5'), 10),
82
+ };
83
+ }
84
+
73
85
  export type HttpOutputFormat = 'json' | 'jsonlines' | 'csv' | 'markdown' | 'html';
74
86
 
75
87
  export interface HttpScreen {
@@ -101,7 +113,7 @@ export interface HttpCrawlOpts {
101
113
  urlFilter?: string; // regex pattern — only crawl matching URLs
102
114
  traversalStrategy?: 'BFS' | 'DFS'; // default BFS
103
115
  outputFormats?: HttpOutputFormat[];
104
- cacheDir?: string; // default process.env.CRAWLER_CACHE_DIR || '/tmp/zeta-cache'
116
+ cacheDir?: string; // default '/tmp/zeta-cache'
105
117
  userAgent?: string;
106
118
  headers?: Record<string, string>;
107
119
  respectRobotsTxt?: boolean; // default true
@@ -200,8 +212,10 @@ async function fetchWithBackoff(
200
212
  url: string,
201
213
  cache: ResponseCache,
202
214
  opts: { userAgent: string; timeoutMs: number; extraHeaders?: Record<string, string> },
203
- retryCount = 0
215
+ retryCount = 0,
216
+ httpCfg?: Awaited<ReturnType<typeof getHttpConfig>>
204
217
  ): Promise<FetchResult> {
218
+ if (!httpCfg) httpCfg = await getHttpConfig();
205
219
  const t0 = performance.now();
206
220
 
207
221
  if (cache.isFresh(url)) {
@@ -255,20 +269,20 @@ async function fetchWithBackoff(
255
269
 
256
270
  // 429 / 503 — exponential backoff with jitter
257
271
  if (response.status === 429 || response.status === 503) {
258
- if (retryCount >= 5) throw new Error(`[http-engine] rate-limited after 5 retries: ${url}`);
272
+ if (retryCount >= httpCfg.retryLimit) throw new Error(`[http-engine] rate-limited after ${httpCfg.retryLimit} retries: ${url}`);
259
273
  const retryAfter = parseInt(response.headers.get('retry-after') ?? '0') || 0;
260
- const baseDelay = retryAfter > 0 ? retryAfter * 1000 : Math.min(60_000, 1_000 * Math.pow(2, retryCount));
274
+ const baseDelay = retryAfter > 0 ? retryAfter * 1000 : Math.min(httpCfg.backoffCapMs, 1_000 * Math.pow(2, retryCount));
261
275
  const jitter = Math.random() * 1000;
262
- console.log(`[http-engine] ${response.status} on ${url} — backing off ${Math.round(baseDelay + jitter)}ms (retry ${retryCount + 1}/5)`);
276
+ console.log(`[http-engine] ${response.status} on ${url} — backing off ${Math.round(baseDelay + jitter)}ms (retry ${retryCount + 1}/${httpCfg.retryLimit})`);
263
277
  await new Promise(r => setTimeout(r, baseDelay + jitter));
264
- return fetchWithBackoff(url, cache, opts, retryCount + 1);
278
+ return fetchWithBackoff(url, cache, opts, retryCount + 1, httpCfg);
265
279
  }
266
280
 
267
281
  // 5xx — retry up to 3 times
268
282
  if (response.status >= 500) {
269
- if (retryCount < 3) {
283
+ if (retryCount < httpCfg.retry5xx) {
270
284
  await new Promise(r => setTimeout(r, 2_000 * (retryCount + 1)));
271
- return fetchWithBackoff(url, cache, opts, retryCount + 1);
285
+ return fetchWithBackoff(url, cache, opts, retryCount + 1, httpCfg);
272
286
  }
273
287
  if (cache.staleIfError(url)) {
274
288
  const cached = cache.get(url)!;
@@ -292,11 +306,11 @@ async function fetchWithBackoff(
292
306
 
293
307
  export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
294
308
  const startTime = performance.now();
295
- const maxScreens = opts.maxScreens ?? (parseInt(process.env.CRAWLER_HTTP_ENGINE_MAX_SCREENS ?? '') || 100);
296
- const cacheDir = opts.cacheDir ?? process.env.CRAWLER_CACHE_DIR ?? '/tmp/zeta-cache';
297
- const timeoutMs = opts.timeoutMs ?? 15_000;
298
- const CRAWLER_USER_AGENT = process.env.CRAWLER_USER_AGENT ?? 'ZeTa-Crawler/1.0 (+https://zeta.taodigitalsolutions.com/crawler)';
299
- const userAgent = opts.userAgent ?? CRAWLER_USER_AGENT;
309
+ const httpCfg = await getHttpConfig();
310
+ const maxScreens = opts.maxScreens ?? 100;
311
+ const cacheDir = opts.cacheDir ?? '/tmp/zeta-cache';
312
+ const timeoutMs = opts.timeoutMs ?? httpCfg.timeoutMs;
313
+ const userAgent = opts.userAgent ?? 'ZeTa-Crawler/1.0 (+https://zeta.taodigitalsolutions.com/crawler)';
300
314
  const cache = new ResponseCache(cacheDir);
301
315
  const robots = opts.respectRobotsTxt !== false ? new RobotsCache() : null;
302
316
  const urlFilterRe = opts.urlFilter ? (() => { try { return new RegExp(opts.urlFilter!, 'i'); } catch { return null; } })() : null;
@@ -340,7 +354,7 @@ export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
340
354
 
341
355
  let fetchResult: FetchResult;
342
356
  try {
343
- fetchResult = await fetchWithBackoff(url, cache, { userAgent, timeoutMs, extraHeaders: opts.headers });
357
+ fetchResult = await fetchWithBackoff(url, cache, { userAgent, timeoutMs, extraHeaders: opts.headers }, 0, httpCfg);
344
358
  if (fetchResult.statusCode === 429 || fetchResult.statusCode === 503) rateLimitedCount++;
345
359
  } catch (err: any) {
346
360
  console.error(`[http-engine] error fetching ${url}: ${err.message}`);
@@ -399,7 +413,7 @@ export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
399
413
  }));
400
414
  };
401
415
 
402
- const concurrency = opts.concurrency ?? 5;
416
+ const concurrency = opts.concurrency ?? httpCfg.concurrency;
403
417
 
404
418
  while (queue.length > 0 && screens.length < maxScreens) {
405
419
  const batch = queue.splice(0, concurrency);
package/src/index.ts CHANGED
@@ -35,5 +35,18 @@ export { startSpan, endSpan, withSpan, increment, flushMetrics } from './telemet
35
35
  export type { Span, FinishedSpan } from './telemetry.js';
36
36
  export { analyzeFailure } from './failure-analysis-agent.js';
37
37
  export type { FailureContext } from './failure-analysis-agent.js';
38
- export { elementToPlaywrightLocator, elementsToPlaywrightLocators, formatLocatorsForPrompt } from './locator-extractor.js';
38
+ export { elementToPlaywrightLocator, elementsToPlaywrightLocators, formatLocatorsForPrompt, detectAuthWallLocators } from './locator-extractor.js';
39
39
  export type { PlaywrightLocator } from './locator-extractor.js';
40
+ export * from './dropdown-enumerator.js';
41
+ export * from './crud-interaction.js';
42
+ export * from './filter-pagination-crawler.js';
43
+ export * from './multi-context-handler.js';
44
+ export * from './advanced-ui-probes.js';
45
+ export * from './state-graph.js';
46
+ export * from './nested-window-crawler.js';
47
+ export * from './shadow-dom-crawler.js';
48
+ export * from './network-action-correlator.js';
49
+ export * from './crawl-session-manager.js';
50
+ export * from './accessibility-tree-crawler.js';
51
+ export * from './performance-metrics.js';
52
+ export * from './service-worker-interceptor.js';
package/src/job-queue.ts CHANGED
@@ -1703,7 +1703,9 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1703
1703
  const appUrl: string = config.appUrl ?? crawlCfg.appUrl;
1704
1704
  if (!appUrl) throw new Error('No app URL configured. Save the crawl settings first.');
1705
1705
 
1706
- const maxDiscoverUrls: number = config.maxDiscoverUrls ?? 2000;
1706
+ const { getSystemConfig } = await import('@detiq/core');
1707
+ const crawlTuning = await getSystemConfig('crawl_tuning');
1708
+ const maxDiscoverUrls: number = config.maxDiscoverUrls ?? parseInt(String(crawlTuning?.maxDiscoverUrls ?? '2000'), 10);
1707
1709
  const proxyUrl: string | undefined = config.proxyUrl ?? crawlCfg.proxyUrl;
1708
1710
  const screenshotDir = os.tmpdir();
1709
1711
  const discoverStorageConfig = await getTenantStorageConfig(tenantId).catch(() => null);
@@ -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
+ }