@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 +1 -1
- package/src/content-chunker.ts +2 -2
- package/src/crawler.ts +54 -28
- package/src/frontier.ts +5 -3
- package/src/gap-pass.ts +1 -1
- package/src/http-engine.ts +35 -16
- package/src/job-queue.ts +14 -6
- 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/sitemap.ts +17 -10
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,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
|
|
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
|
|
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':
|
|
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 >=
|
|
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(
|
|
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}
|
|
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 <
|
|
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 ??
|
|
292
|
-
const timeoutMs = opts.timeoutMs ??
|
|
293
|
-
const
|
|
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 ??
|
|
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: {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 <
|
|
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,
|
|
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,
|
|
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 <
|
|
28
|
-
: pHashDistance <
|
|
29
|
+
: pHashDistance < approveThreshold ? 'APPROVE'
|
|
30
|
+
: pHashDistance < reviewThreshold ? 'REVIEW'
|
|
29
31
|
: 'FLAG';
|
|
30
32
|
return { changeType, recommendation, changes };
|
|
31
33
|
}
|