doc2vec 2.3.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.
- package/README.md +68 -8
- package/dist/content-processor.js +544 -32
- package/dist/database.js +91 -1
- package/dist/doc2vec.js +143 -250
- package/dist/utils.js +3 -0
- package/package.json +1 -1
|
@@ -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
|
|
175
|
-
// Handle standard sitemaps
|
|
176
|
-
$('url
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
194
|
-
|
|
202
|
+
const nestedMap = await this.parseSitemap(nestedSitemapUrl, logger);
|
|
203
|
+
for (const [url, lastmod] of nestedMap) {
|
|
204
|
+
urlMap.set(url, lastmod);
|
|
205
|
+
}
|
|
195
206
|
}
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
//
|
|
232
|
-
for
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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: [
|
|
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
|
|
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
|
|
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;
|