@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 +1 -1
- package/src/crawler.ts +21 -8
- package/src/http-engine.ts +29 -15
- package/src/job-queue.ts +3 -1
- package/src/sitemap.ts +17 -10
package/package.json
CHANGED
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(
|
|
864
|
-
try { page.setDefaultNavigationTimeout(
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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);
|
package/src/http-engine.ts
CHANGED
|
@@ -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
|
|
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 >=
|
|
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(
|
|
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}
|
|
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 <
|
|
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
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
const userAgent = opts.userAgent ??
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
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
|
}
|