doc2vec 2.2.0 → 2.4.0

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.
@@ -147,6 +147,14 @@ class ContentProcessor {
147
147
  if (!html || !html.trim()) {
148
148
  return '';
149
149
  }
150
+ // Pre-process tabbed content before sanitization strips ARIA attributes
151
+ const dom = new jsdom_1.JSDOM(html);
152
+ const tabButtons = dom.window.document.querySelectorAll('[role="tab"]');
153
+ if (tabButtons.length > 0) {
154
+ const logger = this.logger.child('markdown');
155
+ this.preprocessTabs(dom.window.document, logger);
156
+ html = dom.window.document.body.innerHTML;
157
+ }
150
158
  // Sanitize the HTML first
151
159
  const cleanHtml = (0, sanitize_html_1.default)(html, {
152
160
  allowedTags: [
@@ -171,12 +179,13 @@ class ContentProcessor {
171
179
  try {
172
180
  const response = await axios_1.default.get(sitemapUrl);
173
181
  const $ = (0, cheerio_1.load)(response.data, { xmlMode: true });
174
- const urls = [];
175
- // Handle standard sitemaps
176
- $('url > loc').each((_, element) => {
177
- const url = $(element).text().trim();
178
- if (url) {
179
- urls.push(url);
182
+ const urlMap = new Map();
183
+ // Handle standard sitemaps — extract <loc> and optional <lastmod>
184
+ $('url').each((_, element) => {
185
+ const loc = $(element).find('loc').text().trim();
186
+ const lastmod = $(element).find('lastmod').text().trim() || undefined;
187
+ if (loc) {
188
+ urlMap.set(loc, lastmod);
180
189
  }
181
190
  });
182
191
  // Handle sitemap indexes (sitemaps that link to other sitemaps)
@@ -190,18 +199,21 @@ class ContentProcessor {
190
199
  // Recursively process nested sitemaps
191
200
  for (const nestedSitemapUrl of sitemapLinks) {
192
201
  logger.debug(`Found nested sitemap: ${nestedSitemapUrl}`);
193
- const nestedUrls = await this.parseSitemap(nestedSitemapUrl, logger);
194
- urls.push(...nestedUrls);
202
+ const nestedMap = await this.parseSitemap(nestedSitemapUrl, logger);
203
+ for (const [url, lastmod] of nestedMap) {
204
+ urlMap.set(url, lastmod);
205
+ }
195
206
  }
196
- logger.info(`Found ${urls.length} URLs in sitemap ${sitemapUrl}`);
197
- return urls;
207
+ const withLastmod = [...urlMap.values()].filter(v => v !== undefined).length;
208
+ logger.info(`Found ${urlMap.size} URLs in sitemap ${sitemapUrl} (${withLastmod} with lastmod)`);
209
+ return urlMap;
198
210
  }
199
211
  catch (error) {
200
212
  logger.error(`Error parsing sitemap at ${sitemapUrl}:`, error);
201
- return [];
213
+ return new Map();
202
214
  }
203
215
  }
204
- async crawlWebsite(baseUrl, sourceConfig, processPageContent, parentLogger, visitedUrls) {
216
+ async crawlWebsite(baseUrl, sourceConfig, processPageContent, parentLogger, visitedUrls, options) {
205
217
  const logger = parentLogger.child('crawler');
206
218
  const queue = [baseUrl];
207
219
  const brokenLinks = [];
@@ -224,112 +236,494 @@ class ContentProcessor {
224
236
  brokenLinks.push({ source: sourceUrl, target: targetUrl });
225
237
  };
226
238
  addReferrer(baseUrl, baseUrl);
227
- // Process sitemap if provided
239
+ const { knownUrls, etagStore, lastmodStore } = options ?? {};
240
+ // Pre-seed queue with previously-known URLs so link discovery isn't lost
241
+ // when pages are skipped via ETag/lastmod matching on re-runs
242
+ if (knownUrls && knownUrls.size > 0) {
243
+ for (const url of knownUrls) {
244
+ if (url.startsWith(sourceConfig.url) && !queue.includes(url)) {
245
+ queue.push(url);
246
+ }
247
+ }
248
+ logger.info(`Pre-seeded ${knownUrls.size} known URLs from database into crawl queue`);
249
+ }
250
+ // Process sitemap if provided — extract URLs and lastmod dates
251
+ // The lastmod map is used later in the crawl loop for change detection
252
+ const sitemapLastmod = new Map();
228
253
  if (sourceConfig.sitemap_url) {
229
254
  logger.section('SITEMAP PROCESSING');
230
- const sitemapUrls = await this.parseSitemap(sourceConfig.sitemap_url, logger);
231
- // Add sitemap URLs to the queue if they're within the website scope
232
- for (const url of sitemapUrls) {
255
+ const sitemapData = await this.parseSitemap(sourceConfig.sitemap_url, logger);
256
+ // Collect parent URLs that have lastmod (directories ending with /)
257
+ // so we can inherit lastmod for child URLs that lack their own.
258
+ // Example: /gloo-mesh/2.10.x/ has lastmod but /gloo-mesh/2.10.x/reference/... doesn't.
259
+ const parentLastmods = [];
260
+ // First pass: add URLs to queue and collect explicit lastmod values
261
+ for (const [url, lastmod] of sitemapData) {
233
262
  if (url.startsWith(sourceConfig.url)) {
234
263
  addReferrer(url, sourceConfig.sitemap_url);
264
+ if (lastmod) {
265
+ sitemapLastmod.set(url, lastmod);
266
+ // Track as a potential parent for child URL inheritance
267
+ if (url.endsWith('/')) {
268
+ parentLastmods.push({ prefix: url, lastmod });
269
+ }
270
+ }
235
271
  if (!queue.includes(url)) {
236
272
  logger.debug(`Adding URL from sitemap to queue: ${url}`);
237
273
  queue.push(url);
238
274
  }
239
275
  }
240
276
  }
241
- logger.info(`Added ${queue.length - 1} URLs from sitemap to the crawl queue`);
277
+ // Second pass: inherit lastmod from the most specific parent for URLs
278
+ // that don't have their own lastmod
279
+ if (parentLastmods.length > 0) {
280
+ // Sort by prefix length descending so longest (most specific) match wins
281
+ parentLastmods.sort((a, b) => b.prefix.length - a.prefix.length);
282
+ let inheritedCount = 0;
283
+ for (const [url] of sitemapData) {
284
+ if (url.startsWith(sourceConfig.url) && !sitemapLastmod.has(url)) {
285
+ const parent = parentLastmods.find(p => url.startsWith(p.prefix));
286
+ if (parent) {
287
+ sitemapLastmod.set(url, parent.lastmod);
288
+ inheritedCount++;
289
+ }
290
+ }
291
+ }
292
+ if (inheritedCount > 0) {
293
+ logger.info(`Inherited lastmod from parent URLs for ${inheritedCount} child URLs without their own lastmod`);
294
+ }
295
+ }
296
+ logger.info(`Added ${queue.length - 1} URLs from sitemap to the crawl queue (${sitemapLastmod.size} with lastmod)`);
242
297
  }
243
298
  logger.info(`Starting crawl from ${baseUrl} with ${queue.length} URLs in initial queue`);
244
299
  let processedCount = 0;
245
300
  let skippedCount = 0;
246
301
  let skippedSizeCount = 0;
302
+ let etagSkippedCount = 0;
303
+ let lastmodSkippedCount = 0;
247
304
  let pdfProcessedCount = 0;
248
305
  let errorCount = 0;
249
306
  let hasNetworkErrors = false;
250
- while (queue.length > 0) {
251
- const url = queue.shift();
252
- if (!url)
253
- continue;
254
- const normalizedUrl = utils_1.Utils.normalizeUrl(url);
255
- if (visitedUrls.has(normalizedUrl))
256
- continue;
257
- visitedUrls.add(normalizedUrl);
258
- if (!utils_1.Utils.shouldProcessUrl(url)) {
259
- logger.debug(`Skipping URL with unsupported extension: ${url}`);
260
- skippedCount++;
261
- continue;
307
+ // Tracks whether the server supports ETags. Starts true and is set to false
308
+ // after a few consecutive successful HEAD responses return no ETag header,
309
+ // avoiding wasted HEAD requests for servers that don't support ETags.
310
+ // HEAD request failures (network errors, timeouts) do NOT count toward
311
+ // this threshold — they're transient issues, not evidence of missing ETag support.
312
+ let serverSupportsEtags = true;
313
+ let consecutiveMissingEtag = 0;
314
+ const MISSING_ETAG_THRESHOLD = 3;
315
+ // Track per-URL retry counts for rate limiting (429) responses
316
+ const retryCounts = new Map();
317
+ const MAX_RETRIES_PER_URL = 3;
318
+ const DEFAULT_RETRY_DELAY_MS = 30000; // 30s default when no Retry-After header
319
+ const MAX_RETRY_DELAY_MS = 120000; // Cap at 2 minutes
320
+ // Adaptive backoff for HEAD requests: starts at 0 (no delay), increases
321
+ // on 429 responses, and decays back toward 0 on successful responses.
322
+ let headBackoffMs = 0;
323
+ const HEAD_BACKOFF_INITIAL_MS = 200; // First backoff step after a 429
324
+ const HEAD_BACKOFF_MAX_MS = 5000; // Cap at 5 seconds
325
+ const HEAD_BACKOFF_DECAY_FACTOR = 0.5; // Halve delay on each success
326
+ const HEAD_BACKOFF_FLOOR_MS = 10; // Below this, snap to 0
327
+ // Launch a single browser instance and reuse one page (tab) for all URLs
328
+ let browser = null;
329
+ let page = null;
330
+ const launchBrowser = async () => {
331
+ let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
332
+ if (!executablePath) {
333
+ if (fs.existsSync('/usr/bin/chromium')) {
334
+ executablePath = '/usr/bin/chromium';
335
+ }
336
+ else if (fs.existsSync('/usr/bin/chromium-browser')) {
337
+ executablePath = '/usr/bin/chromium-browser';
338
+ }
262
339
  }
263
- try {
264
- logger.info(`Crawling: ${url}`);
265
- const sources = referrers.get(url) ?? new Set([baseUrl]);
266
- const content = await this.processPage(url, sourceConfig, (reportedUrl, status) => {
267
- if (status === 404) {
268
- for (const source of sources) {
269
- addBrokenLink(source, reportedUrl);
270
- }
340
+ const b = await puppeteer_1.default.launch({
341
+ executablePath,
342
+ args: [
343
+ '--no-sandbox',
344
+ '--disable-setuid-sandbox',
345
+ '--disable-dev-shm-usage', // Use /tmp instead of /dev/shm (critical in Docker, default 64MB)
346
+ '--disable-gpu',
347
+ '--disable-extensions',
348
+ ],
349
+ protocolTimeout: 60000,
350
+ });
351
+ const p = await b.newPage();
352
+ return { browser: b, page: p };
353
+ };
354
+ // Track whether the page needs to be recreated (e.g., after a timeout error)
355
+ let pageNeedsRecreation = false;
356
+ // Track consecutive protocol-level errors (indicates browser process is degraded)
357
+ let consecutiveProtocolErrors = 0;
358
+ // Track pages processed since the last browser (re)launch for periodic restarts
359
+ let pagesSinceRestart = 0;
360
+ // Restart the browser every N pages to prevent memory accumulation
361
+ const PAGES_BETWEEN_RESTARTS = 50;
362
+ const restartBrowser = async (reason) => {
363
+ logger.warn(`Restarting browser: ${reason}`);
364
+ if (browser) {
365
+ try {
366
+ await browser.close();
367
+ }
368
+ catch { /* ignore close errors on a degraded browser */ }
369
+ }
370
+ const launched = await launchBrowser();
371
+ browser = launched.browser;
372
+ page = launched.page;
373
+ pageNeedsRecreation = false;
374
+ consecutiveProtocolErrors = 0;
375
+ pagesSinceRestart = 0;
376
+ };
377
+ const ensureBrowser = async () => {
378
+ // If protocol errors are persisting after page recreation, the browser
379
+ // process itself is degraded — a full restart is needed.
380
+ const needsBrowserRestart = pageNeedsRecreation && consecutiveProtocolErrors >= 1;
381
+ if (!browser || !browser.isConnected() || needsBrowserRestart) {
382
+ const reason = needsBrowserRestart
383
+ ? `protocol errors persisting after page recreation (${consecutiveProtocolErrors} consecutive)`
384
+ : browser ? 'browser disconnected' : 'initial launch';
385
+ await restartBrowser(reason);
386
+ }
387
+ else if (pagesSinceRestart >= PAGES_BETWEEN_RESTARTS) {
388
+ // Periodic restart to prevent memory accumulation from long crawls
389
+ await restartBrowser(`periodic restart after ${pagesSinceRestart} pages`);
390
+ }
391
+ else if (pageNeedsRecreation) {
392
+ // The previous page.evaluate or navigation timed out / errored.
393
+ // Close the stale page and create a fresh one to avoid corrupted state.
394
+ logger.info('Recreating page after previous error...');
395
+ try {
396
+ if (page)
397
+ await page.close();
398
+ }
399
+ catch {
400
+ // Ignore errors closing a stale page
401
+ }
402
+ page = await browser.newPage();
403
+ pageNeedsRecreation = false;
404
+ }
405
+ return page;
406
+ };
407
+ try {
408
+ while (queue.length > 0) {
409
+ const url = queue.shift();
410
+ if (!url)
411
+ continue;
412
+ const normalizedUrl = utils_1.Utils.normalizeUrl(url);
413
+ if (visitedUrls.has(normalizedUrl))
414
+ continue;
415
+ visitedUrls.add(normalizedUrl);
416
+ if (!utils_1.Utils.shouldProcessUrl(url)) {
417
+ logger.debug(`Skipping URL with unsupported extension: ${url}`);
418
+ skippedCount++;
419
+ continue;
420
+ }
421
+ // Sitemap lastmod-based skip: if the sitemap provides a lastmod date for
422
+ // this URL and it matches the stored value, skip entirely. This avoids
423
+ // both HEAD requests and full page loads — the sitemap is fetched once
424
+ // for the entire crawl.
425
+ const urlLastmod = sitemapLastmod.get(url);
426
+ if (urlLastmod && lastmodStore) {
427
+ const storedLastmod = await lastmodStore.get(url);
428
+ if (storedLastmod && storedLastmod === urlLastmod) {
429
+ logger.info(`Lastmod unchanged (${urlLastmod}), skipping: ${url}`);
430
+ visitedUrls.add(url);
431
+ lastmodSkippedCount++;
432
+ continue;
271
433
  }
272
- });
273
- if (content !== null) {
274
- await processPageContent(url, content);
275
- if (utils_1.Utils.isPdfUrl(url)) {
276
- pdfProcessedCount++;
434
+ else if (storedLastmod) {
435
+ logger.debug(`Lastmod changed (stored: ${storedLastmod}, sitemap: ${urlLastmod}), processing: ${url}`);
277
436
  }
278
437
  else {
279
- processedCount++;
438
+ logger.debug(`No stored lastmod for ${url}, processing`);
280
439
  }
281
440
  }
282
- else {
283
- skippedSizeCount++;
284
- }
285
- // Only try to extract links from HTML pages, not PDFs
286
- if (!utils_1.Utils.isPdfUrl(url)) {
287
- const response = await axios_1.default.get(url);
288
- const $ = (0, cheerio_1.load)(response.data);
289
- const pageUrlForLinks = response?.request?.res?.responseUrl || url;
290
- logger.debug(`Finding links on page ${url}`);
291
- let newLinksFound = 0;
292
- $('a[href]').each((_, element) => {
293
- const href = $(element).attr('href');
294
- if (!href || href.startsWith('#') || href.startsWith('mailto:'))
295
- return;
296
- const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks);
297
- if (fullUrl.startsWith(sourceConfig.url)) {
298
- addReferrer(fullUrl, pageUrlForLinks);
299
- if (!visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
300
- if (!queue.includes(fullUrl)) {
301
- queue.push(fullUrl);
302
- newLinksFound++;
441
+ // ETag-based skip: send a lightweight HEAD request and compare the ETag
442
+ // to the stored value. If unchanged, skip the entire page (no Puppeteer,
443
+ // no content extraction, no chunking, no embedding).
444
+ // Skip ETag check if the URL has a lastmod from the sitemap we trust
445
+ // lastmod as the primary change-detection signal when available.
446
+ if (!urlLastmod && !utils_1.Utils.isPdfUrl(url) && etagStore && serverSupportsEtags) {
447
+ // Adaptive backoff: wait before HEAD if we've been rate-limited recently
448
+ if (headBackoffMs > 0) {
449
+ await new Promise(resolve => setTimeout(resolve, headBackoffMs));
450
+ }
451
+ const headConfig = {
452
+ timeout: 10000,
453
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; doc2vec crawler)' },
454
+ maxRedirects: 5,
455
+ };
456
+ // Checks an ETag from a HEAD response against the stored value.
457
+ // Returns 'skip' if unchanged, 'process' if changed/missing, 'no-etag' if header absent.
458
+ const checkEtag = async (etag) => {
459
+ if (!etag)
460
+ return 'no-etag';
461
+ consecutiveMissingEtag = 0;
462
+ const storedEtag = await etagStore.get(url);
463
+ if (storedEtag && storedEtag === etag) {
464
+ return 'skip';
465
+ }
466
+ else if (storedEtag) {
467
+ logger.debug(`ETag changed (stored: ${storedEtag}, received: ${etag}), processing: ${url}`);
468
+ }
469
+ else {
470
+ logger.debug(`No stored ETag for ${url}, processing`);
471
+ }
472
+ return 'process';
473
+ };
474
+ // Decay backoff on successful HEAD (non-429)
475
+ const decayBackoff = () => {
476
+ if (headBackoffMs > 0) {
477
+ headBackoffMs = headBackoffMs * HEAD_BACKOFF_DECAY_FACTOR;
478
+ if (headBackoffMs < HEAD_BACKOFF_FLOOR_MS) {
479
+ headBackoffMs = 0;
480
+ logger.debug('HEAD backoff decayed to 0ms');
481
+ }
482
+ }
483
+ };
484
+ // Increase backoff on 429
485
+ const increaseBackoff = () => {
486
+ const previousMs = headBackoffMs;
487
+ headBackoffMs = headBackoffMs === 0
488
+ ? HEAD_BACKOFF_INITIAL_MS
489
+ : Math.min(headBackoffMs * 2, HEAD_BACKOFF_MAX_MS);
490
+ logger.debug(`HEAD backoff increased: ${previousMs}ms → ${headBackoffMs}ms`);
491
+ };
492
+ try {
493
+ const headResponse = await axios_1.default.head(url, headConfig);
494
+ decayBackoff();
495
+ const result = await checkEtag(headResponse.headers['etag']);
496
+ if (result === 'skip') {
497
+ logger.info(`ETag unchanged, skipping: ${url}`);
498
+ visitedUrls.add(url);
499
+ etagSkippedCount++;
500
+ continue;
501
+ }
502
+ else if (result === 'no-etag') {
503
+ consecutiveMissingEtag++;
504
+ logger.debug(`No ETag in HEAD response for ${url} (${consecutiveMissingEtag}/${MISSING_ETAG_THRESHOLD})`);
505
+ if (consecutiveMissingEtag >= MISSING_ETAG_THRESHOLD) {
506
+ serverSupportsEtags = false;
507
+ logger.warn(`Server does not return ETag headers (${MISSING_ETAG_THRESHOLD} consecutive pages without ETag). Disabling ETag-based skip for this crawl.`);
508
+ }
509
+ }
510
+ }
511
+ catch (headError) {
512
+ // If the HEAD request was rate-limited (429), increase backoff,
513
+ // wait, and retry once. The backoff ensures subsequent HEAD
514
+ // requests are spaced out to avoid further 429s.
515
+ const headStatus = headError?.response?.status;
516
+ if (headStatus === 429) {
517
+ increaseBackoff();
518
+ const retryAfterMs = this.parseRetryAfter(headError.response?.headers?.['retry-after']);
519
+ const delayMs = Math.min(retryAfterMs ?? 5000, 30000);
520
+ logger.debug(`HEAD rate-limited (429) for ${url}, waiting ${Math.round(delayMs / 1000)}s before retry`);
521
+ await new Promise(resolve => setTimeout(resolve, delayMs));
522
+ try {
523
+ const retryResponse = await axios_1.default.head(url, headConfig);
524
+ decayBackoff();
525
+ const result = await checkEtag(retryResponse.headers['etag']);
526
+ if (result === 'skip') {
527
+ logger.info(`ETag unchanged, skipping: ${url}`);
528
+ visitedUrls.add(url);
529
+ etagSkippedCount++;
530
+ continue;
303
531
  }
532
+ // 'process' or 'no-etag' — fall through to full processing
533
+ }
534
+ catch {
535
+ logger.debug(`HEAD retry also failed for ${url}, falling through to full processing`);
304
536
  }
305
537
  }
306
- });
307
- logger.debug(`Found ${newLinksFound} new links on ${url}`);
538
+ else if (headStatus && headStatus >= 400 && headStatus < 500 && headStatus !== 405) {
539
+ // Client error (404, 403, 410, etc.) the page doesn't
540
+ // exist or is inaccessible. Skip instead of wasting a
541
+ // full Puppeteer load on a non-existent page.
542
+ // 405 (Method Not Allowed) is excluded because the server
543
+ // may not support HEAD but the page may still exist via GET.
544
+ logger.debug(`HEAD returned ${headStatus} for ${url}, skipping`);
545
+ skippedCount++;
546
+ continue;
547
+ }
548
+ else {
549
+ // Non-HTTP failure (network error, timeout), 5xx server
550
+ // error, or 405 — fall through to full processing.
551
+ // Do NOT count toward the missing-ETag threshold.
552
+ logger.debug(`HEAD request failed for ${url}: ${headError?.message || headError}. Falling through to full processing.`);
553
+ }
554
+ }
308
555
  }
309
- }
310
- catch (error) {
311
- logger.error(`Failed during processing or link discovery for ${url}:`, error);
312
- errorCount++;
313
- const status = this.getHttpStatus(error);
314
- if (status === 404) {
556
+ try {
557
+ logger.info(`Crawling: ${url}`);
315
558
  const sources = referrers.get(url) ?? new Set([baseUrl]);
316
- for (const source of sources) {
317
- addBrokenLink(source, url);
559
+ // For HTML pages, ensure the browser is running and pass the shared page
560
+ // For PDFs, processPage handles them without Puppeteer
561
+ const currentPage = utils_1.Utils.isPdfUrl(url) ? undefined : await ensureBrowser();
562
+ const result = await this.processPage(url, sourceConfig, (reportedUrl, status) => {
563
+ if (status === 404) {
564
+ for (const source of sources) {
565
+ addBrokenLink(source, reportedUrl);
566
+ }
567
+ }
568
+ }, currentPage);
569
+ let contentProcessedSuccessfully = false;
570
+ if (result.content !== null) {
571
+ contentProcessedSuccessfully = await processPageContent(url, result.content);
572
+ if (utils_1.Utils.isPdfUrl(url)) {
573
+ pdfProcessedCount++;
574
+ }
575
+ else {
576
+ processedCount++;
577
+ }
578
+ }
579
+ else {
580
+ skippedSizeCount++;
581
+ }
582
+ // Use links extracted from the full rendered DOM by processPage
583
+ // (no separate axios request needed)
584
+ if (result.links.length > 0) {
585
+ const pageUrlForLinks = result.finalUrl || url;
586
+ logger.debug(`Finding links on page ${url}`);
587
+ let newLinksFound = 0;
588
+ for (const href of result.links) {
589
+ const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks);
590
+ if (fullUrl.startsWith(sourceConfig.url)) {
591
+ addReferrer(fullUrl, pageUrlForLinks);
592
+ if (!visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
593
+ if (!queue.includes(fullUrl)) {
594
+ queue.push(fullUrl);
595
+ newLinksFound++;
596
+ }
597
+ }
598
+ }
599
+ }
600
+ logger.debug(`Found ${newLinksFound} new links on ${url}`);
601
+ }
602
+ // Only store ETag/lastmod when content was processed successfully.
603
+ // If chunking or embedding failed, we want the next run to retry
604
+ // this page rather than skipping it based on stale metadata.
605
+ if (contentProcessedSuccessfully) {
606
+ // Store the ETag for this URL so subsequent runs can skip it
607
+ if (result.etag && etagStore) {
608
+ try {
609
+ await etagStore.set(url, result.etag);
610
+ }
611
+ catch {
612
+ // ETag storage failure is non-critical
613
+ }
614
+ }
615
+ // Store the sitemap lastmod for this URL so subsequent runs can
616
+ // skip it without any HTTP requests when the lastmod hasn't changed
617
+ if (urlLastmod && lastmodStore) {
618
+ try {
619
+ await lastmodStore.set(url, urlLastmod);
620
+ }
621
+ catch {
622
+ // Lastmod storage failure is non-critical
623
+ }
624
+ }
625
+ }
626
+ // Page processed successfully — reset protocol error counter and
627
+ // increment the periodic restart counter
628
+ consecutiveProtocolErrors = 0;
629
+ if (!utils_1.Utils.isPdfUrl(url)) {
630
+ pagesSinceRestart++;
631
+ }
632
+ // Navigate to about:blank to clear any lingering JS, timers, or
633
+ // event listeners from the processed page before moving to the next URL.
634
+ if (currentPage) {
635
+ try {
636
+ await currentPage.goto('about:blank', { waitUntil: 'load', timeout: 5000 });
637
+ }
638
+ catch {
639
+ // If even about:blank fails, the page is stuck — mark for recreation
640
+ pageNeedsRecreation = true;
641
+ }
318
642
  }
319
643
  }
320
- // Check if this is a network error (DNS resolution, connection issues, etc.)
321
- if (this.isNetworkError(error)) {
322
- hasNetworkErrors = true;
323
- logger.warn(`Network error detected for ${url}, this may affect cleanup decisions`);
644
+ catch (error) {
645
+ const status = this.getHttpStatus(error);
646
+ // ── Handle 429 (Too Many Requests) with retry ──
647
+ if (status === 429) {
648
+ const retries = retryCounts.get(url) ?? 0;
649
+ if (retries < MAX_RETRIES_PER_URL) {
650
+ const retryAfterMs = error.retryAfterMs;
651
+ const delayMs = Math.min(retryAfterMs ?? DEFAULT_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS);
652
+ retryCounts.set(url, retries + 1);
653
+ logger.warn(`Rate limited (429) on ${url} (attempt ${retries + 1}/${MAX_RETRIES_PER_URL}). ` +
654
+ `Waiting ${Math.round(delayMs / 1000)}s before retry...`);
655
+ await new Promise(resolve => setTimeout(resolve, delayMs));
656
+ // Re-add to the front of the queue so it's retried next.
657
+ // Remove from visitedUrls so it's not skipped.
658
+ visitedUrls.delete(normalizedUrl);
659
+ queue.unshift(url);
660
+ // Do NOT set pageNeedsRecreation — the page/browser is healthy,
661
+ // the server is just rate-limiting us.
662
+ // Do NOT increment errorCount — this is a temporary condition.
663
+ continue;
664
+ }
665
+ // Max retries exhausted — fall through to normal error handling
666
+ logger.error(`Rate limit retries exhausted for ${url} after ${MAX_RETRIES_PER_URL} attempts`);
667
+ }
668
+ // ── General error handling ──
669
+ logger.error(`Failed during processing or link discovery for ${url}:`, error);
670
+ errorCount++;
671
+ // Mark the page for recreation so the next URL gets a clean tab.
672
+ // This prevents cascading failures from a stuck/corrupted page state.
673
+ pageNeedsRecreation = true;
674
+ // Track protocol-level errors separately — these indicate the browser
675
+ // process itself is degraded, not just the page. If they persist after
676
+ // page recreation, ensureBrowser() will escalate to a full browser restart.
677
+ if (this.isProtocolError(error)) {
678
+ consecutiveProtocolErrors++;
679
+ logger.warn(`Protocol error detected (${consecutiveProtocolErrors} consecutive), browser may need restart`);
680
+ }
681
+ else {
682
+ consecutiveProtocolErrors = 0;
683
+ }
684
+ if (status === 404) {
685
+ const sources = referrers.get(url) ?? new Set([baseUrl]);
686
+ for (const source of sources) {
687
+ addBrokenLink(source, url);
688
+ }
689
+ }
690
+ // Check if this is a network error (DNS resolution, connection issues, etc.)
691
+ if (this.isNetworkError(error)) {
692
+ hasNetworkErrors = true;
693
+ logger.warn(`Network error detected for ${url}, this may affect cleanup decisions`);
694
+ }
324
695
  }
325
696
  }
326
697
  }
327
- logger.info(`Crawl completed. HTML Pages: ${processedCount}, PDFs: ${pdfProcessedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
698
+ finally {
699
+ // Close the shared browser instance when the crawl is done
700
+ const browserToClose = browser;
701
+ if (browserToClose && browserToClose.isConnected()) {
702
+ await browserToClose.close();
703
+ logger.debug('Shared browser closed after crawl completed');
704
+ }
705
+ }
706
+ logger.info(`Crawl completed. HTML Pages: ${processedCount}, PDFs: ${pdfProcessedCount}, Skipped (Lastmod): ${lastmodSkippedCount}, Skipped (ETag): ${etagSkippedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
328
707
  if (hasNetworkErrors) {
329
708
  logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
330
709
  }
331
710
  return { hasNetworkErrors, brokenLinks };
332
711
  }
712
+ /**
713
+ * Detects Chrome DevTools Protocol-level errors that indicate the browser
714
+ * process is degraded/stuck (not just a single page issue).
715
+ * These errors cannot be fixed by page recreation alone — the browser needs a full restart.
716
+ */
717
+ isProtocolError(error) {
718
+ const msg = error?.message || '';
719
+ return msg.includes('ProtocolError') ||
720
+ msg.includes('Protocol error') ||
721
+ (msg.includes('timed out') && msg.includes('protocolTimeout')) ||
722
+ msg.includes('Target closed') ||
723
+ msg.includes('Session closed') ||
724
+ msg.includes('Network.enable timed out') ||
725
+ msg.includes('Connection closed');
726
+ }
333
727
  isNetworkError(error) {
334
728
  // Check for common network error patterns
335
729
  if (error?.code) {
@@ -369,7 +763,7 @@ class ContentProcessor {
369
763
  }
370
764
  return false;
371
765
  }
372
- async processPage(url, sourceConfig, onHttpStatus) {
766
+ async processPage(url, sourceConfig, onHttpStatus, existingPage) {
373
767
  const logger = this.logger.child('page-processor');
374
768
  logger.debug(`Processing content from ${url}`);
375
769
  // Check if this is a PDF URL
@@ -380,9 +774,9 @@ class ContentProcessor {
380
774
  // Check size limit for PDF content
381
775
  if (markdown.length > sourceConfig.max_size) {
382
776
  logger.warn(`PDF content (${markdown.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping ${url}.`);
383
- return null;
777
+ return { content: null, links: [], finalUrl: url };
384
778
  }
385
- return markdown;
779
+ return { content: markdown, links: [], finalUrl: url };
386
780
  }
387
781
  catch (error) {
388
782
  const status = this.getHttpStatus(error);
@@ -393,37 +787,82 @@ class ContentProcessor {
393
787
  throw error;
394
788
  }
395
789
  logger.error(`Failed to process PDF ${url}:`, error);
396
- return null;
790
+ return { content: null, links: [], finalUrl: url };
397
791
  }
398
792
  }
399
- // Original HTML page processing logic
793
+ // HTML page processing logic
794
+ // If an existing page (tab) is provided, reuse it; otherwise launch a standalone browser
400
795
  let browser = null;
796
+ let page;
797
+ const ownsTheBrowser = !existingPage;
401
798
  try {
402
- // Use system Chromium if available (for Docker environments)
403
- let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
404
- if (!executablePath) {
405
- if (fs.existsSync('/usr/bin/chromium')) {
406
- executablePath = '/usr/bin/chromium';
407
- }
408
- else if (fs.existsSync('/usr/bin/chromium-browser')) {
409
- executablePath = '/usr/bin/chromium-browser';
799
+ if (existingPage) {
800
+ page = existingPage;
801
+ }
802
+ else {
803
+ // Standalone mode: launch a browser for this single page
804
+ let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
805
+ if (!executablePath) {
806
+ if (fs.existsSync('/usr/bin/chromium')) {
807
+ executablePath = '/usr/bin/chromium';
808
+ }
809
+ else if (fs.existsSync('/usr/bin/chromium-browser')) {
810
+ executablePath = '/usr/bin/chromium-browser';
811
+ }
410
812
  }
813
+ browser = await puppeteer_1.default.launch({
814
+ executablePath,
815
+ args: [
816
+ '--no-sandbox',
817
+ '--disable-setuid-sandbox',
818
+ '--disable-dev-shm-usage',
819
+ '--disable-gpu',
820
+ '--disable-extensions',
821
+ ],
822
+ protocolTimeout: 60000,
823
+ });
824
+ page = await browser.newPage();
411
825
  }
412
- browser = await puppeteer_1.default.launch({
413
- executablePath,
414
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
415
- });
416
- const page = await browser.newPage();
417
826
  logger.debug(`Navigating to ${url}`);
418
827
  const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
419
828
  const status = response?.status();
420
829
  if (status !== undefined && status >= 400) {
421
830
  const error = new Error(`Failed to load page: HTTP ${status}`);
422
831
  error.status = status;
832
+ // For 429 (Too Many Requests), extract the Retry-After header so the
833
+ // caller can wait the right amount of time before retrying.
834
+ if (status === 429 && response) {
835
+ const retryAfterMs = this.parseRetryAfter(response.headers()['retry-after']);
836
+ if (retryAfterMs !== undefined) {
837
+ error.retryAfterMs = retryAfterMs;
838
+ logger.info(`Rate limited (429). Retry-After: ${retryAfterMs}ms`);
839
+ }
840
+ else {
841
+ logger.info(`Rate limited (429). No Retry-After header present.`);
842
+ }
843
+ }
423
844
  throw error;
424
845
  }
425
- const htmlContent = await page.evaluate(() => {
426
- // 💡 Try specific content selectors first, then fall back to broader ones
846
+ // Get the final URL after any redirects
847
+ const finalUrl = page.url();
848
+ // Extract ETag header for change detection on subsequent runs
849
+ const etag = response?.headers()['etag'] || undefined;
850
+ // Extract ALL links from the full rendered DOM before any content filtering
851
+ // This searches the entire document, not just the main content area
852
+ const links = await this.evaluateWithTimeout(page, () => {
853
+ const anchors = document.querySelectorAll('a[href]');
854
+ const hrefs = [];
855
+ anchors.forEach(a => {
856
+ const href = a.getAttribute('href');
857
+ if (href && !href.startsWith('#') && !href.startsWith('mailto:')) {
858
+ hrefs.push(href);
859
+ }
860
+ });
861
+ return hrefs;
862
+ });
863
+ logger.debug(`Extracted ${links.length} links from full DOM of ${url}`);
864
+ const htmlContent = await this.evaluateWithTimeout(page, () => {
865
+ // Try specific content selectors first, then fall back to broader ones
427
866
  const mainContentElement = document.querySelector('.docs-content') || // Common docs pattern
428
867
  document.querySelector('.doc-content') || // Alternative docs pattern
429
868
  document.querySelector('.markdown-body') || // GitHub-style
@@ -435,8 +874,7 @@ class ContentProcessor {
435
874
  });
436
875
  if (htmlContent.length > sourceConfig.max_size) {
437
876
  logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
438
- await browser.close();
439
- return null;
877
+ return { content: null, links, finalUrl, etag };
440
878
  }
441
879
  logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
442
880
  const dom = new jsdom_1.JSDOM(htmlContent);
@@ -446,7 +884,12 @@ class ContentProcessor {
446
884
  pre.setAttribute('data-readable-content-score', '100');
447
885
  this.markCodeParents(pre.parentElement);
448
886
  });
449
- // 💡 Extract H1s BEFORE Readability - it often strips them as "chrome"
887
+ // Pre-process tabbed content: inject tab labels into panels
888
+ // and make hidden panels visible so Readability doesn't discard them.
889
+ // Done in JSDOM (not in Puppeteer's page.evaluate) to avoid triggering
890
+ // reactive framework re-renders when modifying data-state attributes.
891
+ this.preprocessTabs(document, logger);
892
+ // Extract H1s BEFORE Readability - it often strips them as "chrome"
450
893
  // We'll inject them back after Readability processing
451
894
  const h1Elements = document.querySelectorAll('h1');
452
895
  const extractedH1s = [];
@@ -468,8 +911,7 @@ class ContentProcessor {
468
911
  const article = reader.parse();
469
912
  if (!article) {
470
913
  logger.warn(`Failed to parse article content with Readability for ${url}`);
471
- await browser.close();
472
- return null;
914
+ return { content: null, links, finalUrl, etag };
473
915
  }
474
916
  // Debug: Log what Readability extracted
475
917
  logger.debug(`[Readability Debug] article.title: "${article.title}"`);
@@ -478,7 +920,7 @@ class ContentProcessor {
478
920
  logger.debug(`[Readability Debug] Contains H1 tag: ${article.content?.includes('<h1')}`);
479
921
  logger.debug(`[Readability Debug] Contains H2 tag: ${article.content?.includes('<h2')}`);
480
922
  logger.debug(`[Readability Debug] Contains original-h1 class: ${article.content?.includes('original-h1')}`);
481
- // 💡 Restore H1s: find elements with our marker class and convert back from H2
923
+ // Restore H1s: find elements with our marker class and convert back from H2
482
924
  const articleDom = new jsdom_1.JSDOM(article.content);
483
925
  const articleDoc = articleDom.window.document;
484
926
  const originalH1Elements = articleDoc.querySelectorAll('.original-h1');
@@ -515,7 +957,7 @@ class ContentProcessor {
515
957
  });
516
958
  logger.debug(`Converting HTML to Markdown`);
517
959
  let markdown = this.turndownService.turndown(cleanHtml);
518
- // 💡 Inject extracted H1s back if they're not in the markdown
960
+ // Inject extracted H1s back if they're not in the markdown
519
961
  // Readability often strips them as "page chrome"
520
962
  // Use article.title as fallback if no H1 was extracted
521
963
  const pageTitle = extractedH1s.length > 0 ? extractedH1s[0] : (article.title?.trim() || '');
@@ -535,7 +977,7 @@ class ContentProcessor {
535
977
  }
536
978
  }
537
979
  logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
538
- return markdown;
980
+ return { content: markdown, links, finalUrl, etag };
539
981
  }
540
982
  catch (error) {
541
983
  const status = this.getHttpStatus(error);
@@ -546,15 +988,26 @@ class ContentProcessor {
546
988
  throw error;
547
989
  }
548
990
  logger.error(`Error processing page ${url}:`, error);
549
- return null;
991
+ return { content: null, links: [], finalUrl: url };
550
992
  }
551
993
  finally {
552
- if (browser && browser.isConnected()) {
994
+ // Only close the browser if we launched it ourselves (standalone mode)
995
+ if (ownsTheBrowser && browser && browser.isConnected()) {
553
996
  await browser.close();
554
997
  logger.debug(`Browser closed for ${url}`);
555
998
  }
556
999
  }
557
1000
  }
1001
+ /**
1002
+ * Wraps a page.evaluate call with a timeout to prevent indefinite hangs.
1003
+ * Some pages have heavy/infinite JavaScript that blocks evaluate calls.
1004
+ */
1005
+ evaluateWithTimeout(page, fn, timeoutMs = 30000) {
1006
+ return Promise.race([
1007
+ page.evaluate(fn),
1008
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`page.evaluate timed out after ${timeoutMs}ms`)), timeoutMs))
1009
+ ]);
1010
+ }
558
1011
  getHttpStatus(error) {
559
1012
  if (typeof error?.status === 'number') {
560
1013
  return error.status;
@@ -564,6 +1017,126 @@ class ContentProcessor {
564
1017
  }
565
1018
  return undefined;
566
1019
  }
1020
+ /**
1021
+ * Parses a Retry-After header value into milliseconds.
1022
+ *
1023
+ * The header can be either:
1024
+ * - A number of seconds (e.g., "60")
1025
+ * - An HTTP-date (e.g., "Thu, 15 Feb 2026 14:21:00 GMT")
1026
+ *
1027
+ * Returns undefined if the header is missing, empty, or unparseable.
1028
+ * Returns a minimum of 1000ms even if the header indicates 0 or a past date.
1029
+ */
1030
+ parseRetryAfter(headerValue) {
1031
+ if (!headerValue)
1032
+ return undefined;
1033
+ // Try parsing as a number of seconds first
1034
+ const seconds = Number(headerValue);
1035
+ if (!isNaN(seconds) && isFinite(seconds)) {
1036
+ return Math.max(1000, Math.round(seconds * 1000));
1037
+ }
1038
+ // Try parsing as an HTTP-date
1039
+ const date = new Date(headerValue);
1040
+ if (!isNaN(date.getTime())) {
1041
+ const delayMs = date.getTime() - Date.now();
1042
+ return Math.max(1000, delayMs);
1043
+ }
1044
+ return undefined;
1045
+ }
1046
+ /**
1047
+ * Pre-processes tabbed content in the DOM before Readability runs.
1048
+ *
1049
+ * Detects tabs using the standard WAI-ARIA tabs pattern:
1050
+ * - Tab buttons: elements with role="tab" and aria-controls pointing to a panel
1051
+ * - Tab panels: elements with role="tabpanel" matched by id
1052
+ *
1053
+ * For each tab/panel pair, injects the tab label as a bold heading into
1054
+ * the panel content, so the context is preserved after conversion to markdown.
1055
+ * Also ensures hidden panels are visible so Readability doesn't discard them.
1056
+ *
1057
+ * Falls back to positional matching (nth tab -> nth panel) when aria-controls
1058
+ * is missing.
1059
+ */
1060
+ preprocessTabs(document, logger) {
1061
+ // Find all tab buttons using the WAI-ARIA role="tab" attribute
1062
+ const tabButtons = document.querySelectorAll('[role="tab"]');
1063
+ if (tabButtons.length === 0)
1064
+ return;
1065
+ logger.debug(`[Tab Preprocessing] Found ${tabButtons.length} tab buttons`);
1066
+ // Collect all tabpanels for fallback positional matching
1067
+ const allPanels = document.querySelectorAll('[role="tabpanel"]');
1068
+ // Track panels that have already been labeled to avoid duplicates.
1069
+ // Pages can have multiple tab groups that reuse the same panel IDs
1070
+ // (e.g., two separate tab sets both using tabs-panel-0, tabs-panel-1, etc.),
1071
+ // causing getElementById to return the same panel for different tab buttons.
1072
+ const labeledPanels = new Set();
1073
+ tabButtons.forEach((tab, index) => {
1074
+ const label = tab.textContent?.trim();
1075
+ if (!label) {
1076
+ logger.debug(`[Tab Preprocessing] Skipping tab[${index}] with empty label`);
1077
+ return;
1078
+ }
1079
+ // Try to find the linked panel via aria-controls -> id
1080
+ let panel = null;
1081
+ const controlsId = tab.getAttribute('aria-controls');
1082
+ if (controlsId) {
1083
+ panel = document.getElementById(controlsId);
1084
+ }
1085
+ // Fallback: positional matching (nth tab -> nth panel)
1086
+ if (!panel && index < allPanels.length) {
1087
+ panel = allPanels[index];
1088
+ logger.debug(`[Tab Preprocessing] Using positional fallback for tab[${index}]: "${label}"`);
1089
+ }
1090
+ if (!panel) {
1091
+ logger.debug(`[Tab Preprocessing] No panel found for tab[${index}]: "${label}"`);
1092
+ return;
1093
+ }
1094
+ // Skip if this panel has already been labeled (duplicate panel ID across tab groups)
1095
+ if (labeledPanels.has(panel)) {
1096
+ logger.debug(`[Tab Preprocessing] Skipping tab[${index}]: "${label}" — panel already labeled`);
1097
+ return;
1098
+ }
1099
+ labeledPanels.add(panel);
1100
+ logger.debug(`[Tab Preprocessing] Injecting label for tab[${index}]: "${label}"`);
1101
+ // Inject the tab label as bold text at the top of the panel
1102
+ const labelElement = document.createElement('p');
1103
+ const strong = document.createElement('strong');
1104
+ strong.textContent = label + ':';
1105
+ labelElement.appendChild(strong);
1106
+ panel.insertBefore(labelElement, panel.firstChild);
1107
+ // Ensure the panel is visible so Readability doesn't skip it.
1108
+ // Remove common hiding patterns:
1109
+ // 1. data-state attribute (used by Hextra, Radix, etc.)
1110
+ if (panel.getAttribute('data-state') !== 'selected') {
1111
+ panel.setAttribute('data-state', 'selected');
1112
+ }
1113
+ // 2. CSS classes that hide content
1114
+ const classList = panel.classList;
1115
+ // Common hiding classes across frameworks
1116
+ const hidingClasses = ['hidden', 'hx-hidden', 'is-hidden', 'display-none', 'd-none', 'invisible'];
1117
+ hidingClasses.forEach(cls => classList.remove(cls));
1118
+ // Also remove any class containing 'hidden' as a segment (e.g., 'hx-hidden')
1119
+ Array.from(classList).forEach(cls => {
1120
+ if (/\bhidden\b/i.test(cls)) {
1121
+ classList.remove(cls);
1122
+ }
1123
+ });
1124
+ // 3. Inline style hiding
1125
+ const style = panel.getAttribute('style') || '';
1126
+ if (style.includes('display') && style.includes('none')) {
1127
+ panel.setAttribute('style', style.replace(/display\s*:\s*none\s*;?/gi, ''));
1128
+ }
1129
+ // Mark the panel as content so Readability preserves it
1130
+ panel.classList.add('article-content');
1131
+ panel.setAttribute('data-readable-content-score', '100');
1132
+ });
1133
+ // Remove the tab buttons since we've injected the labels into panels
1134
+ // This prevents the tab labels from appearing twice in the output
1135
+ tabButtons.forEach((tab) => {
1136
+ tab.remove();
1137
+ });
1138
+ logger.debug(`[Tab Preprocessing] Completed tab preprocessing`);
1139
+ }
567
1140
  markCodeParents(node) {
568
1141
  if (!node)
569
1142
  return;