@detiq/crawler 1.1.11 → 1.1.13

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/src/crawler.ts CHANGED
@@ -155,9 +155,9 @@ type AuthDiscoveryOptions = {
155
155
  storageConfig?: any;
156
156
  };
157
157
 
158
- const AUTH_DISCOVERY_MAX_SCREENS = 36;
159
- const AUTH_DISCOVERY_MAX_QUEUE = 48;
160
- const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = 6;
158
+ const AUTH_DISCOVERY_MAX_SCREENS = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_SCREENS ?? '') || 36;
159
+ const AUTH_DISCOVERY_MAX_QUEUE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_QUEUE ?? '') || 48;
160
+ const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE ?? '') || 6;
161
161
  const AUTH_DISCOVERY_PATHS = [
162
162
  '/',
163
163
  '/login', '/log-in', '/signin', '/sign-in', '/auth', '/auth/login', '/account/login', '/user/login', '/app/login',
@@ -576,15 +576,15 @@ export async function crawlProject(opts: {
576
576
  break;
577
577
  case 'openrouter':
578
578
  stagehandModel = `openai/${m}`;
579
- stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || 'https://openrouter.ai/api/v1' };
579
+ stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || process.env.CRAWLER_OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' };
580
580
  break;
581
581
  case 'ollama':
582
582
  stagehandModel = `ollama/${m}`;
583
- stagehandModelOpts = { baseURL: baseUrl || 'http://localhost:11434' };
583
+ stagehandModelOpts = { baseURL: baseUrl || process.env.CRAWLER_OLLAMA_BASE_URL || 'http://localhost:11434' };
584
584
  break;
585
585
  case 'nvidia':
586
586
  stagehandModel = `openai/${m.startsWith('openai/') ? m.slice(7) : m}`;
587
- stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || 'https://integrate.api.nvidia.com/v1' };
587
+ stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || process.env.CRAWLER_NVIDIA_BASE_URL || 'https://integrate.api.nvidia.com/v1' };
588
588
  break;
589
589
  case 'groq':
590
590
  stagehandModel = `groq/${m}`;
@@ -635,15 +635,18 @@ export async function crawlProject(opts: {
635
635
  const preferAnthropic = envModel.includes('claude') || (!envModel && !process.env.OPENAI_API_KEY);
636
636
  if (preferAnthropic && process.env.ANTHROPIC_API_KEY) {
637
637
  stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
638
- stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || 'claude-sonnet-4-6'}`;
638
+ const defaultAnthropicModel = process.env.CRAWLER_DEFAULT_MODEL ?? 'claude-sonnet-4-6';
639
+ stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || defaultAnthropicModel}`;
639
640
  stagehandConfigured = true;
640
641
  } else if (process.env.OPENAI_API_KEY) {
641
642
  stagehandModelOpts = { apiKey: process.env.OPENAI_API_KEY };
642
- stagehandModel = envModel.includes('/') ? envModel : `openai/${envModel || 'gpt-4o'}`;
643
+ const defaultOpenaiModel = process.env.CRAWLER_DEFAULT_MODEL ?? 'gpt-4o';
644
+ stagehandModel = envModel.includes('/') ? envModel : `openai/${envModel || defaultOpenaiModel}`;
643
645
  stagehandConfigured = true;
644
646
  } else if (process.env.ANTHROPIC_API_KEY) {
645
647
  stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
646
- stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || 'claude-sonnet-4-6'}`;
648
+ const defaultAnthropicModel2 = process.env.CRAWLER_DEFAULT_MODEL ?? 'claude-sonnet-4-6';
649
+ stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || defaultAnthropicModel2}`;
647
650
  stagehandConfigured = true;
648
651
  }
649
652
  }
@@ -848,7 +851,7 @@ export async function crawlProject(opts: {
848
851
  verbose: 1,
849
852
  logger: stagehandLogger,
850
853
  localBrowserLaunchOptions: { cdpUrl, headless: true },
851
- domSettleTimeout: 2000,
854
+ domSettleTimeout: parseInt(process.env.CRAWLER_DOM_SETTLE_TIMEOUT_MS ?? '') || 2000,
852
855
  });
853
856
 
854
857
  await stagehand.init();
@@ -857,8 +860,8 @@ export async function crawlProject(opts: {
857
860
  // Use resolvePage() to get the active V3Page (which is a full Playwright proxy).
858
861
  const page = await (stagehand as any).resolvePage();
859
862
  // Cap all Playwright operations so one stuck page can't freeze the crawl.
860
- try { page.setDefaultTimeout(45_000); } catch { /* proxy may not expose this */ }
861
- try { page.setDefaultNavigationTimeout(30_000); } catch { /* proxy may not expose this */ }
863
+ try { page.setDefaultTimeout(parseInt(process.env.CRAWLER_PLAYWRIGHT_DEFAULT_TIMEOUT_MS ?? '') || 45_000); } catch { /* proxy may not expose this */ }
864
+ try { page.setDefaultNavigationTimeout(parseInt(process.env.CRAWLER_PLAYWRIGHT_NAV_TIMEOUT_MS ?? '') || 30_000); } catch { /* proxy may not expose this */ }
862
865
  // Stagehand V3Page proxy only supports the 'console' event (page.on throws for others).
863
866
  // rawPage will be wired to a real Playwright Page via the CDP connection established below.
864
867
  // Declared as `let` so the network-observer block can populate it.
@@ -1472,7 +1475,7 @@ export async function crawlProject(opts: {
1472
1475
  ];
1473
1476
  const queued = new Set<string>(seedUrls.map(canonicalize));
1474
1477
  const queue = new CrawlFrontier(opts.traversalStrategy ?? 'BFS', seedUrls, opts.bestFirstKeywords, opts.urlFilter);
1475
- const deduplicator = new ContentDeduplicator(0.88);
1478
+ const deduplicator = new ContentDeduplicator(parseFloat(process.env.CRAWLER_DEDUP_THRESHOLD ?? '') || 0.88);
1476
1479
  const deadLinkTracker = new DeadLinkTracker();
1477
1480
  const redirectRegistry = new RedirectRegistry();
1478
1481
  // Re-seed from previously-discovered screens so deep routes found by AI link
@@ -1626,7 +1629,12 @@ export async function crawlProject(opts: {
1626
1629
  // Legal/policy-only pages — no interactive elements worth testing; skip to prevent noise
1627
1630
  // NOTE: /about, /contact, /careers, /help are intentionally NOT skipped — they have
1628
1631
  // real test targets (contact forms, job apply forms, help search, team content)
1629
- const SKIP_PATH = /\/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap)([-/]|$)/i;
1632
+ const skipPathExtra = process.env.CRAWL_SKIP_PATH_EXTRA
1633
+ ? process.env.CRAWL_SKIP_PATH_EXTRA.split(',').map((p) => p.trim()).filter(Boolean)
1634
+ : [];
1635
+ const SKIP_PATH = skipPathExtra.length > 0
1636
+ ? new RegExp(`/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap|${skipPathExtra.join('|')})([-/]|$)`, 'i')
1637
+ : /\/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap)([-/]|$)/i;
1630
1638
  const segIdx = opts.segmentIndex ?? 1;
1631
1639
 
1632
1640
  const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
@@ -1637,6 +1645,7 @@ export async function crawlProject(opts: {
1637
1645
  // (was: 3 errors → skip 10 screens — too aggressive for Nvidia NIM which rate-limits quickly)
1638
1646
  let aiErrorStreak = 0;
1639
1647
  let aiSkipCount = 0;
1648
+ const aiMinElements = parseInt(process.env.CRAWL_AI_LINK_MIN_ELEMENTS ?? '') || 5;
1640
1649
  let aiRateLimitWarned = false;
1641
1650
  // Hard guard: total URLs attempted (including skips) capped at maxScreens * 4
1642
1651
  // to prevent infinite-URL-explosion from WooCommerce/Shopify facet filter combos
@@ -1746,7 +1755,7 @@ export async function crawlProject(opts: {
1746
1755
  for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
1747
1756
  const wp = await pwCtx.newPage();
1748
1757
  await wp.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); }).catch(() => {});
1749
- wp.setDefaultTimeout(45_000);
1758
+ wp.setDefaultTimeout(parseInt(process.env.CRAWLER_PLAYWRIGHT_DEFAULT_TIMEOUT_MS ?? '') || 45_000);
1750
1759
  liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {})));
1751
1760
  }
1752
1761
  }
@@ -2469,22 +2478,68 @@ export async function crawlProject(opts: {
2469
2478
  // FM-4: capture device pixel ratio for coordinate scaling in element highlighting
2470
2479
  const pageDpr: number = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
2471
2480
 
2472
- // Parallelize: screenshot + element extraction + link discovery + AT capture (all work on current page state)
2473
- const [, elements, nextLinks, atElements] = await Promise.all([
2481
+ // Extract elements first so we can highlight them in the screenshot
2482
+ const elements = await extractElements(stagehand, page, elementLLM ?? undefined).catch((err: any) => {
2483
+ console.error('[crawler] extractElements failed:', err);
2484
+ return [] as Awaited<ReturnType<typeof extractElements>>;
2485
+ });
2486
+
2487
+ // Inject bounding boxes for the screenshot
2488
+ await page.evaluate((elems: any[]) => {
2489
+ const container = document.createElement('div');
2490
+ container.id = '__zeta_element_highlights';
2491
+ container.style.position = 'absolute';
2492
+ container.style.top = '0';
2493
+ container.style.left = '0';
2494
+ container.style.width = '100%';
2495
+ container.style.height = '100%';
2496
+ container.style.pointerEvents = 'none';
2497
+ container.style.zIndex = '2147483647';
2498
+
2499
+ elems.forEach((el, index) => {
2500
+ if (!el.boundingRect) return;
2501
+ const rect = el.boundingRect;
2502
+ const box = document.createElement('div');
2503
+ box.style.position = 'absolute';
2504
+ box.style.left = (rect.x + window.scrollX) + 'px';
2505
+ box.style.top = (rect.y + window.scrollY) + 'px';
2506
+ box.style.width = rect.width + 'px';
2507
+ box.style.height = rect.height + 'px';
2508
+ box.style.border = '2px solid rgba(255, 0, 0, 0.8)';
2509
+ box.style.backgroundColor = 'rgba(255, 0, 0, 0.1)';
2510
+ box.style.boxSizing = 'border-box';
2511
+
2512
+ const label = document.createElement('div');
2513
+ label.textContent = el.role || 'element';
2514
+ label.style.position = 'absolute';
2515
+ label.style.top = '-16px';
2516
+ label.style.left = '-2px';
2517
+ label.style.backgroundColor = 'rgba(255, 0, 0, 0.9)';
2518
+ label.style.color = '#FFF';
2519
+ label.style.fontSize = '10px';
2520
+ label.style.padding = '2px 4px';
2521
+ label.style.borderRadius = '2px';
2522
+ label.style.whiteSpace = 'nowrap';
2523
+ label.style.fontFamily = 'monospace';
2524
+ box.appendChild(label);
2525
+
2526
+ container.appendChild(box);
2527
+ });
2528
+ document.body.appendChild(container);
2529
+ }, elements).catch(() => {});
2530
+
2531
+ // Parallelize: screenshot + link discovery + AT capture
2532
+ const [, nextLinks, atElements] = await Promise.all([
2474
2533
  page.screenshot({ path: shotPath, fullPage: true })
2475
2534
  .then(async () => {
2476
2535
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
2477
2536
  return (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl, pageDpr);
2478
2537
  })
2479
2538
  .catch((err: any) => console.error('[crawler] page.screenshot failed:', err)),
2480
- extractElements(stagehand, page, elementLLM ?? undefined).catch((err: any) => {
2481
- console.error('[crawler] extractElements failed:', err);
2482
- return [] as Awaited<ReturnType<typeof extractElements>>;
2483
- }),
2484
2539
  // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
2485
2540
  // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
2486
2541
  // Skip AI on simple pages (< 5 interactive elements) — DOM-only is sufficient and saves ~12s per page.
2487
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0 || preDomCount < 5)
2542
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0 || preDomCount < aiMinElements)
2488
2543
  .then((freshLinks) => {
2489
2544
  if (aiSkipCount > 0) aiSkipCount--;
2490
2545
  aiErrorStreak = 0;
@@ -2517,6 +2572,12 @@ export async function crawlProject(opts: {
2517
2572
  }),
2518
2573
  readScreen(page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>),
2519
2574
  ]);
2575
+
2576
+ // Cleanup highlights
2577
+ await page.evaluate(() => {
2578
+ const container = document.getElementById('__zeta_element_highlights');
2579
+ if (container) container.remove();
2580
+ }).catch(() => {});
2520
2581
  logger.info({ url, screenId: screen.id, version: screen.version, elements: elements.length, links: nextLinks.length, atSample: atElements.slice(0, 3).map((a: any) => a.label) }, '[crawler] screen captured');
2521
2582
  console.log('[crawler] AT:', JSON.stringify(atElements.slice(0, 5)));
2522
2583
  // Fire-and-forget keyboard-nav probe — records Tab sequence and focus-indicator issues per screen
@@ -2912,12 +2973,15 @@ export async function crawlProject(opts: {
2912
2973
  // P3a: Post-extraction DOM count — detect async-loaded content.
2913
2974
  // Only check sparse pages (preDomCount < 25) where async widgets are likely.
2914
2975
  // If DOM grew significantly during AI extraction, re-visit later with networkidle.
2915
- if (preDomCount < 25 && !revisitQueue.includes(url)) {
2976
+ const asyncContentCheckMinDom = parseInt(process.env.CRAWLER_ASYNC_CONTENT_MIN_DOM ?? '') || 25;
2977
+ if (preDomCount < asyncContentCheckMinDom && !revisitQueue.includes(url)) {
2916
2978
  try {
2917
2979
  const postDomCount = await page.evaluate(() =>
2918
2980
  document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
2919
2981
  ).catch(() => 0);
2920
- if (postDomCount > preDomCount * 1.3 + 4) {
2982
+ const asyncGrowthRatio = parseFloat(process.env.CRAWLER_ASYNC_GROWTH_RATIO ?? '') || 1.3;
2983
+ const asyncGrowthFloor = parseInt(process.env.CRAWLER_ASYNC_GROWTH_FLOOR ?? '') || 4;
2984
+ if (postDomCount > preDomCount * asyncGrowthRatio + asyncGrowthFloor) {
2921
2985
  revisitQueue.push(url);
2922
2986
  logger.info({ url, preDomCount, postDomCount }, '[P3a] Async content detected — scheduled for re-visit');
2923
2987
  }
@@ -2926,7 +2990,7 @@ export async function crawlProject(opts: {
2926
2990
 
2927
2991
  // P2: Static JS router detection — scan bundle for declared client-side routes.
2928
2992
  // Only run on first 5 pages (bundle is the same across pages; scanning every page is wasteful).
2929
- if (visited.size <= 5) {
2993
+ if (visited.size <= (parseInt(process.env.CRAWLER_SPA_ROUTER_DETECT_LIMIT ?? '') || 5)) {
2930
2994
  try {
2931
2995
  const jsDetectedRoutes = await detectJsRoutes(page, origin);
2932
2996
  for (const route of jsDetectedRoutes) {
@@ -2959,7 +3023,7 @@ export async function crawlProject(opts: {
2959
3023
  // Triggered per-page when: zero DOM links found + fewer than 5 elements (dead-end / canvas page).
2960
3024
  // Skyvern returns URLs it discovered → added to queue for ZeTa's own BFS crawl (not saved directly).
2961
3025
  // ZeTa does all actual crawling — Skyvern is only a URL-discovery override layer.
2962
- if (nextLinks.length === 0 && elements.length < 5 && opts.skyvernConfig) {
3026
+ if (nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? '') || 5) && opts.skyvernConfig) {
2963
3027
  try {
2964
3028
  logger.info({ url }, 'Gap 5: thin page detected — asking Skyvern to discover navigation URLs');
2965
3029
  const skyvernNav = await skyvernNavigate(
@@ -3073,7 +3137,8 @@ export async function crawlProject(opts: {
3073
3137
  // Skyvern is ONLY a URL-discovery layer here — it does NOT save screens directly.
3074
3138
  // Use only successfully-saved screens (screenId present), not total discovered including errors.
3075
3139
  const successfulScreenCount = discovered.filter(d => d.screenId).length;
3076
- if (successfulScreenCount < 10 && opts.skyvernConfig) {
3140
+ const skyvernMinScreens = parseInt(process.env.CRAWL_SKYVERN_MIN_SCREENS ?? '') || 10;
3141
+ if (successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
3077
3142
  await onProgress?.(78, 'Few screens found — using Skyvern to discover additional navigation URLs');
3078
3143
  const currentUrl = page.url();
3079
3144
  const skyvernNav = await skyvernNavigate(
@@ -3231,7 +3296,7 @@ export async function crawlProject(opts: {
3231
3296
  // P6: Gap Pass — LLM gap analysis on discovered screens to find structurally missing pages.
3232
3297
  // Runs after P3a re-visits so the gap analysis has the most complete screen list possible.
3233
3298
  // Confirmed gap URLs (HEAD=200) are visited directly with the existing browser.
3234
- if (visited.size >= 3) {
3299
+ if (visited.size >= (parseInt(process.env.CRAWLER_GAP_PASS_MIN_SCREENS ?? '') || 3)) {
3235
3300
  try {
3236
3301
  await onProgress?.(80, 'Running gap analysis…');
3237
3302
  const llmCfgForGap = await getCachedCrawlLLM(opts.tenantId);
@@ -3257,7 +3322,7 @@ export async function crawlProject(opts: {
3257
3322
 
3258
3323
  // Visit each confirmed gap URL with the existing browser (depth=1, no link traversal)
3259
3324
  const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
3260
- for (const gap of confirmedGaps.slice(0, 8)) {
3325
+ for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? '') || 8)) {
3261
3326
  const gapUrl = gap.confirmedUrl!;
3262
3327
  if (visited.size >= maxScreens) break;
3263
3328
  try {
@@ -3852,7 +3917,7 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
3852
3917
  }
3853
3918
  }
3854
3919
 
3855
- async function domFallbackElements(page: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
3920
+ export async function domFallbackElements(page: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
3856
3921
  return page.evaluate(() => {
3857
3922
  const out: { meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }[] = [];
3858
3923
  const seen = new Set<string>();
package/src/frontier.ts CHANGED
@@ -27,11 +27,13 @@ function scoreUrl(url: string, keywords: string[]): number {
27
27
  try {
28
28
  const u = new URL(url);
29
29
  const segments = u.pathname.split('/').filter(Boolean);
30
- let score = -segments.length;
30
+ const depthPenaltyWeight = parseFloat(process.env.CRAWLER_FRONTIER_DEPTH_PENALTY ?? '') || 1;
31
+ const keywordBoostWeight = parseFloat(process.env.CRAWLER_FRONTIER_KEYWORD_BOOST ?? '') || 10;
32
+ let score = -segments.length * depthPenaltyWeight;
31
33
  if (keywords.length > 0) {
32
34
  const lower = url.toLowerCase();
33
35
  for (const kw of keywords) {
34
- if (kw && lower.includes(kw)) score += 10;
36
+ if (kw && lower.includes(kw)) score += keywordBoostWeight;
35
37
  }
36
38
  }
37
39
  return score;
@@ -69,7 +71,7 @@ export class CrawlFrontier {
69
71
  const sparseBoost =
70
72
  this.strategy === 'BEST_FIRST' &&
71
73
  pushOpts?.parentElements !== undefined &&
72
- pushOpts.parentElements < 5
74
+ pushOpts.parentElements < (parseInt(process.env.CRAWLER_FRONTIER_SPARSE_PARENT_THRESHOLD ?? '') || 5)
73
75
  ? 1
74
76
  : 0;
75
77
  this.items.push({ url, score: this.strategy === 'BEST_FIRST' ? scoreUrl(url, this.keywords) + sparseBoost : 0 });
package/src/gap-pass.ts CHANGED
@@ -73,7 +73,7 @@ Respond with ONLY valid JSON, no markdown:
73
73
  const Anthropic = (await import('@anthropic-ai/sdk')).default;
74
74
  const client = new Anthropic({ apiKey: llmCfg.apiKey });
75
75
  const response = await client.messages.create({
76
- model: llmCfg.model ?? 'claude-haiku-4-5-20251001',
76
+ model: llmCfg.model ?? process.env.CRAWLER_DEFAULT_MODEL ?? 'claude-haiku-4-5-20251001',
77
77
  max_tokens: 1024,
78
78
  temperature: 0,
79
79
  // @ts-ignore
@@ -63,7 +63,12 @@ async function getChromeFetchDispatcher() {
63
63
  const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
64
64
 
65
65
  // Pages unlikely to have testable UI — skip from HTTP crawl queue
66
- const SKIP_PATH = /\/(terms|privacy|dpa|legal|cookie|changelog|blog|help|docs|about|contact|careers|press|imprint|gdpr|compliance|acceptable-use|security-policy|accessibility)([-/]|$)/i;
66
+ const _skipPathExtra = process.env.CRAWL_SKIP_PATH_EXTRA
67
+ ? process.env.CRAWL_SKIP_PATH_EXTRA.split(',').map((p) => p.trim()).filter(Boolean)
68
+ : [];
69
+ const SKIP_PATH = _skipPathExtra.length > 0
70
+ ? new RegExp(`/(terms|privacy|dpa|legal|cookie|changelog|blog|help|docs|about|contact|careers|press|imprint|gdpr|compliance|acceptable-use|security-policy|accessibility|${_skipPathExtra.join('|')})([-/]|$)`, 'i')
71
+ : /\/(terms|privacy|dpa|legal|cookie|changelog|blog|help|docs|about|contact|careers|press|imprint|gdpr|compliance|acceptable-use|security-policy|accessibility)([-/]|$)/i;
67
72
 
68
73
  export type HttpOutputFormat = 'json' | 'jsonlines' | 'csv' | 'markdown' | 'html';
69
74
 
@@ -212,7 +217,7 @@ async function fetchWithBackoff(
212
217
  'Accept-Language': 'en-US,en;q=0.9',
213
218
  'Accept-Encoding': 'gzip, deflate, br, zstd',
214
219
  // Chrome-like security headers reduce bot-detection fingerprint
215
- 'sec-ch-ua': '"Google Chrome";v="120", "Chromium";v="120", "Not-A.Brand";v="99"',
220
+ 'sec-ch-ua': `"Google Chrome";v="${process.env.CRAWLER_CHROME_VERSION ?? '120'}", "Chromium";v="${process.env.CRAWLER_CHROME_VERSION ?? '120'}", "Not-A.Brand";v="99"`,
216
221
  'sec-ch-ua-mobile': '?0',
217
222
  'sec-ch-ua-platform': '"Windows"',
218
223
  'Sec-Fetch-Dest': 'document',
@@ -287,10 +292,10 @@ async function fetchWithBackoff(
287
292
 
288
293
  export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
289
294
  const startTime = performance.now();
290
- const maxScreens = opts.maxScreens ?? 100;
295
+ const maxScreens = opts.maxScreens ?? (parseInt(process.env.CRAWLER_HTTP_ENGINE_MAX_SCREENS ?? '') || 100);
291
296
  const cacheDir = opts.cacheDir ?? process.env.CRAWLER_CACHE_DIR ?? '/tmp/zeta-cache';
292
297
  const timeoutMs = opts.timeoutMs ?? 15_000;
293
- const CRAWLER_USER_AGENT = process.env.CRAWLER_USER_AGENT ?? 'ZeTa-Crawler/1.0 (+https://zetaai.io/crawler)';
298
+ const CRAWLER_USER_AGENT = process.env.CRAWLER_USER_AGENT ?? 'ZeTa-Crawler/1.0 (+https://zeta.taodigitalsolutions.com/crawler)';
294
299
  const userAgent = opts.userAgent ?? CRAWLER_USER_AGENT;
295
300
  const cache = new ResponseCache(cacheDir);
296
301
  const robots = opts.respectRobotsTxt !== false ? new RobotsCache() : null;