doc2vec 2.3.0 → 2.5.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,29 +236,94 @@ 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;
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
250
327
  // Launch a single browser instance and reuse one page (tab) for all URLs
251
328
  let browser = null;
252
329
  let page = null;
@@ -262,17 +339,68 @@ class ContentProcessor {
262
339
  }
263
340
  const b = await puppeteer_1.default.launch({
264
341
  executablePath,
265
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
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,
266
350
  });
267
351
  const p = await b.newPage();
268
352
  return { browser: b, page: p };
269
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
+ };
270
377
  const ensureBrowser = async () => {
271
- if (!browser || !browser.isConnected()) {
272
- logger.info(browser ? 'Browser disconnected, relaunching...' : 'Launching browser...');
273
- const launched = await launchBrowser();
274
- browser = launched.browser;
275
- page = launched.page;
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;
276
404
  }
277
405
  return page;
278
406
  };
@@ -290,6 +418,141 @@ class ContentProcessor {
290
418
  skippedCount++;
291
419
  continue;
292
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;
433
+ }
434
+ else if (storedLastmod) {
435
+ logger.debug(`Lastmod changed (stored: ${storedLastmod}, sitemap: ${urlLastmod}), processing: ${url}`);
436
+ }
437
+ else {
438
+ logger.debug(`No stored lastmod for ${url}, processing`);
439
+ }
440
+ }
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;
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`);
536
+ }
537
+ }
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
+ }
555
+ }
293
556
  try {
294
557
  logger.info(`Crawling: ${url}`);
295
558
  const sources = referrers.get(url) ?? new Set([baseUrl]);
@@ -303,8 +566,9 @@ class ContentProcessor {
303
566
  }
304
567
  }
305
568
  }, currentPage);
569
+ let contentProcessedSuccessfully = false;
306
570
  if (result.content !== null) {
307
- await processPageContent(url, result.content);
571
+ contentProcessedSuccessfully = await processPageContent(url, result.content);
308
572
  if (utils_1.Utils.isPdfUrl(url)) {
309
573
  pdfProcessedCount++;
310
574
  }
@@ -322,7 +586,7 @@ class ContentProcessor {
322
586
  logger.debug(`Finding links on page ${url}`);
323
587
  let newLinksFound = 0;
324
588
  for (const href of result.links) {
325
- const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks);
589
+ const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks, logger);
326
590
  if (fullUrl.startsWith(sourceConfig.url)) {
327
591
  addReferrer(fullUrl, pageUrlForLinks);
328
592
  if (!visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
@@ -335,11 +599,88 @@ class ContentProcessor {
335
599
  }
336
600
  logger.debug(`Found ${newLinksFound} new links on ${url}`);
337
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
+ }
642
+ }
338
643
  }
339
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 ──
340
669
  logger.error(`Failed during processing or link discovery for ${url}:`, error);
341
670
  errorCount++;
342
- const status = this.getHttpStatus(error);
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
+ }
343
684
  if (status === 404) {
344
685
  const sources = referrers.get(url) ?? new Set([baseUrl]);
345
686
  for (const source of sources) {
@@ -362,12 +703,27 @@ class ContentProcessor {
362
703
  logger.debug('Shared browser closed after crawl completed');
363
704
  }
364
705
  }
365
- logger.info(`Crawl completed. HTML Pages: ${processedCount}, PDFs: ${pdfProcessedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
706
+ logger.info(`Crawl completed. HTML Pages: ${processedCount}, PDFs: ${pdfProcessedCount}, Skipped (Lastmod): ${lastmodSkippedCount}, Skipped (ETag): ${etagSkippedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
366
707
  if (hasNetworkErrors) {
367
708
  logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
368
709
  }
369
710
  return { hasNetworkErrors, brokenLinks };
370
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
+ }
371
727
  isNetworkError(error) {
372
728
  // Check for common network error patterns
373
729
  if (error?.code) {
@@ -456,7 +812,14 @@ class ContentProcessor {
456
812
  }
457
813
  browser = await puppeteer_1.default.launch({
458
814
  executablePath,
459
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
815
+ args: [
816
+ '--no-sandbox',
817
+ '--disable-setuid-sandbox',
818
+ '--disable-dev-shm-usage',
819
+ '--disable-gpu',
820
+ '--disable-extensions',
821
+ ],
822
+ protocolTimeout: 60000,
460
823
  });
461
824
  page = await browser.newPage();
462
825
  }
@@ -466,13 +829,27 @@ class ContentProcessor {
466
829
  if (status !== undefined && status >= 400) {
467
830
  const error = new Error(`Failed to load page: HTTP ${status}`);
468
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
+ }
469
844
  throw error;
470
845
  }
471
846
  // Get the final URL after any redirects
472
847
  const finalUrl = page.url();
848
+ // Extract ETag header for change detection on subsequent runs
849
+ const etag = response?.headers()['etag'] || undefined;
473
850
  // Extract ALL links from the full rendered DOM before any content filtering
474
851
  // This searches the entire document, not just the main content area
475
- const links = await page.evaluate(() => {
852
+ const links = await this.evaluateWithTimeout(page, () => {
476
853
  const anchors = document.querySelectorAll('a[href]');
477
854
  const hrefs = [];
478
855
  anchors.forEach(a => {
@@ -484,7 +861,7 @@ class ContentProcessor {
484
861
  return hrefs;
485
862
  });
486
863
  logger.debug(`Extracted ${links.length} links from full DOM of ${url}`);
487
- const htmlContent = await page.evaluate(() => {
864
+ const htmlContent = await this.evaluateWithTimeout(page, () => {
488
865
  // Try specific content selectors first, then fall back to broader ones
489
866
  const mainContentElement = document.querySelector('.docs-content') || // Common docs pattern
490
867
  document.querySelector('.doc-content') || // Alternative docs pattern
@@ -497,7 +874,7 @@ class ContentProcessor {
497
874
  });
498
875
  if (htmlContent.length > sourceConfig.max_size) {
499
876
  logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
500
- return { content: null, links, finalUrl };
877
+ return { content: null, links, finalUrl, etag };
501
878
  }
502
879
  logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
503
880
  const dom = new jsdom_1.JSDOM(htmlContent);
@@ -507,6 +884,11 @@ class ContentProcessor {
507
884
  pre.setAttribute('data-readable-content-score', '100');
508
885
  this.markCodeParents(pre.parentElement);
509
886
  });
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);
510
892
  // Extract H1s BEFORE Readability - it often strips them as "chrome"
511
893
  // We'll inject them back after Readability processing
512
894
  const h1Elements = document.querySelectorAll('h1');
@@ -529,7 +911,7 @@ class ContentProcessor {
529
911
  const article = reader.parse();
530
912
  if (!article) {
531
913
  logger.warn(`Failed to parse article content with Readability for ${url}`);
532
- return { content: null, links, finalUrl };
914
+ return { content: null, links, finalUrl, etag };
533
915
  }
534
916
  // Debug: Log what Readability extracted
535
917
  logger.debug(`[Readability Debug] article.title: "${article.title}"`);
@@ -595,7 +977,7 @@ class ContentProcessor {
595
977
  }
596
978
  }
597
979
  logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
598
- return { content: markdown, links, finalUrl };
980
+ return { content: markdown, links, finalUrl, etag };
599
981
  }
600
982
  catch (error) {
601
983
  const status = this.getHttpStatus(error);
@@ -616,6 +998,16 @@ class ContentProcessor {
616
998
  }
617
999
  }
618
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
+ }
619
1011
  getHttpStatus(error) {
620
1012
  if (typeof error?.status === 'number') {
621
1013
  return error.status;
@@ -625,6 +1017,126 @@ class ContentProcessor {
625
1017
  }
626
1018
  return undefined;
627
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
+ }
628
1140
  markCodeParents(node) {
629
1141
  if (!node)
630
1142
  return;