doc2vec 2.0.0 → 2.3.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 +64 -5
- package/dist/code-chunker.js +179 -0
- package/dist/content-processor.js +469 -83
- package/dist/database.js +197 -7
- package/dist/doc2vec.js +474 -4
- package/dist/vitest.config.js +16 -0
- package/package.json +10 -2
|
@@ -47,6 +47,7 @@ const turndown_1 = __importDefault(require("turndown"));
|
|
|
47
47
|
const fs = __importStar(require("fs"));
|
|
48
48
|
const path = __importStar(require("path"));
|
|
49
49
|
const utils_1 = require("./utils");
|
|
50
|
+
const code_chunker_1 = require("./code-chunker");
|
|
50
51
|
class ContentProcessor {
|
|
51
52
|
constructor(logger) {
|
|
52
53
|
this.logger = logger;
|
|
@@ -54,6 +55,9 @@ class ContentProcessor {
|
|
|
54
55
|
codeBlockStyle: 'fenced',
|
|
55
56
|
headingStyle: 'atx'
|
|
56
57
|
});
|
|
58
|
+
this.tokenChunkerCache = new Map();
|
|
59
|
+
this.codeChunkerCache = new Map();
|
|
60
|
+
this.tokenizerCache = null;
|
|
57
61
|
this.setupTurndownRules();
|
|
58
62
|
}
|
|
59
63
|
setupTurndownRules() {
|
|
@@ -200,15 +204,38 @@ class ContentProcessor {
|
|
|
200
204
|
async crawlWebsite(baseUrl, sourceConfig, processPageContent, parentLogger, visitedUrls) {
|
|
201
205
|
const logger = parentLogger.child('crawler');
|
|
202
206
|
const queue = [baseUrl];
|
|
207
|
+
const brokenLinks = [];
|
|
208
|
+
const brokenLinkKeys = new Set();
|
|
209
|
+
const referrers = new Map();
|
|
210
|
+
const addReferrer = (targetUrl, sourceUrl) => {
|
|
211
|
+
const existing = referrers.get(targetUrl);
|
|
212
|
+
if (existing) {
|
|
213
|
+
existing.add(sourceUrl);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
referrers.set(targetUrl, new Set([sourceUrl]));
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const addBrokenLink = (sourceUrl, targetUrl) => {
|
|
220
|
+
const key = `${sourceUrl} -> ${targetUrl}`;
|
|
221
|
+
if (brokenLinkKeys.has(key))
|
|
222
|
+
return;
|
|
223
|
+
brokenLinkKeys.add(key);
|
|
224
|
+
brokenLinks.push({ source: sourceUrl, target: targetUrl });
|
|
225
|
+
};
|
|
226
|
+
addReferrer(baseUrl, baseUrl);
|
|
203
227
|
// Process sitemap if provided
|
|
204
228
|
if (sourceConfig.sitemap_url) {
|
|
205
229
|
logger.section('SITEMAP PROCESSING');
|
|
206
230
|
const sitemapUrls = await this.parseSitemap(sourceConfig.sitemap_url, logger);
|
|
207
231
|
// Add sitemap URLs to the queue if they're within the website scope
|
|
208
232
|
for (const url of sitemapUrls) {
|
|
209
|
-
if (url.startsWith(sourceConfig.url)
|
|
210
|
-
|
|
211
|
-
queue.
|
|
233
|
+
if (url.startsWith(sourceConfig.url)) {
|
|
234
|
+
addReferrer(url, sourceConfig.sitemap_url);
|
|
235
|
+
if (!queue.includes(url)) {
|
|
236
|
+
logger.debug(`Adding URL from sitemap to queue: ${url}`);
|
|
237
|
+
queue.push(url);
|
|
238
|
+
}
|
|
212
239
|
}
|
|
213
240
|
}
|
|
214
241
|
logger.info(`Added ${queue.length - 1} URLs from sitemap to the crawl queue`);
|
|
@@ -220,70 +247,126 @@ class ContentProcessor {
|
|
|
220
247
|
let pdfProcessedCount = 0;
|
|
221
248
|
let errorCount = 0;
|
|
222
249
|
let hasNetworkErrors = false;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
250
|
+
// Launch a single browser instance and reuse one page (tab) for all URLs
|
|
251
|
+
let browser = null;
|
|
252
|
+
let page = null;
|
|
253
|
+
const launchBrowser = async () => {
|
|
254
|
+
let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
255
|
+
if (!executablePath) {
|
|
256
|
+
if (fs.existsSync('/usr/bin/chromium')) {
|
|
257
|
+
executablePath = '/usr/bin/chromium';
|
|
258
|
+
}
|
|
259
|
+
else if (fs.existsSync('/usr/bin/chromium-browser')) {
|
|
260
|
+
executablePath = '/usr/bin/chromium-browser';
|
|
261
|
+
}
|
|
235
262
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
263
|
+
const b = await puppeteer_1.default.launch({
|
|
264
|
+
executablePath,
|
|
265
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
266
|
+
});
|
|
267
|
+
const p = await b.newPage();
|
|
268
|
+
return { browser: b, page: p };
|
|
269
|
+
};
|
|
270
|
+
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;
|
|
276
|
+
}
|
|
277
|
+
return page;
|
|
278
|
+
};
|
|
279
|
+
try {
|
|
280
|
+
while (queue.length > 0) {
|
|
281
|
+
const url = queue.shift();
|
|
282
|
+
if (!url)
|
|
283
|
+
continue;
|
|
284
|
+
const normalizedUrl = utils_1.Utils.normalizeUrl(url);
|
|
285
|
+
if (visitedUrls.has(normalizedUrl))
|
|
286
|
+
continue;
|
|
287
|
+
visitedUrls.add(normalizedUrl);
|
|
288
|
+
if (!utils_1.Utils.shouldProcessUrl(url)) {
|
|
289
|
+
logger.debug(`Skipping URL with unsupported extension: ${url}`);
|
|
290
|
+
skippedCount++;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
logger.info(`Crawling: ${url}`);
|
|
295
|
+
const sources = referrers.get(url) ?? new Set([baseUrl]);
|
|
296
|
+
// For HTML pages, ensure the browser is running and pass the shared page
|
|
297
|
+
// For PDFs, processPage handles them without Puppeteer
|
|
298
|
+
const currentPage = utils_1.Utils.isPdfUrl(url) ? undefined : await ensureBrowser();
|
|
299
|
+
const result = await this.processPage(url, sourceConfig, (reportedUrl, status) => {
|
|
300
|
+
if (status === 404) {
|
|
301
|
+
for (const source of sources) {
|
|
302
|
+
addBrokenLink(source, reportedUrl);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}, currentPage);
|
|
306
|
+
if (result.content !== null) {
|
|
307
|
+
await processPageContent(url, result.content);
|
|
308
|
+
if (utils_1.Utils.isPdfUrl(url)) {
|
|
309
|
+
pdfProcessedCount++;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
processedCount++;
|
|
313
|
+
}
|
|
243
314
|
}
|
|
244
315
|
else {
|
|
245
|
-
|
|
316
|
+
skippedSizeCount++;
|
|
246
317
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (!queue.includes(fullUrl)) {
|
|
264
|
-
queue.push(fullUrl);
|
|
265
|
-
newLinksFound++;
|
|
318
|
+
// Use links extracted from the full rendered DOM by processPage
|
|
319
|
+
// (no separate axios request needed)
|
|
320
|
+
if (result.links.length > 0) {
|
|
321
|
+
const pageUrlForLinks = result.finalUrl || url;
|
|
322
|
+
logger.debug(`Finding links on page ${url}`);
|
|
323
|
+
let newLinksFound = 0;
|
|
324
|
+
for (const href of result.links) {
|
|
325
|
+
const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks);
|
|
326
|
+
if (fullUrl.startsWith(sourceConfig.url)) {
|
|
327
|
+
addReferrer(fullUrl, pageUrlForLinks);
|
|
328
|
+
if (!visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
|
|
329
|
+
if (!queue.includes(fullUrl)) {
|
|
330
|
+
queue.push(fullUrl);
|
|
331
|
+
newLinksFound++;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
266
334
|
}
|
|
267
335
|
}
|
|
268
|
-
|
|
269
|
-
|
|
336
|
+
logger.debug(`Found ${newLinksFound} new links on ${url}`);
|
|
337
|
+
}
|
|
270
338
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
339
|
+
catch (error) {
|
|
340
|
+
logger.error(`Failed during processing or link discovery for ${url}:`, error);
|
|
341
|
+
errorCount++;
|
|
342
|
+
const status = this.getHttpStatus(error);
|
|
343
|
+
if (status === 404) {
|
|
344
|
+
const sources = referrers.get(url) ?? new Set([baseUrl]);
|
|
345
|
+
for (const source of sources) {
|
|
346
|
+
addBrokenLink(source, url);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Check if this is a network error (DNS resolution, connection issues, etc.)
|
|
350
|
+
if (this.isNetworkError(error)) {
|
|
351
|
+
hasNetworkErrors = true;
|
|
352
|
+
logger.warn(`Network error detected for ${url}, this may affect cleanup decisions`);
|
|
353
|
+
}
|
|
279
354
|
}
|
|
280
355
|
}
|
|
281
356
|
}
|
|
357
|
+
finally {
|
|
358
|
+
// Close the shared browser instance when the crawl is done
|
|
359
|
+
const browserToClose = browser;
|
|
360
|
+
if (browserToClose && browserToClose.isConnected()) {
|
|
361
|
+
await browserToClose.close();
|
|
362
|
+
logger.debug('Shared browser closed after crawl completed');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
282
365
|
logger.info(`Crawl completed. HTML Pages: ${processedCount}, PDFs: ${pdfProcessedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
|
|
283
366
|
if (hasNetworkErrors) {
|
|
284
367
|
logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
|
|
285
368
|
}
|
|
286
|
-
return { hasNetworkErrors };
|
|
369
|
+
return { hasNetworkErrors, brokenLinks };
|
|
287
370
|
}
|
|
288
371
|
isNetworkError(error) {
|
|
289
372
|
// Check for common network error patterns
|
|
@@ -324,7 +407,7 @@ class ContentProcessor {
|
|
|
324
407
|
}
|
|
325
408
|
return false;
|
|
326
409
|
}
|
|
327
|
-
async processPage(url, sourceConfig) {
|
|
410
|
+
async processPage(url, sourceConfig, onHttpStatus, existingPage) {
|
|
328
411
|
const logger = this.logger.child('page-processor');
|
|
329
412
|
logger.debug(`Processing content from ${url}`);
|
|
330
413
|
// Check if this is a PDF URL
|
|
@@ -335,37 +418,74 @@ class ContentProcessor {
|
|
|
335
418
|
// Check size limit for PDF content
|
|
336
419
|
if (markdown.length > sourceConfig.max_size) {
|
|
337
420
|
logger.warn(`PDF content (${markdown.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping ${url}.`);
|
|
338
|
-
return null;
|
|
421
|
+
return { content: null, links: [], finalUrl: url };
|
|
339
422
|
}
|
|
340
|
-
return markdown;
|
|
423
|
+
return { content: markdown, links: [], finalUrl: url };
|
|
341
424
|
}
|
|
342
425
|
catch (error) {
|
|
426
|
+
const status = this.getHttpStatus(error);
|
|
427
|
+
if (status !== undefined && status >= 400) {
|
|
428
|
+
if (onHttpStatus) {
|
|
429
|
+
onHttpStatus(url, status);
|
|
430
|
+
}
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
343
433
|
logger.error(`Failed to process PDF ${url}:`, error);
|
|
344
|
-
return null;
|
|
434
|
+
return { content: null, links: [], finalUrl: url };
|
|
345
435
|
}
|
|
346
436
|
}
|
|
347
|
-
//
|
|
437
|
+
// HTML page processing logic
|
|
438
|
+
// If an existing page (tab) is provided, reuse it; otherwise launch a standalone browser
|
|
348
439
|
let browser = null;
|
|
440
|
+
let page;
|
|
441
|
+
const ownsTheBrowser = !existingPage;
|
|
349
442
|
try {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
443
|
+
if (existingPage) {
|
|
444
|
+
page = existingPage;
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
// Standalone mode: launch a browser for this single page
|
|
448
|
+
let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
449
|
+
if (!executablePath) {
|
|
450
|
+
if (fs.existsSync('/usr/bin/chromium')) {
|
|
451
|
+
executablePath = '/usr/bin/chromium';
|
|
452
|
+
}
|
|
453
|
+
else if (fs.existsSync('/usr/bin/chromium-browser')) {
|
|
454
|
+
executablePath = '/usr/bin/chromium-browser';
|
|
455
|
+
}
|
|
358
456
|
}
|
|
457
|
+
browser = await puppeteer_1.default.launch({
|
|
458
|
+
executablePath,
|
|
459
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
460
|
+
});
|
|
461
|
+
page = await browser.newPage();
|
|
359
462
|
}
|
|
360
|
-
browser = await puppeteer_1.default.launch({
|
|
361
|
-
executablePath,
|
|
362
|
-
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
363
|
-
});
|
|
364
|
-
const page = await browser.newPage();
|
|
365
463
|
logger.debug(`Navigating to ${url}`);
|
|
366
|
-
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
|
464
|
+
const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
|
465
|
+
const status = response?.status();
|
|
466
|
+
if (status !== undefined && status >= 400) {
|
|
467
|
+
const error = new Error(`Failed to load page: HTTP ${status}`);
|
|
468
|
+
error.status = status;
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
// Get the final URL after any redirects
|
|
472
|
+
const finalUrl = page.url();
|
|
473
|
+
// Extract ALL links from the full rendered DOM before any content filtering
|
|
474
|
+
// This searches the entire document, not just the main content area
|
|
475
|
+
const links = await page.evaluate(() => {
|
|
476
|
+
const anchors = document.querySelectorAll('a[href]');
|
|
477
|
+
const hrefs = [];
|
|
478
|
+
anchors.forEach(a => {
|
|
479
|
+
const href = a.getAttribute('href');
|
|
480
|
+
if (href && !href.startsWith('#') && !href.startsWith('mailto:')) {
|
|
481
|
+
hrefs.push(href);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
return hrefs;
|
|
485
|
+
});
|
|
486
|
+
logger.debug(`Extracted ${links.length} links from full DOM of ${url}`);
|
|
367
487
|
const htmlContent = await page.evaluate(() => {
|
|
368
|
-
//
|
|
488
|
+
// Try specific content selectors first, then fall back to broader ones
|
|
369
489
|
const mainContentElement = document.querySelector('.docs-content') || // Common docs pattern
|
|
370
490
|
document.querySelector('.doc-content') || // Alternative docs pattern
|
|
371
491
|
document.querySelector('.markdown-body') || // GitHub-style
|
|
@@ -377,8 +497,7 @@ class ContentProcessor {
|
|
|
377
497
|
});
|
|
378
498
|
if (htmlContent.length > sourceConfig.max_size) {
|
|
379
499
|
logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
|
|
380
|
-
|
|
381
|
-
return null;
|
|
500
|
+
return { content: null, links, finalUrl };
|
|
382
501
|
}
|
|
383
502
|
logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
|
|
384
503
|
const dom = new jsdom_1.JSDOM(htmlContent);
|
|
@@ -388,7 +507,7 @@ class ContentProcessor {
|
|
|
388
507
|
pre.setAttribute('data-readable-content-score', '100');
|
|
389
508
|
this.markCodeParents(pre.parentElement);
|
|
390
509
|
});
|
|
391
|
-
//
|
|
510
|
+
// Extract H1s BEFORE Readability - it often strips them as "chrome"
|
|
392
511
|
// We'll inject them back after Readability processing
|
|
393
512
|
const h1Elements = document.querySelectorAll('h1');
|
|
394
513
|
const extractedH1s = [];
|
|
@@ -410,8 +529,7 @@ class ContentProcessor {
|
|
|
410
529
|
const article = reader.parse();
|
|
411
530
|
if (!article) {
|
|
412
531
|
logger.warn(`Failed to parse article content with Readability for ${url}`);
|
|
413
|
-
|
|
414
|
-
return null;
|
|
532
|
+
return { content: null, links, finalUrl };
|
|
415
533
|
}
|
|
416
534
|
// Debug: Log what Readability extracted
|
|
417
535
|
logger.debug(`[Readability Debug] article.title: "${article.title}"`);
|
|
@@ -420,7 +538,7 @@ class ContentProcessor {
|
|
|
420
538
|
logger.debug(`[Readability Debug] Contains H1 tag: ${article.content?.includes('<h1')}`);
|
|
421
539
|
logger.debug(`[Readability Debug] Contains H2 tag: ${article.content?.includes('<h2')}`);
|
|
422
540
|
logger.debug(`[Readability Debug] Contains original-h1 class: ${article.content?.includes('original-h1')}`);
|
|
423
|
-
//
|
|
541
|
+
// Restore H1s: find elements with our marker class and convert back from H2
|
|
424
542
|
const articleDom = new jsdom_1.JSDOM(article.content);
|
|
425
543
|
const articleDoc = articleDom.window.document;
|
|
426
544
|
const originalH1Elements = articleDoc.querySelectorAll('.original-h1');
|
|
@@ -457,7 +575,7 @@ class ContentProcessor {
|
|
|
457
575
|
});
|
|
458
576
|
logger.debug(`Converting HTML to Markdown`);
|
|
459
577
|
let markdown = this.turndownService.turndown(cleanHtml);
|
|
460
|
-
//
|
|
578
|
+
// Inject extracted H1s back if they're not in the markdown
|
|
461
579
|
// Readability often strips them as "page chrome"
|
|
462
580
|
// Use article.title as fallback if no H1 was extracted
|
|
463
581
|
const pageTitle = extractedH1s.length > 0 ? extractedH1s[0] : (article.title?.trim() || '');
|
|
@@ -477,19 +595,36 @@ class ContentProcessor {
|
|
|
477
595
|
}
|
|
478
596
|
}
|
|
479
597
|
logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
|
|
480
|
-
return markdown;
|
|
598
|
+
return { content: markdown, links, finalUrl };
|
|
481
599
|
}
|
|
482
600
|
catch (error) {
|
|
601
|
+
const status = this.getHttpStatus(error);
|
|
602
|
+
if (status !== undefined && status >= 400) {
|
|
603
|
+
if (onHttpStatus) {
|
|
604
|
+
onHttpStatus(url, status);
|
|
605
|
+
}
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
483
608
|
logger.error(`Error processing page ${url}:`, error);
|
|
484
|
-
return null;
|
|
609
|
+
return { content: null, links: [], finalUrl: url };
|
|
485
610
|
}
|
|
486
611
|
finally {
|
|
487
|
-
|
|
612
|
+
// Only close the browser if we launched it ourselves (standalone mode)
|
|
613
|
+
if (ownsTheBrowser && browser && browser.isConnected()) {
|
|
488
614
|
await browser.close();
|
|
489
615
|
logger.debug(`Browser closed for ${url}`);
|
|
490
616
|
}
|
|
491
617
|
}
|
|
492
618
|
}
|
|
619
|
+
getHttpStatus(error) {
|
|
620
|
+
if (typeof error?.status === 'number') {
|
|
621
|
+
return error.status;
|
|
622
|
+
}
|
|
623
|
+
if (typeof error?.response?.status === 'number') {
|
|
624
|
+
return error.response.status;
|
|
625
|
+
}
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
493
628
|
markCodeParents(node) {
|
|
494
629
|
if (!node)
|
|
495
630
|
return;
|
|
@@ -812,6 +947,257 @@ class ContentProcessor {
|
|
|
812
947
|
logger.error(`Error reading directory ${dirPath}:`, error);
|
|
813
948
|
}
|
|
814
949
|
}
|
|
950
|
+
async processCodeDirectory(dirPath, config, processFileContent, parentLogger, visitedPaths = new Set(), options) {
|
|
951
|
+
const logger = parentLogger.child('code-directory-processor');
|
|
952
|
+
logger.info(`Processing code directory: ${dirPath}`);
|
|
953
|
+
const recursive = config.recursive !== undefined ? config.recursive : true;
|
|
954
|
+
const includeExtensions = config.include_extensions || [
|
|
955
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
956
|
+
'.py', '.go', '.rs', '.java', '.kt', '.kts', '.swift',
|
|
957
|
+
'.c', '.cc', '.cpp', '.h', '.hpp', '.cs',
|
|
958
|
+
'.rb', '.php', '.scala', '.sql', '.sh', '.bash', '.zsh',
|
|
959
|
+
'.html', '.css', '.scss', '.sass', '.less',
|
|
960
|
+
'.json', '.yaml', '.yml', '.md'
|
|
961
|
+
];
|
|
962
|
+
const excludeExtensions = config.exclude_extensions || [];
|
|
963
|
+
const encoding = config.encoding || 'utf8';
|
|
964
|
+
let maxMtime = 0;
|
|
965
|
+
try {
|
|
966
|
+
const files = fs.readdirSync(dirPath);
|
|
967
|
+
let processedFiles = 0;
|
|
968
|
+
let skippedFiles = 0;
|
|
969
|
+
const allowedFiles = options?.allowedFiles;
|
|
970
|
+
const mtimeCutoff = options?.mtimeCutoff;
|
|
971
|
+
const trackFiles = options?.trackFiles;
|
|
972
|
+
for (const file of files) {
|
|
973
|
+
const filePath = path.join(dirPath, file);
|
|
974
|
+
const stat = fs.statSync(filePath);
|
|
975
|
+
if (visitedPaths.has(filePath)) {
|
|
976
|
+
logger.debug(`Skipping already visited path: ${filePath}`);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
visitedPaths.add(filePath);
|
|
980
|
+
if (stat.isDirectory()) {
|
|
981
|
+
if (recursive) {
|
|
982
|
+
const childResult = await this.processCodeDirectory(filePath, config, processFileContent, logger, visitedPaths, options);
|
|
983
|
+
processedFiles += childResult.processedFiles;
|
|
984
|
+
skippedFiles += childResult.skippedFiles;
|
|
985
|
+
maxMtime = Math.max(maxMtime, childResult.maxMtime);
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
logger.debug(`Skipping directory ${filePath} (recursive=false)`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
else if (stat.isFile()) {
|
|
992
|
+
const extension = path.extname(file).toLowerCase();
|
|
993
|
+
if (excludeExtensions.includes(extension)) {
|
|
994
|
+
logger.debug(`Skipping file with excluded extension: ${filePath}`);
|
|
995
|
+
skippedFiles++;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
|
|
999
|
+
logger.debug(`Skipping file with non-included extension: ${filePath}`);
|
|
1000
|
+
skippedFiles++;
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
trackFiles?.add(filePath);
|
|
1004
|
+
maxMtime = Math.max(maxMtime, stat.mtimeMs);
|
|
1005
|
+
if (allowedFiles && !allowedFiles.has(filePath)) {
|
|
1006
|
+
skippedFiles++;
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (mtimeCutoff !== undefined && stat.mtimeMs <= mtimeCutoff) {
|
|
1010
|
+
skippedFiles++;
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
try {
|
|
1014
|
+
logger.info(`Reading file: ${filePath}`);
|
|
1015
|
+
const content = fs.readFileSync(filePath, { encoding: encoding });
|
|
1016
|
+
if (content.length > config.max_size) {
|
|
1017
|
+
logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
|
|
1018
|
+
skippedFiles++;
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
await processFileContent(filePath, content);
|
|
1022
|
+
processedFiles++;
|
|
1023
|
+
}
|
|
1024
|
+
catch (error) {
|
|
1025
|
+
logger.error(`Error processing file ${filePath}:`, error);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
logger.info(`Code directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
|
|
1030
|
+
return { processedFiles, skippedFiles, maxMtime };
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
logger.error(`Error reading code directory ${dirPath}:`, error);
|
|
1034
|
+
return { processedFiles: 0, skippedFiles: 0, maxMtime };
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
async getTokenChunker(chunkSize) {
|
|
1038
|
+
const cacheKey = `${chunkSize || 'default'}`;
|
|
1039
|
+
const cached = this.tokenChunkerCache.get(cacheKey);
|
|
1040
|
+
if (cached) {
|
|
1041
|
+
return cached;
|
|
1042
|
+
}
|
|
1043
|
+
const chunkerPromise = (async () => {
|
|
1044
|
+
const { TokenChunker } = await this.importChonkieModule('@chonkiejs/core');
|
|
1045
|
+
return await TokenChunker.create({ chunkSize, tokenizer: 'character' });
|
|
1046
|
+
})();
|
|
1047
|
+
this.tokenChunkerCache.set(cacheKey, chunkerPromise);
|
|
1048
|
+
return chunkerPromise;
|
|
1049
|
+
}
|
|
1050
|
+
async getCodeChunker(lang, chunkSize) {
|
|
1051
|
+
const cacheKey = `${lang}:${chunkSize || 'default'}`;
|
|
1052
|
+
const cached = this.codeChunkerCache.get(cacheKey);
|
|
1053
|
+
if (cached) {
|
|
1054
|
+
return cached;
|
|
1055
|
+
}
|
|
1056
|
+
const chunkerPromise = (async () => {
|
|
1057
|
+
const tokenizer = await this.getTokenizer();
|
|
1058
|
+
return await code_chunker_1.CodeChunker.create({
|
|
1059
|
+
lang,
|
|
1060
|
+
chunkSize,
|
|
1061
|
+
tokenCounter: async (text) => tokenizer.countTokens(text)
|
|
1062
|
+
});
|
|
1063
|
+
})();
|
|
1064
|
+
this.codeChunkerCache.set(cacheKey, chunkerPromise);
|
|
1065
|
+
return chunkerPromise;
|
|
1066
|
+
}
|
|
1067
|
+
async getTokenizer() {
|
|
1068
|
+
if (!this.tokenizerCache) {
|
|
1069
|
+
this.tokenizerCache = (async () => {
|
|
1070
|
+
const { Tokenizer } = await this.importChonkieModule('@chonkiejs/core');
|
|
1071
|
+
return await Tokenizer.create('character');
|
|
1072
|
+
})();
|
|
1073
|
+
}
|
|
1074
|
+
return this.tokenizerCache;
|
|
1075
|
+
}
|
|
1076
|
+
detectCodeLanguage(filePath) {
|
|
1077
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
1078
|
+
const languageMap = {
|
|
1079
|
+
'.ts': 'typescript',
|
|
1080
|
+
'.tsx': 'typescript',
|
|
1081
|
+
'.js': 'javascript',
|
|
1082
|
+
'.jsx': 'javascript',
|
|
1083
|
+
'.mjs': 'javascript',
|
|
1084
|
+
'.cjs': 'javascript',
|
|
1085
|
+
'.py': 'python',
|
|
1086
|
+
'.go': 'go',
|
|
1087
|
+
'.rs': 'rust',
|
|
1088
|
+
'.java': 'java',
|
|
1089
|
+
'.kt': 'kotlin',
|
|
1090
|
+
'.kts': 'kotlin',
|
|
1091
|
+
'.swift': 'swift',
|
|
1092
|
+
'.c': 'c',
|
|
1093
|
+
'.cc': 'cpp',
|
|
1094
|
+
'.cpp': 'cpp',
|
|
1095
|
+
'.h': 'cpp',
|
|
1096
|
+
'.hpp': 'cpp',
|
|
1097
|
+
'.cs': 'csharp',
|
|
1098
|
+
'.rb': 'ruby',
|
|
1099
|
+
'.php': 'php',
|
|
1100
|
+
'.scala': 'scala',
|
|
1101
|
+
'.sql': 'sql',
|
|
1102
|
+
'.sh': 'bash',
|
|
1103
|
+
'.bash': 'bash',
|
|
1104
|
+
'.zsh': 'bash',
|
|
1105
|
+
'.html': 'html',
|
|
1106
|
+
'.css': 'css',
|
|
1107
|
+
'.scss': 'scss',
|
|
1108
|
+
'.sass': 'scss',
|
|
1109
|
+
'.less': 'css',
|
|
1110
|
+
'.json': 'json',
|
|
1111
|
+
'.yaml': 'yaml',
|
|
1112
|
+
'.yml': 'yaml',
|
|
1113
|
+
'.md': 'markdown'
|
|
1114
|
+
};
|
|
1115
|
+
return languageMap[extension];
|
|
1116
|
+
}
|
|
1117
|
+
async importChonkieModule(specifier) {
|
|
1118
|
+
// Use dynamic import() directly - preserved by TypeScript with target ES2020+
|
|
1119
|
+
// even in CommonJS mode, as Node.js supports import() in CJS contexts
|
|
1120
|
+
return Promise.resolve(`${specifier}`).then(s => __importStar(require(s)));
|
|
1121
|
+
}
|
|
1122
|
+
async chunkCode(code, sourceConfig, url, filePath, branch, repo) {
|
|
1123
|
+
const logger = this.logger.child('code-chunker');
|
|
1124
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
1125
|
+
const lang = this.detectCodeLanguage(filePath);
|
|
1126
|
+
let chunks;
|
|
1127
|
+
if (lang === 'markdown') {
|
|
1128
|
+
const markdownChunks = await this.chunkMarkdown(code, sourceConfig, url);
|
|
1129
|
+
for (const chunk of markdownChunks) {
|
|
1130
|
+
if (normalizedPath) {
|
|
1131
|
+
const filePrefix = `[File: ${normalizedPath}]\n`;
|
|
1132
|
+
const searchableText = filePrefix + chunk.content;
|
|
1133
|
+
const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
|
|
1134
|
+
chunk.content = searchableText;
|
|
1135
|
+
chunk.metadata.heading_hierarchy = [normalizedPath, ...chunk.metadata.heading_hierarchy.filter(Boolean)];
|
|
1136
|
+
chunk.metadata.section = normalizedPath;
|
|
1137
|
+
chunk.metadata.chunk_id = chunkId;
|
|
1138
|
+
chunk.metadata.hash = chunkId;
|
|
1139
|
+
}
|
|
1140
|
+
if (branch) {
|
|
1141
|
+
chunk.metadata.branch = branch;
|
|
1142
|
+
}
|
|
1143
|
+
if (repo) {
|
|
1144
|
+
chunk.metadata.repo = repo;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
logger.debug(`Chunked ${normalizedPath || url}: ${markdownChunks.length} chunks created.`);
|
|
1148
|
+
return markdownChunks;
|
|
1149
|
+
}
|
|
1150
|
+
if (lang) {
|
|
1151
|
+
try {
|
|
1152
|
+
const codeChunker = await this.getCodeChunker(lang, sourceConfig.chunk_size);
|
|
1153
|
+
chunks = await codeChunker.chunk(code);
|
|
1154
|
+
}
|
|
1155
|
+
catch (error) {
|
|
1156
|
+
logger.warn(`CodeChunker failed for ${normalizedPath || url}, falling back to token chunking:`, error);
|
|
1157
|
+
const chunker = await this.getTokenChunker(sourceConfig.chunk_size);
|
|
1158
|
+
chunks = await chunker.chunk(code);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
const chunker = await this.getTokenChunker(sourceConfig.chunk_size);
|
|
1163
|
+
chunks = await chunker.chunk(code);
|
|
1164
|
+
}
|
|
1165
|
+
const documentChunks = [];
|
|
1166
|
+
let chunkCounter = 0;
|
|
1167
|
+
const headingHierarchy = normalizedPath ? [normalizedPath] : [];
|
|
1168
|
+
const contextPrefix = normalizedPath ? `[File: ${normalizedPath}]\n` : '';
|
|
1169
|
+
for (const chunk of chunks) {
|
|
1170
|
+
const content = chunk.text?.trim();
|
|
1171
|
+
if (!content) {
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
const searchableText = contextPrefix + content;
|
|
1175
|
+
const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
|
|
1176
|
+
documentChunks.push({
|
|
1177
|
+
content: searchableText,
|
|
1178
|
+
metadata: {
|
|
1179
|
+
product_name: sourceConfig.product_name,
|
|
1180
|
+
version: sourceConfig.version,
|
|
1181
|
+
...(branch ? { branch } : {}),
|
|
1182
|
+
...(repo ? { repo } : {}),
|
|
1183
|
+
heading_hierarchy: headingHierarchy,
|
|
1184
|
+
section: normalizedPath || 'Code',
|
|
1185
|
+
chunk_id: chunkId,
|
|
1186
|
+
url: url,
|
|
1187
|
+
hash: chunkId,
|
|
1188
|
+
chunk_index: chunkCounter,
|
|
1189
|
+
total_chunks: 0
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
chunkCounter++;
|
|
1193
|
+
}
|
|
1194
|
+
const totalChunks = documentChunks.length;
|
|
1195
|
+
for (const chunk of documentChunks) {
|
|
1196
|
+
chunk.metadata.total_chunks = totalChunks;
|
|
1197
|
+
}
|
|
1198
|
+
logger.debug(`Chunked ${normalizedPath || url}: ${documentChunks.length} chunks created.`);
|
|
1199
|
+
return documentChunks;
|
|
1200
|
+
}
|
|
815
1201
|
async chunkMarkdown(markdown, sourceConfig, url) {
|
|
816
1202
|
const logger = this.logger.child('chunker');
|
|
817
1203
|
// --- Configuration ---
|