@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 +1 -1
- package/src/content-chunker.ts +2 -2
- package/src/crawler.ts +36 -23
- package/src/frontier.ts +5 -3
- package/src/gap-pass.ts +1 -1
- package/src/http-engine.ts +9 -4
- package/src/job-queue.ts +11 -5
- package/src/policies/crawl-safety.ts +7 -1
- package/src/profiles/domain-profile.ts +24 -1
- package/src/rate-limiter.ts +2 -2
- package/src/url-pattern-completer.ts +1 -1
- package/src/visual-diff-verdict.ts +4 -2
package/package.json
CHANGED
package/src/content-chunker.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
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
|
package/src/http-engine.ts
CHANGED
|
@@ -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
|
|
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':
|
|
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://
|
|
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: {
|
|
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
|
|
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
|
|
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;
|
package/src/rate-limiter.ts
CHANGED
|
@@ -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 <
|
|
28
|
-
: pHashDistance <
|
|
29
|
+
: pHashDistance < approveThreshold ? 'APPROVE'
|
|
30
|
+
: pHashDistance < reviewThreshold ? 'REVIEW'
|
|
29
31
|
: 'FLAG';
|
|
30
32
|
return { changeType, recommendation, changes };
|
|
31
33
|
}
|