@detiq/crawler 1.1.12 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.12",
3
+ "version": "1.1.13",
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
@@ -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
  }
@@ -2530,7 +2539,7 @@ export async function crawlProject(opts: {
2530
2539
  // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
2531
2540
  // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
2532
2541
  // 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)
2542
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0 || preDomCount < aiMinElements)
2534
2543
  .then((freshLinks) => {
2535
2544
  if (aiSkipCount > 0) aiSkipCount--;
2536
2545
  aiErrorStreak = 0;
@@ -2964,12 +2973,15 @@ export async function crawlProject(opts: {
2964
2973
  // P3a: Post-extraction DOM count — detect async-loaded content.
2965
2974
  // Only check sparse pages (preDomCount < 25) where async widgets are likely.
2966
2975
  // If DOM grew significantly during AI extraction, re-visit later with networkidle.
2967
- 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)) {
2968
2978
  try {
2969
2979
  const postDomCount = await page.evaluate(() =>
2970
2980
  document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
2971
2981
  ).catch(() => 0);
2972
- 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) {
2973
2985
  revisitQueue.push(url);
2974
2986
  logger.info({ url, preDomCount, postDomCount }, '[P3a] Async content detected — scheduled for re-visit');
2975
2987
  }
@@ -2978,7 +2990,7 @@ export async function crawlProject(opts: {
2978
2990
 
2979
2991
  // P2: Static JS router detection — scan bundle for declared client-side routes.
2980
2992
  // Only run on first 5 pages (bundle is the same across pages; scanning every page is wasteful).
2981
- if (visited.size <= 5) {
2993
+ if (visited.size <= (parseInt(process.env.CRAWLER_SPA_ROUTER_DETECT_LIMIT ?? '') || 5)) {
2982
2994
  try {
2983
2995
  const jsDetectedRoutes = await detectJsRoutes(page, origin);
2984
2996
  for (const route of jsDetectedRoutes) {
@@ -3011,7 +3023,7 @@ export async function crawlProject(opts: {
3011
3023
  // Triggered per-page when: zero DOM links found + fewer than 5 elements (dead-end / canvas page).
3012
3024
  // Skyvern returns URLs it discovered → added to queue for ZeTa's own BFS crawl (not saved directly).
3013
3025
  // ZeTa does all actual crawling — Skyvern is only a URL-discovery override layer.
3014
- 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) {
3015
3027
  try {
3016
3028
  logger.info({ url }, 'Gap 5: thin page detected — asking Skyvern to discover navigation URLs');
3017
3029
  const skyvernNav = await skyvernNavigate(
@@ -3125,7 +3137,8 @@ export async function crawlProject(opts: {
3125
3137
  // Skyvern is ONLY a URL-discovery layer here — it does NOT save screens directly.
3126
3138
  // Use only successfully-saved screens (screenId present), not total discovered including errors.
3127
3139
  const successfulScreenCount = discovered.filter(d => d.screenId).length;
3128
- if (successfulScreenCount < 10 && opts.skyvernConfig) {
3140
+ const skyvernMinScreens = parseInt(process.env.CRAWL_SKYVERN_MIN_SCREENS ?? '') || 10;
3141
+ if (successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
3129
3142
  await onProgress?.(78, 'Few screens found — using Skyvern to discover additional navigation URLs');
3130
3143
  const currentUrl = page.url();
3131
3144
  const skyvernNav = await skyvernNavigate(
@@ -3283,7 +3296,7 @@ export async function crawlProject(opts: {
3283
3296
  // P6: Gap Pass — LLM gap analysis on discovered screens to find structurally missing pages.
3284
3297
  // Runs after P3a re-visits so the gap analysis has the most complete screen list possible.
3285
3298
  // Confirmed gap URLs (HEAD=200) are visited directly with the existing browser.
3286
- if (visited.size >= 3) {
3299
+ if (visited.size >= (parseInt(process.env.CRAWLER_GAP_PASS_MIN_SCREENS ?? '') || 3)) {
3287
3300
  try {
3288
3301
  await onProgress?.(80, 'Running gap analysis…');
3289
3302
  const llmCfgForGap = await getCachedCrawlLLM(opts.tenantId);
@@ -3309,7 +3322,7 @@ export async function crawlProject(opts: {
3309
3322
 
3310
3323
  // Visit each confirmed gap URL with the existing browser (depth=1, no link traversal)
3311
3324
  const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
3312
- 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)) {
3313
3326
  const gapUrl = gap.confirmedUrl!;
3314
3327
  if (visited.size >= maxScreens) break;
3315
3328
  try {
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;
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({
@@ -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
 
@@ -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
  }