@detiq/crawler 1.1.22 → 1.1.24

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.
Files changed (40) hide show
  1. package/dist/crawler.js +173 -28
  2. package/dist/crawler.js.map +2 -2
  3. package/dist/extractors/branding-extractor.js +104 -0
  4. package/dist/extractors/branding-extractor.js.map +7 -0
  5. package/dist/extractors/summary-extractor.js +36 -0
  6. package/dist/extractors/summary-extractor.js.map +7 -0
  7. package/dist/frontier.js +20 -2
  8. package/dist/frontier.js.map +2 -2
  9. package/dist/incremental-diff.js +76 -0
  10. package/dist/incremental-diff.js.map +2 -2
  11. package/dist/index.js +3 -1
  12. package/dist/index.js.map +2 -2
  13. package/dist/interactive-probe.js +108 -17
  14. package/dist/interactive-probe.js.map +2 -2
  15. package/dist/job-queue.js +430 -67
  16. package/dist/job-queue.js.map +2 -2
  17. package/dist/sqs-queue.js +78 -10
  18. package/dist/sqs-queue.js.map +2 -2
  19. package/dist/worker.js +7 -7
  20. package/dist/worker.js.map +2 -2
  21. package/package.json +1 -1
  22. package/src/crawler.ts +208 -30
  23. package/src/extractors/branding-extractor.ts +159 -0
  24. package/src/extractors/summary-extractor.ts +49 -0
  25. package/src/frontier.ts +31 -1
  26. package/src/incremental-diff.ts +138 -0
  27. package/src/index.ts +1 -1
  28. package/src/interactive-probe.ts +129 -20
  29. package/src/job-queue.ts +525 -69
  30. package/src/sqs-queue.ts +92 -12
  31. package/src/worker.ts +13 -11
  32. package/types/crawler.d.ts +20 -0
  33. package/types/extractors/branding-extractor.d.ts +46 -0
  34. package/types/extractors/summary-extractor.d.ts +10 -0
  35. package/types/frontier.d.ts +9 -1
  36. package/types/incremental-diff.d.ts +57 -0
  37. package/types/index.d.ts +1 -1
  38. package/types/interactive-probe.d.ts +5 -1
  39. package/types/job-queue.d.ts +53 -0
  40. package/types/sqs-queue.d.ts +12 -1
package/src/crawler.ts CHANGED
@@ -79,7 +79,7 @@ import { probeCrudFlows } from './crud-interaction.js';
79
79
  import { crawlFiltersAndPagination } from './filter-pagination-crawler.js';
80
80
  import { probeMultiContext } from './multi-context-handler.js';
81
81
  import { probeAdvancedUI } from './advanced-ui-probes.js';
82
- import { ScreenTransitionGraph } from './state-graph.js';
82
+ import { ScreenTransitionGraph, simHash32 } from './state-graph.js';
83
83
  import { crawlNestedContexts } from './nested-window-crawler.js';
84
84
  import { crawlShadowDom } from './shadow-dom-crawler.js';
85
85
  import { crawlAccessibilityTree } from './accessibility-tree-crawler.js';
@@ -125,7 +125,18 @@ interface PerformanceMetric {
125
125
  cls?: number;
126
126
  }
127
127
 
128
+ export interface DiscoveredLinkMeta {
129
+ url: string;
130
+ text?: string;
131
+ landmark?: string;
132
+ source: 'DOM' | 'AI' | 'INTERACTIVE';
133
+ }
134
+
128
135
  const ALLOWED_NUMERIC_PATHS = new Set(['/2fa', '/404', '/500', '/403', '/401']);
136
+
137
+ // Seed path patterns compiled for the active crawl. Set by crawlProject() at the
138
+ // start of each run. All isUsefulNavigationUrl() calls share this for the duration.
139
+ let _activeSeedPatterns: RegExp[] = [];
129
140
  const UUID_PATH_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
130
141
  const CUID_PATH_SEGMENT_RE = /^c[a-z0-9]{20,}$/i;
131
142
  // Match 4+ digit pure numeric IDs only — aligns with canonicalize()'s /\d{4,}/ threshold.
@@ -171,7 +182,9 @@ function isUsefulNavigationUrl(href: string, siteOrigin: string): boolean {
171
182
  const u = new URL(href);
172
183
  if (!sameSite(u.toString(), siteOrigin)) return false;
173
184
  if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
174
- if (isGeneratedDynamicPath(u.pathname)) return false;
185
+ // Bypass CUID/UUID guard for seed path patterns declared by the caller.
186
+ // _activeSeedPatterns is set per-crawl by crawlProject.
187
+ if (isGeneratedDynamicPath(u.pathname) && !_activeSeedPatterns.some(re => re.test(u.pathname))) return false;
175
188
  if (DOWNLOAD_EXT_RE.test(u.pathname)) return false;
176
189
  if (API_PATH_RE.test(u.pathname)) return false;
177
190
  if (EXPORT_PATH_RE.test(u.pathname)) return false;
@@ -181,6 +194,19 @@ function isUsefulNavigationUrl(href: string, siteOrigin: string): boolean {
181
194
  }
182
195
  }
183
196
 
197
+ /**
198
+ * Compile seedPathPatterns (e.g., ['/projects/{id}/tests']) into RegExp matchers.
199
+ * {id} matches any single path segment (CUID, UUID, numeric, slug, etc.).
200
+ * Example: '/projects/{id}/tests' → /^\/projects\/[^/]+\/tests(\/.*)?$/i
201
+ */
202
+ function buildSeedPatternRegexes(patterns: string[]): RegExp[] {
203
+ return patterns.map((p) => {
204
+ const parts = p.split('{id}').map(part => part.replace(/[.+^${}()|[\]\\]/g, '\\$&'));
205
+ const body = parts.join('[^/]+');
206
+ return new RegExp(`^${body}(/.*)?$`, 'i');
207
+ });
208
+ }
209
+
184
210
  type AuthFlowType = 'public' | 'login' | 'register' | 'password_reset' | 'verification' | 'invite' | 'session_recovery';
185
211
  type AuthDiscoverySource = 'entry' | 'seed_path' | 'page_link' | 'auth_click';
186
212
  type AuthDiscoveryProgress = (pct: number, step: string) => void | Promise<void>;
@@ -327,11 +353,15 @@ async function launchChromeCDP(
327
353
  execPath: string,
328
354
  extraArgs: string[],
329
355
  proxy?: { server: string; username?: string; password?: string },
356
+ useStealthPlugin = true,
330
357
  ): Promise<{ wsUrl: string; proc: any }> {
331
358
  // Use a random port in 9200-9900 range to avoid clashes between concurrent crawls.
332
359
  const port = 9200 + Math.floor(Math.random() * 700);
333
360
 
334
- const browserServer = await stealthChromium.launchServer({
361
+ // Use stealthChromium (playwright-extra + puppeteer-extra-plugin-stealth) by default.
362
+ // Pass useStealthPlugin=false to opt out for a specific crawl job (opts.stealth === false).
363
+ const launcher = useStealthPlugin ? stealthChromium : chromium;
364
+ const browserServer = await launcher.launchServer({
335
365
  headless: true,
336
366
  executablePath: execPath,
337
367
  // Use Playwright's native proxy option — it handles 407 auth challenges automatically.
@@ -503,6 +533,13 @@ export async function crawlProject(opts: {
503
533
  startUrls?: string[];
504
534
  /** When provided, crawl ONLY these exact URLs (no link-following, no full screen clear). Used for selected-screen recrawl. */
505
535
  selectiveUrls?: string[];
536
+ /**
537
+ * URL path patterns that bypass the CUID/UUID generated-ID filter during BFS link discovery.
538
+ * Use {id} as a placeholder for any generated path segment (CUID, UUID, numeric ID, etc.).
539
+ * Example: ['/projects/{id}/tests', '/workspaces/{id}/settings']
540
+ * Without this, all URLs containing generated IDs are silently skipped by the BFS.
541
+ */
542
+ seedPathPatterns?: string[];
506
543
  /** When true, run the interactive probe after static crawl to capture real validation errors, toasts, and modals. */
507
544
  interactive?: boolean;
508
545
  /** Optional abort signal — abort() cancels the crawl between page navigations. */
@@ -586,10 +623,17 @@ export async function crawlProject(opts: {
586
623
  autoThrottle?: boolean;
587
624
  /** Regex pattern — only crawl URLs whose path+query match this pattern. */
588
625
  urlFilter?: string;
626
+ /** Glob patterns — only enqueue URLs whose pathname matches at least one (supports * and **). */
627
+ includePatterns?: string[];
628
+ /** Glob patterns — skip URLs whose pathname matches any of these (supports * and **). */
629
+ excludePatterns?: string[];
589
630
  /** Already-captured URLs from a previous failed job — pre-seeded into visited map so they're never re-crawled. */
590
631
  skipUrls?: string[];
591
632
  /** DOM hashes collected in a prior segment — prevents re-screenshotting structurally identical pages across segments. */
592
633
  knownDomHashes?: string[];
634
+ /** When false, disables the puppeteer-extra stealth plugin (playwright-extra) for this crawl.
635
+ * Default true — stealth is applied unless explicitly set to false. */
636
+ stealth?: boolean;
593
637
  /** Called every 10 successful screen captures with current visited URLs and remaining queue — used for checkpoint persistence. */
594
638
  onCheckpoint?: (visitedUrls: string[], pendingQueue: string[]) => Promise<void>;
595
639
  /** Discovery mode only: called after each page with the current set of discovered original URLs — enables streaming partial results to callers. */
@@ -597,6 +641,10 @@ export async function crawlProject(opts: {
597
641
  }, onProgress?: (pct: number, step: string) => Promise<void>) {
598
642
  const tuning = await getCrawlTuning();
599
643
 
644
+ // Compile seed path patterns for this crawl run. Applied by isUsefulNavigationUrl()
645
+ // to bypass the CUID guard for explicitly declared URL shapes.
646
+ _activeSeedPatterns = buildSeedPatternRegexes(opts.seedPathPatterns ?? []);
647
+
600
648
  if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
601
649
 
602
650
  // Upload screenshot to cloud storage if configured, returns cloudUrl or undefined
@@ -908,7 +956,7 @@ export async function crawlProject(opts: {
908
956
  }
909
957
  }
910
958
 
911
- const { wsUrl: cdpUrl, proc: chromeProc } = await launchChromeCDP(executablePath, chromeArgs, playwrightProxy);
959
+ const { wsUrl: cdpUrl, proc: chromeProc } = await launchChromeCDP(executablePath, chromeArgs, playwrightProxy, opts.stealth !== false);
912
960
 
913
961
  // Accumulate token usage from Stagehand's internal LLM calls (extract/act/observe).
914
962
  // Stagehand doesn't expose per-call usage externally; we parse it from its logger.
@@ -1354,6 +1402,7 @@ export async function crawlProject(opts: {
1354
1402
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
1355
1403
  if (!screen) continue;
1356
1404
  const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
1405
+ // TODO GAP-09: pass opts.screenshotConfig (width/height/quality) here when screenshotConfig is wired into the opts type.
1357
1406
  await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {});
1358
1407
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => undefined);
1359
1408
  await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl).catch(() => {});
@@ -1787,7 +1836,7 @@ export async function crawlProject(opts: {
1787
1836
  ...sitemapUrls.filter((u) => canonicalize(u) !== canonicalize(startUrl)),
1788
1837
  ];
1789
1838
  const queued = new Set<string>(seedUrls.map(canonicalize));
1790
- const queue = new CrawlFrontier(opts.traversalStrategy ?? 'BFS', seedUrls, opts.bestFirstKeywords, opts.urlFilter);
1839
+ const queue = new CrawlFrontier(opts.traversalStrategy ?? 'BFS', seedUrls, opts.bestFirstKeywords, opts.urlFilter, opts.includePatterns, opts.excludePatterns);
1791
1840
  // Selective recrawl: block all link-discovery pushes so BFS only visits the requested URLs.
1792
1841
  // Constructor seeding already ran above — this no-op override fires only for BFS-loop discoveries.
1793
1842
  if (selective) {
@@ -1824,7 +1873,8 @@ export async function crawlProject(opts: {
1824
1873
  const stripWww = (h: string) => h.replace(/^www\./i, '').toLowerCase();
1825
1874
  if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
1826
1875
  // Don't re-seed generated dynamic routes — they produce duplicate templates.
1827
- if (isGeneratedDynamicPath(pathname)) return false;
1876
+ // Exception: seed path patterns explicitly declared by the caller.
1877
+ if (isGeneratedDynamicPath(pathname) && !_activeSeedPatterns.some(re => re.test(pathname))) return false;
1828
1878
  // Don't re-seed API/export/download paths — they were misidentified as screens.
1829
1879
  if (!isUsefulNavigationUrl(u, startUrl)) return false;
1830
1880
  // Canonical dedup: mark seen immediately during filter so duplicates are caught.
@@ -2018,7 +2068,7 @@ export async function crawlProject(opts: {
2018
2068
 
2019
2069
  try {
2020
2070
  const { pathname } = new URL(url);
2021
- if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
2071
+ if (SKIP_PATH.test(pathname) || (isGeneratedDynamicPath(pathname) && !_activeSeedPatterns.some(re => re.test(pathname)))) continue;
2022
2072
  // Skip API/export paths — these return data files, not navigable pages
2023
2073
  if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
2024
2074
  } catch { continue; }
@@ -2376,7 +2426,8 @@ export async function crawlProject(opts: {
2376
2426
  // Generated ID-like path segments are usually fallback IDs or entity
2377
2427
  // records. They flood the catalog with duplicate page templates, so
2378
2428
  // skip them before screenshots or screen creation.
2379
- if (isGeneratedDynamicPath(pathname)) {
2429
+ // Exception: seed path patterns declared by the caller bypass this guard.
2430
+ if (isGeneratedDynamicPath(pathname) && !_activeSeedPatterns.some(re => re.test(pathname))) {
2380
2431
  console.log(`[crawler] skip generated dynamic route: ${url}`);
2381
2432
  continue;
2382
2433
  }
@@ -3231,10 +3282,20 @@ export async function crawlProject(opts: {
3231
3282
  // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
3232
3283
  // Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
3233
3284
  discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined)
3285
+ .then((metas) => {
3286
+ // Persist nav-graph edges fire-and-forget
3287
+ if (metas.length > 0) {
3288
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, screen.id, metas.map((m) => ({
3289
+ href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
3290
+ }))).catch(() => {});
3291
+ }
3292
+ return metas.map(m => m.url);
3293
+ })
3234
3294
  .then((freshLinks) => {
3235
3295
  if (aiSkipCount > 0) aiSkipCount--;
3236
3296
  aiErrorStreak = 0;
3237
- const merged = (cachedLinks ? Array.from(new Set([...cachedLinks, ...freshLinks])) : freshLinks)
3297
+ const freshUrls = freshLinks;
3298
+ const merged = (cachedLinks ? Array.from(new Set([...cachedLinks, ...freshUrls])) : freshUrls)
3238
3299
  .filter((href) => isUsefulNavigationUrl(href, origin));
3239
3300
  logger.info({ url, domLinks: freshLinks.length, cached: cachedLinks?.length ?? 0, merged: merged.length }, '[crawler] link-discovery: result');
3240
3301
  // Always persist fresh recipe so stale cache gets overwritten with new AI discoveries
@@ -3250,7 +3311,7 @@ export async function crawlProject(opts: {
3250
3311
  return merged;
3251
3312
  })
3252
3313
  .catch((err: any) => {
3253
- const fallback: string[] = err?.domLinks ?? [];
3314
+ const fallback: string[] = (err?.domLinks ?? []).map((l: DiscoveredLinkMeta) => l.url);
3254
3315
  if (err?.isRateLimit) {
3255
3316
  aiErrorStreak++;
3256
3317
  if (aiErrorStreak >= 5) { aiSkipCount = 3; aiErrorStreak = 0; logger.warn('[crawler] 5 consecutive AI rate limits — pausing AI link discovery for next 3 screens then retrying'); }
@@ -3823,7 +3884,7 @@ export async function crawlProject(opts: {
3823
3884
  // Don't flood the queue with generated ID-like route segments.
3824
3885
  try {
3825
3886
  const hrefPath = new URL(href).pathname;
3826
- if (isGeneratedDynamicPath(hrefPath)) continue;
3887
+ if (isGeneratedDynamicPath(hrefPath) && !_activeSeedPatterns.some(re => re.test(hrefPath))) continue;
3827
3888
  } catch { /* ignore invalid URLs */ }
3828
3889
  if (robotsCache && !await robotsCache.isAllowed(href)) continue;
3829
3890
  queued.add(canonHref);
@@ -4062,7 +4123,14 @@ export async function crawlProject(opts: {
4062
4123
  }
4063
4124
  const [elems, links, linkMappings] = await Promise.all([
4064
4125
  extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
4065
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined),
4126
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined).then((metas) => {
4127
+ if (metas.length > 0) {
4128
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, screen.id, metas.map((m) => ({
4129
+ href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
4130
+ }))).catch(() => {});
4131
+ }
4132
+ return metas.map(m => m.url);
4133
+ }),
4066
4134
  extractLinkMappings(page, origin),
4067
4135
  ]);
4068
4136
  for (const el of elems) {
@@ -4077,7 +4145,8 @@ export async function crawlProject(opts: {
4077
4145
  pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
4078
4146
  }
4079
4147
  // Also enqueue links Skyvern page found for further ZeTa crawling
4080
- for (const href of links) {
4148
+ for (const link of links) {
4149
+ const href = link;
4081
4150
  const c = canonicalize(href);
4082
4151
  if (isUsefulNavigationUrl(href, origin) && !queued.has(c) && !visited.has(c)) { queued.add(c); queue.push(href); }
4083
4152
  }
@@ -4233,7 +4302,16 @@ export async function crawlProject(opts: {
4233
4302
  }
4234
4303
  }
4235
4304
  // Harvest any new links revealed by async content
4236
- const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []);
4305
+ const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined)
4306
+ .then((metas) => {
4307
+ if (metas.length > 0) {
4308
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, existingScreenId, metas.map((m) => ({
4309
+ href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
4310
+ }))).catch(() => {});
4311
+ }
4312
+ return metas.map(m => m.url);
4313
+ })
4314
+ .catch(() => []);
4237
4315
  for (const href of revisitLinks) {
4238
4316
  const canon = canonicalize(href);
4239
4317
  if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 3) {
@@ -4285,7 +4363,16 @@ export async function crawlProject(opts: {
4285
4363
  }
4286
4364
  const [p3bElems, p3bLinks] = await Promise.all([
4287
4365
  extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
4288
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined).catch(() => []),
4366
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined)
4367
+ .then((metas) => {
4368
+ if (metas.length > 0) {
4369
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, screen.id, metas.map((m) => ({
4370
+ href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
4371
+ }))).catch(() => {});
4372
+ }
4373
+ return metas.map(m => m.url);
4374
+ })
4375
+ .catch(() => []),
4289
4376
  ]);
4290
4377
  if (p3bElems.length > 0) {
4291
4378
  await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
@@ -4369,7 +4456,16 @@ export async function crawlProject(opts: {
4369
4456
  }
4370
4457
  const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
4371
4458
  extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
4372
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []),
4459
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined)
4460
+ .then((metas) => {
4461
+ if (metas.length > 0) {
4462
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, gapScreen.id, metas.map((m) => ({
4463
+ href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
4464
+ }))).catch(() => {});
4465
+ }
4466
+ return metas.map(m => m.url);
4467
+ })
4468
+ .catch(() => []),
4373
4469
  extractLinkMappings(page, origin).catch(() => []),
4374
4470
  ]);
4375
4471
  for (const el of gapElems) {
@@ -4494,6 +4590,26 @@ export async function crawlProject(opts: {
4494
4590
  } catch { return false; }
4495
4591
  }).length;
4496
4592
  logger.info({ transitionCount }, 'KAG: wired element→screen transitions');
4593
+
4594
+ // Navigation graph: resolve DiscoveredLink.toScreenId by matching href → visited screen IDs.
4595
+ // Build a raw-href → screenId map using both canonical and pathname-based lookups.
4596
+ if (opts.jobId) {
4597
+ const hrefToScreenId = new Map<string, string>();
4598
+ for (const [canonUrl, screenId] of visited) {
4599
+ if (screenId && screenId !== '_pending_' && screenId !== '_error_') {
4600
+ hrefToScreenId.set(canonUrl, screenId);
4601
+ try {
4602
+ const u = new URL(canonUrl);
4603
+ hrefToScreenId.set(u.href, screenId);
4604
+ // Also index by origin+pathname so links with extra query params still resolve
4605
+ hrefToScreenId.set(u.origin + u.pathname, screenId);
4606
+ hrefToScreenId.set(u.origin + u.pathname.replace(/\/$/, ''), screenId);
4607
+ } catch { /* skip */ }
4608
+ }
4609
+ }
4610
+ AppBrain.resolveDiscoveredLinkTargets(opts.tenantId, opts.projectId, opts.jobId, hrefToScreenId).catch(() => {});
4611
+ }
4612
+
4497
4613
  AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
4498
4614
  jobId: opts.jobId,
4499
4615
  type: 'WORKFLOW_GRAPH_SUMMARY',
@@ -4672,7 +4788,7 @@ export async function crawlProject(opts: {
4672
4788
  const probeInputs = discovered
4673
4789
  .filter((d) => d.screenId)
4674
4790
  .map((d) => ({ url: d.url, screenId: d.screenId!, elements: d.elementFacts ?? [] }));
4675
- await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
4791
+ await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress, opts.jobId);
4676
4792
  }
4677
4793
 
4678
4794
  // Step 7: End-of-crawl state graph coverage report.
@@ -4681,7 +4797,32 @@ export async function crawlProject(opts: {
4681
4797
  // loop above and are saved as PER_SCREEN_PROBES evidence.
4682
4798
  try {
4683
4799
  await onProgress?.(90, 'Generating state graph coverage report');
4800
+ // Populate stateGraph from discovered screens + BFS edges (previously always empty).
4684
4801
  const stateGraph = new ScreenTransitionGraph();
4802
+ const now = new Date().toISOString();
4803
+ for (const d of discovered) {
4804
+ if (!d.screenId || !d.url) continue;
4805
+ const hashVal = simHash32(d.url);
4806
+ stateGraph.addState({
4807
+ id: d.screenId,
4808
+ url: d.url,
4809
+ normalizedUrl: d.url,
4810
+ title: d.url,
4811
+ interactiveElementCount: d.elements ?? 0,
4812
+ domFingerprint: hashVal.toString(16).padStart(8, '0'),
4813
+ simHashValue: hashVal,
4814
+ visitedAt: now,
4815
+ });
4816
+ }
4817
+ // Wire transitions from BFS edges
4818
+ for (const edge of edges) {
4819
+ const fromId = visited.get(canonicalize(edge.fromUrl));
4820
+ const toId = visited.get(canonicalize(edge.toUrl));
4821
+ if (fromId && toId && fromId !== '_pending_' && fromId !== '_error_' &&
4822
+ toId !== '_pending_' && toId !== '_error_') {
4823
+ stateGraph.addTransition(fromId, toId, 'navigate', edge.toUrl, now);
4824
+ }
4825
+ }
4685
4826
  const coveragePayload: Record<string, unknown> = {
4686
4827
  stateGraph: stateGraph.getCoverageReport(),
4687
4828
  totalScreensCrawled: discovered.filter(d => !!d.screenId).length,
@@ -4961,7 +5102,7 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
4961
5102
  }
4962
5103
  }
4963
5104
 
4964
- async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false, rawPage?: any): Promise<string[]> {
5105
+ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false, rawPage?: any): Promise<DiscoveredLinkMeta[]> {
4965
5106
  // Expand collapsed nav menus (hamburger, aria-haspopup, Radix closed triggers) so hidden routes
4966
5107
  // become part of the DOM before querying. This catches SPA nav dropdowns that only render on click.
4967
5108
  const navExpanded = await expandNavMenus(page);
@@ -4971,7 +5112,9 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
4971
5112
  const domLinks = await domFallbackLinks(page, origin, rawPage);
4972
5113
 
4973
5114
  // Interact with tabs, carousels, and accordions to reveal hidden content and discover more links.
4974
- const interactiveLinks = await interactAndCollectLinks(page, origin);
5115
+ // interactAndCollectLinks returns string[] — convert to DiscoveredLinkMeta[] with source INTERACTIVE.
5116
+ const interactiveRaw = await interactAndCollectLinks(page, origin);
5117
+ const interactiveLinks: DiscoveredLinkMeta[] = interactiveRaw.map((url) => ({ url, source: 'INTERACTIVE' as const }));
4975
5118
 
4976
5119
  // Dismiss any opened menus so page is clean for screenshot/element extraction after this call.
4977
5120
  if (navExpanded) {
@@ -4984,7 +5127,15 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
4984
5127
  }).catch(() => { });
4985
5128
  }
4986
5129
 
4987
- const allDomLinks = Array.from(new Set([...domLinks, ...interactiveLinks]));
5130
+ // Deduplicate by URL — DOM wins over INTERACTIVE for metadata richness.
5131
+ const seenUrls = new Set<string>();
5132
+ const allDomLinks: DiscoveredLinkMeta[] = [];
5133
+ for (const link of [...domLinks, ...interactiveLinks]) {
5134
+ if (!seenUrls.has(link.url)) {
5135
+ seenUrls.add(link.url);
5136
+ allDomLinks.push(link);
5137
+ }
5138
+ }
4988
5139
 
4989
5140
  if (allDomLinks.length >= 50 || skipAi) return allDomLinks;
4990
5141
 
@@ -5004,7 +5155,7 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
5004
5155
  if (!extracted) return allDomLinks;
5005
5156
  const { links } = extracted as any;
5006
5157
  const currentUrl: string = page.url?.() ?? '';
5007
- const aiLinks = links
5158
+ const aiLinks: DiscoveredLinkMeta[] = links
5008
5159
  .map((href: string) => {
5009
5160
  try { const u = new URL(href, currentUrl); if (!/^#!?\//.test(u.hash)) u.hash = ''; return u.toString(); }
5010
5161
  catch { return null; }
@@ -5012,9 +5163,19 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
5012
5163
  .filter((u: string | null): u is string => {
5013
5164
  if (!u) return false;
5014
5165
  return isUsefulNavigationUrl(u, origin);
5015
- });
5016
- // Always merge DOM + AI — AI complements, never replaces
5017
- return Array.from(new Set([...allDomLinks, ...aiLinks])).filter((href) => isUsefulNavigationUrl(href, origin));
5166
+ })
5167
+ .map((url: string) => ({ url, source: 'AI' as const }));
5168
+
5169
+ // Always merge DOM + AI — AI complements, never replaces. Dedup by URL.
5170
+ const merged: DiscoveredLinkMeta[] = [...allDomLinks];
5171
+ const mergedUrls = new Set(merged.map((l) => l.url));
5172
+ for (const link of aiLinks) {
5173
+ if (!mergedUrls.has(link.url) && isUsefulNavigationUrl(link.url, origin)) {
5174
+ mergedUrls.add(link.url);
5175
+ merged.push(link);
5176
+ }
5177
+ }
5178
+ return merged.filter((l) => isUsefulNavigationUrl(l.url, origin));
5018
5179
  } catch (err: any) {
5019
5180
  // Detect OpenAI rate-limit errors — signal caller via thrown special error so circuit breaker can trip
5020
5181
  const is429 = err?.status === 429 || String(err?.message ?? '').includes('429') || String(err?.message ?? '').includes('rate limit');
@@ -6225,14 +6386,16 @@ async function expandNavMenus(page: any): Promise<boolean> {
6225
6386
  } catch { return false; }
6226
6387
  }
6227
6388
 
6228
- async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promise<string[]> {
6389
+ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promise<DiscoveredLinkMeta[]> {
6229
6390
  // Named function so .toString() gives the raw function body for CDP evaluation.
6230
6391
  // esbuild wraps nested named arrow-function constants with __name() — prepend a
6231
6392
  // tiny polyfill so the browser has it when we serialize this function as a string.
6393
+ // IMPORTANT: _linkEval is serialized to a string via .toString() and evaluated in the
6394
+ // browser via CDP. It CANNOT contain TypeScript types or Node.js imports.
6232
6395
  function _linkEval(originStr: string) {
6233
6396
  const logout = /logout|log out|sign out|delete/i;
6234
6397
  const isRouteHash = (hash: string) => /^#!?\//.test(hash);
6235
- const seen = new Set<string>();
6398
+ const seen = new Map();
6236
6399
  const add = (rawHref: string, textContent: string) => {
6237
6400
  if (logout.test(textContent)) return;
6238
6401
  if (!rawHref) return;
@@ -6241,7 +6404,7 @@ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promi
6241
6404
  try {
6242
6405
  const u = new URL(rawHref, location.href);
6243
6406
  if (!isRouteHash(u.hash)) u.hash = '';
6244
- if (new URL(u).origin === originStr) seen.add(u.toString());
6407
+ if (new URL(u).origin === originStr) seen.set(u.toString(), (textContent || '').trim().slice(0, 120));
6245
6408
  } catch { /* ignore */ }
6246
6409
  };
6247
6410
  const allRoots: Array<Document | ShadowRoot> = [document];
@@ -6273,7 +6436,22 @@ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promi
6273
6436
  });
6274
6437
  } catch (_) { /* element threw — skip root */ }
6275
6438
  }
6276
- return Array.from(seen);
6439
+ return Array.from(seen.entries()).map(([url, text]) => ({ url, text }));
6440
+ }
6441
+
6442
+ function inferLandmark(url: string): string | undefined {
6443
+ const p = url.toLowerCase();
6444
+ if (p.includes('/auth') || p.includes('/login') || p.includes('/register')) return 'nav';
6445
+ return undefined;
6446
+ }
6447
+
6448
+ function toMeta(raw: Array<{ url: string; text: string }>): DiscoveredLinkMeta[] {
6449
+ return raw.map(({ url, text }) => ({
6450
+ url,
6451
+ text: text || undefined,
6452
+ landmark: inferLandmark(url),
6453
+ source: 'DOM' as const,
6454
+ }));
6277
6455
  }
6278
6456
 
6279
6457
  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)})})();`;
@@ -6291,7 +6469,7 @@ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promi
6291
6469
  awaitPromise: false,
6292
6470
  });
6293
6471
  if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
6294
- return evalResult.result.value as string[];
6472
+ return toMeta(evalResult.result.value as Array<{ url: string; text: string }>);
6295
6473
  }
6296
6474
  if (evalResult?.exceptionDetails) {
6297
6475
  console.warn('[crawler] domFallbackLinks CDP error:', evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
@@ -6307,7 +6485,7 @@ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promi
6307
6485
 
6308
6486
  // Fallback: pass string expression so esbuild __name wrapper never reaches browser
6309
6487
  const evalPage = rawPage ?? page;
6310
- return (evalPage.evaluate(_linkEvalStr as any) as Promise<string[]>).catch((err: any) => {
6488
+ return (evalPage.evaluate(_linkEvalStr as any) as Promise<Array<{ url: string; text: string }>>).then(toMeta).catch((err: any) => {
6311
6489
  console.warn('[crawler] domFallbackLinks page.evaluate threw:', err?.message ?? String(err));
6312
6490
  return [];
6313
6491
  });
@@ -0,0 +1,159 @@
1
+ import type { Page } from 'playwright';
2
+
3
+ export interface BrandingColors {
4
+ primary?: string;
5
+ accent?: string;
6
+ background?: string;
7
+ textPrimary?: string;
8
+ link?: string;
9
+ }
10
+
11
+ export interface BrandingFont {
12
+ family: string;
13
+ role: 'heading' | 'body';
14
+ }
15
+
16
+ export interface BrandingImages {
17
+ logo?: string;
18
+ favicon?: string;
19
+ ogImage?: string;
20
+ }
21
+
22
+ export interface BrandingPersonality {
23
+ tone?: string;
24
+ energy?: 'high' | 'medium' | 'low';
25
+ targetAudience?: string;
26
+ }
27
+
28
+ export interface BrandingResult {
29
+ colorScheme: 'light' | 'dark';
30
+ colors: BrandingColors;
31
+ fonts: BrandingFont[];
32
+ images: BrandingImages;
33
+ personality?: BrandingPersonality;
34
+ designSystem?: { framework?: string; componentLibrary?: string };
35
+ metadata?: { title?: string; description?: string; themeColor?: string; language?: string };
36
+ confidence: { colors: number; fonts: number; images: number; overall: number };
37
+ }
38
+
39
+ export async function extractBranding(page: Page): Promise<BrandingResult> {
40
+ try {
41
+ const result = await page.evaluate(() => {
42
+ const bodyStyle = window.getComputedStyle(document.body);
43
+ const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches ||
44
+ document.documentElement.getAttribute('data-theme') === 'dark' ||
45
+ document.body.classList.contains('dark');
46
+
47
+ // Extract colors from computed styles
48
+ const bg = bodyStyle.backgroundColor;
49
+ const text = bodyStyle.color;
50
+
51
+ // Find primary button color
52
+ const primaryBtn = document.querySelector(
53
+ 'button[class*="primary"], .btn-primary, [class*="btn-primary"], [class*="button-primary"]'
54
+ );
55
+ const primaryColor = primaryBtn ? window.getComputedStyle(primaryBtn).backgroundColor : undefined;
56
+
57
+ // Find link color
58
+ const firstLink = document.querySelector('a');
59
+ const linkColor = firstLink ? window.getComputedStyle(firstLink).color : undefined;
60
+
61
+ // Extract font families
62
+ const headingEl = document.querySelector('h1, h2');
63
+ const bodyEl = document.body;
64
+ const headingFont = headingEl ? window.getComputedStyle(headingEl).fontFamily : bodyStyle.fontFamily;
65
+ const bodyFont = bodyStyle.fontFamily;
66
+
67
+ // Extract images
68
+ const logoEl = document.querySelector(
69
+ 'header img, img[alt*="logo" i], img[class*="logo" i], [class*="logo"] img, [id*="logo"] img'
70
+ ) as HTMLImageElement | null;
71
+ const faviconEl = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]') as HTMLLinkElement | null;
72
+ const ogImageEl = document.querySelector('meta[property="og:image"]') as HTMLMetaElement | null;
73
+
74
+ // Extract metadata
75
+ const titleEl = document.querySelector('title');
76
+ const descEl = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
77
+ const themeColorEl = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null;
78
+
79
+ // Detect design framework
80
+ const hasBootstrap = !!document.querySelector('[class*="btn btn-"], .container-fluid, .navbar-expand');
81
+ const hasTailwind = !!document.querySelector('[class*="flex "], [class*="text-"], [class*="bg-"]');
82
+ const hasChakra = !!(document as any).__chakra_factory__ || !!document.querySelector('[class*="chakra-"]');
83
+ const hasMUI = !!document.querySelector('.MuiButton-root, .MuiTypography-root');
84
+
85
+ const framework = hasBootstrap ? 'Bootstrap' : hasTailwind ? 'Tailwind CSS' : hasChakra ? 'Chakra UI' : hasMUI ? 'Material UI' : null;
86
+
87
+ return {
88
+ isDark,
89
+ bg,
90
+ text,
91
+ primaryColor,
92
+ linkColor,
93
+ headingFont,
94
+ bodyFont,
95
+ logo: logoEl?.src || logoEl?.getAttribute('src') || undefined,
96
+ favicon: faviconEl?.href || undefined,
97
+ ogImage: ogImageEl?.content || undefined,
98
+ title: titleEl?.textContent || undefined,
99
+ description: descEl?.content || undefined,
100
+ themeColor: themeColorEl?.content || undefined,
101
+ language: document.documentElement.lang || undefined,
102
+ framework,
103
+ };
104
+ });
105
+
106
+ const fonts: BrandingFont[] = [];
107
+ if (result.headingFont && result.headingFont !== result.bodyFont) {
108
+ fonts.push({ family: result.headingFont.split(',')[0].replace(/['"]/g, '').trim(), role: 'heading' });
109
+ }
110
+ if (result.bodyFont) {
111
+ fonts.push({ family: result.bodyFont.split(',')[0].replace(/['"]/g, '').trim(), role: 'body' });
112
+ }
113
+
114
+ const colorConfidence = (result.bg || result.text || result.primaryColor) ? 0.8 : 0.3;
115
+ const fontConfidence = fonts.length > 0 ? 0.7 : 0.2;
116
+ const imageConfidence = (result.logo || result.ogImage) ? 0.9 : 0.2;
117
+
118
+ return {
119
+ colorScheme: result.isDark ? 'dark' : 'light',
120
+ colors: {
121
+ background: result.bg || undefined,
122
+ textPrimary: result.text || undefined,
123
+ primary: result.primaryColor || undefined,
124
+ link: result.linkColor || undefined,
125
+ },
126
+ fonts,
127
+ images: {
128
+ logo: result.logo,
129
+ favicon: result.favicon,
130
+ ogImage: result.ogImage,
131
+ },
132
+ designSystem: {
133
+ framework: result.framework || undefined,
134
+ componentLibrary: undefined,
135
+ },
136
+ metadata: {
137
+ title: result.title,
138
+ description: result.description,
139
+ themeColor: result.themeColor,
140
+ language: result.language,
141
+ },
142
+ confidence: {
143
+ colors: colorConfidence,
144
+ fonts: fontConfidence,
145
+ images: imageConfidence,
146
+ overall: (colorConfidence + fontConfidence + imageConfidence) / 3,
147
+ },
148
+ };
149
+ } catch (err) {
150
+ console.warn('[branding-extractor] Extraction failed:', err);
151
+ return {
152
+ colorScheme: 'light',
153
+ colors: {},
154
+ fonts: [],
155
+ images: {},
156
+ confidence: { colors: 0, fonts: 0, images: 0, overall: 0 },
157
+ };
158
+ }
159
+ }