@detiq/crawler 1.1.14 → 1.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +1 -1
- package/dist/accessibility-tree-crawler.js +156 -0
- package/dist/accessibility-tree-crawler.js.map +7 -0
- package/dist/advanced-ui-probes.js +310 -0
- package/dist/advanced-ui-probes.js.map +7 -0
- package/dist/content-chunker.js +2 -2
- package/dist/content-chunker.js.map +2 -2
- package/dist/crawl-session-manager.js +145 -0
- package/dist/crawl-session-manager.js.map +7 -0
- package/dist/crawler.js +1377 -344
- package/dist/crawler.js.map +2 -2
- package/dist/crud-interaction.js +264 -0
- package/dist/crud-interaction.js.map +7 -0
- package/dist/dropdown-enumerator.js +230 -0
- package/dist/dropdown-enumerator.js.map +7 -0
- package/dist/filter-pagination-crawler.js +299 -0
- package/dist/filter-pagination-crawler.js.map +7 -0
- package/dist/frontier.js +5 -3
- package/dist/frontier.js.map +2 -2
- package/dist/gap-pass.js +47 -14
- package/dist/gap-pass.js.map +2 -2
- package/dist/http-engine.js +28 -15
- package/dist/http-engine.js.map +2 -2
- package/dist/index.js +15 -1
- package/dist/index.js.map +2 -2
- package/dist/job-queue.js +23 -6
- package/dist/job-queue.js.map +2 -2
- package/dist/locator-extractor.js +23 -0
- package/dist/locator-extractor.js.map +2 -2
- package/dist/multi-context-handler.js +182 -0
- package/dist/multi-context-handler.js.map +7 -0
- package/dist/nested-window-crawler.js +172 -0
- package/dist/nested-window-crawler.js.map +7 -0
- package/dist/network-action-correlator.js +209 -0
- package/dist/network-action-correlator.js.map +7 -0
- package/dist/performance-metrics-collector.js +155 -0
- package/dist/performance-metrics-collector.js.map +7 -0
- package/dist/performance-metrics.js.map +2 -2
- package/dist/policies/crawl-safety.js +5 -1
- package/dist/policies/crawl-safety.js.map +2 -2
- package/dist/profiles/domain-profile.js +21 -1
- package/dist/profiles/domain-profile.js.map +2 -2
- package/dist/rate-limiter.js +2 -2
- package/dist/rate-limiter.js.map +2 -2
- package/dist/screen-reader.js +1 -0
- package/dist/screen-reader.js.map +2 -2
- package/dist/service-worker-interceptor.js +136 -0
- package/dist/service-worker-interceptor.js.map +7 -0
- package/dist/session-capture.js +4 -2
- package/dist/session-capture.js.map +2 -2
- package/dist/shadow-dom-crawler.js +132 -0
- package/dist/shadow-dom-crawler.js.map +7 -0
- package/dist/sitemap.js +16 -10
- package/dist/sitemap.js.map +2 -2
- package/dist/state-graph.js +193 -0
- package/dist/state-graph.js.map +7 -0
- package/dist/stealth/human-behavior.js +36 -3
- package/dist/stealth/human-behavior.js.map +2 -2
- package/dist/url-pattern-completer.js +48 -14
- package/dist/url-pattern-completer.js.map +2 -2
- package/dist/visual-diff-verdict.js +3 -1
- package/dist/visual-diff-verdict.js.map +2 -2
- package/package.json +2 -1
- package/src/accessibility-tree-crawler.ts +246 -0
- package/src/advanced-ui-probes.ts +470 -0
- package/src/crawl-session-manager.ts +231 -0
- package/src/crawler.ts +1196 -150
- package/src/crud-interaction.ts +386 -0
- package/src/dropdown-enumerator.ts +312 -0
- package/src/filter-pagination-crawler.ts +500 -0
- package/src/gap-pass.ts +58 -16
- package/src/index.ts +14 -1
- package/src/job-queue.ts +9 -0
- package/src/locator-extractor.ts +45 -0
- package/src/multi-context-handler.ts +296 -0
- package/src/nested-window-crawler.ts +310 -0
- package/src/network-action-correlator.ts +320 -0
- package/src/performance-metrics-collector.ts +243 -0
- package/src/performance-metrics.ts +1 -1
- package/src/screen-reader.ts +4 -1
- package/src/service-worker-interceptor.ts +185 -0
- package/src/session-capture.ts +3 -0
- package/src/shadow-dom-crawler.ts +198 -0
- package/src/state-graph.ts +280 -0
- package/src/stealth/human-behavior.ts +38 -4
- package/src/url-pattern-completer.ts +61 -15
- package/tsconfig.json +1 -1
- package/types/accessibility-tree-crawler.d.ts +44 -0
- package/types/advanced-ui-probes.d.ts +47 -0
- package/types/crawl-session-manager.d.ts +44 -0
- package/types/crawler.d.ts +3 -1
- package/types/crud-interaction.d.ts +31 -0
- package/types/dropdown-enumerator.d.ts +20 -0
- package/types/filter-pagination-crawler.d.ts +52 -0
- package/types/gap-pass.d.ts +1 -0
- package/types/index.d.ts +14 -1
- package/types/job-queue.d.ts +18 -9
- package/types/locator-extractor.d.ts +7 -0
- package/types/multi-context-handler.d.ts +55 -0
- package/types/nested-window-crawler.d.ts +41 -0
- package/types/network-action-correlator.d.ts +47 -0
- package/types/performance-metrics-collector.d.ts +50 -0
- package/types/performance-metrics.d.ts +1 -2
- package/types/screen-reader.d.ts +1 -2
- package/types/service-worker-interceptor.d.ts +36 -0
- package/types/session-capture.d.ts +1 -1
- package/types/shadow-dom-crawler.d.ts +35 -0
- package/types/sitemap.d.ts +2 -2
- package/types/state-graph.d.ts +59 -0
- package/types/stealth/human-behavior.d.ts +1 -1
package/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
|
+
});
|
|
714
795
|
});
|
|
796
|
+
pwContext.on("download", (dl) => {
|
|
797
|
+
dl.cancel().catch(() => {
|
|
798
|
+
});
|
|
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,85 @@ 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 AUTH_WALL_LABEL_RE = /^(sign.?in|log.?in|login|platform access|back\s+to|checking\s+auth|get started|create account|register|continue with|join now)/i;
|
|
955
|
+
const allAnchorOnly = preAuthElements.every((el) => el.role === "a");
|
|
956
|
+
const looksLikeAuthWall = preAuthElements.length <= 3 && (allAnchorOnly || preAuthElements.every((el) => AUTH_WALL_LABEL_RE.test(el.label)));
|
|
957
|
+
if (looksLikeAuthWall) {
|
|
958
|
+
logger.info({ url: finalUrl, elements: preAuthElements.length, labels: preAuthElements.map((e) => e.label) }, "[crawler] pre-auth auth wall \u2014 deferring to authenticated BFS");
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
const pathParts = finalPath.split("/").filter(Boolean);
|
|
962
|
+
const screenName = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
|
|
963
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
|
|
964
|
+
if (!screen) continue;
|
|
965
|
+
const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
|
|
966
|
+
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {
|
|
967
|
+
});
|
|
968
|
+
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => void 0);
|
|
969
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl).catch(() => {
|
|
970
|
+
});
|
|
971
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
|
|
972
|
+
});
|
|
973
|
+
for (const el of preAuthElements) {
|
|
974
|
+
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(() => {
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
preAuthPublicCaptured.add(finalUrl);
|
|
978
|
+
logger.info({ url: finalUrl, screenId: screen.id, elements: preAuthElements.length }, "[crawler] pre-auth public page captured");
|
|
979
|
+
} catch (err) {
|
|
980
|
+
logger.warn({ url: link, err: err?.message }, "[crawler] pre-auth public page visit failed \u2014 skipping");
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if (preAuthPublicCaptured.size > 0) {
|
|
984
|
+
await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 2e4 }).catch(() => {
|
|
985
|
+
});
|
|
986
|
+
await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
}
|
|
802
990
|
if (!opts.discoveryMode) {
|
|
803
991
|
try {
|
|
804
992
|
await runAuthDiscoveryPhase(page, opts, new URL(opts.appUrl).origin, onProgress);
|
|
@@ -806,6 +994,16 @@ async function crawlProject(opts, onProgress) {
|
|
|
806
994
|
logger.warn({ err: preAuthErr }, "[crawler] auth-discovery phase failed \u2014 continuing with authenticated crawl");
|
|
807
995
|
}
|
|
808
996
|
}
|
|
997
|
+
{
|
|
998
|
+
const worldOk = await page.evaluate(() => true).catch(() => false);
|
|
999
|
+
if (!worldOk) {
|
|
1000
|
+
logger.warn("[crawler] isolated world corrupted after auth discovery \u2014 re-navigating to recover");
|
|
1001
|
+
await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
|
|
1002
|
+
});
|
|
1003
|
+
await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
809
1007
|
let didLogin = false;
|
|
810
1008
|
if (opts.authToken) {
|
|
811
1009
|
await onProgress?.(18, "API token configured \u2014 skipping login");
|
|
@@ -819,8 +1017,42 @@ async function crawlProject(opts, onProgress) {
|
|
|
819
1017
|
if (browserCtx) {
|
|
820
1018
|
await browserCtx.addCookies(state.cookies);
|
|
821
1019
|
}
|
|
1020
|
+
try {
|
|
1021
|
+
const stagehandCtx = page.context?.();
|
|
1022
|
+
if (stagehandCtx && typeof stagehandCtx.addCookies === "function") {
|
|
1023
|
+
await stagehandCtx.addCookies(state.cookies);
|
|
1024
|
+
}
|
|
1025
|
+
} catch {
|
|
1026
|
+
}
|
|
822
1027
|
}
|
|
823
1028
|
if (state.origins?.length) {
|
|
1029
|
+
const originCtx = playwrightBrowser?.contexts()[0];
|
|
1030
|
+
if (originCtx) {
|
|
1031
|
+
await originCtx.addInitScript((origins) => {
|
|
1032
|
+
for (const entry of origins ?? []) {
|
|
1033
|
+
if (window.location.origin === entry.origin) {
|
|
1034
|
+
for (const item of entry.localStorage) {
|
|
1035
|
+
try {
|
|
1036
|
+
localStorage.setItem(item.name, item.value);
|
|
1037
|
+
} catch {
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}, state.origins);
|
|
1043
|
+
}
|
|
1044
|
+
await page.addInitScript((origins) => {
|
|
1045
|
+
for (const entry of origins ?? []) {
|
|
1046
|
+
if (window.location.origin === entry.origin) {
|
|
1047
|
+
for (const item of entry.localStorage) {
|
|
1048
|
+
try {
|
|
1049
|
+
localStorage.setItem(item.name, item.value);
|
|
1050
|
+
} catch {
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}, state.origins);
|
|
824
1056
|
await rawPage.addInitScript((origins) => {
|
|
825
1057
|
for (const entry of origins ?? []) {
|
|
826
1058
|
if (window.location.origin === entry.origin) {
|
|
@@ -1024,12 +1256,43 @@ async function crawlProject(opts, onProgress) {
|
|
|
1024
1256
|
"nonce",
|
|
1025
1257
|
"session",
|
|
1026
1258
|
"sid",
|
|
1259
|
+
// App content/state params — these change the content rendered within the same
|
|
1260
|
+
// page template (a playground example, a search query, a tab selection) but do NOT
|
|
1261
|
+
// represent structurally distinct pages. Not stripping them causes URL explosion:
|
|
1262
|
+
// every unique ?cmd=DSN-XXXXX&example=... or ?q=foo is queued as a new screen.
|
|
1263
|
+
"cmd",
|
|
1264
|
+
"example",
|
|
1265
|
+
"query",
|
|
1266
|
+
"q",
|
|
1267
|
+
"search",
|
|
1268
|
+
"keyword",
|
|
1269
|
+
"keywords",
|
|
1270
|
+
"tab",
|
|
1271
|
+
"view",
|
|
1272
|
+
"mode",
|
|
1273
|
+
"sort",
|
|
1274
|
+
"order",
|
|
1275
|
+
"direction",
|
|
1276
|
+
"orderby",
|
|
1277
|
+
"page",
|
|
1278
|
+
"p",
|
|
1279
|
+
"offset",
|
|
1280
|
+
"limit",
|
|
1281
|
+
"per_page",
|
|
1282
|
+
"size",
|
|
1283
|
+
"rows",
|
|
1284
|
+
// Export format params — same endpoint, different output format, not a new screen
|
|
1285
|
+
"format",
|
|
1286
|
+
"output",
|
|
1287
|
+
"export_format",
|
|
1288
|
+
"download_format",
|
|
1289
|
+
"fileformat",
|
|
1290
|
+
"file_format",
|
|
1027
1291
|
// WooCommerce / e-commerce action params
|
|
1028
1292
|
"add-to-cart",
|
|
1029
1293
|
"add_to_wishlist",
|
|
1030
1294
|
"quantity",
|
|
1031
1295
|
"wc-ajax",
|
|
1032
|
-
"orderby",
|
|
1033
1296
|
"min_price",
|
|
1034
1297
|
"max_price",
|
|
1035
1298
|
"removed_item",
|
|
@@ -1097,9 +1360,11 @@ async function crawlProject(opts, onProgress) {
|
|
|
1097
1360
|
};
|
|
1098
1361
|
const gitInfo = getGitInfo();
|
|
1099
1362
|
const visited = /* @__PURE__ */ new Map();
|
|
1363
|
+
const crawlWarnings = [];
|
|
1100
1364
|
if (opts.skipUrls?.length) {
|
|
1101
1365
|
for (const u of opts.skipUrls) visited.set(canonicalize(u), "skipped");
|
|
1102
1366
|
}
|
|
1367
|
+
for (const u of preAuthPublicCaptured) visited.set(canonicalize(u), "pre-auth");
|
|
1103
1368
|
let checkpointSuccessCount = 0;
|
|
1104
1369
|
let maxSeenTotal = 0;
|
|
1105
1370
|
let lastEmittedPct = 20;
|
|
@@ -1133,7 +1398,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1133
1398
|
];
|
|
1134
1399
|
const queued = new Set(seedUrls.map(canonicalize));
|
|
1135
1400
|
const queue = new CrawlFrontier(opts.traversalStrategy ?? "BFS", seedUrls, opts.bestFirstKeywords, opts.urlFilter);
|
|
1136
|
-
const deduplicator = new ContentDeduplicator(0.88);
|
|
1401
|
+
const deduplicator = new ContentDeduplicator(parseFloat(process.env.CRAWLER_DEDUP_THRESHOLD ?? "") || 0.88);
|
|
1137
1402
|
const deadLinkTracker = new DeadLinkTracker();
|
|
1138
1403
|
const redirectRegistry = new RedirectRegistry();
|
|
1139
1404
|
if (!selective && resumeUrls.length === 0) {
|
|
@@ -1143,6 +1408,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1143
1408
|
const orphanIdSet = new Set(
|
|
1144
1409
|
(orphanReport?.screens ?? []).filter((s) => s.classification !== "REACHABLE").map((s) => s.id)
|
|
1145
1410
|
);
|
|
1411
|
+
const seenReseedCanon = new Set(queued);
|
|
1146
1412
|
const priorScreens = (priorMap?.screens ?? []).filter((s) => s.status !== "PENDING_REMOVAL").filter((s) => {
|
|
1147
1413
|
const u = s.url;
|
|
1148
1414
|
if (!u) return false;
|
|
@@ -1151,11 +1417,15 @@ async function crawlProject(opts, onProgress) {
|
|
|
1151
1417
|
const stripWww = (h) => h.replace(/^www\./i, "").toLowerCase();
|
|
1152
1418
|
if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
|
|
1153
1419
|
if (isGeneratedDynamicPath(pathname)) return false;
|
|
1420
|
+
if (!isUsefulNavigationUrl(u, startUrl)) return false;
|
|
1421
|
+
const canon = canonicalize(u);
|
|
1422
|
+
if (seenReseedCanon.has(canon)) return false;
|
|
1423
|
+
seenReseedCanon.add(canon);
|
|
1154
1424
|
return true;
|
|
1155
1425
|
} catch {
|
|
1156
1426
|
return false;
|
|
1157
1427
|
}
|
|
1158
|
-
})
|
|
1428
|
+
});
|
|
1159
1429
|
let orphanSeedCount = 0;
|
|
1160
1430
|
for (const s of priorScreens) {
|
|
1161
1431
|
const u = s.url;
|
|
@@ -1213,6 +1483,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1213
1483
|
try {
|
|
1214
1484
|
const u = new URL(sUrl);
|
|
1215
1485
|
if (u.origin !== sitemapOrigin) continue;
|
|
1486
|
+
if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) continue;
|
|
1216
1487
|
await page.goto(sUrl, { waitUntil: "domcontentloaded", timeout: 12e3 });
|
|
1217
1488
|
await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
|
|
1218
1489
|
});
|
|
@@ -1268,16 +1539,20 @@ async function crawlProject(opts, onProgress) {
|
|
|
1268
1539
|
const discovered = [];
|
|
1269
1540
|
const performanceMetrics = [];
|
|
1270
1541
|
const seenDomHashes = new Set(opts.knownDomHashes ?? []);
|
|
1271
|
-
const
|
|
1542
|
+
const skipPathExtra = process.env.CRAWL_SKIP_PATH_EXTRA ? process.env.CRAWL_SKIP_PATH_EXTRA.split(",").map((p) => p.trim()).filter(Boolean) : [];
|
|
1543
|
+
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
1544
|
const segIdx = opts.segmentIndex ?? 1;
|
|
1273
1545
|
const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
|
|
1274
1546
|
let avgNavMs = 0;
|
|
1275
1547
|
let navCount = 0;
|
|
1276
1548
|
let aiErrorStreak = 0;
|
|
1277
1549
|
let aiSkipCount = 0;
|
|
1550
|
+
const aiMinElements = parseInt(process.env.CRAWL_AI_LINK_MIN_ELEMENTS ?? "") || 5;
|
|
1278
1551
|
let aiRateLimitWarned = false;
|
|
1279
1552
|
let totalAttemptsThisSegment = 0;
|
|
1280
1553
|
const maxAttempts = Math.max(segmentSize * 4, 200);
|
|
1554
|
+
let loginWallRedirectCount = 0;
|
|
1555
|
+
const LOGIN_WALL_ABORT_THRESHOLD = Math.max(8, Math.ceil(segmentSize * 0.4));
|
|
1281
1556
|
let pendingSessionRestore = null;
|
|
1282
1557
|
const CRAWL_PAGE_PARALLELISM = Math.max(1, parseInt(process.env.CRAWL_PAGE_PARALLELISM ?? "5", 10));
|
|
1283
1558
|
async function runLiteWorker(workerPage) {
|
|
@@ -1291,18 +1566,42 @@ async function crawlProject(opts, onProgress) {
|
|
|
1291
1566
|
try {
|
|
1292
1567
|
const { pathname } = new URL(url);
|
|
1293
1568
|
if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
|
|
1569
|
+
if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
|
|
1294
1570
|
} catch {
|
|
1295
1571
|
continue;
|
|
1296
1572
|
}
|
|
1297
1573
|
visited.set(canonUrl, "_pending_");
|
|
1298
1574
|
try {
|
|
1299
|
-
const navErr = await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout:
|
|
1575
|
+
const navErr = await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).then(() => null, (e) => e);
|
|
1300
1576
|
if (navErr && /timeout/i.test(String(navErr?.message ?? navErr))) {
|
|
1301
1577
|
await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch(() => {
|
|
1302
1578
|
});
|
|
1303
1579
|
}
|
|
1304
1580
|
await workerPage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
1305
1581
|
});
|
|
1582
|
+
await waitForStableDOM(workerPage, 6e3, 400).catch(() => {
|
|
1583
|
+
});
|
|
1584
|
+
await dismissOverlays(workerPage).catch(() => {
|
|
1585
|
+
});
|
|
1586
|
+
const lwCurrentUrl = workerPage.url();
|
|
1587
|
+
const lwPath = (() => {
|
|
1588
|
+
try {
|
|
1589
|
+
return new URL(lwCurrentUrl).pathname.toLowerCase();
|
|
1590
|
+
} catch {
|
|
1591
|
+
return "";
|
|
1592
|
+
}
|
|
1593
|
+
})();
|
|
1594
|
+
const lwIsAuthWall = /^\/(login|signin|sign-in|auth|authenticate|session\/new|account\/login|user\/login)/.test(lwPath) && lwCurrentUrl !== url;
|
|
1595
|
+
const lwHasLoginForm = lwIsAuthWall ? false : await workerPage.evaluate(() => {
|
|
1596
|
+
const inputs = document.querySelectorAll('input[type="password"]');
|
|
1597
|
+
return inputs.length > 0;
|
|
1598
|
+
}).catch(() => false);
|
|
1599
|
+
if (lwIsAuthWall) {
|
|
1600
|
+
logger.warn({ url, landed: lwCurrentUrl }, "[lite-worker] auth wall detected \u2014 queuing for main worker re-auth");
|
|
1601
|
+
visited.set(canonUrl, "_auth_wall_");
|
|
1602
|
+
if (!revisitQueue.includes(url)) revisitQueue.push(url);
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1306
1605
|
if (homeFingerprintElemCount >= 0 && url !== startUrl) {
|
|
1307
1606
|
const wTitle = await workerPage.title().catch(() => "");
|
|
1308
1607
|
if (wTitle === homeFingerprintTitle && wTitle !== "") {
|
|
@@ -1321,31 +1620,228 @@ async function crawlProject(opts, onProgress) {
|
|
|
1321
1620
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
|
|
1322
1621
|
visited.set(canonUrl, screen.id);
|
|
1323
1622
|
const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
|
|
1623
|
+
let liteElements = await domFallbackElements(workerPage).catch(() => []);
|
|
1624
|
+
if (liteElements.length === 0) {
|
|
1625
|
+
liteElements = await workerPage.evaluate(() => {
|
|
1626
|
+
const sel = 'button,a[href],input,select,textarea,[role],[tabindex="0"],[onclick],[data-action]';
|
|
1627
|
+
return Array.from(document.querySelectorAll(sel)).filter((el) => el.offsetParent !== null).slice(0, 150).map((el) => ({
|
|
1628
|
+
meaning: el.getAttribute("aria-label") || el.textContent?.trim().slice(0, 100) || el.tagName.toLowerCase(),
|
|
1629
|
+
role: el.getAttribute("role") || el.tagName.toLowerCase(),
|
|
1630
|
+
expectedData: null,
|
|
1631
|
+
notes: null,
|
|
1632
|
+
boundingRect: null,
|
|
1633
|
+
ariaState: null,
|
|
1634
|
+
parentLandmark: null
|
|
1635
|
+
}));
|
|
1636
|
+
}).catch(() => []);
|
|
1637
|
+
}
|
|
1324
1638
|
const [elements] = await Promise.all([
|
|
1325
|
-
|
|
1639
|
+
Promise.resolve(liteElements),
|
|
1326
1640
|
workerPage.screenshot({ path: shotPath, fullPage: true }).then(async () => {
|
|
1327
1641
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
1328
1642
|
return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl);
|
|
1329
1643
|
}).catch(() => {
|
|
1330
1644
|
})
|
|
1331
1645
|
]);
|
|
1646
|
+
const _navEls = await workerPage.evaluate(() => {
|
|
1647
|
+
if (typeof globalThis.__name === "undefined") globalThis.__name = (fn) => fn;
|
|
1648
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1649
|
+
const items = [];
|
|
1650
|
+
function isV(el) {
|
|
1651
|
+
const s = window.getComputedStyle(el);
|
|
1652
|
+
return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0";
|
|
1653
|
+
}
|
|
1654
|
+
const NAV_SEL = [
|
|
1655
|
+
"nav a",
|
|
1656
|
+
'[role="navigation"] a',
|
|
1657
|
+
"header a",
|
|
1658
|
+
"footer nav a",
|
|
1659
|
+
'[aria-label*="navigation" i] a',
|
|
1660
|
+
'[aria-label*="sidebar" i] a',
|
|
1661
|
+
"aside a",
|
|
1662
|
+
'[role="complementary"] a',
|
|
1663
|
+
'[class*="sidebar" i] a',
|
|
1664
|
+
'[class*="side-nav" i] a',
|
|
1665
|
+
'[class*="sidenav" i] a',
|
|
1666
|
+
'[class*="nav-menu" i] a',
|
|
1667
|
+
'[class*="navmenu" i] a',
|
|
1668
|
+
"[data-nav] a"
|
|
1669
|
+
].join(", ");
|
|
1670
|
+
document.querySelectorAll(NAV_SEL).forEach((a) => {
|
|
1671
|
+
if (!isV(a)) return;
|
|
1672
|
+
const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
|
|
1673
|
+
const href = a.href ?? "";
|
|
1674
|
+
if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
|
|
1675
|
+
let landmark = "Site Navigation";
|
|
1676
|
+
let cur = a.parentElement;
|
|
1677
|
+
while (cur) {
|
|
1678
|
+
const t = cur.tagName?.toLowerCase();
|
|
1679
|
+
const r = cur.getAttribute?.("role")?.toLowerCase();
|
|
1680
|
+
const cl = (cur.className ?? "").toLowerCase();
|
|
1681
|
+
if (t === "header" || r === "banner") {
|
|
1682
|
+
landmark = "Header";
|
|
1683
|
+
break;
|
|
1684
|
+
}
|
|
1685
|
+
if (t === "footer" || r === "contentinfo") {
|
|
1686
|
+
landmark = "Footer";
|
|
1687
|
+
break;
|
|
1688
|
+
}
|
|
1689
|
+
if (t === "aside" || r === "complementary" || cl.includes("sidebar") || cl.includes("sidenav") || cl.includes("side-nav")) {
|
|
1690
|
+
landmark = "Sidebar";
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
if (t === "nav" || r === "navigation") {
|
|
1694
|
+
landmark = cur.getAttribute("aria-label")?.trim() || "Site Navigation";
|
|
1695
|
+
break;
|
|
1696
|
+
}
|
|
1697
|
+
cur = cur.parentElement;
|
|
1698
|
+
}
|
|
1699
|
+
const key = `nav|${label.toLowerCase()}|${href}`;
|
|
1700
|
+
if (!seen.has(key)) {
|
|
1701
|
+
seen.add(key);
|
|
1702
|
+
items.push({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
let fc = 0;
|
|
1706
|
+
document.querySelectorAll('footer a, [role="contentinfo"] a, [class*="footer" i] a').forEach((a) => {
|
|
1707
|
+
if (fc >= 20 || !isV(a)) return;
|
|
1708
|
+
const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
|
|
1709
|
+
const href = a.href ?? "";
|
|
1710
|
+
if (!label || !href) return;
|
|
1711
|
+
const key = `footer|${label.toLowerCase()}|${href}`;
|
|
1712
|
+
if (!seen.has(key)) {
|
|
1713
|
+
seen.add(key);
|
|
1714
|
+
items.push({ meaning: label, role: "footer-link", notes: JSON.stringify({ href: href.slice(0, 150) }) });
|
|
1715
|
+
fc++;
|
|
1716
|
+
}
|
|
1717
|
+
});
|
|
1718
|
+
{
|
|
1719
|
+
const navHrefsSeen = /* @__PURE__ */ new Set();
|
|
1720
|
+
for (const it of items) {
|
|
1721
|
+
try {
|
|
1722
|
+
const h = JSON.parse(it.notes ?? "{}").href;
|
|
1723
|
+
if (h) navHrefsSeen.add(h);
|
|
1724
|
+
} catch {
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
const gpGroups = /* @__PURE__ */ new Map();
|
|
1728
|
+
document.querySelectorAll("a[href]").forEach((a) => {
|
|
1729
|
+
if (!isV(a)) return;
|
|
1730
|
+
const href = a.href ?? "";
|
|
1731
|
+
if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsSeen.has(href)) return;
|
|
1732
|
+
const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
|
|
1733
|
+
if (!label || label.length > 40) return;
|
|
1734
|
+
const parent = a.parentElement;
|
|
1735
|
+
const gp = parent?.parentElement;
|
|
1736
|
+
if (!parent || !gp) return;
|
|
1737
|
+
if (parent.querySelectorAll("a").length !== 1) return;
|
|
1738
|
+
const gpTag = gp.tagName.toLowerCase();
|
|
1739
|
+
if (["body", "html", "form"].includes(gpTag)) return;
|
|
1740
|
+
const arr = gpGroups.get(gp) ?? [];
|
|
1741
|
+
arr.push({ label, href });
|
|
1742
|
+
gpGroups.set(gp, arr);
|
|
1743
|
+
});
|
|
1744
|
+
for (const [, links] of gpGroups.entries()) {
|
|
1745
|
+
if (links.length >= 4) {
|
|
1746
|
+
for (const { label, href } of links) {
|
|
1747
|
+
const k = `cluster|${label.toLowerCase()}|${href}`;
|
|
1748
|
+
if (!seen.has(k)) {
|
|
1749
|
+
seen.add(k);
|
|
1750
|
+
items.push({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark: "Sidebar" }) });
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
return items;
|
|
1757
|
+
}).catch(() => []);
|
|
1758
|
+
const allElements = [
|
|
1759
|
+
...elements,
|
|
1760
|
+
..._navEls.map((ne) => ({ ...ne, expectedData: void 0, boundingRect: void 0, ariaState: void 0, parentLandmark: void 0 }))
|
|
1761
|
+
];
|
|
1762
|
+
if (allElements.length > 0) {
|
|
1763
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
|
|
1764
|
+
});
|
|
1765
|
+
for (let _ei = 0; _ei < allElements.length; _ei += 4) {
|
|
1766
|
+
const _batch = allElements.slice(_ei, _ei + 4);
|
|
1767
|
+
await Promise.all(_batch.map(
|
|
1768
|
+
(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(() => {
|
|
1769
|
+
})
|
|
1770
|
+
));
|
|
1771
|
+
}
|
|
1772
|
+
logger.info({ url, screenId: screen.id, elements: elements.length, navLinks: _navEls.length }, "[lite-worker] screen captured");
|
|
1773
|
+
} else if (!revisitQueue.includes(url)) {
|
|
1774
|
+
logger.warn({ url, screenId: screen.id }, "[lite-worker] 0 elements \u2014 queued for P3a revisit");
|
|
1775
|
+
revisitQueue.push(url);
|
|
1776
|
+
}
|
|
1777
|
+
if (screen?.id) {
|
|
1778
|
+
const [shadowRes, a11yRes, swRes] = await Promise.race([
|
|
1779
|
+
Promise.allSettled([
|
|
1780
|
+
crawlShadowDom(workerPage),
|
|
1781
|
+
crawlAccessibilityTree(workerPage),
|
|
1782
|
+
inspectServiceWorker(workerPage)
|
|
1783
|
+
]),
|
|
1784
|
+
new Promise(
|
|
1785
|
+
(resolve) => setTimeout(() => resolve([
|
|
1786
|
+
{ status: "rejected", reason: "timeout" },
|
|
1787
|
+
{ status: "rejected", reason: "timeout" },
|
|
1788
|
+
{ status: "rejected", reason: "timeout" }
|
|
1789
|
+
]), 15e3)
|
|
1790
|
+
)
|
|
1791
|
+
]);
|
|
1792
|
+
const litePayload = { screenId: screen.id, url };
|
|
1793
|
+
if (shadowRes.status === "fulfilled" && shadowRes.value && shadowRes.value.shadowHostCount > 0)
|
|
1794
|
+
litePayload.shadowDom = shadowRes.value;
|
|
1795
|
+
if (a11yRes.status === "fulfilled" && a11yRes.value)
|
|
1796
|
+
litePayload.accessibilityTree = a11yRes.value;
|
|
1797
|
+
if (swRes.status === "fulfilled" && swRes.value.supported)
|
|
1798
|
+
litePayload.serviceWorker = swRes.value;
|
|
1799
|
+
if (Object.keys(litePayload).length > 2) {
|
|
1800
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
1801
|
+
jobId: opts.jobId,
|
|
1802
|
+
type: "PER_SCREEN_PROBES",
|
|
1803
|
+
payload: litePayload
|
|
1804
|
+
}).catch(() => {
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1332
1808
|
const nextLinks = await workerPage.evaluate((originStr) => {
|
|
1333
|
-
return Array.from(document.querySelectorAll("a[href]")).map((a) => {
|
|
1809
|
+
return Array.from(document.querySelectorAll("a[href],[data-href]")).map((a) => {
|
|
1810
|
+
const raw = a.getAttribute("href") || a.getAttribute("data-href") || "";
|
|
1334
1811
|
try {
|
|
1335
|
-
return new URL(
|
|
1812
|
+
return new URL(raw, window.location.href).href;
|
|
1336
1813
|
} catch {
|
|
1337
1814
|
return "";
|
|
1338
1815
|
}
|
|
1339
|
-
}).filter((h) =>
|
|
1816
|
+
}).filter((h) => {
|
|
1817
|
+
if (!h) return false;
|
|
1818
|
+
try {
|
|
1819
|
+
const u = new URL(h);
|
|
1820
|
+
if (u.origin !== originStr) return false;
|
|
1821
|
+
if (u.hash && !u.hash.startsWith("#/") && !u.hash.startsWith("#!/")) return false;
|
|
1822
|
+
return true;
|
|
1823
|
+
} catch {
|
|
1824
|
+
return false;
|
|
1825
|
+
}
|
|
1826
|
+
}).slice(0, 200);
|
|
1340
1827
|
}, origin).catch(() => []);
|
|
1341
1828
|
for (const link of nextLinks) {
|
|
1829
|
+
if (!isUsefulNavigationUrl(link, origin)) continue;
|
|
1342
1830
|
const cLink = canonicalize(link);
|
|
1343
|
-
if (!visited.has(cLink)) {
|
|
1831
|
+
if (!visited.has(cLink) && !queued.has(cLink)) {
|
|
1832
|
+
queued.add(cLink);
|
|
1344
1833
|
queue.push(link);
|
|
1345
1834
|
edges.push({ fromUrl: url, toUrl: link });
|
|
1346
1835
|
}
|
|
1347
1836
|
}
|
|
1348
1837
|
discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
|
|
1838
|
+
const lwEffMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
|
|
1839
|
+
maxSeenTotal = Math.max(maxSeenTotal, lwEffMax);
|
|
1840
|
+
const lwRawPct = 20 + Math.round(Math.min(57, visited.size / Math.max(1, maxSeenTotal) * 57));
|
|
1841
|
+
const lwPct = Math.max(lastEmittedPct, lwRawPct);
|
|
1842
|
+
lastEmittedPct = lwPct;
|
|
1843
|
+
await onProgress?.(lwPct, `Crawling screens (${visited.size} mapped, ${queue.length} pending)`).catch(() => {
|
|
1844
|
+
});
|
|
1349
1845
|
} catch (err) {
|
|
1350
1846
|
logger.warn({ url, err: err?.message ?? String(err) }, "[lite-worker] URL processing failed");
|
|
1351
1847
|
discovered.push({ url, error: err?.message ?? String(err) });
|
|
@@ -1353,7 +1849,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1353
1849
|
}
|
|
1354
1850
|
}
|
|
1355
1851
|
const liteWorkerPromises = [];
|
|
1356
|
-
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
|
|
1852
|
+
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser && !opts.discoveryMode) {
|
|
1357
1853
|
const pwCtx = playwrightBrowser.contexts()[0];
|
|
1358
1854
|
for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
|
|
1359
1855
|
const wp = await pwCtx.newPage();
|
|
@@ -1361,7 +1857,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1361
1857
|
Object.defineProperty(navigator, "webdriver", { get: () => void 0 });
|
|
1362
1858
|
}).catch(() => {
|
|
1363
1859
|
});
|
|
1364
|
-
wp.setDefaultTimeout(45e3);
|
|
1860
|
+
wp.setDefaultTimeout(tuning.defaultTimeoutMs || 45e3);
|
|
1365
1861
|
liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {
|
|
1366
1862
|
})));
|
|
1367
1863
|
}
|
|
@@ -1386,9 +1882,10 @@ async function crawlProject(opts, onProgress) {
|
|
|
1386
1882
|
}
|
|
1387
1883
|
const url = queue.shift();
|
|
1388
1884
|
const canonUrl = canonicalize(url);
|
|
1389
|
-
logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size + 1}/${segmentSize}` }, "[crawler] visiting URL");
|
|
1390
1885
|
const apiObservationStartIndex = capturedApiEndpoints.length;
|
|
1391
1886
|
if (visited.has(canonUrl)) continue;
|
|
1887
|
+
visited.set(canonUrl, "_pending_");
|
|
1888
|
+
logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size}/${segmentSize}` }, "[crawler] visiting URL");
|
|
1392
1889
|
if (robotsCache && !await robotsCache.isAllowed(url)) {
|
|
1393
1890
|
logger.info({ url }, "[crawler] robots.txt: skipping disallowed URL");
|
|
1394
1891
|
continue;
|
|
@@ -1403,6 +1900,11 @@ async function crawlProject(opts, onProgress) {
|
|
|
1403
1900
|
console.log(`[crawler] skip generated dynamic route: ${url}`);
|
|
1404
1901
|
continue;
|
|
1405
1902
|
}
|
|
1903
|
+
if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) {
|
|
1904
|
+
console.log(`[crawler] skip API/export/download path: ${url}`);
|
|
1905
|
+
visited.set(canonUrl, "_skipped_api_");
|
|
1906
|
+
continue;
|
|
1907
|
+
}
|
|
1406
1908
|
} catch {
|
|
1407
1909
|
}
|
|
1408
1910
|
try {
|
|
@@ -1481,7 +1983,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1481
1983
|
logger.info({ url }, "[crawler] temporarily cleared session for public auth route");
|
|
1482
1984
|
}
|
|
1483
1985
|
}
|
|
1484
|
-
const navErr15 = await page.goto(url, { waitUntil: "domcontentloaded", timeout:
|
|
1986
|
+
const navErr15 = await page.goto(url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).then(() => null, (e) => e);
|
|
1485
1987
|
if (navErr15) {
|
|
1486
1988
|
if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
|
|
1487
1989
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch((navErr) => {
|
|
@@ -1493,14 +1995,19 @@ async function crawlProject(opts, onProgress) {
|
|
|
1493
1995
|
}
|
|
1494
1996
|
rawPage.off("response", onRedirectResponse);
|
|
1495
1997
|
if (redirectHops.length > 0) {
|
|
1998
|
+
const isCircularRedirect = new Set(redirectHops.map((h) => h.from)).size < redirectHops.length;
|
|
1496
1999
|
redirectRegistry.register({
|
|
1497
2000
|
originalUrl: url,
|
|
1498
2001
|
finalUrl: page.url(),
|
|
1499
2002
|
hops: redirectHops.map((h, i) => ({ ...h, durationMs: i === 0 ? Date.now() - navStart : 0 })),
|
|
1500
|
-
isCircular:
|
|
2003
|
+
isCircular: isCircularRedirect,
|
|
1501
2004
|
hopCount: redirectHops.length,
|
|
1502
2005
|
totalDurationMs: Date.now() - navStart
|
|
1503
2006
|
});
|
|
2007
|
+
if (isCircularRedirect) {
|
|
2008
|
+
logger.warn({ url, hops: redirectHops.map((h) => h.from) }, "[crawler] circular redirect detected \u2014 skipping URL");
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
1504
2011
|
}
|
|
1505
2012
|
const navMs = Date.now() - navStart;
|
|
1506
2013
|
navCount++;
|
|
@@ -1510,9 +2017,36 @@ async function crawlProject(opts, onProgress) {
|
|
|
1510
2017
|
console.warn(`[crawler] connection failed for ${url} \u2014 skipping (chrome-error page)`);
|
|
1511
2018
|
continue;
|
|
1512
2019
|
}
|
|
1513
|
-
await page.waitForLoadState("load", { timeout: 8e3 }).catch(() => {
|
|
2020
|
+
await (rawPage ?? page).waitForLoadState("load", { timeout: 8e3 }).catch(() => {
|
|
1514
2021
|
});
|
|
1515
|
-
await waitForStableDOM(page);
|
|
2022
|
+
await waitForStableDOM(page, 8e3, 400, rawPage ?? void 0);
|
|
2023
|
+
const sparseCheck = await (rawPage ?? page).evaluate(
|
|
2024
|
+
() => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[aria-label]').length
|
|
2025
|
+
).catch(() => 99);
|
|
2026
|
+
if (sparseCheck < 15) {
|
|
2027
|
+
await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
|
|
2028
|
+
});
|
|
2029
|
+
await waitForStableDOM(page, 4e3, 300, rawPage ?? void 0);
|
|
2030
|
+
}
|
|
2031
|
+
try {
|
|
2032
|
+
const spinnerPage = rawPage ?? page;
|
|
2033
|
+
if (typeof spinnerPage.waitForFunction === "function") {
|
|
2034
|
+
await spinnerPage.waitForFunction(() => {
|
|
2035
|
+
const body = document.body;
|
|
2036
|
+
if (!body) return true;
|
|
2037
|
+
const text = body.innerText?.toLowerCase() ?? "";
|
|
2038
|
+
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;
|
|
2039
|
+
const busyEl = body.querySelector('[aria-busy="true"]');
|
|
2040
|
+
if (busyEl && busyEl.offsetParent !== null) return false;
|
|
2041
|
+
if (body.querySelector('[data-loading="true"],[data-pending="true"],[data-fetching="true"]')) return false;
|
|
2042
|
+
if (body.querySelector("progress:not([value])")) return false;
|
|
2043
|
+
const skeleton = body.querySelector(".skeleton,.skeleton-loader,.shimmer,.pulse-loader,.content-loader");
|
|
2044
|
+
if (skeleton && skeleton.offsetParent !== null) return false;
|
|
2045
|
+
return true;
|
|
2046
|
+
}, { timeout: 12e3 });
|
|
2047
|
+
}
|
|
2048
|
+
} catch {
|
|
2049
|
+
}
|
|
1516
2050
|
if (!opts.discoveryMode) {
|
|
1517
2051
|
try {
|
|
1518
2052
|
const appModalVisible = await page.evaluate(() => {
|
|
@@ -1529,7 +2063,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1529
2063
|
const modalShotPath = path.join(opts.screenshotDir ?? "/tmp", `modal-${modalHash}.png`);
|
|
1530
2064
|
await page.screenshot({ path: modalShotPath, fullPage: false }).catch(() => {
|
|
1531
2065
|
});
|
|
1532
|
-
const modalName = await page.evaluate(() => {
|
|
2066
|
+
const modalName = await (rawPage ?? page).evaluate(() => {
|
|
1533
2067
|
const h = document.querySelector('[role="dialog"] h1,[role="dialog"] h2,[role="dialog"] [role="heading"]');
|
|
1534
2068
|
return h?.textContent?.trim() ?? "Modal";
|
|
1535
2069
|
}).catch(() => "Modal");
|
|
@@ -1537,13 +2071,18 @@ async function crawlProject(opts, onProgress) {
|
|
|
1537
2071
|
if (modalScreen) {
|
|
1538
2072
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, "DESKTOP").catch(() => {
|
|
1539
2073
|
});
|
|
1540
|
-
|
|
2074
|
+
const modalElements = await domFallbackElements(page, rawPage ?? void 0).catch(() => []);
|
|
2075
|
+
for (const el of modalElements) {
|
|
2076
|
+
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(() => {
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
logger.info({ url, name: modalName, elementCount: modalElements.length }, "[crawler] captured app modal before dismiss");
|
|
1541
2080
|
}
|
|
1542
2081
|
}
|
|
1543
2082
|
} catch {
|
|
1544
2083
|
}
|
|
1545
2084
|
await dismissOverlays(page);
|
|
1546
|
-
await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }).catch(() => {
|
|
2085
|
+
await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }, rawPage ?? void 0).catch(() => {
|
|
1547
2086
|
});
|
|
1548
2087
|
}
|
|
1549
2088
|
if (cdpSession?.off) cdpSession.off("Network.responseReceived", secHeadersCdp);
|
|
@@ -1563,6 +2102,22 @@ async function crawlProject(opts, onProgress) {
|
|
|
1563
2102
|
const weWantedThisPage = BFS_ERROR_PATH.test(new URL(url).pathname) || BFS_LOGIN_PATH.test(new URL(url).pathname);
|
|
1564
2103
|
const redirectedToError = !weWantedThisPage && (BFS_ERROR_PATH.test(new URL(landedUrl).pathname) || BFS_LOGIN_PATH.test(new URL(landedUrl).pathname));
|
|
1565
2104
|
if (redirectedToError) {
|
|
2105
|
+
if (BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)) {
|
|
2106
|
+
loginWallRedirectCount++;
|
|
2107
|
+
if (!opts.credentials && loginWallRedirectCount >= LOGIN_WALL_ABORT_THRESHOLD) {
|
|
2108
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2109
|
+
jobId: opts.jobId,
|
|
2110
|
+
type: "AUTH_FAILURE",
|
|
2111
|
+
payload: {
|
|
2112
|
+
url,
|
|
2113
|
+
landed: landedUrl,
|
|
2114
|
+
reason: `Login wall detected: ${loginWallRedirectCount} consecutive URLs redirected to login. Add credentials in project settings to crawl authenticated pages.`
|
|
2115
|
+
}
|
|
2116
|
+
}).catch(() => {
|
|
2117
|
+
});
|
|
2118
|
+
throw new Error(`[AUTH_FAILURE] Login wall detected: ${loginWallRedirectCount} URLs redirected to the login page. Add credentials in project settings and retry the crawl.`);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
1566
2121
|
logger.warn({ requested: url, landed: landedUrl }, "[crawler] Mid-crawl redirect to auth/error \u2014 attempting re-auth");
|
|
1567
2122
|
let reauthed = false;
|
|
1568
2123
|
if (opts.credentials) {
|
|
@@ -1579,9 +2134,9 @@ async function crawlProject(opts, onProgress) {
|
|
|
1579
2134
|
logger.info({ url }, "[crawler] Re-auth succeeded \u2014 retrying URL");
|
|
1580
2135
|
await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {
|
|
1581
2136
|
});
|
|
1582
|
-
await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
2137
|
+
await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
1583
2138
|
});
|
|
1584
|
-
await waitForStableDOM(page);
|
|
2139
|
+
await waitForStableDOM(page, 8e3, 400, rawPage ?? void 0);
|
|
1585
2140
|
const afterReauthUrl = page.url();
|
|
1586
2141
|
if (BFS_ERROR_PATH.test(new URL(afterReauthUrl).pathname) || BFS_LOGIN_PATH.test(new URL(afterReauthUrl).pathname)) {
|
|
1587
2142
|
logger.warn({ url }, "[crawler] Still redirecting after re-auth \u2014 skipping this URL");
|
|
@@ -1598,6 +2153,10 @@ async function crawlProject(opts, onProgress) {
|
|
|
1598
2153
|
throw new Error("[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.");
|
|
1599
2154
|
}
|
|
1600
2155
|
} else {
|
|
2156
|
+
if (opts.discoveryMode) {
|
|
2157
|
+
logger.info({ url, landed: landedUrl }, "[discover] skipping auth-required URL \u2014 no credentials, continuing BFS");
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
1601
2160
|
logger.warn("[crawler] Session expired mid-crawl, no credentials to recover \u2014 skipping URL");
|
|
1602
2161
|
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
1603
2162
|
jobId: opts.jobId,
|
|
@@ -1611,6 +2170,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1611
2170
|
if (!redirectedToError && landedUrl !== url) {
|
|
1612
2171
|
logger.info({ requested: url, landed: landedUrl }, "[crawler] navigated with redirect");
|
|
1613
2172
|
}
|
|
2173
|
+
if (!redirectedToError) loginWallRedirectCount = 0;
|
|
1614
2174
|
if (opts.discoveryMode) {
|
|
1615
2175
|
visited.set(canonicalize(url), url);
|
|
1616
2176
|
await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
|
|
@@ -1749,7 +2309,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1749
2309
|
continue;
|
|
1750
2310
|
}
|
|
1751
2311
|
const preDomHash = await computeDomHash(page);
|
|
1752
|
-
if (seenDomHashes.has(preDomHash)) {
|
|
2312
|
+
if (preDomHash !== "" && seenDomHashes.has(preDomHash)) {
|
|
1753
2313
|
console.log(`[crawler] skip ${url} \u2014 duplicate DOM structure (same content as prior page)`);
|
|
1754
2314
|
continue;
|
|
1755
2315
|
}
|
|
@@ -1835,7 +2395,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1835
2395
|
const lastSeg = allSegs[allSegs.length - 1];
|
|
1836
2396
|
pathName = lastSeg.slice(0, 8).toUpperCase();
|
|
1837
2397
|
}
|
|
1838
|
-
const SIG_PARAMS = ["product_cat", "category", "cat", "type", "tag", "brand", "filter", "tab", "section", "view"];
|
|
2398
|
+
const SIG_PARAMS = ["product_cat", "category", "cat", "type", "tag", "brand", "filter", "tab", "section", "view", "play", "mode", "id", "page"];
|
|
1839
2399
|
const paramSuffix = SIG_PARAMS.map((p) => {
|
|
1840
2400
|
const v = u.searchParams.get(p);
|
|
1841
2401
|
return v ? ` \xB7 ${v.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}` : "";
|
|
@@ -1855,14 +2415,15 @@ async function crawlProject(opts, onProgress) {
|
|
|
1855
2415
|
}
|
|
1856
2416
|
const candidate = parts[0].trim();
|
|
1857
2417
|
const isBrand = candidate.split(/\s+/).length === 1 && (candidate.toLowerCase().includes(brandName) || candidate.toLowerCase() === brandName);
|
|
1858
|
-
|
|
2418
|
+
const isSiteGenericTitle = homeFingerprintTitle !== "" && candidate === homeFingerprintTitle;
|
|
2419
|
+
if (!isBrand && !isSiteGenericTitle && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
|
|
1859
2420
|
return candidate;
|
|
1860
2421
|
}
|
|
1861
2422
|
}
|
|
1862
2423
|
} catch {
|
|
1863
2424
|
}
|
|
1864
2425
|
try {
|
|
1865
|
-
const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout:
|
|
2426
|
+
const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
|
|
1866
2427
|
if (activeNav) {
|
|
1867
2428
|
const t = activeNav.trim();
|
|
1868
2429
|
if (t.length > 1 && t.length < 60 && t.toLowerCase() !== brandName) return t;
|
|
@@ -1881,7 +2442,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
1881
2442
|
".page-header h2"
|
|
1882
2443
|
];
|
|
1883
2444
|
for (const sel of selectors) {
|
|
1884
|
-
const h = await page.locator(sel).first().textContent({ timeout:
|
|
2445
|
+
const h = await page.locator(sel).first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
|
|
1885
2446
|
if (!h) continue;
|
|
1886
2447
|
const t = h.trim();
|
|
1887
2448
|
if (t.length < 2 || t.length > 80) continue;
|
|
@@ -1971,21 +2532,24 @@ async function crawlProject(opts, onProgress) {
|
|
|
1971
2532
|
if (cachedLinks) {
|
|
1972
2533
|
logger.info({ url, cachedCount: cachedLinks.length }, "[crawler] link-discovery: using cached recipe \u2014 AI will still run and merge");
|
|
1973
2534
|
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2535
|
+
{
|
|
2536
|
+
const effMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
|
|
2537
|
+
maxSeenTotal = Math.max(maxSeenTotal, effMax);
|
|
2538
|
+
const rawPrePct = 20 + Math.round(Math.min(57, visited.size / Math.max(1, maxSeenTotal) * 57));
|
|
2539
|
+
const preCapturePct = Math.max(lastEmittedPct, rawPrePct);
|
|
2540
|
+
lastEmittedPct = preCapturePct;
|
|
2541
|
+
await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} \xB7 ${(() => {
|
|
2542
|
+
try {
|
|
2543
|
+
return new URL(url).pathname || "/";
|
|
2544
|
+
} catch {
|
|
2545
|
+
return "/";
|
|
2546
|
+
}
|
|
2547
|
+
})()}`);
|
|
2548
|
+
}
|
|
2549
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
1985
2550
|
const preDomCount = await page.evaluate(
|
|
1986
2551
|
() => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
|
|
1987
2552
|
).catch(() => 0);
|
|
1988
|
-
await waitForScreenshotReady(page);
|
|
1989
2553
|
await onProgress?.(lastEmittedPct, `Capturing ${(() => {
|
|
1990
2554
|
try {
|
|
1991
2555
|
return new URL(url).pathname || "/";
|
|
@@ -1993,8 +2557,8 @@ async function crawlProject(opts, onProgress) {
|
|
|
1993
2557
|
return "/";
|
|
1994
2558
|
}
|
|
1995
2559
|
})()}`);
|
|
1996
|
-
const pageDpr = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
|
|
1997
|
-
const elements = await extractElements(stagehand, page, elementLLM ?? void 0).catch((err) => {
|
|
2560
|
+
const pageDpr = await (rawPage ?? page).evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
|
|
2561
|
+
const elements = await extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch((err) => {
|
|
1998
2562
|
console.error("[crawler] extractElements failed:", err);
|
|
1999
2563
|
return [];
|
|
2000
2564
|
});
|
|
@@ -2045,8 +2609,8 @@ async function crawlProject(opts, onProgress) {
|
|
|
2045
2609
|
}).catch((err) => console.error("[crawler] page.screenshot failed:", err)),
|
|
2046
2610
|
// Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
|
|
2047
2611
|
// 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
|
|
2612
|
+
// Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
|
|
2613
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).then((freshLinks) => {
|
|
2050
2614
|
if (aiSkipCount > 0) aiSkipCount--;
|
|
2051
2615
|
aiErrorStreak = 0;
|
|
2052
2616
|
const merged = (cachedLinks ? Array.from(/* @__PURE__ */ new Set([...cachedLinks, ...freshLinks])) : freshLinks).filter((href) => isUsefulNavigationUrl(href, origin));
|
|
@@ -2079,7 +2643,7 @@ async function crawlProject(opts, onProgress) {
|
|
|
2079
2643
|
}
|
|
2080
2644
|
return fallback;
|
|
2081
2645
|
}),
|
|
2082
|
-
readScreen(page).catch(() => [])
|
|
2646
|
+
readScreen(rawPage ?? page).catch(() => [])
|
|
2083
2647
|
]);
|
|
2084
2648
|
await page.evaluate(() => {
|
|
2085
2649
|
const container = document.getElementById("__zeta_element_highlights");
|
|
@@ -2100,6 +2664,104 @@ async function crawlProject(opts, onProgress) {
|
|
|
2100
2664
|
}
|
|
2101
2665
|
}).catch(() => {
|
|
2102
2666
|
});
|
|
2667
|
+
{
|
|
2668
|
+
const safeContext = typeof rawPage?.context === "function" ? rawPage.context() : typeof page?.context === "function" ? page.context() : playwrightBrowser?.contexts()[0] ?? null;
|
|
2669
|
+
const roTimeout = new Promise(
|
|
2670
|
+
(resolve) => setTimeout(() => resolve([
|
|
2671
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2672
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2673
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2674
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2675
|
+
{ status: "rejected", reason: "probe-timeout" }
|
|
2676
|
+
]), 25e3)
|
|
2677
|
+
);
|
|
2678
|
+
const [nestedCtxRes, shadowRes, a11yRes, perfRes, swRes] = await Promise.race([
|
|
2679
|
+
Promise.allSettled([
|
|
2680
|
+
crawlNestedContexts(rawPage ?? page, safeContext, url),
|
|
2681
|
+
crawlShadowDom(rawPage ?? page),
|
|
2682
|
+
crawlAccessibilityTree(rawPage ?? page),
|
|
2683
|
+
collectPerfMetrics(rawPage ?? page, url),
|
|
2684
|
+
inspectServiceWorker(rawPage ?? page)
|
|
2685
|
+
]),
|
|
2686
|
+
roTimeout
|
|
2687
|
+
]);
|
|
2688
|
+
const roPayload = { screenId: screen.id, url };
|
|
2689
|
+
if (nestedCtxRes.status === "fulfilled" && nestedCtxRes.value && nestedCtxRes.value.totalContextsFound > 0)
|
|
2690
|
+
roPayload.nestedContexts = nestedCtxRes.value;
|
|
2691
|
+
if (shadowRes.status === "fulfilled" && shadowRes.value && shadowRes.value.shadowHostCount > 0)
|
|
2692
|
+
roPayload.shadowDom = shadowRes.value;
|
|
2693
|
+
if (a11yRes.status === "fulfilled" && a11yRes.value)
|
|
2694
|
+
roPayload.accessibilityTree = a11yRes.value;
|
|
2695
|
+
if (perfRes.status === "fulfilled" && perfRes.value.requestCount > 0)
|
|
2696
|
+
roPayload.performance = perfRes.value;
|
|
2697
|
+
if (swRes.status === "fulfilled" && swRes.value.supported)
|
|
2698
|
+
roPayload.serviceWorker = swRes.value;
|
|
2699
|
+
if (Object.keys(roPayload).length > 2) {
|
|
2700
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2701
|
+
jobId: opts.jobId,
|
|
2702
|
+
type: "PER_SCREEN_PROBES",
|
|
2703
|
+
payload: roPayload
|
|
2704
|
+
}).catch(() => {
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
if (screen?.id && opts.interactive !== false && safeContext) {
|
|
2708
|
+
void (async () => {
|
|
2709
|
+
let probePage = null;
|
|
2710
|
+
try {
|
|
2711
|
+
const openProbePages = safeContext.pages().length;
|
|
2712
|
+
if (openProbePages > 8) {
|
|
2713
|
+
logger.warn({ url, openProbePages }, "[crawler] probe page cap reached \u2014 skipping mutating probes for this screen");
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
probePage = await safeContext.newPage();
|
|
2717
|
+
await probePage.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
|
|
2718
|
+
});
|
|
2719
|
+
await probePage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
2720
|
+
});
|
|
2721
|
+
await waitForStableDOM(probePage, 4e3, 400).catch(() => {
|
|
2722
|
+
});
|
|
2723
|
+
const muTimeout = new Promise(
|
|
2724
|
+
(resolve) => setTimeout(() => resolve([
|
|
2725
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2726
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2727
|
+
{ status: "rejected", reason: "probe-timeout" },
|
|
2728
|
+
{ status: "rejected", reason: "probe-timeout" }
|
|
2729
|
+
]), 6e4)
|
|
2730
|
+
);
|
|
2731
|
+
const [dropEnumRes, crudRes, filterRes, advancedRes] = await Promise.race([
|
|
2732
|
+
Promise.allSettled([
|
|
2733
|
+
enumerateDropdowns(probePage),
|
|
2734
|
+
probeCrudFlows(probePage, name),
|
|
2735
|
+
crawlFiltersAndPagination(probePage),
|
|
2736
|
+
probeAdvancedUI(probePage)
|
|
2737
|
+
]),
|
|
2738
|
+
muTimeout
|
|
2739
|
+
]);
|
|
2740
|
+
const muPayload = { screenId: screen.id, url };
|
|
2741
|
+
if (dropEnumRes.status === "fulfilled" && dropEnumRes.value.totalOptionsFound > 0)
|
|
2742
|
+
muPayload.dropdowns = dropEnumRes.value;
|
|
2743
|
+
if (crudRes.status === "fulfilled" && (crudRes.value.flows?.length > 0 || crudRes.value.toastsObserved?.length > 0))
|
|
2744
|
+
muPayload.crudFlows = crudRes.value;
|
|
2745
|
+
if (filterRes.status === "fulfilled" && filterRes.value.filters?.length > 0)
|
|
2746
|
+
muPayload.filtersAndPagination = filterRes.value;
|
|
2747
|
+
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"))
|
|
2748
|
+
muPayload.advancedUI = advancedRes.value;
|
|
2749
|
+
if (Object.keys(muPayload).length > 2) {
|
|
2750
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
2751
|
+
jobId: opts.jobId,
|
|
2752
|
+
type: "PER_SCREEN_PROBES_MUTATING",
|
|
2753
|
+
payload: muPayload
|
|
2754
|
+
}).catch(() => {
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
} catch {
|
|
2758
|
+
} finally {
|
|
2759
|
+
await probePage?.close().catch(() => {
|
|
2760
|
+
});
|
|
2761
|
+
}
|
|
2762
|
+
})();
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2103
2765
|
try {
|
|
2104
2766
|
const domContent = await capturePageHtml(page);
|
|
2105
2767
|
if (!domContent) {
|
|
@@ -2248,252 +2910,315 @@ ${snippet}`,
|
|
|
2248
2910
|
try {
|
|
2249
2911
|
const baseline = await AppBrain.getVisualBaselineForDiff(opts.tenantId, screen.id, "DESKTOP", gitInfo.branch);
|
|
2250
2912
|
if (baseline && baseline.storagePath !== shotPath) {
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
screenPurpose: "Application screen captured during automated crawl",
|
|
2276
|
-
tenantId: opts.tenantId,
|
|
2277
|
-
projectId: opts.projectId
|
|
2278
|
-
});
|
|
2279
|
-
verdict = mergeAiVisualVerdict(verdict, aiResult, screen.name);
|
|
2280
|
-
} catch (aiErr) {
|
|
2281
|
-
logger.warn({ screenId: screen.id, err: String(aiErr) }, "[crawler] AI visual compare failed, keeping pHash-only verdict");
|
|
2913
|
+
let baselinePath = baseline.storagePath;
|
|
2914
|
+
let tempBaselinePath = null;
|
|
2915
|
+
const localExists = await fs.access(baseline.storagePath).then(() => true, () => false);
|
|
2916
|
+
if (!localExists) {
|
|
2917
|
+
if (baseline.cloudUrl) {
|
|
2918
|
+
try {
|
|
2919
|
+
const resp = await fetch(baseline.cloudUrl);
|
|
2920
|
+
if (resp.ok) {
|
|
2921
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
2922
|
+
tempBaselinePath = `${shotPath}.baseline-tmp.png`;
|
|
2923
|
+
await fs.writeFile(tempBaselinePath, buf);
|
|
2924
|
+
baselinePath = tempBaselinePath;
|
|
2925
|
+
logger.info({ screenId: screen.id }, "[crawler] VisualDiff baseline downloaded from cloud storage");
|
|
2926
|
+
} else {
|
|
2927
|
+
logger.warn({ screenId: screen.id, status: resp.status }, "[crawler] VisualDiff skipped \u2014 baseline cloud fetch returned non-200");
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
} catch (dlErr) {
|
|
2931
|
+
logger.warn({ screenId: screen.id, err: String(dlErr) }, "[crawler] VisualDiff skipped \u2014 baseline cloud download failed");
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
} else {
|
|
2935
|
+
logger.warn({ screenId: screen.id }, "[crawler] VisualDiff skipped \u2014 baseline not on disk and no cloudUrl available");
|
|
2936
|
+
return;
|
|
2282
2937
|
}
|
|
2283
2938
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2939
|
+
try {
|
|
2940
|
+
const { screenshotDiffPercent, gridLayoutDiff } = await import("./phash.js");
|
|
2941
|
+
const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
|
|
2942
|
+
screenshotDiffPercent(baselinePath, shotPath, baseline.ignoredRegions),
|
|
2943
|
+
gridLayoutDiff(baselinePath, shotPath, baseline.ignoredRegions),
|
|
2944
|
+
AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, "DESKTOP")
|
|
2945
|
+
]);
|
|
2946
|
+
let verdict = pHashVerdict(pHashDistance, changeLevel, [
|
|
2947
|
+
...layoutChanges,
|
|
2948
|
+
...elementShifts.map((s) => ({ location: "element", severity: s.severity, description: s.description }))
|
|
2949
|
+
]);
|
|
2950
|
+
if (verdict.changeType === "SIGNIFICANT" || verdict.changeType === "CRITICAL") {
|
|
2951
|
+
try {
|
|
2952
|
+
const regions = baseline.ignoredRegions ?? [];
|
|
2953
|
+
const [baselineBuffer, newBuffer] = regions.length > 0 ? await (async () => {
|
|
2954
|
+
const { applyMask } = await import("./phash.js");
|
|
2955
|
+
return Promise.all([applyMask(baselinePath, regions), applyMask(shotPath, regions)]);
|
|
2956
|
+
})() : await Promise.all([
|
|
2957
|
+
fs.readFile(baselinePath),
|
|
2958
|
+
fs.readFile(shotPath)
|
|
2959
|
+
]);
|
|
2960
|
+
const aiResult = await aiVisualCompare({
|
|
2961
|
+
baselineImageBase64: baselineBuffer.toString("base64"),
|
|
2962
|
+
newImageBase64: newBuffer.toString("base64"),
|
|
2963
|
+
screenName: screen.name,
|
|
2964
|
+
screenPurpose: "Application screen captured during automated crawl",
|
|
2965
|
+
tenantId: opts.tenantId,
|
|
2966
|
+
projectId: opts.projectId
|
|
2967
|
+
});
|
|
2968
|
+
verdict = mergeAiVisualVerdict(verdict, aiResult, screen.name);
|
|
2969
|
+
} catch (aiErr) {
|
|
2970
|
+
logger.warn({ screenId: screen.id, err: String(aiErr) }, "[crawler] AI visual compare failed, keeping pHash-only verdict");
|
|
2291
2971
|
}
|
|
2292
|
-
} catch {
|
|
2293
2972
|
}
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
if (storageCfg) {
|
|
2302
|
-
const key = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
|
|
2303
|
-
newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
|
|
2304
|
-
try {
|
|
2305
|
-
const { generatePixelmatchDiff } = await import("./phash.js");
|
|
2306
|
-
const diffBuffer = await generatePixelmatchDiff(baseline.storagePath, shotPath, baseline.ignoredRegions ?? []);
|
|
2307
|
-
const diffTmpPath = `${shotPath}.diff.png`;
|
|
2308
|
-
await fs.writeFile(diffTmpPath, diffBuffer);
|
|
2309
|
-
const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
|
|
2310
|
-
diffImageCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, diffTmpPath, diffKey);
|
|
2311
|
-
fs.unlink(diffTmpPath).catch(() => {
|
|
2312
|
-
});
|
|
2313
|
-
} catch (diffErr) {
|
|
2314
|
-
logger.warn({ screenId: screen.id, err: String(diffErr) }, "[crawler] pixelmatch diff generation failed, skipping");
|
|
2973
|
+
let { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
|
|
2974
|
+
if (changeType !== "NONE") {
|
|
2975
|
+
try {
|
|
2976
|
+
const project = await AppBrain.getProject(opts.tenantId, opts.projectId);
|
|
2977
|
+
const threshold = project?.visualDiffThreshold ?? null;
|
|
2978
|
+
if (threshold !== null && pHashDistance < threshold) {
|
|
2979
|
+
changeType = "NONE";
|
|
2315
2980
|
}
|
|
2981
|
+
} catch {
|
|
2316
2982
|
}
|
|
2317
|
-
} catch {
|
|
2318
2983
|
}
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2984
|
+
if (changeType !== "NONE") {
|
|
2985
|
+
let newScreenshotCloudUrl;
|
|
2986
|
+
let diffImageCloudUrl;
|
|
2987
|
+
try {
|
|
2988
|
+
const { getTenantStorageConfig, uploadScreenshotToTenantStorage, buildArtifactKey } = await import("@detiq/app-brain");
|
|
2989
|
+
const storageCfg = await getTenantStorageConfig(opts.tenantId).catch(() => null);
|
|
2990
|
+
if (storageCfg) {
|
|
2991
|
+
const key = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
|
|
2992
|
+
newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
|
|
2993
|
+
try {
|
|
2994
|
+
const { generatePixelmatchDiff } = await import("./phash.js");
|
|
2995
|
+
const diffBuffer = await generatePixelmatchDiff(baselinePath, shotPath, baseline.ignoredRegions ?? []);
|
|
2996
|
+
const diffTmpPath = `${shotPath}.diff.png`;
|
|
2997
|
+
await fs.writeFile(diffTmpPath, diffBuffer);
|
|
2998
|
+
const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
|
|
2999
|
+
diffImageCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, diffTmpPath, diffKey);
|
|
3000
|
+
fs.unlink(diffTmpPath).catch(() => {
|
|
3001
|
+
});
|
|
3002
|
+
} catch (diffErr) {
|
|
3003
|
+
logger.warn({ screenId: screen.id, err: String(diffErr) }, "[crawler] pixelmatch diff generation failed, skipping");
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
} catch {
|
|
3007
|
+
}
|
|
3008
|
+
await AppBrain.saveVisualDiff(opts.tenantId, {
|
|
3009
|
+
baselineId: baseline.id,
|
|
3010
|
+
newScreenshotPath: shotPath,
|
|
3011
|
+
newScreenshotCloudUrl,
|
|
3012
|
+
diffImageCloudUrl,
|
|
3013
|
+
diffScore: pHashDistance,
|
|
3014
|
+
changeType,
|
|
3015
|
+
changes: allChanges,
|
|
3016
|
+
recommendation,
|
|
3017
|
+
bugReportDraft,
|
|
3018
|
+
aiSummary
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
} finally {
|
|
3022
|
+
if (tempBaselinePath) fs.unlink(tempBaselinePath).catch(() => {
|
|
2330
3023
|
});
|
|
2331
3024
|
}
|
|
2332
3025
|
}
|
|
2333
3026
|
} catch (err) {
|
|
2334
|
-
|
|
2335
|
-
if (missing) {
|
|
2336
|
-
logger.warn({ screenId: screen.id, version: screen.version, err: String(err?.message ?? err) }, "[crawler] VisualDiff skipped \u2014 baseline screenshot not on disk (stored in cloud only). Run a full recrawl to regenerate baselines.");
|
|
2337
|
-
} else {
|
|
2338
|
-
console.warn("[crawler] VisualDiff pipeline failed:", err);
|
|
2339
|
-
}
|
|
3027
|
+
console.warn("[crawler] VisualDiff pipeline failed:", err);
|
|
2340
3028
|
}
|
|
2341
3029
|
})();
|
|
2342
3030
|
}
|
|
2343
3031
|
const linkMappings = await extractLinkMappings(page, origin);
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
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;
|
|
3032
|
+
const postExtractUrl = (() => {
|
|
3033
|
+
try {
|
|
3034
|
+
return page.url();
|
|
3035
|
+
} catch {
|
|
3036
|
+
return url;
|
|
3037
|
+
}
|
|
3038
|
+
})();
|
|
3039
|
+
const postExtractParsed = (() => {
|
|
3040
|
+
try {
|
|
3041
|
+
return new URL(postExtractUrl);
|
|
3042
|
+
} catch {
|
|
3043
|
+
return new URL(url);
|
|
2372
3044
|
}
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
3045
|
+
})();
|
|
3046
|
+
const driftedToAuthWall = !weWantedThisPage && (BFS_LOGIN_PATH.test(postExtractParsed.pathname) || BFS_ERROR_PATH.test(postExtractParsed.pathname));
|
|
3047
|
+
if (driftedToAuthWall) {
|
|
3048
|
+
logger.warn({ url, postExtractUrl }, "[crawler] Page drifted to auth wall during element extraction \u2014 skipping element save to prevent login-page element contamination");
|
|
3049
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3050
|
+
screenId: screen.id,
|
|
3051
|
+
jobId: opts.jobId,
|
|
3052
|
+
type: "AUTH_FAILURE",
|
|
3053
|
+
payload: { url, landed: postExtractUrl, reason: "Session expired during element extraction. Re-capture session and re-crawl this page." }
|
|
2380
3054
|
}).catch(() => {
|
|
2381
3055
|
});
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
3056
|
+
} else {
|
|
3057
|
+
if (elements.length === 0) {
|
|
3058
|
+
logger.warn({ url, screenId: screen.id }, "[crawler] extractElements returned 0 \u2014 preserving existing elements to avoid data loss");
|
|
3059
|
+
if (!revisitQueue.includes(url)) {
|
|
3060
|
+
revisitQueue.push(url);
|
|
3061
|
+
logger.info({ url }, "[crawler] 0-element page queued for re-visit with extended hydration wait");
|
|
3062
|
+
}
|
|
3063
|
+
} else {
|
|
3064
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
|
|
3065
|
+
});
|
|
3066
|
+
for (let _ei = 0; _ei < elements.length; _ei += 4) {
|
|
3067
|
+
const _batch = elements.slice(_ei, _ei + 4);
|
|
3068
|
+
await Promise.all(_batch.map(async (el) => {
|
|
3069
|
+
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);
|
|
3070
|
+
const label = el.meaning;
|
|
3071
|
+
const role = el.role ?? "unknown";
|
|
3072
|
+
const selectorHint = atByRole.get(role) ?? role;
|
|
3073
|
+
const selectors = [
|
|
3074
|
+
{ type: "role-label", value: `${role}:${label}`, confidence: 0.9 },
|
|
3075
|
+
{ type: "aria-label", value: label, confidence: 0.85 },
|
|
3076
|
+
{ type: "tag-hint", value: selectorHint, confidence: 0.6 }
|
|
3077
|
+
];
|
|
3078
|
+
AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
|
|
3079
|
+
}));
|
|
2394
3080
|
}
|
|
2395
3081
|
}
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
3082
|
+
for (const lm of linkMappings) {
|
|
3083
|
+
const safety = assessActionSafety(lm.label, domainProfile);
|
|
3084
|
+
if (!safety.allowed) {
|
|
3085
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3086
|
+
screenId: screen.id,
|
|
3087
|
+
jobId: opts.jobId,
|
|
3088
|
+
type: "ACTION_BLOCKED_BY_SAFETY_POLICY",
|
|
3089
|
+
payload: { label: lm.label, href: lm.href, decision: safety, profile: domainProfile.industry }
|
|
3090
|
+
}).catch(() => {
|
|
3091
|
+
});
|
|
3092
|
+
continue;
|
|
2405
3093
|
}
|
|
2406
|
-
|
|
2407
|
-
|
|
3094
|
+
pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
|
|
3095
|
+
AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
|
|
3096
|
+
domain: new URL(url).hostname,
|
|
3097
|
+
purpose: `navigate:${lm.label}`,
|
|
3098
|
+
fingerprint: sha256(`${canonicalize(url)}:${lm.label}:${canonicalize(lm.href)}`),
|
|
3099
|
+
steps: [{ action: "click", label: lm.label, targetUrl: lm.href, fromUrl: url }],
|
|
3100
|
+
successRate: 0.5
|
|
3101
|
+
}).catch(() => {
|
|
3102
|
+
});
|
|
3103
|
+
if (!selective) {
|
|
2408
3104
|
try {
|
|
2409
|
-
const
|
|
2410
|
-
|
|
3105
|
+
const targetPath = new URL(lm.href).pathname;
|
|
3106
|
+
const canonLmHref = canonicalize(lm.href);
|
|
3107
|
+
if (!SKIP_PATH.test(targetPath) && isUsefulNavigationUrl(lm.href, origin) && !queued.has(canonLmHref) && !visited.has(canonLmHref) && queued.size < maxScreens * 2) {
|
|
3108
|
+
if (robotsCache && !await robotsCache.isAllowed(lm.href)) continue;
|
|
3109
|
+
queued.add(canonLmHref);
|
|
3110
|
+
queue.push(lm.href, { parentElements: elements.length });
|
|
3111
|
+
edges.push({ fromUrl: url, toUrl: lm.href });
|
|
3112
|
+
logger.info({ fromUrl: url, newUrl: lm.href, label: lm.label, queueSize: queue.length }, "[crawler] anchor mapping added to queue");
|
|
3113
|
+
}
|
|
2411
3114
|
} catch {
|
|
2412
3115
|
}
|
|
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
3116
|
}
|
|
2418
3117
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
const routes = window.__zetaDiscoveredRoutes ?? [];
|
|
2423
|
-
window.__zetaDiscoveredRoutes = [];
|
|
2424
|
-
return routes;
|
|
2425
|
-
}).catch(() => []);
|
|
2426
|
-
for (const route of jsRoutes) {
|
|
3118
|
+
if (!selective) {
|
|
3119
|
+
for (const href of nextLinks) {
|
|
3120
|
+
edges.push({ fromUrl: url, toUrl: href });
|
|
2427
3121
|
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
|
-
}
|
|
3122
|
+
const hrefParams = new URL(href).searchParams;
|
|
3123
|
+
const actionVal = hrefParams.get("action") ?? "";
|
|
3124
|
+
if (/^yith-|^wc-|^vc_|^elementor/.test(actionVal)) continue;
|
|
2435
3125
|
} catch {
|
|
2436
3126
|
}
|
|
3127
|
+
const canonHref = canonicalize(href);
|
|
3128
|
+
if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 2) {
|
|
3129
|
+
try {
|
|
3130
|
+
const hrefPath = new URL(href).pathname;
|
|
3131
|
+
if (isGeneratedDynamicPath(hrefPath)) continue;
|
|
3132
|
+
} catch {
|
|
3133
|
+
}
|
|
3134
|
+
if (robotsCache && !await robotsCache.isAllowed(href)) continue;
|
|
3135
|
+
queued.add(canonHref);
|
|
3136
|
+
queue.push(href, { parentElements: elements.length });
|
|
3137
|
+
logger.info({ newUrl: href, queueSize: queue.length }, "[crawler] new URL added to queue");
|
|
3138
|
+
}
|
|
2437
3139
|
}
|
|
2438
|
-
|
|
2439
|
-
}
|
|
2440
|
-
if (preDomCount < 25 && !revisitQueue.includes(url)) {
|
|
3140
|
+
logger.info({ fromUrl: url, discovered: nextLinks.length, queueAfter: queue.length, visitedSoFar: visited.size }, "[crawler] link processing done");
|
|
2441
3141
|
try {
|
|
2442
|
-
const
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
3142
|
+
const jsRoutes = await page.evaluate(() => {
|
|
3143
|
+
const routes = window.__zetaDiscoveredRoutes ?? [];
|
|
3144
|
+
window.__zetaDiscoveredRoutes = [];
|
|
3145
|
+
return routes;
|
|
3146
|
+
}).catch(() => []);
|
|
3147
|
+
for (const route of jsRoutes) {
|
|
3148
|
+
try {
|
|
3149
|
+
const abs = route.startsWith("http") ? route : `${origin}${route.startsWith("/") ? "" : "/"}${route}`;
|
|
3150
|
+
const canonRoute = canonicalize(abs);
|
|
3151
|
+
if (!queued.has(canonRoute) && !visited.has(canonRoute)) {
|
|
3152
|
+
queued.add(canonRoute);
|
|
3153
|
+
queue.push(abs);
|
|
3154
|
+
logger.info({ route: abs }, "[crawler] history.pushState: discovered SPA route");
|
|
3155
|
+
}
|
|
3156
|
+
} catch {
|
|
3157
|
+
}
|
|
2448
3158
|
}
|
|
2449
3159
|
} catch {
|
|
2450
3160
|
}
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
3161
|
+
const asyncContentCheckMinDom = parseInt(process.env.CRAWLER_ASYNC_CONTENT_MIN_DOM ?? "") || 25;
|
|
3162
|
+
if (preDomCount < asyncContentCheckMinDom && !revisitQueue.includes(url)) {
|
|
3163
|
+
try {
|
|
3164
|
+
const postDomCount = await page.evaluate(
|
|
3165
|
+
() => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
|
|
3166
|
+
).catch(() => 0);
|
|
3167
|
+
const asyncGrowthRatio = parseFloat(process.env.CRAWLER_ASYNC_GROWTH_RATIO ?? "") || 1.3;
|
|
3168
|
+
const asyncGrowthFloor = parseInt(process.env.CRAWLER_ASYNC_GROWTH_FLOOR ?? "") || 4;
|
|
3169
|
+
if (postDomCount > preDomCount * asyncGrowthRatio + asyncGrowthFloor) {
|
|
3170
|
+
revisitQueue.push(url);
|
|
3171
|
+
logger.info({ url, preDomCount, postDomCount }, "[P3a] Async content detected \u2014 scheduled for re-visit");
|
|
2461
3172
|
}
|
|
3173
|
+
} catch {
|
|
2462
3174
|
}
|
|
2463
|
-
} catch {
|
|
2464
3175
|
}
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
3176
|
+
if (visited.size <= (parseInt(process.env.CRAWLER_SPA_ROUTER_DETECT_LIMIT ?? "") || 5)) {
|
|
3177
|
+
try {
|
|
3178
|
+
const jsDetectedRoutes = await detectJsRoutes(page, origin);
|
|
3179
|
+
for (const route of jsDetectedRoutes) {
|
|
3180
|
+
const canonRoute = canonicalize(route);
|
|
3181
|
+
if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 2) {
|
|
3182
|
+
queued.add(canonRoute);
|
|
3183
|
+
queue.push(route, { parentElements: elements.length });
|
|
3184
|
+
logger.info({ route }, "[P2] JS router: discovered route added to queue");
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
} catch {
|
|
2475
3188
|
}
|
|
2476
|
-
} catch {
|
|
2477
3189
|
}
|
|
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 });
|
|
3190
|
+
for (const relPath of [...xhrDiscoveredPaths]) {
|
|
3191
|
+
xhrDiscoveredPaths.delete(relPath);
|
|
3192
|
+
try {
|
|
3193
|
+
const abs = `${origin}${relPath}`;
|
|
3194
|
+
const canon = canonicalize(abs);
|
|
3195
|
+
if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
|
|
3196
|
+
queued.add(canon);
|
|
3197
|
+
queue.push(abs);
|
|
3198
|
+
logger.info({ url: abs }, "[P4] XHR: page URL from JSON response added to queue");
|
|
2494
3199
|
}
|
|
3200
|
+
} catch {
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
if (nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? "") || 5) && opts.skyvernConfig) {
|
|
3204
|
+
try {
|
|
3205
|
+
logger.info({ url }, "Gap 5: thin page detected \u2014 asking Skyvern to discover navigation URLs");
|
|
3206
|
+
const skyvernNav = await skyvernNavigate(
|
|
3207
|
+
url,
|
|
3208
|
+
["Find and return all navigation links, menu items, and page URLs accessible from this screen"],
|
|
3209
|
+
opts.skyvernConfig
|
|
3210
|
+
);
|
|
3211
|
+
for (const skyvernUrl of skyvernNav.visitedUrls) {
|
|
3212
|
+
const canonSkyvernUrl = canonicalize(skyvernUrl);
|
|
3213
|
+
if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 2) {
|
|
3214
|
+
logger.info({ url: skyvernUrl }, "Gap 5: Skyvern-discovered URL added to ZeTa crawl queue");
|
|
3215
|
+
queued.add(canonSkyvernUrl);
|
|
3216
|
+
queue.push(skyvernUrl);
|
|
3217
|
+
pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: "Skyvern-discovered link", targetUrl: skyvernUrl });
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
} catch {
|
|
2495
3221
|
}
|
|
2496
|
-
} catch {
|
|
2497
3222
|
}
|
|
2498
3223
|
}
|
|
2499
3224
|
}
|
|
@@ -2569,7 +3294,8 @@ ${snippet}`,
|
|
|
2569
3294
|
};
|
|
2570
3295
|
}
|
|
2571
3296
|
const successfulScreenCount = discovered.filter((d) => d.screenId).length;
|
|
2572
|
-
|
|
3297
|
+
const skyvernMinScreens = parseInt(process.env.CRAWL_SKYVERN_MIN_SCREENS ?? "") || 10;
|
|
3298
|
+
if (successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
|
|
2573
3299
|
await onProgress?.(78, "Few screens found \u2014 using Skyvern to discover additional navigation URLs");
|
|
2574
3300
|
const currentUrl = page.url();
|
|
2575
3301
|
const skyvernNav = await skyvernNavigate(
|
|
@@ -2587,16 +3313,16 @@ ${snippet}`,
|
|
|
2587
3313
|
if (visited.size >= maxScreens) break;
|
|
2588
3314
|
try {
|
|
2589
3315
|
await page.goto(skyvernUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
|
|
2590
|
-
await
|
|
3316
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2591
3317
|
const skyvernName = new URL(skyvernUrl).pathname.split("/").filter(Boolean).pop() ?? "Screen";
|
|
2592
3318
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, skyvernName, skyvernUrl, opts.jobId, "SKYVERN_AI");
|
|
2593
3319
|
const shotPath = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
|
|
2594
|
-
await waitForScreenshotReady(page);
|
|
3320
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
2595
3321
|
await page.screenshot({ path: shotPath, fullPage: true });
|
|
2596
3322
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP");
|
|
2597
3323
|
const [elems, links, linkMappings] = await Promise.all([
|
|
2598
|
-
extractElements(stagehand, page, elementLLM ?? void 0),
|
|
2599
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0),
|
|
3324
|
+
extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
|
|
3325
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0),
|
|
2600
3326
|
extractLinkMappings(page, origin)
|
|
2601
3327
|
]);
|
|
2602
3328
|
for (const el of elems) {
|
|
@@ -2626,15 +3352,15 @@ ${snippet}`,
|
|
|
2626
3352
|
if (!isUsefulNavigationUrl(href, origin)) continue;
|
|
2627
3353
|
try {
|
|
2628
3354
|
await page.goto(href, { waitUntil: "domcontentloaded", timeout: 2e4 });
|
|
2629
|
-
await
|
|
3355
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
2630
3356
|
const nm = new URL(href).pathname.split("/").filter(Boolean).pop() ?? "Screen";
|
|
2631
3357
|
const sc = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, "SKYVERN_AI");
|
|
2632
3358
|
const sp = `${opts.screenshotDir ?? "/tmp"}/${sc.id}-v${sc.version}.png`;
|
|
2633
|
-
await waitForScreenshotReady(page);
|
|
3359
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
2634
3360
|
await page.screenshot({ path: sp, fullPage: true });
|
|
2635
3361
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, "DESKTOP");
|
|
2636
3362
|
const [elems2, linkMappings2] = await Promise.all([
|
|
2637
|
-
extractElements(stagehand, page, elementLLM ?? void 0),
|
|
3363
|
+
extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
|
|
2638
3364
|
extractLinkMappings(page, origin)
|
|
2639
3365
|
]);
|
|
2640
3366
|
for (const el of elems2) {
|
|
@@ -2680,28 +3406,51 @@ ${snippet}`,
|
|
|
2680
3406
|
);
|
|
2681
3407
|
}
|
|
2682
3408
|
} catch (err) {
|
|
2683
|
-
|
|
3409
|
+
const msg = String(err);
|
|
3410
|
+
logger.warn({ err: msg }, "[P1] URL pattern completion failed \u2014 non-fatal, continuing");
|
|
3411
|
+
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
3412
|
}
|
|
2685
3413
|
if (revisitQueue.length > 0) {
|
|
2686
3414
|
await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content\u2026`);
|
|
2687
3415
|
logger.info({ count: revisitQueue.length }, "[P3a] Starting async content re-visit pass");
|
|
2688
3416
|
for (const revisitUrl of revisitQueue.slice(0, 10)) {
|
|
2689
|
-
const
|
|
2690
|
-
|
|
3417
|
+
const rawScreenId = visited.get(canonicalize(revisitUrl));
|
|
3418
|
+
const isAuthWallRevisit = rawScreenId === "_auth_wall_";
|
|
3419
|
+
if (!rawScreenId) continue;
|
|
2691
3420
|
try {
|
|
2692
3421
|
await page.goto(revisitUrl, { waitUntil: "networkidle", timeout: 25e3 });
|
|
2693
|
-
await
|
|
3422
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
3423
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
3424
|
+
const landedUrl = (rawPage ?? page).url?.() ?? revisitUrl;
|
|
3425
|
+
const landedCanon = canonicalize(landedUrl);
|
|
3426
|
+
let existingScreenId = isAuthWallRevisit ? visited.get(landedCanon) ?? null : rawScreenId;
|
|
3427
|
+
if (isAuthWallRevisit && (!existingScreenId || existingScreenId.startsWith("_")) && !visited.has(landedCanon)) {
|
|
3428
|
+
const pathParts = new URL(landedUrl).pathname.split("/").filter(Boolean);
|
|
3429
|
+
const screenName = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
|
|
3430
|
+
const newScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, landedUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
|
|
3431
|
+
if (newScreen) {
|
|
3432
|
+
visited.set(landedCanon, newScreen.id);
|
|
3433
|
+
existingScreenId = newScreen.id;
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
if (!existingScreenId || existingScreenId.startsWith("_")) {
|
|
3437
|
+
logger.warn({ url: revisitUrl, landed: landedUrl }, "[P3a] Re-visit skipped \u2014 could not resolve screen ID");
|
|
3438
|
+
continue;
|
|
3439
|
+
}
|
|
2694
3440
|
const shotPath2 = `${opts.screenshotDir ?? "/tmp"}/${existingScreenId}-revisit.png`;
|
|
2695
|
-
await waitForScreenshotReady(page);
|
|
2696
3441
|
await page.screenshot({ path: shotPath2, fullPage: true });
|
|
2697
|
-
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP").catch(() => {
|
|
3442
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP", void 0).catch(() => {
|
|
2698
3443
|
});
|
|
2699
|
-
const revisitElements = await extractElements(stagehand, page, elementLLM ?? void 0).catch(() => []);
|
|
2700
|
-
|
|
2701
|
-
await AppBrain.
|
|
3444
|
+
const revisitElements = await extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []);
|
|
3445
|
+
if (revisitElements.length > 0) {
|
|
3446
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, existingScreenId).catch(() => {
|
|
2702
3447
|
});
|
|
3448
|
+
for (const el of revisitElements) {
|
|
3449
|
+
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(() => {
|
|
3450
|
+
});
|
|
3451
|
+
}
|
|
2703
3452
|
}
|
|
2704
|
-
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false).catch(() => []);
|
|
3453
|
+
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []);
|
|
2705
3454
|
for (const href of revisitLinks) {
|
|
2706
3455
|
const canon = canonicalize(href);
|
|
2707
3456
|
if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
|
|
@@ -2709,13 +3458,68 @@ ${snippet}`,
|
|
|
2709
3458
|
queue.push(href);
|
|
2710
3459
|
}
|
|
2711
3460
|
}
|
|
2712
|
-
logger.info({ url: revisitUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, "[P3a] Re-visit complete");
|
|
3461
|
+
logger.info({ url: revisitUrl, landed: landedUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, "[P3a] Re-visit complete");
|
|
2713
3462
|
} catch (err) {
|
|
2714
3463
|
logger.warn({ url: revisitUrl, err: String(err) }, "[P3a] Re-visit failed \u2014 non-fatal");
|
|
2715
3464
|
}
|
|
2716
3465
|
}
|
|
2717
3466
|
}
|
|
2718
|
-
if (visited.size
|
|
3467
|
+
if (queue.length > 0 && visited.size < maxScreens) {
|
|
3468
|
+
const p3bCount = Math.min(queue.length, maxScreens - visited.size);
|
|
3469
|
+
logger.info({ newLinks: p3bCount }, "[P3b] Extended BFS \u2014 processing links discovered during revisit pass");
|
|
3470
|
+
await onProgress?.(82, `Extended crawl: ${p3bCount} newly discovered page(s)\u2026`);
|
|
3471
|
+
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
3472
|
+
const href = queue.shift();
|
|
3473
|
+
if (!href) break;
|
|
3474
|
+
const canonHref = canonicalize(href);
|
|
3475
|
+
if (visited.has(canonHref)) continue;
|
|
3476
|
+
if (!isUsefulNavigationUrl(href, origin)) continue;
|
|
3477
|
+
visited.set(canonHref, "_pending_");
|
|
3478
|
+
try {
|
|
3479
|
+
await page.goto(href, { waitUntil: "networkidle", timeout: 25e3 }).catch(() => {
|
|
3480
|
+
});
|
|
3481
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
3482
|
+
const pathParts = new URL(href).pathname.split("/").filter(Boolean);
|
|
3483
|
+
const nm = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
|
|
3484
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, "LINK_FOLLOW");
|
|
3485
|
+
visited.set(canonHref, screen.id);
|
|
3486
|
+
const sp = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
|
|
3487
|
+
await page.screenshot({ path: sp, fullPage: true }).catch(() => {
|
|
3488
|
+
});
|
|
3489
|
+
const cloudUrl3 = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => "");
|
|
3490
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, sp, screen.version, "DESKTOP", cloudUrl3).catch(() => {
|
|
3491
|
+
});
|
|
3492
|
+
const [p3bElems, p3bLinks] = await Promise.all([
|
|
3493
|
+
extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
|
|
3494
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).catch(() => [])
|
|
3495
|
+
]);
|
|
3496
|
+
if (p3bElems.length > 0) {
|
|
3497
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
|
|
3498
|
+
});
|
|
3499
|
+
for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 4) {
|
|
3500
|
+
const _b3 = p3bElems.slice(_ei3, _ei3 + 4);
|
|
3501
|
+
await Promise.all(_b3.map(
|
|
3502
|
+
(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(() => {
|
|
3503
|
+
})
|
|
3504
|
+
));
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
for (const link of p3bLinks) {
|
|
3508
|
+
const c = canonicalize(link);
|
|
3509
|
+
if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 2) {
|
|
3510
|
+
queued.add(c);
|
|
3511
|
+
queue.push(link);
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
discovered.push({ url: href, screenId: screen.id, elements: p3bElems.length, version: screen.version });
|
|
3515
|
+
logger.info({ url: href, elements: p3bElems.length, newLinks: p3bLinks.length }, "[P3b] Extended BFS screen captured");
|
|
3516
|
+
} catch (err) {
|
|
3517
|
+
logger.warn({ url: href, err: String(err) }, "[P3b] Extended BFS URL failed");
|
|
3518
|
+
visited.set(canonHref, "_error_");
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
if (visited.size >= (parseInt(process.env.CRAWLER_GAP_PASS_MIN_SCREENS ?? "") || 3)) {
|
|
2719
3523
|
try {
|
|
2720
3524
|
await onProgress?.(80, "Running gap analysis\u2026");
|
|
2721
3525
|
const llmCfgForGap = await getCachedCrawlLLM(opts.tenantId);
|
|
@@ -2739,7 +3543,7 @@ ${snippet}`,
|
|
|
2739
3543
|
});
|
|
2740
3544
|
logger.info({ confirmed: confirmedGaps.length, total: gapCandidates.length }, "[P6] Gap pass: visiting confirmed gap URLs");
|
|
2741
3545
|
const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
|
|
2742
|
-
for (const gap of confirmedGaps.slice(0, 8)) {
|
|
3546
|
+
for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? "") || 8)) {
|
|
2743
3547
|
const gapUrl = gap.confirmedUrl;
|
|
2744
3548
|
if (visited.size >= maxScreens) break;
|
|
2745
3549
|
try {
|
|
@@ -2748,16 +3552,16 @@ ${snippet}`,
|
|
|
2748
3552
|
logger.warn({ gapUrl, landed: page.url() }, "[P6] Gap URL redirected to login \u2014 skipping");
|
|
2749
3553
|
continue;
|
|
2750
3554
|
}
|
|
2751
|
-
await
|
|
3555
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2752
3556
|
const gapName = gap.category;
|
|
2753
3557
|
const gapScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, gapName, gapUrl, opts.jobId, "ENTRY_POINT");
|
|
2754
3558
|
const gapShot = `${opts.screenshotDir ?? "/tmp"}/${gapScreen.id}-v${gapScreen.version}.png`;
|
|
2755
|
-
await waitForScreenshotReady(page);
|
|
3559
|
+
await waitForScreenshotReady(page, rawPage ?? void 0);
|
|
2756
3560
|
await page.screenshot({ path: gapShot, fullPage: true });
|
|
2757
3561
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, "DESKTOP");
|
|
2758
3562
|
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(() => []),
|
|
3563
|
+
extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
|
|
3564
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []),
|
|
2761
3565
|
extractLinkMappings(page, origin).catch(() => [])
|
|
2762
3566
|
]);
|
|
2763
3567
|
for (const el of gapElems) {
|
|
@@ -2785,11 +3589,14 @@ ${snippet}`,
|
|
|
2785
3589
|
logger.info({ total: gapCandidates.length }, "[P6] Gap pass: no confirmed gaps found");
|
|
2786
3590
|
}
|
|
2787
3591
|
} catch (err) {
|
|
2788
|
-
|
|
3592
|
+
const msg = String(err);
|
|
3593
|
+
logger.warn({ err: msg }, "[P6] Gap pass failed \u2014 non-fatal, continuing");
|
|
3594
|
+
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
3595
|
}
|
|
2790
3596
|
}
|
|
2791
3597
|
if (!selective && visited.size > 0) {
|
|
2792
|
-
const
|
|
3598
|
+
const VISIT_MARKER_RE = /^_|^pre-auth$|^skipped$/;
|
|
3599
|
+
const discoveredUrls = Array.from(visited.entries()).filter(([, v]) => typeof v === "string" && !VISIT_MARKER_RE.test(v)).map(([k]) => k);
|
|
2793
3600
|
await AppBrain.markRemovedScreens(opts.tenantId, opts.projectId, discoveredUrls, opts.jobId);
|
|
2794
3601
|
}
|
|
2795
3602
|
await onProgress?.(82, "Saving app map");
|
|
@@ -2964,6 +3771,10 @@ ${snippet}`,
|
|
|
2964
3771
|
ignoreHTTPSErrors: true,
|
|
2965
3772
|
...mainStorageState ? { storageState: mainStorageState } : {}
|
|
2966
3773
|
});
|
|
3774
|
+
mobileCtx.on("download", (dl) => {
|
|
3775
|
+
dl.cancel().catch(() => {
|
|
3776
|
+
});
|
|
3777
|
+
});
|
|
2967
3778
|
const mobilePage = await mobileCtx.newPage();
|
|
2968
3779
|
for (const disc of discovered) {
|
|
2969
3780
|
if (!disc.screenId) continue;
|
|
@@ -2976,11 +3787,13 @@ ${snippet}`,
|
|
|
2976
3787
|
await mobilePage.addStyleTag({ content: "*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }" });
|
|
2977
3788
|
} catch {
|
|
2978
3789
|
}
|
|
3790
|
+
await infiniteScrollToBottom(mobilePage, { maxScrolls: 20, scrollDelay: 300, stabilizeMs: 400 }, mobilePage).catch(() => {
|
|
3791
|
+
});
|
|
2979
3792
|
const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${disc.screenId}-${profile.toLowerCase()}-v${disc.version}.png`);
|
|
2980
|
-
await waitForScreenshotReady(mobilePage);
|
|
3793
|
+
await waitForScreenshotReady(mobilePage, mobilePage);
|
|
2981
3794
|
const [, vpElements] = await Promise.all([
|
|
2982
3795
|
mobilePage.screenshot({ path: shotPath, fullPage: true }),
|
|
2983
|
-
domFallbackElements(mobilePage)
|
|
3796
|
+
domFallbackElements(mobilePage, mobilePage)
|
|
2984
3797
|
]);
|
|
2985
3798
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
2986
3799
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version, profile, cloudUrl);
|
|
@@ -3033,6 +3846,22 @@ ${snippet}`,
|
|
|
3033
3846
|
const probeInputs = discovered.filter((d) => d.screenId).map((d) => ({ url: d.url, screenId: d.screenId, elements: d.elementFacts ?? [] }));
|
|
3034
3847
|
await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
|
|
3035
3848
|
}
|
|
3849
|
+
try {
|
|
3850
|
+
await onProgress?.(90, "Generating state graph coverage report");
|
|
3851
|
+
const stateGraph = new ScreenTransitionGraph();
|
|
3852
|
+
const coveragePayload = {
|
|
3853
|
+
stateGraph: stateGraph.getCoverageReport(),
|
|
3854
|
+
totalScreensCrawled: discovered.filter((d) => !!d.screenId).length
|
|
3855
|
+
};
|
|
3856
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3857
|
+
jobId: opts.jobId,
|
|
3858
|
+
type: "CRAWL_COVERAGE_REPORT",
|
|
3859
|
+
payload: coveragePayload
|
|
3860
|
+
}).catch(() => {
|
|
3861
|
+
});
|
|
3862
|
+
} catch (extErr) {
|
|
3863
|
+
logger.warn({ err: String(extErr) }, "[coverage-report] non-fatal error");
|
|
3864
|
+
}
|
|
3036
3865
|
await onProgress?.(97, "Running post-crawl analysis");
|
|
3037
3866
|
const successScreens = discovered.filter((d) => !!d.screenId);
|
|
3038
3867
|
;
|
|
@@ -3113,6 +3942,14 @@ ${snippet}`,
|
|
|
3113
3942
|
const hasMoreSegments = pendingUrls.length > 0;
|
|
3114
3943
|
const screensSucceeded = discovered.filter((d) => !!d.screenId).length;
|
|
3115
3944
|
const screensErrored = discovered.filter((d) => !!d.error && !d.screenId).length;
|
|
3945
|
+
if (screensSucceeded >= 3) {
|
|
3946
|
+
const screensWithElements = discovered.filter((d) => (d.elements ?? 0) > 0 && d.screenId).length;
|
|
3947
|
+
if (screensWithElements === 0) {
|
|
3948
|
+
crawlWarnings.push(
|
|
3949
|
+
`element-extraction: 0 elements found across all ${screensSucceeded} screens \u2014 DOM evaluate may be failing (check server logs for "page.evaluate threw" warnings)`
|
|
3950
|
+
);
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3116
3953
|
return {
|
|
3117
3954
|
screensFound: screensSucceeded,
|
|
3118
3955
|
// successful captures only (not errors)
|
|
@@ -3129,8 +3966,9 @@ ${snippet}`,
|
|
|
3129
3966
|
segmentIndex: segIdx,
|
|
3130
3967
|
// which segment this was
|
|
3131
3968
|
hasMoreSegments,
|
|
3132
|
-
domHashes: Array.from(seenDomHashes)
|
|
3969
|
+
domHashes: Array.from(seenDomHashes),
|
|
3133
3970
|
// carry forward to next segment to avoid re-screenshotting
|
|
3971
|
+
crawlWarnings: crawlWarnings.length > 0 ? crawlWarnings : void 0
|
|
3134
3972
|
};
|
|
3135
3973
|
} finally {
|
|
3136
3974
|
if (stagehandPromptTokens > 0 || stagehandCompletionTokens > 0) {
|
|
@@ -3165,8 +4003,8 @@ ${snippet}`,
|
|
|
3165
4003
|
}
|
|
3166
4004
|
}
|
|
3167
4005
|
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) => {
|
|
4006
|
+
async function extractElements(stagehand, page, llmCfg, rawPage) {
|
|
4007
|
+
const domElements = await domFallbackElements(page, rawPage).catch((err) => {
|
|
3170
4008
|
console.warn("[crawler] domFallbackElements failed (page context issue or DOM error):", err?.message ?? err);
|
|
3171
4009
|
return [];
|
|
3172
4010
|
});
|
|
@@ -3204,7 +4042,10 @@ async function extractElements(stagehand, page, llmCfg) {
|
|
|
3204
4042
|
dynamicElements = [];
|
|
3205
4043
|
}
|
|
3206
4044
|
}
|
|
3207
|
-
const contentElements = await contentFallbackElements(page)
|
|
4045
|
+
const contentElements = await contentFallbackElements(page).catch((err) => {
|
|
4046
|
+
console.warn("[crawler] contentFallbackElements failed (page context issue):", err?.message ?? err);
|
|
4047
|
+
return [];
|
|
4048
|
+
});
|
|
3208
4049
|
const crossSourceDedup = (arr) => {
|
|
3209
4050
|
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
4051
|
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 +4086,9 @@ async function extractElements(stagehand, page, llmCfg) {
|
|
|
3245
4086
|
return domElements;
|
|
3246
4087
|
}
|
|
3247
4088
|
}
|
|
3248
|
-
async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false) {
|
|
4089
|
+
async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false, rawPage) {
|
|
3249
4090
|
const navExpanded = await expandNavMenus(page);
|
|
3250
|
-
const domLinks = await domFallbackLinks(page, origin);
|
|
4091
|
+
const domLinks = await domFallbackLinks(page, origin, rawPage);
|
|
3251
4092
|
const interactiveLinks = await interactAndCollectLinks(page, origin);
|
|
3252
4093
|
if (navExpanded) {
|
|
3253
4094
|
page.keyboard?.press("Escape").catch(() => {
|
|
@@ -3294,8 +4135,8 @@ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false) {
|
|
|
3294
4135
|
return allDomLinks;
|
|
3295
4136
|
}
|
|
3296
4137
|
}
|
|
3297
|
-
async function domFallbackElements(page) {
|
|
3298
|
-
|
|
4138
|
+
async function domFallbackElements(page, rawPage) {
|
|
4139
|
+
function _browserEval() {
|
|
3299
4140
|
const out = [];
|
|
3300
4141
|
const seen = /* @__PURE__ */ new Set();
|
|
3301
4142
|
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 +4152,7 @@ async function domFallbackElements(page) {
|
|
|
3311
4152
|
}
|
|
3312
4153
|
return results;
|
|
3313
4154
|
};
|
|
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"],
|
|
4155
|
+
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
4156
|
querySelectorAllDeep(document, selector).forEach((el) => {
|
|
3316
4157
|
const style = window.getComputedStyle(el);
|
|
3317
4158
|
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || style.pointerEvents === "none") return;
|
|
@@ -3323,11 +4164,10 @@ async function domFallbackElements(page) {
|
|
|
3323
4164
|
const tag = el.tagName.toLowerCase();
|
|
3324
4165
|
const ariaRole = el.getAttribute("role");
|
|
3325
4166
|
const isActionEl = tag === "button" || tag === "a" || ariaRole === "button";
|
|
3326
|
-
if (tag === "a"
|
|
4167
|
+
if (tag === "a") {
|
|
3327
4168
|
let p = el.parentElement;
|
|
3328
4169
|
while (p) {
|
|
3329
|
-
|
|
3330
|
-
if (["header", "footer"].includes(ptag)) return;
|
|
4170
|
+
if (p.tagName?.toLowerCase() === "footer") return;
|
|
3331
4171
|
p = p.parentElement;
|
|
3332
4172
|
}
|
|
3333
4173
|
}
|
|
@@ -3472,7 +4312,48 @@ async function domFallbackElements(page) {
|
|
|
3472
4312
|
const priority = (e) => ["email", "password", "text", "tel", "date", "number", "select", "textarea", "url", "file"].includes(e.role) ? 0 : 1;
|
|
3473
4313
|
out.sort((a, b) => priority(a) - priority(b));
|
|
3474
4314
|
return out;
|
|
3475
|
-
}
|
|
4315
|
+
}
|
|
4316
|
+
const _browserEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_browserEval.toString()})()})();`;
|
|
4317
|
+
if (rawPage && typeof rawPage.context === "function") {
|
|
4318
|
+
try {
|
|
4319
|
+
const ctx = rawPage.context();
|
|
4320
|
+
if (typeof ctx?.newCDPSession === "function") {
|
|
4321
|
+
const cdp = await ctx.newCDPSession(rawPage);
|
|
4322
|
+
try {
|
|
4323
|
+
const evalResult = await cdp.send("Runtime.evaluate", {
|
|
4324
|
+
expression: _browserEvalStr,
|
|
4325
|
+
returnByValue: true,
|
|
4326
|
+
awaitPromise: false
|
|
4327
|
+
});
|
|
4328
|
+
if (evalResult?.exceptionDetails) {
|
|
4329
|
+
console.warn("[crawler] domFallbackElements CDP primary error:", evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
|
|
4330
|
+
} else if (Array.isArray(evalResult?.result?.value)) {
|
|
4331
|
+
return evalResult.result.value.map((e) => ({ ...e, isInteractive: true }));
|
|
4332
|
+
}
|
|
4333
|
+
} finally {
|
|
4334
|
+
cdp.detach().catch(() => {
|
|
4335
|
+
});
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
} catch (cdpPrimaryErr) {
|
|
4339
|
+
console.warn("[crawler] domFallbackElements CDP primary failed, trying page.evaluate:", cdpPrimaryErr?.message ?? String(cdpPrimaryErr));
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
const evalPage = rawPage ?? page;
|
|
4343
|
+
const _doEvaluate = () => evalPage.evaluate(_browserEvalStr);
|
|
4344
|
+
let result;
|
|
4345
|
+
try {
|
|
4346
|
+
result = await _doEvaluate();
|
|
4347
|
+
} catch (err1) {
|
|
4348
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
4349
|
+
try {
|
|
4350
|
+
result = await _doEvaluate();
|
|
4351
|
+
} catch (err2) {
|
|
4352
|
+
console.warn("[crawler] domFallbackElements page.evaluate threw (attempt 2):", err2?.message ?? String(err2));
|
|
4353
|
+
return [];
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
return (result ?? []).map((e) => ({ ...e, isInteractive: true }));
|
|
3476
4357
|
}
|
|
3477
4358
|
async function contentFallbackElements(page) {
|
|
3478
4359
|
return page.evaluate(() => {
|
|
@@ -3567,7 +4448,26 @@ async function contentFallbackElements(page) {
|
|
|
3567
4448
|
const s = window.getComputedStyle(el);
|
|
3568
4449
|
return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0";
|
|
3569
4450
|
};
|
|
3570
|
-
|
|
4451
|
+
const navSel = [
|
|
4452
|
+
"nav a",
|
|
4453
|
+
'[role="navigation"] a',
|
|
4454
|
+
"header a",
|
|
4455
|
+
"header nav a",
|
|
4456
|
+
"footer nav a",
|
|
4457
|
+
'[aria-label*="navigation" i] a',
|
|
4458
|
+
'[aria-label*="menu" i] a',
|
|
4459
|
+
'[aria-label*="sidebar" i] a',
|
|
4460
|
+
"aside a",
|
|
4461
|
+
'[role="complementary"] a',
|
|
4462
|
+
'[class*="sidebar" i] a',
|
|
4463
|
+
'[class*="side-nav" i] a',
|
|
4464
|
+
'[class*="sidenav" i] a',
|
|
4465
|
+
'[class*="nav-menu" i] a',
|
|
4466
|
+
'[class*="navmenu" i] a',
|
|
4467
|
+
"[data-nav] a"
|
|
4468
|
+
].join(", ");
|
|
4469
|
+
const navLinksFoundCount = { n: 0 };
|
|
4470
|
+
document.querySelectorAll(navSel).forEach((a) => {
|
|
3571
4471
|
if (!isNavVisible(a)) return;
|
|
3572
4472
|
const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
|
|
3573
4473
|
const href = a.href;
|
|
@@ -3577,6 +4477,7 @@ async function contentFallbackElements(page) {
|
|
|
3577
4477
|
while (el) {
|
|
3578
4478
|
const tag = el.tagName?.toLowerCase();
|
|
3579
4479
|
const role = el.getAttribute?.("role")?.toLowerCase();
|
|
4480
|
+
const cl = (el.className ?? "").toLowerCase();
|
|
3580
4481
|
if (tag === "header" || role === "banner") {
|
|
3581
4482
|
landmark = "Header";
|
|
3582
4483
|
break;
|
|
@@ -3585,6 +4486,10 @@ async function contentFallbackElements(page) {
|
|
|
3585
4486
|
landmark = "Footer";
|
|
3586
4487
|
break;
|
|
3587
4488
|
}
|
|
4489
|
+
if (tag === "aside" || role === "complementary" || cl.includes("sidebar") || cl.includes("sidenav") || cl.includes("side-nav")) {
|
|
4490
|
+
landmark = "Sidebar";
|
|
4491
|
+
break;
|
|
4492
|
+
}
|
|
3588
4493
|
if (tag === "nav" || role === "navigation") {
|
|
3589
4494
|
const directLabel = el.getAttribute("aria-label")?.trim() ?? "";
|
|
3590
4495
|
const labelId = el.getAttribute("aria-labelledby");
|
|
@@ -3595,7 +4500,44 @@ async function contentFallbackElements(page) {
|
|
|
3595
4500
|
el = el.parentElement;
|
|
3596
4501
|
}
|
|
3597
4502
|
addUniq({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
|
|
4503
|
+
navLinksFoundCount.n++;
|
|
3598
4504
|
});
|
|
4505
|
+
{
|
|
4506
|
+
const navHrefsAlready = /* @__PURE__ */ new Set();
|
|
4507
|
+
for (const item of out) {
|
|
4508
|
+
if (item.role === "nav-link") {
|
|
4509
|
+
try {
|
|
4510
|
+
const h = JSON.parse(item.notes ?? "{}").href;
|
|
4511
|
+
if (h) navHrefsAlready.add(h);
|
|
4512
|
+
} catch {
|
|
4513
|
+
}
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
4516
|
+
const gpGroups2 = /* @__PURE__ */ new Map();
|
|
4517
|
+
document.querySelectorAll("a[href]").forEach((a) => {
|
|
4518
|
+
if (!isNavVisible(a)) return;
|
|
4519
|
+
const href = a.href ?? "";
|
|
4520
|
+
if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsAlready.has(href)) return;
|
|
4521
|
+
const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
|
|
4522
|
+
if (!label || label.length > 40) return;
|
|
4523
|
+
const parent = a.parentElement;
|
|
4524
|
+
const gp = parent?.parentElement;
|
|
4525
|
+
if (!parent || !gp) return;
|
|
4526
|
+
if (parent.querySelectorAll("a").length !== 1) return;
|
|
4527
|
+
const gpTag = gp.tagName.toLowerCase();
|
|
4528
|
+
if (["body", "html", "form"].includes(gpTag)) return;
|
|
4529
|
+
const arr = gpGroups2.get(gp) ?? [];
|
|
4530
|
+
arr.push({ label, href });
|
|
4531
|
+
gpGroups2.set(gp, arr);
|
|
4532
|
+
});
|
|
4533
|
+
for (const [, links] of gpGroups2.entries()) {
|
|
4534
|
+
if (links.length >= 4) {
|
|
4535
|
+
for (const { label, href } of links) {
|
|
4536
|
+
addUniq({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark: "Sidebar" }) });
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
3599
4541
|
document.querySelectorAll('[aria-label*="breadcrumb" i] a, [class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a').forEach((a) => {
|
|
3600
4542
|
const label = a.textContent?.replace(/\s+/g, " ").trim().slice(0, 60);
|
|
3601
4543
|
const href = a.href;
|
|
@@ -4272,8 +5214,8 @@ async function expandNavMenus(page) {
|
|
|
4272
5214
|
return false;
|
|
4273
5215
|
}
|
|
4274
5216
|
}
|
|
4275
|
-
async function domFallbackLinks(page, origin) {
|
|
4276
|
-
|
|
5217
|
+
async function domFallbackLinks(page, origin, rawPage) {
|
|
5218
|
+
function _linkEval(originStr) {
|
|
4277
5219
|
const logout = /logout|log out|sign out|delete/i;
|
|
4278
5220
|
const isRouteHash = (hash) => /^#!?\//.test(hash);
|
|
4279
5221
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -4285,7 +5227,7 @@ async function domFallbackLinks(page, origin) {
|
|
|
4285
5227
|
try {
|
|
4286
5228
|
const u = new URL(rawHref, location.href);
|
|
4287
5229
|
if (!isRouteHash(u.hash)) u.hash = "";
|
|
4288
|
-
if (new URL(u).origin ===
|
|
5230
|
+
if (new URL(u).origin === originStr) seen.add(u.toString());
|
|
4289
5231
|
} catch {
|
|
4290
5232
|
}
|
|
4291
5233
|
};
|
|
@@ -4316,7 +5258,39 @@ async function domFallbackLinks(page, origin) {
|
|
|
4316
5258
|
}
|
|
4317
5259
|
}
|
|
4318
5260
|
return Array.from(seen);
|
|
4319
|
-
}
|
|
5261
|
+
}
|
|
5262
|
+
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)})})();`;
|
|
5263
|
+
if (rawPage && typeof rawPage.context === "function") {
|
|
5264
|
+
try {
|
|
5265
|
+
const ctx = rawPage.context();
|
|
5266
|
+
if (typeof ctx?.newCDPSession === "function") {
|
|
5267
|
+
const cdp = await ctx.newCDPSession(rawPage);
|
|
5268
|
+
try {
|
|
5269
|
+
const evalResult = await cdp.send("Runtime.evaluate", {
|
|
5270
|
+
expression: _linkEvalStr,
|
|
5271
|
+
returnByValue: true,
|
|
5272
|
+
awaitPromise: false
|
|
5273
|
+
});
|
|
5274
|
+
if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
|
|
5275
|
+
return evalResult.result.value;
|
|
5276
|
+
}
|
|
5277
|
+
if (evalResult?.exceptionDetails) {
|
|
5278
|
+
console.warn("[crawler] domFallbackLinks CDP error:", evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
|
|
5279
|
+
}
|
|
5280
|
+
} finally {
|
|
5281
|
+
cdp.detach().catch(() => {
|
|
5282
|
+
});
|
|
5283
|
+
}
|
|
5284
|
+
}
|
|
5285
|
+
} catch (cdpErr) {
|
|
5286
|
+
console.warn("[crawler] domFallbackLinks CDP failed:", cdpErr?.message ?? String(cdpErr));
|
|
5287
|
+
}
|
|
5288
|
+
}
|
|
5289
|
+
const evalPage = rawPage ?? page;
|
|
5290
|
+
return evalPage.evaluate(_linkEvalStr).catch((err) => {
|
|
5291
|
+
console.warn("[crawler] domFallbackLinks page.evaluate threw:", err?.message ?? String(err));
|
|
5292
|
+
return [];
|
|
5293
|
+
});
|
|
4320
5294
|
}
|
|
4321
5295
|
async function extractLinkMappings(page, origin) {
|
|
4322
5296
|
return page.evaluate((origin2) => {
|
|
@@ -4417,50 +5391,105 @@ async function dismissOverlays(page) {
|
|
|
4417
5391
|
} catch {
|
|
4418
5392
|
}
|
|
4419
5393
|
}
|
|
4420
|
-
await
|
|
4421
|
-
});
|
|
5394
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
4422
5395
|
}
|
|
4423
|
-
async function scrollToTriggerLazyLoad(page) {
|
|
5396
|
+
async function scrollToTriggerLazyLoad(page, rawPage) {
|
|
5397
|
+
const evalPage = rawPage ?? page;
|
|
4424
5398
|
try {
|
|
4425
|
-
await
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
5399
|
+
const { scrollHeight, innerHeight, innerWidth } = await evalPage.evaluate(() => ({
|
|
5400
|
+
scrollHeight: Math.min(document.body.scrollHeight, 12e3),
|
|
5401
|
+
innerHeight: window.innerHeight || 800,
|
|
5402
|
+
innerWidth: window.innerWidth || 1280
|
|
5403
|
+
})).catch(() => ({ scrollHeight: 0, innerHeight: 800, innerWidth: 1280 }));
|
|
5404
|
+
if (scrollHeight > innerHeight) {
|
|
5405
|
+
let cdp = null;
|
|
5406
|
+
if (rawPage && typeof rawPage.context === "function") {
|
|
5407
|
+
try {
|
|
5408
|
+
const ctx = rawPage.context();
|
|
5409
|
+
if (typeof ctx?.newCDPSession === "function") cdp = await ctx.newCDPSession(rawPage);
|
|
5410
|
+
} catch {
|
|
5411
|
+
cdp = null;
|
|
5412
|
+
}
|
|
4431
5413
|
}
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
5414
|
+
const step = Math.max(innerHeight, 400);
|
|
5415
|
+
for (let y = step; y < scrollHeight; y += step) {
|
|
5416
|
+
await evalPage.evaluate((pos) => window.scrollTo(0, pos), y).catch(() => {
|
|
5417
|
+
});
|
|
5418
|
+
if (cdp) {
|
|
5419
|
+
await cdp.send("Input.dispatchMouseEvent", {
|
|
5420
|
+
type: "mouseWheel",
|
|
5421
|
+
x: Math.floor(innerWidth / 2),
|
|
5422
|
+
y: Math.floor(innerHeight / 2),
|
|
5423
|
+
deltaX: 0,
|
|
5424
|
+
deltaY: step,
|
|
5425
|
+
modifiers: 0
|
|
5426
|
+
}).catch(() => {
|
|
5427
|
+
});
|
|
5428
|
+
}
|
|
5429
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
5430
|
+
}
|
|
5431
|
+
await evalPage.evaluate(() => window.scrollTo(0, 0)).catch(() => {
|
|
5432
|
+
});
|
|
5433
|
+
if (cdp) cdp.detach().catch(() => {
|
|
5434
|
+
});
|
|
5435
|
+
}
|
|
5436
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
5437
|
+
const scrollWaitPage = rawPage ?? page;
|
|
5438
|
+
if (typeof scrollWaitPage.waitForLoadState === "function") {
|
|
5439
|
+
await scrollWaitPage.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
|
|
5440
|
+
});
|
|
5441
|
+
}
|
|
5442
|
+
await waitForStableDOM(page, 4e3, 500, rawPage);
|
|
4437
5443
|
} catch {
|
|
4438
5444
|
}
|
|
4439
5445
|
}
|
|
4440
|
-
async function waitForStableDOM(page, maxWait = 8e3, stabilityMs = 400) {
|
|
5446
|
+
async function waitForStableDOM(page, maxWait = 8e3, stabilityMs = 400, rawPage) {
|
|
5447
|
+
const evalPage = rawPage ?? page;
|
|
4441
5448
|
const deadline = Date.now() + maxWait;
|
|
4442
5449
|
let lastCount = -1;
|
|
4443
5450
|
let stableAt = 0;
|
|
5451
|
+
let evalErrors = 0;
|
|
4444
5452
|
while (Date.now() < deadline) {
|
|
4445
|
-
const count = await
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
5453
|
+
const count = await evalPage.evaluate(() => {
|
|
5454
|
+
let c = 0;
|
|
5455
|
+
const visit = (root) => {
|
|
5456
|
+
const els = root.querySelectorAll("*");
|
|
5457
|
+
c += els.length;
|
|
5458
|
+
els.forEach((el) => {
|
|
5459
|
+
if (el.shadowRoot) visit(el.shadowRoot);
|
|
5460
|
+
});
|
|
5461
|
+
};
|
|
5462
|
+
visit(document);
|
|
5463
|
+
return c;
|
|
5464
|
+
}).catch(() => -1);
|
|
5465
|
+
if (count === -1) {
|
|
5466
|
+
if (++evalErrors >= 3) {
|
|
5467
|
+
await new Promise((r) => setTimeout(r, stabilityMs));
|
|
5468
|
+
return;
|
|
5469
|
+
}
|
|
5470
|
+
} else {
|
|
5471
|
+
evalErrors = 0;
|
|
5472
|
+
if (count !== lastCount) {
|
|
5473
|
+
lastCount = count;
|
|
5474
|
+
stableAt = Date.now();
|
|
5475
|
+
} else if (Date.now() - stableAt >= stabilityMs) {
|
|
5476
|
+
return;
|
|
5477
|
+
}
|
|
4451
5478
|
}
|
|
4452
5479
|
await new Promise((r) => setTimeout(r, 100));
|
|
4453
5480
|
}
|
|
4454
5481
|
}
|
|
4455
|
-
async function waitForScreenshotReady(page) {
|
|
4456
|
-
const hardDeadline = Date.now() +
|
|
5482
|
+
async function waitForScreenshotReady(page, rawPage) {
|
|
5483
|
+
const hardDeadline = Date.now() + 3e4;
|
|
4457
5484
|
const withHardCap = (p) => Promise.race([p, new Promise((resolve) => setTimeout(resolve, Math.max(0, hardDeadline - Date.now())))]);
|
|
5485
|
+
const waitPage = rawPage ?? page;
|
|
5486
|
+
const evalPage = rawPage ?? page;
|
|
4458
5487
|
try {
|
|
4459
|
-
await withHardCap(
|
|
5488
|
+
await withHardCap(waitPage.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => {
|
|
4460
5489
|
}));
|
|
4461
|
-
await withHardCap(
|
|
5490
|
+
await withHardCap(waitPage.waitForLoadState("networkidle", { timeout: 12e3 }).catch(() => {
|
|
4462
5491
|
}));
|
|
4463
|
-
await withHardCap(
|
|
5492
|
+
await withHardCap(evalPage.evaluate(() => {
|
|
4464
5493
|
if (document.readyState === "complete") return true;
|
|
4465
5494
|
return new Promise((resolve) => {
|
|
4466
5495
|
const done = () => resolve(true);
|
|
@@ -4469,14 +5498,14 @@ async function waitForScreenshotReady(page) {
|
|
|
4469
5498
|
});
|
|
4470
5499
|
}).catch(() => {
|
|
4471
5500
|
}));
|
|
4472
|
-
await withHardCap(
|
|
5501
|
+
await withHardCap(evalPage.evaluate(async () => {
|
|
4473
5502
|
const fonts = document.fonts;
|
|
4474
5503
|
if (fonts?.ready) await fonts.ready;
|
|
4475
5504
|
}).catch(() => {
|
|
4476
5505
|
}));
|
|
4477
|
-
await withHardCap(scrollToTriggerLazyLoad(page));
|
|
4478
|
-
await withHardCap(waitForStableDOM(page, 4e3, 700));
|
|
4479
|
-
await withHardCap(
|
|
5506
|
+
await withHardCap(scrollToTriggerLazyLoad(rawPage ?? page, rawPage));
|
|
5507
|
+
await withHardCap(waitForStableDOM(page, 4e3, 700, rawPage));
|
|
5508
|
+
await withHardCap(evalPage.evaluate(async () => {
|
|
4480
5509
|
const pending = Array.from(document.images).filter((img) => !img.complete);
|
|
4481
5510
|
if (pending.length === 0) return;
|
|
4482
5511
|
await Promise.race([
|
|
@@ -4488,7 +5517,7 @@ async function waitForScreenshotReady(page) {
|
|
|
4488
5517
|
]);
|
|
4489
5518
|
}).catch(() => {
|
|
4490
5519
|
}));
|
|
4491
|
-
await withHardCap(waitForStableDOM(page, 2e3, 500));
|
|
5520
|
+
await withHardCap(waitForStableDOM(page, 2e3, 500, rawPage));
|
|
4492
5521
|
} catch {
|
|
4493
5522
|
}
|
|
4494
5523
|
}
|
|
@@ -4642,6 +5671,7 @@ async function captureAuthDiscoveryScreen(page, opts, requestedUrl, source, ordi
|
|
|
4642
5671
|
return { captured: true, finalUrl, flowType };
|
|
4643
5672
|
}
|
|
4644
5673
|
async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
|
|
5674
|
+
const tuning = await getCrawlTuning();
|
|
4645
5675
|
const queue = [];
|
|
4646
5676
|
const queued = /* @__PURE__ */ new Set();
|
|
4647
5677
|
const capturedKeys = /* @__PURE__ */ new Set();
|
|
@@ -4677,8 +5707,11 @@ async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
|
|
|
4677
5707
|
await onProgress?.(10, "Discovering public and auth flows");
|
|
4678
5708
|
while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
|
|
4679
5709
|
const current = queue.shift();
|
|
5710
|
+
const _authPct = 10 + Math.round(captured / AUTH_DISCOVERY_MAX_SCREENS * 7);
|
|
5711
|
+
const _authPath = current.url.replace(/^https?:\/\/[^/]+/, "") || "/";
|
|
5712
|
+
await onProgress?.(_authPct, `Auth discovery \xB7 ${_authPath} \xB7 ${captured} found`);
|
|
4680
5713
|
try {
|
|
4681
|
-
const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout:
|
|
5714
|
+
const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).catch(() => null);
|
|
4682
5715
|
if (response && response.status && response.status() >= 400) continue;
|
|
4683
5716
|
await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
|
|
4684
5717
|
});
|