doc2vec 1.3.0 → 2.0.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.
@@ -0,0 +1,819 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.ContentProcessor = void 0;
40
+ const readability_1 = require("@mozilla/readability");
41
+ const axios_1 = __importDefault(require("axios"));
42
+ const cheerio_1 = require("cheerio");
43
+ const jsdom_1 = require("jsdom");
44
+ const puppeteer_1 = __importDefault(require("puppeteer"));
45
+ const sanitize_html_1 = __importDefault(require("sanitize-html"));
46
+ const turndown_1 = __importDefault(require("turndown"));
47
+ const fs = __importStar(require("fs"));
48
+ const path = __importStar(require("path"));
49
+ const utils_1 = require("./utils");
50
+ class ContentProcessor {
51
+ constructor(logger) {
52
+ this.logger = logger;
53
+ this.turndownService = new turndown_1.default({
54
+ codeBlockStyle: 'fenced',
55
+ headingStyle: 'atx'
56
+ });
57
+ this.setupTurndownRules();
58
+ }
59
+ setupTurndownRules() {
60
+ const logger = this.logger.child('markdown');
61
+ logger.debug('Setting up Turndown rules for markdown conversion');
62
+ this.turndownService.addRule('codeBlocks', {
63
+ filter: (node) => node.nodeName === 'PRE',
64
+ replacement: (content, node) => {
65
+ const htmlNode = node;
66
+ const code = htmlNode.querySelector('code');
67
+ let codeContent;
68
+ if (code) {
69
+ codeContent = code.textContent || '';
70
+ }
71
+ else {
72
+ codeContent = htmlNode.textContent || '';
73
+ }
74
+ const lines = codeContent.split('\n');
75
+ let minIndent = Infinity;
76
+ for (const line of lines) {
77
+ if (line.trim() === '')
78
+ continue;
79
+ const leadingWhitespace = line.match(/^\s*/)?.[0] || '';
80
+ minIndent = Math.min(minIndent, leadingWhitespace.length);
81
+ }
82
+ const cleanedLines = lines.map(line => {
83
+ return line.substring(minIndent);
84
+ });
85
+ let cleanContent = cleanedLines.join('\n');
86
+ cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
87
+ cleanContent = cleanContent.replace(/\n{2,}/g, '\n');
88
+ return `\n\`\`\`\n${cleanContent}\n\`\`\`\n`;
89
+ }
90
+ });
91
+ this.turndownService.addRule('tableCell', {
92
+ filter: ['th', 'td'],
93
+ replacement: (content, node) => {
94
+ const htmlNode = node;
95
+ let cellContent = '';
96
+ if (htmlNode.querySelector('p')) {
97
+ cellContent = Array.from(htmlNode.querySelectorAll('p'))
98
+ .map(p => p.textContent || '')
99
+ .join(' ')
100
+ .trim();
101
+ }
102
+ else {
103
+ cellContent = content.trim();
104
+ }
105
+ return ` ${cellContent.replace(/\|/g, '\\|')} |`;
106
+ }
107
+ });
108
+ this.turndownService.addRule('tableRow', {
109
+ filter: 'tr',
110
+ replacement: (content, node) => {
111
+ const htmlNode = node;
112
+ const cells = Array.from(htmlNode.cells);
113
+ const isHeader = htmlNode.parentNode?.nodeName === 'THEAD';
114
+ let output = '|' + content.trimEnd();
115
+ if (isHeader) {
116
+ const separator = cells.map(() => '---').join(' | ');
117
+ output += '\n|' + separator + '|';
118
+ }
119
+ if (!isHeader || !htmlNode.nextElementSibling) {
120
+ output += '\n';
121
+ }
122
+ return output;
123
+ }
124
+ });
125
+ this.turndownService.addRule('table', {
126
+ filter: 'table',
127
+ replacement: (content) => {
128
+ return '\n' + content.replace(/\n+/g, '\n').trim() + '\n';
129
+ }
130
+ });
131
+ this.turndownService.addRule('preserveTableWhitespace', {
132
+ filter: (node) => {
133
+ return ((node.nodeName === 'TD' || node.nodeName === 'TH') &&
134
+ (node.textContent?.trim().length === 0));
135
+ },
136
+ replacement: () => {
137
+ return ' |';
138
+ }
139
+ });
140
+ logger.debug('Turndown rules setup complete');
141
+ }
142
+ convertHtmlToMarkdown(html) {
143
+ if (!html || !html.trim()) {
144
+ return '';
145
+ }
146
+ // Sanitize the HTML first
147
+ const cleanHtml = (0, sanitize_html_1.default)(html, {
148
+ allowedTags: [
149
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
150
+ 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
151
+ 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
152
+ 'blockquote', 'br'
153
+ ],
154
+ allowedAttributes: {
155
+ 'a': ['href'],
156
+ 'pre': ['class', 'data-language'],
157
+ 'code': ['class', 'data-language'],
158
+ 'div': ['class'],
159
+ 'span': ['class']
160
+ }
161
+ });
162
+ // Convert to markdown using TurndownService
163
+ return this.turndownService.turndown(cleanHtml).trim();
164
+ }
165
+ async parseSitemap(sitemapUrl, logger) {
166
+ logger.info(`Parsing sitemap from ${sitemapUrl}`);
167
+ try {
168
+ const response = await axios_1.default.get(sitemapUrl);
169
+ const $ = (0, cheerio_1.load)(response.data, { xmlMode: true });
170
+ const urls = [];
171
+ // Handle standard sitemaps
172
+ $('url > loc').each((_, element) => {
173
+ const url = $(element).text().trim();
174
+ if (url) {
175
+ urls.push(url);
176
+ }
177
+ });
178
+ // Handle sitemap indexes (sitemaps that link to other sitemaps)
179
+ const sitemapLinks = [];
180
+ $('sitemap > loc').each((_, element) => {
181
+ const nestedSitemapUrl = $(element).text().trim();
182
+ if (nestedSitemapUrl) {
183
+ sitemapLinks.push(nestedSitemapUrl);
184
+ }
185
+ });
186
+ // Recursively process nested sitemaps
187
+ for (const nestedSitemapUrl of sitemapLinks) {
188
+ logger.debug(`Found nested sitemap: ${nestedSitemapUrl}`);
189
+ const nestedUrls = await this.parseSitemap(nestedSitemapUrl, logger);
190
+ urls.push(...nestedUrls);
191
+ }
192
+ logger.info(`Found ${urls.length} URLs in sitemap ${sitemapUrl}`);
193
+ return urls;
194
+ }
195
+ catch (error) {
196
+ logger.error(`Error parsing sitemap at ${sitemapUrl}:`, error);
197
+ return [];
198
+ }
199
+ }
200
+ async crawlWebsite(baseUrl, sourceConfig, processPageContent, parentLogger, visitedUrls) {
201
+ const logger = parentLogger.child('crawler');
202
+ const queue = [baseUrl];
203
+ // Process sitemap if provided
204
+ if (sourceConfig.sitemap_url) {
205
+ logger.section('SITEMAP PROCESSING');
206
+ const sitemapUrls = await this.parseSitemap(sourceConfig.sitemap_url, logger);
207
+ // Add sitemap URLs to the queue if they're within the website scope
208
+ for (const url of sitemapUrls) {
209
+ if (url.startsWith(sourceConfig.url) && !queue.includes(url)) {
210
+ logger.debug(`Adding URL from sitemap to queue: ${url}`);
211
+ queue.push(url);
212
+ }
213
+ }
214
+ logger.info(`Added ${queue.length - 1} URLs from sitemap to the crawl queue`);
215
+ }
216
+ logger.info(`Starting crawl from ${baseUrl} with ${queue.length} URLs in initial queue`);
217
+ let processedCount = 0;
218
+ let skippedCount = 0;
219
+ let skippedSizeCount = 0;
220
+ let pdfProcessedCount = 0;
221
+ let errorCount = 0;
222
+ let hasNetworkErrors = false;
223
+ while (queue.length > 0) {
224
+ const url = queue.shift();
225
+ if (!url)
226
+ continue;
227
+ const normalizedUrl = utils_1.Utils.normalizeUrl(url);
228
+ if (visitedUrls.has(normalizedUrl))
229
+ continue;
230
+ visitedUrls.add(normalizedUrl);
231
+ if (!utils_1.Utils.shouldProcessUrl(url)) {
232
+ logger.debug(`Skipping URL with unsupported extension: ${url}`);
233
+ skippedCount++;
234
+ continue;
235
+ }
236
+ try {
237
+ logger.info(`Crawling: ${url}`);
238
+ const content = await this.processPage(url, sourceConfig);
239
+ if (content !== null) {
240
+ await processPageContent(url, content);
241
+ if (utils_1.Utils.isPdfUrl(url)) {
242
+ pdfProcessedCount++;
243
+ }
244
+ else {
245
+ processedCount++;
246
+ }
247
+ }
248
+ else {
249
+ skippedSizeCount++;
250
+ }
251
+ // Only try to extract links from HTML pages, not PDFs
252
+ if (!utils_1.Utils.isPdfUrl(url)) {
253
+ const response = await axios_1.default.get(url);
254
+ const $ = (0, cheerio_1.load)(response.data);
255
+ logger.debug(`Finding links on page ${url}`);
256
+ let newLinksFound = 0;
257
+ $('a[href]').each((_, element) => {
258
+ const href = $(element).attr('href');
259
+ if (!href || href.startsWith('#') || href.startsWith('mailto:'))
260
+ return;
261
+ const fullUrl = utils_1.Utils.buildUrl(href, url);
262
+ if (fullUrl.startsWith(sourceConfig.url) && !visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
263
+ if (!queue.includes(fullUrl)) {
264
+ queue.push(fullUrl);
265
+ newLinksFound++;
266
+ }
267
+ }
268
+ });
269
+ logger.debug(`Found ${newLinksFound} new links on ${url}`);
270
+ }
271
+ }
272
+ catch (error) {
273
+ logger.error(`Failed during processing or link discovery for ${url}:`, error);
274
+ errorCount++;
275
+ // Check if this is a network error (DNS resolution, connection issues, etc.)
276
+ if (this.isNetworkError(error)) {
277
+ hasNetworkErrors = true;
278
+ logger.warn(`Network error detected for ${url}, this may affect cleanup decisions`);
279
+ }
280
+ }
281
+ }
282
+ logger.info(`Crawl completed. HTML Pages: ${processedCount}, PDFs: ${pdfProcessedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
283
+ if (hasNetworkErrors) {
284
+ logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
285
+ }
286
+ return { hasNetworkErrors };
287
+ }
288
+ isNetworkError(error) {
289
+ // Check for common network error patterns
290
+ if (error?.code) {
291
+ // DNS resolution errors
292
+ if (error.code === 'ENOTFOUND')
293
+ return true;
294
+ // Connection refused
295
+ if (error.code === 'ECONNREFUSED')
296
+ return true;
297
+ // Connection timeout
298
+ if (error.code === 'ETIMEDOUT')
299
+ return true;
300
+ // Connection reset
301
+ if (error.code === 'ECONNRESET')
302
+ return true;
303
+ // Host unreachable
304
+ if (error.code === 'EHOSTUNREACH')
305
+ return true;
306
+ // Network unreachable
307
+ if (error.code === 'ENETUNREACH')
308
+ return true;
309
+ }
310
+ // Check for axios-specific network errors
311
+ if (error?.isAxiosError) {
312
+ // If there's no response, it's likely a network error
313
+ if (!error.response)
314
+ return true;
315
+ }
316
+ // Check error message for network-related terms
317
+ const errorMessage = error?.message?.toLowerCase() || '';
318
+ if (errorMessage.includes('getaddrinfo') ||
319
+ errorMessage.includes('network') ||
320
+ errorMessage.includes('timeout') ||
321
+ errorMessage.includes('connection') ||
322
+ errorMessage.includes('dns')) {
323
+ return true;
324
+ }
325
+ return false;
326
+ }
327
+ async processPage(url, sourceConfig) {
328
+ const logger = this.logger.child('page-processor');
329
+ logger.debug(`Processing content from ${url}`);
330
+ // Check if this is a PDF URL
331
+ if (utils_1.Utils.isPdfUrl(url)) {
332
+ logger.info(`Processing PDF: ${url}`);
333
+ try {
334
+ const markdown = await this.downloadAndConvertPdfFromUrl(url, logger);
335
+ // Check size limit for PDF content
336
+ if (markdown.length > sourceConfig.max_size) {
337
+ logger.warn(`PDF content (${markdown.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping ${url}.`);
338
+ return null;
339
+ }
340
+ return markdown;
341
+ }
342
+ catch (error) {
343
+ logger.error(`Failed to process PDF ${url}:`, error);
344
+ return null;
345
+ }
346
+ }
347
+ // Original HTML page processing logic
348
+ let browser = null;
349
+ try {
350
+ // Use system Chromium if available (for Docker environments)
351
+ let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
352
+ if (!executablePath) {
353
+ if (fs.existsSync('/usr/bin/chromium')) {
354
+ executablePath = '/usr/bin/chromium';
355
+ }
356
+ else if (fs.existsSync('/usr/bin/chromium-browser')) {
357
+ executablePath = '/usr/bin/chromium-browser';
358
+ }
359
+ }
360
+ browser = await puppeteer_1.default.launch({
361
+ executablePath,
362
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
363
+ });
364
+ const page = await browser.newPage();
365
+ logger.debug(`Navigating to ${url}`);
366
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
367
+ const htmlContent = await page.evaluate(() => {
368
+ const mainContentElement = document.querySelector('div[role="main"].document') || document.querySelector('main') || document.body;
369
+ return mainContentElement.innerHTML;
370
+ });
371
+ if (htmlContent.length > sourceConfig.max_size) {
372
+ logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
373
+ await browser.close();
374
+ return null;
375
+ }
376
+ logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
377
+ const dom = new jsdom_1.JSDOM(htmlContent);
378
+ const document = dom.window.document;
379
+ document.querySelectorAll('pre').forEach((pre) => {
380
+ pre.classList.add('article-content');
381
+ pre.setAttribute('data-readable-content-score', '100');
382
+ this.markCodeParents(pre.parentElement);
383
+ });
384
+ logger.debug(`Applying Readability to extract main content`);
385
+ const reader = new readability_1.Readability(document, {
386
+ charThreshold: 20,
387
+ classesToPreserve: ['article-content'],
388
+ });
389
+ const article = reader.parse();
390
+ if (!article) {
391
+ logger.warn(`Failed to parse article content with Readability for ${url}`);
392
+ await browser.close();
393
+ return null;
394
+ }
395
+ logger.debug(`Sanitizing HTML (${article.content.length} chars)`);
396
+ const cleanHtml = (0, sanitize_html_1.default)(article.content, {
397
+ allowedTags: [
398
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
399
+ 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
400
+ 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
401
+ ],
402
+ allowedAttributes: {
403
+ 'a': ['href'],
404
+ 'pre': ['class', 'data-language'],
405
+ 'code': ['class', 'data-language'],
406
+ 'div': ['class'],
407
+ 'span': ['class']
408
+ }
409
+ });
410
+ logger.debug(`Converting HTML to Markdown`);
411
+ const markdown = this.turndownService.turndown(cleanHtml);
412
+ logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
413
+ return markdown;
414
+ }
415
+ catch (error) {
416
+ logger.error(`Error processing page ${url}:`, error);
417
+ return null;
418
+ }
419
+ finally {
420
+ if (browser && browser.isConnected()) {
421
+ await browser.close();
422
+ logger.debug(`Browser closed for ${url}`);
423
+ }
424
+ }
425
+ }
426
+ markCodeParents(node) {
427
+ if (!node)
428
+ return;
429
+ if (node.querySelector('pre, code')) {
430
+ node.classList.add('article-content');
431
+ node.setAttribute('data-readable-content-score', '100');
432
+ }
433
+ this.markCodeParents(node.parentElement);
434
+ }
435
+ async convertPdfToMarkdown(filePath, logger) {
436
+ logger.debug(`Converting PDF to markdown: ${filePath}`);
437
+ try {
438
+ // Dynamic import for PDF.js to handle ES module compatibility
439
+ const pdfjsLib = await Promise.resolve().then(() => __importStar(require('pdfjs-dist/legacy/build/pdf.mjs')));
440
+ // Read the PDF file as a buffer and convert to Uint8Array
441
+ const pdfBuffer = fs.readFileSync(filePath);
442
+ const pdfData = new Uint8Array(pdfBuffer);
443
+ // Load the PDF document
444
+ const loadingTask = pdfjsLib.getDocument({
445
+ data: pdfData,
446
+ // Disable worker to avoid issues in Node.js environment
447
+ useWorkerFetch: false,
448
+ isEvalSupported: false,
449
+ useSystemFonts: true
450
+ });
451
+ const pdfDocument = await loadingTask.promise;
452
+ const numPages = pdfDocument.numPages;
453
+ logger.debug(`PDF has ${numPages} pages`);
454
+ let markdown = `# ${path.basename(filePath, '.pdf')}\n\n`;
455
+ // Extract text from each page
456
+ for (let pageNum = 1; pageNum <= numPages; pageNum++) {
457
+ const page = await pdfDocument.getPage(pageNum);
458
+ const textContent = await page.getTextContent();
459
+ // Combine text items into a readable format
460
+ let pageText = '';
461
+ let currentY = -1;
462
+ for (const item of textContent.items) {
463
+ if ('str' in item) {
464
+ // If this is a new line (different Y position), add a line break
465
+ if (currentY !== -1 && Math.abs(item.transform[5] - currentY) > 5) {
466
+ pageText += '\n';
467
+ }
468
+ pageText += item.str;
469
+ // Add space if the next item doesn't start immediately after this one
470
+ if ('width' in item && item.width > 0) {
471
+ pageText += ' ';
472
+ }
473
+ currentY = item.transform[5];
474
+ }
475
+ }
476
+ // Clean up the text
477
+ pageText = pageText
478
+ .replace(/\s+/g, ' ') // Replace multiple whitespace with single space
479
+ .replace(/\n\s+/g, '\n') // Clean up line starts
480
+ .trim();
481
+ if (pageText.length > 0) {
482
+ if (numPages > 1) {
483
+ markdown += `## Page ${pageNum}\n\n`;
484
+ }
485
+ markdown += pageText + '\n\n';
486
+ }
487
+ }
488
+ // Clean up the final markdown
489
+ markdown = markdown.replace(/\n{3,}/g, '\n\n').trim();
490
+ logger.debug(`Converted PDF to ${markdown.length} characters of markdown`);
491
+ return markdown;
492
+ }
493
+ catch (error) {
494
+ logger.error(`Failed to convert PDF ${filePath}:`, error);
495
+ throw error;
496
+ }
497
+ }
498
+ async downloadAndConvertPdfFromUrl(url, logger) {
499
+ logger.debug(`Downloading and converting PDF from URL: ${url}`);
500
+ try {
501
+ // Download the PDF file
502
+ const response = await axios_1.default.get(url, {
503
+ responseType: 'arraybuffer',
504
+ timeout: 60000, // 60 second timeout
505
+ headers: {
506
+ 'User-Agent': 'Mozilla/5.0 (compatible; doc2vec PDF processor)'
507
+ }
508
+ });
509
+ if (response.status !== 200) {
510
+ throw new Error(`Failed to download PDF: HTTP ${response.status}`);
511
+ }
512
+ logger.debug(`Downloaded PDF (${response.data.byteLength} bytes)`);
513
+ // Dynamic import for PDF.js to handle ES module compatibility
514
+ const pdfjsLib = await Promise.resolve().then(() => __importStar(require('pdfjs-dist/legacy/build/pdf.mjs')));
515
+ // Convert ArrayBuffer to Uint8Array
516
+ const pdfData = new Uint8Array(response.data);
517
+ // Load the PDF document
518
+ const loadingTask = pdfjsLib.getDocument({
519
+ data: pdfData,
520
+ // Disable worker to avoid issues in Node.js environment
521
+ useWorkerFetch: false,
522
+ isEvalSupported: false,
523
+ useSystemFonts: true
524
+ });
525
+ const pdfDocument = await loadingTask.promise;
526
+ const numPages = pdfDocument.numPages;
527
+ logger.debug(`PDF has ${numPages} pages`);
528
+ // Get the filename from URL for the title
529
+ const urlPath = new URL(url).pathname;
530
+ const filename = path.basename(urlPath, '.pdf') || 'document';
531
+ let markdown = `# ${filename}\n\n`;
532
+ // Extract text from each page
533
+ for (let pageNum = 1; pageNum <= numPages; pageNum++) {
534
+ const page = await pdfDocument.getPage(pageNum);
535
+ const textContent = await page.getTextContent();
536
+ // Combine text items into a readable format
537
+ let pageText = '';
538
+ let currentY = -1;
539
+ for (const item of textContent.items) {
540
+ if ('str' in item) {
541
+ // If this is a new line (different Y position), add a line break
542
+ if (currentY !== -1 && Math.abs(item.transform[5] - currentY) > 5) {
543
+ pageText += '\n';
544
+ }
545
+ pageText += item.str;
546
+ // Add space if the next item doesn't start immediately after this one
547
+ if ('width' in item && item.width > 0) {
548
+ pageText += ' ';
549
+ }
550
+ currentY = item.transform[5];
551
+ }
552
+ }
553
+ // Clean up the text
554
+ pageText = pageText
555
+ .replace(/\s+/g, ' ') // Replace multiple whitespace with single space
556
+ .replace(/\n\s+/g, '\n') // Clean up line starts
557
+ .trim();
558
+ if (pageText.length > 0) {
559
+ if (numPages > 1) {
560
+ markdown += `## Page ${pageNum}\n\n`;
561
+ }
562
+ markdown += pageText + '\n\n';
563
+ }
564
+ }
565
+ // Clean up the final markdown
566
+ markdown = markdown.replace(/\n{3,}/g, '\n\n').trim();
567
+ logger.debug(`Converted PDF to ${markdown.length} characters of markdown`);
568
+ return markdown;
569
+ }
570
+ catch (error) {
571
+ logger.error(`Failed to download and convert PDF from ${url}:`, error);
572
+ throw error;
573
+ }
574
+ }
575
+ async processDirectory(dirPath, config, processFileContent, parentLogger, visitedPaths = new Set()) {
576
+ const logger = parentLogger.child('directory-processor');
577
+ logger.info(`Processing directory: ${dirPath}`);
578
+ const recursive = config.recursive !== undefined ? config.recursive : true;
579
+ const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm', '.pdf'];
580
+ const excludeExtensions = config.exclude_extensions || [];
581
+ const encoding = config.encoding || 'utf8';
582
+ try {
583
+ const files = fs.readdirSync(dirPath);
584
+ let processedFiles = 0;
585
+ let skippedFiles = 0;
586
+ for (const file of files) {
587
+ const filePath = path.join(dirPath, file);
588
+ const stat = fs.statSync(filePath);
589
+ // Skip already visited paths
590
+ if (visitedPaths.has(filePath)) {
591
+ logger.debug(`Skipping already visited path: ${filePath}`);
592
+ continue;
593
+ }
594
+ visitedPaths.add(filePath);
595
+ if (stat.isDirectory()) {
596
+ if (recursive) {
597
+ await this.processDirectory(filePath, config, processFileContent, logger, visitedPaths);
598
+ }
599
+ else {
600
+ logger.debug(`Skipping directory ${filePath} (recursive=false)`);
601
+ }
602
+ }
603
+ else if (stat.isFile()) {
604
+ const extension = path.extname(file).toLowerCase();
605
+ // Apply extension filters
606
+ if (excludeExtensions.includes(extension)) {
607
+ logger.debug(`Skipping file with excluded extension: ${filePath}`);
608
+ skippedFiles++;
609
+ continue;
610
+ }
611
+ if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
612
+ logger.debug(`Skipping file with non-included extension: ${filePath}`);
613
+ skippedFiles++;
614
+ continue;
615
+ }
616
+ try {
617
+ logger.info(`Reading file: ${filePath}`);
618
+ let content;
619
+ let processedContent;
620
+ if (extension === '.pdf') {
621
+ // Handle PDF files
622
+ logger.debug(`Processing PDF file: ${filePath}`);
623
+ processedContent = await this.convertPdfToMarkdown(filePath, logger);
624
+ }
625
+ else {
626
+ // Handle text-based files
627
+ content = fs.readFileSync(filePath, { encoding: encoding });
628
+ if (content.length > config.max_size) {
629
+ logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
630
+ skippedFiles++;
631
+ continue;
632
+ }
633
+ // Convert HTML to Markdown if needed
634
+ if (extension === '.html' || extension === '.htm') {
635
+ logger.debug(`Converting HTML to Markdown for ${filePath}`);
636
+ const cleanHtml = (0, sanitize_html_1.default)(content, {
637
+ allowedTags: [
638
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
639
+ 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
640
+ 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
641
+ ],
642
+ allowedAttributes: {
643
+ 'a': ['href'],
644
+ 'pre': ['class', 'data-language'],
645
+ 'code': ['class', 'data-language'],
646
+ 'div': ['class'],
647
+ 'span': ['class']
648
+ }
649
+ });
650
+ processedContent = this.turndownService.turndown(cleanHtml);
651
+ }
652
+ else {
653
+ processedContent = content;
654
+ }
655
+ }
656
+ // Check size limit for processed content
657
+ if (processedContent.length > config.max_size) {
658
+ logger.warn(`Processed content (${processedContent.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
659
+ skippedFiles++;
660
+ continue;
661
+ }
662
+ await processFileContent(filePath, processedContent);
663
+ processedFiles++;
664
+ }
665
+ catch (error) {
666
+ logger.error(`Error processing file ${filePath}:`, error);
667
+ }
668
+ }
669
+ }
670
+ logger.info(`Directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
671
+ }
672
+ catch (error) {
673
+ logger.error(`Error reading directory ${dirPath}:`, error);
674
+ }
675
+ }
676
+ async chunkMarkdown(markdown, sourceConfig, url) {
677
+ const logger = this.logger.child('chunker');
678
+ // --- Configuration ---
679
+ const MAX_TOKENS = 1000;
680
+ const MIN_TOKENS = 150; // 💡 Merges "OpenAI-compatible" sentence into the next block
681
+ const OVERLAP_PERCENT = 0.1; // 10% overlap for large splits
682
+ const chunks = [];
683
+ const lines = markdown.split("\n");
684
+ let buffer = "";
685
+ let headingHierarchy = [];
686
+ let bufferHeadings = []; // Track headings in current buffer
687
+ /**
688
+ * Computes the topic hierarchy for merged content.
689
+ * When merging sibling sections (same level), uses their parent heading.
690
+ * Otherwise uses the current hierarchy.
691
+ */
692
+ const computeTopicHierarchy = () => {
693
+ if (bufferHeadings.length === 0) {
694
+ return headingHierarchy;
695
+ }
696
+ // Find the deepest level (most recent headings)
697
+ const deepestLevel = Math.max(...bufferHeadings.map(h => h.level));
698
+ // Get all headings at the deepest level
699
+ const deepestHeadings = bufferHeadings.filter(h => h.level === deepestLevel);
700
+ // If we have multiple sibling headings at the deepest level, use their parent
701
+ if (deepestHeadings.length > 1 && deepestLevel > 1) {
702
+ // Use parent heading (one level up from the sibling headings)
703
+ // headingHierarchy still contains the parent at index (deepestLevel - 2)
704
+ // We want everything up to (but not including) the deepest level
705
+ return headingHierarchy.slice(0, deepestLevel - 1);
706
+ }
707
+ // Single heading or different levels: use the current hierarchy
708
+ // This reflects the most recent heading which is appropriate
709
+ return headingHierarchy;
710
+ };
711
+ /**
712
+ * Internal helper to create the final chunk object with injected context.
713
+ */
714
+ const createDocumentChunk = (content, hierarchy) => {
715
+ // 💡 BREADCRUMB INJECTION
716
+ // We prepend the hierarchy to the text. This makes the vector highly relevant
717
+ // to searches for parent topics even if the body doesn't mention them.
718
+ const breadcrumbs = hierarchy.filter(h => h).join(" > ");
719
+ const contextPrefix = breadcrumbs ? `[Topic: ${breadcrumbs}]\n` : "";
720
+ const searchableText = contextPrefix + content.trim();
721
+ console.log(searchableText);
722
+ console.log(hierarchy);
723
+ const chunkId = utils_1.Utils.generateHash(searchableText);
724
+ return {
725
+ content: searchableText,
726
+ metadata: {
727
+ product_name: sourceConfig.product_name,
728
+ version: sourceConfig.version,
729
+ heading_hierarchy: hierarchy.filter(h => h),
730
+ section: hierarchy[hierarchy.length - 1] || "Introduction",
731
+ chunk_id: chunkId,
732
+ url: url,
733
+ hash: chunkId
734
+ }
735
+ };
736
+ };
737
+ /**
738
+ * Flushes the current buffer into the chunks array.
739
+ * Uses sub-splitting logic if the buffer exceeds MAX_TOKENS.
740
+ */
741
+ const flushBuffer = (force = false) => {
742
+ const trimmedBuffer = buffer.trim();
743
+ if (!trimmedBuffer)
744
+ return;
745
+ const tokenCount = utils_1.Utils.tokenize(trimmedBuffer).length;
746
+ // 💡 SEMANTIC MERGING
747
+ // If the current section is too short (like just a title or a one-liner),
748
+ // we don't flush yet unless it's the end of the file (force=true).
749
+ if (tokenCount < MIN_TOKENS && !force) {
750
+ return;
751
+ }
752
+ // Compute the appropriate topic hierarchy for merged content
753
+ const topicHierarchy = computeTopicHierarchy();
754
+ if (tokenCount > MAX_TOKENS) {
755
+ // 💡 RECURSIVE OVERLAP SPLITTING
756
+ // If the section is a massive guide, split it but keep headers on every sub-piece.
757
+ const tokens = utils_1.Utils.tokenize(trimmedBuffer);
758
+ const overlapSize = Math.floor(MAX_TOKENS * OVERLAP_PERCENT);
759
+ for (let i = 0; i < tokens.length; i += (MAX_TOKENS - overlapSize)) {
760
+ const subTokens = tokens.slice(i, i + MAX_TOKENS);
761
+ const subContent = subTokens.join("");
762
+ chunks.push(createDocumentChunk(subContent, topicHierarchy));
763
+ }
764
+ }
765
+ else {
766
+ chunks.push(createDocumentChunk(trimmedBuffer, topicHierarchy));
767
+ }
768
+ buffer = ""; // Reset buffer after successful flush
769
+ bufferHeadings = []; // Reset tracked headings
770
+ };
771
+ // --- Main Processing Loop ---
772
+ for (const line of lines) {
773
+ const isHeading = line.startsWith("#");
774
+ if (isHeading) {
775
+ // Update Hierarchy Stack for the new heading
776
+ const levelMatch = line.match(/^(#+)/);
777
+ const level = levelMatch ? levelMatch[1].length : 1;
778
+ const headingText = line.replace(/^#+\s*/, "").trim();
779
+ // Check if we should merge with previous content
780
+ const currentTokenCount = utils_1.Utils.tokenize(buffer.trim()).length;
781
+ const hasBufferContent = currentTokenCount > 0;
782
+ const bufferIsSmall = currentTokenCount < MIN_TOKENS;
783
+ // Only merge if:
784
+ // 1. Buffer has content and is small
785
+ // 2. Buffer has tracked headings (we're merging sections, not just content)
786
+ // 3. New heading is at same or deeper level than the deepest heading in buffer (siblings or children)
787
+ // If new heading is shallower (e.g., H2 after H3), it's a new section - flush first
788
+ const deepestBufferLevel = bufferHeadings.length > 0
789
+ ? Math.max(...bufferHeadings.map(h => h.level))
790
+ : 0;
791
+ const shouldMerge = hasBufferContent && bufferIsSmall && bufferHeadings.length > 0 &&
792
+ level >= deepestBufferLevel;
793
+ if (!shouldMerge && hasBufferContent) {
794
+ // Buffer is large enough OR new heading starts a new section - flush first
795
+ flushBuffer();
796
+ }
797
+ // If shouldMerge is true, we keep the buffer and merge the sections
798
+ // Reset hierarchy below this level (e.g., H2 reset should clear previous H3s)
799
+ headingHierarchy = headingHierarchy.slice(0, level - 1);
800
+ headingHierarchy[level - 1] = headingText;
801
+ // Track this heading in the buffer
802
+ bufferHeadings.push({ level, text: headingText });
803
+ buffer += `${line}\n`;
804
+ }
805
+ else {
806
+ buffer += `${line}\n`;
807
+ // Safety valve: if a single section is huge, flush it periodically
808
+ if (utils_1.Utils.tokenize(buffer).length >= MAX_TOKENS) {
809
+ flushBuffer();
810
+ }
811
+ }
812
+ }
813
+ // Final sweep
814
+ flushBuffer(true);
815
+ logger.debug(`Chunking complete: ${chunks.length} rich context chunks created.`);
816
+ return chunks;
817
+ }
818
+ }
819
+ exports.ContentProcessor = ContentProcessor;