@detiq/crawler 1.1.12 → 1.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -21,9 +21,9 @@ export function estimateTokens(text: string): number {
21
21
  }
22
22
 
23
23
  export function chunkContent(text: string, opts: ChunkOpts): Chunk[] {
24
- const size = opts.chunkSize ?? 2000;
24
+ const size = opts.chunkSize ?? (parseInt(process.env.CRAWLER_CONTENT_CHUNK_SIZE ?? '') || 2000);
25
25
  const charSize = size * 4;
26
- const overlap = opts.overlap ?? 200;
26
+ const overlap = opts.overlap ?? (parseInt(process.env.CRAWLER_CONTENT_CHUNK_OVERLAP ?? '') || 200);
27
27
  const overlapChars = overlap * 4;
28
28
 
29
29
  switch (opts.strategy) {
package/src/crawler.ts CHANGED
@@ -75,6 +75,16 @@ import { generateOpenApiSpec, specToYaml } from './openapi-generator.js';
75
75
  import { WebhookNotifier } from './webhook-notifier.js';
76
76
  import { generatePlaywrightTest } from './playwright-recorder.js';
77
77
 
78
+ async function getCrawlTuning() {
79
+ const { getSystemConfig } = await import('@detiq/core');
80
+ const cfg = await getSystemConfig('crawl_tuning');
81
+ return {
82
+ navTimeoutMs: parseInt(String(cfg?.navTimeoutMs ?? '15000'), 10),
83
+ defaultTimeoutMs: parseInt(String(cfg?.defaultTimeoutMs ?? '2000'), 10),
84
+ userAgent: String(cfg?.userAgent ?? 'ZeTa-Crawler/1.0 (+https://zeta.taodigitalsolutions.com/crawler)'),
85
+ };
86
+ }
87
+
78
88
  // Module-level cache for per-crawl LLM config — avoids redundant DB round-trips inside per-page loops.
79
89
  // Keyed by tenantId so multi-tenant workers don't share configs across tenants.
80
90
  const _crawlLLMCache = new Map<string, any>();
@@ -155,9 +165,9 @@ type AuthDiscoveryOptions = {
155
165
  storageConfig?: any;
156
166
  };
157
167
 
158
- const AUTH_DISCOVERY_MAX_SCREENS = 36;
159
- const AUTH_DISCOVERY_MAX_QUEUE = 48;
160
- const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = 6;
168
+ const AUTH_DISCOVERY_MAX_SCREENS = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_SCREENS ?? '') || 36;
169
+ const AUTH_DISCOVERY_MAX_QUEUE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_QUEUE ?? '') || 48;
170
+ const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE ?? '') || 6;
161
171
  const AUTH_DISCOVERY_PATHS = [
162
172
  '/',
163
173
  '/login', '/log-in', '/signin', '/sign-in', '/auth', '/auth/login', '/account/login', '/user/login', '/app/login',
@@ -517,6 +527,8 @@ export async function crawlProject(opts: {
517
527
  /** Discovery mode only: called after each page with the current set of discovered original URLs — enables streaming partial results to callers. */
518
528
  onUrlsDiscovered?: (urls: string[]) => Promise<void>;
519
529
  }, onProgress?: (pct: number, step: string) => Promise<void>) {
530
+ const tuning = await getCrawlTuning();
531
+
520
532
  if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
521
533
 
522
534
  // Upload screenshot to cloud storage if configured, returns cloudUrl or undefined
@@ -576,15 +588,15 @@ export async function crawlProject(opts: {
576
588
  break;
577
589
  case 'openrouter':
578
590
  stagehandModel = `openai/${m}`;
579
- stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || 'https://openrouter.ai/api/v1' };
591
+ stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || process.env.CRAWLER_OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' };
580
592
  break;
581
593
  case 'ollama':
582
594
  stagehandModel = `ollama/${m}`;
583
- stagehandModelOpts = { baseURL: baseUrl || 'http://localhost:11434' };
595
+ stagehandModelOpts = { baseURL: baseUrl || process.env.CRAWLER_OLLAMA_BASE_URL || 'http://localhost:11434' };
584
596
  break;
585
597
  case 'nvidia':
586
598
  stagehandModel = `openai/${m.startsWith('openai/') ? m.slice(7) : m}`;
587
- stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || 'https://integrate.api.nvidia.com/v1' };
599
+ stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || process.env.CRAWLER_NVIDIA_BASE_URL || 'https://integrate.api.nvidia.com/v1' };
588
600
  break;
589
601
  case 'groq':
590
602
  stagehandModel = `groq/${m}`;
@@ -635,15 +647,18 @@ export async function crawlProject(opts: {
635
647
  const preferAnthropic = envModel.includes('claude') || (!envModel && !process.env.OPENAI_API_KEY);
636
648
  if (preferAnthropic && process.env.ANTHROPIC_API_KEY) {
637
649
  stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
638
- stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || 'claude-sonnet-4-6'}`;
650
+ const defaultAnthropicModel = process.env.CRAWLER_DEFAULT_MODEL ?? 'claude-sonnet-4-6';
651
+ stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || defaultAnthropicModel}`;
639
652
  stagehandConfigured = true;
640
653
  } else if (process.env.OPENAI_API_KEY) {
641
654
  stagehandModelOpts = { apiKey: process.env.OPENAI_API_KEY };
642
- stagehandModel = envModel.includes('/') ? envModel : `openai/${envModel || 'gpt-4o'}`;
655
+ const defaultOpenaiModel = process.env.CRAWLER_DEFAULT_MODEL ?? 'gpt-4o';
656
+ stagehandModel = envModel.includes('/') ? envModel : `openai/${envModel || defaultOpenaiModel}`;
643
657
  stagehandConfigured = true;
644
658
  } else if (process.env.ANTHROPIC_API_KEY) {
645
659
  stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
646
- stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || 'claude-sonnet-4-6'}`;
660
+ const defaultAnthropicModel2 = process.env.CRAWLER_DEFAULT_MODEL ?? 'claude-sonnet-4-6';
661
+ stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || defaultAnthropicModel2}`;
647
662
  stagehandConfigured = true;
648
663
  }
649
664
  }
@@ -848,7 +863,7 @@ export async function crawlProject(opts: {
848
863
  verbose: 1,
849
864
  logger: stagehandLogger,
850
865
  localBrowserLaunchOptions: { cdpUrl, headless: true },
851
- domSettleTimeout: 2000,
866
+ domSettleTimeout: parseInt(process.env.CRAWLER_DOM_SETTLE_TIMEOUT_MS ?? '') || 2000,
852
867
  });
853
868
 
854
869
  await stagehand.init();
@@ -857,8 +872,8 @@ export async function crawlProject(opts: {
857
872
  // Use resolvePage() to get the active V3Page (which is a full Playwright proxy).
858
873
  const page = await (stagehand as any).resolvePage();
859
874
  // 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 */ }
875
+ try { page.setDefaultTimeout(tuning.defaultTimeoutMs || 45_000); } catch { /* proxy may not expose this */ }
876
+ try { page.setDefaultNavigationTimeout(tuning.navTimeoutMs || 30_000); } catch { /* proxy may not expose this */ }
862
877
  // Stagehand V3Page proxy only supports the 'console' event (page.on throws for others).
863
878
  // rawPage will be wired to a real Playwright Page via the CDP connection established below.
864
879
  // Declared as `let` so the network-observer block can populate it.
@@ -1472,7 +1487,7 @@ export async function crawlProject(opts: {
1472
1487
  ];
1473
1488
  const queued = new Set<string>(seedUrls.map(canonicalize));
1474
1489
  const queue = new CrawlFrontier(opts.traversalStrategy ?? 'BFS', seedUrls, opts.bestFirstKeywords, opts.urlFilter);
1475
- const deduplicator = new ContentDeduplicator(0.88);
1490
+ const deduplicator = new ContentDeduplicator(parseFloat(process.env.CRAWLER_DEDUP_THRESHOLD ?? '') || 0.88);
1476
1491
  const deadLinkTracker = new DeadLinkTracker();
1477
1492
  const redirectRegistry = new RedirectRegistry();
1478
1493
  // Re-seed from previously-discovered screens so deep routes found by AI link
@@ -1626,7 +1641,12 @@ export async function crawlProject(opts: {
1626
1641
  // Legal/policy-only pages — no interactive elements worth testing; skip to prevent noise
1627
1642
  // NOTE: /about, /contact, /careers, /help are intentionally NOT skipped — they have
1628
1643
  // 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;
1644
+ const skipPathExtra = process.env.CRAWL_SKIP_PATH_EXTRA
1645
+ ? process.env.CRAWL_SKIP_PATH_EXTRA.split(',').map((p) => p.trim()).filter(Boolean)
1646
+ : [];
1647
+ const SKIP_PATH = skipPathExtra.length > 0
1648
+ ? new RegExp(`/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap|${skipPathExtra.join('|')})([-/]|$)`, 'i')
1649
+ : /\/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap)([-/]|$)/i;
1630
1650
  const segIdx = opts.segmentIndex ?? 1;
1631
1651
 
1632
1652
  const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
@@ -1637,6 +1657,7 @@ export async function crawlProject(opts: {
1637
1657
  // (was: 3 errors → skip 10 screens — too aggressive for Nvidia NIM which rate-limits quickly)
1638
1658
  let aiErrorStreak = 0;
1639
1659
  let aiSkipCount = 0;
1660
+ const aiMinElements = parseInt(process.env.CRAWL_AI_LINK_MIN_ELEMENTS ?? '') || 5;
1640
1661
  let aiRateLimitWarned = false;
1641
1662
  // Hard guard: total URLs attempted (including skips) capped at maxScreens * 4
1642
1663
  // to prevent infinite-URL-explosion from WooCommerce/Shopify facet filter combos
@@ -1672,7 +1693,7 @@ export async function crawlProject(opts: {
1672
1693
  visited.set(canonUrl, '_pending_');
1673
1694
 
1674
1695
  try {
1675
- const navErr = await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).then(() => null as null, (e: any) => e);
1696
+ const navErr = await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: tuning.navTimeoutMs }).then(() => null as null, (e: any) => e);
1676
1697
  if (navErr && /timeout/i.test(String((navErr as any)?.message ?? navErr))) {
1677
1698
  await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
1678
1699
  }
@@ -1746,7 +1767,7 @@ export async function crawlProject(opts: {
1746
1767
  for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
1747
1768
  const wp = await pwCtx.newPage();
1748
1769
  await wp.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); }).catch(() => {});
1749
- wp.setDefaultTimeout(45_000);
1770
+ wp.setDefaultTimeout(tuning.defaultTimeoutMs || 45_000);
1750
1771
  liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {})));
1751
1772
  }
1752
1773
  }
@@ -1881,7 +1902,7 @@ export async function crawlProject(opts: {
1881
1902
  }
1882
1903
 
1883
1904
  // Try fast 15 s first; only escalate to 30 s on timeout so happy-path pages don't wait
1884
- const navErr15 = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).then(() => null, (e: any) => e);
1905
+ const navErr15 = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: tuning.navTimeoutMs }).then(() => null, (e: any) => e);
1885
1906
  if (navErr15) {
1886
1907
  if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
1887
1908
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
@@ -2336,7 +2357,7 @@ export async function crawlProject(opts: {
2336
2357
 
2337
2358
  // 2. Active nav item — SPAs set aria-current="page" on the active route link
2338
2359
  try {
2339
- const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: 2000 }).catch(() => null);
2360
+ const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
2340
2361
  if (activeNav) {
2341
2362
  const t = activeNav.trim();
2342
2363
  if (t.length > 1 && t.length < 60 && t.toLowerCase() !== brandName) return t;
@@ -2350,7 +2371,7 @@ export async function crawlProject(opts: {
2350
2371
  'main h2', '[role="main"] h2', '.page-header h2',
2351
2372
  ];
2352
2373
  for (const sel of selectors) {
2353
- const h = await page.locator(sel).first().textContent({ timeout: 2000 }).catch(() => null);
2374
+ const h = await page.locator(sel).first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
2354
2375
  if (!h) continue;
2355
2376
  const t = h.trim();
2356
2377
  if (t.length < 2 || t.length > 80) continue;
@@ -2530,7 +2551,7 @@ export async function crawlProject(opts: {
2530
2551
  // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
2531
2552
  // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
2532
2553
  // Skip AI on simple pages (< 5 interactive elements) — DOM-only is sufficient and saves ~12s per page.
2533
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0 || preDomCount < 5)
2554
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0 || preDomCount < aiMinElements)
2534
2555
  .then((freshLinks) => {
2535
2556
  if (aiSkipCount > 0) aiSkipCount--;
2536
2557
  aiErrorStreak = 0;
@@ -2964,12 +2985,15 @@ export async function crawlProject(opts: {
2964
2985
  // P3a: Post-extraction DOM count — detect async-loaded content.
2965
2986
  // Only check sparse pages (preDomCount < 25) where async widgets are likely.
2966
2987
  // If DOM grew significantly during AI extraction, re-visit later with networkidle.
2967
- if (preDomCount < 25 && !revisitQueue.includes(url)) {
2988
+ const asyncContentCheckMinDom = parseInt(process.env.CRAWLER_ASYNC_CONTENT_MIN_DOM ?? '') || 25;
2989
+ if (preDomCount < asyncContentCheckMinDom && !revisitQueue.includes(url)) {
2968
2990
  try {
2969
2991
  const postDomCount = await page.evaluate(() =>
2970
2992
  document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
2971
2993
  ).catch(() => 0);
2972
- if (postDomCount > preDomCount * 1.3 + 4) {
2994
+ const asyncGrowthRatio = parseFloat(process.env.CRAWLER_ASYNC_GROWTH_RATIO ?? '') || 1.3;
2995
+ const asyncGrowthFloor = parseInt(process.env.CRAWLER_ASYNC_GROWTH_FLOOR ?? '') || 4;
2996
+ if (postDomCount > preDomCount * asyncGrowthRatio + asyncGrowthFloor) {
2973
2997
  revisitQueue.push(url);
2974
2998
  logger.info({ url, preDomCount, postDomCount }, '[P3a] Async content detected — scheduled for re-visit');
2975
2999
  }
@@ -2978,7 +3002,7 @@ export async function crawlProject(opts: {
2978
3002
 
2979
3003
  // P2: Static JS router detection — scan bundle for declared client-side routes.
2980
3004
  // Only run on first 5 pages (bundle is the same across pages; scanning every page is wasteful).
2981
- if (visited.size <= 5) {
3005
+ if (visited.size <= (parseInt(process.env.CRAWLER_SPA_ROUTER_DETECT_LIMIT ?? '') || 5)) {
2982
3006
  try {
2983
3007
  const jsDetectedRoutes = await detectJsRoutes(page, origin);
2984
3008
  for (const route of jsDetectedRoutes) {
@@ -3011,7 +3035,7 @@ export async function crawlProject(opts: {
3011
3035
  // Triggered per-page when: zero DOM links found + fewer than 5 elements (dead-end / canvas page).
3012
3036
  // Skyvern returns URLs it discovered → added to queue for ZeTa's own BFS crawl (not saved directly).
3013
3037
  // ZeTa does all actual crawling — Skyvern is only a URL-discovery override layer.
3014
- if (nextLinks.length === 0 && elements.length < 5 && opts.skyvernConfig) {
3038
+ if (nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? '') || 5) && opts.skyvernConfig) {
3015
3039
  try {
3016
3040
  logger.info({ url }, 'Gap 5: thin page detected — asking Skyvern to discover navigation URLs');
3017
3041
  const skyvernNav = await skyvernNavigate(
@@ -3125,7 +3149,8 @@ export async function crawlProject(opts: {
3125
3149
  // Skyvern is ONLY a URL-discovery layer here — it does NOT save screens directly.
3126
3150
  // Use only successfully-saved screens (screenId present), not total discovered including errors.
3127
3151
  const successfulScreenCount = discovered.filter(d => d.screenId).length;
3128
- if (successfulScreenCount < 10 && opts.skyvernConfig) {
3152
+ const skyvernMinScreens = parseInt(process.env.CRAWL_SKYVERN_MIN_SCREENS ?? '') || 10;
3153
+ if (successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
3129
3154
  await onProgress?.(78, 'Few screens found — using Skyvern to discover additional navigation URLs');
3130
3155
  const currentUrl = page.url();
3131
3156
  const skyvernNav = await skyvernNavigate(
@@ -3283,7 +3308,7 @@ export async function crawlProject(opts: {
3283
3308
  // P6: Gap Pass — LLM gap analysis on discovered screens to find structurally missing pages.
3284
3309
  // Runs after P3a re-visits so the gap analysis has the most complete screen list possible.
3285
3310
  // Confirmed gap URLs (HEAD=200) are visited directly with the existing browser.
3286
- if (visited.size >= 3) {
3311
+ if (visited.size >= (parseInt(process.env.CRAWLER_GAP_PASS_MIN_SCREENS ?? '') || 3)) {
3287
3312
  try {
3288
3313
  await onProgress?.(80, 'Running gap analysis…');
3289
3314
  const llmCfgForGap = await getCachedCrawlLLM(opts.tenantId);
@@ -3309,7 +3334,7 @@ export async function crawlProject(opts: {
3309
3334
 
3310
3335
  // Visit each confirmed gap URL with the existing browser (depth=1, no link traversal)
3311
3336
  const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
3312
- for (const gap of confirmedGaps.slice(0, 8)) {
3337
+ for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? '') || 8)) {
3313
3338
  const gapUrl = gap.confirmedUrl!;
3314
3339
  if (visited.size >= maxScreens) break;
3315
3340
  try {
@@ -5426,6 +5451,7 @@ async function runAuthDiscoveryPhase(
5426
5451
  origin: string,
5427
5452
  onProgress?: AuthDiscoveryProgress,
5428
5453
  ): Promise<{ captured: number; discoveredUrls: string[] }> {
5454
+ const tuning = await getCrawlTuning();
5429
5455
  const queue: Array<{ url: string; source: AuthDiscoverySource }> = [];
5430
5456
  const queued = new Set<string>();
5431
5457
  const capturedKeys = new Set<string>();
@@ -5465,7 +5491,7 @@ async function runAuthDiscoveryPhase(
5465
5491
  while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
5466
5492
  const current = queue.shift()!;
5467
5493
  try {
5468
- const response = await page.goto(current.url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => null);
5494
+ const response = await page.goto(current.url, { waitUntil: 'domcontentloaded', timeout: tuning.navTimeoutMs }).catch(() => null);
5469
5495
  if (response && response.status && response.status() >= 400) continue;
5470
5496
  await page.waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => { });
5471
5497
  await waitForStableDOM(page);
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,24 @@ 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;
72
+
73
+ async function getHttpConfig() {
74
+ const { getSystemConfig } = await import('@detiq/core');
75
+ const cfg = await getSystemConfig('crawl_tuning');
76
+ return {
77
+ retryLimit: parseInt(String(cfg?.httpRetryLimit ?? '5'), 10),
78
+ backoffCapMs: parseInt(String(cfg?.httpBackoffCapMs ?? '60000'), 10),
79
+ retry5xx: parseInt(String(cfg?.http5xxRetryLimit ?? '3'), 10),
80
+ timeoutMs: parseInt(String(cfg?.httpTimeoutMs ?? '15000'), 10),
81
+ concurrency: parseInt(String(cfg?.httpConcurrency ?? '5'), 10),
82
+ };
83
+ }
67
84
 
68
85
  export type HttpOutputFormat = 'json' | 'jsonlines' | 'csv' | 'markdown' | 'html';
69
86
 
@@ -96,7 +113,7 @@ export interface HttpCrawlOpts {
96
113
  urlFilter?: string; // regex pattern — only crawl matching URLs
97
114
  traversalStrategy?: 'BFS' | 'DFS'; // default BFS
98
115
  outputFormats?: HttpOutputFormat[];
99
- cacheDir?: string; // default process.env.CRAWLER_CACHE_DIR || '/tmp/zeta-cache'
116
+ cacheDir?: string; // default '/tmp/zeta-cache'
100
117
  userAgent?: string;
101
118
  headers?: Record<string, string>;
102
119
  respectRobotsTxt?: boolean; // default true
@@ -195,8 +212,10 @@ async function fetchWithBackoff(
195
212
  url: string,
196
213
  cache: ResponseCache,
197
214
  opts: { userAgent: string; timeoutMs: number; extraHeaders?: Record<string, string> },
198
- retryCount = 0
215
+ retryCount = 0,
216
+ httpCfg?: Awaited<ReturnType<typeof getHttpConfig>>
199
217
  ): Promise<FetchResult> {
218
+ if (!httpCfg) httpCfg = await getHttpConfig();
200
219
  const t0 = performance.now();
201
220
 
202
221
  if (cache.isFresh(url)) {
@@ -212,7 +231,7 @@ async function fetchWithBackoff(
212
231
  'Accept-Language': 'en-US,en;q=0.9',
213
232
  'Accept-Encoding': 'gzip, deflate, br, zstd',
214
233
  // Chrome-like security headers reduce bot-detection fingerprint
215
- 'sec-ch-ua': '"Google Chrome";v="120", "Chromium";v="120", "Not-A.Brand";v="99"',
234
+ '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
235
  'sec-ch-ua-mobile': '?0',
217
236
  'sec-ch-ua-platform': '"Windows"',
218
237
  'Sec-Fetch-Dest': 'document',
@@ -250,20 +269,20 @@ async function fetchWithBackoff(
250
269
 
251
270
  // 429 / 503 — exponential backoff with jitter
252
271
  if (response.status === 429 || response.status === 503) {
253
- if (retryCount >= 5) throw new Error(`[http-engine] rate-limited after 5 retries: ${url}`);
272
+ if (retryCount >= httpCfg.retryLimit) throw new Error(`[http-engine] rate-limited after ${httpCfg.retryLimit} retries: ${url}`);
254
273
  const retryAfter = parseInt(response.headers.get('retry-after') ?? '0') || 0;
255
- const baseDelay = retryAfter > 0 ? retryAfter * 1000 : Math.min(60_000, 1_000 * Math.pow(2, retryCount));
274
+ const baseDelay = retryAfter > 0 ? retryAfter * 1000 : Math.min(httpCfg.backoffCapMs, 1_000 * Math.pow(2, retryCount));
256
275
  const jitter = Math.random() * 1000;
257
- console.log(`[http-engine] ${response.status} on ${url} — backing off ${Math.round(baseDelay + jitter)}ms (retry ${retryCount + 1}/5)`);
276
+ console.log(`[http-engine] ${response.status} on ${url} — backing off ${Math.round(baseDelay + jitter)}ms (retry ${retryCount + 1}/${httpCfg.retryLimit})`);
258
277
  await new Promise(r => setTimeout(r, baseDelay + jitter));
259
- return fetchWithBackoff(url, cache, opts, retryCount + 1);
278
+ return fetchWithBackoff(url, cache, opts, retryCount + 1, httpCfg);
260
279
  }
261
280
 
262
281
  // 5xx — retry up to 3 times
263
282
  if (response.status >= 500) {
264
- if (retryCount < 3) {
283
+ if (retryCount < httpCfg.retry5xx) {
265
284
  await new Promise(r => setTimeout(r, 2_000 * (retryCount + 1)));
266
- return fetchWithBackoff(url, cache, opts, retryCount + 1);
285
+ return fetchWithBackoff(url, cache, opts, retryCount + 1, httpCfg);
267
286
  }
268
287
  if (cache.staleIfError(url)) {
269
288
  const cached = cache.get(url)!;
@@ -287,11 +306,11 @@ async function fetchWithBackoff(
287
306
 
288
307
  export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
289
308
  const startTime = performance.now();
309
+ const httpCfg = await getHttpConfig();
290
310
  const maxScreens = opts.maxScreens ?? 100;
291
- const cacheDir = opts.cacheDir ?? process.env.CRAWLER_CACHE_DIR ?? '/tmp/zeta-cache';
292
- const timeoutMs = opts.timeoutMs ?? 15_000;
293
- const CRAWLER_USER_AGENT = process.env.CRAWLER_USER_AGENT ?? 'ZeTa-Crawler/1.0 (+https://zetaai.io/crawler)';
294
- const userAgent = opts.userAgent ?? CRAWLER_USER_AGENT;
311
+ const cacheDir = opts.cacheDir ?? '/tmp/zeta-cache';
312
+ const timeoutMs = opts.timeoutMs ?? httpCfg.timeoutMs;
313
+ const userAgent = opts.userAgent ?? 'ZeTa-Crawler/1.0 (+https://zeta.taodigitalsolutions.com/crawler)';
295
314
  const cache = new ResponseCache(cacheDir);
296
315
  const robots = opts.respectRobotsTxt !== false ? new RobotsCache() : null;
297
316
  const urlFilterRe = opts.urlFilter ? (() => { try { return new RegExp(opts.urlFilter!, 'i'); } catch { return null; } })() : null;
@@ -335,7 +354,7 @@ export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
335
354
 
336
355
  let fetchResult: FetchResult;
337
356
  try {
338
- fetchResult = await fetchWithBackoff(url, cache, { userAgent, timeoutMs, extraHeaders: opts.headers });
357
+ fetchResult = await fetchWithBackoff(url, cache, { userAgent, timeoutMs, extraHeaders: opts.headers }, 0, httpCfg);
339
358
  if (fetchResult.statusCode === 429 || fetchResult.statusCode === 503) rateLimitedCount++;
340
359
  } catch (err: any) {
341
360
  console.error(`[http-engine] error fetching ${url}: ${err.message}`);
@@ -394,7 +413,7 @@ export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
394
413
  }));
395
414
  };
396
415
 
397
- const concurrency = opts.concurrency ?? 5;
416
+ const concurrency = opts.concurrency ?? httpCfg.concurrency;
398
417
 
399
418
  while (queue.length > 0 && screens.length < maxScreens) {
400
419
  const batch = queue.splice(0, concurrency);
package/src/job-queue.ts CHANGED
@@ -383,12 +383,18 @@ export async function createTrackedJob(
383
383
  return job.id;
384
384
  }
385
385
 
386
- /** Mark a tracked job DONE or FAILED. Safe to call in finally — swallows errors. */
386
+ /** Mark a tracked job DONE or FAILED. Safe to call in finally — swallows errors.
387
+ * Gap 30: When marking FAILED, sets nextRetryAt (1 min) so the retry worker picks it up promptly. */
387
388
  export async function finishTrackedJob(tenantId: string, jobId: string, error?: string): Promise<void> {
389
+ const nextRetryAt = error ? new Date(Date.now() + 60_000) : undefined;
388
390
  await withTenant(tenantId, (tx) =>
389
391
  tx.platformJob.update({
390
392
  where: { id: jobId },
391
- data: { status: error ? 'FAILED' : 'DONE', finishedAt: new Date(), ...(error ? { error } : { progress: 100 }) },
393
+ data: {
394
+ status: error ? 'FAILED' : 'DONE',
395
+ finishedAt: new Date(),
396
+ ...(error ? { error, nextRetryAt } : { progress: 100 }),
397
+ },
392
398
  })
393
399
  ).catch(() => {});
394
400
  }
@@ -471,7 +477,7 @@ export async function cancelJob(tenantId: string, jobId: string): Promise<void>
471
477
  await withTenant(tenantId, (tx) =>
472
478
  tx.platformJob.update({
473
479
  where: { id: jobId },
474
- data: { status: 'CANCELLED', finishedAt: new Date() },
480
+ data: { status: 'CANCELLED', finishedAt: new Date(), heartbeatAt: null, workerId: null },
475
481
  })
476
482
  );
477
483
  }
@@ -637,7 +643,7 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
637
643
  await opts?.onProgress?.(pct);
638
644
  };
639
645
 
640
- const DEFAULT_MAX_SCREENS = 150;
646
+ const DEFAULT_MAX_SCREENS = parseInt(process.env.CRAWL_DEFAULT_MAX_SCREENS ?? '') || 150;
641
647
 
642
648
  try {
643
649
  // Per-project maxScreens + deviceProfiles + seedUrls + interactive probing + sitemap seeding + traversal strategy from crawlConfig override job config defaults.
@@ -1611,7 +1617,7 @@ export async function processDataRetentionPurge(): Promise<void> {
1611
1617
  orderBy: { dataRetentionDays: 'asc' },
1612
1618
  select: { dataRetentionDays: true },
1613
1619
  });
1614
- const retentionDays = setting?.dataRetentionDays ?? 365;
1620
+ const retentionDays = setting?.dataRetentionDays ?? (parseInt(process.env.DEFAULT_DATA_RETENTION_DAYS ?? '') || 365);
1615
1621
  const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
1616
1622
 
1617
1623
  const result = await prismaAdmin.auditLog.deleteMany({
@@ -1697,7 +1703,9 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1697
1703
  const appUrl: string = config.appUrl ?? crawlCfg.appUrl;
1698
1704
  if (!appUrl) throw new Error('No app URL configured. Save the crawl settings first.');
1699
1705
 
1700
- const maxDiscoverUrls: number = config.maxDiscoverUrls ?? 2000;
1706
+ const { getSystemConfig } = await import('@detiq/core');
1707
+ const crawlTuning = await getSystemConfig('crawl_tuning');
1708
+ const maxDiscoverUrls: number = config.maxDiscoverUrls ?? parseInt(String(crawlTuning?.maxDiscoverUrls ?? '2000'), 10);
1701
1709
  const proxyUrl: string | undefined = config.proxyUrl ?? crawlCfg.proxyUrl;
1702
1710
  const screenshotDir = os.tmpdir();
1703
1711
  const discoverStorageConfig = await getTenantStorageConfig(tenantId).catch(() => null);
@@ -6,7 +6,13 @@ export interface CrawlSafetyDecision {
6
6
  severity: 'info' | 'warn' | 'block';
7
7
  }
8
8
 
9
- const DESTRUCTIVE_ACTION = /\b(delete|remove|destroy|deactivate|disable|cancel|refund|void|transfer|withdraw|pay now|place order|submit order|confirm payment|send money|publish|approve|reject|bulk update|drop)\b/i;
9
+ const _extraDestructive = process.env.CRAWL_EXTRA_DESTRUCTIVE_PATTERNS
10
+ ? process.env.CRAWL_EXTRA_DESTRUCTIVE_PATTERNS.split(',').map((p) => p.trim()).filter(Boolean)
11
+ : [];
12
+ const DESTRUCTIVE_ACTION = new RegExp(
13
+ `\\b(delete|remove|destroy|deactivate|disable|cancel|refund|void|transfer|withdraw|pay now|place order|submit order|confirm payment|send money|publish|approve|reject|bulk update|drop${_extraDestructive.length ? `|${_extraDestructive.join('|')}` : ''})\\b`,
14
+ 'i',
15
+ );
10
16
  const SECRET_LIKE = /\b(api[_ -]?key|secret|token|password|authorization|cookie|set-cookie)\b/i;
11
17
 
12
18
  export function assessActionSafety(label: string, profile: DomainProfile): CrawlSafetyDecision {
@@ -114,7 +114,7 @@ const PROFILES: Record<Industry, Omit<DomainProfile, 'confidence' | 'evidence'>>
114
114
  },
115
115
  };
116
116
 
117
- const INDUSTRY_HINTS: Array<{ industry: Industry; terms: RegExp[] }> = [
117
+ const BASE_INDUSTRY_HINTS: Array<{ industry: Industry; terms: RegExp[] }> = [
118
118
  { industry: 'ecommerce', terms: [/cart/i, /checkout/i, /product/i, /catalog/i, /sku/i, /wishlist/i, /order/i] },
119
119
  { industry: 'banking', terms: [/account/i, /statement/i, /beneficiar/i, /transfer/i, /iban/i, /routing/i, /balance/i] },
120
120
  { industry: 'fintech', terms: [/wallet/i, /kyc/i, /payout/i, /card/i, /payment/i, /risk/i, /dispute/i] },
@@ -125,6 +125,29 @@ const INDUSTRY_HINTS: Array<{ industry: Industry; terms: RegExp[] }> = [
125
125
  { industry: 'saas', terms: [/workspace/i, /tenant/i, /integration/i, /billing/i, /api key/i, /admin/i, /dashboard/i] },
126
126
  ];
127
127
 
128
+ // Allow additional industry hint keywords via ENV without replacing the baseline.
129
+ // Format: JSON array of { industry: string; terms: string[] } — terms are treated as case-insensitive regex.
130
+ // Example: EXTRA_INDUSTRY_HINTS_JSON='[{"industry":"saas","terms":["platform","multitenant"]}]'
131
+ const _extraHints: Array<{ industry: Industry; terms: RegExp[] }> = (() => {
132
+ const raw = process.env.EXTRA_INDUSTRY_HINTS_JSON;
133
+ if (!raw) return [];
134
+ try {
135
+ const parsed: Array<{ industry: string; terms: string[] }> = JSON.parse(raw);
136
+ return parsed.map(h => ({ industry: h.industry as Industry, terms: h.terms.map(t => new RegExp(t, 'i')) }));
137
+ } catch { return []; }
138
+ })();
139
+
140
+ const INDUSTRY_HINTS = [...BASE_INDUSTRY_HINTS, ..._extraHints];
141
+
142
+ // Allow patching profile fields (riskyActions, piiRules, etc.) per industry via ENV.
143
+ // Format: JSON object keyed by industry, value is partial DomainProfile fields to merge.
144
+ // Example: DOMAIN_PROFILE_OVERRIDES_JSON='{"saas":{"riskyActions":["delete workspace","export all data"]}}'
145
+ const _profileOverrides: Partial<Record<Industry, Partial<Omit<DomainProfile, 'confidence' | 'evidence'>>>> = (() => {
146
+ const raw = process.env.DOMAIN_PROFILE_OVERRIDES_JSON;
147
+ if (!raw) return {};
148
+ try { return JSON.parse(raw); } catch { return {}; }
149
+ })();
150
+
128
151
  export function classifyDomainProfile(input: {
129
152
  appUrl: string;
130
153
  projectName?: string | null;
@@ -30,8 +30,8 @@ export class DomainRateLimiter {
30
30
  private readonly rateMap: Record<string, number>;
31
31
 
32
32
  constructor(opts: RateLimiterOpts = {}) {
33
- this.defaultRps = opts.defaultRps ?? 2;
34
- this.burstSize = opts.burstSize ?? 5;
33
+ this.defaultRps = opts.defaultRps ?? (parseFloat(process.env.CRAWLER_RATE_LIMIT_DEFAULT_RPS ?? '') || 2);
34
+ this.burstSize = opts.burstSize ?? (parseInt(process.env.CRAWLER_RATE_LIMIT_BURST_SIZE ?? '') || 5);
35
35
  this.rateMap = opts.rateMap ?? {};
36
36
  }
37
37
 
package/src/sitemap.ts CHANGED
@@ -13,7 +13,12 @@ import { logger } from './logger.js';
13
13
  const FETCH_TIMEOUT_MS = 10_000;
14
14
  const MAX_SITEMAP_INDEX_DEPTH = 3; // sitemap index -> sub-sitemaps -> (no deeper nesting expected)
15
15
  const MAX_SUB_SITEMAPS = 50; // bound fan-out from a single sitemap index
16
- const MAX_URLS = 5000; // sane upper bound so a huge/malicious sitemap can't blow up memory
16
+
17
+ async function getSitemapMaxUrls(): Promise<number> {
18
+ const { getSystemConfig } = await import('@detiq/core');
19
+ const cfg = await getSystemConfig('crawl_tuning');
20
+ return parseInt(String(cfg?.sitemapMaxUrls ?? '5000'), 10);
21
+ }
17
22
 
18
23
  function toArray<T>(x: T | T[] | undefined | null): T[] {
19
24
  if (x == null) return [];
@@ -44,7 +49,7 @@ export function sameSite(candidateUrl: string, siteOrigin: string): boolean {
44
49
  * and deduped. Pure function — no I/O — for direct unit testing without a
45
50
  * real HTTP fetch.
46
51
  */
47
- export function extractUrlsFromParsedSitemap(parsed: any, origin: string): { pageUrls: string[]; subSitemapUrls: string[] } {
52
+ export function extractUrlsFromParsedSitemap(parsed: any, origin: string, maxUrls = 5000): { pageUrls: string[]; subSitemapUrls: string[] } {
48
53
  const rawPageUrls: string[] = [];
49
54
  let subSitemapUrls: string[] = [];
50
55
 
@@ -66,7 +71,7 @@ export function extractUrlsFromParsedSitemap(parsed: any, origin: string): { pag
66
71
  // Same-site only (www/protocol-tolerant) — a sitemap listing external URLs shouldn't seed this project's queue.
67
72
  const sameOrigin = rawPageUrls.filter((u) => sameSite(u, origin));
68
73
 
69
- return { pageUrls: [...new Set(sameOrigin)].slice(0, MAX_URLS), subSitemapUrls };
74
+ return { pageUrls: [...new Set(sameOrigin)].slice(0, maxUrls), subSitemapUrls };
70
75
  }
71
76
 
72
77
  async function fetchAndParseXml(url: string, dispatcher?: any): Promise<any | null> {
@@ -103,22 +108,23 @@ async function fetchAndParseXml(url: string, dispatcher?: any): Promise<any | nu
103
108
  * Fetches and parses a sitemap (or sitemap index, recursed up to a small
104
109
  * depth) into a flat, deduped list of same-origin URLs.
105
110
  */
106
- export async function fetchSitemapUrls(sitemapUrl: string, origin: string, depth = 0, dispatcher?: any): Promise<string[]> {
111
+ export async function fetchSitemapUrls(sitemapUrl: string, origin: string, depth = 0, dispatcher?: any, maxUrls?: number): Promise<string[]> {
107
112
  if (depth > MAX_SITEMAP_INDEX_DEPTH) return [];
113
+ if (maxUrls === undefined) maxUrls = await getSitemapMaxUrls();
108
114
  const parsed = await fetchAndParseXml(sitemapUrl, dispatcher);
109
115
  if (!parsed) return [];
110
116
 
111
- const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parsed, origin);
117
+ const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parsed, origin, maxUrls);
112
118
  const collected = [...pageUrls];
113
119
 
114
- if (subSitemapUrls.length > 0 && collected.length < MAX_URLS) {
120
+ if (subSitemapUrls.length > 0 && collected.length < maxUrls) {
115
121
  const subResults = await Promise.all(
116
- subSitemapUrls.slice(0, 10).map(subUrl => fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher))
122
+ subSitemapUrls.slice(0, 10).map(subUrl => fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher, maxUrls))
117
123
  );
118
124
  for (const sub of subResults) collected.push(...sub);
119
125
  }
120
126
 
121
- return [...new Set(collected)].slice(0, MAX_URLS);
127
+ return [...new Set(collected)].slice(0, maxUrls);
122
128
  }
123
129
 
124
130
  /**
@@ -192,6 +198,7 @@ export async function discoverProjectUrls(
192
198
  try { origin = new URL(appUrl).origin; } catch { return { candidates: [], sitemapFound: false }; }
193
199
 
194
200
  const dispatcher = buildProxyDispatcher(opts?.proxyUrl);
201
+ const maxUrls = await getSitemapMaxUrls();
195
202
 
196
203
  // 1. robots.txt → declared sitemaps
197
204
  let robotsSitemaps: string[] = [];
@@ -216,11 +223,11 @@ export async function discoverProjectUrls(
216
223
  const seen = new Map<string, UrlCandidate>();
217
224
  let sitemapFound = false;
218
225
  for (const sm of sitemapTargets) {
219
- const urls = await fetchSitemapUrls(sm, origin, 0, dispatcher);
226
+ const urls = await fetchSitemapUrls(sm, origin, 0, dispatcher, maxUrls);
220
227
  if (urls.length) sitemapFound = true;
221
228
  const source: UrlCandidate['source'] = robotsSet.has(sm) ? 'robots' : 'sitemap';
222
229
  for (const u of urls) if (!seen.has(u)) seen.set(u, { url: u, source });
223
230
  }
224
231
 
225
- return { candidates: [...seen.values()].slice(0, MAX_URLS), sitemapFound };
232
+ return { candidates: [...seen.values()].slice(0, maxUrls), sitemapFound };
226
233
  }
@@ -70,7 +70,7 @@ Respond with ONLY valid JSON, no explanation:
70
70
  const Anthropic = (await import('@anthropic-ai/sdk')).default;
71
71
  const client = new Anthropic({ apiKey: llmCfg.apiKey });
72
72
  const response = await client.messages.create({
73
- model: llmCfg.model ?? 'claude-haiku-4-5-20251001',
73
+ model: llmCfg.model ?? process.env.CRAWLER_DEFAULT_MODEL ?? 'claude-haiku-4-5-20251001',
74
74
  max_tokens: 1024,
75
75
  temperature: 0,
76
76
  // @ts-ignore: cache_control supported in beta
@@ -23,9 +23,11 @@ export function pHashVerdict(
23
23
  : changeLevel === 'MINOR' ? 'MINOR'
24
24
  : changeLevel === 'MODERATE' ? 'SIGNIFICANT'
25
25
  : 'CRITICAL';
26
+ const approveThreshold = parseInt(process.env.CRAWLER_VISUAL_DIFF_APPROVE_THRESHOLD ?? '') || 5;
27
+ const reviewThreshold = parseInt(process.env.CRAWLER_VISUAL_DIFF_REVIEW_THRESHOLD ?? '') || 20;
26
28
  const recommendation: VisualDiffVerdict['recommendation'] = pHashDistance === 0 ? 'APPROVE'
27
- : pHashDistance < 5 ? 'APPROVE'
28
- : pHashDistance < 20 ? 'REVIEW'
29
+ : pHashDistance < approveThreshold ? 'APPROVE'
30
+ : pHashDistance < reviewThreshold ? 'REVIEW'
29
31
  : 'FLAG';
30
32
  return { changeType, recommendation, changes };
31
33
  }