@detiq/crawler 1.1.23 → 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 +408 -61
  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 +489 -63
  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/dist/crawler.js CHANGED
@@ -54,7 +54,7 @@ import { enumerateDropdowns } from "./dropdown-enumerator.js";
54
54
  import { probeCrudFlows } from "./crud-interaction.js";
55
55
  import { crawlFiltersAndPagination } from "./filter-pagination-crawler.js";
56
56
  import { probeAdvancedUI } from "./advanced-ui-probes.js";
57
- import { ScreenTransitionGraph } from "./state-graph.js";
57
+ import { ScreenTransitionGraph, simHash32 } from "./state-graph.js";
58
58
  import { crawlNestedContexts } from "./nested-window-crawler.js";
59
59
  import { crawlShadowDom } from "./shadow-dom-crawler.js";
60
60
  import { crawlAccessibilityTree } from "./accessibility-tree-crawler.js";
@@ -86,6 +86,7 @@ function getGitInfo() {
86
86
  }
87
87
  }
88
88
  const ALLOWED_NUMERIC_PATHS = /* @__PURE__ */ new Set(["/2fa", "/404", "/500", "/403", "/401"]);
89
+ let _activeSeedPatterns = [];
89
90
  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;
90
91
  const CUID_PATH_SEGMENT_RE = /^c[a-z0-9]{20,}$/i;
91
92
  const NUMERIC_ID_PATH_SEGMENT_RE = /^\d{4,}$/;
@@ -110,7 +111,7 @@ function isUsefulNavigationUrl(href, siteOrigin) {
110
111
  const u = new URL(href);
111
112
  if (!sameSite(u.toString(), siteOrigin)) return false;
112
113
  if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
113
- if (isGeneratedDynamicPath(u.pathname)) return false;
114
+ if (isGeneratedDynamicPath(u.pathname) && !_activeSeedPatterns.some((re) => re.test(u.pathname))) return false;
114
115
  if (DOWNLOAD_EXT_RE.test(u.pathname)) return false;
115
116
  if (API_PATH_RE.test(u.pathname)) return false;
116
117
  if (EXPORT_PATH_RE.test(u.pathname)) return false;
@@ -119,6 +120,13 @@ function isUsefulNavigationUrl(href, siteOrigin) {
119
120
  return false;
120
121
  }
121
122
  }
123
+ function buildSeedPatternRegexes(patterns) {
124
+ return patterns.map((p) => {
125
+ const parts = p.split("{id}").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&"));
126
+ const body = parts.join("[^/]+");
127
+ return new RegExp(`^${body}(/.*)?$`, "i");
128
+ });
129
+ }
122
130
  const AUTH_DISCOVERY_MAX_SCREENS = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_SCREENS ?? "") || 36;
123
131
  const AUTH_DISCOVERY_MAX_QUEUE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_QUEUE ?? "") || 48;
124
132
  const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE ?? "") || 6;
@@ -244,9 +252,10 @@ async function capturePageHtml(page) {
244
252
  }
245
253
  return html;
246
254
  }
247
- async function launchChromeCDP(execPath, extraArgs, proxy) {
255
+ async function launchChromeCDP(execPath, extraArgs, proxy, useStealthPlugin = true) {
248
256
  const port = 9200 + Math.floor(Math.random() * 700);
249
- const browserServer = await stealthChromium.launchServer({
257
+ const launcher = useStealthPlugin ? stealthChromium : chromium;
258
+ const browserServer = await launcher.launchServer({
250
259
  headless: true,
251
260
  executablePath: execPath,
252
261
  // Use Playwright's native proxy option — it handles 407 auth challenges automatically.
@@ -377,6 +386,7 @@ const TwoFASchema = z.object({
377
386
  const INLINE_AUTH_WALL_RE = /your login session (was|has been) expired|session (has )?expired[^.]*click|please (log ?in|sign ?in) to continue|you (are|were) logged out|your session has timed out/i;
378
387
  async function crawlProject(opts, onProgress) {
379
388
  const tuning = await getCrawlTuning();
389
+ _activeSeedPatterns = buildSeedPatternRegexes(opts.seedPathPatterns ?? []);
380
390
  if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
381
391
  const uploadToCloud = async (localPath, key) => {
382
392
  if (!opts.storageConfig) return void 0;
@@ -643,7 +653,7 @@ async function crawlProject(opts, onProgress) {
643
653
  logger.warn({ err: foxErr }, "[crawler] Camoufox bootstrap failed \u2014 continuing without it");
644
654
  }
645
655
  }
646
- const { wsUrl: cdpUrl, proc: chromeProc } = await launchChromeCDP(executablePath, chromeArgs, playwrightProxy);
656
+ const { wsUrl: cdpUrl, proc: chromeProc } = await launchChromeCDP(executablePath, chromeArgs, playwrightProxy, opts.stealth !== false);
647
657
  let stagehandPromptTokens = 0;
648
658
  let stagehandCompletionTokens = 0;
649
659
  const stagehandLogger = (line) => {
@@ -1444,7 +1454,7 @@ async function crawlProject(opts, onProgress) {
1444
1454
  ...sitemapUrls.filter((u) => canonicalize(u) !== canonicalize(startUrl))
1445
1455
  ];
1446
1456
  const queued = new Set(seedUrls.map(canonicalize));
1447
- const queue = new CrawlFrontier(opts.traversalStrategy ?? "BFS", seedUrls, opts.bestFirstKeywords, opts.urlFilter);
1457
+ const queue = new CrawlFrontier(opts.traversalStrategy ?? "BFS", seedUrls, opts.bestFirstKeywords, opts.urlFilter, opts.includePatterns, opts.excludePatterns);
1448
1458
  if (selective) {
1449
1459
  queue.push = () => {
1450
1460
  };
@@ -1467,7 +1477,7 @@ async function crawlProject(opts, onProgress) {
1467
1477
  const { pathname } = new URL(u);
1468
1478
  const stripWww = (h) => h.replace(/^www\./i, "").toLowerCase();
1469
1479
  if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
1470
- if (isGeneratedDynamicPath(pathname)) return false;
1480
+ if (isGeneratedDynamicPath(pathname) && !_activeSeedPatterns.some((re) => re.test(pathname))) return false;
1471
1481
  if (!isUsefulNavigationUrl(u, startUrl)) return false;
1472
1482
  const canon = canonicalize(u);
1473
1483
  if (seenReseedCanon.has(canon)) return false;
@@ -1629,7 +1639,7 @@ async function crawlProject(opts, onProgress) {
1629
1639
  if (visited.has(canonUrl)) continue;
1630
1640
  try {
1631
1641
  const { pathname } = new URL(url);
1632
- if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
1642
+ if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname) && !_activeSeedPatterns.some((re) => re.test(pathname))) continue;
1633
1643
  if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
1634
1644
  } catch {
1635
1645
  continue;
@@ -1985,7 +1995,7 @@ async function crawlProject(opts, onProgress) {
1985
1995
  console.log(`[crawler] skip content page: ${url}`);
1986
1996
  continue;
1987
1997
  }
1988
- if (isGeneratedDynamicPath(pathname)) {
1998
+ if (isGeneratedDynamicPath(pathname) && !_activeSeedPatterns.some((re) => re.test(pathname))) {
1989
1999
  console.log(`[crawler] skip generated dynamic route: ${url}`);
1990
2000
  continue;
1991
2001
  }
@@ -2724,10 +2734,24 @@ async function crawlProject(opts, onProgress) {
2724
2734
  // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
2725
2735
  // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
2726
2736
  // Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
2727
- discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).then((freshLinks) => {
2737
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).then((metas) => {
2738
+ if (metas.length > 0) {
2739
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, screen.id, metas.map((m) => ({
2740
+ href: m.url,
2741
+ text: m.text,
2742
+ landmark: m.landmark,
2743
+ linkType: "NAV_LINK",
2744
+ source: m.source,
2745
+ jobId: opts.jobId
2746
+ }))).catch(() => {
2747
+ });
2748
+ }
2749
+ return metas.map((m) => m.url);
2750
+ }).then((freshLinks) => {
2728
2751
  if (aiSkipCount > 0) aiSkipCount--;
2729
2752
  aiErrorStreak = 0;
2730
- const merged = (cachedLinks ? Array.from(/* @__PURE__ */ new Set([...cachedLinks, ...freshLinks])) : freshLinks).filter((href) => isUsefulNavigationUrl(href, origin));
2753
+ const freshUrls = freshLinks;
2754
+ const merged = (cachedLinks ? Array.from(/* @__PURE__ */ new Set([...cachedLinks, ...freshUrls])) : freshUrls).filter((href) => isUsefulNavigationUrl(href, origin));
2731
2755
  logger.info({ url, domLinks: freshLinks.length, cached: cachedLinks?.length ?? 0, merged: merged.length }, "[crawler] link-discovery: result");
2732
2756
  if (merged.length > 0) {
2733
2757
  AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
@@ -2741,7 +2765,7 @@ async function crawlProject(opts, onProgress) {
2741
2765
  }
2742
2766
  return merged;
2743
2767
  }).catch((err) => {
2744
- const fallback = err?.domLinks ?? [];
2768
+ const fallback = (err?.domLinks ?? []).map((l) => l.url);
2745
2769
  if (err?.isRateLimit) {
2746
2770
  aiErrorStreak++;
2747
2771
  if (aiErrorStreak >= 5) {
@@ -3269,7 +3293,7 @@ ${snippet}`,
3269
3293
  if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 3) {
3270
3294
  try {
3271
3295
  const hrefPath = new URL(href).pathname;
3272
- if (isGeneratedDynamicPath(hrefPath)) continue;
3296
+ if (isGeneratedDynamicPath(hrefPath) && !_activeSeedPatterns.some((re) => re.test(hrefPath))) continue;
3273
3297
  } catch {
3274
3298
  }
3275
3299
  if (robotsCache && !await robotsCache.isAllowed(href)) continue;
@@ -3473,7 +3497,20 @@ ${snippet}`,
3473
3497
  }
3474
3498
  const [elems, links, linkMappings] = await Promise.all([
3475
3499
  extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
3476
- discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0),
3500
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).then((metas) => {
3501
+ if (metas.length > 0) {
3502
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, screen.id, metas.map((m) => ({
3503
+ href: m.url,
3504
+ text: m.text,
3505
+ landmark: m.landmark,
3506
+ linkType: "NAV_LINK",
3507
+ source: m.source,
3508
+ jobId: opts.jobId
3509
+ }))).catch(() => {
3510
+ });
3511
+ }
3512
+ return metas.map((m) => m.url);
3513
+ }),
3477
3514
  extractLinkMappings(page, origin)
3478
3515
  ]);
3479
3516
  for (const el of elems) {
@@ -3485,7 +3522,8 @@ ${snippet}`,
3485
3522
  for (const lm of linkMappings) {
3486
3523
  pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
3487
3524
  }
3488
- for (const href of links) {
3525
+ for (const link of links) {
3526
+ const href = link;
3489
3527
  const c = canonicalize(href);
3490
3528
  if (isUsefulNavigationUrl(href, origin) && !queued.has(c) && !visited.has(c)) {
3491
3529
  queued.add(c);
@@ -3629,7 +3667,20 @@ ${snippet}`,
3629
3667
  });
3630
3668
  }
3631
3669
  }
3632
- const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []);
3670
+ const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).then((metas) => {
3671
+ if (metas.length > 0) {
3672
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, existingScreenId, metas.map((m) => ({
3673
+ href: m.url,
3674
+ text: m.text,
3675
+ landmark: m.landmark,
3676
+ linkType: "NAV_LINK",
3677
+ source: m.source,
3678
+ jobId: opts.jobId
3679
+ }))).catch(() => {
3680
+ });
3681
+ }
3682
+ return metas.map((m) => m.url);
3683
+ }).catch(() => []);
3633
3684
  for (const href of revisitLinks) {
3634
3685
  const canon = canonicalize(href);
3635
3686
  if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 3) {
@@ -3680,7 +3731,20 @@ ${snippet}`,
3680
3731
  }
3681
3732
  const [p3bElems, p3bLinks] = await Promise.all([
3682
3733
  extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
3683
- discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).catch(() => [])
3734
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).then((metas) => {
3735
+ if (metas.length > 0) {
3736
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, screen.id, metas.map((m) => ({
3737
+ href: m.url,
3738
+ text: m.text,
3739
+ landmark: m.landmark,
3740
+ linkType: "NAV_LINK",
3741
+ source: m.source,
3742
+ jobId: opts.jobId
3743
+ }))).catch(() => {
3744
+ });
3745
+ }
3746
+ return metas.map((m) => m.url);
3747
+ }).catch(() => [])
3684
3748
  ]);
3685
3749
  if (p3bElems.length > 0) {
3686
3750
  await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
@@ -3760,7 +3824,20 @@ ${snippet}`,
3760
3824
  }
3761
3825
  const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
3762
3826
  extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
3763
- discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []),
3827
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).then((metas) => {
3828
+ if (metas.length > 0) {
3829
+ AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, gapScreen.id, metas.map((m) => ({
3830
+ href: m.url,
3831
+ text: m.text,
3832
+ landmark: m.landmark,
3833
+ linkType: "NAV_LINK",
3834
+ source: m.source,
3835
+ jobId: opts.jobId
3836
+ }))).catch(() => {
3837
+ });
3838
+ }
3839
+ return metas.map((m) => m.url);
3840
+ }).catch(() => []),
3764
3841
  extractLinkMappings(page, origin).catch(() => [])
3765
3842
  ]);
3766
3843
  for (const el of gapElems) {
@@ -3882,6 +3959,23 @@ ${snippet}`,
3882
3959
  }
3883
3960
  }).length;
3884
3961
  logger.info({ transitionCount }, "KAG: wired element\u2192screen transitions");
3962
+ if (opts.jobId) {
3963
+ const hrefToScreenId = /* @__PURE__ */ new Map();
3964
+ for (const [canonUrl, screenId] of visited) {
3965
+ if (screenId && screenId !== "_pending_" && screenId !== "_error_") {
3966
+ hrefToScreenId.set(canonUrl, screenId);
3967
+ try {
3968
+ const u = new URL(canonUrl);
3969
+ hrefToScreenId.set(u.href, screenId);
3970
+ hrefToScreenId.set(u.origin + u.pathname, screenId);
3971
+ hrefToScreenId.set(u.origin + u.pathname.replace(/\/$/, ""), screenId);
3972
+ } catch {
3973
+ }
3974
+ }
3975
+ }
3976
+ AppBrain.resolveDiscoveredLinkTargets(opts.tenantId, opts.projectId, opts.jobId, hrefToScreenId).catch(() => {
3977
+ });
3978
+ }
3885
3979
  AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3886
3980
  jobId: opts.jobId,
3887
3981
  type: "WORKFLOW_GRAPH_SUMMARY",
@@ -4052,11 +4146,33 @@ ${snippet}`,
4052
4146
  if (shouldRunInteractiveProbe) {
4053
4147
  await onProgress?.(88, "Running interactive probe (capturing real errors & modals)");
4054
4148
  const probeInputs = discovered.filter((d) => d.screenId).map((d) => ({ url: d.url, screenId: d.screenId, elements: d.elementFacts ?? [] }));
4055
- await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
4149
+ await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress, opts.jobId);
4056
4150
  }
4057
4151
  try {
4058
4152
  await onProgress?.(90, "Generating state graph coverage report");
4059
4153
  const stateGraph = new ScreenTransitionGraph();
4154
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4155
+ for (const d of discovered) {
4156
+ if (!d.screenId || !d.url) continue;
4157
+ const hashVal = simHash32(d.url);
4158
+ stateGraph.addState({
4159
+ id: d.screenId,
4160
+ url: d.url,
4161
+ normalizedUrl: d.url,
4162
+ title: d.url,
4163
+ interactiveElementCount: d.elements ?? 0,
4164
+ domFingerprint: hashVal.toString(16).padStart(8, "0"),
4165
+ simHashValue: hashVal,
4166
+ visitedAt: now
4167
+ });
4168
+ }
4169
+ for (const edge of edges) {
4170
+ const fromId = visited.get(canonicalize(edge.fromUrl));
4171
+ const toId = visited.get(canonicalize(edge.toUrl));
4172
+ if (fromId && toId && fromId !== "_pending_" && fromId !== "_error_" && toId !== "_pending_" && toId !== "_error_") {
4173
+ stateGraph.addTransition(fromId, toId, "navigate", edge.toUrl, now);
4174
+ }
4175
+ }
4060
4176
  const coveragePayload = {
4061
4177
  stateGraph: stateGraph.getCoverageReport(),
4062
4178
  totalScreensCrawled: discovered.filter((d) => !!d.screenId).length
@@ -4313,7 +4429,8 @@ async function extractElements(stagehand, page, llmCfg, rawPage) {
4313
4429
  async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false, rawPage) {
4314
4430
  const navExpanded = await expandNavMenus(page);
4315
4431
  const domLinks = await domFallbackLinks(page, origin, rawPage);
4316
- const interactiveLinks = await interactAndCollectLinks(page, origin);
4432
+ const interactiveRaw = await interactAndCollectLinks(page, origin);
4433
+ const interactiveLinks = interactiveRaw.map((url) => ({ url, source: "INTERACTIVE" }));
4317
4434
  if (navExpanded) {
4318
4435
  page.keyboard?.press("Escape").catch(() => {
4319
4436
  });
@@ -4327,7 +4444,14 @@ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false, ra
4327
4444
  }).catch(() => {
4328
4445
  });
4329
4446
  }
4330
- const allDomLinks = Array.from(/* @__PURE__ */ new Set([...domLinks, ...interactiveLinks]));
4447
+ const seenUrls = /* @__PURE__ */ new Set();
4448
+ const allDomLinks = [];
4449
+ for (const link of [...domLinks, ...interactiveLinks]) {
4450
+ if (!seenUrls.has(link.url)) {
4451
+ seenUrls.add(link.url);
4452
+ allDomLinks.push(link);
4453
+ }
4454
+ }
4331
4455
  if (allDomLinks.length >= 50 || skipAi) return allDomLinks;
4332
4456
  try {
4333
4457
  const extracted = await Promise.race([
@@ -4351,8 +4475,16 @@ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false, ra
4351
4475
  }).filter((u) => {
4352
4476
  if (!u) return false;
4353
4477
  return isUsefulNavigationUrl(u, origin);
4354
- });
4355
- return Array.from(/* @__PURE__ */ new Set([...allDomLinks, ...aiLinks])).filter((href) => isUsefulNavigationUrl(href, origin));
4478
+ }).map((url) => ({ url, source: "AI" }));
4479
+ const merged = [...allDomLinks];
4480
+ const mergedUrls = new Set(merged.map((l) => l.url));
4481
+ for (const link of aiLinks) {
4482
+ if (!mergedUrls.has(link.url) && isUsefulNavigationUrl(link.url, origin)) {
4483
+ mergedUrls.add(link.url);
4484
+ merged.push(link);
4485
+ }
4486
+ }
4487
+ return merged.filter((l) => isUsefulNavigationUrl(l.url, origin));
4356
4488
  } catch (err) {
4357
4489
  const is429 = err?.status === 429 || String(err?.message ?? "").includes("429") || String(err?.message ?? "").includes("rate limit");
4358
4490
  if (is429) throw Object.assign(new Error("RATE_LIMITED"), { isRateLimit: true, domLinks: allDomLinks });
@@ -5442,7 +5574,7 @@ async function domFallbackLinks(page, origin, rawPage) {
5442
5574
  function _linkEval(originStr) {
5443
5575
  const logout = /logout|log out|sign out|delete/i;
5444
5576
  const isRouteHash = (hash) => /^#!?\//.test(hash);
5445
- const seen = /* @__PURE__ */ new Set();
5577
+ const seen = /* @__PURE__ */ new Map();
5446
5578
  const add = (rawHref, textContent) => {
5447
5579
  if (logout.test(textContent)) return;
5448
5580
  if (!rawHref) return;
@@ -5451,7 +5583,7 @@ async function domFallbackLinks(page, origin, rawPage) {
5451
5583
  try {
5452
5584
  const u = new URL(rawHref, location.href);
5453
5585
  if (!isRouteHash(u.hash)) u.hash = "";
5454
- if (new URL(u).origin === originStr) seen.add(u.toString());
5586
+ if (new URL(u).origin === originStr) seen.set(u.toString(), (textContent || "").trim().slice(0, 120));
5455
5587
  } catch {
5456
5588
  }
5457
5589
  };
@@ -5485,7 +5617,20 @@ async function domFallbackLinks(page, origin, rawPage) {
5485
5617
  } catch (_) {
5486
5618
  }
5487
5619
  }
5488
- return Array.from(seen);
5620
+ return Array.from(seen.entries()).map(([url, text]) => ({ url, text }));
5621
+ }
5622
+ function inferLandmark(url) {
5623
+ const p = url.toLowerCase();
5624
+ if (p.includes("/auth") || p.includes("/login") || p.includes("/register")) return "nav";
5625
+ return void 0;
5626
+ }
5627
+ function toMeta(raw) {
5628
+ return raw.map(({ url, text }) => ({
5629
+ url,
5630
+ text: text || void 0,
5631
+ landmark: inferLandmark(url),
5632
+ source: "DOM"
5633
+ }));
5489
5634
  }
5490
5635
  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)})})();`;
5491
5636
  if (rawPage && typeof rawPage.context === "function") {
@@ -5500,7 +5645,7 @@ async function domFallbackLinks(page, origin, rawPage) {
5500
5645
  awaitPromise: false
5501
5646
  });
5502
5647
  if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
5503
- return evalResult.result.value;
5648
+ return toMeta(evalResult.result.value);
5504
5649
  }
5505
5650
  if (evalResult?.exceptionDetails) {
5506
5651
  console.warn("[crawler] domFallbackLinks CDP error:", evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
@@ -5515,7 +5660,7 @@ async function domFallbackLinks(page, origin, rawPage) {
5515
5660
  }
5516
5661
  }
5517
5662
  const evalPage = rawPage ?? page;
5518
- return evalPage.evaluate(_linkEvalStr).catch((err) => {
5663
+ return evalPage.evaluate(_linkEvalStr).then(toMeta).catch((err) => {
5519
5664
  console.warn("[crawler] domFallbackLinks page.evaluate threw:", err?.message ?? String(err));
5520
5665
  return [];
5521
5666
  });