@detiq/crawler 1.1.13 → 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.13",
3
+ "version": "1.1.14",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
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>();
@@ -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
@@ -860,8 +872,8 @@ export async function crawlProject(opts: {
860
872
  // Use resolvePage() to get the active V3Page (which is a full Playwright proxy).
861
873
  const page = await (stagehand as any).resolvePage();
862
874
  // Cap all Playwright operations so one stuck page can't freeze the crawl.
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 */ }
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 */ }
865
877
  // Stagehand V3Page proxy only supports the 'console' event (page.on throws for others).
866
878
  // rawPage will be wired to a real Playwright Page via the CDP connection established below.
867
879
  // Declared as `let` so the network-observer block can populate it.
@@ -1681,7 +1693,7 @@ export async function crawlProject(opts: {
1681
1693
  visited.set(canonUrl, '_pending_');
1682
1694
 
1683
1695
  try {
1684
- 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);
1685
1697
  if (navErr && /timeout/i.test(String((navErr as any)?.message ?? navErr))) {
1686
1698
  await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
1687
1699
  }
@@ -1755,7 +1767,7 @@ export async function crawlProject(opts: {
1755
1767
  for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
1756
1768
  const wp = await pwCtx.newPage();
1757
1769
  await wp.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); }).catch(() => {});
1758
- wp.setDefaultTimeout(parseInt(process.env.CRAWLER_PLAYWRIGHT_DEFAULT_TIMEOUT_MS ?? '') || 45_000);
1770
+ wp.setDefaultTimeout(tuning.defaultTimeoutMs || 45_000);
1759
1771
  liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {})));
1760
1772
  }
1761
1773
  }
@@ -1890,7 +1902,7 @@ export async function crawlProject(opts: {
1890
1902
  }
1891
1903
 
1892
1904
  // Try fast 15 s first; only escalate to 30 s on timeout so happy-path pages don't wait
1893
- 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);
1894
1906
  if (navErr15) {
1895
1907
  if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
1896
1908
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
@@ -2345,7 +2357,7 @@ export async function crawlProject(opts: {
2345
2357
 
2346
2358
  // 2. Active nav item — SPAs set aria-current="page" on the active route link
2347
2359
  try {
2348
- 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);
2349
2361
  if (activeNav) {
2350
2362
  const t = activeNav.trim();
2351
2363
  if (t.length > 1 && t.length < 60 && t.toLowerCase() !== brandName) return t;
@@ -2359,7 +2371,7 @@ export async function crawlProject(opts: {
2359
2371
  'main h2', '[role="main"] h2', '.page-header h2',
2360
2372
  ];
2361
2373
  for (const sel of selectors) {
2362
- 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);
2363
2375
  if (!h) continue;
2364
2376
  const t = h.trim();
2365
2377
  if (t.length < 2 || t.length > 80) continue;
@@ -5439,6 +5451,7 @@ async function runAuthDiscoveryPhase(
5439
5451
  origin: string,
5440
5452
  onProgress?: AuthDiscoveryProgress,
5441
5453
  ): Promise<{ captured: number; discoveredUrls: string[] }> {
5454
+ const tuning = await getCrawlTuning();
5442
5455
  const queue: Array<{ url: string; source: AuthDiscoverySource }> = [];
5443
5456
  const queued = new Set<string>();
5444
5457
  const capturedKeys = new Set<string>();
@@ -5478,7 +5491,7 @@ async function runAuthDiscoveryPhase(
5478
5491
  while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
5479
5492
  const current = queue.shift()!;
5480
5493
  try {
5481
- 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);
5482
5495
  if (response && response.status && response.status() >= 400) continue;
5483
5496
  await page.waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => { });
5484
5497
  await waitForStableDOM(page);
@@ -70,6 +70,18 @@ const SKIP_PATH = _skipPathExtra.length > 0
70
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
71
  : /\/(terms|privacy|dpa|legal|cookie|changelog|blog|help|docs|about|contact|careers|press|imprint|gdpr|compliance|acceptable-use|security-policy|accessibility)([-/]|$)/i;
72
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
+ }
84
+
73
85
  export type HttpOutputFormat = 'json' | 'jsonlines' | 'csv' | 'markdown' | 'html';
74
86
 
75
87
  export interface HttpScreen {
@@ -101,7 +113,7 @@ export interface HttpCrawlOpts {
101
113
  urlFilter?: string; // regex pattern — only crawl matching URLs
102
114
  traversalStrategy?: 'BFS' | 'DFS'; // default BFS
103
115
  outputFormats?: HttpOutputFormat[];
104
- cacheDir?: string; // default process.env.CRAWLER_CACHE_DIR || '/tmp/zeta-cache'
116
+ cacheDir?: string; // default '/tmp/zeta-cache'
105
117
  userAgent?: string;
106
118
  headers?: Record<string, string>;
107
119
  respectRobotsTxt?: boolean; // default true
@@ -200,8 +212,10 @@ async function fetchWithBackoff(
200
212
  url: string,
201
213
  cache: ResponseCache,
202
214
  opts: { userAgent: string; timeoutMs: number; extraHeaders?: Record<string, string> },
203
- retryCount = 0
215
+ retryCount = 0,
216
+ httpCfg?: Awaited<ReturnType<typeof getHttpConfig>>
204
217
  ): Promise<FetchResult> {
218
+ if (!httpCfg) httpCfg = await getHttpConfig();
205
219
  const t0 = performance.now();
206
220
 
207
221
  if (cache.isFresh(url)) {
@@ -255,20 +269,20 @@ async function fetchWithBackoff(
255
269
 
256
270
  // 429 / 503 — exponential backoff with jitter
257
271
  if (response.status === 429 || response.status === 503) {
258
- 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}`);
259
273
  const retryAfter = parseInt(response.headers.get('retry-after') ?? '0') || 0;
260
- 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));
261
275
  const jitter = Math.random() * 1000;
262
- 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})`);
263
277
  await new Promise(r => setTimeout(r, baseDelay + jitter));
264
- return fetchWithBackoff(url, cache, opts, retryCount + 1);
278
+ return fetchWithBackoff(url, cache, opts, retryCount + 1, httpCfg);
265
279
  }
266
280
 
267
281
  // 5xx — retry up to 3 times
268
282
  if (response.status >= 500) {
269
- if (retryCount < 3) {
283
+ if (retryCount < httpCfg.retry5xx) {
270
284
  await new Promise(r => setTimeout(r, 2_000 * (retryCount + 1)));
271
- return fetchWithBackoff(url, cache, opts, retryCount + 1);
285
+ return fetchWithBackoff(url, cache, opts, retryCount + 1, httpCfg);
272
286
  }
273
287
  if (cache.staleIfError(url)) {
274
288
  const cached = cache.get(url)!;
@@ -292,11 +306,11 @@ async function fetchWithBackoff(
292
306
 
293
307
  export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
294
308
  const startTime = performance.now();
295
- const maxScreens = opts.maxScreens ?? (parseInt(process.env.CRAWLER_HTTP_ENGINE_MAX_SCREENS ?? '') || 100);
296
- const cacheDir = opts.cacheDir ?? process.env.CRAWLER_CACHE_DIR ?? '/tmp/zeta-cache';
297
- const timeoutMs = opts.timeoutMs ?? 15_000;
298
- const CRAWLER_USER_AGENT = process.env.CRAWLER_USER_AGENT ?? 'ZeTa-Crawler/1.0 (+https://zeta.taodigitalsolutions.com/crawler)';
299
- const userAgent = opts.userAgent ?? CRAWLER_USER_AGENT;
309
+ const httpCfg = await getHttpConfig();
310
+ const maxScreens = opts.maxScreens ?? 100;
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)';
300
314
  const cache = new ResponseCache(cacheDir);
301
315
  const robots = opts.respectRobotsTxt !== false ? new RobotsCache() : null;
302
316
  const urlFilterRe = opts.urlFilter ? (() => { try { return new RegExp(opts.urlFilter!, 'i'); } catch { return null; } })() : null;
@@ -340,7 +354,7 @@ export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
340
354
 
341
355
  let fetchResult: FetchResult;
342
356
  try {
343
- fetchResult = await fetchWithBackoff(url, cache, { userAgent, timeoutMs, extraHeaders: opts.headers });
357
+ fetchResult = await fetchWithBackoff(url, cache, { userAgent, timeoutMs, extraHeaders: opts.headers }, 0, httpCfg);
344
358
  if (fetchResult.statusCode === 429 || fetchResult.statusCode === 503) rateLimitedCount++;
345
359
  } catch (err: any) {
346
360
  console.error(`[http-engine] error fetching ${url}: ${err.message}`);
@@ -399,7 +413,7 @@ export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
399
413
  }));
400
414
  };
401
415
 
402
- const concurrency = opts.concurrency ?? 5;
416
+ const concurrency = opts.concurrency ?? httpCfg.concurrency;
403
417
 
404
418
  while (queue.length > 0 && screens.length < maxScreens) {
405
419
  const batch = queue.splice(0, concurrency);
package/src/job-queue.ts CHANGED
@@ -1703,7 +1703,9 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1703
1703
  const appUrl: string = config.appUrl ?? crawlCfg.appUrl;
1704
1704
  if (!appUrl) throw new Error('No app URL configured. Save the crawl settings first.');
1705
1705
 
1706
- 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);
1707
1709
  const proxyUrl: string | undefined = config.proxyUrl ?? crawlCfg.proxyUrl;
1708
1710
  const screenshotDir = os.tmpdir();
1709
1711
  const discoverStorageConfig = await getTenantStorageConfig(tenantId).catch(() => null);
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
  }