@detiq/crawler 1.1.14 → 1.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +1 -1
- package/dist/accessibility-tree-crawler.js +156 -0
- package/dist/accessibility-tree-crawler.js.map +7 -0
- package/dist/advanced-ui-probes.js +310 -0
- package/dist/advanced-ui-probes.js.map +7 -0
- package/dist/content-chunker.js +2 -2
- package/dist/content-chunker.js.map +2 -2
- package/dist/crawl-session-manager.js +145 -0
- package/dist/crawl-session-manager.js.map +7 -0
- package/dist/crawler.js +1142 -263
- package/dist/crawler.js.map +2 -2
- package/dist/crud-interaction.js +264 -0
- package/dist/crud-interaction.js.map +7 -0
- package/dist/dropdown-enumerator.js +230 -0
- package/dist/dropdown-enumerator.js.map +7 -0
- package/dist/filter-pagination-crawler.js +299 -0
- package/dist/filter-pagination-crawler.js.map +7 -0
- package/dist/frontier.js +5 -3
- package/dist/frontier.js.map +2 -2
- package/dist/gap-pass.js +47 -14
- package/dist/gap-pass.js.map +2 -2
- package/dist/http-engine.js +28 -15
- package/dist/http-engine.js.map +2 -2
- package/dist/index.js +15 -1
- package/dist/index.js.map +2 -2
- package/dist/job-queue.js +12 -5
- package/dist/job-queue.js.map +2 -2
- package/dist/locator-extractor.js +23 -0
- package/dist/locator-extractor.js.map +2 -2
- package/dist/multi-context-handler.js +182 -0
- package/dist/multi-context-handler.js.map +7 -0
- package/dist/nested-window-crawler.js +172 -0
- package/dist/nested-window-crawler.js.map +7 -0
- package/dist/network-action-correlator.js +209 -0
- package/dist/network-action-correlator.js.map +7 -0
- package/dist/performance-metrics-collector.js +155 -0
- package/dist/performance-metrics-collector.js.map +7 -0
- package/dist/performance-metrics.js.map +2 -2
- package/dist/policies/crawl-safety.js +5 -1
- package/dist/policies/crawl-safety.js.map +2 -2
- package/dist/profiles/domain-profile.js +21 -1
- package/dist/profiles/domain-profile.js.map +2 -2
- package/dist/rate-limiter.js +2 -2
- package/dist/rate-limiter.js.map +2 -2
- package/dist/screen-reader.js +1 -0
- package/dist/screen-reader.js.map +2 -2
- package/dist/service-worker-interceptor.js +136 -0
- package/dist/service-worker-interceptor.js.map +7 -0
- package/dist/session-capture.js +4 -2
- package/dist/session-capture.js.map +2 -2
- package/dist/shadow-dom-crawler.js +132 -0
- package/dist/shadow-dom-crawler.js.map +7 -0
- package/dist/sitemap.js +16 -10
- package/dist/sitemap.js.map +2 -2
- package/dist/state-graph.js +193 -0
- package/dist/state-graph.js.map +7 -0
- package/dist/stealth/human-behavior.js +36 -3
- package/dist/stealth/human-behavior.js.map +2 -2
- package/dist/url-pattern-completer.js +48 -14
- package/dist/url-pattern-completer.js.map +2 -2
- package/dist/visual-diff-verdict.js +3 -1
- package/dist/visual-diff-verdict.js.map +2 -2
- package/package.json +2 -1
- package/src/accessibility-tree-crawler.ts +246 -0
- package/src/advanced-ui-probes.ts +470 -0
- package/src/crawl-session-manager.ts +231 -0
- package/src/crawler.ts +1050 -134
- 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/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/dist/crawler.js
CHANGED
|
@@ -50,6 +50,25 @@ import { screensToSitemapUrls, writeSitemap } from "./sitemap-writer.js";
|
|
|
50
50
|
import { generateOpenApiSpec, specToYaml } from "./openapi-generator.js";
|
|
51
51
|
import { WebhookNotifier } from "./webhook-notifier.js";
|
|
52
52
|
import { generatePlaywrightTest } from "./playwright-recorder.js";
|
|
53
|
+
import { enumerateDropdowns } from "./dropdown-enumerator.js";
|
|
54
|
+
import { probeCrudFlows } from "./crud-interaction.js";
|
|
55
|
+
import { crawlFiltersAndPagination } from "./filter-pagination-crawler.js";
|
|
56
|
+
import { probeAdvancedUI } from "./advanced-ui-probes.js";
|
|
57
|
+
import { ScreenTransitionGraph } from "./state-graph.js";
|
|
58
|
+
import { crawlNestedContexts } from "./nested-window-crawler.js";
|
|
59
|
+
import { crawlShadowDom } from "./shadow-dom-crawler.js";
|
|
60
|
+
import { crawlAccessibilityTree } from "./accessibility-tree-crawler.js";
|
|
61
|
+
import { collectPerfMetrics } from "./performance-metrics.js";
|
|
62
|
+
import { inspectServiceWorker } from "./service-worker-interceptor.js";
|
|
63
|
+
async function getCrawlTuning() {
|
|
64
|
+
const { getSystemConfig } = await import("@detiq/core");
|
|
65
|
+
const cfg = await getSystemConfig("crawl_tuning");
|
|
66
|
+
return {
|
|
67
|
+
navTimeoutMs: parseInt(String(cfg?.navTimeoutMs ?? "15000"), 10),
|
|
68
|
+
defaultTimeoutMs: parseInt(String(cfg?.defaultTimeoutMs ?? "2000"), 10),
|
|
69
|
+
userAgent: String(cfg?.userAgent ?? "ZeTa-Crawler/1.0 (+https://zeta.taodigitalsolutions.com/crawler)")
|
|
70
|
+
};
|
|
71
|
+
}
|
|
53
72
|
const _crawlLLMCache = /* @__PURE__ */ new Map();
|
|
54
73
|
async function getCachedCrawlLLM(tenantId) {
|
|
55
74
|
if (_crawlLLMCache.has(tenantId)) return _crawlLLMCache.get(tenantId);
|
|
@@ -83,20 +102,26 @@ function isGeneratedDynamicPath(pathname) {
|
|
|
83
102
|
if (ALLOWED_NUMERIC_PATHS.has(normalized)) return false;
|
|
84
103
|
return normalized.split("/").filter(Boolean).some(isGeneratedPathSegment);
|
|
85
104
|
}
|
|
105
|
+
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;
|
|
106
|
+
const API_PATH_RE = /^\/api\//i;
|
|
107
|
+
const EXPORT_PATH_RE = /\/(exports?|downloads?|export-csv|export-pdf|export-excel|download-report|generate-report|report-download)(\/|$|\?)/i;
|
|
86
108
|
function isUsefulNavigationUrl(href, siteOrigin) {
|
|
87
109
|
try {
|
|
88
110
|
const u = new URL(href);
|
|
89
111
|
if (!sameSite(u.toString(), siteOrigin)) return false;
|
|
90
112
|
if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
|
|
91
113
|
if (isGeneratedDynamicPath(u.pathname)) return false;
|
|
114
|
+
if (DOWNLOAD_EXT_RE.test(u.pathname)) return false;
|
|
115
|
+
if (API_PATH_RE.test(u.pathname)) return false;
|
|
116
|
+
if (EXPORT_PATH_RE.test(u.pathname)) return false;
|
|
92
117
|
return true;
|
|
93
118
|
} catch {
|
|
94
119
|
return false;
|
|
95
120
|
}
|
|
96
121
|
}
|
|
97
|
-
const AUTH_DISCOVERY_MAX_SCREENS = 36;
|
|
98
|
-
const AUTH_DISCOVERY_MAX_QUEUE = 48;
|
|
99
|
-
const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = 6;
|
|
122
|
+
const AUTH_DISCOVERY_MAX_SCREENS = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_SCREENS ?? "") || 36;
|
|
123
|
+
const AUTH_DISCOVERY_MAX_QUEUE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_QUEUE ?? "") || 48;
|
|
124
|
+
const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE ?? "") || 6;
|
|
100
125
|
const AUTH_DISCOVERY_PATHS = [
|
|
101
126
|
"/",
|
|
102
127
|
"/login",
|
|
@@ -322,6 +347,7 @@ const TwoFASchema = z.object({
|
|
|
322
347
|
fieldLabel: z.string().optional()
|
|
323
348
|
});
|
|
324
349
|
async function crawlProject(opts, onProgress) {
|
|
350
|
+
const tuning = await getCrawlTuning();
|
|
325
351
|
if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
|
|
326
352
|
const uploadToCloud = async (localPath, key) => {
|
|
327
353
|
if (!opts.storageConfig) return void 0;
|
|
@@ -367,15 +393,15 @@ async function crawlProject(opts, onProgress) {
|
|
|
367
393
|
break;
|
|
368
394
|
case "openrouter":
|
|
369
395
|
stagehandModel = `openai/${m}`;
|
|
370
|
-
stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || "https://openrouter.ai/api/v1" };
|
|
396
|
+
stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || process.env.CRAWLER_OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1" };
|
|
371
397
|
break;
|
|
372
398
|
case "ollama":
|
|
373
399
|
stagehandModel = `ollama/${m}`;
|
|
374
|
-
stagehandModelOpts = { baseURL: baseUrl || "http://localhost:11434" };
|
|
400
|
+
stagehandModelOpts = { baseURL: baseUrl || process.env.CRAWLER_OLLAMA_BASE_URL || "http://localhost:11434" };
|
|
375
401
|
break;
|
|
376
402
|
case "nvidia":
|
|
377
403
|
stagehandModel = `openai/${m.startsWith("openai/") ? m.slice(7) : m}`;
|
|
378
|
-
stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || "https://integrate.api.nvidia.com/v1" };
|
|
404
|
+
stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || process.env.CRAWLER_NVIDIA_BASE_URL || "https://integrate.api.nvidia.com/v1" };
|
|
379
405
|
break;
|
|
380
406
|
case "groq":
|
|
381
407
|
stagehandModel = `groq/${m}`;
|
|
@@ -422,15 +448,18 @@ async function crawlProject(opts, onProgress) {
|
|
|
422
448
|
const preferAnthropic = envModel.includes("claude") || !envModel && !process.env.OPENAI_API_KEY;
|
|
423
449
|
if (preferAnthropic && process.env.ANTHROPIC_API_KEY) {
|
|
424
450
|
stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
|
|
425
|
-
|
|
451
|
+
const defaultAnthropicModel = process.env.CRAWLER_DEFAULT_MODEL ?? "claude-sonnet-4-6";
|
|
452
|
+
stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || defaultAnthropicModel}`;
|
|
426
453
|
stagehandConfigured = true;
|
|
427
454
|
} else if (process.env.OPENAI_API_KEY) {
|
|
428
455
|
stagehandModelOpts = { apiKey: process.env.OPENAI_API_KEY };
|
|
429
|
-
|
|
456
|
+
const defaultOpenaiModel = process.env.CRAWLER_DEFAULT_MODEL ?? "gpt-4o";
|
|
457
|
+
stagehandModel = envModel.includes("/") ? envModel : `openai/${envModel || defaultOpenaiModel}`;
|
|
430
458
|
stagehandConfigured = true;
|
|
431
459
|
} else if (process.env.ANTHROPIC_API_KEY) {
|
|
432
460
|
stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
|
|
433
|
-
|
|
461
|
+
const defaultAnthropicModel2 = process.env.CRAWLER_DEFAULT_MODEL ?? "claude-sonnet-4-6";
|
|
462
|
+
stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || defaultAnthropicModel2}`;
|
|
434
463
|
stagehandConfigured = true;
|
|
435
464
|
}
|
|
436
465
|
}
|
|
@@ -464,7 +493,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
464
493
|
type: "CRAWL_PROFILE",
|
|
465
494
|
payload: { profile: domainProfile, routeDecision }
|
|
466
495
|
}).catch((err) => console.warn("[crawler] profile evidence save failed:", err?.message ?? err));
|
|
467
|
-
if (!stagehandConfigured) {
|
|
496
|
+
if (!stagehandConfigured && !opts.discoveryMode) {
|
|
468
497
|
throw new Error(
|
|
469
498
|
configuredProvider ? `No API key configured for provider "${configuredProvider}". Go to Workspace \u2192 Settings \u2192 AI Provider.` : "No AI provider configured. Go to Workspace \u2192 Settings \u2192 AI Provider to set up an AI provider for browser crawling."
|
|
470
499
|
);
|
|
@@ -607,17 +636,17 @@ async function crawlProject(opts, onProgress) {
|
|
|
607
636
|
verbose: 1,
|
|
608
637
|
logger: stagehandLogger,
|
|
609
638
|
localBrowserLaunchOptions: { cdpUrl, headless: true },
|
|
610
|
-
domSettleTimeout: 2e3
|
|
639
|
+
domSettleTimeout: parseInt(process.env.CRAWLER_DOM_SETTLE_TIMEOUT_MS ?? "") || 2e3
|
|
611
640
|
});
|
|
612
641
|
await stagehand.init();
|
|
613
642
|
await onProgress?.(5, "Browser launched");
|
|
614
643
|
const page = await stagehand.resolvePage();
|
|
615
644
|
try {
|
|
616
|
-
page.setDefaultTimeout(45e3);
|
|
645
|
+
page.setDefaultTimeout(tuning.defaultTimeoutMs || 45e3);
|
|
617
646
|
} catch {
|
|
618
647
|
}
|
|
619
648
|
try {
|
|
620
|
-
page.setDefaultNavigationTimeout(3e4);
|
|
649
|
+
page.setDefaultNavigationTimeout(tuning.navTimeoutMs || 3e4);
|
|
621
650
|
} catch {
|
|
622
651
|
}
|
|
623
652
|
let rawPage = null;
|
|
@@ -707,11 +736,91 @@ async function crawlProject(opts, onProgress) {
|
|
|
707
736
|
const pwContext = playwrightBrowser.contexts()[0];
|
|
708
737
|
if (pwContext) {
|
|
709
738
|
attachNetworkObserver(pwContext, capturedApiEndpoints, (relPath) => xhrDiscoveredPaths.add(relPath));
|
|
739
|
+
const OAUTH_PROVIDER_RE = /google\.|github\.|microsoft\.|facebook\.|twitter\.|linkedin\.|okta\.|auth0\.|ping\.|onelogin\.|sso\.|oauth\.|\/authorize\?|\/oauth2\//i;
|
|
710
740
|
pwContext.on("page", (popup) => {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
741
|
+
popup.waitForURL((u) => !u.startsWith("about:"), { timeout: 1e4 }).then(async () => {
|
|
742
|
+
const popupUrl = popup.url();
|
|
743
|
+
if (OAUTH_PROVIDER_RE.test(popupUrl)) {
|
|
744
|
+
logger.info({ url: popupUrl }, "[crawler] OAuth popup navigated to provider");
|
|
745
|
+
popup.waitForEvent("close", { timeout: 12e4 }).then(() => logger.info("[crawler] OAuth popup closed \u2014 SSO flow complete")).catch(() => logger.warn("[crawler] OAuth popup timed out or errored"));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
const _popupPath = (() => {
|
|
750
|
+
try {
|
|
751
|
+
return new URL(popupUrl).pathname;
|
|
752
|
+
} catch {
|
|
753
|
+
return "";
|
|
754
|
+
}
|
|
755
|
+
})();
|
|
756
|
+
if (API_PATH_RE.test(_popupPath) || EXPORT_PATH_RE.test(_popupPath) || DOWNLOAD_EXT_RE.test(_popupPath)) return;
|
|
757
|
+
} catch {
|
|
758
|
+
}
|
|
759
|
+
logger.info({ url: popupUrl }, "[crawler] App popup detected \u2014 capturing");
|
|
760
|
+
try {
|
|
761
|
+
await popup.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => {
|
|
762
|
+
});
|
|
763
|
+
await popup.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
|
|
764
|
+
});
|
|
765
|
+
const popupTitle = await popup.title().catch(() => "");
|
|
766
|
+
const popupName = popupTitle || (() => {
|
|
767
|
+
try {
|
|
768
|
+
return new URL(popupUrl).pathname.split("/").filter(Boolean).pop() || "Popup";
|
|
769
|
+
} catch {
|
|
770
|
+
return "Popup";
|
|
771
|
+
}
|
|
772
|
+
})();
|
|
773
|
+
const popupHash = popupUrl.replace(/[^a-z0-9]/gi, "").slice(-12);
|
|
774
|
+
const popupShotPath = path.join(opts.screenshotDir ?? "/tmp", `popup-${popupHash}.png`);
|
|
775
|
+
await waitForScreenshotReady(popup, popup);
|
|
776
|
+
await popup.screenshot({ path: popupShotPath, fullPage: false }).catch(() => {
|
|
777
|
+
});
|
|
778
|
+
const popupScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, popupName, popupUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
|
|
779
|
+
if (popupScreen) {
|
|
780
|
+
const cloudUrl = await uploadToCloud(popupShotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(popupShotPath)}`).catch(() => void 0);
|
|
781
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, popupScreen.id, popupShotPath, popupScreen.version, "DESKTOP", cloudUrl).catch(() => {
|
|
782
|
+
});
|
|
783
|
+
const popupEls = await domFallbackElements(popup, popup).catch(() => []);
|
|
784
|
+
for (const el of popupEls) {
|
|
785
|
+
AppBrain.saveElement(opts.tenantId, opts.projectId, popupScreen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
logger.info({ url: popupUrl, name: popupName, elementCount: popupEls.length }, "[crawler] App popup screen saved");
|
|
789
|
+
}
|
|
790
|
+
} catch (popupErr) {
|
|
791
|
+
logger.warn({ url: popupUrl, err: popupErr?.message }, "[crawler] App popup capture failed \u2014 non-fatal");
|
|
792
|
+
}
|
|
793
|
+
}).catch(() => {
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
pwContext.on("download", (dl) => {
|
|
797
|
+
dl.cancel().catch(() => {
|
|
798
|
+
});
|
|
714
799
|
});
|
|
800
|
+
try {
|
|
801
|
+
await pwContext.route("**/*", async (route, request) => {
|
|
802
|
+
const resourceType = request.resourceType?.() ?? "";
|
|
803
|
+
if (resourceType !== "document") {
|
|
804
|
+
await route.continue().catch(() => {
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
const u = new URL(request.url());
|
|
810
|
+
if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) {
|
|
811
|
+
logger.info({ url: request.url() }, "[crawler] route: aborting API/export/download navigation");
|
|
812
|
+
await route.abort("failed").catch(() => {
|
|
813
|
+
});
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
} catch {
|
|
817
|
+
}
|
|
818
|
+
await route.continue().catch(() => {
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
} catch (routeErr) {
|
|
822
|
+
logger.warn({ err: routeErr?.message }, "[crawler] route interception setup failed \u2014 downloads may occur");
|
|
823
|
+
}
|
|
715
824
|
}
|
|
716
825
|
const pwContext2 = playwrightBrowser?.contexts()?.[0];
|
|
717
826
|
if (pwContext2) {
|
|
@@ -799,6 +908,78 @@ async function crawlProject(opts, onProgress) {
|
|
|
799
908
|
const homeFingerprintElemCount = await page.evaluate(
|
|
800
909
|
() => document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
|
|
801
910
|
).catch(() => -1);
|
|
911
|
+
const preAuthPublicCaptured = /* @__PURE__ */ new Set();
|
|
912
|
+
if (!opts.discoveryMode) {
|
|
913
|
+
const _appOrigin = new URL(opts.appUrl).origin;
|
|
914
|
+
const _norm = (u) => {
|
|
915
|
+
try {
|
|
916
|
+
const p = new URL(u);
|
|
917
|
+
p.hash = "";
|
|
918
|
+
return p.href.replace(/\/$/, "");
|
|
919
|
+
} catch {
|
|
920
|
+
return u;
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
const _startNorm = _norm(opts.appUrl);
|
|
924
|
+
const _authPathRe = /\/(sign.?in|sign.?up|log.?in|register|auth|oauth|sso|forgot.?password|reset.?password)(\/|$|\?)/i;
|
|
925
|
+
const _skipPathRe = /\/(terms|privacy|legal|cookie-policy|changelog|blog|imprint)([-/]|$)/i;
|
|
926
|
+
const publicNavLinks = await page.evaluate((originStr) => {
|
|
927
|
+
return Array.from(document.querySelectorAll("a[href]")).map((a) => {
|
|
928
|
+
try {
|
|
929
|
+
const u = new URL(a.getAttribute("href"), window.location.href);
|
|
930
|
+
return u.origin === originStr ? u.href.split("#")[0] : "";
|
|
931
|
+
} catch {
|
|
932
|
+
return "";
|
|
933
|
+
}
|
|
934
|
+
}).filter((h, i, arr) => h && arr.indexOf(h) === i).slice(0, 30);
|
|
935
|
+
}, _appOrigin).catch(() => []);
|
|
936
|
+
for (const link of publicNavLinks) {
|
|
937
|
+
if (_norm(link) === _startNorm) continue;
|
|
938
|
+
try {
|
|
939
|
+
const { pathname } = new URL(link);
|
|
940
|
+
if (_authPathRe.test(pathname) || _skipPathRe.test(pathname) || API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
|
|
941
|
+
await page.goto(link, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
|
|
942
|
+
});
|
|
943
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
|
|
944
|
+
});
|
|
945
|
+
const finalUrl = page.url();
|
|
946
|
+
const finalPath = new URL(finalUrl).pathname;
|
|
947
|
+
if (_authPathRe.test(finalPath)) continue;
|
|
948
|
+
if (_norm(finalUrl) === _startNorm) continue;
|
|
949
|
+
const preAuthElements = await readScreen(page).catch(() => []);
|
|
950
|
+
if (preAuthElements.length === 0) {
|
|
951
|
+
logger.info({ url: finalUrl }, "[crawler] pre-auth page has 0 elements \u2014 deferring to authenticated BFS");
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const pathParts = finalPath.split("/").filter(Boolean);
|
|
955
|
+
const screenName = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
|
|
956
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
|
|
957
|
+
if (!screen) continue;
|
|
958
|
+
const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
|
|
959
|
+
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {
|
|
960
|
+
});
|
|
961
|
+
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => void 0);
|
|
962
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl).catch(() => {
|
|
963
|
+
});
|
|
964
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
|
|
965
|
+
});
|
|
966
|
+
for (const el of preAuthElements) {
|
|
967
|
+
await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.label, el.role, el.expectedData, void 0, void 0, true, void 0, void 0, void 0).catch(() => {
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
preAuthPublicCaptured.add(finalUrl);
|
|
971
|
+
logger.info({ url: finalUrl, screenId: screen.id, elements: preAuthElements.length }, "[crawler] pre-auth public page captured");
|
|
972
|
+
} catch (err) {
|
|
973
|
+
logger.warn({ url: link, err: err?.message }, "[crawler] pre-auth public page visit failed \u2014 skipping");
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if (preAuthPublicCaptured.size > 0) {
|
|
977
|
+
await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 2e4 }).catch(() => {
|
|
978
|
+
});
|
|
979
|
+
await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
}
|
|
802
983
|
if (!opts.discoveryMode) {
|
|
803
984
|
try {
|
|
804
985
|
await runAuthDiscoveryPhase(page, opts, new URL(opts.appUrl).origin, onProgress);
|
|
@@ -806,6 +987,16 @@ async function crawlProject(opts, onProgress) {
|
|
|
806
987
|
logger.warn({ err: preAuthErr }, "[crawler] auth-discovery phase failed \u2014 continuing with authenticated crawl");
|
|
807
988
|
}
|
|
808
989
|
}
|
|
990
|
+
{
|
|
991
|
+
const worldOk = await page.evaluate(() => true).catch(() => false);
|
|
992
|
+
if (!worldOk) {
|
|
993
|
+
logger.warn("[crawler] isolated world corrupted after auth discovery \u2014 re-navigating to recover");
|
|
994
|
+
await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
|
|
995
|
+
});
|
|
996
|
+
await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
}
|
|
809
1000
|
let didLogin = false;
|
|
810
1001
|
if (opts.authToken) {
|
|
811
1002
|
await onProgress?.(18, "API token configured \u2014 skipping login");
|
|
@@ -819,8 +1010,42 @@ async function crawlProject(opts, onProgress) {
|
|
|
819
1010
|
if (browserCtx) {
|
|
820
1011
|
await browserCtx.addCookies(state.cookies);
|
|
821
1012
|
}
|
|
1013
|
+
try {
|
|
1014
|
+
const stagehandCtx = page.context?.();
|
|
1015
|
+
if (stagehandCtx && typeof stagehandCtx.addCookies === "function") {
|
|
1016
|
+
await stagehandCtx.addCookies(state.cookies);
|
|
1017
|
+
}
|
|
1018
|
+
} catch {
|
|
1019
|
+
}
|
|
822
1020
|
}
|
|
823
1021
|
if (state.origins?.length) {
|
|
1022
|
+
const originCtx = playwrightBrowser?.contexts()[0];
|
|
1023
|
+
if (originCtx) {
|
|
1024
|
+
await originCtx.addInitScript((origins) => {
|
|
1025
|
+
for (const entry of origins ?? []) {
|
|
1026
|
+
if (window.location.origin === entry.origin) {
|
|
1027
|
+
for (const item of entry.localStorage) {
|
|
1028
|
+
try {
|
|
1029
|
+
localStorage.setItem(item.name, item.value);
|
|
1030
|
+
} catch {
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}, state.origins);
|
|
1036
|
+
}
|
|
1037
|
+
await page.addInitScript((origins) => {
|
|
1038
|
+
for (const entry of origins ?? []) {
|
|
1039
|
+
if (window.location.origin === entry.origin) {
|
|
1040
|
+
for (const item of entry.localStorage) {
|
|
1041
|
+
try {
|
|
1042
|
+
localStorage.setItem(item.name, item.value);
|
|
1043
|
+
} catch {
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}, state.origins);
|
|
824
1049
|
await rawPage.addInitScript((origins) => {
|
|
825
1050
|
for (const entry of origins ?? []) {
|
|
826
1051
|
if (window.location.origin === entry.origin) {
|
|
@@ -1024,12 +1249,43 @@ async function crawlProject(opts, onProgress) {
|
|
|
1024
1249
|
"nonce",
|
|
1025
1250
|
"session",
|
|
1026
1251
|
"sid",
|
|
1252
|
+
// App content/state params — these change the content rendered within the same
|
|
1253
|
+
// page template (a playground example, a search query, a tab selection) but do NOT
|
|
1254
|
+
// represent structurally distinct pages. Not stripping them causes URL explosion:
|
|
1255
|
+
// every unique ?cmd=DSN-XXXXX&example=... or ?q=foo is queued as a new screen.
|
|
1256
|
+
"cmd",
|
|
1257
|
+
"example",
|
|
1258
|
+
"query",
|
|
1259
|
+
"q",
|
|
1260
|
+
"search",
|
|
1261
|
+
"keyword",
|
|
1262
|
+
"keywords",
|
|
1263
|
+
"tab",
|
|
1264
|
+
"view",
|
|
1265
|
+
"mode",
|
|
1266
|
+
"sort",
|
|
1267
|
+
"order",
|
|
1268
|
+
"direction",
|
|
1269
|
+
"orderby",
|
|
1270
|
+
"page",
|
|
1271
|
+
"p",
|
|
1272
|
+
"offset",
|
|
1273
|
+
"limit",
|
|
1274
|
+
"per_page",
|
|
1275
|
+
"size",
|
|
1276
|
+
"rows",
|
|
1277
|
+
// Export format params — same endpoint, different output format, not a new screen
|
|
1278
|
+
"format",
|
|
1279
|
+
"output",
|
|
1280
|
+
"export_format",
|
|
1281
|
+
"download_format",
|
|
1282
|
+
"fileformat",
|
|
1283
|
+
"file_format",
|
|
1027
1284
|
// WooCommerce / e-commerce action params
|
|
1028
1285
|
"add-to-cart",
|
|
1029
1286
|
"add_to_wishlist",
|
|
1030
1287
|
"quantity",
|
|
1031
1288
|
"wc-ajax",
|
|
1032
|
-
"orderby",
|
|
1033
1289
|
"min_price",
|
|
1034
1290
|
"max_price",
|
|
1035
1291
|
"removed_item",
|
|
@@ -1097,9 +1353,11 @@ async function crawlProject(opts, onProgress) {
|
|
|
1097
1353
|
};
|
|
1098
1354
|
const gitInfo = getGitInfo();
|
|
1099
1355
|
const visited = /* @__PURE__ */ new Map();
|
|
1356
|
+
const crawlWarnings = [];
|
|
1100
1357
|
if (opts.skipUrls?.length) {
|
|
1101
1358
|
for (const u of opts.skipUrls) visited.set(canonicalize(u), "skipped");
|
|
1102
1359
|
}
|
|
1360
|
+
for (const u of preAuthPublicCaptured) visited.set(canonicalize(u), "pre-auth");
|
|
1103
1361
|
let checkpointSuccessCount = 0;
|
|
1104
1362
|
let maxSeenTotal = 0;
|
|
1105
1363
|
let lastEmittedPct = 20;
|
|
@@ -1133,7 +1391,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1133
1391
|
];
|
|
1134
1392
|
const queued = new Set(seedUrls.map(canonicalize));
|
|
1135
1393
|
const queue = new CrawlFrontier(opts.traversalStrategy ?? "BFS", seedUrls, opts.bestFirstKeywords, opts.urlFilter);
|
|
1136
|
-
const deduplicator = new ContentDeduplicator(0.88);
|
|
1394
|
+
const deduplicator = new ContentDeduplicator(parseFloat(process.env.CRAWLER_DEDUP_THRESHOLD ?? "") || 0.88);
|
|
1137
1395
|
const deadLinkTracker = new DeadLinkTracker();
|
|
1138
1396
|
const redirectRegistry = new RedirectRegistry();
|
|
1139
1397
|
if (!selective && resumeUrls.length === 0) {
|
|
@@ -1143,6 +1401,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1143
1401
|
const orphanIdSet = new Set(
|
|
1144
1402
|
(orphanReport?.screens ?? []).filter((s) => s.classification !== "REACHABLE").map((s) => s.id)
|
|
1145
1403
|
);
|
|
1404
|
+
const seenReseedCanon = new Set(queued);
|
|
1146
1405
|
const priorScreens = (priorMap?.screens ?? []).filter((s) => s.status !== "PENDING_REMOVAL").filter((s) => {
|
|
1147
1406
|
const u = s.url;
|
|
1148
1407
|
if (!u) return false;
|
|
@@ -1151,11 +1410,15 @@ async function crawlProject(opts, onProgress) {
|
|
|
1151
1410
|
const stripWww = (h) => h.replace(/^www\./i, "").toLowerCase();
|
|
1152
1411
|
if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
|
|
1153
1412
|
if (isGeneratedDynamicPath(pathname)) return false;
|
|
1413
|
+
if (!isUsefulNavigationUrl(u, startUrl)) return false;
|
|
1414
|
+
const canon = canonicalize(u);
|
|
1415
|
+
if (seenReseedCanon.has(canon)) return false;
|
|
1416
|
+
seenReseedCanon.add(canon);
|
|
1154
1417
|
return true;
|
|
1155
1418
|
} catch {
|
|
1156
1419
|
return false;
|
|
1157
1420
|
}
|
|
1158
|
-
})
|
|
1421
|
+
});
|
|
1159
1422
|
let orphanSeedCount = 0;
|
|
1160
1423
|
for (const s of priorScreens) {
|
|
1161
1424
|
const u = s.url;
|
|
@@ -1213,6 +1476,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1213
1476
|
try {
|
|
1214
1477
|
const u = new URL(sUrl);
|
|
1215
1478
|
if (u.origin !== sitemapOrigin) continue;
|
|
1479
|
+
if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) continue;
|
|
1216
1480
|
await page.goto(sUrl, { waitUntil: "domcontentloaded", timeout: 12e3 });
|
|
1217
1481
|
await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
|
|
1218
1482
|
});
|
|
@@ -1268,16 +1532,20 @@ async function crawlProject(opts, onProgress) {
|
|
|
1268
1532
|
const discovered = [];
|
|
1269
1533
|
const performanceMetrics = [];
|
|
1270
1534
|
const seenDomHashes = new Set(opts.knownDomHashes ?? []);
|
|
1271
|
-
const
|
|
1535
|
+
const skipPathExtra = process.env.CRAWL_SKIP_PATH_EXTRA ? process.env.CRAWL_SKIP_PATH_EXTRA.split(",").map((p) => p.trim()).filter(Boolean) : [];
|
|
1536
|
+
const SKIP_PATH = skipPathExtra.length > 0 ? new RegExp(`/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap|${skipPathExtra.join("|")})([-/]|$)`, "i") : /\/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap)([-/]|$)/i;
|
|
1272
1537
|
const segIdx = opts.segmentIndex ?? 1;
|
|
1273
1538
|
const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
|
|
1274
1539
|
let avgNavMs = 0;
|
|
1275
1540
|
let navCount = 0;
|
|
1276
1541
|
let aiErrorStreak = 0;
|
|
1277
1542
|
let aiSkipCount = 0;
|
|
1543
|
+
const aiMinElements = parseInt(process.env.CRAWL_AI_LINK_MIN_ELEMENTS ?? "") || 5;
|
|
1278
1544
|
let aiRateLimitWarned = false;
|
|
1279
1545
|
let totalAttemptsThisSegment = 0;
|
|
1280
1546
|
const maxAttempts = Math.max(segmentSize * 4, 200);
|
|
1547
|
+
let loginWallRedirectCount = 0;
|
|
1548
|
+
const LOGIN_WALL_ABORT_THRESHOLD = Math.max(8, Math.ceil(segmentSize * 0.4));
|
|
1281
1549
|
let pendingSessionRestore = null;
|
|
1282
1550
|
const CRAWL_PAGE_PARALLELISM = Math.max(1, parseInt(process.env.CRAWL_PAGE_PARALLELISM ?? "5", 10));
|
|
1283
1551
|
async function runLiteWorker(workerPage) {
|
|
@@ -1291,18 +1559,42 @@ async function crawlProject(opts, onProgress) {
|
|
|
1291
1559
|
try {
|
|
1292
1560
|
const { pathname } = new URL(url);
|
|
1293
1561
|
if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
|
|
1562
|
+
if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
|
|
1294
1563
|
} catch {
|
|
1295
1564
|
continue;
|
|
1296
1565
|
}
|
|
1297
1566
|
visited.set(canonUrl, "_pending_");
|
|
1298
1567
|
try {
|
|
1299
|
-
const navErr = await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout:
|
|
1568
|
+
const navErr = await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).then(() => null, (e) => e);
|
|
1300
1569
|
if (navErr && /timeout/i.test(String(navErr?.message ?? navErr))) {
|
|
1301
1570
|
await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch(() => {
|
|
1302
1571
|
});
|
|
1303
1572
|
}
|
|
1304
1573
|
await workerPage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
1305
1574
|
});
|
|
1575
|
+
await waitForStableDOM(workerPage, 6e3, 400).catch(() => {
|
|
1576
|
+
});
|
|
1577
|
+
await dismissOverlays(workerPage).catch(() => {
|
|
1578
|
+
});
|
|
1579
|
+
const lwCurrentUrl = workerPage.url();
|
|
1580
|
+
const lwPath = (() => {
|
|
1581
|
+
try {
|
|
1582
|
+
return new URL(lwCurrentUrl).pathname.toLowerCase();
|
|
1583
|
+
} catch {
|
|
1584
|
+
return "";
|
|
1585
|
+
}
|
|
1586
|
+
})();
|
|
1587
|
+
const lwIsAuthWall = /^\/(login|signin|sign-in|auth|authenticate|session\/new|account\/login|user\/login)/.test(lwPath) && lwCurrentUrl !== url;
|
|
1588
|
+
const lwHasLoginForm = lwIsAuthWall ? false : await workerPage.evaluate(() => {
|
|
1589
|
+
const inputs = document.querySelectorAll('input[type="password"]');
|
|
1590
|
+
return inputs.length > 0;
|
|
1591
|
+
}).catch(() => false);
|
|
1592
|
+
if (lwIsAuthWall) {
|
|
1593
|
+
logger.warn({ url, landed: lwCurrentUrl }, "[lite-worker] auth wall detected \u2014 queuing for main worker re-auth");
|
|
1594
|
+
visited.set(canonUrl, "_auth_wall_");
|
|
1595
|
+
if (!revisitQueue.includes(url)) revisitQueue.push(url);
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1306
1598
|
if (homeFingerprintElemCount >= 0 && url !== startUrl) {
|
|
1307
1599
|
const wTitle = await workerPage.title().catch(() => "");
|
|
1308
1600
|
if (wTitle === homeFingerprintTitle && wTitle !== "") {
|
|
@@ -1321,31 +1613,169 @@ async function crawlProject(opts, onProgress) {
|
|
|
1321
1613
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
|
|
1322
1614
|
visited.set(canonUrl, screen.id);
|
|
1323
1615
|
const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
|
|
1616
|
+
let liteElements = await domFallbackElements(workerPage).catch(() => []);
|
|
1617
|
+
if (liteElements.length === 0) {
|
|
1618
|
+
liteElements = await workerPage.evaluate(() => {
|
|
1619
|
+
const sel = 'button,a[href],input,select,textarea,[role],[tabindex="0"],[onclick],[data-action]';
|
|
1620
|
+
return Array.from(document.querySelectorAll(sel)).filter((el) => el.offsetParent !== null).slice(0, 150).map((el) => ({
|
|
1621
|
+
meaning: el.getAttribute("aria-label") || el.textContent?.trim().slice(0, 100) || el.tagName.toLowerCase(),
|
|
1622
|
+
role: el.getAttribute("role") || el.tagName.toLowerCase(),
|
|
1623
|
+
expectedData: null,
|
|
1624
|
+
notes: null,
|
|
1625
|
+
boundingRect: null,
|
|
1626
|
+
ariaState: null,
|
|
1627
|
+
parentLandmark: null
|
|
1628
|
+
}));
|
|
1629
|
+
}).catch(() => []);
|
|
1630
|
+
}
|
|
1324
1631
|
const [elements] = await Promise.all([
|
|
1325
|
-
|
|
1632
|
+
Promise.resolve(liteElements),
|
|
1326
1633
|
workerPage.screenshot({ path: shotPath, fullPage: true }).then(async () => {
|
|
1327
1634
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
1328
1635
|
return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl);
|
|
1329
1636
|
}).catch(() => {
|
|
1330
1637
|
})
|
|
1331
1638
|
]);
|
|
1639
|
+
const _navEls = await workerPage.evaluate(() => {
|
|
1640
|
+
if (typeof globalThis.__name === "undefined") globalThis.__name = (fn) => fn;
|
|
1641
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1642
|
+
const items = [];
|
|
1643
|
+
function isV(el) {
|
|
1644
|
+
const s = window.getComputedStyle(el);
|
|
1645
|
+
return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0";
|
|
1646
|
+
}
|
|
1647
|
+
document.querySelectorAll('nav a, [role="navigation"] a, header a, footer nav a, [aria-label*="navigation" i] a').forEach((a) => {
|
|
1648
|
+
if (!isV(a)) return;
|
|
1649
|
+
const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
|
|
1650
|
+
const href = a.href ?? "";
|
|
1651
|
+
if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
|
|
1652
|
+
let landmark = "Site Navigation";
|
|
1653
|
+
let cur = a.parentElement;
|
|
1654
|
+
while (cur) {
|
|
1655
|
+
const t = cur.tagName?.toLowerCase();
|
|
1656
|
+
const r = cur.getAttribute?.("role")?.toLowerCase();
|
|
1657
|
+
if (t === "header" || r === "banner") {
|
|
1658
|
+
landmark = "Header";
|
|
1659
|
+
break;
|
|
1660
|
+
}
|
|
1661
|
+
if (t === "footer" || r === "contentinfo") {
|
|
1662
|
+
landmark = "Footer";
|
|
1663
|
+
break;
|
|
1664
|
+
}
|
|
1665
|
+
if (t === "nav" || r === "navigation") {
|
|
1666
|
+
landmark = cur.getAttribute("aria-label")?.trim() || "Site Navigation";
|
|
1667
|
+
break;
|
|
1668
|
+
}
|
|
1669
|
+
cur = cur.parentElement;
|
|
1670
|
+
}
|
|
1671
|
+
const key = `nav|${label.toLowerCase()}|${href}`;
|
|
1672
|
+
if (!seen.has(key)) {
|
|
1673
|
+
seen.add(key);
|
|
1674
|
+
items.push({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
let fc = 0;
|
|
1678
|
+
document.querySelectorAll('footer a, [role="contentinfo"] a, [class*="footer" i] a').forEach((a) => {
|
|
1679
|
+
if (fc >= 20 || !isV(a)) return;
|
|
1680
|
+
const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
|
|
1681
|
+
const href = a.href ?? "";
|
|
1682
|
+
if (!label || !href) return;
|
|
1683
|
+
const key = `footer|${label.toLowerCase()}|${href}`;
|
|
1684
|
+
if (!seen.has(key)) {
|
|
1685
|
+
seen.add(key);
|
|
1686
|
+
items.push({ meaning: label, role: "footer-link", notes: JSON.stringify({ href: href.slice(0, 150) }) });
|
|
1687
|
+
fc++;
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
return items;
|
|
1691
|
+
}).catch(() => []);
|
|
1692
|
+
const allElements = [
|
|
1693
|
+
...elements,
|
|
1694
|
+
..._navEls.map((ne) => ({ ...ne, expectedData: void 0, boundingRect: void 0, ariaState: void 0, parentLandmark: void 0 }))
|
|
1695
|
+
];
|
|
1696
|
+
if (allElements.length > 0) {
|
|
1697
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
|
|
1698
|
+
});
|
|
1699
|
+
for (let _ei = 0; _ei < allElements.length; _ei += 4) {
|
|
1700
|
+
const _batch = allElements.slice(_ei, _ei + 4);
|
|
1701
|
+
await Promise.all(_batch.map(
|
|
1702
|
+
(el) => AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
|
|
1703
|
+
})
|
|
1704
|
+
));
|
|
1705
|
+
}
|
|
1706
|
+
logger.info({ url, screenId: screen.id, elements: elements.length, navLinks: _navEls.length }, "[lite-worker] screen captured");
|
|
1707
|
+
} else if (!revisitQueue.includes(url)) {
|
|
1708
|
+
logger.warn({ url, screenId: screen.id }, "[lite-worker] 0 elements \u2014 queued for P3a revisit");
|
|
1709
|
+
revisitQueue.push(url);
|
|
1710
|
+
}
|
|
1711
|
+
if (screen?.id) {
|
|
1712
|
+
const [shadowRes, a11yRes, swRes] = await Promise.race([
|
|
1713
|
+
Promise.allSettled([
|
|
1714
|
+
crawlShadowDom(workerPage),
|
|
1715
|
+
crawlAccessibilityTree(workerPage),
|
|
1716
|
+
inspectServiceWorker(workerPage)
|
|
1717
|
+
]),
|
|
1718
|
+
new Promise(
|
|
1719
|
+
(resolve) => setTimeout(() => resolve([
|
|
1720
|
+
{ status: "rejected", reason: "timeout" },
|
|
1721
|
+
{ status: "rejected", reason: "timeout" },
|
|
1722
|
+
{ status: "rejected", reason: "timeout" }
|
|
1723
|
+
]), 15e3)
|
|
1724
|
+
)
|
|
1725
|
+
]);
|
|
1726
|
+
const litePayload = { screenId: screen.id, url };
|
|
1727
|
+
if (shadowRes.status === "fulfilled" && shadowRes.value && shadowRes.value.shadowHostCount > 0)
|
|
1728
|
+
litePayload.shadowDom = shadowRes.value;
|
|
1729
|
+
if (a11yRes.status === "fulfilled" && a11yRes.value)
|
|
1730
|
+
litePayload.accessibilityTree = a11yRes.value;
|
|
1731
|
+
if (swRes.status === "fulfilled" && swRes.value.supported)
|
|
1732
|
+
litePayload.serviceWorker = swRes.value;
|
|
1733
|
+
if (Object.keys(litePayload).length > 2) {
|
|
1734
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
1735
|
+
jobId: opts.jobId,
|
|
1736
|
+
type: "PER_SCREEN_PROBES",
|
|
1737
|
+
payload: litePayload
|
|
1738
|
+
}).catch(() => {
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1332
1742
|
const nextLinks = await workerPage.evaluate((originStr) => {
|
|
1333
|
-
return Array.from(document.querySelectorAll("a[href]")).map((a) => {
|
|
1743
|
+
return Array.from(document.querySelectorAll("a[href],[data-href]")).map((a) => {
|
|
1744
|
+
const raw = a.getAttribute("href") || a.getAttribute("data-href") || "";
|
|
1334
1745
|
try {
|
|
1335
|
-
return new URL(
|
|
1746
|
+
return new URL(raw, window.location.href).href;
|
|
1336
1747
|
} catch {
|
|
1337
1748
|
return "";
|
|
1338
1749
|
}
|
|
1339
|
-
}).filter((h) =>
|
|
1750
|
+
}).filter((h) => {
|
|
1751
|
+
if (!h) return false;
|
|
1752
|
+
try {
|
|
1753
|
+
const u = new URL(h);
|
|
1754
|
+
if (u.origin !== originStr) return false;
|
|
1755
|
+
if (u.hash && !u.hash.startsWith("#/") && !u.hash.startsWith("#!/")) return false;
|
|
1756
|
+
return true;
|
|
1757
|
+
} catch {
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
}).slice(0, 200);
|
|
1340
1761
|
}, origin).catch(() => []);
|
|
1341
1762
|
for (const link of nextLinks) {
|
|
1763
|
+
if (!isUsefulNavigationUrl(link, origin)) continue;
|
|
1342
1764
|
const cLink = canonicalize(link);
|
|
1343
|
-
if (!visited.has(cLink)) {
|
|
1765
|
+
if (!visited.has(cLink) && !queued.has(cLink)) {
|
|
1766
|
+
queued.add(cLink);
|
|
1344
1767
|
queue.push(link);
|
|
1345
1768
|
edges.push({ fromUrl: url, toUrl: link });
|
|
1346
1769
|
}
|
|
1347
1770
|
}
|
|
1348
1771
|
discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
|
|
1772
|
+
const lwEffMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
|
|
1773
|
+
maxSeenTotal = Math.max(maxSeenTotal, lwEffMax);
|
|
1774
|
+
const lwRawPct = 20 + Math.round(Math.min(57, visited.size / Math.max(1, maxSeenTotal) * 57));
|
|
1775
|
+
const lwPct = Math.max(lastEmittedPct, lwRawPct);
|
|
1776
|
+
lastEmittedPct = lwPct;
|
|
1777
|
+
await onProgress?.(lwPct, `Crawling screens (${visited.size} mapped, ${queue.length} pending)`).catch(() => {
|
|
1778
|
+
});
|
|
1349
1779
|
} catch (err) {
|
|
1350
1780
|
logger.warn({ url, err: err?.message ?? String(err) }, "[lite-worker] URL processing failed");
|
|
1351
1781
|
discovered.push({ url, error: err?.message ?? String(err) });
|
|
@@ -1353,7 +1783,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1353
1783
|
}
|
|
1354
1784
|
}
|
|
1355
1785
|
const liteWorkerPromises = [];
|
|
1356
|
-
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
|
|
1786
|
+
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser && !opts.discoveryMode) {
|
|
1357
1787
|
const pwCtx = playwrightBrowser.contexts()[0];
|
|
1358
1788
|
for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
|
|
1359
1789
|
const wp = await pwCtx.newPage();
|
|
@@ -1361,7 +1791,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1361
1791
|
Object.defineProperty(navigator, "webdriver", { get: () => void 0 });
|
|
1362
1792
|
}).catch(() => {
|
|
1363
1793
|
});
|
|
1364
|
-
wp.setDefaultTimeout(45e3);
|
|
1794
|
+
wp.setDefaultTimeout(tuning.defaultTimeoutMs || 45e3);
|
|
1365
1795
|
liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {
|
|
1366
1796
|
})));
|
|
1367
1797
|
}
|
|
@@ -1386,9 +1816,10 @@ async function crawlProject(opts, onProgress) {
|
|
|
1386
1816
|
}
|
|
1387
1817
|
const url = queue.shift();
|
|
1388
1818
|
const canonUrl = canonicalize(url);
|
|
1389
|
-
logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size + 1}/${segmentSize}` }, "[crawler] visiting URL");
|
|
1390
1819
|
const apiObservationStartIndex = capturedApiEndpoints.length;
|
|
1391
1820
|
if (visited.has(canonUrl)) continue;
|
|
1821
|
+
visited.set(canonUrl, "_pending_");
|
|
1822
|
+
logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size}/${segmentSize}` }, "[crawler] visiting URL");
|
|
1392
1823
|
if (robotsCache && !await robotsCache.isAllowed(url)) {
|
|
1393
1824
|
logger.info({ url }, "[crawler] robots.txt: skipping disallowed URL");
|
|
1394
1825
|
continue;
|
|
@@ -1403,6 +1834,11 @@ async function crawlProject(opts, onProgress) {
|
|
|
1403
1834
|
console.log(`[crawler] skip generated dynamic route: ${url}`);
|
|
1404
1835
|
continue;
|
|
1405
1836
|
}
|
|
1837
|
+
if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) {
|
|
1838
|
+
console.log(`[crawler] skip API/export/download path: ${url}`);
|
|
1839
|
+
visited.set(canonUrl, "_skipped_api_");
|
|
1840
|
+
continue;
|
|
1841
|
+
}
|
|
1406
1842
|
} catch {
|
|
1407
1843
|
}
|
|
1408
1844
|
try {
|
|
@@ -1481,7 +1917,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1481
1917
|
logger.info({ url }, "[crawler] temporarily cleared session for public auth route");
|
|
1482
1918
|
}
|
|
1483
1919
|
}
|
|
1484
|
-
const navErr15 = await page.goto(url, { waitUntil: "domcontentloaded", timeout:
|
|
1920
|
+
const navErr15 = await page.goto(url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).then(() => null, (e) => e);
|
|
1485
1921
|
if (navErr15) {
|
|
1486
1922
|
if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
|
|
1487
1923
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch((navErr) => {
|
|
@@ -1493,14 +1929,19 @@ async function crawlProject(opts, onProgress) {
|
|
|
1493
1929
|
}
|
|
1494
1930
|
rawPage.off("response", onRedirectResponse);
|
|
1495
1931
|
if (redirectHops.length > 0) {
|
|
1932
|
+
const isCircularRedirect = new Set(redirectHops.map((h) => h.from)).size < redirectHops.length;
|
|
1496
1933
|
redirectRegistry.register({
|
|
1497
1934
|
originalUrl: url,
|
|
1498
1935
|
finalUrl: page.url(),
|
|
1499
1936
|
hops: redirectHops.map((h, i) => ({ ...h, durationMs: i === 0 ? Date.now() - navStart : 0 })),
|
|
1500
|
-
isCircular:
|
|
1937
|
+
isCircular: isCircularRedirect,
|
|
1501
1938
|
hopCount: redirectHops.length,
|
|
1502
1939
|
totalDurationMs: Date.now() - navStart
|
|
1503
1940
|
});
|
|
1941
|
+
if (isCircularRedirect) {
|
|
1942
|
+
logger.warn({ url, hops: redirectHops.map((h) => h.from) }, "[crawler] circular redirect detected \u2014 skipping URL");
|
|
1943
|
+
continue;
|
|
1944
|
+
}
|
|
1504
1945
|
}
|
|
1505
1946
|
const navMs = Date.now() - navStart;
|
|
1506
1947
|
navCount++;
|
|
@@ -1510,9 +1951,36 @@ async function crawlProject(opts, onProgress) {
|
|
|
1510
1951
|
console.warn(`[crawler] connection failed for ${url} \u2014 skipping (chrome-error page)`);
|
|
1511
1952
|
continue;
|
|
1512
1953
|
}
|
|
1513
|
-
await page.waitForLoadState("load", { timeout: 8e3 }).catch(() => {
|
|
1954
|
+
await (rawPage ?? page).waitForLoadState("load", { timeout: 8e3 }).catch(() => {
|
|
1514
1955
|
});
|
|
1515
|
-
await waitForStableDOM(page);
|
|
1956
|
+
await waitForStableDOM(page, 8e3, 400, rawPage ?? void 0);
|
|
1957
|
+
const sparseCheck = await (rawPage ?? page).evaluate(
|
|
1958
|
+
() => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[aria-label]').length
|
|
1959
|
+
).catch(() => 99);
|
|
1960
|
+
if (sparseCheck < 15) {
|
|
1961
|
+
await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
|
|
1962
|
+
});
|
|
1963
|
+
await waitForStableDOM(page, 4e3, 300, rawPage ?? void 0);
|
|
1964
|
+
}
|
|
1965
|
+
try {
|
|
1966
|
+
const spinnerPage = rawPage ?? page;
|
|
1967
|
+
if (typeof spinnerPage.waitForFunction === "function") {
|
|
1968
|
+
await spinnerPage.waitForFunction(() => {
|
|
1969
|
+
const body = document.body;
|
|
1970
|
+
if (!body) return true;
|
|
1971
|
+
const text = body.innerText?.toLowerCase() ?? "";
|
|
1972
|
+
if (text.includes("checking authentication") || text.includes("checking auth") || text.includes("authenticating") || text.includes("verifying session") || text.includes("please wait") || text.includes("loading data") || text.includes("loading") && text.length < 200) return false;
|
|
1973
|
+
const busyEl = body.querySelector('[aria-busy="true"]');
|
|
1974
|
+
if (busyEl && busyEl.offsetParent !== null) return false;
|
|
1975
|
+
if (body.querySelector('[data-loading="true"],[data-pending="true"],[data-fetching="true"]')) return false;
|
|
1976
|
+
if (body.querySelector("progress:not([value])")) return false;
|
|
1977
|
+
const skeleton = body.querySelector(".skeleton,.skeleton-loader,.shimmer,.pulse-loader,.content-loader");
|
|
1978
|
+
if (skeleton && skeleton.offsetParent !== null) return false;
|
|
1979
|
+
return true;
|
|
1980
|
+
}, { timeout: 12e3 });
|
|
1981
|
+
}
|
|
1982
|
+
} catch {
|
|
1983
|
+
}
|
|
1516
1984
|
if (!opts.discoveryMode) {
|
|
1517
1985
|
try {
|
|
1518
1986
|
const appModalVisible = await page.evaluate(() => {
|
|
@@ -1529,7 +1997,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1529
1997
|
const modalShotPath = path.join(opts.screenshotDir ?? "/tmp", `modal-${modalHash}.png`);
|
|
1530
1998
|
await page.screenshot({ path: modalShotPath, fullPage: false }).catch(() => {
|
|
1531
1999
|
});
|
|
1532
|
-
const modalName = await page.evaluate(() => {
|
|
2000
|
+
const modalName = await (rawPage ?? page).evaluate(() => {
|
|
1533
2001
|
const h = document.querySelector('[role="dialog"] h1,[role="dialog"] h2,[role="dialog"] [role="heading"]');
|
|
1534
2002
|
return h?.textContent?.trim() ?? "Modal";
|
|
1535
2003
|
}).catch(() => "Modal");
|
|
@@ -1537,13 +2005,18 @@ async function crawlProject(opts, onProgress) {
|
|
|
1537
2005
|
if (modalScreen) {
|
|
1538
2006
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, "DESKTOP").catch(() => {
|
|
1539
2007
|
});
|
|
1540
|
-
|
|
2008
|
+
const modalElements = await domFallbackElements(page, rawPage ?? void 0).catch(() => []);
|
|
2009
|
+
for (const el of modalElements) {
|
|
2010
|
+
AppBrain.saveElement(opts.tenantId, opts.projectId, modalScreen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
logger.info({ url, name: modalName, elementCount: modalElements.length }, "[crawler] captured app modal before dismiss");
|
|
1541
2014
|
}
|
|
1542
2015
|
}
|
|
1543
2016
|
} catch {
|
|
1544
2017
|
}
|
|
1545
2018
|
await dismissOverlays(page);
|
|
1546
|
-
await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }).catch(() => {
|
|
2019
|
+
await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }, rawPage ?? void 0).catch(() => {
|
|
1547
2020
|
});
|
|
1548
2021
|
}
|
|
1549
2022
|
if (cdpSession?.off) cdpSession.off("Network.responseReceived", secHeadersCdp);
|
|
@@ -1563,6 +2036,22 @@ async function crawlProject(opts, onProgress) {
|
|
|
1563
2036
|
const weWantedThisPage = BFS_ERROR_PATH.test(new URL(url).pathname) || BFS_LOGIN_PATH.test(new URL(url).pathname);
|
|
1564
2037
|
const redirectedToError = !weWantedThisPage && (BFS_ERROR_PATH.test(new URL(landedUrl).pathname) || BFS_LOGIN_PATH.test(new URL(landedUrl).pathname));
|
|
1565
2038
|
if (redirectedToError) {
|
|
2039
|
+
if (BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)) {
|
|
2040
|
+
loginWallRedirectCount++;
|
|
2041
|
+
if (!opts.credentials && loginWallRedirectCount >= LOGIN_WALL_ABORT_THRESHOLD) {
|
|
2042
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2043
|
+
jobId: opts.jobId,
|
|
2044
|
+
type: "AUTH_FAILURE",
|
|
2045
|
+
payload: {
|
|
2046
|
+
url,
|
|
2047
|
+
landed: landedUrl,
|
|
2048
|
+
reason: `Login wall detected: ${loginWallRedirectCount} consecutive URLs redirected to login. Add credentials in project settings to crawl authenticated pages.`
|
|
2049
|
+
}
|
|
2050
|
+
}).catch(() => {
|
|
2051
|
+
});
|
|
2052
|
+
throw new Error(`[AUTH_FAILURE] Login wall detected: ${loginWallRedirectCount} URLs redirected to the login page. Add credentials in project settings and retry the crawl.`);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
1566
2055
|
logger.warn({ requested: url, landed: landedUrl }, "[crawler] Mid-crawl redirect to auth/error \u2014 attempting re-auth");
|
|
1567
2056
|
let reauthed = false;
|
|
1568
2057
|
if (opts.credentials) {
|
|
@@ -1579,9 +2068,9 @@ async function crawlProject(opts, onProgress) {
|
|
|
1579
2068
|
logger.info({ url }, "[crawler] Re-auth succeeded \u2014 retrying URL");
|
|
1580
2069
|
await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {
|
|
1581
2070
|
});
|
|
1582
|
-
await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
2071
|
+
await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
1583
2072
|
});
|
|
1584
|
-
await waitForStableDOM(page);
|
|
2073
|
+
await waitForStableDOM(page, 8e3, 400, rawPage ?? void 0);
|
|
1585
2074
|
const afterReauthUrl = page.url();
|
|
1586
2075
|
if (BFS_ERROR_PATH.test(new URL(afterReauthUrl).pathname) || BFS_LOGIN_PATH.test(new URL(afterReauthUrl).pathname)) {
|
|
1587
2076
|
logger.warn({ url }, "[crawler] Still redirecting after re-auth \u2014 skipping this URL");
|
|
@@ -1598,6 +2087,10 @@ async function crawlProject(opts, onProgress) {
|
|
|
1598
2087
|
throw new Error("[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.");
|
|
1599
2088
|
}
|
|
1600
2089
|
} else {
|
|
2090
|
+
if (opts.discoveryMode) {
|
|
2091
|
+
logger.info({ url, landed: landedUrl }, "[discover] skipping auth-required URL \u2014 no credentials, continuing BFS");
|
|
2092
|
+
continue;
|
|
2093
|
+
}
|
|
1601
2094
|
logger.warn("[crawler] Session expired mid-crawl, no credentials to recover \u2014 skipping URL");
|
|
1602
2095
|
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
1603
2096
|
jobId: opts.jobId,
|
|
@@ -1611,6 +2104,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1611
2104
|
if (!redirectedToError && landedUrl !== url) {
|
|
1612
2105
|
logger.info({ requested: url, landed: landedUrl }, "[crawler] navigated with redirect");
|
|
1613
2106
|
}
|
|
2107
|
+
if (!redirectedToError) loginWallRedirectCount = 0;
|
|
1614
2108
|
if (opts.discoveryMode) {
|
|
1615
2109
|
visited.set(canonicalize(url), url);
|
|
1616
2110
|
await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
|
|
@@ -1749,7 +2243,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1749
2243
|
continue;
|
|
1750
2244
|
}
|
|
1751
2245
|
const preDomHash = await computeDomHash(page);
|
|
1752
|
-
if (seenDomHashes.has(preDomHash)) {
|
|
2246
|
+
if (preDomHash !== "" && seenDomHashes.has(preDomHash)) {
|
|
1753
2247
|
console.log(`[crawler] skip ${url} \u2014 duplicate DOM structure (same content as prior page)`);
|
|
1754
2248
|
continue;
|
|
1755
2249
|
}
|
|
@@ -1862,7 +2356,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1862
2356
|
} catch {
|
|
1863
2357
|
}
|
|
1864
2358
|
try {
|
|
1865
|
-
const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout:
|
|
2359
|
+
const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
|
|
1866
2360
|
if (activeNav) {
|
|
1867
2361
|
const t = activeNav.trim();
|
|
1868
2362
|
if (t.length > 1 && t.length < 60 && t.toLowerCase() !== brandName) return t;
|
|
@@ -1881,7 +2375,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1881
2375
|
".page-header h2"
|
|
1882
2376
|
];
|
|
1883
2377
|
for (const sel of selectors) {
|
|
1884
|
-
const h = await page.locator(sel).first().textContent({ timeout:
|
|
2378
|
+
const h = await page.locator(sel).first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
|
|
1885
2379
|
if (!h) continue;
|
|
1886
2380
|
const t = h.trim();
|
|
1887
2381
|
if (t.length < 2 || t.length > 80) continue;
|
|
@@ -1971,21 +2465,24 @@ async function crawlProject(opts, onProgress) {
|
|
|
1971
2465
|
if (cachedLinks) {
|
|
1972
2466
|
logger.info({ url, cachedCount: cachedLinks.length }, "[crawler] link-discovery: using cached recipe \u2014 AI will still run and merge");
|
|
1973
2467
|
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2468
|
+
{
|
|
2469
|
+
const effMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
|
|
2470
|
+
maxSeenTotal = Math.max(maxSeenTotal, effMax);
|
|
2471
|
+
const rawPrePct = 20 + Math.round(Math.min(57, visited.size / Math.max(1, maxSeenTotal) * 57));
|
|
2472
|
+
const preCapturePct = Math.max(lastEmittedPct, rawPrePct);
|
|
2473
|
+
lastEmittedPct = preCapturePct;
|
|
2474
|
+
await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} \xB7 ${(() => {
|
|
2475
|
+
try {
|
|
2476
|
+
return new URL(url).pathname || "/";
|
|
2477
|
+
} catch {
|
|
2478
|
+
return "/";
|
|
2479
|
+
}
|
|
2480
|
+
})()}`);
|
|
2481
|
+
}
|
|
2482
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
1985
2483
|
const preDomCount = await page.evaluate(
|
|
1986
2484
|
() => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
|
|
1987
2485
|
).catch(() => 0);
|
|
1988
|
-
await waitForScreenshotReady(page);
|
|
1989
2486
|
await onProgress?.(lastEmittedPct, `Capturing ${(() => {
|
|
1990
2487
|
try {
|
|
1991
2488
|
return new URL(url).pathname || "/";
|
|
@@ -1993,8 +2490,8 @@ async function crawlProject(opts, onProgress) {
|
|
|
1993
2490
|
return "/";
|
|
1994
2491
|
}
|
|
1995
2492
|
})()}`);
|
|
1996
|
-
const pageDpr = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
|
|
1997
|
-
const elements = await extractElements(stagehand, page, elementLLM ?? void 0).catch((err) => {
|
|
2493
|
+
const pageDpr = await (rawPage ?? page).evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
|
|
2494
|
+
const elements = await extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch((err) => {
|
|
1998
2495
|
console.error("[crawler] extractElements failed:", err);
|
|
1999
2496
|
return [];
|
|
2000
2497
|
});
|
|
@@ -2045,8 +2542,8 @@ async function crawlProject(opts, onProgress) {
|
|
|
2045
2542
|
}).catch((err) => console.error("[crawler] page.screenshot failed:", err)),
|
|
2046
2543
|
// Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
|
|
2047
2544
|
// Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
|
|
2048
|
-
//
|
|
2049
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0
|
|
2545
|
+
// Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
|
|
2546
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).then((freshLinks) => {
|
|
2050
2547
|
if (aiSkipCount > 0) aiSkipCount--;
|
|
2051
2548
|
aiErrorStreak = 0;
|
|
2052
2549
|
const merged = (cachedLinks ? Array.from(/* @__PURE__ */ new Set([...cachedLinks, ...freshLinks])) : freshLinks).filter((href) => isUsefulNavigationUrl(href, origin));
|
|
@@ -2079,7 +2576,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
2079
2576
|
}
|
|
2080
2577
|
return fallback;
|
|
2081
2578
|
}),
|
|
2082
|
-
readScreen(page).catch(() => [])
|
|
2579
|
+
readScreen(rawPage ?? page).catch(() => [])
|
|
2083
2580
|
]);
|
|
2084
2581
|
await page.evaluate(() => {
|
|
2085
2582
|
const container = document.getElementById("__zeta_element_highlights");
|
|
@@ -2100,6 +2597,104 @@ async function crawlProject(opts, onProgress) {
|
|
|
2100
2597
|
}
|
|
2101
2598
|
}).catch(() => {
|
|
2102
2599
|
});
|
|
2600
|
+
{
|
|
2601
|
+
const safeContext = typeof rawPage?.context === "function" ? rawPage.context() : typeof page?.context === "function" ? page.context() : playwrightBrowser?.contexts()[0] ?? null;
|
|
2602
|
+
const roTimeout = new Promise(
|
|
2603
|
+
(resolve) => setTimeout(() => resolve([
|
|
2604
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2605
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2606
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2607
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2608
|
+
{ status: "rejected", reason: "probe-timeout" }
|
|
2609
|
+
]), 25e3)
|
|
2610
|
+
);
|
|
2611
|
+
const [nestedCtxRes, shadowRes, a11yRes, perfRes, swRes] = await Promise.race([
|
|
2612
|
+
Promise.allSettled([
|
|
2613
|
+
crawlNestedContexts(rawPage ?? page, safeContext, url),
|
|
2614
|
+
crawlShadowDom(rawPage ?? page),
|
|
2615
|
+
crawlAccessibilityTree(rawPage ?? page),
|
|
2616
|
+
collectPerfMetrics(rawPage ?? page, url),
|
|
2617
|
+
inspectServiceWorker(rawPage ?? page)
|
|
2618
|
+
]),
|
|
2619
|
+
roTimeout
|
|
2620
|
+
]);
|
|
2621
|
+
const roPayload = { screenId: screen.id, url };
|
|
2622
|
+
if (nestedCtxRes.status === "fulfilled" && nestedCtxRes.value && nestedCtxRes.value.totalContextsFound > 0)
|
|
2623
|
+
roPayload.nestedContexts = nestedCtxRes.value;
|
|
2624
|
+
if (shadowRes.status === "fulfilled" && shadowRes.value && shadowRes.value.shadowHostCount > 0)
|
|
2625
|
+
roPayload.shadowDom = shadowRes.value;
|
|
2626
|
+
if (a11yRes.status === "fulfilled" && a11yRes.value)
|
|
2627
|
+
roPayload.accessibilityTree = a11yRes.value;
|
|
2628
|
+
if (perfRes.status === "fulfilled" && perfRes.value.requestCount > 0)
|
|
2629
|
+
roPayload.performance = perfRes.value;
|
|
2630
|
+
if (swRes.status === "fulfilled" && swRes.value.supported)
|
|
2631
|
+
roPayload.serviceWorker = swRes.value;
|
|
2632
|
+
if (Object.keys(roPayload).length > 2) {
|
|
2633
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2634
|
+
jobId: opts.jobId,
|
|
2635
|
+
type: "PER_SCREEN_PROBES",
|
|
2636
|
+
payload: roPayload
|
|
2637
|
+
}).catch(() => {
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
2640
|
+
if (screen?.id && opts.interactive !== false && safeContext) {
|
|
2641
|
+
void (async () => {
|
|
2642
|
+
let probePage = null;
|
|
2643
|
+
try {
|
|
2644
|
+
const openProbePages = safeContext.pages().length;
|
|
2645
|
+
if (openProbePages > 8) {
|
|
2646
|
+
logger.warn({ url, openProbePages }, "[crawler] probe page cap reached \u2014 skipping mutating probes for this screen");
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
probePage = await safeContext.newPage();
|
|
2650
|
+
await probePage.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
|
|
2651
|
+
});
|
|
2652
|
+
await probePage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
2653
|
+
});
|
|
2654
|
+
await waitForStableDOM(probePage, 4e3, 400).catch(() => {
|
|
2655
|
+
});
|
|
2656
|
+
const muTimeout = new Promise(
|
|
2657
|
+
(resolve) => setTimeout(() => resolve([
|
|
2658
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2659
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2660
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2661
|
+
{ status: "rejected", reason: "probe-timeout" }
|
|
2662
|
+
]), 6e4)
|
|
2663
|
+
);
|
|
2664
|
+
const [dropEnumRes, crudRes, filterRes, advancedRes] = await Promise.race([
|
|
2665
|
+
Promise.allSettled([
|
|
2666
|
+
enumerateDropdowns(probePage),
|
|
2667
|
+
probeCrudFlows(probePage, name),
|
|
2668
|
+
crawlFiltersAndPagination(probePage),
|
|
2669
|
+
probeAdvancedUI(probePage)
|
|
2670
|
+
]),
|
|
2671
|
+
muTimeout
|
|
2672
|
+
]);
|
|
2673
|
+
const muPayload = { screenId: screen.id, url };
|
|
2674
|
+
if (dropEnumRes.status === "fulfilled" && dropEnumRes.value.totalOptionsFound > 0)
|
|
2675
|
+
muPayload.dropdowns = dropEnumRes.value;
|
|
2676
|
+
if (crudRes.status === "fulfilled" && (crudRes.value.flows?.length > 0 || crudRes.value.toastsObserved?.length > 0))
|
|
2677
|
+
muPayload.crudFlows = crudRes.value;
|
|
2678
|
+
if (filterRes.status === "fulfilled" && filterRes.value.filters?.length > 0)
|
|
2679
|
+
muPayload.filtersAndPagination = filterRes.value;
|
|
2680
|
+
if (advancedRes.status === "fulfilled" && advancedRes.value && (advancedRes.value.datePickers?.length > 0 || advancedRes.value.sliders?.length > 0 || advancedRes.value.typeaheads?.length > 0 || advancedRes.value.dragDrop?.library !== "none"))
|
|
2681
|
+
muPayload.advancedUI = advancedRes.value;
|
|
2682
|
+
if (Object.keys(muPayload).length > 2) {
|
|
2683
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2684
|
+
jobId: opts.jobId,
|
|
2685
|
+
type: "PER_SCREEN_PROBES_MUTATING",
|
|
2686
|
+
payload: muPayload
|
|
2687
|
+
}).catch(() => {
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
} catch {
|
|
2691
|
+
} finally {
|
|
2692
|
+
await probePage?.close().catch(() => {
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
})();
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2103
2698
|
try {
|
|
2104
2699
|
const domContent = await capturePageHtml(page);
|
|
2105
2700
|
if (!domContent) {
|
|
@@ -2341,159 +2936,196 @@ ${snippet}`,
|
|
|
2341
2936
|
})();
|
|
2342
2937
|
}
|
|
2343
2938
|
const linkMappings = await extractLinkMappings(page, origin);
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark);
|
|
2350
|
-
const label = el.meaning;
|
|
2351
|
-
const role = el.role ?? "unknown";
|
|
2352
|
-
const selectorHint = atByRole.get(role) ?? role;
|
|
2353
|
-
const selectors = [
|
|
2354
|
-
{ type: "role-label", value: `${role}:${label}`, confidence: 0.9 },
|
|
2355
|
-
{ type: "aria-label", value: label, confidence: 0.85 },
|
|
2356
|
-
{ type: "tag-hint", value: selectorHint, confidence: 0.6 }
|
|
2357
|
-
];
|
|
2358
|
-
AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
|
|
2359
|
-
}));
|
|
2360
|
-
}
|
|
2361
|
-
for (const lm of linkMappings) {
|
|
2362
|
-
const safety = assessActionSafety(lm.label, domainProfile);
|
|
2363
|
-
if (!safety.allowed) {
|
|
2364
|
-
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2365
|
-
screenId: screen.id,
|
|
2366
|
-
jobId: opts.jobId,
|
|
2367
|
-
type: "ACTION_BLOCKED_BY_SAFETY_POLICY",
|
|
2368
|
-
payload: { label: lm.label, href: lm.href, decision: safety, profile: domainProfile.industry }
|
|
2369
|
-
}).catch(() => {
|
|
2370
|
-
});
|
|
2371
|
-
continue;
|
|
2939
|
+
const postExtractUrl = (() => {
|
|
2940
|
+
try {
|
|
2941
|
+
return page.url();
|
|
2942
|
+
} catch {
|
|
2943
|
+
return url;
|
|
2372
2944
|
}
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2945
|
+
})();
|
|
2946
|
+
const postExtractParsed = (() => {
|
|
2947
|
+
try {
|
|
2948
|
+
return new URL(postExtractUrl);
|
|
2949
|
+
} catch {
|
|
2950
|
+
return new URL(url);
|
|
2951
|
+
}
|
|
2952
|
+
})();
|
|
2953
|
+
const driftedToAuthWall = !weWantedThisPage && (BFS_LOGIN_PATH.test(postExtractParsed.pathname) || BFS_ERROR_PATH.test(postExtractParsed.pathname));
|
|
2954
|
+
if (driftedToAuthWall) {
|
|
2955
|
+
logger.warn({ url, postExtractUrl }, "[crawler] Page drifted to auth wall during element extraction \u2014 skipping element save to prevent login-page element contamination");
|
|
2956
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2957
|
+
screenId: screen.id,
|
|
2958
|
+
jobId: opts.jobId,
|
|
2959
|
+
type: "AUTH_FAILURE",
|
|
2960
|
+
payload: { url, landed: postExtractUrl, reason: "Session expired during element extraction. Re-capture session and re-crawl this page." }
|
|
2380
2961
|
}).catch(() => {
|
|
2381
2962
|
});
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2963
|
+
} else {
|
|
2964
|
+
if (elements.length === 0) {
|
|
2965
|
+
logger.warn({ url, screenId: screen.id }, "[crawler] extractElements returned 0 \u2014 preserving existing elements to avoid data loss");
|
|
2966
|
+
if (!revisitQueue.includes(url)) {
|
|
2967
|
+
revisitQueue.push(url);
|
|
2968
|
+
logger.info({ url }, "[crawler] 0-element page queued for re-visit with extended hydration wait");
|
|
2969
|
+
}
|
|
2970
|
+
} else {
|
|
2971
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
|
|
2972
|
+
});
|
|
2973
|
+
for (let _ei = 0; _ei < elements.length; _ei += 4) {
|
|
2974
|
+
const _batch = elements.slice(_ei, _ei + 4);
|
|
2975
|
+
await Promise.all(_batch.map(async (el) => {
|
|
2976
|
+
const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark);
|
|
2977
|
+
const label = el.meaning;
|
|
2978
|
+
const role = el.role ?? "unknown";
|
|
2979
|
+
const selectorHint = atByRole.get(role) ?? role;
|
|
2980
|
+
const selectors = [
|
|
2981
|
+
{ type: "role-label", value: `${role}:${label}`, confidence: 0.9 },
|
|
2982
|
+
{ type: "aria-label", value: label, confidence: 0.85 },
|
|
2983
|
+
{ type: "tag-hint", value: selectorHint, confidence: 0.6 }
|
|
2984
|
+
];
|
|
2985
|
+
AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
|
|
2986
|
+
}));
|
|
2394
2987
|
}
|
|
2395
2988
|
}
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2989
|
+
for (const lm of linkMappings) {
|
|
2990
|
+
const safety = assessActionSafety(lm.label, domainProfile);
|
|
2991
|
+
if (!safety.allowed) {
|
|
2992
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2993
|
+
screenId: screen.id,
|
|
2994
|
+
jobId: opts.jobId,
|
|
2995
|
+
type: "ACTION_BLOCKED_BY_SAFETY_POLICY",
|
|
2996
|
+
payload: { label: lm.label, href: lm.href, decision: safety, profile: domainProfile.industry }
|
|
2997
|
+
}).catch(() => {
|
|
2998
|
+
});
|
|
2999
|
+
continue;
|
|
2405
3000
|
}
|
|
2406
|
-
|
|
2407
|
-
|
|
3001
|
+
pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
|
|
3002
|
+
AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
|
|
3003
|
+
domain: new URL(url).hostname,
|
|
3004
|
+
purpose: `navigate:${lm.label}`,
|
|
3005
|
+
fingerprint: sha256(`${canonicalize(url)}:${lm.label}:${canonicalize(lm.href)}`),
|
|
3006
|
+
steps: [{ action: "click", label: lm.label, targetUrl: lm.href, fromUrl: url }],
|
|
3007
|
+
successRate: 0.5
|
|
3008
|
+
}).catch(() => {
|
|
3009
|
+
});
|
|
3010
|
+
if (!selective) {
|
|
2408
3011
|
try {
|
|
2409
|
-
const
|
|
2410
|
-
|
|
3012
|
+
const targetPath = new URL(lm.href).pathname;
|
|
3013
|
+
const canonLmHref = canonicalize(lm.href);
|
|
3014
|
+
if (!SKIP_PATH.test(targetPath) && isUsefulNavigationUrl(lm.href, origin) && !queued.has(canonLmHref) && !visited.has(canonLmHref) && queued.size < maxScreens * 2) {
|
|
3015
|
+
if (robotsCache && !await robotsCache.isAllowed(lm.href)) continue;
|
|
3016
|
+
queued.add(canonLmHref);
|
|
3017
|
+
queue.push(lm.href, { parentElements: elements.length });
|
|
3018
|
+
edges.push({ fromUrl: url, toUrl: lm.href });
|
|
3019
|
+
logger.info({ fromUrl: url, newUrl: lm.href, label: lm.label, queueSize: queue.length }, "[crawler] anchor mapping added to queue");
|
|
3020
|
+
}
|
|
2411
3021
|
} catch {
|
|
2412
3022
|
}
|
|
2413
|
-
if (robotsCache && !await robotsCache.isAllowed(href)) continue;
|
|
2414
|
-
queued.add(canonHref);
|
|
2415
|
-
queue.push(href, { parentElements: elements.length });
|
|
2416
|
-
logger.info({ newUrl: href, queueSize: queue.length }, "[crawler] new URL added to queue");
|
|
2417
3023
|
}
|
|
2418
3024
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
const routes = window.__zetaDiscoveredRoutes ?? [];
|
|
2423
|
-
window.__zetaDiscoveredRoutes = [];
|
|
2424
|
-
return routes;
|
|
2425
|
-
}).catch(() => []);
|
|
2426
|
-
for (const route of jsRoutes) {
|
|
3025
|
+
if (!selective) {
|
|
3026
|
+
for (const href of nextLinks) {
|
|
3027
|
+
edges.push({ fromUrl: url, toUrl: href });
|
|
2427
3028
|
try {
|
|
2428
|
-
const
|
|
2429
|
-
const
|
|
2430
|
-
if (
|
|
2431
|
-
queued.add(canonRoute);
|
|
2432
|
-
queue.push(abs);
|
|
2433
|
-
logger.info({ route: abs }, "[crawler] history.pushState: discovered SPA route");
|
|
2434
|
-
}
|
|
3029
|
+
const hrefParams = new URL(href).searchParams;
|
|
3030
|
+
const actionVal = hrefParams.get("action") ?? "";
|
|
3031
|
+
if (/^yith-|^wc-|^vc_|^elementor/.test(actionVal)) continue;
|
|
2435
3032
|
} catch {
|
|
2436
3033
|
}
|
|
3034
|
+
const canonHref = canonicalize(href);
|
|
3035
|
+
if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 2) {
|
|
3036
|
+
try {
|
|
3037
|
+
const hrefPath = new URL(href).pathname;
|
|
3038
|
+
if (isGeneratedDynamicPath(hrefPath)) continue;
|
|
3039
|
+
} catch {
|
|
3040
|
+
}
|
|
3041
|
+
if (robotsCache && !await robotsCache.isAllowed(href)) continue;
|
|
3042
|
+
queued.add(canonHref);
|
|
3043
|
+
queue.push(href, { parentElements: elements.length });
|
|
3044
|
+
logger.info({ newUrl: href, queueSize: queue.length }, "[crawler] new URL added to queue");
|
|
3045
|
+
}
|
|
2437
3046
|
}
|
|
2438
|
-
|
|
2439
|
-
}
|
|
2440
|
-
if (preDomCount < 25 && !revisitQueue.includes(url)) {
|
|
3047
|
+
logger.info({ fromUrl: url, discovered: nextLinks.length, queueAfter: queue.length, visitedSoFar: visited.size }, "[crawler] link processing done");
|
|
2441
3048
|
try {
|
|
2442
|
-
const
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
3049
|
+
const jsRoutes = await page.evaluate(() => {
|
|
3050
|
+
const routes = window.__zetaDiscoveredRoutes ?? [];
|
|
3051
|
+
window.__zetaDiscoveredRoutes = [];
|
|
3052
|
+
return routes;
|
|
3053
|
+
}).catch(() => []);
|
|
3054
|
+
for (const route of jsRoutes) {
|
|
3055
|
+
try {
|
|
3056
|
+
const abs = route.startsWith("http") ? route : `${origin}${route.startsWith("/") ? "" : "/"}${route}`;
|
|
3057
|
+
const canonRoute = canonicalize(abs);
|
|
3058
|
+
if (!queued.has(canonRoute) && !visited.has(canonRoute)) {
|
|
3059
|
+
queued.add(canonRoute);
|
|
3060
|
+
queue.push(abs);
|
|
3061
|
+
logger.info({ route: abs }, "[crawler] history.pushState: discovered SPA route");
|
|
3062
|
+
}
|
|
3063
|
+
} catch {
|
|
3064
|
+
}
|
|
2448
3065
|
}
|
|
2449
3066
|
} catch {
|
|
2450
3067
|
}
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
3068
|
+
const asyncContentCheckMinDom = parseInt(process.env.CRAWLER_ASYNC_CONTENT_MIN_DOM ?? "") || 25;
|
|
3069
|
+
if (preDomCount < asyncContentCheckMinDom && !revisitQueue.includes(url)) {
|
|
3070
|
+
try {
|
|
3071
|
+
const postDomCount = await page.evaluate(
|
|
3072
|
+
() => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
|
|
3073
|
+
).catch(() => 0);
|
|
3074
|
+
const asyncGrowthRatio = parseFloat(process.env.CRAWLER_ASYNC_GROWTH_RATIO ?? "") || 1.3;
|
|
3075
|
+
const asyncGrowthFloor = parseInt(process.env.CRAWLER_ASYNC_GROWTH_FLOOR ?? "") || 4;
|
|
3076
|
+
if (postDomCount > preDomCount * asyncGrowthRatio + asyncGrowthFloor) {
|
|
3077
|
+
revisitQueue.push(url);
|
|
3078
|
+
logger.info({ url, preDomCount, postDomCount }, "[P3a] Async content detected \u2014 scheduled for re-visit");
|
|
2461
3079
|
}
|
|
3080
|
+
} catch {
|
|
2462
3081
|
}
|
|
2463
|
-
} catch {
|
|
2464
3082
|
}
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
3083
|
+
if (visited.size <= (parseInt(process.env.CRAWLER_SPA_ROUTER_DETECT_LIMIT ?? "") || 5)) {
|
|
3084
|
+
try {
|
|
3085
|
+
const jsDetectedRoutes = await detectJsRoutes(page, origin);
|
|
3086
|
+
for (const route of jsDetectedRoutes) {
|
|
3087
|
+
const canonRoute = canonicalize(route);
|
|
3088
|
+
if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 2) {
|
|
3089
|
+
queued.add(canonRoute);
|
|
3090
|
+
queue.push(route, { parentElements: elements.length });
|
|
3091
|
+
logger.info({ route }, "[P2] JS router: discovered route added to queue");
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
} catch {
|
|
2475
3095
|
}
|
|
2476
|
-
} catch {
|
|
2477
3096
|
}
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
for (const skyvernUrl of skyvernNav.visitedUrls) {
|
|
2488
|
-
const canonSkyvernUrl = canonicalize(skyvernUrl);
|
|
2489
|
-
if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 2) {
|
|
2490
|
-
logger.info({ url: skyvernUrl }, "Gap 5: Skyvern-discovered URL added to ZeTa crawl queue");
|
|
2491
|
-
queued.add(canonSkyvernUrl);
|
|
2492
|
-
queue.push(skyvernUrl);
|
|
2493
|
-
pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: "Skyvern-discovered link", targetUrl: skyvernUrl });
|
|
3097
|
+
for (const relPath of [...xhrDiscoveredPaths]) {
|
|
3098
|
+
xhrDiscoveredPaths.delete(relPath);
|
|
3099
|
+
try {
|
|
3100
|
+
const abs = `${origin}${relPath}`;
|
|
3101
|
+
const canon = canonicalize(abs);
|
|
3102
|
+
if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
|
|
3103
|
+
queued.add(canon);
|
|
3104
|
+
queue.push(abs);
|
|
3105
|
+
logger.info({ url: abs }, "[P4] XHR: page URL from JSON response added to queue");
|
|
2494
3106
|
}
|
|
3107
|
+
} catch {
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
if (nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? "") || 5) && opts.skyvernConfig) {
|
|
3111
|
+
try {
|
|
3112
|
+
logger.info({ url }, "Gap 5: thin page detected \u2014 asking Skyvern to discover navigation URLs");
|
|
3113
|
+
const skyvernNav = await skyvernNavigate(
|
|
3114
|
+
url,
|
|
3115
|
+
["Find and return all navigation links, menu items, and page URLs accessible from this screen"],
|
|
3116
|
+
opts.skyvernConfig
|
|
3117
|
+
);
|
|
3118
|
+
for (const skyvernUrl of skyvernNav.visitedUrls) {
|
|
3119
|
+
const canonSkyvernUrl = canonicalize(skyvernUrl);
|
|
3120
|
+
if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 2) {
|
|
3121
|
+
logger.info({ url: skyvernUrl }, "Gap 5: Skyvern-discovered URL added to ZeTa crawl queue");
|
|
3122
|
+
queued.add(canonSkyvernUrl);
|
|
3123
|
+
queue.push(skyvernUrl);
|
|
3124
|
+
pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: "Skyvern-discovered link", targetUrl: skyvernUrl });
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
} catch {
|
|
2495
3128
|
}
|
|
2496
|
-
} catch {
|
|
2497
3129
|
}
|
|
2498
3130
|
}
|
|
2499
3131
|
}
|
|
@@ -2569,7 +3201,8 @@ ${snippet}`,
|
|
|
2569
3201
|
};
|
|
2570
3202
|
}
|
|
2571
3203
|
const successfulScreenCount = discovered.filter((d) => d.screenId).length;
|
|
2572
|
-
|
|
3204
|
+
const skyvernMinScreens = parseInt(process.env.CRAWL_SKYVERN_MIN_SCREENS ?? "") || 10;
|
|
3205
|
+
if (successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
|
|
2573
3206
|
await onProgress?.(78, "Few screens found \u2014 using Skyvern to discover additional navigation URLs");
|
|
2574
3207
|
const currentUrl = page.url();
|
|
2575
3208
|
const skyvernNav = await skyvernNavigate(
|
|
@@ -2587,16 +3220,16 @@ ${snippet}`,
|
|
|
2587
3220
|
if (visited.size >= maxScreens) break;
|
|
2588
3221
|
try {
|
|
2589
3222
|
await page.goto(skyvernUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
|
|
2590
|
-
await
|
|
3223
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2591
3224
|
const skyvernName = new URL(skyvernUrl).pathname.split("/").filter(Boolean).pop() ?? "Screen";
|
|
2592
3225
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, skyvernName, skyvernUrl, opts.jobId, "SKYVERN_AI");
|
|
2593
3226
|
const shotPath = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
|
|
2594
|
-
await waitForScreenshotReady(page);
|
|
3227
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
2595
3228
|
await page.screenshot({ path: shotPath, fullPage: true });
|
|
2596
3229
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP");
|
|
2597
3230
|
const [elems, links, linkMappings] = await Promise.all([
|
|
2598
|
-
extractElements(stagehand, page, elementLLM ?? void 0),
|
|
2599
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0),
|
|
3231
|
+
extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
|
|
3232
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0),
|
|
2600
3233
|
extractLinkMappings(page, origin)
|
|
2601
3234
|
]);
|
|
2602
3235
|
for (const el of elems) {
|
|
@@ -2626,15 +3259,15 @@ ${snippet}`,
|
|
|
2626
3259
|
if (!isUsefulNavigationUrl(href, origin)) continue;
|
|
2627
3260
|
try {
|
|
2628
3261
|
await page.goto(href, { waitUntil: "domcontentloaded", timeout: 2e4 });
|
|
2629
|
-
await
|
|
3262
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
2630
3263
|
const nm = new URL(href).pathname.split("/").filter(Boolean).pop() ?? "Screen";
|
|
2631
3264
|
const sc = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, "SKYVERN_AI");
|
|
2632
3265
|
const sp = `${opts.screenshotDir ?? "/tmp"}/${sc.id}-v${sc.version}.png`;
|
|
2633
|
-
await waitForScreenshotReady(page);
|
|
3266
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
2634
3267
|
await page.screenshot({ path: sp, fullPage: true });
|
|
2635
3268
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, "DESKTOP");
|
|
2636
3269
|
const [elems2, linkMappings2] = await Promise.all([
|
|
2637
|
-
extractElements(stagehand, page, elementLLM ?? void 0),
|
|
3270
|
+
extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
|
|
2638
3271
|
extractLinkMappings(page, origin)
|
|
2639
3272
|
]);
|
|
2640
3273
|
for (const el of elems2) {
|
|
@@ -2680,28 +3313,51 @@ ${snippet}`,
|
|
|
2680
3313
|
);
|
|
2681
3314
|
}
|
|
2682
3315
|
} catch (err) {
|
|
2683
|
-
|
|
3316
|
+
const msg = String(err);
|
|
3317
|
+
logger.warn({ err: msg }, "[P1] URL pattern completion failed \u2014 non-fatal, continuing");
|
|
3318
|
+
crawlWarnings.push(`url-completer skipped: ${msg.includes("401") || msg.toLowerCase().includes("api key") || msg.toLowerCase().includes("authentication") ? "LLM API key invalid or missing" : msg}`);
|
|
2684
3319
|
}
|
|
2685
3320
|
if (revisitQueue.length > 0) {
|
|
2686
3321
|
await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content\u2026`);
|
|
2687
3322
|
logger.info({ count: revisitQueue.length }, "[P3a] Starting async content re-visit pass");
|
|
2688
3323
|
for (const revisitUrl of revisitQueue.slice(0, 10)) {
|
|
2689
|
-
const
|
|
2690
|
-
|
|
3324
|
+
const rawScreenId = visited.get(canonicalize(revisitUrl));
|
|
3325
|
+
const isAuthWallRevisit = rawScreenId === "_auth_wall_";
|
|
3326
|
+
if (!rawScreenId) continue;
|
|
2691
3327
|
try {
|
|
2692
3328
|
await page.goto(revisitUrl, { waitUntil: "networkidle", timeout: 25e3 });
|
|
2693
|
-
await
|
|
3329
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
3330
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
3331
|
+
const landedUrl = (rawPage ?? page).url?.() ?? revisitUrl;
|
|
3332
|
+
const landedCanon = canonicalize(landedUrl);
|
|
3333
|
+
let existingScreenId = isAuthWallRevisit ? visited.get(landedCanon) ?? null : rawScreenId;
|
|
3334
|
+
if (isAuthWallRevisit && (!existingScreenId || existingScreenId.startsWith("_")) && !visited.has(landedCanon)) {
|
|
3335
|
+
const pathParts = new URL(landedUrl).pathname.split("/").filter(Boolean);
|
|
3336
|
+
const screenName = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
|
|
3337
|
+
const newScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, landedUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
|
|
3338
|
+
if (newScreen) {
|
|
3339
|
+
visited.set(landedCanon, newScreen.id);
|
|
3340
|
+
existingScreenId = newScreen.id;
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
if (!existingScreenId || existingScreenId.startsWith("_")) {
|
|
3344
|
+
logger.warn({ url: revisitUrl, landed: landedUrl }, "[P3a] Re-visit skipped \u2014 could not resolve screen ID");
|
|
3345
|
+
continue;
|
|
3346
|
+
}
|
|
2694
3347
|
const shotPath2 = `${opts.screenshotDir ?? "/tmp"}/${existingScreenId}-revisit.png`;
|
|
2695
|
-
await waitForScreenshotReady(page);
|
|
2696
3348
|
await page.screenshot({ path: shotPath2, fullPage: true });
|
|
2697
|
-
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP").catch(() => {
|
|
3349
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP", void 0).catch(() => {
|
|
2698
3350
|
});
|
|
2699
|
-
const revisitElements = await extractElements(stagehand, page, elementLLM ?? void 0).catch(() => []);
|
|
2700
|
-
|
|
2701
|
-
await AppBrain.
|
|
3351
|
+
const revisitElements = await extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []);
|
|
3352
|
+
if (revisitElements.length > 0) {
|
|
3353
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, existingScreenId).catch(() => {
|
|
2702
3354
|
});
|
|
3355
|
+
for (const el of revisitElements) {
|
|
3356
|
+
await AppBrain.saveElement(opts.tenantId, opts.projectId, existingScreenId, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
|
|
3357
|
+
});
|
|
3358
|
+
}
|
|
2703
3359
|
}
|
|
2704
|
-
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false).catch(() => []);
|
|
3360
|
+
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []);
|
|
2705
3361
|
for (const href of revisitLinks) {
|
|
2706
3362
|
const canon = canonicalize(href);
|
|
2707
3363
|
if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
|
|
@@ -2709,13 +3365,68 @@ ${snippet}`,
|
|
|
2709
3365
|
queue.push(href);
|
|
2710
3366
|
}
|
|
2711
3367
|
}
|
|
2712
|
-
logger.info({ url: revisitUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, "[P3a] Re-visit complete");
|
|
3368
|
+
logger.info({ url: revisitUrl, landed: landedUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, "[P3a] Re-visit complete");
|
|
2713
3369
|
} catch (err) {
|
|
2714
3370
|
logger.warn({ url: revisitUrl, err: String(err) }, "[P3a] Re-visit failed \u2014 non-fatal");
|
|
2715
3371
|
}
|
|
2716
3372
|
}
|
|
2717
3373
|
}
|
|
2718
|
-
if (visited.size
|
|
3374
|
+
if (queue.length > 0 && visited.size < maxScreens) {
|
|
3375
|
+
const p3bCount = Math.min(queue.length, maxScreens - visited.size);
|
|
3376
|
+
logger.info({ newLinks: p3bCount }, "[P3b] Extended BFS \u2014 processing links discovered during revisit pass");
|
|
3377
|
+
await onProgress?.(82, `Extended crawl: ${p3bCount} newly discovered page(s)\u2026`);
|
|
3378
|
+
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
3379
|
+
const href = queue.shift();
|
|
3380
|
+
if (!href) break;
|
|
3381
|
+
const canonHref = canonicalize(href);
|
|
3382
|
+
if (visited.has(canonHref)) continue;
|
|
3383
|
+
if (!isUsefulNavigationUrl(href, origin)) continue;
|
|
3384
|
+
visited.set(canonHref, "_pending_");
|
|
3385
|
+
try {
|
|
3386
|
+
await page.goto(href, { waitUntil: "networkidle", timeout: 25e3 }).catch(() => {
|
|
3387
|
+
});
|
|
3388
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
3389
|
+
const pathParts = new URL(href).pathname.split("/").filter(Boolean);
|
|
3390
|
+
const nm = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
|
|
3391
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, "LINK_FOLLOW");
|
|
3392
|
+
visited.set(canonHref, screen.id);
|
|
3393
|
+
const sp = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
|
|
3394
|
+
await page.screenshot({ path: sp, fullPage: true }).catch(() => {
|
|
3395
|
+
});
|
|
3396
|
+
const cloudUrl3 = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => "");
|
|
3397
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, sp, screen.version, "DESKTOP", cloudUrl3).catch(() => {
|
|
3398
|
+
});
|
|
3399
|
+
const [p3bElems, p3bLinks] = await Promise.all([
|
|
3400
|
+
extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
|
|
3401
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).catch(() => [])
|
|
3402
|
+
]);
|
|
3403
|
+
if (p3bElems.length > 0) {
|
|
3404
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
|
|
3405
|
+
});
|
|
3406
|
+
for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 4) {
|
|
3407
|
+
const _b3 = p3bElems.slice(_ei3, _ei3 + 4);
|
|
3408
|
+
await Promise.all(_b3.map(
|
|
3409
|
+
(el) => AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
|
|
3410
|
+
})
|
|
3411
|
+
));
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
for (const link of p3bLinks) {
|
|
3415
|
+
const c = canonicalize(link);
|
|
3416
|
+
if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 2) {
|
|
3417
|
+
queued.add(c);
|
|
3418
|
+
queue.push(link);
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
discovered.push({ url: href, screenId: screen.id, elements: p3bElems.length, version: screen.version });
|
|
3422
|
+
logger.info({ url: href, elements: p3bElems.length, newLinks: p3bLinks.length }, "[P3b] Extended BFS screen captured");
|
|
3423
|
+
} catch (err) {
|
|
3424
|
+
logger.warn({ url: href, err: String(err) }, "[P3b] Extended BFS URL failed");
|
|
3425
|
+
visited.set(canonHref, "_error_");
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
if (visited.size >= (parseInt(process.env.CRAWLER_GAP_PASS_MIN_SCREENS ?? "") || 3)) {
|
|
2719
3430
|
try {
|
|
2720
3431
|
await onProgress?.(80, "Running gap analysis\u2026");
|
|
2721
3432
|
const llmCfgForGap = await getCachedCrawlLLM(opts.tenantId);
|
|
@@ -2739,7 +3450,7 @@ ${snippet}`,
|
|
|
2739
3450
|
});
|
|
2740
3451
|
logger.info({ confirmed: confirmedGaps.length, total: gapCandidates.length }, "[P6] Gap pass: visiting confirmed gap URLs");
|
|
2741
3452
|
const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
|
|
2742
|
-
for (const gap of confirmedGaps.slice(0, 8)) {
|
|
3453
|
+
for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? "") || 8)) {
|
|
2743
3454
|
const gapUrl = gap.confirmedUrl;
|
|
2744
3455
|
if (visited.size >= maxScreens) break;
|
|
2745
3456
|
try {
|
|
@@ -2748,16 +3459,16 @@ ${snippet}`,
|
|
|
2748
3459
|
logger.warn({ gapUrl, landed: page.url() }, "[P6] Gap URL redirected to login \u2014 skipping");
|
|
2749
3460
|
continue;
|
|
2750
3461
|
}
|
|
2751
|
-
await
|
|
3462
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2752
3463
|
const gapName = gap.category;
|
|
2753
3464
|
const gapScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, gapName, gapUrl, opts.jobId, "ENTRY_POINT");
|
|
2754
3465
|
const gapShot = `${opts.screenshotDir ?? "/tmp"}/${gapScreen.id}-v${gapScreen.version}.png`;
|
|
2755
|
-
await waitForScreenshotReady(page);
|
|
3466
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
2756
3467
|
await page.screenshot({ path: gapShot, fullPage: true });
|
|
2757
3468
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, "DESKTOP");
|
|
2758
3469
|
const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
|
|
2759
|
-
extractElements(stagehand, page, elementLLM ?? void 0).catch(() => []),
|
|
2760
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false).catch(() => []),
|
|
3470
|
+
extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
|
|
3471
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []),
|
|
2761
3472
|
extractLinkMappings(page, origin).catch(() => [])
|
|
2762
3473
|
]);
|
|
2763
3474
|
for (const el of gapElems) {
|
|
@@ -2785,11 +3496,14 @@ ${snippet}`,
|
|
|
2785
3496
|
logger.info({ total: gapCandidates.length }, "[P6] Gap pass: no confirmed gaps found");
|
|
2786
3497
|
}
|
|
2787
3498
|
} catch (err) {
|
|
2788
|
-
|
|
3499
|
+
const msg = String(err);
|
|
3500
|
+
logger.warn({ err: msg }, "[P6] Gap pass failed \u2014 non-fatal, continuing");
|
|
3501
|
+
crawlWarnings.push(`gap-analysis skipped: ${msg.includes("401") || msg.toLowerCase().includes("api key") || msg.toLowerCase().includes("authentication") ? "LLM API key invalid or missing" : msg}`);
|
|
2789
3502
|
}
|
|
2790
3503
|
}
|
|
2791
3504
|
if (!selective && visited.size > 0) {
|
|
2792
|
-
const
|
|
3505
|
+
const VISIT_MARKER_RE = /^_|^pre-auth$|^skipped$/;
|
|
3506
|
+
const discoveredUrls = Array.from(visited.entries()).filter(([, v]) => typeof v === "string" && !VISIT_MARKER_RE.test(v)).map(([k]) => k);
|
|
2793
3507
|
await AppBrain.markRemovedScreens(opts.tenantId, opts.projectId, discoveredUrls, opts.jobId);
|
|
2794
3508
|
}
|
|
2795
3509
|
await onProgress?.(82, "Saving app map");
|
|
@@ -2964,6 +3678,10 @@ ${snippet}`,
|
|
|
2964
3678
|
ignoreHTTPSErrors: true,
|
|
2965
3679
|
...mainStorageState ? { storageState: mainStorageState } : {}
|
|
2966
3680
|
});
|
|
3681
|
+
mobileCtx.on("download", (dl) => {
|
|
3682
|
+
dl.cancel().catch(() => {
|
|
3683
|
+
});
|
|
3684
|
+
});
|
|
2967
3685
|
const mobilePage = await mobileCtx.newPage();
|
|
2968
3686
|
for (const disc of discovered) {
|
|
2969
3687
|
if (!disc.screenId) continue;
|
|
@@ -2976,11 +3694,13 @@ ${snippet}`,
|
|
|
2976
3694
|
await mobilePage.addStyleTag({ content: "*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }" });
|
|
2977
3695
|
} catch {
|
|
2978
3696
|
}
|
|
3697
|
+
await infiniteScrollToBottom(mobilePage, { maxScrolls: 20, scrollDelay: 300, stabilizeMs: 400 }, mobilePage).catch(() => {
|
|
3698
|
+
});
|
|
2979
3699
|
const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${disc.screenId}-${profile.toLowerCase()}-v${disc.version}.png`);
|
|
2980
|
-
await waitForScreenshotReady(mobilePage);
|
|
3700
|
+
await waitForScreenshotReady(mobilePage, mobilePage);
|
|
2981
3701
|
const [, vpElements] = await Promise.all([
|
|
2982
3702
|
mobilePage.screenshot({ path: shotPath, fullPage: true }),
|
|
2983
|
-
domFallbackElements(mobilePage)
|
|
3703
|
+
domFallbackElements(mobilePage, mobilePage)
|
|
2984
3704
|
]);
|
|
2985
3705
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
2986
3706
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version, profile, cloudUrl);
|
|
@@ -3033,6 +3753,22 @@ ${snippet}`,
|
|
|
3033
3753
|
const probeInputs = discovered.filter((d) => d.screenId).map((d) => ({ url: d.url, screenId: d.screenId, elements: d.elementFacts ?? [] }));
|
|
3034
3754
|
await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
|
|
3035
3755
|
}
|
|
3756
|
+
try {
|
|
3757
|
+
await onProgress?.(90, "Generating state graph coverage report");
|
|
3758
|
+
const stateGraph = new ScreenTransitionGraph();
|
|
3759
|
+
const coveragePayload = {
|
|
3760
|
+
stateGraph: stateGraph.getCoverageReport(),
|
|
3761
|
+
totalScreensCrawled: discovered.filter((d) => !!d.screenId).length
|
|
3762
|
+
};
|
|
3763
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3764
|
+
jobId: opts.jobId,
|
|
3765
|
+
type: "CRAWL_COVERAGE_REPORT",
|
|
3766
|
+
payload: coveragePayload
|
|
3767
|
+
}).catch(() => {
|
|
3768
|
+
});
|
|
3769
|
+
} catch (extErr) {
|
|
3770
|
+
logger.warn({ err: String(extErr) }, "[coverage-report] non-fatal error");
|
|
3771
|
+
}
|
|
3036
3772
|
await onProgress?.(97, "Running post-crawl analysis");
|
|
3037
3773
|
const successScreens = discovered.filter((d) => !!d.screenId);
|
|
3038
3774
|
;
|
|
@@ -3113,6 +3849,14 @@ ${snippet}`,
|
|
|
3113
3849
|
const hasMoreSegments = pendingUrls.length > 0;
|
|
3114
3850
|
const screensSucceeded = discovered.filter((d) => !!d.screenId).length;
|
|
3115
3851
|
const screensErrored = discovered.filter((d) => !!d.error && !d.screenId).length;
|
|
3852
|
+
if (screensSucceeded >= 3) {
|
|
3853
|
+
const screensWithElements = discovered.filter((d) => (d.elements ?? 0) > 0 && d.screenId).length;
|
|
3854
|
+
if (screensWithElements === 0) {
|
|
3855
|
+
crawlWarnings.push(
|
|
3856
|
+
`element-extraction: 0 elements found across all ${screensSucceeded} screens \u2014 DOM evaluate may be failing (check server logs for "page.evaluate threw" warnings)`
|
|
3857
|
+
);
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3116
3860
|
return {
|
|
3117
3861
|
screensFound: screensSucceeded,
|
|
3118
3862
|
// successful captures only (not errors)
|
|
@@ -3129,8 +3873,9 @@ ${snippet}`,
|
|
|
3129
3873
|
segmentIndex: segIdx,
|
|
3130
3874
|
// which segment this was
|
|
3131
3875
|
hasMoreSegments,
|
|
3132
|
-
domHashes: Array.from(seenDomHashes)
|
|
3876
|
+
domHashes: Array.from(seenDomHashes),
|
|
3133
3877
|
// carry forward to next segment to avoid re-screenshotting
|
|
3878
|
+
crawlWarnings: crawlWarnings.length > 0 ? crawlWarnings : void 0
|
|
3134
3879
|
};
|
|
3135
3880
|
} finally {
|
|
3136
3881
|
if (stagehandPromptTokens > 0 || stagehandCompletionTokens > 0) {
|
|
@@ -3165,8 +3910,8 @@ ${snippet}`,
|
|
|
3165
3910
|
}
|
|
3166
3911
|
}
|
|
3167
3912
|
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;
|
|
3168
|
-
async function extractElements(stagehand, page, llmCfg) {
|
|
3169
|
-
const domElements = await domFallbackElements(page).catch((err) => {
|
|
3913
|
+
async function extractElements(stagehand, page, llmCfg, rawPage) {
|
|
3914
|
+
const domElements = await domFallbackElements(page, rawPage).catch((err) => {
|
|
3170
3915
|
console.warn("[crawler] domFallbackElements failed (page context issue or DOM error):", err?.message ?? err);
|
|
3171
3916
|
return [];
|
|
3172
3917
|
});
|
|
@@ -3204,7 +3949,10 @@ async function extractElements(stagehand, page, llmCfg) {
|
|
|
3204
3949
|
dynamicElements = [];
|
|
3205
3950
|
}
|
|
3206
3951
|
}
|
|
3207
|
-
const contentElements = await contentFallbackElements(page)
|
|
3952
|
+
const contentElements = await contentFallbackElements(page).catch((err) => {
|
|
3953
|
+
console.warn("[crawler] contentFallbackElements failed (page context issue):", err?.message ?? err);
|
|
3954
|
+
return [];
|
|
3955
|
+
});
|
|
3208
3956
|
const crossSourceDedup = (arr) => {
|
|
3209
3957
|
const stripPhrases = (s) => s.replace(/^button to expand ['"]?|['"]?\s*details$/gi, "").replace(/\s+(faq\s*)?button$/i, "").replace(/^button:\s*/i, "").toLowerCase().trim().slice(0, 80);
|
|
3210
3958
|
const ROLE_PRIORITY = { "faq-question": 10, "faq-answer": 9, "h1": 8, "h2": 7, "h3": 6, "h4": 5, "heading": 8, button: 3, a: 2 };
|
|
@@ -3245,9 +3993,9 @@ async function extractElements(stagehand, page, llmCfg) {
|
|
|
3245
3993
|
return domElements;
|
|
3246
3994
|
}
|
|
3247
3995
|
}
|
|
3248
|
-
async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false) {
|
|
3996
|
+
async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false, rawPage) {
|
|
3249
3997
|
const navExpanded = await expandNavMenus(page);
|
|
3250
|
-
const domLinks = await domFallbackLinks(page, origin);
|
|
3998
|
+
const domLinks = await domFallbackLinks(page, origin, rawPage);
|
|
3251
3999
|
const interactiveLinks = await interactAndCollectLinks(page, origin);
|
|
3252
4000
|
if (navExpanded) {
|
|
3253
4001
|
page.keyboard?.press("Escape").catch(() => {
|
|
@@ -3294,8 +4042,8 @@ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false) {
|
|
|
3294
4042
|
return allDomLinks;
|
|
3295
4043
|
}
|
|
3296
4044
|
}
|
|
3297
|
-
async function domFallbackElements(page) {
|
|
3298
|
-
|
|
4045
|
+
async function domFallbackElements(page, rawPage) {
|
|
4046
|
+
function _browserEval() {
|
|
3299
4047
|
const out = [];
|
|
3300
4048
|
const seen = /* @__PURE__ */ new Set();
|
|
3301
4049
|
const 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;
|
|
@@ -3311,7 +4059,7 @@ async function domFallbackElements(page) {
|
|
|
3311
4059
|
}
|
|
3312
4060
|
return results;
|
|
3313
4061
|
};
|
|
3314
|
-
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"],
|
|
4062
|
+
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"]';
|
|
3315
4063
|
querySelectorAllDeep(document, selector).forEach((el) => {
|
|
3316
4064
|
const style = window.getComputedStyle(el);
|
|
3317
4065
|
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || style.pointerEvents === "none") return;
|
|
@@ -3323,11 +4071,10 @@ async function domFallbackElements(page) {
|
|
|
3323
4071
|
const tag = el.tagName.toLowerCase();
|
|
3324
4072
|
const ariaRole = el.getAttribute("role");
|
|
3325
4073
|
const isActionEl = tag === "button" || tag === "a" || ariaRole === "button";
|
|
3326
|
-
if (tag === "a"
|
|
4074
|
+
if (tag === "a") {
|
|
3327
4075
|
let p = el.parentElement;
|
|
3328
4076
|
while (p) {
|
|
3329
|
-
|
|
3330
|
-
if (["header", "footer"].includes(ptag)) return;
|
|
4077
|
+
if (p.tagName?.toLowerCase() === "footer") return;
|
|
3331
4078
|
p = p.parentElement;
|
|
3332
4079
|
}
|
|
3333
4080
|
}
|
|
@@ -3472,7 +4219,48 @@ async function domFallbackElements(page) {
|
|
|
3472
4219
|
const priority = (e) => ["email", "password", "text", "tel", "date", "number", "select", "textarea", "url", "file"].includes(e.role) ? 0 : 1;
|
|
3473
4220
|
out.sort((a, b) => priority(a) - priority(b));
|
|
3474
4221
|
return out;
|
|
3475
|
-
}
|
|
4222
|
+
}
|
|
4223
|
+
const _browserEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_browserEval.toString()})()})();`;
|
|
4224
|
+
if (rawPage && typeof rawPage.context === "function") {
|
|
4225
|
+
try {
|
|
4226
|
+
const ctx = rawPage.context();
|
|
4227
|
+
if (typeof ctx?.newCDPSession === "function") {
|
|
4228
|
+
const cdp = await ctx.newCDPSession(rawPage);
|
|
4229
|
+
try {
|
|
4230
|
+
const evalResult = await cdp.send("Runtime.evaluate", {
|
|
4231
|
+
expression: _browserEvalStr,
|
|
4232
|
+
returnByValue: true,
|
|
4233
|
+
awaitPromise: false
|
|
4234
|
+
});
|
|
4235
|
+
if (evalResult?.exceptionDetails) {
|
|
4236
|
+
console.warn("[crawler] domFallbackElements CDP primary error:", evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
|
|
4237
|
+
} else if (Array.isArray(evalResult?.result?.value)) {
|
|
4238
|
+
return evalResult.result.value.map((e) => ({ ...e, isInteractive: true }));
|
|
4239
|
+
}
|
|
4240
|
+
} finally {
|
|
4241
|
+
cdp.detach().catch(() => {
|
|
4242
|
+
});
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
} catch (cdpPrimaryErr) {
|
|
4246
|
+
console.warn("[crawler] domFallbackElements CDP primary failed, trying page.evaluate:", cdpPrimaryErr?.message ?? String(cdpPrimaryErr));
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
const evalPage = rawPage ?? page;
|
|
4250
|
+
const _doEvaluate = () => evalPage.evaluate(_browserEvalStr);
|
|
4251
|
+
let result;
|
|
4252
|
+
try {
|
|
4253
|
+
result = await _doEvaluate();
|
|
4254
|
+
} catch (err1) {
|
|
4255
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
4256
|
+
try {
|
|
4257
|
+
result = await _doEvaluate();
|
|
4258
|
+
} catch (err2) {
|
|
4259
|
+
console.warn("[crawler] domFallbackElements page.evaluate threw (attempt 2):", err2?.message ?? String(err2));
|
|
4260
|
+
return [];
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
return (result ?? []).map((e) => ({ ...e, isInteractive: true }));
|
|
3476
4264
|
}
|
|
3477
4265
|
async function contentFallbackElements(page) {
|
|
3478
4266
|
return page.evaluate(() => {
|
|
@@ -4272,8 +5060,8 @@ async function expandNavMenus(page) {
|
|
|
4272
5060
|
return false;
|
|
4273
5061
|
}
|
|
4274
5062
|
}
|
|
4275
|
-
async function domFallbackLinks(page, origin) {
|
|
4276
|
-
|
|
5063
|
+
async function domFallbackLinks(page, origin, rawPage) {
|
|
5064
|
+
function _linkEval(originStr) {
|
|
4277
5065
|
const logout = /logout|log out|sign out|delete/i;
|
|
4278
5066
|
const isRouteHash = (hash) => /^#!?\//.test(hash);
|
|
4279
5067
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -4285,7 +5073,7 @@ async function domFallbackLinks(page, origin) {
|
|
|
4285
5073
|
try {
|
|
4286
5074
|
const u = new URL(rawHref, location.href);
|
|
4287
5075
|
if (!isRouteHash(u.hash)) u.hash = "";
|
|
4288
|
-
if (new URL(u).origin ===
|
|
5076
|
+
if (new URL(u).origin === originStr) seen.add(u.toString());
|
|
4289
5077
|
} catch {
|
|
4290
5078
|
}
|
|
4291
5079
|
};
|
|
@@ -4316,7 +5104,39 @@ async function domFallbackLinks(page, origin) {
|
|
|
4316
5104
|
}
|
|
4317
5105
|
}
|
|
4318
5106
|
return Array.from(seen);
|
|
4319
|
-
}
|
|
5107
|
+
}
|
|
5108
|
+
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)})})();`;
|
|
5109
|
+
if (rawPage && typeof rawPage.context === "function") {
|
|
5110
|
+
try {
|
|
5111
|
+
const ctx = rawPage.context();
|
|
5112
|
+
if (typeof ctx?.newCDPSession === "function") {
|
|
5113
|
+
const cdp = await ctx.newCDPSession(rawPage);
|
|
5114
|
+
try {
|
|
5115
|
+
const evalResult = await cdp.send("Runtime.evaluate", {
|
|
5116
|
+
expression: _linkEvalStr,
|
|
5117
|
+
returnByValue: true,
|
|
5118
|
+
awaitPromise: false
|
|
5119
|
+
});
|
|
5120
|
+
if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
|
|
5121
|
+
return evalResult.result.value;
|
|
5122
|
+
}
|
|
5123
|
+
if (evalResult?.exceptionDetails) {
|
|
5124
|
+
console.warn("[crawler] domFallbackLinks CDP error:", evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
|
|
5125
|
+
}
|
|
5126
|
+
} finally {
|
|
5127
|
+
cdp.detach().catch(() => {
|
|
5128
|
+
});
|
|
5129
|
+
}
|
|
5130
|
+
}
|
|
5131
|
+
} catch (cdpErr) {
|
|
5132
|
+
console.warn("[crawler] domFallbackLinks CDP failed:", cdpErr?.message ?? String(cdpErr));
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5135
|
+
const evalPage = rawPage ?? page;
|
|
5136
|
+
return evalPage.evaluate(_linkEvalStr).catch((err) => {
|
|
5137
|
+
console.warn("[crawler] domFallbackLinks page.evaluate threw:", err?.message ?? String(err));
|
|
5138
|
+
return [];
|
|
5139
|
+
});
|
|
4320
5140
|
}
|
|
4321
5141
|
async function extractLinkMappings(page, origin) {
|
|
4322
5142
|
return page.evaluate((origin2) => {
|
|
@@ -4417,50 +5237,105 @@ async function dismissOverlays(page) {
|
|
|
4417
5237
|
} catch {
|
|
4418
5238
|
}
|
|
4419
5239
|
}
|
|
4420
|
-
await
|
|
4421
|
-
});
|
|
5240
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
4422
5241
|
}
|
|
4423
|
-
async function scrollToTriggerLazyLoad(page) {
|
|
5242
|
+
async function scrollToTriggerLazyLoad(page, rawPage) {
|
|
5243
|
+
const evalPage = rawPage ?? page;
|
|
4424
5244
|
try {
|
|
4425
|
-
await
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
5245
|
+
const { scrollHeight, innerHeight, innerWidth } = await evalPage.evaluate(() => ({
|
|
5246
|
+
scrollHeight: Math.min(document.body.scrollHeight, 12e3),
|
|
5247
|
+
innerHeight: window.innerHeight || 800,
|
|
5248
|
+
innerWidth: window.innerWidth || 1280
|
|
5249
|
+
})).catch(() => ({ scrollHeight: 0, innerHeight: 800, innerWidth: 1280 }));
|
|
5250
|
+
if (scrollHeight > innerHeight) {
|
|
5251
|
+
let cdp = null;
|
|
5252
|
+
if (rawPage && typeof rawPage.context === "function") {
|
|
5253
|
+
try {
|
|
5254
|
+
const ctx = rawPage.context();
|
|
5255
|
+
if (typeof ctx?.newCDPSession === "function") cdp = await ctx.newCDPSession(rawPage);
|
|
5256
|
+
} catch {
|
|
5257
|
+
cdp = null;
|
|
5258
|
+
}
|
|
4431
5259
|
}
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
5260
|
+
const step = Math.max(innerHeight, 400);
|
|
5261
|
+
for (let y = step; y < scrollHeight; y += step) {
|
|
5262
|
+
await evalPage.evaluate((pos) => window.scrollTo(0, pos), y).catch(() => {
|
|
5263
|
+
});
|
|
5264
|
+
if (cdp) {
|
|
5265
|
+
await cdp.send("Input.dispatchMouseEvent", {
|
|
5266
|
+
type: "mouseWheel",
|
|
5267
|
+
x: Math.floor(innerWidth / 2),
|
|
5268
|
+
y: Math.floor(innerHeight / 2),
|
|
5269
|
+
deltaX: 0,
|
|
5270
|
+
deltaY: step,
|
|
5271
|
+
modifiers: 0
|
|
5272
|
+
}).catch(() => {
|
|
5273
|
+
});
|
|
5274
|
+
}
|
|
5275
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
5276
|
+
}
|
|
5277
|
+
await evalPage.evaluate(() => window.scrollTo(0, 0)).catch(() => {
|
|
5278
|
+
});
|
|
5279
|
+
if (cdp) cdp.detach().catch(() => {
|
|
5280
|
+
});
|
|
5281
|
+
}
|
|
5282
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
5283
|
+
const scrollWaitPage = rawPage ?? page;
|
|
5284
|
+
if (typeof scrollWaitPage.waitForLoadState === "function") {
|
|
5285
|
+
await scrollWaitPage.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
|
|
5286
|
+
});
|
|
5287
|
+
}
|
|
5288
|
+
await waitForStableDOM(page, 4e3, 500, rawPage);
|
|
4437
5289
|
} catch {
|
|
4438
5290
|
}
|
|
4439
5291
|
}
|
|
4440
|
-
async function waitForStableDOM(page, maxWait = 8e3, stabilityMs = 400) {
|
|
5292
|
+
async function waitForStableDOM(page, maxWait = 8e3, stabilityMs = 400, rawPage) {
|
|
5293
|
+
const evalPage = rawPage ?? page;
|
|
4441
5294
|
const deadline = Date.now() + maxWait;
|
|
4442
5295
|
let lastCount = -1;
|
|
4443
5296
|
let stableAt = 0;
|
|
5297
|
+
let evalErrors = 0;
|
|
4444
5298
|
while (Date.now() < deadline) {
|
|
4445
|
-
const count = await
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
5299
|
+
const count = await evalPage.evaluate(() => {
|
|
5300
|
+
let c = 0;
|
|
5301
|
+
const visit = (root) => {
|
|
5302
|
+
const els = root.querySelectorAll("*");
|
|
5303
|
+
c += els.length;
|
|
5304
|
+
els.forEach((el) => {
|
|
5305
|
+
if (el.shadowRoot) visit(el.shadowRoot);
|
|
5306
|
+
});
|
|
5307
|
+
};
|
|
5308
|
+
visit(document);
|
|
5309
|
+
return c;
|
|
5310
|
+
}).catch(() => -1);
|
|
5311
|
+
if (count === -1) {
|
|
5312
|
+
if (++evalErrors >= 3) {
|
|
5313
|
+
await new Promise((r) => setTimeout(r, stabilityMs));
|
|
5314
|
+
return;
|
|
5315
|
+
}
|
|
5316
|
+
} else {
|
|
5317
|
+
evalErrors = 0;
|
|
5318
|
+
if (count !== lastCount) {
|
|
5319
|
+
lastCount = count;
|
|
5320
|
+
stableAt = Date.now();
|
|
5321
|
+
} else if (Date.now() - stableAt >= stabilityMs) {
|
|
5322
|
+
return;
|
|
5323
|
+
}
|
|
4451
5324
|
}
|
|
4452
5325
|
await new Promise((r) => setTimeout(r, 100));
|
|
4453
5326
|
}
|
|
4454
5327
|
}
|
|
4455
|
-
async function waitForScreenshotReady(page) {
|
|
4456
|
-
const hardDeadline = Date.now() +
|
|
5328
|
+
async function waitForScreenshotReady(page, rawPage) {
|
|
5329
|
+
const hardDeadline = Date.now() + 3e4;
|
|
4457
5330
|
const withHardCap = (p) => Promise.race([p, new Promise((resolve) => setTimeout(resolve, Math.max(0, hardDeadline - Date.now())))]);
|
|
5331
|
+
const waitPage = rawPage ?? page;
|
|
5332
|
+
const evalPage = rawPage ?? page;
|
|
4458
5333
|
try {
|
|
4459
|
-
await withHardCap(
|
|
5334
|
+
await withHardCap(waitPage.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => {
|
|
4460
5335
|
}));
|
|
4461
|
-
await withHardCap(
|
|
5336
|
+
await withHardCap(waitPage.waitForLoadState("networkidle", { timeout: 12e3 }).catch(() => {
|
|
4462
5337
|
}));
|
|
4463
|
-
await withHardCap(
|
|
5338
|
+
await withHardCap(evalPage.evaluate(() => {
|
|
4464
5339
|
if (document.readyState === "complete") return true;
|
|
4465
5340
|
return new Promise((resolve) => {
|
|
4466
5341
|
const done = () => resolve(true);
|
|
@@ -4469,14 +5344,14 @@ async function waitForScreenshotReady(page) {
|
|
|
4469
5344
|
});
|
|
4470
5345
|
}).catch(() => {
|
|
4471
5346
|
}));
|
|
4472
|
-
await withHardCap(
|
|
5347
|
+
await withHardCap(evalPage.evaluate(async () => {
|
|
4473
5348
|
const fonts = document.fonts;
|
|
4474
5349
|
if (fonts?.ready) await fonts.ready;
|
|
4475
5350
|
}).catch(() => {
|
|
4476
5351
|
}));
|
|
4477
|
-
await withHardCap(scrollToTriggerLazyLoad(page));
|
|
4478
|
-
await withHardCap(waitForStableDOM(page, 4e3, 700));
|
|
4479
|
-
await withHardCap(
|
|
5352
|
+
await withHardCap(scrollToTriggerLazyLoad(rawPage ?? page, rawPage));
|
|
5353
|
+
await withHardCap(waitForStableDOM(page, 4e3, 700, rawPage));
|
|
5354
|
+
await withHardCap(evalPage.evaluate(async () => {
|
|
4480
5355
|
const pending = Array.from(document.images).filter((img) => !img.complete);
|
|
4481
5356
|
if (pending.length === 0) return;
|
|
4482
5357
|
await Promise.race([
|
|
@@ -4488,7 +5363,7 @@ async function waitForScreenshotReady(page) {
|
|
|
4488
5363
|
]);
|
|
4489
5364
|
}).catch(() => {
|
|
4490
5365
|
}));
|
|
4491
|
-
await withHardCap(waitForStableDOM(page, 2e3, 500));
|
|
5366
|
+
await withHardCap(waitForStableDOM(page, 2e3, 500, rawPage));
|
|
4492
5367
|
} catch {
|
|
4493
5368
|
}
|
|
4494
5369
|
}
|
|
@@ -4642,6 +5517,7 @@ async function captureAuthDiscoveryScreen(page, opts, requestedUrl, source, ordi
|
|
|
4642
5517
|
return { captured: true, finalUrl, flowType };
|
|
4643
5518
|
}
|
|
4644
5519
|
async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
|
|
5520
|
+
const tuning = await getCrawlTuning();
|
|
4645
5521
|
const queue = [];
|
|
4646
5522
|
const queued = /* @__PURE__ */ new Set();
|
|
4647
5523
|
const capturedKeys = /* @__PURE__ */ new Set();
|
|
@@ -4677,8 +5553,11 @@ async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
|
|
|
4677
5553
|
await onProgress?.(10, "Discovering public and auth flows");
|
|
4678
5554
|
while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
|
|
4679
5555
|
const current = queue.shift();
|
|
5556
|
+
const _authPct = 10 + Math.round(captured / AUTH_DISCOVERY_MAX_SCREENS * 7);
|
|
5557
|
+
const _authPath = current.url.replace(/^https?:\/\/[^/]+/, "") || "/";
|
|
5558
|
+
await onProgress?.(_authPct, `Auth discovery \xB7 ${_authPath} \xB7 ${captured} found`);
|
|
4680
5559
|
try {
|
|
4681
|
-
const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout:
|
|
5560
|
+
const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).catch(() => null);
|
|
4682
5561
|
if (response && response.status && response.status() >= 400) continue;
|
|
4683
5562
|
await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
|
|
4684
5563
|
});
|