@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/crawler.ts
CHANGED
|
@@ -74,6 +74,27 @@ import { screensToSitemapUrls, writeSitemap } from './sitemap-writer.js';
|
|
|
74
74
|
import { generateOpenApiSpec, specToYaml } from './openapi-generator.js';
|
|
75
75
|
import { WebhookNotifier } from './webhook-notifier.js';
|
|
76
76
|
import { generatePlaywrightTest } from './playwright-recorder.js';
|
|
77
|
+
import { enumerateDropdowns } from './dropdown-enumerator.js';
|
|
78
|
+
import { probeCrudFlows } from './crud-interaction.js';
|
|
79
|
+
import { crawlFiltersAndPagination } from './filter-pagination-crawler.js';
|
|
80
|
+
import { probeMultiContext } from './multi-context-handler.js';
|
|
81
|
+
import { probeAdvancedUI } from './advanced-ui-probes.js';
|
|
82
|
+
import { ScreenTransitionGraph } from './state-graph.js';
|
|
83
|
+
import { crawlNestedContexts } from './nested-window-crawler.js';
|
|
84
|
+
import { crawlShadowDom } from './shadow-dom-crawler.js';
|
|
85
|
+
import { crawlAccessibilityTree } from './accessibility-tree-crawler.js';
|
|
86
|
+
import { collectPerfMetrics } from './performance-metrics.js';
|
|
87
|
+
import { inspectServiceWorker } from './service-worker-interceptor.js';
|
|
88
|
+
|
|
89
|
+
async function getCrawlTuning() {
|
|
90
|
+
const { getSystemConfig } = await import('@detiq/core');
|
|
91
|
+
const cfg = await getSystemConfig('crawl_tuning');
|
|
92
|
+
return {
|
|
93
|
+
navTimeoutMs: parseInt(String(cfg?.navTimeoutMs ?? '15000'), 10),
|
|
94
|
+
defaultTimeoutMs: parseInt(String(cfg?.defaultTimeoutMs ?? '2000'), 10),
|
|
95
|
+
userAgent: String(cfg?.userAgent ?? 'ZeTa-Crawler/1.0 (+https://zeta.taodigitalsolutions.com/crawler)'),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
77
98
|
|
|
78
99
|
// Module-level cache for per-crawl LLM config — avoids redundant DB round-trips inside per-page loops.
|
|
79
100
|
// Keyed by tenantId so multi-tenant workers don't share configs across tenants.
|
|
@@ -131,12 +152,27 @@ function isGeneratedDynamicPath(pathname: string): boolean {
|
|
|
131
152
|
return normalized.split('/').filter(Boolean).some(isGeneratedPathSegment);
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
// File extensions that trigger browser downloads rather than page renders.
|
|
156
|
+
// URLs ending with these are skipped from the BFS queue entirely.
|
|
157
|
+
const DOWNLOAD_EXT_RE = /\.(pdf|zip|gz|tar|rar|7z|exe|dmg|pkg|deb|rpm|msi|apk|ipa|doc|docx|xls|xlsx|ppt|pptx|csv|json|xml|yaml|yml|txt|log|sql|db|sqlite|mp4|mp3|avi|mov|mkv|webm|wav|ogg|flac|jpg|jpeg|png|gif|bmp|svg|webp|ico|woff|woff2|ttf|eot|otf|bin|iso|img|vhd|vmdk)(\?.*)?$/i;
|
|
158
|
+
|
|
159
|
+
// API endpoint path patterns — these return data (JSON/CSV/binary), not rendered pages.
|
|
160
|
+
// Crawling them triggers file downloads and wastes BFS slots.
|
|
161
|
+
const API_PATH_RE = /^\/api\//i;
|
|
162
|
+
|
|
163
|
+
// Export/download path segments that appear in apps — these return file data even without
|
|
164
|
+
// a file extension in the URL (e.g. /admin/exports/report, /download/invoice/123).
|
|
165
|
+
const EXPORT_PATH_RE = /\/(exports?|downloads?|export-csv|export-pdf|export-excel|download-report|generate-report|report-download)(\/|$|\?)/i;
|
|
166
|
+
|
|
134
167
|
function isUsefulNavigationUrl(href: string, siteOrigin: string): boolean {
|
|
135
168
|
try {
|
|
136
169
|
const u = new URL(href);
|
|
137
170
|
if (!sameSite(u.toString(), siteOrigin)) return false;
|
|
138
171
|
if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
|
|
139
172
|
if (isGeneratedDynamicPath(u.pathname)) return false;
|
|
173
|
+
if (DOWNLOAD_EXT_RE.test(u.pathname)) return false;
|
|
174
|
+
if (API_PATH_RE.test(u.pathname)) return false;
|
|
175
|
+
if (EXPORT_PATH_RE.test(u.pathname)) return false;
|
|
140
176
|
return true;
|
|
141
177
|
} catch {
|
|
142
178
|
return false;
|
|
@@ -517,6 +553,8 @@ export async function crawlProject(opts: {
|
|
|
517
553
|
/** Discovery mode only: called after each page with the current set of discovered original URLs — enables streaming partial results to callers. */
|
|
518
554
|
onUrlsDiscovered?: (urls: string[]) => Promise<void>;
|
|
519
555
|
}, onProgress?: (pct: number, step: string) => Promise<void>) {
|
|
556
|
+
const tuning = await getCrawlTuning();
|
|
557
|
+
|
|
520
558
|
if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
|
|
521
559
|
|
|
522
560
|
// Upload screenshot to cloud storage if configured, returns cloudUrl or undefined
|
|
@@ -683,7 +721,9 @@ export async function crawlProject(opts: {
|
|
|
683
721
|
payload: { profile: domainProfile, routeDecision },
|
|
684
722
|
}).catch((err: any) => console.warn('[crawler] profile evidence save failed:', err?.message ?? err));
|
|
685
723
|
|
|
686
|
-
if (!stagehandConfigured) {
|
|
724
|
+
if (!stagehandConfigured && !opts.discoveryMode) {
|
|
725
|
+
// Discovery mode never calls Stagehand AI — DOM link extraction only.
|
|
726
|
+
// Skip this guard so URL discovery works even without an AI provider configured.
|
|
687
727
|
throw new Error(
|
|
688
728
|
configuredProvider
|
|
689
729
|
? `No API key configured for provider "${configuredProvider}". Go to Workspace → Settings → AI Provider.`
|
|
@@ -860,8 +900,8 @@ export async function crawlProject(opts: {
|
|
|
860
900
|
// Use resolvePage() to get the active V3Page (which is a full Playwright proxy).
|
|
861
901
|
const page = await (stagehand as any).resolvePage();
|
|
862
902
|
// Cap all Playwright operations so one stuck page can't freeze the crawl.
|
|
863
|
-
try { page.setDefaultTimeout(
|
|
864
|
-
try { page.setDefaultNavigationTimeout(
|
|
903
|
+
try { page.setDefaultTimeout(tuning.defaultTimeoutMs || 45_000); } catch { /* proxy may not expose this */ }
|
|
904
|
+
try { page.setDefaultNavigationTimeout(tuning.navTimeoutMs || 30_000); } catch { /* proxy may not expose this */ }
|
|
865
905
|
// Stagehand V3Page proxy only supports the 'console' event (page.on throws for others).
|
|
866
906
|
// rawPage will be wired to a real Playwright Page via the CDP connection established below.
|
|
867
907
|
// Declared as `let` so the network-observer block can populate it.
|
|
@@ -1009,14 +1049,81 @@ export async function crawlProject(opts: {
|
|
|
1009
1049
|
if (pwContext) {
|
|
1010
1050
|
// P4: pass XHR URL discovery callback — adds relative paths to xhrDiscoveredPaths
|
|
1011
1051
|
attachNetworkObserver(pwContext, capturedApiEndpoints, (relPath) => xhrDiscoveredPaths.add(relPath));
|
|
1012
|
-
//
|
|
1052
|
+
// Popup/new-window handler — fires for every window.open() call.
|
|
1053
|
+
// Handles two cases:
|
|
1054
|
+
// 1. OAuth/SSO provider windows — wait for close, then continue login flow.
|
|
1055
|
+
// 2. App-generated popups (help, report, lightbox-in-window) — screenshot + save as screen.
|
|
1056
|
+
const OAUTH_PROVIDER_RE = /google\.|github\.|microsoft\.|facebook\.|twitter\.|linkedin\.|okta\.|auth0\.|ping\.|onelogin\.|sso\.|oauth\.|\/authorize\?|\/oauth2\//i;
|
|
1013
1057
|
pwContext.on('page', (popup: any) => {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1058
|
+
// Wait for the popup to navigate to a real URL (about:blank initially)
|
|
1059
|
+
popup.waitForURL((u: string) => !u.startsWith('about:'), { timeout: 10_000 })
|
|
1060
|
+
.then(async () => {
|
|
1061
|
+
const popupUrl = popup.url();
|
|
1062
|
+
if (OAUTH_PROVIDER_RE.test(popupUrl)) {
|
|
1063
|
+
// OAuth flow — just wait for it to close
|
|
1064
|
+
logger.info({ url: popupUrl }, '[crawler] OAuth popup navigated to provider');
|
|
1065
|
+
popup.waitForEvent('close', { timeout: 120_000 })
|
|
1066
|
+
.then(() => logger.info('[crawler] OAuth popup closed — SSO flow complete'))
|
|
1067
|
+
.catch(() => logger.warn('[crawler] OAuth popup timed out or errored'));
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
// App popup — capture as a screen (skip API/export/download URLs)
|
|
1071
|
+
try {
|
|
1072
|
+
const _popupPath = (() => { try { return new URL(popupUrl).pathname; } catch { return ''; } })();
|
|
1073
|
+
if (API_PATH_RE.test(_popupPath) || EXPORT_PATH_RE.test(_popupPath) || DOWNLOAD_EXT_RE.test(_popupPath)) return;
|
|
1074
|
+
} catch {}
|
|
1075
|
+
logger.info({ url: popupUrl }, '[crawler] App popup detected — capturing');
|
|
1076
|
+
try {
|
|
1077
|
+
await popup.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => {});
|
|
1078
|
+
await popup.waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => {});
|
|
1079
|
+
const popupTitle = await popup.title().catch(() => '');
|
|
1080
|
+
const popupName = popupTitle || (() => { try { return new URL(popupUrl).pathname.split('/').filter(Boolean).pop() || 'Popup'; } catch { return 'Popup'; } })();
|
|
1081
|
+
const popupHash = popupUrl.replace(/[^a-z0-9]/gi, '').slice(-12);
|
|
1082
|
+
const popupShotPath = path.join(opts.screenshotDir ?? '/tmp', `popup-${popupHash}.png`);
|
|
1083
|
+
await waitForScreenshotReady(popup, popup);
|
|
1084
|
+
await popup.screenshot({ path: popupShotPath, fullPage: false }).catch(() => {});
|
|
1085
|
+
const popupScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, popupName, popupUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
|
|
1086
|
+
if (popupScreen) {
|
|
1087
|
+
const cloudUrl = await uploadToCloud(popupShotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(popupShotPath)}`).catch(() => undefined);
|
|
1088
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, popupScreen.id, popupShotPath, popupScreen.version, 'DESKTOP', cloudUrl).catch(() => {});
|
|
1089
|
+
const popupEls = await domFallbackElements(popup, popup).catch(() => []);
|
|
1090
|
+
for (const el of popupEls) {
|
|
1091
|
+
AppBrain.saveElement(opts.tenantId, opts.projectId, popupScreen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {});
|
|
1092
|
+
}
|
|
1093
|
+
logger.info({ url: popupUrl, name: popupName, elementCount: popupEls.length }, '[crawler] App popup screen saved');
|
|
1094
|
+
}
|
|
1095
|
+
} catch (popupErr: any) {
|
|
1096
|
+
logger.warn({ url: popupUrl, err: popupErr?.message }, '[crawler] App popup capture failed — non-fatal');
|
|
1097
|
+
}
|
|
1098
|
+
})
|
|
1099
|
+
.catch(() => {}); // popup may close before navigating (race condition)
|
|
1019
1100
|
});
|
|
1101
|
+
// Cancel any file downloads triggered by link navigation — the crawler captures
|
|
1102
|
+
// pages, not binary files. Without this, clicking download links opens the browser
|
|
1103
|
+
// download UI and stalls the crawl waiting for a page load that never comes.
|
|
1104
|
+
pwContext.on('download', (dl: any) => { dl.cancel().catch(() => {}); });
|
|
1105
|
+
|
|
1106
|
+
// Route-level interception: immediately abort navigation to known API/export/download
|
|
1107
|
+
// paths that would trigger file downloads. Uses URL-only matching (no double-fetch)
|
|
1108
|
+
// to avoid the extra network round-trip that was causing "Failed to fetch" crashes.
|
|
1109
|
+
// The download event handler above catches any remaining downloads that slip through.
|
|
1110
|
+
try {
|
|
1111
|
+
await pwContext.route('**/*', async (route: any, request: any) => {
|
|
1112
|
+
const resourceType: string = request.resourceType?.() ?? '';
|
|
1113
|
+
if (resourceType !== 'document') { await route.continue().catch(() => {}); return; }
|
|
1114
|
+
try {
|
|
1115
|
+
const u = new URL(request.url());
|
|
1116
|
+
if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) {
|
|
1117
|
+
logger.info({ url: request.url() }, '[crawler] route: aborting API/export/download navigation');
|
|
1118
|
+
await route.abort('failed').catch(() => {});
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
} catch {}
|
|
1122
|
+
await route.continue().catch(() => {});
|
|
1123
|
+
});
|
|
1124
|
+
} catch (routeErr: any) {
|
|
1125
|
+
logger.warn({ err: routeErr?.message }, '[crawler] route interception setup failed — downloads may occur');
|
|
1126
|
+
}
|
|
1020
1127
|
}
|
|
1021
1128
|
// Wire rawPage to the real Playwright page from this CDP connection.
|
|
1022
1129
|
// This gives us a true Playwright Page that supports all events (.on('response'), etc.)
|
|
@@ -1136,6 +1243,78 @@ export async function crawlProject(opts: {
|
|
|
1136
1243
|
document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
|
|
1137
1244
|
).catch(() => -1);
|
|
1138
1245
|
|
|
1246
|
+
// ── Pre-auth public-page discovery ───────────────────────────────────────
|
|
1247
|
+
// The page is unauthenticated here. On sites where login redirects users
|
|
1248
|
+
// away from the marketing/landing pages (e.g. "/" → app dashboard), BFS
|
|
1249
|
+
// (which runs after auth) will never discover those public pages. We extract
|
|
1250
|
+
// nav links from the current unauthenticated view and visit/capture each one
|
|
1251
|
+
// before the session is applied, so the marketing pages are captured correctly.
|
|
1252
|
+
const preAuthPublicCaptured = new Set<string>();
|
|
1253
|
+
if (!opts.discoveryMode) {
|
|
1254
|
+
const _appOrigin = new URL(opts.appUrl).origin;
|
|
1255
|
+
// Simple URL normalizer for pre-auth use (canonicalize() declared later in scope)
|
|
1256
|
+
const _norm = (u: string) => { try { const p = new URL(u); p.hash = ''; return p.href.replace(/\/$/, ''); } catch { return u; } };
|
|
1257
|
+
const _startNorm = _norm(opts.appUrl);
|
|
1258
|
+
const _authPathRe = /\/(sign.?in|sign.?up|log.?in|register|auth|oauth|sso|forgot.?password|reset.?password)(\/|$|\?)/i;
|
|
1259
|
+
const _skipPathRe = /\/(terms|privacy|legal|cookie-policy|changelog|blog|imprint)([-/]|$)/i;
|
|
1260
|
+
|
|
1261
|
+
const publicNavLinks: string[] = await page.evaluate((originStr: string) => {
|
|
1262
|
+
return Array.from(document.querySelectorAll('a[href]'))
|
|
1263
|
+
.map((a: Element) => {
|
|
1264
|
+
try {
|
|
1265
|
+
const u = new URL((a as HTMLAnchorElement).getAttribute('href')!, window.location.href);
|
|
1266
|
+
return u.origin === originStr ? u.href.split('#')[0] : '';
|
|
1267
|
+
} catch { return ''; }
|
|
1268
|
+
})
|
|
1269
|
+
.filter((h: string, i: number, arr: string[]) => h && arr.indexOf(h) === i)
|
|
1270
|
+
.slice(0, 30);
|
|
1271
|
+
}, _appOrigin).catch(() => [] as string[]);
|
|
1272
|
+
|
|
1273
|
+
for (const link of publicNavLinks) {
|
|
1274
|
+
if (_norm(link) === _startNorm) continue;
|
|
1275
|
+
try {
|
|
1276
|
+
const { pathname } = new URL(link);
|
|
1277
|
+
if (_authPathRe.test(pathname) || _skipPathRe.test(pathname) || API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
|
|
1278
|
+
await page.goto(link, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
|
|
1279
|
+
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
|
|
1280
|
+
const finalUrl = page.url();
|
|
1281
|
+
const finalPath = new URL(finalUrl).pathname;
|
|
1282
|
+
if (_authPathRe.test(finalPath)) continue; // redirected to auth — skip
|
|
1283
|
+
if (_norm(finalUrl) === _startNorm) continue; // SPA catch-all redirect
|
|
1284
|
+
// Read interactive elements first — SPA pages show 0 elements without auth.
|
|
1285
|
+
// Only lock as pre-auth if the page actually has content; otherwise BFS handles it authenticated.
|
|
1286
|
+
const preAuthElements = await readScreen(page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>);
|
|
1287
|
+
if (preAuthElements.length === 0) {
|
|
1288
|
+
logger.info({ url: finalUrl }, '[crawler] pre-auth page has 0 elements — deferring to authenticated BFS');
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
const pathParts = finalPath.split('/').filter(Boolean);
|
|
1292
|
+
const screenName = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
1293
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
|
|
1294
|
+
if (!screen) continue;
|
|
1295
|
+
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
|
|
1296
|
+
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {});
|
|
1297
|
+
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => undefined);
|
|
1298
|
+
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl).catch(() => {});
|
|
1299
|
+
// Save elements found in the pre-auth view
|
|
1300
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
1301
|
+
for (const el of preAuthElements) {
|
|
1302
|
+
await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.label, el.role, el.expectedData, undefined, undefined, true, undefined, undefined, undefined).catch(() => {});
|
|
1303
|
+
}
|
|
1304
|
+
preAuthPublicCaptured.add(finalUrl);
|
|
1305
|
+
logger.info({ url: finalUrl, screenId: screen.id, elements: preAuthElements.length }, '[crawler] pre-auth public page captured');
|
|
1306
|
+
} catch (err: any) {
|
|
1307
|
+
logger.warn({ url: link, err: err?.message }, '[crawler] pre-auth public page visit failed — skipping');
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Navigate back to start so auth phase and fingerprint comparison are correct
|
|
1312
|
+
if (preAuthPublicCaptured.size > 0) {
|
|
1313
|
+
await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 20_000 }).catch(() => {});
|
|
1314
|
+
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1139
1318
|
// Dedicated logged-out/auth discovery phase. This runs before credentials or
|
|
1140
1319
|
// storageState are applied, so public pages and auth recovery flows remain
|
|
1141
1320
|
// visible even when the main crawl later becomes authenticated.
|
|
@@ -1147,6 +1326,18 @@ export async function crawlProject(opts: {
|
|
|
1147
1326
|
}
|
|
1148
1327
|
}
|
|
1149
1328
|
|
|
1329
|
+
// Auth discovery opens OAuth sign-in popups (Google, GitHub, etc.) which fire CDP events that
|
|
1330
|
+
// can permanently corrupt the Playwright isolated world. Verify the world is intact; if not,
|
|
1331
|
+
// re-navigate to appUrl to force a fresh execution context before BFS begins.
|
|
1332
|
+
{
|
|
1333
|
+
const worldOk = await page.evaluate(() => true).catch(() => false);
|
|
1334
|
+
if (!worldOk) {
|
|
1335
|
+
logger.warn('[crawler] isolated world corrupted after auth discovery — re-navigating to recover');
|
|
1336
|
+
await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
|
|
1337
|
+
await page.waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => {});
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1150
1341
|
let didLogin = false; // track whether AI login was actually performed
|
|
1151
1342
|
|
|
1152
1343
|
if (opts.authToken) {
|
|
@@ -1167,14 +1358,50 @@ export async function crawlProject(opts: {
|
|
|
1167
1358
|
};
|
|
1168
1359
|
|
|
1169
1360
|
if (state.cookies?.length) {
|
|
1170
|
-
//
|
|
1361
|
+
// Apply cookies to both the secondary Playwright context AND Stagehand's own context.
|
|
1362
|
+
// Stagehand V3 uses a separate browser context from the CDP secondary client, so
|
|
1363
|
+
// cookies added only to playwrightBrowser.contexts()[0] don't reach Stagehand's pages.
|
|
1171
1364
|
const browserCtx = playwrightBrowser?.contexts()[0];
|
|
1172
1365
|
if (browserCtx) {
|
|
1173
1366
|
await browserCtx.addCookies(state.cookies as any);
|
|
1174
1367
|
}
|
|
1368
|
+
// Also apply to Stagehand's context via its page proxy
|
|
1369
|
+
try {
|
|
1370
|
+
const stagehandCtx = (page as any).context?.();
|
|
1371
|
+
if (stagehandCtx && typeof stagehandCtx.addCookies === 'function') {
|
|
1372
|
+
await stagehandCtx.addCookies(state.cookies as any);
|
|
1373
|
+
}
|
|
1374
|
+
} catch { /* Stagehand V3 proxy may not expose context() */ }
|
|
1175
1375
|
}
|
|
1176
1376
|
|
|
1177
1377
|
if (state.origins?.length) {
|
|
1378
|
+
// Apply localStorage via addInitScript to ALL contexts:
|
|
1379
|
+
// 1. Secondary Playwright context (rawPage navigations)
|
|
1380
|
+
// 2. Stagehand's page directly (Stagehand V3 uses its own context separate from playwrightBrowser)
|
|
1381
|
+
// Without #2, Stagehand's BFS navigations never get localStorage → pages load unauthenticated.
|
|
1382
|
+
const originCtx = playwrightBrowser?.contexts()[0];
|
|
1383
|
+
if (originCtx) {
|
|
1384
|
+
await originCtx.addInitScript((origins: typeof state.origins) => {
|
|
1385
|
+
for (const entry of (origins ?? [])) {
|
|
1386
|
+
if (window.location.origin === entry.origin) {
|
|
1387
|
+
for (const item of entry.localStorage) {
|
|
1388
|
+
try { localStorage.setItem(item.name, item.value); } catch { /* quota/sandboxed */ }
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}, state.origins);
|
|
1393
|
+
}
|
|
1394
|
+
// Stagehand's page — covers all BFS navigations via Stagehand
|
|
1395
|
+
await page.addInitScript((origins: typeof state.origins) => {
|
|
1396
|
+
for (const entry of (origins ?? [])) {
|
|
1397
|
+
if (window.location.origin === entry.origin) {
|
|
1398
|
+
for (const item of entry.localStorage) {
|
|
1399
|
+
try { localStorage.setItem(item.name, item.value); } catch { /* quota/sandboxed */ }
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}, state.origins);
|
|
1404
|
+
// Also cover rawPage for the initial navigation below
|
|
1178
1405
|
await rawPage.addInitScript((origins: typeof state.origins) => {
|
|
1179
1406
|
for (const entry of (origins ?? [])) {
|
|
1180
1407
|
if (window.location.origin === entry.origin) {
|
|
@@ -1384,8 +1611,17 @@ export async function crawlProject(opts: {
|
|
|
1384
1611
|
[
|
|
1385
1612
|
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'ref', 'source', '_gl', 'fbclid', 'gclid',
|
|
1386
1613
|
'ts', 'timestamp', 'cache', 'cacheBust', 'cache_bust', '_', 'nonce', 'session', 'sid',
|
|
1614
|
+
// App content/state params — these change the content rendered within the same
|
|
1615
|
+
// page template (a playground example, a search query, a tab selection) but do NOT
|
|
1616
|
+
// represent structurally distinct pages. Not stripping them causes URL explosion:
|
|
1617
|
+
// every unique ?cmd=DSN-XXXXX&example=... or ?q=foo is queued as a new screen.
|
|
1618
|
+
'cmd', 'example', 'query', 'q', 'search', 'keyword', 'keywords',
|
|
1619
|
+
'tab', 'view', 'mode', 'sort', 'order', 'direction', 'orderby',
|
|
1620
|
+
'page', 'p', 'offset', 'limit', 'per_page', 'size', 'rows',
|
|
1621
|
+
// Export format params — same endpoint, different output format, not a new screen
|
|
1622
|
+
'format', 'output', 'export_format', 'download_format', 'fileformat', 'file_format',
|
|
1387
1623
|
// WooCommerce / e-commerce action params
|
|
1388
|
-
'add-to-cart', 'add_to_wishlist', 'quantity', 'wc-ajax',
|
|
1624
|
+
'add-to-cart', 'add_to_wishlist', 'quantity', 'wc-ajax',
|
|
1389
1625
|
'min_price', 'max_price', 'removed_item', 'restore', 'remove_item',
|
|
1390
1626
|
'undo_item', 'coupon_code', 'apply_coupon', 'added-to-cart',
|
|
1391
1627
|
'wc_error', 'wc_notice', 'wc_notice_type', 'update_cart',
|
|
@@ -1428,10 +1664,14 @@ export async function crawlProject(opts: {
|
|
|
1428
1664
|
const gitInfo = getGitInfo();
|
|
1429
1665
|
|
|
1430
1666
|
const visited = new Map<string, string>(); // key = canonical URL
|
|
1667
|
+
const crawlWarnings: string[] = []; // non-fatal step failures surfaced in job result
|
|
1431
1668
|
// Pre-populate from a previous failed job so already-captured URLs are skipped
|
|
1432
1669
|
if (opts.skipUrls?.length) {
|
|
1433
1670
|
for (const u of opts.skipUrls) visited.set(canonicalize(u), 'skipped');
|
|
1434
1671
|
}
|
|
1672
|
+
// Pre-auth public pages already captured above — mark visited so BFS (post-auth)
|
|
1673
|
+
// doesn't overwrite them with authenticated redirects showing app content instead.
|
|
1674
|
+
for (const u of preAuthPublicCaptured) visited.set(canonicalize(u), 'pre-auth');
|
|
1435
1675
|
let checkpointSuccessCount = 0;
|
|
1436
1676
|
let maxSeenTotal = 0; // track high-water mark so progress % never goes backwards
|
|
1437
1677
|
let lastEmittedPct = 20; // floor above auth-discovery + login phases (max ~19%)
|
|
@@ -1492,6 +1732,10 @@ export async function crawlProject(opts: {
|
|
|
1492
1732
|
.filter((s: any) => s.classification !== 'REACHABLE')
|
|
1493
1733
|
.map((s: any) => s.id as string),
|
|
1494
1734
|
);
|
|
1735
|
+
// Snapshot queued set BEFORE filter so canonical dedup works correctly during filter.
|
|
1736
|
+
// Without this, multiple prior screens with the same canonical (e.g. playground?cmd=X1,
|
|
1737
|
+
// playground?cmd=X2 → same canonical) all pass the filter since queued isn't updated yet.
|
|
1738
|
+
const seenReseedCanon = new Set<string>(queued);
|
|
1495
1739
|
const priorScreens = (priorMap?.screens ?? [])
|
|
1496
1740
|
.filter((s: any) => s.status !== 'PENDING_REMOVAL')
|
|
1497
1741
|
.filter((s: any) => {
|
|
@@ -1503,10 +1747,15 @@ export async function crawlProject(opts: {
|
|
|
1503
1747
|
if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
|
|
1504
1748
|
// Don't re-seed generated dynamic routes — they produce duplicate templates.
|
|
1505
1749
|
if (isGeneratedDynamicPath(pathname)) return false;
|
|
1750
|
+
// Don't re-seed API/export/download paths — they were misidentified as screens.
|
|
1751
|
+
if (!isUsefulNavigationUrl(u, startUrl)) return false;
|
|
1752
|
+
// Canonical dedup: mark seen immediately during filter so duplicates are caught.
|
|
1753
|
+
const canon = canonicalize(u);
|
|
1754
|
+
if (seenReseedCanon.has(canon)) return false;
|
|
1755
|
+
seenReseedCanon.add(canon);
|
|
1506
1756
|
return true;
|
|
1507
1757
|
} catch { return false; }
|
|
1508
|
-
})
|
|
1509
|
-
.filter((s: any) => !queued.has(canonicalize(s.url as string)));
|
|
1758
|
+
});
|
|
1510
1759
|
let orphanSeedCount = 0;
|
|
1511
1760
|
for (const s of priorScreens) {
|
|
1512
1761
|
const u = s.url as string;
|
|
@@ -1568,6 +1817,7 @@ export async function crawlProject(opts: {
|
|
|
1568
1817
|
try {
|
|
1569
1818
|
const u = new URL(sUrl);
|
|
1570
1819
|
if (u.origin !== sitemapOrigin) continue;
|
|
1820
|
+
if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) continue;
|
|
1571
1821
|
await page.goto(sUrl, { waitUntil: 'domcontentloaded', timeout: 12_000 });
|
|
1572
1822
|
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => { });
|
|
1573
1823
|
await waitForStableDOM(page);
|
|
@@ -1652,6 +1902,9 @@ export async function crawlProject(opts: {
|
|
|
1652
1902
|
// that slip through canonicalize() if any new filter param type is encountered.
|
|
1653
1903
|
let totalAttemptsThisSegment = 0;
|
|
1654
1904
|
const maxAttempts = Math.max(segmentSize * 4, 200);
|
|
1905
|
+
// Count consecutive login-wall redirects — if too many, abort early (auth-wall site, no creds)
|
|
1906
|
+
let loginWallRedirectCount = 0;
|
|
1907
|
+
const LOGIN_WALL_ABORT_THRESHOLD = Math.max(8, Math.ceil(segmentSize * 0.4));
|
|
1655
1908
|
|
|
1656
1909
|
let pendingSessionRestore: (() => Promise<void>) | null = null;
|
|
1657
1910
|
|
|
@@ -1675,13 +1928,15 @@ export async function crawlProject(opts: {
|
|
|
1675
1928
|
try {
|
|
1676
1929
|
const { pathname } = new URL(url);
|
|
1677
1930
|
if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
|
|
1931
|
+
// Skip API/export paths — these return data files, not navigable pages
|
|
1932
|
+
if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
|
|
1678
1933
|
} catch { continue; }
|
|
1679
1934
|
|
|
1680
1935
|
// Claim URL before first await — prevents duplicate processing across parallel workers
|
|
1681
1936
|
visited.set(canonUrl, '_pending_');
|
|
1682
1937
|
|
|
1683
1938
|
try {
|
|
1684
|
-
const navErr = await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout:
|
|
1939
|
+
const navErr = await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: tuning.navTimeoutMs }).then(() => null as null, (e: any) => e);
|
|
1685
1940
|
if (navErr && /timeout/i.test(String((navErr as any)?.message ?? navErr))) {
|
|
1686
1941
|
await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
|
|
1687
1942
|
}
|
|
@@ -1689,6 +1944,28 @@ export async function crawlProject(opts: {
|
|
|
1689
1944
|
// Wait for SPA JS to render components before extracting elements or running soft-404 check.
|
|
1690
1945
|
// domcontentloaded fires before JS executes — networkidle ensures React/Vue/Angular hydration.
|
|
1691
1946
|
await workerPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
|
|
1947
|
+
// Wait for DOM to stop mutating (React/Vue render cycles) before element extraction.
|
|
1948
|
+
await waitForStableDOM(workerPage, 6_000, 400).catch(() => {});
|
|
1949
|
+
// Dismiss cookie banners / GDPR overlays so underlying elements are accessible.
|
|
1950
|
+
await dismissOverlays(workerPage).catch(() => {});
|
|
1951
|
+
|
|
1952
|
+
// Auth wall detection: if navigation redirected to a login page, skip this URL.
|
|
1953
|
+
// Lite worker cannot re-authenticate (no Stagehand access) — mark and skip.
|
|
1954
|
+
const lwCurrentUrl = workerPage.url();
|
|
1955
|
+
const lwPath = (() => { try { return new URL(lwCurrentUrl).pathname.toLowerCase(); } catch { return ''; } })();
|
|
1956
|
+
const lwIsAuthWall = /^\/(login|signin|sign-in|auth|authenticate|session\/new|account\/login|user\/login)/.test(lwPath) && lwCurrentUrl !== url;
|
|
1957
|
+
const lwHasLoginForm = lwIsAuthWall ? false : await workerPage.evaluate(() => {
|
|
1958
|
+
const inputs = document.querySelectorAll('input[type="password"]');
|
|
1959
|
+
return inputs.length > 0;
|
|
1960
|
+
}).catch(() => false);
|
|
1961
|
+
if (lwIsAuthWall) {
|
|
1962
|
+
logger.warn({ url, landed: lwCurrentUrl }, '[lite-worker] auth wall detected — queuing for main worker re-auth');
|
|
1963
|
+
visited.set(canonUrl, '_auth_wall_');
|
|
1964
|
+
if (!revisitQueue.includes(url)) revisitQueue.push(url);
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
// lwHasLoginForm (direct sign-in/register page): process normally — capture the form elements.
|
|
1968
|
+
// Do NOT add to revisitQueue; P3a cannot do anything useful with a login page revisit.
|
|
1692
1969
|
|
|
1693
1970
|
// Soft-404 detection for lite-worker: skip SPA catch-all URLs that mirror homepage
|
|
1694
1971
|
if (homeFingerprintElemCount >= 0 && url !== startUrl) {
|
|
@@ -1713,8 +1990,23 @@ export async function crawlProject(opts: {
|
|
|
1713
1990
|
|
|
1714
1991
|
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
|
|
1715
1992
|
|
|
1993
|
+
let liteElements = await domFallbackElements(workerPage).catch(() => [] as Awaited<ReturnType<typeof domFallbackElements>>);
|
|
1994
|
+
if (liteElements.length === 0) {
|
|
1995
|
+
// domFallbackElements returned nothing — try broader visible-element selector as last resort.
|
|
1996
|
+
liteElements = await workerPage.evaluate(() => {
|
|
1997
|
+
const sel = 'button,a[href],input,select,textarea,[role],[tabindex="0"],[onclick],[data-action]';
|
|
1998
|
+
return Array.from(document.querySelectorAll(sel))
|
|
1999
|
+
.filter((el: any) => el.offsetParent !== null)
|
|
2000
|
+
.slice(0, 150)
|
|
2001
|
+
.map((el: any) => ({
|
|
2002
|
+
meaning: el.getAttribute('aria-label') || el.textContent?.trim().slice(0, 100) || el.tagName.toLowerCase(),
|
|
2003
|
+
role: el.getAttribute('role') || el.tagName.toLowerCase(),
|
|
2004
|
+
expectedData: null, notes: null, boundingRect: null, ariaState: null, parentLandmark: null,
|
|
2005
|
+
}));
|
|
2006
|
+
}).catch(() => []) as any[];
|
|
2007
|
+
}
|
|
1716
2008
|
const [elements] = await Promise.all([
|
|
1717
|
-
|
|
2009
|
+
Promise.resolve(liteElements),
|
|
1718
2010
|
workerPage.screenshot({ path: shotPath, fullPage: true })
|
|
1719
2011
|
.then(async () => {
|
|
1720
2012
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
@@ -1723,25 +2015,137 @@ export async function crawlProject(opts: {
|
|
|
1723
2015
|
.catch(() => {}),
|
|
1724
2016
|
]);
|
|
1725
2017
|
|
|
2018
|
+
// Nav-link pass — merged into main save cycle so clearScreenElements covers them too.
|
|
2019
|
+
// extractElements (main BFS) never runs for lite-worker screens, so nav/footer roles
|
|
2020
|
+
// must be captured here. Running before clear+save prevents additive accumulation.
|
|
2021
|
+
const _navEls = await (workerPage.evaluate as any)(() => {
|
|
2022
|
+
if (typeof (globalThis as any).__name === 'undefined') (globalThis as any).__name = (fn: any) => fn;
|
|
2023
|
+
const seen = new Set<string>();
|
|
2024
|
+
const items: Array<{meaning: string; role: string; notes: string}> = [];
|
|
2025
|
+
function isV(el: Element) {
|
|
2026
|
+
const s = window.getComputedStyle(el);
|
|
2027
|
+
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
2028
|
+
}
|
|
2029
|
+
document.querySelectorAll('nav a, [role="navigation"] a, header a, footer nav a, [aria-label*="navigation" i] a').forEach((a: any) => {
|
|
2030
|
+
if (!isV(a)) return;
|
|
2031
|
+
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
2032
|
+
const href = a.href ?? '';
|
|
2033
|
+
if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
|
|
2034
|
+
let landmark = 'Site Navigation';
|
|
2035
|
+
let cur: any = a.parentElement;
|
|
2036
|
+
while (cur) {
|
|
2037
|
+
const t = cur.tagName?.toLowerCase();
|
|
2038
|
+
const r = cur.getAttribute?.('role')?.toLowerCase();
|
|
2039
|
+
if (t === 'header' || r === 'banner') { landmark = 'Header'; break; }
|
|
2040
|
+
if (t === 'footer' || r === 'contentinfo') { landmark = 'Footer'; break; }
|
|
2041
|
+
if (t === 'nav' || r === 'navigation') { landmark = cur.getAttribute('aria-label')?.trim() || 'Site Navigation'; break; }
|
|
2042
|
+
cur = cur.parentElement;
|
|
2043
|
+
}
|
|
2044
|
+
const key = `nav|${label.toLowerCase()}|${href}`;
|
|
2045
|
+
if (!seen.has(key)) { seen.add(key); items.push({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark }) }); }
|
|
2046
|
+
});
|
|
2047
|
+
let fc = 0;
|
|
2048
|
+
document.querySelectorAll('footer a, [role="contentinfo"] a, [class*="footer" i] a').forEach((a: any) => {
|
|
2049
|
+
if (fc >= 20 || !isV(a)) return;
|
|
2050
|
+
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
2051
|
+
const href = a.href ?? '';
|
|
2052
|
+
if (!label || !href) return;
|
|
2053
|
+
const key = `footer|${label.toLowerCase()}|${href}`;
|
|
2054
|
+
if (!seen.has(key)) { seen.add(key); items.push({ meaning: label, role: 'footer-link', notes: JSON.stringify({ href: href.slice(0, 150) }) }); fc++; }
|
|
2055
|
+
});
|
|
2056
|
+
return items;
|
|
2057
|
+
}).catch(() => [] as any[]);
|
|
2058
|
+
|
|
2059
|
+
const allElements = [
|
|
2060
|
+
...elements,
|
|
2061
|
+
..._navEls.map((ne: any) => ({ ...ne, expectedData: undefined, boundingRect: undefined, ariaState: undefined, parentLandmark: undefined })),
|
|
2062
|
+
];
|
|
2063
|
+
|
|
2064
|
+
// Persist lite-worker elements to DB — without this, 80% of screens have 0 DB elements.
|
|
2065
|
+
if (allElements.length > 0) {
|
|
2066
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
2067
|
+
for (let _ei = 0; _ei < allElements.length; _ei += 4) {
|
|
2068
|
+
const _batch = allElements.slice(_ei, _ei + 4);
|
|
2069
|
+
await Promise.all(_batch.map((el: any) =>
|
|
2070
|
+
AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {})
|
|
2071
|
+
));
|
|
2072
|
+
}
|
|
2073
|
+
logger.info({ url, screenId: screen.id, elements: elements.length, navLinks: _navEls.length }, '[lite-worker] screen captured');
|
|
2074
|
+
} else if (!revisitQueue.includes(url)) {
|
|
2075
|
+
// 0-element: queue for main BFS re-visit with full Stagehand AI extraction
|
|
2076
|
+
logger.warn({ url, screenId: screen.id }, '[lite-worker] 0 elements — queued for P3a revisit');
|
|
2077
|
+
revisitQueue.push(url);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// Non-mutating probes for lite-worker screens: shadow DOM, a11y tree, service worker.
|
|
2081
|
+
// Awaited with 15s total timeout — these are read-only and safe on the shared worker page.
|
|
2082
|
+
if (screen?.id) {
|
|
2083
|
+
const [shadowRes, a11yRes, swRes] = await Promise.race([
|
|
2084
|
+
Promise.allSettled([
|
|
2085
|
+
crawlShadowDom(workerPage),
|
|
2086
|
+
crawlAccessibilityTree(workerPage),
|
|
2087
|
+
inspectServiceWorker(workerPage),
|
|
2088
|
+
]),
|
|
2089
|
+
new Promise<PromiseSettledResult<never>[]>((resolve) =>
|
|
2090
|
+
setTimeout(() => resolve([
|
|
2091
|
+
{ status: 'rejected' as const, reason: 'timeout' },
|
|
2092
|
+
{ status: 'rejected' as const, reason: 'timeout' },
|
|
2093
|
+
{ status: 'rejected' as const, reason: 'timeout' },
|
|
2094
|
+
]), 15_000)
|
|
2095
|
+
),
|
|
2096
|
+
]);
|
|
2097
|
+
const litePayload: Record<string, unknown> = { screenId: screen.id, url };
|
|
2098
|
+
if (shadowRes.status === 'fulfilled' && shadowRes.value && (shadowRes.value as any).shadowHostCount > 0)
|
|
2099
|
+
litePayload.shadowDom = shadowRes.value;
|
|
2100
|
+
if (a11yRes.status === 'fulfilled' && a11yRes.value)
|
|
2101
|
+
litePayload.accessibilityTree = a11yRes.value;
|
|
2102
|
+
if (swRes.status === 'fulfilled' && (swRes.value as any).supported)
|
|
2103
|
+
litePayload.serviceWorker = swRes.value;
|
|
2104
|
+
if (Object.keys(litePayload).length > 2) {
|
|
2105
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2106
|
+
jobId: opts.jobId, type: 'PER_SCREEN_PROBES', payload: litePayload,
|
|
2107
|
+
}).catch(() => {});
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
|
|
1726
2111
|
const nextLinks: string[] = await (workerPage.evaluate as any)((originStr: string) => {
|
|
1727
|
-
return Array.from(document.querySelectorAll('a[href]'))
|
|
2112
|
+
return Array.from(document.querySelectorAll('a[href],[data-href]'))
|
|
1728
2113
|
.map((a: any) => {
|
|
1729
|
-
|
|
2114
|
+
const raw = a.getAttribute('href') || a.getAttribute('data-href') || '';
|
|
2115
|
+
try { return new URL(raw, window.location.href).href; }
|
|
1730
2116
|
catch { return ''; }
|
|
1731
2117
|
})
|
|
1732
|
-
.filter((h: string) =>
|
|
2118
|
+
.filter((h: string) => {
|
|
2119
|
+
if (!h) return false;
|
|
2120
|
+
try {
|
|
2121
|
+
const u = new URL(h);
|
|
2122
|
+
if (u.origin !== originStr) return false;
|
|
2123
|
+
// Keep hash-router paths (#/route, #!/route) but drop plain fragment anchors (#section)
|
|
2124
|
+
if (u.hash && !u.hash.startsWith('#/') && !u.hash.startsWith('#!/')) return false;
|
|
2125
|
+
return true;
|
|
2126
|
+
} catch { return false; }
|
|
2127
|
+
})
|
|
1733
2128
|
.slice(0, 200);
|
|
1734
2129
|
}, origin).catch(() => []);
|
|
1735
2130
|
|
|
1736
2131
|
for (const link of nextLinks) {
|
|
2132
|
+
if (!isUsefulNavigationUrl(link, origin)) continue;
|
|
1737
2133
|
const cLink = canonicalize(link);
|
|
1738
|
-
if (!visited.has(cLink)) {
|
|
2134
|
+
if (!visited.has(cLink) && !queued.has(cLink)) {
|
|
2135
|
+
queued.add(cLink);
|
|
1739
2136
|
queue.push(link);
|
|
1740
2137
|
edges.push({ fromUrl: url, toUrl: link });
|
|
1741
2138
|
}
|
|
1742
2139
|
}
|
|
1743
2140
|
|
|
1744
2141
|
discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
|
|
2142
|
+
// Lite workers also advance the progress bar — without this 4/5 workers are invisible.
|
|
2143
|
+
const lwEffMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
|
|
2144
|
+
maxSeenTotal = Math.max(maxSeenTotal, lwEffMax);
|
|
2145
|
+
const lwRawPct = 20 + Math.round(Math.min(57, (visited.size / Math.max(1, maxSeenTotal)) * 57));
|
|
2146
|
+
const lwPct = Math.max(lastEmittedPct, lwRawPct);
|
|
2147
|
+
lastEmittedPct = lwPct;
|
|
2148
|
+
await onProgress?.(lwPct, `Crawling screens (${visited.size} mapped, ${queue.length} pending)`).catch(() => {});
|
|
1745
2149
|
} catch (err: any) {
|
|
1746
2150
|
logger.warn({ url, err: err?.message ?? String(err) }, '[lite-worker] URL processing failed');
|
|
1747
2151
|
discovered.push({ url, error: err?.message ?? String(err) });
|
|
@@ -1750,12 +2154,12 @@ export async function crawlProject(opts: {
|
|
|
1750
2154
|
}
|
|
1751
2155
|
|
|
1752
2156
|
const liteWorkerPromises: Promise<void>[] = [];
|
|
1753
|
-
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
|
|
2157
|
+
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser && !opts.discoveryMode) {
|
|
1754
2158
|
const pwCtx = playwrightBrowser.contexts()[0];
|
|
1755
2159
|
for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
|
|
1756
2160
|
const wp = await pwCtx.newPage();
|
|
1757
2161
|
await wp.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); }).catch(() => {});
|
|
1758
|
-
wp.setDefaultTimeout(
|
|
2162
|
+
wp.setDefaultTimeout(tuning.defaultTimeoutMs || 45_000);
|
|
1759
2163
|
liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {})));
|
|
1760
2164
|
}
|
|
1761
2165
|
}
|
|
@@ -1781,11 +2185,14 @@ export async function crawlProject(opts: {
|
|
|
1781
2185
|
|
|
1782
2186
|
const url = queue.shift()!;
|
|
1783
2187
|
const canonUrl = canonicalize(url);
|
|
1784
|
-
logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size + 1}/${segmentSize}` }, '[crawler] visiting URL');
|
|
1785
2188
|
const apiObservationStartIndex = capturedApiEndpoints.length;
|
|
1786
2189
|
|
|
1787
2190
|
// Skip if already visited (canonical match — catches ID-parameterized duplicates)
|
|
1788
2191
|
if (visited.has(canonUrl)) continue;
|
|
2192
|
+
// Claim immediately before any await — prevents lite workers from picking up the same URL
|
|
2193
|
+
// concurrently while this iteration is suspended (race that causes repeated visits).
|
|
2194
|
+
visited.set(canonUrl, '_pending_');
|
|
2195
|
+
logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size}/${segmentSize}` }, '[crawler] visiting URL');
|
|
1789
2196
|
|
|
1790
2197
|
// robots.txt compliance — skip disallowed URLs
|
|
1791
2198
|
if (robotsCache && !await robotsCache.isAllowed(url)) {
|
|
@@ -1807,6 +2214,13 @@ export async function crawlProject(opts: {
|
|
|
1807
2214
|
console.log(`[crawler] skip generated dynamic route: ${url}`);
|
|
1808
2215
|
continue;
|
|
1809
2216
|
}
|
|
2217
|
+
// Skip API/export/download paths — navigating to these triggers file downloads.
|
|
2218
|
+
// The main BFS uses Stagehand's context which has no route interception.
|
|
2219
|
+
if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) {
|
|
2220
|
+
console.log(`[crawler] skip API/export/download path: ${url}`);
|
|
2221
|
+
visited.set(canonUrl, '_skipped_api_');
|
|
2222
|
+
continue;
|
|
2223
|
+
}
|
|
1810
2224
|
} catch { /* invalid url, let it fall through */ }
|
|
1811
2225
|
|
|
1812
2226
|
try {
|
|
@@ -1890,7 +2304,7 @@ export async function crawlProject(opts: {
|
|
|
1890
2304
|
}
|
|
1891
2305
|
|
|
1892
2306
|
// Try fast 15 s first; only escalate to 30 s on timeout so happy-path pages don't wait
|
|
1893
|
-
const navErr15 = await page.goto(url, { waitUntil: 'domcontentloaded', timeout:
|
|
2307
|
+
const navErr15 = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: tuning.navTimeoutMs }).then(() => null, (e: any) => e);
|
|
1894
2308
|
if (navErr15) {
|
|
1895
2309
|
if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
|
|
1896
2310
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
|
|
@@ -1902,13 +2316,18 @@ export async function crawlProject(opts: {
|
|
|
1902
2316
|
}
|
|
1903
2317
|
rawPage.off('response', onRedirectResponse);
|
|
1904
2318
|
if (redirectHops.length > 0) {
|
|
2319
|
+
const isCircularRedirect = new Set(redirectHops.map(h => h.from)).size < redirectHops.length;
|
|
1905
2320
|
redirectRegistry.register({
|
|
1906
2321
|
originalUrl: url, finalUrl: page.url(),
|
|
1907
2322
|
hops: redirectHops.map((h, i) => ({ ...h, durationMs: i === 0 ? Date.now() - navStart : 0 })),
|
|
1908
|
-
isCircular:
|
|
2323
|
+
isCircular: isCircularRedirect,
|
|
1909
2324
|
hopCount: redirectHops.length,
|
|
1910
2325
|
totalDurationMs: Date.now() - navStart,
|
|
1911
2326
|
});
|
|
2327
|
+
if (isCircularRedirect) {
|
|
2328
|
+
logger.warn({ url, hops: redirectHops.map(h => h.from) }, '[crawler] circular redirect detected — skipping URL');
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
1912
2331
|
}
|
|
1913
2332
|
// Update exponential moving average for AutoThrottle
|
|
1914
2333
|
const navMs = Date.now() - navStart;
|
|
@@ -1927,8 +2346,54 @@ export async function crawlProject(opts: {
|
|
|
1927
2346
|
// 3. Modal capture — screenshot app dialogs BEFORE dismissal (they are testable UI states)
|
|
1928
2347
|
// 4. Overlay dismissal — removes cookie banners/modals before element extraction
|
|
1929
2348
|
// 5. Lazy-load scroll — triggers IntersectionObserver for off-screen images/components
|
|
1930
|
-
await page.waitForLoadState('load', { timeout: 8_000 }).catch(() => { });
|
|
1931
|
-
await waitForStableDOM(page);
|
|
2349
|
+
await (rawPage ?? page).waitForLoadState('load', { timeout: 8_000 }).catch(() => { });
|
|
2350
|
+
await waitForStableDOM(page, 8_000, 400, rawPage ?? undefined);
|
|
2351
|
+
// If DOM is sparse after stability check (< 15 interactive elements), SPA may still be
|
|
2352
|
+
// fetching data — skeleton is DOM-stable but empty. Extra networkidle pass catches it.
|
|
2353
|
+
const sparseCheck = await (rawPage ?? page).evaluate(() =>
|
|
2354
|
+
document.querySelectorAll('button,a,input,select,textarea,[role="button"],[aria-label]').length
|
|
2355
|
+
).catch(() => 99);
|
|
2356
|
+
if (sparseCheck < 15) {
|
|
2357
|
+
await (rawPage ?? page).waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => {});
|
|
2358
|
+
await waitForStableDOM(page, 4_000, 300, rawPage ?? undefined);
|
|
2359
|
+
}
|
|
2360
|
+
// Wait for SPA auth-check / loading spinners to resolve before screenshotting.
|
|
2361
|
+
// SPAs often show a "Checking authentication..." or "Loading..." state after networkidle
|
|
2362
|
+
// while client-side auth verifies tokens. waitForStableDOM catches DOM mutations but
|
|
2363
|
+
// returns as soon as the spinner DOM is stable — this catches the spinner itself.
|
|
2364
|
+
// Stagehand V3 proxy may not expose waitForFunction — use try/catch, not .catch(),
|
|
2365
|
+
// because calling an undefined method throws synchronously before a Promise is created.
|
|
2366
|
+
try {
|
|
2367
|
+
const spinnerPage = rawPage ?? page;
|
|
2368
|
+
if (typeof spinnerPage.waitForFunction === 'function') {
|
|
2369
|
+
await spinnerPage.waitForFunction(() => {
|
|
2370
|
+
const body = document.body;
|
|
2371
|
+
if (!body) return true;
|
|
2372
|
+
const text = body.innerText?.toLowerCase() ?? '';
|
|
2373
|
+
// Text-based loading indicators
|
|
2374
|
+
if (
|
|
2375
|
+
text.includes('checking authentication') ||
|
|
2376
|
+
text.includes('checking auth') ||
|
|
2377
|
+
text.includes('authenticating') ||
|
|
2378
|
+
text.includes('verifying session') ||
|
|
2379
|
+
text.includes('please wait') ||
|
|
2380
|
+
text.includes('loading data') ||
|
|
2381
|
+
(text.includes('loading') && text.length < 200)
|
|
2382
|
+
) return false;
|
|
2383
|
+
// ARIA-based: aria-busy on any visible element
|
|
2384
|
+
const busyEl = body.querySelector('[aria-busy="true"]');
|
|
2385
|
+
if (busyEl && (busyEl as HTMLElement).offsetParent !== null) return false;
|
|
2386
|
+
// Data attributes: React Query / SWR / TanStack in-flight indicators
|
|
2387
|
+
if (body.querySelector('[data-loading="true"],[data-pending="true"],[data-fetching="true"]')) return false;
|
|
2388
|
+
// Indeterminate progress bars
|
|
2389
|
+
if (body.querySelector('progress:not([value])')) return false;
|
|
2390
|
+
// Common CSS class patterns for loading skeletons
|
|
2391
|
+
const skeleton = body.querySelector('.skeleton,.skeleton-loader,.shimmer,.pulse-loader,.content-loader');
|
|
2392
|
+
if (skeleton && (skeleton as HTMLElement).offsetParent !== null) return false;
|
|
2393
|
+
return true;
|
|
2394
|
+
}, { timeout: 12_000 });
|
|
2395
|
+
}
|
|
2396
|
+
} catch { /* timed out or unsupported — screenshot whatever is there */ }
|
|
1932
2397
|
|
|
1933
2398
|
if (!opts.discoveryMode) {
|
|
1934
2399
|
// Capture app-generated modals BEFORE dismissing — cookie/GDPR banners excluded.
|
|
@@ -1946,20 +2411,25 @@ export async function crawlProject(opts: {
|
|
|
1946
2411
|
const modalHash = url.replace(/[^a-z0-9]/gi, '').slice(-12);
|
|
1947
2412
|
const modalShotPath = path.join(opts.screenshotDir ?? '/tmp', `modal-${modalHash}.png`);
|
|
1948
2413
|
await page.screenshot({ path: modalShotPath, fullPage: false }).catch(() => { });
|
|
1949
|
-
const modalName = await page.evaluate(() => {
|
|
2414
|
+
const modalName = await (rawPage ?? page).evaluate(() => {
|
|
1950
2415
|
const h = document.querySelector('[role="dialog"] h1,[role="dialog"] h2,[role="dialog"] [role="heading"]');
|
|
1951
2416
|
return h?.textContent?.trim() ?? 'Modal';
|
|
1952
2417
|
}).catch(() => 'Modal');
|
|
1953
2418
|
const modalScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, modalName, url, opts.jobId, discoveryMethodFor(url)).catch(() => null);
|
|
1954
2419
|
if (modalScreen) {
|
|
1955
2420
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, 'DESKTOP').catch(() => { });
|
|
1956
|
-
|
|
2421
|
+
// Extract elements from the modal BEFORE it is dismissed — use CDP path via rawPage
|
|
2422
|
+
const modalElements = await domFallbackElements(page, rawPage ?? undefined).catch(() => []);
|
|
2423
|
+
for (const el of modalElements) {
|
|
2424
|
+
AppBrain.saveElement(opts.tenantId, opts.projectId, modalScreen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {});
|
|
2425
|
+
}
|
|
2426
|
+
logger.info({ url, name: modalName, elementCount: modalElements.length }, '[crawler] captured app modal before dismiss');
|
|
1957
2427
|
}
|
|
1958
2428
|
}
|
|
1959
2429
|
} catch { /* non-fatal */ }
|
|
1960
2430
|
await dismissOverlays(page);
|
|
1961
2431
|
// Enhanced infinite scroll — handles lazy-load, infinite lists, and virtual scrollers
|
|
1962
|
-
await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }).catch(() => { });
|
|
2432
|
+
await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }, rawPage ?? undefined).catch(() => { });
|
|
1963
2433
|
}
|
|
1964
2434
|
if (cdpSession?.off) cdpSession.off('Network.responseReceived', secHeadersCdp);
|
|
1965
2435
|
wsDetector.detach();
|
|
@@ -1990,6 +2460,22 @@ export async function crawlProject(opts: {
|
|
|
1990
2460
|
BFS_ERROR_PATH.test(new URL(landedUrl).pathname) || BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)
|
|
1991
2461
|
);
|
|
1992
2462
|
if (redirectedToError) {
|
|
2463
|
+
// Track login-wall redirects: if the site sends every URL to /login without credentials,
|
|
2464
|
+
// abort early rather than burning all maxAttempts on the same login page.
|
|
2465
|
+
if (BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)) {
|
|
2466
|
+
loginWallRedirectCount++;
|
|
2467
|
+
if (!opts.credentials && loginWallRedirectCount >= LOGIN_WALL_ABORT_THRESHOLD) {
|
|
2468
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2469
|
+
jobId: opts.jobId,
|
|
2470
|
+
type: 'AUTH_FAILURE',
|
|
2471
|
+
payload: {
|
|
2472
|
+
url, landed: landedUrl,
|
|
2473
|
+
reason: `Login wall detected: ${loginWallRedirectCount} consecutive URLs redirected to login. Add credentials in project settings to crawl authenticated pages.`,
|
|
2474
|
+
},
|
|
2475
|
+
}).catch(() => {});
|
|
2476
|
+
throw new Error(`[AUTH_FAILURE] Login wall detected: ${loginWallRedirectCount} URLs redirected to the login page. Add credentials in project settings and retry the crawl.`);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
1993
2479
|
// We asked for /dashboard but landed on /session-expired — session expired mid-crawl.
|
|
1994
2480
|
logger.warn({ requested: url, landed: landedUrl }, '[crawler] Mid-crawl redirect to auth/error — attempting re-auth');
|
|
1995
2481
|
let reauthed = false;
|
|
@@ -2004,8 +2490,8 @@ export async function crawlProject(opts: {
|
|
|
2004
2490
|
if (reauthed) {
|
|
2005
2491
|
logger.info({ url }, '[crawler] Re-auth succeeded — retrying URL');
|
|
2006
2492
|
await page.goto(url, { waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
2007
|
-
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
2008
|
-
await waitForStableDOM(page);
|
|
2493
|
+
await (rawPage ?? page).waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
2494
|
+
await waitForStableDOM(page, 8_000, 400, rawPage ?? undefined);
|
|
2009
2495
|
const afterReauthUrl = page.url();
|
|
2010
2496
|
if (BFS_ERROR_PATH.test(new URL(afterReauthUrl).pathname) || BFS_LOGIN_PATH.test(new URL(afterReauthUrl).pathname)) {
|
|
2011
2497
|
logger.warn({ url }, '[crawler] Still redirecting after re-auth — skipping this URL');
|
|
@@ -2021,6 +2507,13 @@ export async function crawlProject(opts: {
|
|
|
2021
2507
|
throw new Error('[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.');
|
|
2022
2508
|
}
|
|
2023
2509
|
} else {
|
|
2510
|
+
// Discovery mode: skip auth-required URLs and continue discovering other links.
|
|
2511
|
+
// Throwing here aborts the entire BFS — in discovery mode we want to continue
|
|
2512
|
+
// visiting other queued URLs (e.g. sitemap seeds) that may not require auth.
|
|
2513
|
+
if (opts.discoveryMode) {
|
|
2514
|
+
logger.info({ url, landed: landedUrl }, '[discover] skipping auth-required URL — no credentials, continuing BFS');
|
|
2515
|
+
continue;
|
|
2516
|
+
}
|
|
2024
2517
|
logger.warn('[crawler] Session expired mid-crawl, no credentials to recover — skipping URL');
|
|
2025
2518
|
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2026
2519
|
jobId: opts.jobId,
|
|
@@ -2035,6 +2528,8 @@ export async function crawlProject(opts: {
|
|
|
2035
2528
|
if (!redirectedToError && landedUrl !== url) {
|
|
2036
2529
|
logger.info({ requested: url, landed: landedUrl }, '[crawler] navigated with redirect');
|
|
2037
2530
|
}
|
|
2531
|
+
// Successful non-login page visit: reset the consecutive login-wall counter
|
|
2532
|
+
if (!redirectedToError) loginWallRedirectCount = 0;
|
|
2038
2533
|
|
|
2039
2534
|
// Discovery mode fast-path: record URL + extract DOM links only; skip screenshots/DB/AI.
|
|
2040
2535
|
// IMPORTANT: DOM hash dedup is intentionally DISABLED in discovery mode — SPAs use identical
|
|
@@ -2182,8 +2677,10 @@ export async function crawlProject(opts: {
|
|
|
2182
2677
|
}
|
|
2183
2678
|
|
|
2184
2679
|
// Normal crawl mode: DOM hash dedup — detect duplicate-structure pages (SPA 404s, template clones).
|
|
2680
|
+
// Skip dedup when hash is empty (computeDomHash failed) — two failed-hash pages would
|
|
2681
|
+
// incorrectly match each other and the second would be silently dropped.
|
|
2185
2682
|
const preDomHash = await computeDomHash(page);
|
|
2186
|
-
if (seenDomHashes.has(preDomHash)) {
|
|
2683
|
+
if (preDomHash !== '' && seenDomHashes.has(preDomHash)) {
|
|
2187
2684
|
console.log(`[crawler] skip ${url} — duplicate DOM structure (same content as prior page)`);
|
|
2188
2685
|
continue;
|
|
2189
2686
|
}
|
|
@@ -2345,7 +2842,7 @@ export async function crawlProject(opts: {
|
|
|
2345
2842
|
|
|
2346
2843
|
// 2. Active nav item — SPAs set aria-current="page" on the active route link
|
|
2347
2844
|
try {
|
|
2348
|
-
const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout:
|
|
2845
|
+
const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
|
|
2349
2846
|
if (activeNav) {
|
|
2350
2847
|
const t = activeNav.trim();
|
|
2351
2848
|
if (t.length > 1 && t.length < 60 && t.toLowerCase() !== brandName) return t;
|
|
@@ -2359,7 +2856,7 @@ export async function crawlProject(opts: {
|
|
|
2359
2856
|
'main h2', '[role="main"] h2', '.page-header h2',
|
|
2360
2857
|
];
|
|
2361
2858
|
for (const sel of selectors) {
|
|
2362
|
-
const h = await page.locator(sel).first().textContent({ timeout:
|
|
2859
|
+
const h = await page.locator(sel).first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
|
|
2363
2860
|
if (!h) continue;
|
|
2364
2861
|
const t = h.trim();
|
|
2365
2862
|
if (t.length < 2 || t.length > 80) continue;
|
|
@@ -2455,31 +2952,33 @@ export async function crawlProject(opts: {
|
|
|
2455
2952
|
logger.info({ url, cachedCount: cachedLinks.length }, '[crawler] link-discovery: using cached recipe — AI will still run and merge');
|
|
2456
2953
|
}
|
|
2457
2954
|
|
|
2458
|
-
// Emit progress now — navigation
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2955
|
+
// Emit progress now — navigation done, page stable.
|
|
2956
|
+
// Use the same visited-count formula as the post-capture block so progress
|
|
2957
|
+
// advances on EVERY URL visit (not just on successful screen saves).
|
|
2958
|
+
{
|
|
2959
|
+
const effMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
|
|
2960
|
+
maxSeenTotal = Math.max(maxSeenTotal, effMax);
|
|
2961
|
+
const rawPrePct = 20 + Math.round(Math.min(57, (visited.size / Math.max(1, maxSeenTotal)) * 57));
|
|
2962
|
+
const preCapturePct = Math.max(lastEmittedPct, rawPrePct);
|
|
2963
|
+
lastEmittedPct = preCapturePct;
|
|
2964
|
+
await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} · ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
2968
|
+
|
|
2969
|
+
// P3a: Async content detection — count AFTER networkidle+lazy-load so lazy-fetched content
|
|
2970
|
+
// is included. Previously captured before waitForScreenshotReady which caused AI skip logic
|
|
2971
|
+
// to see skeleton element counts instead of real populated counts.
|
|
2471
2972
|
const preDomCount = await page.evaluate(() =>
|
|
2472
2973
|
document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
|
|
2473
2974
|
).catch(() => 0);
|
|
2474
|
-
|
|
2475
|
-
await waitForScreenshotReady(page);
|
|
2476
2975
|
await onProgress?.(lastEmittedPct, `Capturing ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
|
|
2477
2976
|
|
|
2478
2977
|
// FM-4: capture device pixel ratio for coordinate scaling in element highlighting
|
|
2479
|
-
const pageDpr: number = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
|
|
2978
|
+
const pageDpr: number = await (rawPage ?? page).evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
|
|
2480
2979
|
|
|
2481
2980
|
// Extract elements first so we can highlight them in the screenshot
|
|
2482
|
-
const elements = await extractElements(stagehand, page, elementLLM ?? undefined).catch((err: any) => {
|
|
2981
|
+
const elements = await extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch((err: any) => {
|
|
2483
2982
|
console.error('[crawler] extractElements failed:', err);
|
|
2484
2983
|
return [] as Awaited<ReturnType<typeof extractElements>>;
|
|
2485
2984
|
});
|
|
@@ -2538,8 +3037,8 @@ export async function crawlProject(opts: {
|
|
|
2538
3037
|
.catch((err: any) => console.error('[crawler] page.screenshot failed:', err)),
|
|
2539
3038
|
// Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
|
|
2540
3039
|
// Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
|
|
2541
|
-
//
|
|
2542
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0
|
|
3040
|
+
// Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
|
|
3041
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined)
|
|
2543
3042
|
.then((freshLinks) => {
|
|
2544
3043
|
if (aiSkipCount > 0) aiSkipCount--;
|
|
2545
3044
|
aiErrorStreak = 0;
|
|
@@ -2570,7 +3069,7 @@ export async function crawlProject(opts: {
|
|
|
2570
3069
|
}
|
|
2571
3070
|
return fallback;
|
|
2572
3071
|
}),
|
|
2573
|
-
readScreen(page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>),
|
|
3072
|
+
readScreen(rawPage ?? page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>),
|
|
2574
3073
|
]);
|
|
2575
3074
|
|
|
2576
3075
|
// Cleanup highlights
|
|
@@ -2589,6 +3088,115 @@ export async function crawlProject(opts: {
|
|
|
2589
3088
|
}
|
|
2590
3089
|
}).catch(() => { });
|
|
2591
3090
|
|
|
3091
|
+
// Per-screen probes: split read-only (awaited, safe on main page) vs mutating (isolated page).
|
|
3092
|
+
// READ-ONLY probes are awaited before BFS navigates — DOM must reflect current screen.
|
|
3093
|
+
// MUTATING probes (dropdowns/CRUD/filters/advanced) open a fresh page to isolate state changes.
|
|
3094
|
+
{
|
|
3095
|
+
// Resolve browser context from rawPage (actual Playwright page, not Stagehand proxy).
|
|
3096
|
+
const safeContext: import('playwright').BrowserContext | null =
|
|
3097
|
+
typeof (rawPage as any)?.context === 'function'
|
|
3098
|
+
? (rawPage as any).context()
|
|
3099
|
+
: typeof (page as any)?.context === 'function'
|
|
3100
|
+
? (page as any).context()
|
|
3101
|
+
: playwrightBrowser?.contexts()[0] ?? null;
|
|
3102
|
+
|
|
3103
|
+
// --- Read-only probes: awaited with 25s timeout, never mutate page state ---
|
|
3104
|
+
const roTimeout = new Promise<PromiseSettledResult<never>[]>((resolve) =>
|
|
3105
|
+
setTimeout(() => resolve([
|
|
3106
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3107
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3108
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3109
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3110
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3111
|
+
]), 25_000)
|
|
3112
|
+
);
|
|
3113
|
+
const [nestedCtxRes, shadowRes, a11yRes, perfRes, swRes] = await Promise.race([
|
|
3114
|
+
Promise.allSettled([
|
|
3115
|
+
crawlNestedContexts(rawPage ?? page, safeContext, url),
|
|
3116
|
+
crawlShadowDom(rawPage ?? page),
|
|
3117
|
+
crawlAccessibilityTree(rawPage ?? page),
|
|
3118
|
+
collectPerfMetrics(rawPage ?? page, url),
|
|
3119
|
+
inspectServiceWorker(rawPage ?? page),
|
|
3120
|
+
]),
|
|
3121
|
+
roTimeout,
|
|
3122
|
+
]);
|
|
3123
|
+
|
|
3124
|
+
const roPayload: Record<string, unknown> = { screenId: screen.id, url };
|
|
3125
|
+
if (nestedCtxRes.status === 'fulfilled' && nestedCtxRes.value && (nestedCtxRes.value as any).totalContextsFound > 0)
|
|
3126
|
+
roPayload.nestedContexts = nestedCtxRes.value;
|
|
3127
|
+
if (shadowRes.status === 'fulfilled' && shadowRes.value && (shadowRes.value as any).shadowHostCount > 0)
|
|
3128
|
+
roPayload.shadowDom = shadowRes.value;
|
|
3129
|
+
if (a11yRes.status === 'fulfilled' && a11yRes.value)
|
|
3130
|
+
roPayload.accessibilityTree = a11yRes.value;
|
|
3131
|
+
if (perfRes.status === 'fulfilled' && (perfRes.value as any).requestCount > 0)
|
|
3132
|
+
roPayload.performance = perfRes.value;
|
|
3133
|
+
if (swRes.status === 'fulfilled' && (swRes.value as any).supported)
|
|
3134
|
+
roPayload.serviceWorker = swRes.value;
|
|
3135
|
+
if (Object.keys(roPayload).length > 2) {
|
|
3136
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3137
|
+
jobId: opts.jobId, type: 'PER_SCREEN_PROBES', payload: roPayload,
|
|
3138
|
+
}).catch(() => {});
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
// --- Mutating probes (interactive): isolated page, fire-and-forget, only when interactive ---
|
|
3142
|
+
if (screen?.id && opts.interactive !== false && safeContext) {
|
|
3143
|
+
void (async () => {
|
|
3144
|
+
let probePage: import('playwright').Page | null = null;
|
|
3145
|
+
try {
|
|
3146
|
+
// Cap concurrent probe pages: if too many already open, skip to prevent resource exhaustion.
|
|
3147
|
+
const openProbePages = safeContext.pages().length;
|
|
3148
|
+
if (openProbePages > 8) {
|
|
3149
|
+
logger.warn({ url, openProbePages }, '[crawler] probe page cap reached — skipping mutating probes for this screen');
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
probePage = await safeContext.newPage();
|
|
3153
|
+
await probePage.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
|
|
3154
|
+
await probePage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
|
|
3155
|
+
await waitForStableDOM(probePage as any, 4_000, 400).catch(() => {});
|
|
3156
|
+
|
|
3157
|
+
const muTimeout = new Promise<PromiseSettledResult<never>[]>((resolve) =>
|
|
3158
|
+
setTimeout(() => resolve([
|
|
3159
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3160
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3161
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3162
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3163
|
+
]), 60_000)
|
|
3164
|
+
);
|
|
3165
|
+
const [dropEnumRes, crudRes, filterRes, advancedRes] = await Promise.race([
|
|
3166
|
+
Promise.allSettled([
|
|
3167
|
+
enumerateDropdowns(probePage),
|
|
3168
|
+
probeCrudFlows(probePage, name),
|
|
3169
|
+
crawlFiltersAndPagination(probePage),
|
|
3170
|
+
probeAdvancedUI(probePage),
|
|
3171
|
+
]),
|
|
3172
|
+
muTimeout,
|
|
3173
|
+
]);
|
|
3174
|
+
|
|
3175
|
+
const muPayload: Record<string, unknown> = { screenId: screen.id, url };
|
|
3176
|
+
if (dropEnumRes.status === 'fulfilled' && (dropEnumRes.value as any).totalOptionsFound > 0)
|
|
3177
|
+
muPayload.dropdowns = dropEnumRes.value;
|
|
3178
|
+
if (crudRes.status === 'fulfilled' && ((crudRes.value as any).flows?.length > 0 || (crudRes.value as any).toastsObserved?.length > 0))
|
|
3179
|
+
muPayload.crudFlows = crudRes.value;
|
|
3180
|
+
if (filterRes.status === 'fulfilled' && (filterRes.value as any).filters?.length > 0)
|
|
3181
|
+
muPayload.filtersAndPagination = filterRes.value;
|
|
3182
|
+
if (advancedRes.status === 'fulfilled' && advancedRes.value &&
|
|
3183
|
+
((advancedRes.value as any).datePickers?.length > 0 || (advancedRes.value as any).sliders?.length > 0 ||
|
|
3184
|
+
(advancedRes.value as any).typeaheads?.length > 0 || (advancedRes.value as any).dragDrop?.library !== 'none'))
|
|
3185
|
+
muPayload.advancedUI = advancedRes.value;
|
|
3186
|
+
if (Object.keys(muPayload).length > 2) {
|
|
3187
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3188
|
+
jobId: opts.jobId, type: 'PER_SCREEN_PROBES_MUTATING', payload: muPayload,
|
|
3189
|
+
}).catch(() => {});
|
|
3190
|
+
}
|
|
3191
|
+
} catch {
|
|
3192
|
+
// non-fatal — probe failures must not block BFS
|
|
3193
|
+
} finally {
|
|
3194
|
+
await probePage?.close().catch(() => {});
|
|
3195
|
+
}
|
|
3196
|
+
})();
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
|
|
2592
3200
|
// Capture DOM snapshot for structural analysis, and extract Markdown/text
|
|
2593
3201
|
// content from the same DOM (crawl4AI-parity text output) — no extra fetch.
|
|
2594
3202
|
try {
|
|
@@ -2864,6 +3472,34 @@ export async function crawlProject(opts: {
|
|
|
2864
3472
|
}
|
|
2865
3473
|
|
|
2866
3474
|
const linkMappings = await extractLinkMappings(page, origin);
|
|
3475
|
+
|
|
3476
|
+
// Auth-wall guard: check if the page drifted to a login/error URL during the async
|
|
3477
|
+
// element extraction window (30-90s). If so, the `elements` array contains login-page
|
|
3478
|
+
// elements — saving them would corrupt the screen's element catalog.
|
|
3479
|
+
const postExtractUrl = (() => { try { return page.url(); } catch { return url; } })();
|
|
3480
|
+
const postExtractParsed = (() => { try { return new URL(postExtractUrl); } catch { return new URL(url); } })();
|
|
3481
|
+
const driftedToAuthWall = !weWantedThisPage && (
|
|
3482
|
+
BFS_LOGIN_PATH.test(postExtractParsed.pathname) || BFS_ERROR_PATH.test(postExtractParsed.pathname)
|
|
3483
|
+
);
|
|
3484
|
+
if (driftedToAuthWall) {
|
|
3485
|
+
logger.warn({ url, postExtractUrl }, '[crawler] Page drifted to auth wall during element extraction — skipping element save to prevent login-page element contamination');
|
|
3486
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3487
|
+
screenId: screen.id,
|
|
3488
|
+
jobId: opts.jobId,
|
|
3489
|
+
type: 'AUTH_FAILURE',
|
|
3490
|
+
payload: { url, landed: postExtractUrl, reason: 'Session expired during element extraction. Re-capture session and re-crawl this page.' },
|
|
3491
|
+
}).catch(() => {});
|
|
3492
|
+
} else {
|
|
3493
|
+
if (elements.length === 0) {
|
|
3494
|
+
// Extraction returned nothing — keep existing elements rather than wiping them.
|
|
3495
|
+
// Causes: page.evaluate failure, contentFallbackElements threw, SPA not yet hydrated.
|
|
3496
|
+
logger.warn({ url, screenId: screen.id }, '[crawler] extractElements returned 0 — preserving existing elements to avoid data loss');
|
|
3497
|
+
// Queue for re-visit with longer wait — SPA content may not have hydrated yet
|
|
3498
|
+
if (!revisitQueue.includes(url)) {
|
|
3499
|
+
revisitQueue.push(url);
|
|
3500
|
+
logger.info({ url }, '[crawler] 0-element page queued for re-visit with extended hydration wait');
|
|
3501
|
+
}
|
|
3502
|
+
} else {
|
|
2867
3503
|
// Clear existing elements before re-saving — prevents duplicates on re-crawl
|
|
2868
3504
|
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
2869
3505
|
// Batch element saves: 4 concurrent DB writes instead of serial round-trips
|
|
@@ -2883,6 +3519,7 @@ export async function crawlProject(opts: {
|
|
|
2883
3519
|
AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, (saved as any).id, selectors).catch(console.warn);
|
|
2884
3520
|
}));
|
|
2885
3521
|
}
|
|
3522
|
+
} // end inner else: elements.length > 0
|
|
2886
3523
|
for (const lm of linkMappings) {
|
|
2887
3524
|
const safety = assessActionSafety(lm.label, domainProfile);
|
|
2888
3525
|
if (!safety.allowed) {
|
|
@@ -3044,6 +3681,7 @@ export async function crawlProject(opts: {
|
|
|
3044
3681
|
} catch { /* non-fatal — Skyvern unavailable */ }
|
|
3045
3682
|
}
|
|
3046
3683
|
}
|
|
3684
|
+
} // end auth-wall guard else
|
|
3047
3685
|
|
|
3048
3686
|
if (rawPerf) {
|
|
3049
3687
|
performanceMetrics.push({ url, screenName: name, ...rawPerf });
|
|
@@ -3157,16 +3795,16 @@ export async function crawlProject(opts: {
|
|
|
3157
3795
|
if (visited.size >= maxScreens) break;
|
|
3158
3796
|
try {
|
|
3159
3797
|
await page.goto(skyvernUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
3160
|
-
await
|
|
3798
|
+
await new Promise<void>(r => setTimeout(r, 1500));
|
|
3161
3799
|
const skyvernName = new URL(skyvernUrl).pathname.split('/').filter(Boolean).pop() ?? 'Screen';
|
|
3162
3800
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, skyvernName, skyvernUrl, opts.jobId, 'SKYVERN_AI');
|
|
3163
3801
|
const shotPath = `${opts.screenshotDir ?? '/tmp'}/${screen.id}-v${screen.version}.png`;
|
|
3164
|
-
await waitForScreenshotReady(page);
|
|
3802
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3165
3803
|
await page.screenshot({ path: shotPath, fullPage: true });
|
|
3166
3804
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP');
|
|
3167
3805
|
const [elems, links, linkMappings] = await Promise.all([
|
|
3168
|
-
extractElements(stagehand, page, elementLLM ?? undefined),
|
|
3169
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0),
|
|
3806
|
+
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
|
|
3807
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined),
|
|
3170
3808
|
extractLinkMappings(page, origin),
|
|
3171
3809
|
]);
|
|
3172
3810
|
for (const el of elems) {
|
|
@@ -3197,15 +3835,15 @@ export async function crawlProject(opts: {
|
|
|
3197
3835
|
if (!isUsefulNavigationUrl(href, origin)) continue;
|
|
3198
3836
|
try {
|
|
3199
3837
|
await page.goto(href, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
3200
|
-
await
|
|
3838
|
+
await new Promise<void>(r => setTimeout(r, 1000));
|
|
3201
3839
|
const nm = new URL(href).pathname.split('/').filter(Boolean).pop() ?? 'Screen';
|
|
3202
3840
|
const sc = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, 'SKYVERN_AI');
|
|
3203
3841
|
const sp = `${opts.screenshotDir ?? '/tmp'}/${sc.id}-v${sc.version}.png`;
|
|
3204
|
-
await waitForScreenshotReady(page);
|
|
3842
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3205
3843
|
await page.screenshot({ path: sp, fullPage: true });
|
|
3206
3844
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, 'DESKTOP');
|
|
3207
3845
|
const [elems2, linkMappings2] = await Promise.all([
|
|
3208
|
-
extractElements(stagehand, page, elementLLM ?? undefined),
|
|
3846
|
+
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
|
|
3209
3847
|
extractLinkMappings(page, origin),
|
|
3210
3848
|
]);
|
|
3211
3849
|
for (const el of elems2) {
|
|
@@ -3253,7 +3891,9 @@ export async function crawlProject(opts: {
|
|
|
3253
3891
|
);
|
|
3254
3892
|
}
|
|
3255
3893
|
} catch (err) {
|
|
3256
|
-
|
|
3894
|
+
const msg = String(err);
|
|
3895
|
+
logger.warn({ err: msg }, '[P1] URL pattern completion failed — non-fatal, continuing');
|
|
3896
|
+
crawlWarnings.push(`url-completer skipped: ${msg.includes('401') || msg.toLowerCase().includes('api key') || msg.toLowerCase().includes('authentication') ? 'LLM API key invalid or missing' : msg}`);
|
|
3257
3897
|
}
|
|
3258
3898
|
|
|
3259
3899
|
// P3a: Async content re-visit — revisit pages where DOM grew significantly during extraction.
|
|
@@ -3262,23 +3902,52 @@ export async function crawlProject(opts: {
|
|
|
3262
3902
|
await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content…`);
|
|
3263
3903
|
logger.info({ count: revisitQueue.length }, '[P3a] Starting async content re-visit pass');
|
|
3264
3904
|
for (const revisitUrl of revisitQueue.slice(0, 10)) { // cap at 10 re-visits
|
|
3265
|
-
const
|
|
3266
|
-
|
|
3905
|
+
const rawScreenId = visited.get(canonicalize(revisitUrl));
|
|
3906
|
+
// Auth-wall pages are stored as '_auth_wall_' — not a real screen ID.
|
|
3907
|
+
// Navigate with the authenticated main page; it will redirect to the real content.
|
|
3908
|
+
const isAuthWallRevisit = rawScreenId === '_auth_wall_';
|
|
3909
|
+
if (!rawScreenId) continue;
|
|
3267
3910
|
try {
|
|
3268
3911
|
await page.goto(revisitUrl, { waitUntil: 'networkidle', timeout: 25_000 });
|
|
3269
|
-
await
|
|
3912
|
+
await new Promise<void>(r => setTimeout(r, 2_000));
|
|
3913
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3914
|
+
|
|
3915
|
+
// After navigation, check where we actually landed (auth may redirect)
|
|
3916
|
+
const landedUrl = (rawPage ?? page).url?.() ?? revisitUrl;
|
|
3917
|
+
const landedCanon = canonicalize(landedUrl);
|
|
3918
|
+
let existingScreenId = isAuthWallRevisit
|
|
3919
|
+
? (visited.get(landedCanon) ?? null) // use screen at redirected URL if known
|
|
3920
|
+
: rawScreenId;
|
|
3921
|
+
|
|
3922
|
+
// If auth-wall redirect landed on an unvisited page, save it as a new screen
|
|
3923
|
+
if (isAuthWallRevisit && (!existingScreenId || existingScreenId.startsWith('_')) && !visited.has(landedCanon)) {
|
|
3924
|
+
const pathParts = new URL(landedUrl).pathname.split('/').filter(Boolean);
|
|
3925
|
+
const screenName = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
3926
|
+
const newScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, landedUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
|
|
3927
|
+
if (newScreen) {
|
|
3928
|
+
visited.set(landedCanon, newScreen.id);
|
|
3929
|
+
existingScreenId = newScreen.id;
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
if (!existingScreenId || existingScreenId.startsWith('_')) {
|
|
3934
|
+
logger.warn({ url: revisitUrl, landed: landedUrl }, '[P3a] Re-visit skipped — could not resolve screen ID');
|
|
3935
|
+
continue;
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3270
3938
|
const shotPath2 = `${opts.screenshotDir ?? '/tmp'}/${existingScreenId}-revisit.png`;
|
|
3271
|
-
await waitForScreenshotReady(page);
|
|
3272
3939
|
await page.screenshot({ path: shotPath2, fullPage: true });
|
|
3273
|
-
|
|
3274
|
-
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, 'DESKTOP').catch(() => { });
|
|
3940
|
+
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, 'DESKTOP', undefined).catch(() => {});
|
|
3275
3941
|
// Re-extract elements with the async-loaded content now visible
|
|
3276
|
-
const revisitElements = await extractElements(stagehand, page, elementLLM ?? undefined).catch(() => []);
|
|
3277
|
-
|
|
3278
|
-
await AppBrain.
|
|
3942
|
+
const revisitElements = await extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []);
|
|
3943
|
+
if (revisitElements.length > 0) {
|
|
3944
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, existingScreenId).catch(() => {});
|
|
3945
|
+
for (const el of revisitElements) {
|
|
3946
|
+
await AppBrain.saveElement(opts.tenantId, opts.projectId, existingScreenId, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => { });
|
|
3947
|
+
}
|
|
3279
3948
|
}
|
|
3280
3949
|
// Harvest any new links revealed by async content
|
|
3281
|
-
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false).catch(() => []);
|
|
3950
|
+
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []);
|
|
3282
3951
|
for (const href of revisitLinks) {
|
|
3283
3952
|
const canon = canonicalize(href);
|
|
3284
3953
|
if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
|
|
@@ -3286,13 +3955,66 @@ export async function crawlProject(opts: {
|
|
|
3286
3955
|
queue.push(href);
|
|
3287
3956
|
}
|
|
3288
3957
|
}
|
|
3289
|
-
logger.info({ url: revisitUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, '[P3a] Re-visit complete');
|
|
3958
|
+
logger.info({ url: revisitUrl, landed: landedUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, '[P3a] Re-visit complete');
|
|
3290
3959
|
} catch (err) {
|
|
3291
3960
|
logger.warn({ url: revisitUrl, err: String(err) }, '[P3a] Re-visit failed — non-fatal');
|
|
3292
3961
|
}
|
|
3293
3962
|
}
|
|
3294
3963
|
}
|
|
3295
3964
|
|
|
3965
|
+
// P3b: Extended BFS — process any links discovered during P3a revisit pass.
|
|
3966
|
+
// Without this, auth-wall pages redirect to real app pages, AI discovers new links,
|
|
3967
|
+
// but those links are pushed to queue and NEVER processed (main BFS already exited).
|
|
3968
|
+
if (queue.length > 0 && visited.size < maxScreens) {
|
|
3969
|
+
const p3bCount = Math.min(queue.length, maxScreens - visited.size);
|
|
3970
|
+
logger.info({ newLinks: p3bCount }, '[P3b] Extended BFS — processing links discovered during revisit pass');
|
|
3971
|
+
await onProgress?.(82, `Extended crawl: ${p3bCount} newly discovered page(s)…`);
|
|
3972
|
+
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
3973
|
+
const href = queue.shift();
|
|
3974
|
+
if (!href) break;
|
|
3975
|
+
const canonHref = canonicalize(href);
|
|
3976
|
+
if (visited.has(canonHref)) continue;
|
|
3977
|
+
if (!isUsefulNavigationUrl(href, origin)) continue;
|
|
3978
|
+
visited.set(canonHref, '_pending_');
|
|
3979
|
+
try {
|
|
3980
|
+
await page.goto(href, { waitUntil: 'networkidle', timeout: 25_000 }).catch(() => {});
|
|
3981
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3982
|
+
const pathParts = new URL(href).pathname.split('/').filter(Boolean);
|
|
3983
|
+
const nm = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
3984
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, 'LINK_FOLLOW');
|
|
3985
|
+
visited.set(canonHref, screen.id);
|
|
3986
|
+
const sp = `${opts.screenshotDir ?? '/tmp'}/${screen.id}-v${screen.version}.png`;
|
|
3987
|
+
await page.screenshot({ path: sp, fullPage: true }).catch(() => {});
|
|
3988
|
+
const cloudUrl3 = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => '');
|
|
3989
|
+
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, sp, screen.version, 'DESKTOP', cloudUrl3).catch(() => {});
|
|
3990
|
+
const [p3bElems, p3bLinks] = await Promise.all([
|
|
3991
|
+
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
|
|
3992
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined).catch(() => []),
|
|
3993
|
+
]);
|
|
3994
|
+
if (p3bElems.length > 0) {
|
|
3995
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
3996
|
+
for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 4) {
|
|
3997
|
+
const _b3 = p3bElems.slice(_ei3, _ei3 + 4);
|
|
3998
|
+
await Promise.all(_b3.map((el: any) =>
|
|
3999
|
+
AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {})
|
|
4000
|
+
));
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
for (const link of p3bLinks) {
|
|
4004
|
+
const c = canonicalize(link);
|
|
4005
|
+
if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 2) {
|
|
4006
|
+
queued.add(c); queue.push(link);
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
discovered.push({ url: href, screenId: screen.id, elements: p3bElems.length, version: screen.version });
|
|
4010
|
+
logger.info({ url: href, elements: p3bElems.length, newLinks: p3bLinks.length }, '[P3b] Extended BFS screen captured');
|
|
4011
|
+
} catch (err) {
|
|
4012
|
+
logger.warn({ url: href, err: String(err) }, '[P3b] Extended BFS URL failed');
|
|
4013
|
+
visited.set(canonHref, '_error_');
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
|
|
3296
4018
|
// P6: Gap Pass — LLM gap analysis on discovered screens to find structurally missing pages.
|
|
3297
4019
|
// Runs after P3a re-visits so the gap analysis has the most complete screen list possible.
|
|
3298
4020
|
// Confirmed gap URLs (HEAD=200) are visited directly with the existing browser.
|
|
@@ -3332,16 +4054,16 @@ export async function crawlProject(opts: {
|
|
|
3332
4054
|
logger.warn({ gapUrl, landed: page.url() }, '[P6] Gap URL redirected to login — skipping');
|
|
3333
4055
|
continue;
|
|
3334
4056
|
}
|
|
3335
|
-
await
|
|
4057
|
+
await new Promise<void>(r => setTimeout(r, 1_500));
|
|
3336
4058
|
const gapName = gap.category;
|
|
3337
4059
|
const gapScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, gapName, gapUrl, opts.jobId, 'ENTRY_POINT');
|
|
3338
4060
|
const gapShot = `${opts.screenshotDir ?? '/tmp'}/${gapScreen.id}-v${gapScreen.version}.png`;
|
|
3339
|
-
await waitForScreenshotReady(page);
|
|
4061
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3340
4062
|
await page.screenshot({ path: gapShot, fullPage: true });
|
|
3341
4063
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, 'DESKTOP');
|
|
3342
4064
|
const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
|
|
3343
|
-
extractElements(stagehand, page, elementLLM ?? undefined).catch(() => []),
|
|
3344
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false).catch(() => []),
|
|
4065
|
+
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
|
|
4066
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []),
|
|
3345
4067
|
extractLinkMappings(page, origin).catch(() => []),
|
|
3346
4068
|
]);
|
|
3347
4069
|
for (const el of gapElems) {
|
|
@@ -3368,14 +4090,23 @@ export async function crawlProject(opts: {
|
|
|
3368
4090
|
logger.info({ total: gapCandidates.length }, '[P6] Gap pass: no confirmed gaps found');
|
|
3369
4091
|
}
|
|
3370
4092
|
} catch (err) {
|
|
3371
|
-
|
|
4093
|
+
const msg = String(err);
|
|
4094
|
+
logger.warn({ err: msg }, '[P6] Gap pass failed — non-fatal, continuing');
|
|
4095
|
+
crawlWarnings.push(`gap-analysis skipped: ${msg.includes('401') || msg.toLowerCase().includes('api key') || msg.toLowerCase().includes('authentication') ? 'LLM API key invalid or missing' : msg}`);
|
|
3372
4096
|
}
|
|
3373
4097
|
}
|
|
3374
4098
|
|
|
3375
4099
|
// After crawl: mark screens not re-discovered as PENDING_REMOVAL (non-destructive diff)
|
|
3376
4100
|
// Only run if crawl actually found screens — 0 discovered means failed/aborted run, not evidence of removal
|
|
3377
4101
|
if (!selective && visited.size > 0) {
|
|
3378
|
-
|
|
4102
|
+
// Only pass canonical URLs of screens that were actually visited (saveScreen called).
|
|
4103
|
+
// Exclude marker strings (_skipped_api_, _pending_, _soft404_, pre-auth, skipped)
|
|
4104
|
+
// so that skipped API/export paths don't pollute the discoveredSet and prevent removal
|
|
4105
|
+
// of old API-path screens from previous crawls.
|
|
4106
|
+
const VISIT_MARKER_RE = /^_|^pre-auth$|^skipped$/;
|
|
4107
|
+
const discoveredUrls = Array.from(visited.entries())
|
|
4108
|
+
.filter(([, v]) => typeof v === 'string' && !VISIT_MARKER_RE.test(v))
|
|
4109
|
+
.map(([k]) => k);
|
|
3379
4110
|
await AppBrain.markRemovedScreens(opts.tenantId, opts.projectId, discoveredUrls, opts.jobId);
|
|
3380
4111
|
}
|
|
3381
4112
|
|
|
@@ -3567,6 +4298,7 @@ export async function crawlProject(opts: {
|
|
|
3567
4298
|
ignoreHTTPSErrors: true,
|
|
3568
4299
|
...(mainStorageState ? { storageState: mainStorageState } : {}),
|
|
3569
4300
|
});
|
|
4301
|
+
mobileCtx.on('download', (dl: any) => { dl.cancel().catch(() => {}); });
|
|
3570
4302
|
const mobilePage = await mobileCtx.newPage();
|
|
3571
4303
|
|
|
3572
4304
|
for (const disc of discovered) {
|
|
@@ -3576,12 +4308,15 @@ export async function crawlProject(opts: {
|
|
|
3576
4308
|
await mobilePage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
3577
4309
|
await dismissOverlays(mobilePage);
|
|
3578
4310
|
try { await mobilePage.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' }); } catch { /* ignore */ }
|
|
4311
|
+
// mobilePage is a real Playwright page — pass it as rawPage so CDP wheel events
|
|
4312
|
+
// trigger virtual scrollers (react-window / TanStack Virtual) at mobile viewports.
|
|
4313
|
+
await infiniteScrollToBottom(mobilePage, { maxScrolls: 20, scrollDelay: 300, stabilizeMs: 400 }, mobilePage).catch(() => { });
|
|
3579
4314
|
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${disc.screenId}-${profile.toLowerCase()}-v${disc.version}.png`);
|
|
3580
|
-
await waitForScreenshotReady(mobilePage);
|
|
4315
|
+
await waitForScreenshotReady(mobilePage, mobilePage);
|
|
3581
4316
|
// Screenshot and DOM element extraction run in parallel
|
|
3582
4317
|
const [, vpElements] = await Promise.all([
|
|
3583
4318
|
mobilePage.screenshot({ path: shotPath, fullPage: true }),
|
|
3584
|
-
domFallbackElements(mobilePage),
|
|
4319
|
+
domFallbackElements(mobilePage, mobilePage),
|
|
3585
4320
|
]);
|
|
3586
4321
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
3587
4322
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version!, profile, cloudUrl);
|
|
@@ -3626,6 +4361,26 @@ export async function crawlProject(opts: {
|
|
|
3626
4361
|
await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
|
|
3627
4362
|
}
|
|
3628
4363
|
|
|
4364
|
+
// Step 7: End-of-crawl state graph coverage report.
|
|
4365
|
+
// Per-screen probes (dropdown, CRUD, filter, DnD, nested-contexts, shadow-DOM,
|
|
4366
|
+
// accessibility-tree, performance, service-worker) run per-screen inside the BFS
|
|
4367
|
+
// loop above and are saved as PER_SCREEN_PROBES evidence.
|
|
4368
|
+
try {
|
|
4369
|
+
await onProgress?.(90, 'Generating state graph coverage report');
|
|
4370
|
+
const stateGraph = new ScreenTransitionGraph();
|
|
4371
|
+
const coveragePayload: Record<string, unknown> = {
|
|
4372
|
+
stateGraph: stateGraph.getCoverageReport(),
|
|
4373
|
+
totalScreensCrawled: discovered.filter(d => !!d.screenId).length,
|
|
4374
|
+
};
|
|
4375
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
4376
|
+
jobId: opts.jobId,
|
|
4377
|
+
type: 'CRAWL_COVERAGE_REPORT',
|
|
4378
|
+
payload: coveragePayload,
|
|
4379
|
+
}).catch(() => { });
|
|
4380
|
+
} catch (extErr) {
|
|
4381
|
+
logger.warn({ err: String(extErr) }, '[coverage-report] non-fatal error');
|
|
4382
|
+
}
|
|
4383
|
+
|
|
3629
4384
|
await onProgress?.(97, 'Running post-crawl analysis');
|
|
3630
4385
|
|
|
3631
4386
|
// ── Per-crawl summaries (fire-and-forget, non-blocking) ────────────────
|
|
@@ -3702,6 +4457,17 @@ export async function crawlProject(opts: {
|
|
|
3702
4457
|
const screensSucceeded = discovered.filter(d => !!d.screenId).length;
|
|
3703
4458
|
const screensErrored = discovered.filter(d => !!d.error && !d.screenId).length;
|
|
3704
4459
|
|
|
4460
|
+
// Detect systematic element/link extraction failure across all screens
|
|
4461
|
+
if (screensSucceeded >= 3) {
|
|
4462
|
+
const screensWithElements = discovered.filter(d => (d.elements ?? 0) > 0 && d.screenId).length;
|
|
4463
|
+
if (screensWithElements === 0) {
|
|
4464
|
+
crawlWarnings.push(
|
|
4465
|
+
`element-extraction: 0 elements found across all ${screensSucceeded} screens — ` +
|
|
4466
|
+
`DOM evaluate may be failing (check server logs for "page.evaluate threw" warnings)`,
|
|
4467
|
+
);
|
|
4468
|
+
}
|
|
4469
|
+
}
|
|
4470
|
+
|
|
3705
4471
|
return {
|
|
3706
4472
|
screensFound: screensSucceeded, // successful captures only (not errors)
|
|
3707
4473
|
screensErrored, // pages visited but failed to capture
|
|
@@ -3714,6 +4480,7 @@ export async function crawlProject(opts: {
|
|
|
3714
4480
|
segmentIndex: segIdx, // which segment this was
|
|
3715
4481
|
hasMoreSegments,
|
|
3716
4482
|
domHashes: Array.from(seenDomHashes), // carry forward to next segment to avoid re-screenshotting
|
|
4483
|
+
crawlWarnings: crawlWarnings.length > 0 ? crawlWarnings : undefined,
|
|
3717
4484
|
};
|
|
3718
4485
|
} finally {
|
|
3719
4486
|
// Log accumulated Stagehand LLM usage (extract/act/observe calls) to cost ledger.
|
|
@@ -3748,11 +4515,13 @@ export async function crawlProject(opts: {
|
|
|
3748
4515
|
// Elements whose meanings indicate UI state toggles or noise — filtered from both DOM and AI results.
|
|
3749
4516
|
const ELEMENT_NOISE = /^(cookie|consent|privacy|accept|decline|dismiss|close|copy|©|v\d|switch to (dark|light)|dark mode|light mode|toggle (dark|light|theme)|theme (toggle|switch)|share|tweet|follow|subscribe to newsletter|skip to|back to top|\s*$)/i;
|
|
3750
4517
|
|
|
3751
|
-
async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
|
|
4518
|
+
async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, rawPage?: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
|
|
3752
4519
|
// llmCfg reserved for per-task model selection (future: create task-scoped Stagehand)
|
|
3753
4520
|
// DOM-first: fast, no LLM cost, covers server-rendered and most SPAs.
|
|
3754
4521
|
// Threshold >= 2 so a lone dark-mode button doesn't suppress AI fallback for rich pages.
|
|
3755
|
-
|
|
4522
|
+
// rawPage: real Playwright CDP page used for page.evaluate() — avoids Stagehand's proxy
|
|
4523
|
+
// re-bundling the function with esbuild (which injects __name, breaking browser eval).
|
|
4524
|
+
const domElements = await domFallbackElements(page, rawPage).catch((err: any) => {
|
|
3756
4525
|
console.warn('[crawler] domFallbackElements failed (page context issue or DOM error):', err?.message ?? err);
|
|
3757
4526
|
return [] as Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>;
|
|
3758
4527
|
});
|
|
@@ -3799,7 +4568,10 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
|
|
|
3799
4568
|
// Always capture content elements (headings, meta, cards, nav, FAQ) regardless of interactive count.
|
|
3800
4569
|
// Previously this was fallback-only; now both paths run together so static sections are
|
|
3801
4570
|
// always visible to the test generator even on pages with forms.
|
|
3802
|
-
const contentElements = await contentFallbackElements(page)
|
|
4571
|
+
const contentElements = await contentFallbackElements(page).catch((err: any) => {
|
|
4572
|
+
console.warn('[crawler] contentFallbackElements failed (page context issue):', err?.message ?? err);
|
|
4573
|
+
return [] as Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number> }>;
|
|
4574
|
+
});
|
|
3803
4575
|
|
|
3804
4576
|
// Cross-source text deduplication: strips wrapper phrases like "Button to expand '...' details"
|
|
3805
4577
|
// and "... FAQ button" so duplicate FAQ buttons from DOM + content sources are collapsed.
|
|
@@ -3855,14 +4627,14 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
|
|
|
3855
4627
|
}
|
|
3856
4628
|
}
|
|
3857
4629
|
|
|
3858
|
-
async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false): Promise<string[]> {
|
|
4630
|
+
async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false, rawPage?: any): Promise<string[]> {
|
|
3859
4631
|
// Expand collapsed nav menus (hamburger, aria-haspopup, Radix closed triggers) so hidden routes
|
|
3860
4632
|
// become part of the DOM before querying. This catches SPA nav dropdowns that only render on click.
|
|
3861
4633
|
const navExpanded = await expandNavMenus(page);
|
|
3862
4634
|
|
|
3863
4635
|
// DOM-first: <a href> links — standard apps. AI supplements when DOM coverage is < 20 unique links.
|
|
3864
4636
|
// Threshold of 20 (not 8) because nav sidebars quickly provide 8+ links while missing deep content URLs.
|
|
3865
|
-
const domLinks = await domFallbackLinks(page, origin);
|
|
4637
|
+
const domLinks = await domFallbackLinks(page, origin, rawPage);
|
|
3866
4638
|
|
|
3867
4639
|
// Interact with tabs, carousels, and accordions to reveal hidden content and discover more links.
|
|
3868
4640
|
const interactiveLinks = await interactAndCollectLinks(page, origin);
|
|
@@ -3917,8 +4689,9 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
|
|
|
3917
4689
|
}
|
|
3918
4690
|
}
|
|
3919
4691
|
|
|
3920
|
-
export async function domFallbackElements(page: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
|
|
3921
|
-
|
|
4692
|
+
export async function domFallbackElements(page: any, rawPage?: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
|
|
4693
|
+
// Named function so we can .toString() it for CDP Runtime.evaluate fallback
|
|
4694
|
+
function _browserEval() {
|
|
3922
4695
|
const out: { meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }[] = [];
|
|
3923
4696
|
const seen = new Set<string>();
|
|
3924
4697
|
// UI chrome noise: cookie banners, theme toggles, version strings, social/share buttons
|
|
@@ -3937,8 +4710,8 @@ export async function domFallbackElements(page: any): Promise<Array<{ meaning: s
|
|
|
3937
4710
|
return results;
|
|
3938
4711
|
};
|
|
3939
4712
|
|
|
3940
|
-
// Expanded selector — all interactive ARIA roles + native form elements + rich editors
|
|
3941
|
-
const selector = 'input:not([type=hidden]),select,textarea,button,[role="button"],[role="checkbox"],[role="radio"],[role="combobox"],[role="slider"],[role="spinbutton"],[role="switch"],[role="tab"],[role="dialog"],[role="alertdialog"],[role="menu"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="listbox"],[role="option"],[role="treeitem"],[role="searchbox"],[role="gridcell"],[role="link"],
|
|
4713
|
+
// Expanded selector — all interactive ARIA roles + native form elements + rich editors + bare nav links
|
|
4714
|
+
const selector = 'input:not([type=hidden]),select,textarea,button,a[href],[role="button"],[role="checkbox"],[role="radio"],[role="combobox"],[role="slider"],[role="spinbutton"],[role="switch"],[role="tab"],[role="dialog"],[role="alertdialog"],[role="menu"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="listbox"],[role="option"],[role="treeitem"],[role="searchbox"],[role="gridcell"],[role="link"],[contenteditable="true"]';
|
|
3942
4715
|
|
|
3943
4716
|
querySelectorAllDeep(document, selector).forEach((el: any) => {
|
|
3944
4717
|
const style = window.getComputedStyle(el);
|
|
@@ -3957,14 +4730,13 @@ export async function domFallbackElements(page: any): Promise<Array<{ meaning: s
|
|
|
3957
4730
|
const ariaRole = el.getAttribute('role');
|
|
3958
4731
|
const isActionEl = tag === 'button' || tag === 'a' || ariaRole === 'button';
|
|
3959
4732
|
|
|
3960
|
-
// Skip plain anchor links inside <
|
|
4733
|
+
// Skip plain anchor links inside <footer> only — footer chrome (sitemap, legal links) has no test value.
|
|
4734
|
+
// Header nav links ARE test targets (SPA primary navigation lives in <header>) — do NOT skip.
|
|
3961
4735
|
// Do NOT skip <nav> elements: SPA dashboard sidebars live in <nav> and are primary test targets.
|
|
3962
|
-
|
|
3963
|
-
if (tag === 'a' || (ariaRole === 'button' && tag !== 'button')) {
|
|
4736
|
+
if (tag === 'a') {
|
|
3964
4737
|
let p = el.parentElement;
|
|
3965
4738
|
while (p) {
|
|
3966
|
-
|
|
3967
|
-
if (['header', 'footer'].includes(ptag)) return;
|
|
4739
|
+
if (p.tagName?.toLowerCase() === 'footer') return;
|
|
3968
4740
|
p = p.parentElement;
|
|
3969
4741
|
}
|
|
3970
4742
|
}
|
|
@@ -4145,7 +4917,64 @@ export async function domFallbackElements(page: any): Promise<Array<{ meaning: s
|
|
|
4145
4917
|
['email', 'password', 'text', 'tel', 'date', 'number', 'select', 'textarea', 'url', 'file'].includes(e.role) ? 0 : 1;
|
|
4146
4918
|
out.sort((a, b) => priority(a) - priority(b));
|
|
4147
4919
|
return out; // Cap removed
|
|
4148
|
-
}
|
|
4920
|
+
}
|
|
4921
|
+
// CDP Runtime.evaluate bypasses Playwright's isolated world entirely.
|
|
4922
|
+
// The "__name is not defined" error comes from Playwright's own UtilityScript being absent
|
|
4923
|
+
// from the isolated world (destroyed by navigation events in Stagehand's CDP session).
|
|
4924
|
+
// Solution: when rawPage (a real Playwright Page) is available, use CDP Runtime.evaluate
|
|
4925
|
+
// as the PRIMARY path — no isolated world needed, no __name dependency.
|
|
4926
|
+
//
|
|
4927
|
+
// esbuild injects __name() calls for named arrow-function constants inside _browserEval
|
|
4928
|
+
// (e.g. querySelectorAllDeep). When _browserEval.toString() is sent to the browser,
|
|
4929
|
+
// the browser throws "ReferenceError: __name is not defined". Fix: prepend a tiny
|
|
4930
|
+
// __name polyfill that just returns the function (name-setting is non-critical).
|
|
4931
|
+
const _browserEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_browserEval.toString()})()})();`;
|
|
4932
|
+
|
|
4933
|
+
if (rawPage && typeof rawPage.context === 'function') {
|
|
4934
|
+
try {
|
|
4935
|
+
const ctx = rawPage.context();
|
|
4936
|
+
if (typeof ctx?.newCDPSession === 'function') {
|
|
4937
|
+
const cdp = await ctx.newCDPSession(rawPage);
|
|
4938
|
+
try {
|
|
4939
|
+
const evalResult = await cdp.send('Runtime.evaluate', {
|
|
4940
|
+
expression: _browserEvalStr,
|
|
4941
|
+
returnByValue: true,
|
|
4942
|
+
awaitPromise: false,
|
|
4943
|
+
});
|
|
4944
|
+
if (evalResult?.exceptionDetails) {
|
|
4945
|
+
console.warn('[crawler] domFallbackElements CDP primary error:', evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
|
|
4946
|
+
} else if (Array.isArray(evalResult?.result?.value)) {
|
|
4947
|
+
return (evalResult.result.value as any[]).map((e: any) => ({ ...e, isInteractive: true }));
|
|
4948
|
+
}
|
|
4949
|
+
} finally {
|
|
4950
|
+
cdp.detach().catch(() => {});
|
|
4951
|
+
}
|
|
4952
|
+
}
|
|
4953
|
+
} catch (cdpPrimaryErr: any) {
|
|
4954
|
+
console.warn('[crawler] domFallbackElements CDP primary failed, trying page.evaluate:', cdpPrimaryErr?.message ?? String(cdpPrimaryErr));
|
|
4955
|
+
}
|
|
4956
|
+
}
|
|
4957
|
+
|
|
4958
|
+
// Fallback: pass the pre-serialized string expression so esbuild's __name() wrapper
|
|
4959
|
+
// for nested named functions never reaches the browser (Function.prototype.toString
|
|
4960
|
+
// returns the source including __name() calls for any nested named arrow constants).
|
|
4961
|
+
// Playwright accepts a JS expression string directly — no isolated-world __name leakage.
|
|
4962
|
+
const evalPage = rawPage ?? page;
|
|
4963
|
+
const _doEvaluate = (): Promise<any> => evalPage.evaluate(_browserEvalStr as any);
|
|
4964
|
+
let result: any[];
|
|
4965
|
+
try {
|
|
4966
|
+
result = await _doEvaluate();
|
|
4967
|
+
} catch (err1: any) {
|
|
4968
|
+
// Second attempt after a brief wait — isolated world may need a tick to settle
|
|
4969
|
+
await new Promise<void>(r => setTimeout(r, 500));
|
|
4970
|
+
try {
|
|
4971
|
+
result = await _doEvaluate();
|
|
4972
|
+
} catch (err2: any) {
|
|
4973
|
+
console.warn('[crawler] domFallbackElements page.evaluate threw (attempt 2):', err2?.message ?? String(err2));
|
|
4974
|
+
return [];
|
|
4975
|
+
}
|
|
4976
|
+
}
|
|
4977
|
+
return (result ?? []).map((e: any) => ({ ...e, isInteractive: true }));
|
|
4149
4978
|
}
|
|
4150
4979
|
|
|
4151
4980
|
/** Capture content anatomy for EVERY page — not just static ones.
|
|
@@ -5017,15 +5846,12 @@ async function expandNavMenus(page: any): Promise<boolean> {
|
|
|
5017
5846
|
} catch { return false; }
|
|
5018
5847
|
}
|
|
5019
5848
|
|
|
5020
|
-
async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
|
|
5021
|
-
|
|
5849
|
+
async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promise<string[]> {
|
|
5850
|
+
// Named function so .toString() gives the raw function body for CDP evaluation.
|
|
5851
|
+
// esbuild wraps nested named arrow-function constants with __name() — prepend a
|
|
5852
|
+
// tiny polyfill so the browser has it when we serialize this function as a string.
|
|
5853
|
+
function _linkEval(originStr: string) {
|
|
5022
5854
|
const logout = /logout|log out|sign out|delete/i;
|
|
5023
|
-
// Hash-routed SPAs (React Router hash history, Vue Router hash mode, old
|
|
5024
|
-
// AngularJS) encode the actual route as the fragment — "#/dashboard",
|
|
5025
|
-
// "#!/settings" — as distinct from a same-page scroll anchor like "#top"
|
|
5026
|
-
// or "#section-2". Treating every hash as a scroll anchor (stripping it
|
|
5027
|
-
// entirely) collapses an entire hash-routed app down to its single base
|
|
5028
|
-
// URL, discovering only ~1 screen no matter how many routes it has.
|
|
5029
5855
|
const isRouteHash = (hash: string) => /^#!?\//.test(hash);
|
|
5030
5856
|
const seen = new Set<string>();
|
|
5031
5857
|
const add = (rawHref: string, textContent: string) => {
|
|
@@ -5036,11 +5862,9 @@ async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
|
|
|
5036
5862
|
try {
|
|
5037
5863
|
const u = new URL(rawHref, location.href);
|
|
5038
5864
|
if (!isRouteHash(u.hash)) u.hash = '';
|
|
5039
|
-
if (new URL(u).origin ===
|
|
5865
|
+
if (new URL(u).origin === originStr) seen.add(u.toString());
|
|
5040
5866
|
} catch { /* ignore */ }
|
|
5041
5867
|
};
|
|
5042
|
-
|
|
5043
|
-
// Iterative BFS shadow DOM traversal — avoids stack overflow on deeply nested shadow roots
|
|
5044
5868
|
const allRoots: Array<Document | ShadowRoot> = [document];
|
|
5045
5869
|
const rootSeen = new WeakSet<Document | ShadowRoot>();
|
|
5046
5870
|
rootSeen.add(document);
|
|
@@ -5061,12 +5885,48 @@ async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
|
|
|
5061
5885
|
rootSeen.add(el.shadowRoot);
|
|
5062
5886
|
allRoots.push(el.shadowRoot);
|
|
5063
5887
|
}
|
|
5064
|
-
} catch (_) { /* closed/cross-origin shadow root
|
|
5888
|
+
} catch (_) { /* closed/cross-origin shadow root */ }
|
|
5065
5889
|
});
|
|
5066
|
-
} catch (_) { /* element threw — skip
|
|
5890
|
+
} catch (_) { /* element threw — skip root */ }
|
|
5067
5891
|
}
|
|
5068
5892
|
return Array.from(seen);
|
|
5069
|
-
}
|
|
5893
|
+
}
|
|
5894
|
+
|
|
5895
|
+
const _linkEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_linkEval.toString()})(${JSON.stringify(origin)})})();`;
|
|
5896
|
+
|
|
5897
|
+
// CDP primary — bypasses Stagehand isolated world entirely
|
|
5898
|
+
if (rawPage && typeof rawPage.context === 'function') {
|
|
5899
|
+
try {
|
|
5900
|
+
const ctx = rawPage.context();
|
|
5901
|
+
if (typeof ctx?.newCDPSession === 'function') {
|
|
5902
|
+
const cdp = await ctx.newCDPSession(rawPage);
|
|
5903
|
+
try {
|
|
5904
|
+
const evalResult = await cdp.send('Runtime.evaluate', {
|
|
5905
|
+
expression: _linkEvalStr,
|
|
5906
|
+
returnByValue: true,
|
|
5907
|
+
awaitPromise: false,
|
|
5908
|
+
});
|
|
5909
|
+
if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
|
|
5910
|
+
return evalResult.result.value as string[];
|
|
5911
|
+
}
|
|
5912
|
+
if (evalResult?.exceptionDetails) {
|
|
5913
|
+
console.warn('[crawler] domFallbackLinks CDP error:', evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
|
|
5914
|
+
}
|
|
5915
|
+
} finally {
|
|
5916
|
+
cdp.detach().catch(() => {});
|
|
5917
|
+
}
|
|
5918
|
+
}
|
|
5919
|
+
} catch (cdpErr: any) {
|
|
5920
|
+
console.warn('[crawler] domFallbackLinks CDP failed:', cdpErr?.message ?? String(cdpErr));
|
|
5921
|
+
}
|
|
5922
|
+
}
|
|
5923
|
+
|
|
5924
|
+
// Fallback: pass string expression so esbuild __name wrapper never reaches browser
|
|
5925
|
+
const evalPage = rawPage ?? page;
|
|
5926
|
+
return (evalPage.evaluate(_linkEvalStr as any) as Promise<string[]>).catch((err: any) => {
|
|
5927
|
+
console.warn('[crawler] domFallbackLinks page.evaluate threw:', err?.message ?? String(err));
|
|
5928
|
+
return [];
|
|
5929
|
+
});
|
|
5070
5930
|
}
|
|
5071
5931
|
|
|
5072
5932
|
/** Extract label→href pairs for all in-app anchor links — used to seed KAG element transitions. */
|
|
@@ -5178,57 +6038,122 @@ async function dismissOverlays(page: any): Promise<void> {
|
|
|
5178
6038
|
}
|
|
5179
6039
|
} catch { /* overlay not present — skip */ }
|
|
5180
6040
|
}
|
|
5181
|
-
// Brief settle after dismissals
|
|
5182
|
-
await
|
|
6041
|
+
// Brief settle after dismissals — Node.js timer, no dependency on page.waitForTimeout proxy
|
|
6042
|
+
await new Promise<void>(r => setTimeout(r, 300));
|
|
5183
6043
|
}
|
|
5184
6044
|
|
|
5185
6045
|
/**
|
|
5186
6046
|
* Scroll the full page height to trigger IntersectionObserver-based lazy loads
|
|
5187
6047
|
* (images, components, infinite-scroll content). Scrolls back to top before capture.
|
|
5188
6048
|
*/
|
|
5189
|
-
async function scrollToTriggerLazyLoad(page: any): Promise<void> {
|
|
6049
|
+
async function scrollToTriggerLazyLoad(page: any, rawPage?: any): Promise<void> {
|
|
6050
|
+
const evalPage = rawPage ?? page;
|
|
5190
6051
|
try {
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
6052
|
+
// Scroll the full page in viewport-sized steps.
|
|
6053
|
+
// window.scrollTo handles standard scroll containers and triggers IntersectionObserver.
|
|
6054
|
+
const { scrollHeight, innerHeight, innerWidth } = await evalPage.evaluate(() => ({
|
|
6055
|
+
scrollHeight: Math.min(document.body.scrollHeight, 12_000),
|
|
6056
|
+
innerHeight: window.innerHeight || 800,
|
|
6057
|
+
innerWidth: window.innerWidth || 1280,
|
|
6058
|
+
})).catch(() => ({ scrollHeight: 0, innerHeight: 800, innerWidth: 1280 }));
|
|
6059
|
+
|
|
6060
|
+
if (scrollHeight > innerHeight) {
|
|
6061
|
+
// CDP wheel events trigger virtual scrollers (react-window / TanStack Virtual / AG Grid)
|
|
6062
|
+
// which ignore window.scrollTo but listen on their own container via wheel events.
|
|
6063
|
+
let cdp: any = null;
|
|
6064
|
+
if (rawPage && typeof rawPage.context === 'function') {
|
|
6065
|
+
try {
|
|
6066
|
+
const ctx = rawPage.context();
|
|
6067
|
+
if (typeof ctx?.newCDPSession === 'function') cdp = await ctx.newCDPSession(rawPage);
|
|
6068
|
+
} catch { cdp = null; }
|
|
5197
6069
|
}
|
|
5198
|
-
|
|
5199
|
-
|
|
6070
|
+
|
|
6071
|
+
const step = Math.max(innerHeight, 400);
|
|
6072
|
+
for (let y = step; y < scrollHeight; y += step) {
|
|
6073
|
+
await evalPage.evaluate((pos: number) => window.scrollTo(0, pos), y).catch(() => {});
|
|
6074
|
+
if (cdp) {
|
|
6075
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
6076
|
+
type: 'mouseWheel',
|
|
6077
|
+
x: Math.floor(innerWidth / 2),
|
|
6078
|
+
y: Math.floor(innerHeight / 2),
|
|
6079
|
+
deltaX: 0,
|
|
6080
|
+
deltaY: step,
|
|
6081
|
+
modifiers: 0,
|
|
6082
|
+
}).catch(() => {});
|
|
6083
|
+
}
|
|
6084
|
+
// Node.js-side wait — does NOT depend on page.waitForTimeout (Stagehand proxy may lack it)
|
|
6085
|
+
await new Promise<void>(r => setTimeout(r, 150));
|
|
6086
|
+
}
|
|
6087
|
+
// Scroll back to top
|
|
6088
|
+
await evalPage.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
|
|
6089
|
+
if (cdp) cdp.detach().catch(() => {});
|
|
6090
|
+
}
|
|
6091
|
+
|
|
5200
6092
|
// Let lazy-triggered content render before extraction
|
|
5201
|
-
await
|
|
5202
|
-
//
|
|
5203
|
-
|
|
6093
|
+
await new Promise<void>(r => setTimeout(r, 400));
|
|
6094
|
+
// Wait for networkidle: IntersectionObserver fires on scroll but async data fetches
|
|
6095
|
+
// (React Query, SWR, Apollo) triggered by it may still be in-flight. networkidle ensures
|
|
6096
|
+
// all XHR/fetch requests spawned by the scroll complete before DOM stability check.
|
|
6097
|
+
const scrollWaitPage = rawPage ?? page;
|
|
6098
|
+
if (typeof scrollWaitPage.waitForLoadState === 'function') {
|
|
6099
|
+
await scrollWaitPage.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
|
|
6100
|
+
}
|
|
6101
|
+
// Re-check DOM stability (shadow-piercing) after scroll-triggered mutations + data fetches
|
|
6102
|
+
await waitForStableDOM(page, 4_000, 500, rawPage);
|
|
5204
6103
|
} catch { /* scroll failed — non-fatal, proceed */ }
|
|
5205
6104
|
}
|
|
5206
6105
|
|
|
5207
|
-
async function waitForStableDOM(page: any, maxWait = 8_000, stabilityMs = 400): Promise<void> {
|
|
6106
|
+
async function waitForStableDOM(page: any, maxWait = 8_000, stabilityMs = 400, rawPage?: any): Promise<void> {
|
|
6107
|
+
const evalPage = rawPage ?? page; // prefer rawPage — avoids Stagehand proxy + __name isolated-world issues
|
|
5208
6108
|
const deadline = Date.now() + maxWait;
|
|
5209
6109
|
let lastCount = -1;
|
|
5210
6110
|
let stableAt = 0;
|
|
6111
|
+
let evalErrors = 0;
|
|
5211
6112
|
while (Date.now() < deadline) {
|
|
5212
|
-
const count: number = await
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
6113
|
+
const count: number = await evalPage.evaluate(() => {
|
|
6114
|
+
// Shadow-piercing counter: querySelectorAll('*') only counts light-DOM elements;
|
|
6115
|
+
// shadow roots are separate trees. Counting both ensures changes inside web components
|
|
6116
|
+
// (e.g. Material UI, Lit, Shoelace) reset the stability timer correctly.
|
|
6117
|
+
let c = 0;
|
|
6118
|
+
const visit = (root: Document | ShadowRoot) => {
|
|
6119
|
+
const els = root.querySelectorAll('*');
|
|
6120
|
+
c += els.length;
|
|
6121
|
+
els.forEach((el: any) => { if (el.shadowRoot) visit(el.shadowRoot); });
|
|
6122
|
+
};
|
|
6123
|
+
visit(document);
|
|
6124
|
+
return c;
|
|
6125
|
+
}).catch(() => -1);
|
|
6126
|
+
if (count === -1) {
|
|
6127
|
+
// evaluate failed (isolated world broken) — after 3 consecutive failures, wait stabilityMs and bail
|
|
6128
|
+
if (++evalErrors >= 3) {
|
|
6129
|
+
await new Promise(r => setTimeout(r, stabilityMs));
|
|
6130
|
+
return;
|
|
6131
|
+
}
|
|
6132
|
+
} else {
|
|
6133
|
+
evalErrors = 0;
|
|
6134
|
+
if (count !== lastCount) {
|
|
6135
|
+
lastCount = count;
|
|
6136
|
+
stableAt = Date.now();
|
|
6137
|
+
} else if (Date.now() - stableAt >= stabilityMs) {
|
|
6138
|
+
return;
|
|
6139
|
+
}
|
|
5218
6140
|
}
|
|
5219
6141
|
await new Promise(r => setTimeout(r, 100));
|
|
5220
6142
|
}
|
|
5221
6143
|
// Timed out — DOM may still be loading, proceed anyway
|
|
5222
6144
|
}
|
|
5223
6145
|
|
|
5224
|
-
async function waitForScreenshotReady(page: any): Promise<void> {
|
|
5225
|
-
const hardDeadline = Date.now() +
|
|
6146
|
+
async function waitForScreenshotReady(page: any, rawPage?: any): Promise<void> {
|
|
6147
|
+
const hardDeadline = Date.now() + 30_000;
|
|
5226
6148
|
const withHardCap = <T>(p: Promise<T>): Promise<T | void> =>
|
|
5227
6149
|
Promise.race([p, new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, hardDeadline - Date.now())))]);
|
|
6150
|
+
// Use rawPage for load-state waits — Stagehand proxy may not expose waitForLoadState
|
|
6151
|
+
const waitPage = rawPage ?? page;
|
|
6152
|
+
const evalPage = rawPage ?? page;
|
|
5228
6153
|
try {
|
|
5229
|
-
await withHardCap(
|
|
5230
|
-
await withHardCap(
|
|
5231
|
-
await withHardCap(
|
|
6154
|
+
await withHardCap(waitPage.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => { }));
|
|
6155
|
+
await withHardCap(waitPage.waitForLoadState('networkidle', { timeout: 12_000 }).catch(() => { }));
|
|
6156
|
+
await withHardCap(evalPage.evaluate(() => {
|
|
5232
6157
|
if (document.readyState === 'complete') return true;
|
|
5233
6158
|
return new Promise<boolean>((resolve) => {
|
|
5234
6159
|
const done = () => resolve(true);
|
|
@@ -5236,13 +6161,13 @@ async function waitForScreenshotReady(page: any): Promise<void> {
|
|
|
5236
6161
|
setTimeout(done, 4_000);
|
|
5237
6162
|
});
|
|
5238
6163
|
}).catch(() => { }));
|
|
5239
|
-
await withHardCap(
|
|
6164
|
+
await withHardCap(evalPage.evaluate(async () => {
|
|
5240
6165
|
const fonts = (document as any).fonts;
|
|
5241
6166
|
if (fonts?.ready) await fonts.ready;
|
|
5242
6167
|
}).catch(() => { }));
|
|
5243
|
-
await withHardCap(scrollToTriggerLazyLoad(page));
|
|
5244
|
-
await withHardCap(waitForStableDOM(page, 4_000, 700));
|
|
5245
|
-
await withHardCap(
|
|
6168
|
+
await withHardCap(scrollToTriggerLazyLoad(rawPage ?? page, rawPage));
|
|
6169
|
+
await withHardCap(waitForStableDOM(page, 4_000, 700, rawPage));
|
|
6170
|
+
await withHardCap(evalPage.evaluate(async () => {
|
|
5246
6171
|
const pending = Array.from(document.images).filter((img) => !img.complete);
|
|
5247
6172
|
if (pending.length === 0) return;
|
|
5248
6173
|
await Promise.race([
|
|
@@ -5253,7 +6178,7 @@ async function waitForScreenshotReady(page: any): Promise<void> {
|
|
|
5253
6178
|
new Promise<void>((resolve) => setTimeout(resolve, 4_000)),
|
|
5254
6179
|
]);
|
|
5255
6180
|
}).catch(() => { }));
|
|
5256
|
-
await withHardCap(waitForStableDOM(page, 2_000, 500));
|
|
6181
|
+
await withHardCap(waitForStableDOM(page, 2_000, 500, rawPage));
|
|
5257
6182
|
} catch { /* readiness is best-effort; never fail the crawl for a continuously mutating page */ }
|
|
5258
6183
|
}
|
|
5259
6184
|
|
|
@@ -5439,6 +6364,7 @@ async function runAuthDiscoveryPhase(
|
|
|
5439
6364
|
origin: string,
|
|
5440
6365
|
onProgress?: AuthDiscoveryProgress,
|
|
5441
6366
|
): Promise<{ captured: number; discoveredUrls: string[] }> {
|
|
6367
|
+
const tuning = await getCrawlTuning();
|
|
5442
6368
|
const queue: Array<{ url: string; source: AuthDiscoverySource }> = [];
|
|
5443
6369
|
const queued = new Set<string>();
|
|
5444
6370
|
const capturedKeys = new Set<string>();
|
|
@@ -5477,8 +6403,11 @@ async function runAuthDiscoveryPhase(
|
|
|
5477
6403
|
|
|
5478
6404
|
while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
|
|
5479
6405
|
const current = queue.shift()!;
|
|
6406
|
+
const _authPct = 10 + Math.round((captured / AUTH_DISCOVERY_MAX_SCREENS) * 7);
|
|
6407
|
+
const _authPath = current.url.replace(/^https?:\/\/[^/]+/, '') || '/';
|
|
6408
|
+
await onProgress?.(_authPct, `Auth discovery · ${_authPath} · ${captured} found`);
|
|
5480
6409
|
try {
|
|
5481
|
-
const response = await page.goto(current.url, { waitUntil: 'domcontentloaded', timeout:
|
|
6410
|
+
const response = await page.goto(current.url, { waitUntil: 'domcontentloaded', timeout: tuning.navTimeoutMs }).catch(() => null);
|
|
5482
6411
|
if (response && response.status && response.status() >= 400) continue;
|
|
5483
6412
|
await page.waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => { });
|
|
5484
6413
|
await waitForStableDOM(page);
|