@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
package/src/http-engine.ts
CHANGED
|
@@ -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
|
|
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 >=
|
|
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(
|
|
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}
|
|
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 <
|
|
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
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
const userAgent = opts.userAgent ??
|
|
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 ??
|
|
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
|
|
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);
|
package/src/locator-extractor.ts
CHANGED
|
@@ -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
|
+
}
|