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.
- package/README.md +68 -8
- package/dist/content-processor.js +682 -109
- package/dist/database.js +95 -5
- 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,112 +236,494 @@ 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;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
+
logger.debug(`No stored lastmod for ${url}, processing`);
|
|
280
439
|
}
|
|
281
440
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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;
|