@detiq/crawler 1.1.14 → 1.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1377 -344
- 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 +23 -6
- 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 +1196 -150
- 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/index.ts +14 -1
- package/src/job-queue.ts +9 -0
- 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/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,17 @@ 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';
|
|
77
88
|
|
|
78
89
|
async function getCrawlTuning() {
|
|
79
90
|
const { getSystemConfig } = await import('@detiq/core');
|
|
@@ -141,12 +152,27 @@ function isGeneratedDynamicPath(pathname: string): boolean {
|
|
|
141
152
|
return normalized.split('/').filter(Boolean).some(isGeneratedPathSegment);
|
|
142
153
|
}
|
|
143
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
|
+
|
|
144
167
|
function isUsefulNavigationUrl(href: string, siteOrigin: string): boolean {
|
|
145
168
|
try {
|
|
146
169
|
const u = new URL(href);
|
|
147
170
|
if (!sameSite(u.toString(), siteOrigin)) return false;
|
|
148
171
|
if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
|
|
149
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;
|
|
150
176
|
return true;
|
|
151
177
|
} catch {
|
|
152
178
|
return false;
|
|
@@ -695,7 +721,9 @@ export async function crawlProject(opts: {
|
|
|
695
721
|
payload: { profile: domainProfile, routeDecision },
|
|
696
722
|
}).catch((err: any) => console.warn('[crawler] profile evidence save failed:', err?.message ?? err));
|
|
697
723
|
|
|
698
|
-
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.
|
|
699
727
|
throw new Error(
|
|
700
728
|
configuredProvider
|
|
701
729
|
? `No API key configured for provider "${configuredProvider}". Go to Workspace → Settings → AI Provider.`
|
|
@@ -1021,14 +1049,81 @@ export async function crawlProject(opts: {
|
|
|
1021
1049
|
if (pwContext) {
|
|
1022
1050
|
// P4: pass XHR URL discovery callback — adds relative paths to xhrDiscoveredPaths
|
|
1023
1051
|
attachNetworkObserver(pwContext, capturedApiEndpoints, (relPath) => xhrDiscoveredPaths.add(relPath));
|
|
1024
|
-
//
|
|
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;
|
|
1025
1057
|
pwContext.on('page', (popup: any) => {
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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)
|
|
1031
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
|
+
}
|
|
1032
1127
|
}
|
|
1033
1128
|
// Wire rawPage to the real Playwright page from this CDP connection.
|
|
1034
1129
|
// This gives us a true Playwright Page that supports all events (.on('response'), etc.)
|
|
@@ -1148,6 +1243,89 @@ export async function crawlProject(opts: {
|
|
|
1148
1243
|
document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
|
|
1149
1244
|
).catch(() => -1);
|
|
1150
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
|
+
// Detect auth walls that have a tiny number of elements (login button, back link, spinner).
|
|
1292
|
+
// These pages redirect or gate on auth — main BFS must visit them with a live session.
|
|
1293
|
+
// Label prefix or exact match — "Back to Learn" matches "back to", "Sign In" matches "sign in"
|
|
1294
|
+
const AUTH_WALL_LABEL_RE = /^(sign.?in|log.?in|login|platform access|back\s+to|checking\s+auth|get started|create account|register|continue with|join now)/i;
|
|
1295
|
+
// Also treat pages with only `a`-role elements (no inputs/buttons) as likely auth walls
|
|
1296
|
+
const allAnchorOnly = preAuthElements.every(el => el.role === 'a');
|
|
1297
|
+
const looksLikeAuthWall = preAuthElements.length <= 3 && (allAnchorOnly || preAuthElements.every(el => AUTH_WALL_LABEL_RE.test(el.label)));
|
|
1298
|
+
if (looksLikeAuthWall) {
|
|
1299
|
+
logger.info({ url: finalUrl, elements: preAuthElements.length, labels: preAuthElements.map(e => e.label) }, '[crawler] pre-auth auth wall — deferring to authenticated BFS');
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
const pathParts = finalPath.split('/').filter(Boolean);
|
|
1303
|
+
const screenName = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
1304
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
|
|
1305
|
+
if (!screen) continue;
|
|
1306
|
+
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
|
|
1307
|
+
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {});
|
|
1308
|
+
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => undefined);
|
|
1309
|
+
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl).catch(() => {});
|
|
1310
|
+
// Save elements found in the pre-auth view
|
|
1311
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
1312
|
+
for (const el of preAuthElements) {
|
|
1313
|
+
await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.label, el.role, el.expectedData, undefined, undefined, true, undefined, undefined, undefined).catch(() => {});
|
|
1314
|
+
}
|
|
1315
|
+
preAuthPublicCaptured.add(finalUrl);
|
|
1316
|
+
logger.info({ url: finalUrl, screenId: screen.id, elements: preAuthElements.length }, '[crawler] pre-auth public page captured');
|
|
1317
|
+
} catch (err: any) {
|
|
1318
|
+
logger.warn({ url: link, err: err?.message }, '[crawler] pre-auth public page visit failed — skipping');
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Navigate back to start so auth phase and fingerprint comparison are correct
|
|
1323
|
+
if (preAuthPublicCaptured.size > 0) {
|
|
1324
|
+
await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 20_000 }).catch(() => {});
|
|
1325
|
+
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1151
1329
|
// Dedicated logged-out/auth discovery phase. This runs before credentials or
|
|
1152
1330
|
// storageState are applied, so public pages and auth recovery flows remain
|
|
1153
1331
|
// visible even when the main crawl later becomes authenticated.
|
|
@@ -1159,6 +1337,18 @@ export async function crawlProject(opts: {
|
|
|
1159
1337
|
}
|
|
1160
1338
|
}
|
|
1161
1339
|
|
|
1340
|
+
// Auth discovery opens OAuth sign-in popups (Google, GitHub, etc.) which fire CDP events that
|
|
1341
|
+
// can permanently corrupt the Playwright isolated world. Verify the world is intact; if not,
|
|
1342
|
+
// re-navigate to appUrl to force a fresh execution context before BFS begins.
|
|
1343
|
+
{
|
|
1344
|
+
const worldOk = await page.evaluate(() => true).catch(() => false);
|
|
1345
|
+
if (!worldOk) {
|
|
1346
|
+
logger.warn('[crawler] isolated world corrupted after auth discovery — re-navigating to recover');
|
|
1347
|
+
await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
|
|
1348
|
+
await page.waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => {});
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1162
1352
|
let didLogin = false; // track whether AI login was actually performed
|
|
1163
1353
|
|
|
1164
1354
|
if (opts.authToken) {
|
|
@@ -1179,14 +1369,50 @@ export async function crawlProject(opts: {
|
|
|
1179
1369
|
};
|
|
1180
1370
|
|
|
1181
1371
|
if (state.cookies?.length) {
|
|
1182
|
-
//
|
|
1372
|
+
// Apply cookies to both the secondary Playwright context AND Stagehand's own context.
|
|
1373
|
+
// Stagehand V3 uses a separate browser context from the CDP secondary client, so
|
|
1374
|
+
// cookies added only to playwrightBrowser.contexts()[0] don't reach Stagehand's pages.
|
|
1183
1375
|
const browserCtx = playwrightBrowser?.contexts()[0];
|
|
1184
1376
|
if (browserCtx) {
|
|
1185
1377
|
await browserCtx.addCookies(state.cookies as any);
|
|
1186
1378
|
}
|
|
1379
|
+
// Also apply to Stagehand's context via its page proxy
|
|
1380
|
+
try {
|
|
1381
|
+
const stagehandCtx = (page as any).context?.();
|
|
1382
|
+
if (stagehandCtx && typeof stagehandCtx.addCookies === 'function') {
|
|
1383
|
+
await stagehandCtx.addCookies(state.cookies as any);
|
|
1384
|
+
}
|
|
1385
|
+
} catch { /* Stagehand V3 proxy may not expose context() */ }
|
|
1187
1386
|
}
|
|
1188
1387
|
|
|
1189
1388
|
if (state.origins?.length) {
|
|
1389
|
+
// Apply localStorage via addInitScript to ALL contexts:
|
|
1390
|
+
// 1. Secondary Playwright context (rawPage navigations)
|
|
1391
|
+
// 2. Stagehand's page directly (Stagehand V3 uses its own context separate from playwrightBrowser)
|
|
1392
|
+
// Without #2, Stagehand's BFS navigations never get localStorage → pages load unauthenticated.
|
|
1393
|
+
const originCtx = playwrightBrowser?.contexts()[0];
|
|
1394
|
+
if (originCtx) {
|
|
1395
|
+
await originCtx.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
|
+
}
|
|
1405
|
+
// Stagehand's page — covers all BFS navigations via Stagehand
|
|
1406
|
+
await page.addInitScript((origins: typeof state.origins) => {
|
|
1407
|
+
for (const entry of (origins ?? [])) {
|
|
1408
|
+
if (window.location.origin === entry.origin) {
|
|
1409
|
+
for (const item of entry.localStorage) {
|
|
1410
|
+
try { localStorage.setItem(item.name, item.value); } catch { /* quota/sandboxed */ }
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}, state.origins);
|
|
1415
|
+
// Also cover rawPage for the initial navigation below
|
|
1190
1416
|
await rawPage.addInitScript((origins: typeof state.origins) => {
|
|
1191
1417
|
for (const entry of (origins ?? [])) {
|
|
1192
1418
|
if (window.location.origin === entry.origin) {
|
|
@@ -1396,8 +1622,17 @@ export async function crawlProject(opts: {
|
|
|
1396
1622
|
[
|
|
1397
1623
|
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'ref', 'source', '_gl', 'fbclid', 'gclid',
|
|
1398
1624
|
'ts', 'timestamp', 'cache', 'cacheBust', 'cache_bust', '_', 'nonce', 'session', 'sid',
|
|
1625
|
+
// App content/state params — these change the content rendered within the same
|
|
1626
|
+
// page template (a playground example, a search query, a tab selection) but do NOT
|
|
1627
|
+
// represent structurally distinct pages. Not stripping them causes URL explosion:
|
|
1628
|
+
// every unique ?cmd=DSN-XXXXX&example=... or ?q=foo is queued as a new screen.
|
|
1629
|
+
'cmd', 'example', 'query', 'q', 'search', 'keyword', 'keywords',
|
|
1630
|
+
'tab', 'view', 'mode', 'sort', 'order', 'direction', 'orderby',
|
|
1631
|
+
'page', 'p', 'offset', 'limit', 'per_page', 'size', 'rows',
|
|
1632
|
+
// Export format params — same endpoint, different output format, not a new screen
|
|
1633
|
+
'format', 'output', 'export_format', 'download_format', 'fileformat', 'file_format',
|
|
1399
1634
|
// WooCommerce / e-commerce action params
|
|
1400
|
-
'add-to-cart', 'add_to_wishlist', 'quantity', 'wc-ajax',
|
|
1635
|
+
'add-to-cart', 'add_to_wishlist', 'quantity', 'wc-ajax',
|
|
1401
1636
|
'min_price', 'max_price', 'removed_item', 'restore', 'remove_item',
|
|
1402
1637
|
'undo_item', 'coupon_code', 'apply_coupon', 'added-to-cart',
|
|
1403
1638
|
'wc_error', 'wc_notice', 'wc_notice_type', 'update_cart',
|
|
@@ -1440,10 +1675,14 @@ export async function crawlProject(opts: {
|
|
|
1440
1675
|
const gitInfo = getGitInfo();
|
|
1441
1676
|
|
|
1442
1677
|
const visited = new Map<string, string>(); // key = canonical URL
|
|
1678
|
+
const crawlWarnings: string[] = []; // non-fatal step failures surfaced in job result
|
|
1443
1679
|
// Pre-populate from a previous failed job so already-captured URLs are skipped
|
|
1444
1680
|
if (opts.skipUrls?.length) {
|
|
1445
1681
|
for (const u of opts.skipUrls) visited.set(canonicalize(u), 'skipped');
|
|
1446
1682
|
}
|
|
1683
|
+
// Pre-auth public pages already captured above — mark visited so BFS (post-auth)
|
|
1684
|
+
// doesn't overwrite them with authenticated redirects showing app content instead.
|
|
1685
|
+
for (const u of preAuthPublicCaptured) visited.set(canonicalize(u), 'pre-auth');
|
|
1447
1686
|
let checkpointSuccessCount = 0;
|
|
1448
1687
|
let maxSeenTotal = 0; // track high-water mark so progress % never goes backwards
|
|
1449
1688
|
let lastEmittedPct = 20; // floor above auth-discovery + login phases (max ~19%)
|
|
@@ -1504,6 +1743,10 @@ export async function crawlProject(opts: {
|
|
|
1504
1743
|
.filter((s: any) => s.classification !== 'REACHABLE')
|
|
1505
1744
|
.map((s: any) => s.id as string),
|
|
1506
1745
|
);
|
|
1746
|
+
// Snapshot queued set BEFORE filter so canonical dedup works correctly during filter.
|
|
1747
|
+
// Without this, multiple prior screens with the same canonical (e.g. playground?cmd=X1,
|
|
1748
|
+
// playground?cmd=X2 → same canonical) all pass the filter since queued isn't updated yet.
|
|
1749
|
+
const seenReseedCanon = new Set<string>(queued);
|
|
1507
1750
|
const priorScreens = (priorMap?.screens ?? [])
|
|
1508
1751
|
.filter((s: any) => s.status !== 'PENDING_REMOVAL')
|
|
1509
1752
|
.filter((s: any) => {
|
|
@@ -1515,10 +1758,15 @@ export async function crawlProject(opts: {
|
|
|
1515
1758
|
if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
|
|
1516
1759
|
// Don't re-seed generated dynamic routes — they produce duplicate templates.
|
|
1517
1760
|
if (isGeneratedDynamicPath(pathname)) return false;
|
|
1761
|
+
// Don't re-seed API/export/download paths — they were misidentified as screens.
|
|
1762
|
+
if (!isUsefulNavigationUrl(u, startUrl)) return false;
|
|
1763
|
+
// Canonical dedup: mark seen immediately during filter so duplicates are caught.
|
|
1764
|
+
const canon = canonicalize(u);
|
|
1765
|
+
if (seenReseedCanon.has(canon)) return false;
|
|
1766
|
+
seenReseedCanon.add(canon);
|
|
1518
1767
|
return true;
|
|
1519
1768
|
} catch { return false; }
|
|
1520
|
-
})
|
|
1521
|
-
.filter((s: any) => !queued.has(canonicalize(s.url as string)));
|
|
1769
|
+
});
|
|
1522
1770
|
let orphanSeedCount = 0;
|
|
1523
1771
|
for (const s of priorScreens) {
|
|
1524
1772
|
const u = s.url as string;
|
|
@@ -1580,6 +1828,7 @@ export async function crawlProject(opts: {
|
|
|
1580
1828
|
try {
|
|
1581
1829
|
const u = new URL(sUrl);
|
|
1582
1830
|
if (u.origin !== sitemapOrigin) continue;
|
|
1831
|
+
if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) continue;
|
|
1583
1832
|
await page.goto(sUrl, { waitUntil: 'domcontentloaded', timeout: 12_000 });
|
|
1584
1833
|
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => { });
|
|
1585
1834
|
await waitForStableDOM(page);
|
|
@@ -1664,6 +1913,9 @@ export async function crawlProject(opts: {
|
|
|
1664
1913
|
// that slip through canonicalize() if any new filter param type is encountered.
|
|
1665
1914
|
let totalAttemptsThisSegment = 0;
|
|
1666
1915
|
const maxAttempts = Math.max(segmentSize * 4, 200);
|
|
1916
|
+
// Count consecutive login-wall redirects — if too many, abort early (auth-wall site, no creds)
|
|
1917
|
+
let loginWallRedirectCount = 0;
|
|
1918
|
+
const LOGIN_WALL_ABORT_THRESHOLD = Math.max(8, Math.ceil(segmentSize * 0.4));
|
|
1667
1919
|
|
|
1668
1920
|
let pendingSessionRestore: (() => Promise<void>) | null = null;
|
|
1669
1921
|
|
|
@@ -1687,6 +1939,8 @@ export async function crawlProject(opts: {
|
|
|
1687
1939
|
try {
|
|
1688
1940
|
const { pathname } = new URL(url);
|
|
1689
1941
|
if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
|
|
1942
|
+
// Skip API/export paths — these return data files, not navigable pages
|
|
1943
|
+
if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
|
|
1690
1944
|
} catch { continue; }
|
|
1691
1945
|
|
|
1692
1946
|
// Claim URL before first await — prevents duplicate processing across parallel workers
|
|
@@ -1701,6 +1955,28 @@ export async function crawlProject(opts: {
|
|
|
1701
1955
|
// Wait for SPA JS to render components before extracting elements or running soft-404 check.
|
|
1702
1956
|
// domcontentloaded fires before JS executes — networkidle ensures React/Vue/Angular hydration.
|
|
1703
1957
|
await workerPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
|
|
1958
|
+
// Wait for DOM to stop mutating (React/Vue render cycles) before element extraction.
|
|
1959
|
+
await waitForStableDOM(workerPage, 6_000, 400).catch(() => {});
|
|
1960
|
+
// Dismiss cookie banners / GDPR overlays so underlying elements are accessible.
|
|
1961
|
+
await dismissOverlays(workerPage).catch(() => {});
|
|
1962
|
+
|
|
1963
|
+
// Auth wall detection: if navigation redirected to a login page, skip this URL.
|
|
1964
|
+
// Lite worker cannot re-authenticate (no Stagehand access) — mark and skip.
|
|
1965
|
+
const lwCurrentUrl = workerPage.url();
|
|
1966
|
+
const lwPath = (() => { try { return new URL(lwCurrentUrl).pathname.toLowerCase(); } catch { return ''; } })();
|
|
1967
|
+
const lwIsAuthWall = /^\/(login|signin|sign-in|auth|authenticate|session\/new|account\/login|user\/login)/.test(lwPath) && lwCurrentUrl !== url;
|
|
1968
|
+
const lwHasLoginForm = lwIsAuthWall ? false : await workerPage.evaluate(() => {
|
|
1969
|
+
const inputs = document.querySelectorAll('input[type="password"]');
|
|
1970
|
+
return inputs.length > 0;
|
|
1971
|
+
}).catch(() => false);
|
|
1972
|
+
if (lwIsAuthWall) {
|
|
1973
|
+
logger.warn({ url, landed: lwCurrentUrl }, '[lite-worker] auth wall detected — queuing for main worker re-auth');
|
|
1974
|
+
visited.set(canonUrl, '_auth_wall_');
|
|
1975
|
+
if (!revisitQueue.includes(url)) revisitQueue.push(url);
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
// lwHasLoginForm (direct sign-in/register page): process normally — capture the form elements.
|
|
1979
|
+
// Do NOT add to revisitQueue; P3a cannot do anything useful with a login page revisit.
|
|
1704
1980
|
|
|
1705
1981
|
// Soft-404 detection for lite-worker: skip SPA catch-all URLs that mirror homepage
|
|
1706
1982
|
if (homeFingerprintElemCount >= 0 && url !== startUrl) {
|
|
@@ -1725,8 +2001,23 @@ export async function crawlProject(opts: {
|
|
|
1725
2001
|
|
|
1726
2002
|
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
|
|
1727
2003
|
|
|
2004
|
+
let liteElements = await domFallbackElements(workerPage).catch(() => [] as Awaited<ReturnType<typeof domFallbackElements>>);
|
|
2005
|
+
if (liteElements.length === 0) {
|
|
2006
|
+
// domFallbackElements returned nothing — try broader visible-element selector as last resort.
|
|
2007
|
+
liteElements = await workerPage.evaluate(() => {
|
|
2008
|
+
const sel = 'button,a[href],input,select,textarea,[role],[tabindex="0"],[onclick],[data-action]';
|
|
2009
|
+
return Array.from(document.querySelectorAll(sel))
|
|
2010
|
+
.filter((el: any) => el.offsetParent !== null)
|
|
2011
|
+
.slice(0, 150)
|
|
2012
|
+
.map((el: any) => ({
|
|
2013
|
+
meaning: el.getAttribute('aria-label') || el.textContent?.trim().slice(0, 100) || el.tagName.toLowerCase(),
|
|
2014
|
+
role: el.getAttribute('role') || el.tagName.toLowerCase(),
|
|
2015
|
+
expectedData: null, notes: null, boundingRect: null, ariaState: null, parentLandmark: null,
|
|
2016
|
+
}));
|
|
2017
|
+
}).catch(() => []) as any[];
|
|
2018
|
+
}
|
|
1728
2019
|
const [elements] = await Promise.all([
|
|
1729
|
-
|
|
2020
|
+
Promise.resolve(liteElements),
|
|
1730
2021
|
workerPage.screenshot({ path: shotPath, fullPage: true })
|
|
1731
2022
|
.then(async () => {
|
|
1732
2023
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
@@ -1735,25 +2026,184 @@ export async function crawlProject(opts: {
|
|
|
1735
2026
|
.catch(() => {}),
|
|
1736
2027
|
]);
|
|
1737
2028
|
|
|
2029
|
+
// Nav-link pass — merged into main save cycle so clearScreenElements covers them too.
|
|
2030
|
+
// extractElements (main BFS) never runs for lite-worker screens, so nav/footer roles
|
|
2031
|
+
// must be captured here. Running before clear+save prevents additive accumulation.
|
|
2032
|
+
const _navEls = await (workerPage.evaluate as any)(() => {
|
|
2033
|
+
if (typeof (globalThis as any).__name === 'undefined') (globalThis as any).__name = (fn: any) => fn;
|
|
2034
|
+
const seen = new Set<string>();
|
|
2035
|
+
const items: Array<{meaning: string; role: string; notes: string}> = [];
|
|
2036
|
+
function isV(el: Element) {
|
|
2037
|
+
const s = window.getComputedStyle(el);
|
|
2038
|
+
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
2039
|
+
}
|
|
2040
|
+
// Wide nav selector: covers semantic nav, ARIA, header links, sidebar (aside/[role=complementary]),
|
|
2041
|
+
// and common class-based sidebars used by admin/dashboard pages.
|
|
2042
|
+
const NAV_SEL = [
|
|
2043
|
+
'nav a', '[role="navigation"] a', 'header a', 'footer nav a',
|
|
2044
|
+
'[aria-label*="navigation" i] a', '[aria-label*="sidebar" i] a',
|
|
2045
|
+
'aside a', '[role="complementary"] a',
|
|
2046
|
+
'[class*="sidebar" i] a', '[class*="side-nav" i] a', '[class*="sidenav" i] a',
|
|
2047
|
+
'[class*="nav-menu" i] a', '[class*="navmenu" i] a', '[data-nav] a',
|
|
2048
|
+
].join(', ');
|
|
2049
|
+
document.querySelectorAll(NAV_SEL).forEach((a: any) => {
|
|
2050
|
+
if (!isV(a)) return;
|
|
2051
|
+
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
2052
|
+
const href = a.href ?? '';
|
|
2053
|
+
if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
|
|
2054
|
+
let landmark = 'Site Navigation';
|
|
2055
|
+
let cur: any = a.parentElement;
|
|
2056
|
+
while (cur) {
|
|
2057
|
+
const t = cur.tagName?.toLowerCase();
|
|
2058
|
+
const r = cur.getAttribute?.('role')?.toLowerCase();
|
|
2059
|
+
const cl = (cur.className ?? '').toLowerCase();
|
|
2060
|
+
if (t === 'header' || r === 'banner') { landmark = 'Header'; break; }
|
|
2061
|
+
if (t === 'footer' || r === 'contentinfo') { landmark = 'Footer'; break; }
|
|
2062
|
+
if (t === 'aside' || r === 'complementary' || cl.includes('sidebar') || cl.includes('sidenav') || cl.includes('side-nav')) { landmark = 'Sidebar'; break; }
|
|
2063
|
+
if (t === 'nav' || r === 'navigation') { landmark = cur.getAttribute('aria-label')?.trim() || 'Site Navigation'; break; }
|
|
2064
|
+
cur = cur.parentElement;
|
|
2065
|
+
}
|
|
2066
|
+
const key = `nav|${label.toLowerCase()}|${href}`;
|
|
2067
|
+
if (!seen.has(key)) { seen.add(key); items.push({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark }) }); }
|
|
2068
|
+
});
|
|
2069
|
+
let fc = 0;
|
|
2070
|
+
document.querySelectorAll('footer a, [role="contentinfo"] a, [class*="footer" i] a').forEach((a: any) => {
|
|
2071
|
+
if (fc >= 20 || !isV(a)) return;
|
|
2072
|
+
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
2073
|
+
const href = a.href ?? '';
|
|
2074
|
+
if (!label || !href) return;
|
|
2075
|
+
const key = `footer|${label.toLowerCase()}|${href}`;
|
|
2076
|
+
if (!seen.has(key)) { seen.add(key); items.push({ meaning: label, role: 'footer-link', notes: JSON.stringify({ href: href.slice(0, 150) }) }); fc++; }
|
|
2077
|
+
});
|
|
2078
|
+
// Grandparent-grouping fallback: detects sidebar navigation where each link is
|
|
2079
|
+
// individually wrapped (e.g. <div class="flex"><a>link</a></div> × many siblings).
|
|
2080
|
+
// Runs ALWAYS (not gated on 0 results) so header links don't block sidebar detection.
|
|
2081
|
+
// Deduped against hrefs already captured by the semantic pass above.
|
|
2082
|
+
{
|
|
2083
|
+
const navHrefsSeen = new Set<string>();
|
|
2084
|
+
for (const it of items) {
|
|
2085
|
+
try { const h = JSON.parse(it.notes ?? '{}').href; if (h) navHrefsSeen.add(h); } catch { /* skip */ }
|
|
2086
|
+
}
|
|
2087
|
+
const gpGroups = new Map<Element, Array<{label: string; href: string}>>();
|
|
2088
|
+
document.querySelectorAll('a[href]').forEach((a: any) => {
|
|
2089
|
+
if (!isV(a)) return;
|
|
2090
|
+
const href = a.href ?? '';
|
|
2091
|
+
if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsSeen.has(href)) return;
|
|
2092
|
+
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
2093
|
+
if (!label || label.length > 40) return;
|
|
2094
|
+
const parent = a.parentElement;
|
|
2095
|
+
const gp = parent?.parentElement;
|
|
2096
|
+
if (!parent || !gp) return;
|
|
2097
|
+
// Only match when parent wraps exactly 1 anchor — the sidebar <div><a> pattern
|
|
2098
|
+
if (parent.querySelectorAll('a').length !== 1) return;
|
|
2099
|
+
const gpTag = gp.tagName.toLowerCase();
|
|
2100
|
+
if (['body', 'html', 'form'].includes(gpTag)) return;
|
|
2101
|
+
const arr = gpGroups.get(gp) ?? [];
|
|
2102
|
+
arr.push({ label, href });
|
|
2103
|
+
gpGroups.set(gp, arr);
|
|
2104
|
+
});
|
|
2105
|
+
for (const [, links] of gpGroups.entries()) {
|
|
2106
|
+
if (links.length >= 4) {
|
|
2107
|
+
for (const { label, href } of links) {
|
|
2108
|
+
const k = `cluster|${label.toLowerCase()}|${href}`;
|
|
2109
|
+
if (!seen.has(k)) { seen.add(k); items.push({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark: 'Sidebar' }) }); }
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
return items;
|
|
2115
|
+
}).catch(() => [] as any[]);
|
|
2116
|
+
|
|
2117
|
+
const allElements = [
|
|
2118
|
+
...elements,
|
|
2119
|
+
..._navEls.map((ne: any) => ({ ...ne, expectedData: undefined, boundingRect: undefined, ariaState: undefined, parentLandmark: undefined })),
|
|
2120
|
+
];
|
|
2121
|
+
|
|
2122
|
+
// Persist lite-worker elements to DB — without this, 80% of screens have 0 DB elements.
|
|
2123
|
+
if (allElements.length > 0) {
|
|
2124
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
2125
|
+
for (let _ei = 0; _ei < allElements.length; _ei += 4) {
|
|
2126
|
+
const _batch = allElements.slice(_ei, _ei + 4);
|
|
2127
|
+
await Promise.all(_batch.map((el: any) =>
|
|
2128
|
+
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(() => {})
|
|
2129
|
+
));
|
|
2130
|
+
}
|
|
2131
|
+
logger.info({ url, screenId: screen.id, elements: elements.length, navLinks: _navEls.length }, '[lite-worker] screen captured');
|
|
2132
|
+
} else if (!revisitQueue.includes(url)) {
|
|
2133
|
+
// 0-element: queue for main BFS re-visit with full Stagehand AI extraction
|
|
2134
|
+
logger.warn({ url, screenId: screen.id }, '[lite-worker] 0 elements — queued for P3a revisit');
|
|
2135
|
+
revisitQueue.push(url);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Non-mutating probes for lite-worker screens: shadow DOM, a11y tree, service worker.
|
|
2139
|
+
// Awaited with 15s total timeout — these are read-only and safe on the shared worker page.
|
|
2140
|
+
if (screen?.id) {
|
|
2141
|
+
const [shadowRes, a11yRes, swRes] = await Promise.race([
|
|
2142
|
+
Promise.allSettled([
|
|
2143
|
+
crawlShadowDom(workerPage),
|
|
2144
|
+
crawlAccessibilityTree(workerPage),
|
|
2145
|
+
inspectServiceWorker(workerPage),
|
|
2146
|
+
]),
|
|
2147
|
+
new Promise<PromiseSettledResult<never>[]>((resolve) =>
|
|
2148
|
+
setTimeout(() => resolve([
|
|
2149
|
+
{ status: 'rejected' as const, reason: 'timeout' },
|
|
2150
|
+
{ status: 'rejected' as const, reason: 'timeout' },
|
|
2151
|
+
{ status: 'rejected' as const, reason: 'timeout' },
|
|
2152
|
+
]), 15_000)
|
|
2153
|
+
),
|
|
2154
|
+
]);
|
|
2155
|
+
const litePayload: Record<string, unknown> = { screenId: screen.id, url };
|
|
2156
|
+
if (shadowRes.status === 'fulfilled' && shadowRes.value && (shadowRes.value as any).shadowHostCount > 0)
|
|
2157
|
+
litePayload.shadowDom = shadowRes.value;
|
|
2158
|
+
if (a11yRes.status === 'fulfilled' && a11yRes.value)
|
|
2159
|
+
litePayload.accessibilityTree = a11yRes.value;
|
|
2160
|
+
if (swRes.status === 'fulfilled' && (swRes.value as any).supported)
|
|
2161
|
+
litePayload.serviceWorker = swRes.value;
|
|
2162
|
+
if (Object.keys(litePayload).length > 2) {
|
|
2163
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2164
|
+
jobId: opts.jobId, type: 'PER_SCREEN_PROBES', payload: litePayload,
|
|
2165
|
+
}).catch(() => {});
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
1738
2169
|
const nextLinks: string[] = await (workerPage.evaluate as any)((originStr: string) => {
|
|
1739
|
-
return Array.from(document.querySelectorAll('a[href]'))
|
|
2170
|
+
return Array.from(document.querySelectorAll('a[href],[data-href]'))
|
|
1740
2171
|
.map((a: any) => {
|
|
1741
|
-
|
|
2172
|
+
const raw = a.getAttribute('href') || a.getAttribute('data-href') || '';
|
|
2173
|
+
try { return new URL(raw, window.location.href).href; }
|
|
1742
2174
|
catch { return ''; }
|
|
1743
2175
|
})
|
|
1744
|
-
.filter((h: string) =>
|
|
2176
|
+
.filter((h: string) => {
|
|
2177
|
+
if (!h) return false;
|
|
2178
|
+
try {
|
|
2179
|
+
const u = new URL(h);
|
|
2180
|
+
if (u.origin !== originStr) return false;
|
|
2181
|
+
// Keep hash-router paths (#/route, #!/route) but drop plain fragment anchors (#section)
|
|
2182
|
+
if (u.hash && !u.hash.startsWith('#/') && !u.hash.startsWith('#!/')) return false;
|
|
2183
|
+
return true;
|
|
2184
|
+
} catch { return false; }
|
|
2185
|
+
})
|
|
1745
2186
|
.slice(0, 200);
|
|
1746
2187
|
}, origin).catch(() => []);
|
|
1747
2188
|
|
|
1748
2189
|
for (const link of nextLinks) {
|
|
2190
|
+
if (!isUsefulNavigationUrl(link, origin)) continue;
|
|
1749
2191
|
const cLink = canonicalize(link);
|
|
1750
|
-
if (!visited.has(cLink)) {
|
|
2192
|
+
if (!visited.has(cLink) && !queued.has(cLink)) {
|
|
2193
|
+
queued.add(cLink);
|
|
1751
2194
|
queue.push(link);
|
|
1752
2195
|
edges.push({ fromUrl: url, toUrl: link });
|
|
1753
2196
|
}
|
|
1754
2197
|
}
|
|
1755
2198
|
|
|
1756
2199
|
discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
|
|
2200
|
+
// Lite workers also advance the progress bar — without this 4/5 workers are invisible.
|
|
2201
|
+
const lwEffMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
|
|
2202
|
+
maxSeenTotal = Math.max(maxSeenTotal, lwEffMax);
|
|
2203
|
+
const lwRawPct = 20 + Math.round(Math.min(57, (visited.size / Math.max(1, maxSeenTotal)) * 57));
|
|
2204
|
+
const lwPct = Math.max(lastEmittedPct, lwRawPct);
|
|
2205
|
+
lastEmittedPct = lwPct;
|
|
2206
|
+
await onProgress?.(lwPct, `Crawling screens (${visited.size} mapped, ${queue.length} pending)`).catch(() => {});
|
|
1757
2207
|
} catch (err: any) {
|
|
1758
2208
|
logger.warn({ url, err: err?.message ?? String(err) }, '[lite-worker] URL processing failed');
|
|
1759
2209
|
discovered.push({ url, error: err?.message ?? String(err) });
|
|
@@ -1762,7 +2212,7 @@ export async function crawlProject(opts: {
|
|
|
1762
2212
|
}
|
|
1763
2213
|
|
|
1764
2214
|
const liteWorkerPromises: Promise<void>[] = [];
|
|
1765
|
-
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
|
|
2215
|
+
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser && !opts.discoveryMode) {
|
|
1766
2216
|
const pwCtx = playwrightBrowser.contexts()[0];
|
|
1767
2217
|
for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
|
|
1768
2218
|
const wp = await pwCtx.newPage();
|
|
@@ -1793,11 +2243,14 @@ export async function crawlProject(opts: {
|
|
|
1793
2243
|
|
|
1794
2244
|
const url = queue.shift()!;
|
|
1795
2245
|
const canonUrl = canonicalize(url);
|
|
1796
|
-
logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size + 1}/${segmentSize}` }, '[crawler] visiting URL');
|
|
1797
2246
|
const apiObservationStartIndex = capturedApiEndpoints.length;
|
|
1798
2247
|
|
|
1799
2248
|
// Skip if already visited (canonical match — catches ID-parameterized duplicates)
|
|
1800
2249
|
if (visited.has(canonUrl)) continue;
|
|
2250
|
+
// Claim immediately before any await — prevents lite workers from picking up the same URL
|
|
2251
|
+
// concurrently while this iteration is suspended (race that causes repeated visits).
|
|
2252
|
+
visited.set(canonUrl, '_pending_');
|
|
2253
|
+
logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size}/${segmentSize}` }, '[crawler] visiting URL');
|
|
1801
2254
|
|
|
1802
2255
|
// robots.txt compliance — skip disallowed URLs
|
|
1803
2256
|
if (robotsCache && !await robotsCache.isAllowed(url)) {
|
|
@@ -1819,6 +2272,13 @@ export async function crawlProject(opts: {
|
|
|
1819
2272
|
console.log(`[crawler] skip generated dynamic route: ${url}`);
|
|
1820
2273
|
continue;
|
|
1821
2274
|
}
|
|
2275
|
+
// Skip API/export/download paths — navigating to these triggers file downloads.
|
|
2276
|
+
// The main BFS uses Stagehand's context which has no route interception.
|
|
2277
|
+
if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) {
|
|
2278
|
+
console.log(`[crawler] skip API/export/download path: ${url}`);
|
|
2279
|
+
visited.set(canonUrl, '_skipped_api_');
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
1822
2282
|
} catch { /* invalid url, let it fall through */ }
|
|
1823
2283
|
|
|
1824
2284
|
try {
|
|
@@ -1914,13 +2374,18 @@ export async function crawlProject(opts: {
|
|
|
1914
2374
|
}
|
|
1915
2375
|
rawPage.off('response', onRedirectResponse);
|
|
1916
2376
|
if (redirectHops.length > 0) {
|
|
2377
|
+
const isCircularRedirect = new Set(redirectHops.map(h => h.from)).size < redirectHops.length;
|
|
1917
2378
|
redirectRegistry.register({
|
|
1918
2379
|
originalUrl: url, finalUrl: page.url(),
|
|
1919
2380
|
hops: redirectHops.map((h, i) => ({ ...h, durationMs: i === 0 ? Date.now() - navStart : 0 })),
|
|
1920
|
-
isCircular:
|
|
2381
|
+
isCircular: isCircularRedirect,
|
|
1921
2382
|
hopCount: redirectHops.length,
|
|
1922
2383
|
totalDurationMs: Date.now() - navStart,
|
|
1923
2384
|
});
|
|
2385
|
+
if (isCircularRedirect) {
|
|
2386
|
+
logger.warn({ url, hops: redirectHops.map(h => h.from) }, '[crawler] circular redirect detected — skipping URL');
|
|
2387
|
+
continue;
|
|
2388
|
+
}
|
|
1924
2389
|
}
|
|
1925
2390
|
// Update exponential moving average for AutoThrottle
|
|
1926
2391
|
const navMs = Date.now() - navStart;
|
|
@@ -1939,8 +2404,54 @@ export async function crawlProject(opts: {
|
|
|
1939
2404
|
// 3. Modal capture — screenshot app dialogs BEFORE dismissal (they are testable UI states)
|
|
1940
2405
|
// 4. Overlay dismissal — removes cookie banners/modals before element extraction
|
|
1941
2406
|
// 5. Lazy-load scroll — triggers IntersectionObserver for off-screen images/components
|
|
1942
|
-
await page.waitForLoadState('load', { timeout: 8_000 }).catch(() => { });
|
|
1943
|
-
await waitForStableDOM(page);
|
|
2407
|
+
await (rawPage ?? page).waitForLoadState('load', { timeout: 8_000 }).catch(() => { });
|
|
2408
|
+
await waitForStableDOM(page, 8_000, 400, rawPage ?? undefined);
|
|
2409
|
+
// If DOM is sparse after stability check (< 15 interactive elements), SPA may still be
|
|
2410
|
+
// fetching data — skeleton is DOM-stable but empty. Extra networkidle pass catches it.
|
|
2411
|
+
const sparseCheck = await (rawPage ?? page).evaluate(() =>
|
|
2412
|
+
document.querySelectorAll('button,a,input,select,textarea,[role="button"],[aria-label]').length
|
|
2413
|
+
).catch(() => 99);
|
|
2414
|
+
if (sparseCheck < 15) {
|
|
2415
|
+
await (rawPage ?? page).waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => {});
|
|
2416
|
+
await waitForStableDOM(page, 4_000, 300, rawPage ?? undefined);
|
|
2417
|
+
}
|
|
2418
|
+
// Wait for SPA auth-check / loading spinners to resolve before screenshotting.
|
|
2419
|
+
// SPAs often show a "Checking authentication..." or "Loading..." state after networkidle
|
|
2420
|
+
// while client-side auth verifies tokens. waitForStableDOM catches DOM mutations but
|
|
2421
|
+
// returns as soon as the spinner DOM is stable — this catches the spinner itself.
|
|
2422
|
+
// Stagehand V3 proxy may not expose waitForFunction — use try/catch, not .catch(),
|
|
2423
|
+
// because calling an undefined method throws synchronously before a Promise is created.
|
|
2424
|
+
try {
|
|
2425
|
+
const spinnerPage = rawPage ?? page;
|
|
2426
|
+
if (typeof spinnerPage.waitForFunction === 'function') {
|
|
2427
|
+
await spinnerPage.waitForFunction(() => {
|
|
2428
|
+
const body = document.body;
|
|
2429
|
+
if (!body) return true;
|
|
2430
|
+
const text = body.innerText?.toLowerCase() ?? '';
|
|
2431
|
+
// Text-based loading indicators
|
|
2432
|
+
if (
|
|
2433
|
+
text.includes('checking authentication') ||
|
|
2434
|
+
text.includes('checking auth') ||
|
|
2435
|
+
text.includes('authenticating') ||
|
|
2436
|
+
text.includes('verifying session') ||
|
|
2437
|
+
text.includes('please wait') ||
|
|
2438
|
+
text.includes('loading data') ||
|
|
2439
|
+
(text.includes('loading') && text.length < 200)
|
|
2440
|
+
) return false;
|
|
2441
|
+
// ARIA-based: aria-busy on any visible element
|
|
2442
|
+
const busyEl = body.querySelector('[aria-busy="true"]');
|
|
2443
|
+
if (busyEl && (busyEl as HTMLElement).offsetParent !== null) return false;
|
|
2444
|
+
// Data attributes: React Query / SWR / TanStack in-flight indicators
|
|
2445
|
+
if (body.querySelector('[data-loading="true"],[data-pending="true"],[data-fetching="true"]')) return false;
|
|
2446
|
+
// Indeterminate progress bars
|
|
2447
|
+
if (body.querySelector('progress:not([value])')) return false;
|
|
2448
|
+
// Common CSS class patterns for loading skeletons
|
|
2449
|
+
const skeleton = body.querySelector('.skeleton,.skeleton-loader,.shimmer,.pulse-loader,.content-loader');
|
|
2450
|
+
if (skeleton && (skeleton as HTMLElement).offsetParent !== null) return false;
|
|
2451
|
+
return true;
|
|
2452
|
+
}, { timeout: 12_000 });
|
|
2453
|
+
}
|
|
2454
|
+
} catch { /* timed out or unsupported — screenshot whatever is there */ }
|
|
1944
2455
|
|
|
1945
2456
|
if (!opts.discoveryMode) {
|
|
1946
2457
|
// Capture app-generated modals BEFORE dismissing — cookie/GDPR banners excluded.
|
|
@@ -1958,20 +2469,25 @@ export async function crawlProject(opts: {
|
|
|
1958
2469
|
const modalHash = url.replace(/[^a-z0-9]/gi, '').slice(-12);
|
|
1959
2470
|
const modalShotPath = path.join(opts.screenshotDir ?? '/tmp', `modal-${modalHash}.png`);
|
|
1960
2471
|
await page.screenshot({ path: modalShotPath, fullPage: false }).catch(() => { });
|
|
1961
|
-
const modalName = await page.evaluate(() => {
|
|
2472
|
+
const modalName = await (rawPage ?? page).evaluate(() => {
|
|
1962
2473
|
const h = document.querySelector('[role="dialog"] h1,[role="dialog"] h2,[role="dialog"] [role="heading"]');
|
|
1963
2474
|
return h?.textContent?.trim() ?? 'Modal';
|
|
1964
2475
|
}).catch(() => 'Modal');
|
|
1965
2476
|
const modalScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, modalName, url, opts.jobId, discoveryMethodFor(url)).catch(() => null);
|
|
1966
2477
|
if (modalScreen) {
|
|
1967
2478
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, 'DESKTOP').catch(() => { });
|
|
1968
|
-
|
|
2479
|
+
// Extract elements from the modal BEFORE it is dismissed — use CDP path via rawPage
|
|
2480
|
+
const modalElements = await domFallbackElements(page, rawPage ?? undefined).catch(() => []);
|
|
2481
|
+
for (const el of modalElements) {
|
|
2482
|
+
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(() => {});
|
|
2483
|
+
}
|
|
2484
|
+
logger.info({ url, name: modalName, elementCount: modalElements.length }, '[crawler] captured app modal before dismiss');
|
|
1969
2485
|
}
|
|
1970
2486
|
}
|
|
1971
2487
|
} catch { /* non-fatal */ }
|
|
1972
2488
|
await dismissOverlays(page);
|
|
1973
2489
|
// Enhanced infinite scroll — handles lazy-load, infinite lists, and virtual scrollers
|
|
1974
|
-
await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }).catch(() => { });
|
|
2490
|
+
await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }, rawPage ?? undefined).catch(() => { });
|
|
1975
2491
|
}
|
|
1976
2492
|
if (cdpSession?.off) cdpSession.off('Network.responseReceived', secHeadersCdp);
|
|
1977
2493
|
wsDetector.detach();
|
|
@@ -2002,6 +2518,22 @@ export async function crawlProject(opts: {
|
|
|
2002
2518
|
BFS_ERROR_PATH.test(new URL(landedUrl).pathname) || BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)
|
|
2003
2519
|
);
|
|
2004
2520
|
if (redirectedToError) {
|
|
2521
|
+
// Track login-wall redirects: if the site sends every URL to /login without credentials,
|
|
2522
|
+
// abort early rather than burning all maxAttempts on the same login page.
|
|
2523
|
+
if (BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)) {
|
|
2524
|
+
loginWallRedirectCount++;
|
|
2525
|
+
if (!opts.credentials && loginWallRedirectCount >= LOGIN_WALL_ABORT_THRESHOLD) {
|
|
2526
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2527
|
+
jobId: opts.jobId,
|
|
2528
|
+
type: 'AUTH_FAILURE',
|
|
2529
|
+
payload: {
|
|
2530
|
+
url, landed: landedUrl,
|
|
2531
|
+
reason: `Login wall detected: ${loginWallRedirectCount} consecutive URLs redirected to login. Add credentials in project settings to crawl authenticated pages.`,
|
|
2532
|
+
},
|
|
2533
|
+
}).catch(() => {});
|
|
2534
|
+
throw new Error(`[AUTH_FAILURE] Login wall detected: ${loginWallRedirectCount} URLs redirected to the login page. Add credentials in project settings and retry the crawl.`);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2005
2537
|
// We asked for /dashboard but landed on /session-expired — session expired mid-crawl.
|
|
2006
2538
|
logger.warn({ requested: url, landed: landedUrl }, '[crawler] Mid-crawl redirect to auth/error — attempting re-auth');
|
|
2007
2539
|
let reauthed = false;
|
|
@@ -2016,8 +2548,8 @@ export async function crawlProject(opts: {
|
|
|
2016
2548
|
if (reauthed) {
|
|
2017
2549
|
logger.info({ url }, '[crawler] Re-auth succeeded — retrying URL');
|
|
2018
2550
|
await page.goto(url, { waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
2019
|
-
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
2020
|
-
await waitForStableDOM(page);
|
|
2551
|
+
await (rawPage ?? page).waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
2552
|
+
await waitForStableDOM(page, 8_000, 400, rawPage ?? undefined);
|
|
2021
2553
|
const afterReauthUrl = page.url();
|
|
2022
2554
|
if (BFS_ERROR_PATH.test(new URL(afterReauthUrl).pathname) || BFS_LOGIN_PATH.test(new URL(afterReauthUrl).pathname)) {
|
|
2023
2555
|
logger.warn({ url }, '[crawler] Still redirecting after re-auth — skipping this URL');
|
|
@@ -2033,6 +2565,13 @@ export async function crawlProject(opts: {
|
|
|
2033
2565
|
throw new Error('[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.');
|
|
2034
2566
|
}
|
|
2035
2567
|
} else {
|
|
2568
|
+
// Discovery mode: skip auth-required URLs and continue discovering other links.
|
|
2569
|
+
// Throwing here aborts the entire BFS — in discovery mode we want to continue
|
|
2570
|
+
// visiting other queued URLs (e.g. sitemap seeds) that may not require auth.
|
|
2571
|
+
if (opts.discoveryMode) {
|
|
2572
|
+
logger.info({ url, landed: landedUrl }, '[discover] skipping auth-required URL — no credentials, continuing BFS');
|
|
2573
|
+
continue;
|
|
2574
|
+
}
|
|
2036
2575
|
logger.warn('[crawler] Session expired mid-crawl, no credentials to recover — skipping URL');
|
|
2037
2576
|
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2038
2577
|
jobId: opts.jobId,
|
|
@@ -2047,6 +2586,8 @@ export async function crawlProject(opts: {
|
|
|
2047
2586
|
if (!redirectedToError && landedUrl !== url) {
|
|
2048
2587
|
logger.info({ requested: url, landed: landedUrl }, '[crawler] navigated with redirect');
|
|
2049
2588
|
}
|
|
2589
|
+
// Successful non-login page visit: reset the consecutive login-wall counter
|
|
2590
|
+
if (!redirectedToError) loginWallRedirectCount = 0;
|
|
2050
2591
|
|
|
2051
2592
|
// Discovery mode fast-path: record URL + extract DOM links only; skip screenshots/DB/AI.
|
|
2052
2593
|
// IMPORTANT: DOM hash dedup is intentionally DISABLED in discovery mode — SPAs use identical
|
|
@@ -2194,8 +2735,10 @@ export async function crawlProject(opts: {
|
|
|
2194
2735
|
}
|
|
2195
2736
|
|
|
2196
2737
|
// Normal crawl mode: DOM hash dedup — detect duplicate-structure pages (SPA 404s, template clones).
|
|
2738
|
+
// Skip dedup when hash is empty (computeDomHash failed) — two failed-hash pages would
|
|
2739
|
+
// incorrectly match each other and the second would be silently dropped.
|
|
2197
2740
|
const preDomHash = await computeDomHash(page);
|
|
2198
|
-
if (seenDomHashes.has(preDomHash)) {
|
|
2741
|
+
if (preDomHash !== '' && seenDomHashes.has(preDomHash)) {
|
|
2199
2742
|
console.log(`[crawler] skip ${url} — duplicate DOM structure (same content as prior page)`);
|
|
2200
2743
|
continue;
|
|
2201
2744
|
}
|
|
@@ -2322,7 +2865,7 @@ export async function crawlProject(opts: {
|
|
|
2322
2865
|
}
|
|
2323
2866
|
|
|
2324
2867
|
// Append significant query params
|
|
2325
|
-
const SIG_PARAMS = ['product_cat', 'category', 'cat', 'type', 'tag', 'brand', 'filter', 'tab', 'section', 'view'];
|
|
2868
|
+
const SIG_PARAMS = ['product_cat', 'category', 'cat', 'type', 'tag', 'brand', 'filter', 'tab', 'section', 'view', 'play', 'mode', 'id', 'page'];
|
|
2326
2869
|
const paramSuffix = SIG_PARAMS
|
|
2327
2870
|
.map((p) => { const v = u.searchParams.get(p); return v ? ` · ${v.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}` : ''; })
|
|
2328
2871
|
.find(Boolean) ?? '';
|
|
@@ -2345,11 +2888,12 @@ export async function crawlProject(opts: {
|
|
|
2345
2888
|
const candidate = parts[0].trim();
|
|
2346
2889
|
if (candidate.length > 2 && !/^\d[\d\s]*$/.test(candidate)) return candidate;
|
|
2347
2890
|
}
|
|
2348
|
-
// Single-segment title — only use if multi-word
|
|
2891
|
+
// Single-segment title — only use if multi-word, not brand-name-like, and not the generic SPA app title
|
|
2349
2892
|
const candidate = parts[0].trim();
|
|
2350
2893
|
const isBrand = candidate.split(/\s+/).length === 1 &&
|
|
2351
2894
|
(candidate.toLowerCase().includes(brandName) || candidate.toLowerCase() === brandName);
|
|
2352
|
-
|
|
2895
|
+
const isSiteGenericTitle = homeFingerprintTitle !== '' && candidate === homeFingerprintTitle;
|
|
2896
|
+
if (!isBrand && !isSiteGenericTitle && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
|
|
2353
2897
|
return candidate;
|
|
2354
2898
|
}
|
|
2355
2899
|
}
|
|
@@ -2467,31 +3011,33 @@ export async function crawlProject(opts: {
|
|
|
2467
3011
|
logger.info({ url, cachedCount: cachedLinks.length }, '[crawler] link-discovery: using cached recipe — AI will still run and merge');
|
|
2468
3012
|
}
|
|
2469
3013
|
|
|
2470
|
-
// Emit progress now — navigation
|
|
2471
|
-
//
|
|
2472
|
-
//
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
3014
|
+
// Emit progress now — navigation done, page stable.
|
|
3015
|
+
// Use the same visited-count formula as the post-capture block so progress
|
|
3016
|
+
// advances on EVERY URL visit (not just on successful screen saves).
|
|
3017
|
+
{
|
|
3018
|
+
const effMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
|
|
3019
|
+
maxSeenTotal = Math.max(maxSeenTotal, effMax);
|
|
3020
|
+
const rawPrePct = 20 + Math.round(Math.min(57, (visited.size / Math.max(1, maxSeenTotal)) * 57));
|
|
3021
|
+
const preCapturePct = Math.max(lastEmittedPct, rawPrePct);
|
|
3022
|
+
lastEmittedPct = preCapturePct;
|
|
3023
|
+
await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} · ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3027
|
+
|
|
3028
|
+
// P3a: Async content detection — count AFTER networkidle+lazy-load so lazy-fetched content
|
|
3029
|
+
// is included. Previously captured before waitForScreenshotReady which caused AI skip logic
|
|
3030
|
+
// to see skeleton element counts instead of real populated counts.
|
|
2483
3031
|
const preDomCount = await page.evaluate(() =>
|
|
2484
3032
|
document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
|
|
2485
3033
|
).catch(() => 0);
|
|
2486
|
-
|
|
2487
|
-
await waitForScreenshotReady(page);
|
|
2488
3034
|
await onProgress?.(lastEmittedPct, `Capturing ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
|
|
2489
3035
|
|
|
2490
3036
|
// FM-4: capture device pixel ratio for coordinate scaling in element highlighting
|
|
2491
|
-
const pageDpr: number = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
|
|
3037
|
+
const pageDpr: number = await (rawPage ?? page).evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
|
|
2492
3038
|
|
|
2493
3039
|
// Extract elements first so we can highlight them in the screenshot
|
|
2494
|
-
const elements = await extractElements(stagehand, page, elementLLM ?? undefined).catch((err: any) => {
|
|
3040
|
+
const elements = await extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch((err: any) => {
|
|
2495
3041
|
console.error('[crawler] extractElements failed:', err);
|
|
2496
3042
|
return [] as Awaited<ReturnType<typeof extractElements>>;
|
|
2497
3043
|
});
|
|
@@ -2550,8 +3096,8 @@ export async function crawlProject(opts: {
|
|
|
2550
3096
|
.catch((err: any) => console.error('[crawler] page.screenshot failed:', err)),
|
|
2551
3097
|
// Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
|
|
2552
3098
|
// Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
|
|
2553
|
-
//
|
|
2554
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0
|
|
3099
|
+
// Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
|
|
3100
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined)
|
|
2555
3101
|
.then((freshLinks) => {
|
|
2556
3102
|
if (aiSkipCount > 0) aiSkipCount--;
|
|
2557
3103
|
aiErrorStreak = 0;
|
|
@@ -2582,7 +3128,7 @@ export async function crawlProject(opts: {
|
|
|
2582
3128
|
}
|
|
2583
3129
|
return fallback;
|
|
2584
3130
|
}),
|
|
2585
|
-
readScreen(page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>),
|
|
3131
|
+
readScreen(rawPage ?? page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>),
|
|
2586
3132
|
]);
|
|
2587
3133
|
|
|
2588
3134
|
// Cleanup highlights
|
|
@@ -2601,6 +3147,115 @@ export async function crawlProject(opts: {
|
|
|
2601
3147
|
}
|
|
2602
3148
|
}).catch(() => { });
|
|
2603
3149
|
|
|
3150
|
+
// Per-screen probes: split read-only (awaited, safe on main page) vs mutating (isolated page).
|
|
3151
|
+
// READ-ONLY probes are awaited before BFS navigates — DOM must reflect current screen.
|
|
3152
|
+
// MUTATING probes (dropdowns/CRUD/filters/advanced) open a fresh page to isolate state changes.
|
|
3153
|
+
{
|
|
3154
|
+
// Resolve browser context from rawPage (actual Playwright page, not Stagehand proxy).
|
|
3155
|
+
const safeContext: import('playwright').BrowserContext | null =
|
|
3156
|
+
typeof (rawPage as any)?.context === 'function'
|
|
3157
|
+
? (rawPage as any).context()
|
|
3158
|
+
: typeof (page as any)?.context === 'function'
|
|
3159
|
+
? (page as any).context()
|
|
3160
|
+
: playwrightBrowser?.contexts()[0] ?? null;
|
|
3161
|
+
|
|
3162
|
+
// --- Read-only probes: awaited with 25s timeout, never mutate page state ---
|
|
3163
|
+
const roTimeout = new Promise<PromiseSettledResult<never>[]>((resolve) =>
|
|
3164
|
+
setTimeout(() => resolve([
|
|
3165
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3166
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3167
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3168
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3169
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3170
|
+
]), 25_000)
|
|
3171
|
+
);
|
|
3172
|
+
const [nestedCtxRes, shadowRes, a11yRes, perfRes, swRes] = await Promise.race([
|
|
3173
|
+
Promise.allSettled([
|
|
3174
|
+
crawlNestedContexts(rawPage ?? page, safeContext, url),
|
|
3175
|
+
crawlShadowDom(rawPage ?? page),
|
|
3176
|
+
crawlAccessibilityTree(rawPage ?? page),
|
|
3177
|
+
collectPerfMetrics(rawPage ?? page, url),
|
|
3178
|
+
inspectServiceWorker(rawPage ?? page),
|
|
3179
|
+
]),
|
|
3180
|
+
roTimeout,
|
|
3181
|
+
]);
|
|
3182
|
+
|
|
3183
|
+
const roPayload: Record<string, unknown> = { screenId: screen.id, url };
|
|
3184
|
+
if (nestedCtxRes.status === 'fulfilled' && nestedCtxRes.value && (nestedCtxRes.value as any).totalContextsFound > 0)
|
|
3185
|
+
roPayload.nestedContexts = nestedCtxRes.value;
|
|
3186
|
+
if (shadowRes.status === 'fulfilled' && shadowRes.value && (shadowRes.value as any).shadowHostCount > 0)
|
|
3187
|
+
roPayload.shadowDom = shadowRes.value;
|
|
3188
|
+
if (a11yRes.status === 'fulfilled' && a11yRes.value)
|
|
3189
|
+
roPayload.accessibilityTree = a11yRes.value;
|
|
3190
|
+
if (perfRes.status === 'fulfilled' && (perfRes.value as any).requestCount > 0)
|
|
3191
|
+
roPayload.performance = perfRes.value;
|
|
3192
|
+
if (swRes.status === 'fulfilled' && (swRes.value as any).supported)
|
|
3193
|
+
roPayload.serviceWorker = swRes.value;
|
|
3194
|
+
if (Object.keys(roPayload).length > 2) {
|
|
3195
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3196
|
+
jobId: opts.jobId, type: 'PER_SCREEN_PROBES', payload: roPayload,
|
|
3197
|
+
}).catch(() => {});
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
// --- Mutating probes (interactive): isolated page, fire-and-forget, only when interactive ---
|
|
3201
|
+
if (screen?.id && opts.interactive !== false && safeContext) {
|
|
3202
|
+
void (async () => {
|
|
3203
|
+
let probePage: import('playwright').Page | null = null;
|
|
3204
|
+
try {
|
|
3205
|
+
// Cap concurrent probe pages: if too many already open, skip to prevent resource exhaustion.
|
|
3206
|
+
const openProbePages = safeContext.pages().length;
|
|
3207
|
+
if (openProbePages > 8) {
|
|
3208
|
+
logger.warn({ url, openProbePages }, '[crawler] probe page cap reached — skipping mutating probes for this screen');
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
probePage = await safeContext.newPage();
|
|
3212
|
+
await probePage.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
|
|
3213
|
+
await probePage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
|
|
3214
|
+
await waitForStableDOM(probePage as any, 4_000, 400).catch(() => {});
|
|
3215
|
+
|
|
3216
|
+
const muTimeout = new Promise<PromiseSettledResult<never>[]>((resolve) =>
|
|
3217
|
+
setTimeout(() => resolve([
|
|
3218
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3219
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3220
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3221
|
+
{ status: 'rejected' as const, reason: 'probe-timeout' },
|
|
3222
|
+
]), 60_000)
|
|
3223
|
+
);
|
|
3224
|
+
const [dropEnumRes, crudRes, filterRes, advancedRes] = await Promise.race([
|
|
3225
|
+
Promise.allSettled([
|
|
3226
|
+
enumerateDropdowns(probePage),
|
|
3227
|
+
probeCrudFlows(probePage, name),
|
|
3228
|
+
crawlFiltersAndPagination(probePage),
|
|
3229
|
+
probeAdvancedUI(probePage),
|
|
3230
|
+
]),
|
|
3231
|
+
muTimeout,
|
|
3232
|
+
]);
|
|
3233
|
+
|
|
3234
|
+
const muPayload: Record<string, unknown> = { screenId: screen.id, url };
|
|
3235
|
+
if (dropEnumRes.status === 'fulfilled' && (dropEnumRes.value as any).totalOptionsFound > 0)
|
|
3236
|
+
muPayload.dropdowns = dropEnumRes.value;
|
|
3237
|
+
if (crudRes.status === 'fulfilled' && ((crudRes.value as any).flows?.length > 0 || (crudRes.value as any).toastsObserved?.length > 0))
|
|
3238
|
+
muPayload.crudFlows = crudRes.value;
|
|
3239
|
+
if (filterRes.status === 'fulfilled' && (filterRes.value as any).filters?.length > 0)
|
|
3240
|
+
muPayload.filtersAndPagination = filterRes.value;
|
|
3241
|
+
if (advancedRes.status === 'fulfilled' && advancedRes.value &&
|
|
3242
|
+
((advancedRes.value as any).datePickers?.length > 0 || (advancedRes.value as any).sliders?.length > 0 ||
|
|
3243
|
+
(advancedRes.value as any).typeaheads?.length > 0 || (advancedRes.value as any).dragDrop?.library !== 'none'))
|
|
3244
|
+
muPayload.advancedUI = advancedRes.value;
|
|
3245
|
+
if (Object.keys(muPayload).length > 2) {
|
|
3246
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3247
|
+
jobId: opts.jobId, type: 'PER_SCREEN_PROBES_MUTATING', payload: muPayload,
|
|
3248
|
+
}).catch(() => {});
|
|
3249
|
+
}
|
|
3250
|
+
} catch {
|
|
3251
|
+
// non-fatal — probe failures must not block BFS
|
|
3252
|
+
} finally {
|
|
3253
|
+
await probePage?.close().catch(() => {});
|
|
3254
|
+
}
|
|
3255
|
+
})();
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
|
|
2604
3259
|
// Capture DOM snapshot for structural analysis, and extract Markdown/text
|
|
2605
3260
|
// content from the same DOM (crawl4AI-parity text output) — no extra fetch.
|
|
2606
3261
|
try {
|
|
@@ -2769,10 +3424,38 @@ export async function crawlProject(opts: {
|
|
|
2769
3424
|
try {
|
|
2770
3425
|
const baseline = await AppBrain.getVisualBaselineForDiff(opts.tenantId, screen.id, 'DESKTOP', gitInfo.branch);
|
|
2771
3426
|
if (baseline && baseline.storagePath !== shotPath) {
|
|
3427
|
+
// Resolve baseline file path — local copy may have been rotated; fall back to cloud download
|
|
3428
|
+
let baselinePath: string = baseline.storagePath;
|
|
3429
|
+
let tempBaselinePath: string | null = null;
|
|
3430
|
+
const localExists = await fs.access(baseline.storagePath).then(() => true, () => false);
|
|
3431
|
+
if (!localExists) {
|
|
3432
|
+
if (baseline.cloudUrl) {
|
|
3433
|
+
try {
|
|
3434
|
+
const resp = await fetch(baseline.cloudUrl);
|
|
3435
|
+
if (resp.ok) {
|
|
3436
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
3437
|
+
tempBaselinePath = `${shotPath}.baseline-tmp.png`;
|
|
3438
|
+
await fs.writeFile(tempBaselinePath, buf);
|
|
3439
|
+
baselinePath = tempBaselinePath;
|
|
3440
|
+
logger.info({ screenId: screen.id }, '[crawler] VisualDiff baseline downloaded from cloud storage');
|
|
3441
|
+
} else {
|
|
3442
|
+
logger.warn({ screenId: screen.id, status: resp.status }, '[crawler] VisualDiff skipped — baseline cloud fetch returned non-200');
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
} catch (dlErr) {
|
|
3446
|
+
logger.warn({ screenId: screen.id, err: String(dlErr) }, '[crawler] VisualDiff skipped — baseline cloud download failed');
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
} else {
|
|
3450
|
+
logger.warn({ screenId: screen.id }, '[crawler] VisualDiff skipped — baseline not on disk and no cloudUrl available');
|
|
3451
|
+
return;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
try {
|
|
2772
3455
|
const { screenshotDiffPercent, gridLayoutDiff } = await import('./phash.js');
|
|
2773
3456
|
const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
|
|
2774
|
-
screenshotDiffPercent(
|
|
2775
|
-
gridLayoutDiff(
|
|
3457
|
+
screenshotDiffPercent(baselinePath, shotPath, baseline.ignoredRegions),
|
|
3458
|
+
gridLayoutDiff(baselinePath, shotPath, baseline.ignoredRegions),
|
|
2776
3459
|
AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, 'DESKTOP'),
|
|
2777
3460
|
]);
|
|
2778
3461
|
let verdict = pHashVerdict(pHashDistance, changeLevel, [
|
|
@@ -2793,10 +3476,10 @@ export async function crawlProject(opts: {
|
|
|
2793
3476
|
const [baselineBuffer, newBuffer] = regions.length > 0
|
|
2794
3477
|
? await (async () => {
|
|
2795
3478
|
const { applyMask } = await import('./phash.js');
|
|
2796
|
-
return Promise.all([applyMask(
|
|
3479
|
+
return Promise.all([applyMask(baselinePath, regions), applyMask(shotPath, regions)]);
|
|
2797
3480
|
})()
|
|
2798
3481
|
: await Promise.all([
|
|
2799
|
-
fs.readFile(
|
|
3482
|
+
fs.readFile(baselinePath),
|
|
2800
3483
|
fs.readFile(shotPath),
|
|
2801
3484
|
]);
|
|
2802
3485
|
const aiResult = await aiVisualCompare({
|
|
@@ -2839,7 +3522,7 @@ export async function crawlProject(opts: {
|
|
|
2839
3522
|
// R5: Generate and upload pixelmatch diff image (changed pixels in red)
|
|
2840
3523
|
try {
|
|
2841
3524
|
const { generatePixelmatchDiff } = await import('./phash.js');
|
|
2842
|
-
const diffBuffer = await generatePixelmatchDiff(
|
|
3525
|
+
const diffBuffer = await generatePixelmatchDiff(baselinePath, shotPath, baseline.ignoredRegions ?? []);
|
|
2843
3526
|
const diffTmpPath = `${shotPath}.diff.png`;
|
|
2844
3527
|
await fs.writeFile(diffTmpPath, diffBuffer);
|
|
2845
3528
|
const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, 'visual-diffs', `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
|
|
@@ -2863,19 +3546,45 @@ export async function crawlProject(opts: {
|
|
|
2863
3546
|
aiSummary,
|
|
2864
3547
|
});
|
|
2865
3548
|
}
|
|
3549
|
+
} finally {
|
|
3550
|
+
if (tempBaselinePath) fs.unlink(tempBaselinePath).catch(() => {});
|
|
3551
|
+
}
|
|
2866
3552
|
}
|
|
2867
3553
|
} catch (err: any) {
|
|
2868
|
-
|
|
2869
|
-
if (missing) {
|
|
2870
|
-
logger.warn({ screenId: screen.id, version: screen.version, err: String(err?.message ?? err) }, '[crawler] VisualDiff skipped — baseline screenshot not on disk (stored in cloud only). Run a full recrawl to regenerate baselines.');
|
|
2871
|
-
} else {
|
|
2872
|
-
console.warn('[crawler] VisualDiff pipeline failed:', err);
|
|
2873
|
-
}
|
|
3554
|
+
console.warn('[crawler] VisualDiff pipeline failed:', err);
|
|
2874
3555
|
}
|
|
2875
3556
|
})();
|
|
2876
3557
|
}
|
|
2877
3558
|
|
|
2878
3559
|
const linkMappings = await extractLinkMappings(page, origin);
|
|
3560
|
+
|
|
3561
|
+
// Auth-wall guard: check if the page drifted to a login/error URL during the async
|
|
3562
|
+
// element extraction window (30-90s). If so, the `elements` array contains login-page
|
|
3563
|
+
// elements — saving them would corrupt the screen's element catalog.
|
|
3564
|
+
const postExtractUrl = (() => { try { return page.url(); } catch { return url; } })();
|
|
3565
|
+
const postExtractParsed = (() => { try { return new URL(postExtractUrl); } catch { return new URL(url); } })();
|
|
3566
|
+
const driftedToAuthWall = !weWantedThisPage && (
|
|
3567
|
+
BFS_LOGIN_PATH.test(postExtractParsed.pathname) || BFS_ERROR_PATH.test(postExtractParsed.pathname)
|
|
3568
|
+
);
|
|
3569
|
+
if (driftedToAuthWall) {
|
|
3570
|
+
logger.warn({ url, postExtractUrl }, '[crawler] Page drifted to auth wall during element extraction — skipping element save to prevent login-page element contamination');
|
|
3571
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3572
|
+
screenId: screen.id,
|
|
3573
|
+
jobId: opts.jobId,
|
|
3574
|
+
type: 'AUTH_FAILURE',
|
|
3575
|
+
payload: { url, landed: postExtractUrl, reason: 'Session expired during element extraction. Re-capture session and re-crawl this page.' },
|
|
3576
|
+
}).catch(() => {});
|
|
3577
|
+
} else {
|
|
3578
|
+
if (elements.length === 0) {
|
|
3579
|
+
// Extraction returned nothing — keep existing elements rather than wiping them.
|
|
3580
|
+
// Causes: page.evaluate failure, contentFallbackElements threw, SPA not yet hydrated.
|
|
3581
|
+
logger.warn({ url, screenId: screen.id }, '[crawler] extractElements returned 0 — preserving existing elements to avoid data loss');
|
|
3582
|
+
// Queue for re-visit with longer wait — SPA content may not have hydrated yet
|
|
3583
|
+
if (!revisitQueue.includes(url)) {
|
|
3584
|
+
revisitQueue.push(url);
|
|
3585
|
+
logger.info({ url }, '[crawler] 0-element page queued for re-visit with extended hydration wait');
|
|
3586
|
+
}
|
|
3587
|
+
} else {
|
|
2879
3588
|
// Clear existing elements before re-saving — prevents duplicates on re-crawl
|
|
2880
3589
|
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
2881
3590
|
// Batch element saves: 4 concurrent DB writes instead of serial round-trips
|
|
@@ -2895,6 +3604,7 @@ export async function crawlProject(opts: {
|
|
|
2895
3604
|
AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, (saved as any).id, selectors).catch(console.warn);
|
|
2896
3605
|
}));
|
|
2897
3606
|
}
|
|
3607
|
+
} // end inner else: elements.length > 0
|
|
2898
3608
|
for (const lm of linkMappings) {
|
|
2899
3609
|
const safety = assessActionSafety(lm.label, domainProfile);
|
|
2900
3610
|
if (!safety.allowed) {
|
|
@@ -3056,6 +3766,7 @@ export async function crawlProject(opts: {
|
|
|
3056
3766
|
} catch { /* non-fatal — Skyvern unavailable */ }
|
|
3057
3767
|
}
|
|
3058
3768
|
}
|
|
3769
|
+
} // end auth-wall guard else
|
|
3059
3770
|
|
|
3060
3771
|
if (rawPerf) {
|
|
3061
3772
|
performanceMetrics.push({ url, screenName: name, ...rawPerf });
|
|
@@ -3169,16 +3880,16 @@ export async function crawlProject(opts: {
|
|
|
3169
3880
|
if (visited.size >= maxScreens) break;
|
|
3170
3881
|
try {
|
|
3171
3882
|
await page.goto(skyvernUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
3172
|
-
await
|
|
3883
|
+
await new Promise<void>(r => setTimeout(r, 1500));
|
|
3173
3884
|
const skyvernName = new URL(skyvernUrl).pathname.split('/').filter(Boolean).pop() ?? 'Screen';
|
|
3174
3885
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, skyvernName, skyvernUrl, opts.jobId, 'SKYVERN_AI');
|
|
3175
3886
|
const shotPath = `${opts.screenshotDir ?? '/tmp'}/${screen.id}-v${screen.version}.png`;
|
|
3176
|
-
await waitForScreenshotReady(page);
|
|
3887
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3177
3888
|
await page.screenshot({ path: shotPath, fullPage: true });
|
|
3178
3889
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP');
|
|
3179
3890
|
const [elems, links, linkMappings] = await Promise.all([
|
|
3180
|
-
extractElements(stagehand, page, elementLLM ?? undefined),
|
|
3181
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0),
|
|
3891
|
+
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
|
|
3892
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined),
|
|
3182
3893
|
extractLinkMappings(page, origin),
|
|
3183
3894
|
]);
|
|
3184
3895
|
for (const el of elems) {
|
|
@@ -3209,15 +3920,15 @@ export async function crawlProject(opts: {
|
|
|
3209
3920
|
if (!isUsefulNavigationUrl(href, origin)) continue;
|
|
3210
3921
|
try {
|
|
3211
3922
|
await page.goto(href, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
3212
|
-
await
|
|
3923
|
+
await new Promise<void>(r => setTimeout(r, 1000));
|
|
3213
3924
|
const nm = new URL(href).pathname.split('/').filter(Boolean).pop() ?? 'Screen';
|
|
3214
3925
|
const sc = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, 'SKYVERN_AI');
|
|
3215
3926
|
const sp = `${opts.screenshotDir ?? '/tmp'}/${sc.id}-v${sc.version}.png`;
|
|
3216
|
-
await waitForScreenshotReady(page);
|
|
3927
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3217
3928
|
await page.screenshot({ path: sp, fullPage: true });
|
|
3218
3929
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, 'DESKTOP');
|
|
3219
3930
|
const [elems2, linkMappings2] = await Promise.all([
|
|
3220
|
-
extractElements(stagehand, page, elementLLM ?? undefined),
|
|
3931
|
+
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
|
|
3221
3932
|
extractLinkMappings(page, origin),
|
|
3222
3933
|
]);
|
|
3223
3934
|
for (const el of elems2) {
|
|
@@ -3265,7 +3976,9 @@ export async function crawlProject(opts: {
|
|
|
3265
3976
|
);
|
|
3266
3977
|
}
|
|
3267
3978
|
} catch (err) {
|
|
3268
|
-
|
|
3979
|
+
const msg = String(err);
|
|
3980
|
+
logger.warn({ err: msg }, '[P1] URL pattern completion failed — non-fatal, continuing');
|
|
3981
|
+
crawlWarnings.push(`url-completer skipped: ${msg.includes('401') || msg.toLowerCase().includes('api key') || msg.toLowerCase().includes('authentication') ? 'LLM API key invalid or missing' : msg}`);
|
|
3269
3982
|
}
|
|
3270
3983
|
|
|
3271
3984
|
// P3a: Async content re-visit — revisit pages where DOM grew significantly during extraction.
|
|
@@ -3274,23 +3987,52 @@ export async function crawlProject(opts: {
|
|
|
3274
3987
|
await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content…`);
|
|
3275
3988
|
logger.info({ count: revisitQueue.length }, '[P3a] Starting async content re-visit pass');
|
|
3276
3989
|
for (const revisitUrl of revisitQueue.slice(0, 10)) { // cap at 10 re-visits
|
|
3277
|
-
const
|
|
3278
|
-
|
|
3990
|
+
const rawScreenId = visited.get(canonicalize(revisitUrl));
|
|
3991
|
+
// Auth-wall pages are stored as '_auth_wall_' — not a real screen ID.
|
|
3992
|
+
// Navigate with the authenticated main page; it will redirect to the real content.
|
|
3993
|
+
const isAuthWallRevisit = rawScreenId === '_auth_wall_';
|
|
3994
|
+
if (!rawScreenId) continue;
|
|
3279
3995
|
try {
|
|
3280
3996
|
await page.goto(revisitUrl, { waitUntil: 'networkidle', timeout: 25_000 });
|
|
3281
|
-
await
|
|
3997
|
+
await new Promise<void>(r => setTimeout(r, 2_000));
|
|
3998
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3999
|
+
|
|
4000
|
+
// After navigation, check where we actually landed (auth may redirect)
|
|
4001
|
+
const landedUrl = (rawPage ?? page).url?.() ?? revisitUrl;
|
|
4002
|
+
const landedCanon = canonicalize(landedUrl);
|
|
4003
|
+
let existingScreenId = isAuthWallRevisit
|
|
4004
|
+
? (visited.get(landedCanon) ?? null) // use screen at redirected URL if known
|
|
4005
|
+
: rawScreenId;
|
|
4006
|
+
|
|
4007
|
+
// If auth-wall redirect landed on an unvisited page, save it as a new screen
|
|
4008
|
+
if (isAuthWallRevisit && (!existingScreenId || existingScreenId.startsWith('_')) && !visited.has(landedCanon)) {
|
|
4009
|
+
const pathParts = new URL(landedUrl).pathname.split('/').filter(Boolean);
|
|
4010
|
+
const screenName = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
4011
|
+
const newScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, landedUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
|
|
4012
|
+
if (newScreen) {
|
|
4013
|
+
visited.set(landedCanon, newScreen.id);
|
|
4014
|
+
existingScreenId = newScreen.id;
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
if (!existingScreenId || existingScreenId.startsWith('_')) {
|
|
4019
|
+
logger.warn({ url: revisitUrl, landed: landedUrl }, '[P3a] Re-visit skipped — could not resolve screen ID');
|
|
4020
|
+
continue;
|
|
4021
|
+
}
|
|
4022
|
+
|
|
3282
4023
|
const shotPath2 = `${opts.screenshotDir ?? '/tmp'}/${existingScreenId}-revisit.png`;
|
|
3283
|
-
await waitForScreenshotReady(page);
|
|
3284
4024
|
await page.screenshot({ path: shotPath2, fullPage: true });
|
|
3285
|
-
|
|
3286
|
-
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, 'DESKTOP').catch(() => { });
|
|
4025
|
+
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, 'DESKTOP', undefined).catch(() => {});
|
|
3287
4026
|
// Re-extract elements with the async-loaded content now visible
|
|
3288
|
-
const revisitElements = await extractElements(stagehand, page, elementLLM ?? undefined).catch(() => []);
|
|
3289
|
-
|
|
3290
|
-
await AppBrain.
|
|
4027
|
+
const revisitElements = await extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []);
|
|
4028
|
+
if (revisitElements.length > 0) {
|
|
4029
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, existingScreenId).catch(() => {});
|
|
4030
|
+
for (const el of revisitElements) {
|
|
4031
|
+
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(() => { });
|
|
4032
|
+
}
|
|
3291
4033
|
}
|
|
3292
4034
|
// Harvest any new links revealed by async content
|
|
3293
|
-
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false).catch(() => []);
|
|
4035
|
+
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []);
|
|
3294
4036
|
for (const href of revisitLinks) {
|
|
3295
4037
|
const canon = canonicalize(href);
|
|
3296
4038
|
if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
|
|
@@ -3298,13 +4040,66 @@ export async function crawlProject(opts: {
|
|
|
3298
4040
|
queue.push(href);
|
|
3299
4041
|
}
|
|
3300
4042
|
}
|
|
3301
|
-
logger.info({ url: revisitUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, '[P3a] Re-visit complete');
|
|
4043
|
+
logger.info({ url: revisitUrl, landed: landedUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, '[P3a] Re-visit complete');
|
|
3302
4044
|
} catch (err) {
|
|
3303
4045
|
logger.warn({ url: revisitUrl, err: String(err) }, '[P3a] Re-visit failed — non-fatal');
|
|
3304
4046
|
}
|
|
3305
4047
|
}
|
|
3306
4048
|
}
|
|
3307
4049
|
|
|
4050
|
+
// P3b: Extended BFS — process any links discovered during P3a revisit pass.
|
|
4051
|
+
// Without this, auth-wall pages redirect to real app pages, AI discovers new links,
|
|
4052
|
+
// but those links are pushed to queue and NEVER processed (main BFS already exited).
|
|
4053
|
+
if (queue.length > 0 && visited.size < maxScreens) {
|
|
4054
|
+
const p3bCount = Math.min(queue.length, maxScreens - visited.size);
|
|
4055
|
+
logger.info({ newLinks: p3bCount }, '[P3b] Extended BFS — processing links discovered during revisit pass');
|
|
4056
|
+
await onProgress?.(82, `Extended crawl: ${p3bCount} newly discovered page(s)…`);
|
|
4057
|
+
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
4058
|
+
const href = queue.shift();
|
|
4059
|
+
if (!href) break;
|
|
4060
|
+
const canonHref = canonicalize(href);
|
|
4061
|
+
if (visited.has(canonHref)) continue;
|
|
4062
|
+
if (!isUsefulNavigationUrl(href, origin)) continue;
|
|
4063
|
+
visited.set(canonHref, '_pending_');
|
|
4064
|
+
try {
|
|
4065
|
+
await page.goto(href, { waitUntil: 'networkidle', timeout: 25_000 }).catch(() => {});
|
|
4066
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
4067
|
+
const pathParts = new URL(href).pathname.split('/').filter(Boolean);
|
|
4068
|
+
const nm = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
4069
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, 'LINK_FOLLOW');
|
|
4070
|
+
visited.set(canonHref, screen.id);
|
|
4071
|
+
const sp = `${opts.screenshotDir ?? '/tmp'}/${screen.id}-v${screen.version}.png`;
|
|
4072
|
+
await page.screenshot({ path: sp, fullPage: true }).catch(() => {});
|
|
4073
|
+
const cloudUrl3 = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => '');
|
|
4074
|
+
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, sp, screen.version, 'DESKTOP', cloudUrl3).catch(() => {});
|
|
4075
|
+
const [p3bElems, p3bLinks] = await Promise.all([
|
|
4076
|
+
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
|
|
4077
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined).catch(() => []),
|
|
4078
|
+
]);
|
|
4079
|
+
if (p3bElems.length > 0) {
|
|
4080
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
4081
|
+
for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 4) {
|
|
4082
|
+
const _b3 = p3bElems.slice(_ei3, _ei3 + 4);
|
|
4083
|
+
await Promise.all(_b3.map((el: any) =>
|
|
4084
|
+
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(() => {})
|
|
4085
|
+
));
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
for (const link of p3bLinks) {
|
|
4089
|
+
const c = canonicalize(link);
|
|
4090
|
+
if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 2) {
|
|
4091
|
+
queued.add(c); queue.push(link);
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
discovered.push({ url: href, screenId: screen.id, elements: p3bElems.length, version: screen.version });
|
|
4095
|
+
logger.info({ url: href, elements: p3bElems.length, newLinks: p3bLinks.length }, '[P3b] Extended BFS screen captured');
|
|
4096
|
+
} catch (err) {
|
|
4097
|
+
logger.warn({ url: href, err: String(err) }, '[P3b] Extended BFS URL failed');
|
|
4098
|
+
visited.set(canonHref, '_error_');
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
|
|
3308
4103
|
// P6: Gap Pass — LLM gap analysis on discovered screens to find structurally missing pages.
|
|
3309
4104
|
// Runs after P3a re-visits so the gap analysis has the most complete screen list possible.
|
|
3310
4105
|
// Confirmed gap URLs (HEAD=200) are visited directly with the existing browser.
|
|
@@ -3344,16 +4139,16 @@ export async function crawlProject(opts: {
|
|
|
3344
4139
|
logger.warn({ gapUrl, landed: page.url() }, '[P6] Gap URL redirected to login — skipping');
|
|
3345
4140
|
continue;
|
|
3346
4141
|
}
|
|
3347
|
-
await
|
|
4142
|
+
await new Promise<void>(r => setTimeout(r, 1_500));
|
|
3348
4143
|
const gapName = gap.category;
|
|
3349
4144
|
const gapScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, gapName, gapUrl, opts.jobId, 'ENTRY_POINT');
|
|
3350
4145
|
const gapShot = `${opts.screenshotDir ?? '/tmp'}/${gapScreen.id}-v${gapScreen.version}.png`;
|
|
3351
|
-
await waitForScreenshotReady(page);
|
|
4146
|
+
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3352
4147
|
await page.screenshot({ path: gapShot, fullPage: true });
|
|
3353
4148
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, 'DESKTOP');
|
|
3354
4149
|
const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
|
|
3355
|
-
extractElements(stagehand, page, elementLLM ?? undefined).catch(() => []),
|
|
3356
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false).catch(() => []),
|
|
4150
|
+
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
|
|
4151
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []),
|
|
3357
4152
|
extractLinkMappings(page, origin).catch(() => []),
|
|
3358
4153
|
]);
|
|
3359
4154
|
for (const el of gapElems) {
|
|
@@ -3380,14 +4175,23 @@ export async function crawlProject(opts: {
|
|
|
3380
4175
|
logger.info({ total: gapCandidates.length }, '[P6] Gap pass: no confirmed gaps found');
|
|
3381
4176
|
}
|
|
3382
4177
|
} catch (err) {
|
|
3383
|
-
|
|
4178
|
+
const msg = String(err);
|
|
4179
|
+
logger.warn({ err: msg }, '[P6] Gap pass failed — non-fatal, continuing');
|
|
4180
|
+
crawlWarnings.push(`gap-analysis skipped: ${msg.includes('401') || msg.toLowerCase().includes('api key') || msg.toLowerCase().includes('authentication') ? 'LLM API key invalid or missing' : msg}`);
|
|
3384
4181
|
}
|
|
3385
4182
|
}
|
|
3386
4183
|
|
|
3387
4184
|
// After crawl: mark screens not re-discovered as PENDING_REMOVAL (non-destructive diff)
|
|
3388
4185
|
// Only run if crawl actually found screens — 0 discovered means failed/aborted run, not evidence of removal
|
|
3389
4186
|
if (!selective && visited.size > 0) {
|
|
3390
|
-
|
|
4187
|
+
// Only pass canonical URLs of screens that were actually visited (saveScreen called).
|
|
4188
|
+
// Exclude marker strings (_skipped_api_, _pending_, _soft404_, pre-auth, skipped)
|
|
4189
|
+
// so that skipped API/export paths don't pollute the discoveredSet and prevent removal
|
|
4190
|
+
// of old API-path screens from previous crawls.
|
|
4191
|
+
const VISIT_MARKER_RE = /^_|^pre-auth$|^skipped$/;
|
|
4192
|
+
const discoveredUrls = Array.from(visited.entries())
|
|
4193
|
+
.filter(([, v]) => typeof v === 'string' && !VISIT_MARKER_RE.test(v))
|
|
4194
|
+
.map(([k]) => k);
|
|
3391
4195
|
await AppBrain.markRemovedScreens(opts.tenantId, opts.projectId, discoveredUrls, opts.jobId);
|
|
3392
4196
|
}
|
|
3393
4197
|
|
|
@@ -3579,6 +4383,7 @@ export async function crawlProject(opts: {
|
|
|
3579
4383
|
ignoreHTTPSErrors: true,
|
|
3580
4384
|
...(mainStorageState ? { storageState: mainStorageState } : {}),
|
|
3581
4385
|
});
|
|
4386
|
+
mobileCtx.on('download', (dl: any) => { dl.cancel().catch(() => {}); });
|
|
3582
4387
|
const mobilePage = await mobileCtx.newPage();
|
|
3583
4388
|
|
|
3584
4389
|
for (const disc of discovered) {
|
|
@@ -3588,12 +4393,15 @@ export async function crawlProject(opts: {
|
|
|
3588
4393
|
await mobilePage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
3589
4394
|
await dismissOverlays(mobilePage);
|
|
3590
4395
|
try { await mobilePage.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' }); } catch { /* ignore */ }
|
|
4396
|
+
// mobilePage is a real Playwright page — pass it as rawPage so CDP wheel events
|
|
4397
|
+
// trigger virtual scrollers (react-window / TanStack Virtual) at mobile viewports.
|
|
4398
|
+
await infiniteScrollToBottom(mobilePage, { maxScrolls: 20, scrollDelay: 300, stabilizeMs: 400 }, mobilePage).catch(() => { });
|
|
3591
4399
|
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${disc.screenId}-${profile.toLowerCase()}-v${disc.version}.png`);
|
|
3592
|
-
await waitForScreenshotReady(mobilePage);
|
|
4400
|
+
await waitForScreenshotReady(mobilePage, mobilePage);
|
|
3593
4401
|
// Screenshot and DOM element extraction run in parallel
|
|
3594
4402
|
const [, vpElements] = await Promise.all([
|
|
3595
4403
|
mobilePage.screenshot({ path: shotPath, fullPage: true }),
|
|
3596
|
-
domFallbackElements(mobilePage),
|
|
4404
|
+
domFallbackElements(mobilePage, mobilePage),
|
|
3597
4405
|
]);
|
|
3598
4406
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
3599
4407
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version!, profile, cloudUrl);
|
|
@@ -3638,6 +4446,26 @@ export async function crawlProject(opts: {
|
|
|
3638
4446
|
await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
|
|
3639
4447
|
}
|
|
3640
4448
|
|
|
4449
|
+
// Step 7: End-of-crawl state graph coverage report.
|
|
4450
|
+
// Per-screen probes (dropdown, CRUD, filter, DnD, nested-contexts, shadow-DOM,
|
|
4451
|
+
// accessibility-tree, performance, service-worker) run per-screen inside the BFS
|
|
4452
|
+
// loop above and are saved as PER_SCREEN_PROBES evidence.
|
|
4453
|
+
try {
|
|
4454
|
+
await onProgress?.(90, 'Generating state graph coverage report');
|
|
4455
|
+
const stateGraph = new ScreenTransitionGraph();
|
|
4456
|
+
const coveragePayload: Record<string, unknown> = {
|
|
4457
|
+
stateGraph: stateGraph.getCoverageReport(),
|
|
4458
|
+
totalScreensCrawled: discovered.filter(d => !!d.screenId).length,
|
|
4459
|
+
};
|
|
4460
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
4461
|
+
jobId: opts.jobId,
|
|
4462
|
+
type: 'CRAWL_COVERAGE_REPORT',
|
|
4463
|
+
payload: coveragePayload,
|
|
4464
|
+
}).catch(() => { });
|
|
4465
|
+
} catch (extErr) {
|
|
4466
|
+
logger.warn({ err: String(extErr) }, '[coverage-report] non-fatal error');
|
|
4467
|
+
}
|
|
4468
|
+
|
|
3641
4469
|
await onProgress?.(97, 'Running post-crawl analysis');
|
|
3642
4470
|
|
|
3643
4471
|
// ── Per-crawl summaries (fire-and-forget, non-blocking) ────────────────
|
|
@@ -3714,6 +4542,17 @@ export async function crawlProject(opts: {
|
|
|
3714
4542
|
const screensSucceeded = discovered.filter(d => !!d.screenId).length;
|
|
3715
4543
|
const screensErrored = discovered.filter(d => !!d.error && !d.screenId).length;
|
|
3716
4544
|
|
|
4545
|
+
// Detect systematic element/link extraction failure across all screens
|
|
4546
|
+
if (screensSucceeded >= 3) {
|
|
4547
|
+
const screensWithElements = discovered.filter(d => (d.elements ?? 0) > 0 && d.screenId).length;
|
|
4548
|
+
if (screensWithElements === 0) {
|
|
4549
|
+
crawlWarnings.push(
|
|
4550
|
+
`element-extraction: 0 elements found across all ${screensSucceeded} screens — ` +
|
|
4551
|
+
`DOM evaluate may be failing (check server logs for "page.evaluate threw" warnings)`,
|
|
4552
|
+
);
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
|
|
3717
4556
|
return {
|
|
3718
4557
|
screensFound: screensSucceeded, // successful captures only (not errors)
|
|
3719
4558
|
screensErrored, // pages visited but failed to capture
|
|
@@ -3726,6 +4565,7 @@ export async function crawlProject(opts: {
|
|
|
3726
4565
|
segmentIndex: segIdx, // which segment this was
|
|
3727
4566
|
hasMoreSegments,
|
|
3728
4567
|
domHashes: Array.from(seenDomHashes), // carry forward to next segment to avoid re-screenshotting
|
|
4568
|
+
crawlWarnings: crawlWarnings.length > 0 ? crawlWarnings : undefined,
|
|
3729
4569
|
};
|
|
3730
4570
|
} finally {
|
|
3731
4571
|
// Log accumulated Stagehand LLM usage (extract/act/observe calls) to cost ledger.
|
|
@@ -3760,11 +4600,13 @@ export async function crawlProject(opts: {
|
|
|
3760
4600
|
// Elements whose meanings indicate UI state toggles or noise — filtered from both DOM and AI results.
|
|
3761
4601
|
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;
|
|
3762
4602
|
|
|
3763
|
-
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 }>> {
|
|
4603
|
+
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 }>> {
|
|
3764
4604
|
// llmCfg reserved for per-task model selection (future: create task-scoped Stagehand)
|
|
3765
4605
|
// DOM-first: fast, no LLM cost, covers server-rendered and most SPAs.
|
|
3766
4606
|
// Threshold >= 2 so a lone dark-mode button doesn't suppress AI fallback for rich pages.
|
|
3767
|
-
|
|
4607
|
+
// rawPage: real Playwright CDP page used for page.evaluate() — avoids Stagehand's proxy
|
|
4608
|
+
// re-bundling the function with esbuild (which injects __name, breaking browser eval).
|
|
4609
|
+
const domElements = await domFallbackElements(page, rawPage).catch((err: any) => {
|
|
3768
4610
|
console.warn('[crawler] domFallbackElements failed (page context issue or DOM error):', err?.message ?? err);
|
|
3769
4611
|
return [] as Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>;
|
|
3770
4612
|
});
|
|
@@ -3811,7 +4653,10 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
|
|
|
3811
4653
|
// Always capture content elements (headings, meta, cards, nav, FAQ) regardless of interactive count.
|
|
3812
4654
|
// Previously this was fallback-only; now both paths run together so static sections are
|
|
3813
4655
|
// always visible to the test generator even on pages with forms.
|
|
3814
|
-
const contentElements = await contentFallbackElements(page)
|
|
4656
|
+
const contentElements = await contentFallbackElements(page).catch((err: any) => {
|
|
4657
|
+
console.warn('[crawler] contentFallbackElements failed (page context issue):', err?.message ?? err);
|
|
4658
|
+
return [] as Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number> }>;
|
|
4659
|
+
});
|
|
3815
4660
|
|
|
3816
4661
|
// Cross-source text deduplication: strips wrapper phrases like "Button to expand '...' details"
|
|
3817
4662
|
// and "... FAQ button" so duplicate FAQ buttons from DOM + content sources are collapsed.
|
|
@@ -3867,14 +4712,14 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
|
|
|
3867
4712
|
}
|
|
3868
4713
|
}
|
|
3869
4714
|
|
|
3870
|
-
async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false): Promise<string[]> {
|
|
4715
|
+
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[]> {
|
|
3871
4716
|
// Expand collapsed nav menus (hamburger, aria-haspopup, Radix closed triggers) so hidden routes
|
|
3872
4717
|
// become part of the DOM before querying. This catches SPA nav dropdowns that only render on click.
|
|
3873
4718
|
const navExpanded = await expandNavMenus(page);
|
|
3874
4719
|
|
|
3875
4720
|
// DOM-first: <a href> links — standard apps. AI supplements when DOM coverage is < 20 unique links.
|
|
3876
4721
|
// Threshold of 20 (not 8) because nav sidebars quickly provide 8+ links while missing deep content URLs.
|
|
3877
|
-
const domLinks = await domFallbackLinks(page, origin);
|
|
4722
|
+
const domLinks = await domFallbackLinks(page, origin, rawPage);
|
|
3878
4723
|
|
|
3879
4724
|
// Interact with tabs, carousels, and accordions to reveal hidden content and discover more links.
|
|
3880
4725
|
const interactiveLinks = await interactAndCollectLinks(page, origin);
|
|
@@ -3929,8 +4774,9 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
|
|
|
3929
4774
|
}
|
|
3930
4775
|
}
|
|
3931
4776
|
|
|
3932
|
-
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 }>> {
|
|
3933
|
-
|
|
4777
|
+
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 }>> {
|
|
4778
|
+
// Named function so we can .toString() it for CDP Runtime.evaluate fallback
|
|
4779
|
+
function _browserEval() {
|
|
3934
4780
|
const out: { meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }[] = [];
|
|
3935
4781
|
const seen = new Set<string>();
|
|
3936
4782
|
// UI chrome noise: cookie banners, theme toggles, version strings, social/share buttons
|
|
@@ -3949,8 +4795,8 @@ export async function domFallbackElements(page: any): Promise<Array<{ meaning: s
|
|
|
3949
4795
|
return results;
|
|
3950
4796
|
};
|
|
3951
4797
|
|
|
3952
|
-
// Expanded selector — all interactive ARIA roles + native form elements + rich editors
|
|
3953
|
-
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"],
|
|
4798
|
+
// Expanded selector — all interactive ARIA roles + native form elements + rich editors + bare nav links
|
|
4799
|
+
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"]';
|
|
3954
4800
|
|
|
3955
4801
|
querySelectorAllDeep(document, selector).forEach((el: any) => {
|
|
3956
4802
|
const style = window.getComputedStyle(el);
|
|
@@ -3969,14 +4815,13 @@ export async function domFallbackElements(page: any): Promise<Array<{ meaning: s
|
|
|
3969
4815
|
const ariaRole = el.getAttribute('role');
|
|
3970
4816
|
const isActionEl = tag === 'button' || tag === 'a' || ariaRole === 'button';
|
|
3971
4817
|
|
|
3972
|
-
// Skip plain anchor links inside <
|
|
4818
|
+
// Skip plain anchor links inside <footer> only — footer chrome (sitemap, legal links) has no test value.
|
|
4819
|
+
// Header nav links ARE test targets (SPA primary navigation lives in <header>) — do NOT skip.
|
|
3973
4820
|
// Do NOT skip <nav> elements: SPA dashboard sidebars live in <nav> and are primary test targets.
|
|
3974
|
-
|
|
3975
|
-
if (tag === 'a' || (ariaRole === 'button' && tag !== 'button')) {
|
|
4821
|
+
if (tag === 'a') {
|
|
3976
4822
|
let p = el.parentElement;
|
|
3977
4823
|
while (p) {
|
|
3978
|
-
|
|
3979
|
-
if (['header', 'footer'].includes(ptag)) return;
|
|
4824
|
+
if (p.tagName?.toLowerCase() === 'footer') return;
|
|
3980
4825
|
p = p.parentElement;
|
|
3981
4826
|
}
|
|
3982
4827
|
}
|
|
@@ -4157,7 +5002,64 @@ export async function domFallbackElements(page: any): Promise<Array<{ meaning: s
|
|
|
4157
5002
|
['email', 'password', 'text', 'tel', 'date', 'number', 'select', 'textarea', 'url', 'file'].includes(e.role) ? 0 : 1;
|
|
4158
5003
|
out.sort((a, b) => priority(a) - priority(b));
|
|
4159
5004
|
return out; // Cap removed
|
|
4160
|
-
}
|
|
5005
|
+
}
|
|
5006
|
+
// CDP Runtime.evaluate bypasses Playwright's isolated world entirely.
|
|
5007
|
+
// The "__name is not defined" error comes from Playwright's own UtilityScript being absent
|
|
5008
|
+
// from the isolated world (destroyed by navigation events in Stagehand's CDP session).
|
|
5009
|
+
// Solution: when rawPage (a real Playwright Page) is available, use CDP Runtime.evaluate
|
|
5010
|
+
// as the PRIMARY path — no isolated world needed, no __name dependency.
|
|
5011
|
+
//
|
|
5012
|
+
// esbuild injects __name() calls for named arrow-function constants inside _browserEval
|
|
5013
|
+
// (e.g. querySelectorAllDeep). When _browserEval.toString() is sent to the browser,
|
|
5014
|
+
// the browser throws "ReferenceError: __name is not defined". Fix: prepend a tiny
|
|
5015
|
+
// __name polyfill that just returns the function (name-setting is non-critical).
|
|
5016
|
+
const _browserEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_browserEval.toString()})()})();`;
|
|
5017
|
+
|
|
5018
|
+
if (rawPage && typeof rawPage.context === 'function') {
|
|
5019
|
+
try {
|
|
5020
|
+
const ctx = rawPage.context();
|
|
5021
|
+
if (typeof ctx?.newCDPSession === 'function') {
|
|
5022
|
+
const cdp = await ctx.newCDPSession(rawPage);
|
|
5023
|
+
try {
|
|
5024
|
+
const evalResult = await cdp.send('Runtime.evaluate', {
|
|
5025
|
+
expression: _browserEvalStr,
|
|
5026
|
+
returnByValue: true,
|
|
5027
|
+
awaitPromise: false,
|
|
5028
|
+
});
|
|
5029
|
+
if (evalResult?.exceptionDetails) {
|
|
5030
|
+
console.warn('[crawler] domFallbackElements CDP primary error:', evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
|
|
5031
|
+
} else if (Array.isArray(evalResult?.result?.value)) {
|
|
5032
|
+
return (evalResult.result.value as any[]).map((e: any) => ({ ...e, isInteractive: true }));
|
|
5033
|
+
}
|
|
5034
|
+
} finally {
|
|
5035
|
+
cdp.detach().catch(() => {});
|
|
5036
|
+
}
|
|
5037
|
+
}
|
|
5038
|
+
} catch (cdpPrimaryErr: any) {
|
|
5039
|
+
console.warn('[crawler] domFallbackElements CDP primary failed, trying page.evaluate:', cdpPrimaryErr?.message ?? String(cdpPrimaryErr));
|
|
5040
|
+
}
|
|
5041
|
+
}
|
|
5042
|
+
|
|
5043
|
+
// Fallback: pass the pre-serialized string expression so esbuild's __name() wrapper
|
|
5044
|
+
// for nested named functions never reaches the browser (Function.prototype.toString
|
|
5045
|
+
// returns the source including __name() calls for any nested named arrow constants).
|
|
5046
|
+
// Playwright accepts a JS expression string directly — no isolated-world __name leakage.
|
|
5047
|
+
const evalPage = rawPage ?? page;
|
|
5048
|
+
const _doEvaluate = (): Promise<any> => evalPage.evaluate(_browserEvalStr as any);
|
|
5049
|
+
let result: any[];
|
|
5050
|
+
try {
|
|
5051
|
+
result = await _doEvaluate();
|
|
5052
|
+
} catch (err1: any) {
|
|
5053
|
+
// Second attempt after a brief wait — isolated world may need a tick to settle
|
|
5054
|
+
await new Promise<void>(r => setTimeout(r, 500));
|
|
5055
|
+
try {
|
|
5056
|
+
result = await _doEvaluate();
|
|
5057
|
+
} catch (err2: any) {
|
|
5058
|
+
console.warn('[crawler] domFallbackElements page.evaluate threw (attempt 2):', err2?.message ?? String(err2));
|
|
5059
|
+
return [];
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
5062
|
+
return (result ?? []).map((e: any) => ({ ...e, isInteractive: true }));
|
|
4161
5063
|
}
|
|
4162
5064
|
|
|
4163
5065
|
/** Capture content anatomy for EVERY page — not just static ones.
|
|
@@ -4277,19 +5179,28 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
4277
5179
|
const s = window.getComputedStyle(el);
|
|
4278
5180
|
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
4279
5181
|
};
|
|
4280
|
-
|
|
5182
|
+
const navSel = [
|
|
5183
|
+
'nav a', '[role="navigation"] a', 'header a', 'header nav a', 'footer nav a',
|
|
5184
|
+
'[aria-label*="navigation" i] a', '[aria-label*="menu" i] a', '[aria-label*="sidebar" i] a',
|
|
5185
|
+
'aside a', '[role="complementary"] a',
|
|
5186
|
+
'[class*="sidebar" i] a', '[class*="side-nav" i] a', '[class*="sidenav" i] a',
|
|
5187
|
+
'[class*="nav-menu" i] a', '[class*="navmenu" i] a', '[data-nav] a',
|
|
5188
|
+
].join(', ');
|
|
5189
|
+
const navLinksFoundCount = { n: 0 };
|
|
5190
|
+
document.querySelectorAll(navSel).forEach((a: any) => {
|
|
4281
5191
|
if (!isNavVisible(a)) return;
|
|
4282
5192
|
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
4283
5193
|
const href = a.href;
|
|
4284
5194
|
if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
|
|
4285
|
-
// Walk up to find nearest nav/header/footer ancestor to get landmark label
|
|
4286
5195
|
let landmark = 'Site Navigation';
|
|
4287
5196
|
let el: any = a.parentElement;
|
|
4288
5197
|
while (el) {
|
|
4289
5198
|
const tag = el.tagName?.toLowerCase();
|
|
4290
5199
|
const role = el.getAttribute?.('role')?.toLowerCase();
|
|
5200
|
+
const cl = (el.className ?? '').toLowerCase();
|
|
4291
5201
|
if (tag === 'header' || role === 'banner') { landmark = 'Header'; break; }
|
|
4292
5202
|
if (tag === 'footer' || role === 'contentinfo') { landmark = 'Footer'; break; }
|
|
5203
|
+
if (tag === 'aside' || role === 'complementary' || cl.includes('sidebar') || cl.includes('sidenav') || cl.includes('side-nav')) { landmark = 'Sidebar'; break; }
|
|
4293
5204
|
if (tag === 'nav' || role === 'navigation') {
|
|
4294
5205
|
const directLabel = el.getAttribute('aria-label')?.trim() ?? '';
|
|
4295
5206
|
const labelId = el.getAttribute('aria-labelledby');
|
|
@@ -4300,7 +5211,43 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
4300
5211
|
el = el.parentElement;
|
|
4301
5212
|
}
|
|
4302
5213
|
addUniq({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
|
|
5214
|
+
navLinksFoundCount.n++;
|
|
4303
5215
|
});
|
|
5216
|
+
// Grandparent-grouping fallback: detects sidebar navigation where each link is individually
|
|
5217
|
+
// wrapped (e.g. <div class="flex"><a>link</a></div> × many siblings). Runs ALWAYS so header
|
|
5218
|
+
// links don't block sidebar detection. Deduped against hrefs already in the semantic pass.
|
|
5219
|
+
{
|
|
5220
|
+
const navHrefsAlready = new Set<string>();
|
|
5221
|
+
for (const item of out) {
|
|
5222
|
+
if (item.role === 'nav-link') {
|
|
5223
|
+
try { const h = JSON.parse(item.notes ?? '{}').href; if (h) navHrefsAlready.add(h); } catch { /* skip */ }
|
|
5224
|
+
}
|
|
5225
|
+
}
|
|
5226
|
+
const gpGroups2 = new Map<Element, Array<{label: string; href: string}>>();
|
|
5227
|
+
document.querySelectorAll('a[href]').forEach((a: any) => {
|
|
5228
|
+
if (!isNavVisible(a)) return;
|
|
5229
|
+
const href = a.href ?? '';
|
|
5230
|
+
if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsAlready.has(href)) return;
|
|
5231
|
+
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
5232
|
+
if (!label || label.length > 40) return;
|
|
5233
|
+
const parent = a.parentElement;
|
|
5234
|
+
const gp = parent?.parentElement;
|
|
5235
|
+
if (!parent || !gp) return;
|
|
5236
|
+
if (parent.querySelectorAll('a').length !== 1) return;
|
|
5237
|
+
const gpTag = gp.tagName.toLowerCase();
|
|
5238
|
+
if (['body', 'html', 'form'].includes(gpTag)) return;
|
|
5239
|
+
const arr = gpGroups2.get(gp) ?? [];
|
|
5240
|
+
arr.push({ label, href });
|
|
5241
|
+
gpGroups2.set(gp, arr);
|
|
5242
|
+
});
|
|
5243
|
+
for (const [, links] of gpGroups2.entries()) {
|
|
5244
|
+
if (links.length >= 4) {
|
|
5245
|
+
for (const { label, href } of links) {
|
|
5246
|
+
addUniq({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark: 'Sidebar' }) });
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
4304
5251
|
|
|
4305
5252
|
// 9. Breadcrumb items
|
|
4306
5253
|
document.querySelectorAll('[aria-label*="breadcrumb" i] a, [class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a').forEach((a: any) => {
|
|
@@ -5029,15 +5976,12 @@ async function expandNavMenus(page: any): Promise<boolean> {
|
|
|
5029
5976
|
} catch { return false; }
|
|
5030
5977
|
}
|
|
5031
5978
|
|
|
5032
|
-
async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
|
|
5033
|
-
|
|
5979
|
+
async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promise<string[]> {
|
|
5980
|
+
// Named function so .toString() gives the raw function body for CDP evaluation.
|
|
5981
|
+
// esbuild wraps nested named arrow-function constants with __name() — prepend a
|
|
5982
|
+
// tiny polyfill so the browser has it when we serialize this function as a string.
|
|
5983
|
+
function _linkEval(originStr: string) {
|
|
5034
5984
|
const logout = /logout|log out|sign out|delete/i;
|
|
5035
|
-
// Hash-routed SPAs (React Router hash history, Vue Router hash mode, old
|
|
5036
|
-
// AngularJS) encode the actual route as the fragment — "#/dashboard",
|
|
5037
|
-
// "#!/settings" — as distinct from a same-page scroll anchor like "#top"
|
|
5038
|
-
// or "#section-2". Treating every hash as a scroll anchor (stripping it
|
|
5039
|
-
// entirely) collapses an entire hash-routed app down to its single base
|
|
5040
|
-
// URL, discovering only ~1 screen no matter how many routes it has.
|
|
5041
5985
|
const isRouteHash = (hash: string) => /^#!?\//.test(hash);
|
|
5042
5986
|
const seen = new Set<string>();
|
|
5043
5987
|
const add = (rawHref: string, textContent: string) => {
|
|
@@ -5048,11 +5992,9 @@ async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
|
|
|
5048
5992
|
try {
|
|
5049
5993
|
const u = new URL(rawHref, location.href);
|
|
5050
5994
|
if (!isRouteHash(u.hash)) u.hash = '';
|
|
5051
|
-
if (new URL(u).origin ===
|
|
5995
|
+
if (new URL(u).origin === originStr) seen.add(u.toString());
|
|
5052
5996
|
} catch { /* ignore */ }
|
|
5053
5997
|
};
|
|
5054
|
-
|
|
5055
|
-
// Iterative BFS shadow DOM traversal — avoids stack overflow on deeply nested shadow roots
|
|
5056
5998
|
const allRoots: Array<Document | ShadowRoot> = [document];
|
|
5057
5999
|
const rootSeen = new WeakSet<Document | ShadowRoot>();
|
|
5058
6000
|
rootSeen.add(document);
|
|
@@ -5073,12 +6015,48 @@ async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
|
|
|
5073
6015
|
rootSeen.add(el.shadowRoot);
|
|
5074
6016
|
allRoots.push(el.shadowRoot);
|
|
5075
6017
|
}
|
|
5076
|
-
} catch (_) { /* closed/cross-origin shadow root
|
|
6018
|
+
} catch (_) { /* closed/cross-origin shadow root */ }
|
|
5077
6019
|
});
|
|
5078
|
-
} catch (_) { /* element threw — skip
|
|
6020
|
+
} catch (_) { /* element threw — skip root */ }
|
|
5079
6021
|
}
|
|
5080
6022
|
return Array.from(seen);
|
|
5081
|
-
}
|
|
6023
|
+
}
|
|
6024
|
+
|
|
6025
|
+
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)})})();`;
|
|
6026
|
+
|
|
6027
|
+
// CDP primary — bypasses Stagehand isolated world entirely
|
|
6028
|
+
if (rawPage && typeof rawPage.context === 'function') {
|
|
6029
|
+
try {
|
|
6030
|
+
const ctx = rawPage.context();
|
|
6031
|
+
if (typeof ctx?.newCDPSession === 'function') {
|
|
6032
|
+
const cdp = await ctx.newCDPSession(rawPage);
|
|
6033
|
+
try {
|
|
6034
|
+
const evalResult = await cdp.send('Runtime.evaluate', {
|
|
6035
|
+
expression: _linkEvalStr,
|
|
6036
|
+
returnByValue: true,
|
|
6037
|
+
awaitPromise: false,
|
|
6038
|
+
});
|
|
6039
|
+
if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
|
|
6040
|
+
return evalResult.result.value as string[];
|
|
6041
|
+
}
|
|
6042
|
+
if (evalResult?.exceptionDetails) {
|
|
6043
|
+
console.warn('[crawler] domFallbackLinks CDP error:', evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
|
|
6044
|
+
}
|
|
6045
|
+
} finally {
|
|
6046
|
+
cdp.detach().catch(() => {});
|
|
6047
|
+
}
|
|
6048
|
+
}
|
|
6049
|
+
} catch (cdpErr: any) {
|
|
6050
|
+
console.warn('[crawler] domFallbackLinks CDP failed:', cdpErr?.message ?? String(cdpErr));
|
|
6051
|
+
}
|
|
6052
|
+
}
|
|
6053
|
+
|
|
6054
|
+
// Fallback: pass string expression so esbuild __name wrapper never reaches browser
|
|
6055
|
+
const evalPage = rawPage ?? page;
|
|
6056
|
+
return (evalPage.evaluate(_linkEvalStr as any) as Promise<string[]>).catch((err: any) => {
|
|
6057
|
+
console.warn('[crawler] domFallbackLinks page.evaluate threw:', err?.message ?? String(err));
|
|
6058
|
+
return [];
|
|
6059
|
+
});
|
|
5082
6060
|
}
|
|
5083
6061
|
|
|
5084
6062
|
/** Extract label→href pairs for all in-app anchor links — used to seed KAG element transitions. */
|
|
@@ -5190,57 +6168,122 @@ async function dismissOverlays(page: any): Promise<void> {
|
|
|
5190
6168
|
}
|
|
5191
6169
|
} catch { /* overlay not present — skip */ }
|
|
5192
6170
|
}
|
|
5193
|
-
// Brief settle after dismissals
|
|
5194
|
-
await
|
|
6171
|
+
// Brief settle after dismissals — Node.js timer, no dependency on page.waitForTimeout proxy
|
|
6172
|
+
await new Promise<void>(r => setTimeout(r, 300));
|
|
5195
6173
|
}
|
|
5196
6174
|
|
|
5197
6175
|
/**
|
|
5198
6176
|
* Scroll the full page height to trigger IntersectionObserver-based lazy loads
|
|
5199
6177
|
* (images, components, infinite-scroll content). Scrolls back to top before capture.
|
|
5200
6178
|
*/
|
|
5201
|
-
async function scrollToTriggerLazyLoad(page: any): Promise<void> {
|
|
6179
|
+
async function scrollToTriggerLazyLoad(page: any, rawPage?: any): Promise<void> {
|
|
6180
|
+
const evalPage = rawPage ?? page;
|
|
5202
6181
|
try {
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
6182
|
+
// Scroll the full page in viewport-sized steps.
|
|
6183
|
+
// window.scrollTo handles standard scroll containers and triggers IntersectionObserver.
|
|
6184
|
+
const { scrollHeight, innerHeight, innerWidth } = await evalPage.evaluate(() => ({
|
|
6185
|
+
scrollHeight: Math.min(document.body.scrollHeight, 12_000),
|
|
6186
|
+
innerHeight: window.innerHeight || 800,
|
|
6187
|
+
innerWidth: window.innerWidth || 1280,
|
|
6188
|
+
})).catch(() => ({ scrollHeight: 0, innerHeight: 800, innerWidth: 1280 }));
|
|
6189
|
+
|
|
6190
|
+
if (scrollHeight > innerHeight) {
|
|
6191
|
+
// CDP wheel events trigger virtual scrollers (react-window / TanStack Virtual / AG Grid)
|
|
6192
|
+
// which ignore window.scrollTo but listen on their own container via wheel events.
|
|
6193
|
+
let cdp: any = null;
|
|
6194
|
+
if (rawPage && typeof rawPage.context === 'function') {
|
|
6195
|
+
try {
|
|
6196
|
+
const ctx = rawPage.context();
|
|
6197
|
+
if (typeof ctx?.newCDPSession === 'function') cdp = await ctx.newCDPSession(rawPage);
|
|
6198
|
+
} catch { cdp = null; }
|
|
5209
6199
|
}
|
|
5210
|
-
|
|
5211
|
-
|
|
6200
|
+
|
|
6201
|
+
const step = Math.max(innerHeight, 400);
|
|
6202
|
+
for (let y = step; y < scrollHeight; y += step) {
|
|
6203
|
+
await evalPage.evaluate((pos: number) => window.scrollTo(0, pos), y).catch(() => {});
|
|
6204
|
+
if (cdp) {
|
|
6205
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
6206
|
+
type: 'mouseWheel',
|
|
6207
|
+
x: Math.floor(innerWidth / 2),
|
|
6208
|
+
y: Math.floor(innerHeight / 2),
|
|
6209
|
+
deltaX: 0,
|
|
6210
|
+
deltaY: step,
|
|
6211
|
+
modifiers: 0,
|
|
6212
|
+
}).catch(() => {});
|
|
6213
|
+
}
|
|
6214
|
+
// Node.js-side wait — does NOT depend on page.waitForTimeout (Stagehand proxy may lack it)
|
|
6215
|
+
await new Promise<void>(r => setTimeout(r, 150));
|
|
6216
|
+
}
|
|
6217
|
+
// Scroll back to top
|
|
6218
|
+
await evalPage.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
|
|
6219
|
+
if (cdp) cdp.detach().catch(() => {});
|
|
6220
|
+
}
|
|
6221
|
+
|
|
5212
6222
|
// Let lazy-triggered content render before extraction
|
|
5213
|
-
await
|
|
5214
|
-
//
|
|
5215
|
-
|
|
6223
|
+
await new Promise<void>(r => setTimeout(r, 400));
|
|
6224
|
+
// Wait for networkidle: IntersectionObserver fires on scroll but async data fetches
|
|
6225
|
+
// (React Query, SWR, Apollo) triggered by it may still be in-flight. networkidle ensures
|
|
6226
|
+
// all XHR/fetch requests spawned by the scroll complete before DOM stability check.
|
|
6227
|
+
const scrollWaitPage = rawPage ?? page;
|
|
6228
|
+
if (typeof scrollWaitPage.waitForLoadState === 'function') {
|
|
6229
|
+
await scrollWaitPage.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
|
|
6230
|
+
}
|
|
6231
|
+
// Re-check DOM stability (shadow-piercing) after scroll-triggered mutations + data fetches
|
|
6232
|
+
await waitForStableDOM(page, 4_000, 500, rawPage);
|
|
5216
6233
|
} catch { /* scroll failed — non-fatal, proceed */ }
|
|
5217
6234
|
}
|
|
5218
6235
|
|
|
5219
|
-
async function waitForStableDOM(page: any, maxWait = 8_000, stabilityMs = 400): Promise<void> {
|
|
6236
|
+
async function waitForStableDOM(page: any, maxWait = 8_000, stabilityMs = 400, rawPage?: any): Promise<void> {
|
|
6237
|
+
const evalPage = rawPage ?? page; // prefer rawPage — avoids Stagehand proxy + __name isolated-world issues
|
|
5220
6238
|
const deadline = Date.now() + maxWait;
|
|
5221
6239
|
let lastCount = -1;
|
|
5222
6240
|
let stableAt = 0;
|
|
6241
|
+
let evalErrors = 0;
|
|
5223
6242
|
while (Date.now() < deadline) {
|
|
5224
|
-
const count: number = await
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
6243
|
+
const count: number = await evalPage.evaluate(() => {
|
|
6244
|
+
// Shadow-piercing counter: querySelectorAll('*') only counts light-DOM elements;
|
|
6245
|
+
// shadow roots are separate trees. Counting both ensures changes inside web components
|
|
6246
|
+
// (e.g. Material UI, Lit, Shoelace) reset the stability timer correctly.
|
|
6247
|
+
let c = 0;
|
|
6248
|
+
const visit = (root: Document | ShadowRoot) => {
|
|
6249
|
+
const els = root.querySelectorAll('*');
|
|
6250
|
+
c += els.length;
|
|
6251
|
+
els.forEach((el: any) => { if (el.shadowRoot) visit(el.shadowRoot); });
|
|
6252
|
+
};
|
|
6253
|
+
visit(document);
|
|
6254
|
+
return c;
|
|
6255
|
+
}).catch(() => -1);
|
|
6256
|
+
if (count === -1) {
|
|
6257
|
+
// evaluate failed (isolated world broken) — after 3 consecutive failures, wait stabilityMs and bail
|
|
6258
|
+
if (++evalErrors >= 3) {
|
|
6259
|
+
await new Promise(r => setTimeout(r, stabilityMs));
|
|
6260
|
+
return;
|
|
6261
|
+
}
|
|
6262
|
+
} else {
|
|
6263
|
+
evalErrors = 0;
|
|
6264
|
+
if (count !== lastCount) {
|
|
6265
|
+
lastCount = count;
|
|
6266
|
+
stableAt = Date.now();
|
|
6267
|
+
} else if (Date.now() - stableAt >= stabilityMs) {
|
|
6268
|
+
return;
|
|
6269
|
+
}
|
|
5230
6270
|
}
|
|
5231
6271
|
await new Promise(r => setTimeout(r, 100));
|
|
5232
6272
|
}
|
|
5233
6273
|
// Timed out — DOM may still be loading, proceed anyway
|
|
5234
6274
|
}
|
|
5235
6275
|
|
|
5236
|
-
async function waitForScreenshotReady(page: any): Promise<void> {
|
|
5237
|
-
const hardDeadline = Date.now() +
|
|
6276
|
+
async function waitForScreenshotReady(page: any, rawPage?: any): Promise<void> {
|
|
6277
|
+
const hardDeadline = Date.now() + 30_000;
|
|
5238
6278
|
const withHardCap = <T>(p: Promise<T>): Promise<T | void> =>
|
|
5239
6279
|
Promise.race([p, new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, hardDeadline - Date.now())))]);
|
|
6280
|
+
// Use rawPage for load-state waits — Stagehand proxy may not expose waitForLoadState
|
|
6281
|
+
const waitPage = rawPage ?? page;
|
|
6282
|
+
const evalPage = rawPage ?? page;
|
|
5240
6283
|
try {
|
|
5241
|
-
await withHardCap(
|
|
5242
|
-
await withHardCap(
|
|
5243
|
-
await withHardCap(
|
|
6284
|
+
await withHardCap(waitPage.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => { }));
|
|
6285
|
+
await withHardCap(waitPage.waitForLoadState('networkidle', { timeout: 12_000 }).catch(() => { }));
|
|
6286
|
+
await withHardCap(evalPage.evaluate(() => {
|
|
5244
6287
|
if (document.readyState === 'complete') return true;
|
|
5245
6288
|
return new Promise<boolean>((resolve) => {
|
|
5246
6289
|
const done = () => resolve(true);
|
|
@@ -5248,13 +6291,13 @@ async function waitForScreenshotReady(page: any): Promise<void> {
|
|
|
5248
6291
|
setTimeout(done, 4_000);
|
|
5249
6292
|
});
|
|
5250
6293
|
}).catch(() => { }));
|
|
5251
|
-
await withHardCap(
|
|
6294
|
+
await withHardCap(evalPage.evaluate(async () => {
|
|
5252
6295
|
const fonts = (document as any).fonts;
|
|
5253
6296
|
if (fonts?.ready) await fonts.ready;
|
|
5254
6297
|
}).catch(() => { }));
|
|
5255
|
-
await withHardCap(scrollToTriggerLazyLoad(page));
|
|
5256
|
-
await withHardCap(waitForStableDOM(page, 4_000, 700));
|
|
5257
|
-
await withHardCap(
|
|
6298
|
+
await withHardCap(scrollToTriggerLazyLoad(rawPage ?? page, rawPage));
|
|
6299
|
+
await withHardCap(waitForStableDOM(page, 4_000, 700, rawPage));
|
|
6300
|
+
await withHardCap(evalPage.evaluate(async () => {
|
|
5258
6301
|
const pending = Array.from(document.images).filter((img) => !img.complete);
|
|
5259
6302
|
if (pending.length === 0) return;
|
|
5260
6303
|
await Promise.race([
|
|
@@ -5265,7 +6308,7 @@ async function waitForScreenshotReady(page: any): Promise<void> {
|
|
|
5265
6308
|
new Promise<void>((resolve) => setTimeout(resolve, 4_000)),
|
|
5266
6309
|
]);
|
|
5267
6310
|
}).catch(() => { }));
|
|
5268
|
-
await withHardCap(waitForStableDOM(page, 2_000, 500));
|
|
6311
|
+
await withHardCap(waitForStableDOM(page, 2_000, 500, rawPage));
|
|
5269
6312
|
} catch { /* readiness is best-effort; never fail the crawl for a continuously mutating page */ }
|
|
5270
6313
|
}
|
|
5271
6314
|
|
|
@@ -5490,6 +6533,9 @@ async function runAuthDiscoveryPhase(
|
|
|
5490
6533
|
|
|
5491
6534
|
while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
|
|
5492
6535
|
const current = queue.shift()!;
|
|
6536
|
+
const _authPct = 10 + Math.round((captured / AUTH_DISCOVERY_MAX_SCREENS) * 7);
|
|
6537
|
+
const _authPath = current.url.replace(/^https?:\/\/[^/]+/, '') || '/';
|
|
6538
|
+
await onProgress?.(_authPct, `Auth discovery · ${_authPath} · ${captured} found`);
|
|
5493
6539
|
try {
|
|
5494
6540
|
const response = await page.goto(current.url, { waitUntil: 'domcontentloaded', timeout: tuning.navTimeoutMs }).catch(() => null);
|
|
5495
6541
|
if (response && response.status && response.status() >= 400) continue;
|