doc2vec 2.0.0 → 2.2.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.
@@ -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) && !queue.includes(url)) {
210
- logger.debug(`Adding URL from sitemap to queue: ${url}`);
211
- queue.push(url);
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`);
@@ -235,7 +262,14 @@ class ContentProcessor {
235
262
  }
236
263
  try {
237
264
  logger.info(`Crawling: ${url}`);
238
- const content = await this.processPage(url, sourceConfig);
265
+ const sources = referrers.get(url) ?? new Set([baseUrl]);
266
+ const content = await this.processPage(url, sourceConfig, (reportedUrl, status) => {
267
+ if (status === 404) {
268
+ for (const source of sources) {
269
+ addBrokenLink(source, reportedUrl);
270
+ }
271
+ }
272
+ });
239
273
  if (content !== null) {
240
274
  await processPageContent(url, content);
241
275
  if (utils_1.Utils.isPdfUrl(url)) {
@@ -252,17 +286,21 @@ class ContentProcessor {
252
286
  if (!utils_1.Utils.isPdfUrl(url)) {
253
287
  const response = await axios_1.default.get(url);
254
288
  const $ = (0, cheerio_1.load)(response.data);
289
+ const pageUrlForLinks = response?.request?.res?.responseUrl || url;
255
290
  logger.debug(`Finding links on page ${url}`);
256
291
  let newLinksFound = 0;
257
292
  $('a[href]').each((_, element) => {
258
293
  const href = $(element).attr('href');
259
294
  if (!href || href.startsWith('#') || href.startsWith('mailto:'))
260
295
  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++;
296
+ const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks);
297
+ if (fullUrl.startsWith(sourceConfig.url)) {
298
+ addReferrer(fullUrl, pageUrlForLinks);
299
+ if (!visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
300
+ if (!queue.includes(fullUrl)) {
301
+ queue.push(fullUrl);
302
+ newLinksFound++;
303
+ }
266
304
  }
267
305
  }
268
306
  });
@@ -272,6 +310,13 @@ class ContentProcessor {
272
310
  catch (error) {
273
311
  logger.error(`Failed during processing or link discovery for ${url}:`, error);
274
312
  errorCount++;
313
+ const status = this.getHttpStatus(error);
314
+ if (status === 404) {
315
+ const sources = referrers.get(url) ?? new Set([baseUrl]);
316
+ for (const source of sources) {
317
+ addBrokenLink(source, url);
318
+ }
319
+ }
275
320
  // Check if this is a network error (DNS resolution, connection issues, etc.)
276
321
  if (this.isNetworkError(error)) {
277
322
  hasNetworkErrors = true;
@@ -283,7 +328,7 @@ class ContentProcessor {
283
328
  if (hasNetworkErrors) {
284
329
  logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
285
330
  }
286
- return { hasNetworkErrors };
331
+ return { hasNetworkErrors, brokenLinks };
287
332
  }
288
333
  isNetworkError(error) {
289
334
  // Check for common network error patterns
@@ -324,7 +369,7 @@ class ContentProcessor {
324
369
  }
325
370
  return false;
326
371
  }
327
- async processPage(url, sourceConfig) {
372
+ async processPage(url, sourceConfig, onHttpStatus) {
328
373
  const logger = this.logger.child('page-processor');
329
374
  logger.debug(`Processing content from ${url}`);
330
375
  // Check if this is a PDF URL
@@ -340,6 +385,13 @@ class ContentProcessor {
340
385
  return markdown;
341
386
  }
342
387
  catch (error) {
388
+ const status = this.getHttpStatus(error);
389
+ if (status !== undefined && status >= 400) {
390
+ if (onHttpStatus) {
391
+ onHttpStatus(url, status);
392
+ }
393
+ throw error;
394
+ }
343
395
  logger.error(`Failed to process PDF ${url}:`, error);
344
396
  return null;
345
397
  }
@@ -363,7 +415,13 @@ class ContentProcessor {
363
415
  });
364
416
  const page = await browser.newPage();
365
417
  logger.debug(`Navigating to ${url}`);
366
- await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
418
+ const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
419
+ const status = response?.status();
420
+ if (status !== undefined && status >= 400) {
421
+ const error = new Error(`Failed to load page: HTTP ${status}`);
422
+ error.status = status;
423
+ throw error;
424
+ }
367
425
  const htmlContent = await page.evaluate(() => {
368
426
  // 💡 Try specific content selectors first, then fall back to broader ones
369
427
  const mainContentElement = document.querySelector('.docs-content') || // Common docs pattern
@@ -480,6 +538,13 @@ class ContentProcessor {
480
538
  return markdown;
481
539
  }
482
540
  catch (error) {
541
+ const status = this.getHttpStatus(error);
542
+ if (status !== undefined && status >= 400) {
543
+ if (onHttpStatus) {
544
+ onHttpStatus(url, status);
545
+ }
546
+ throw error;
547
+ }
483
548
  logger.error(`Error processing page ${url}:`, error);
484
549
  return null;
485
550
  }
@@ -490,6 +555,15 @@ class ContentProcessor {
490
555
  }
491
556
  }
492
557
  }
558
+ getHttpStatus(error) {
559
+ if (typeof error?.status === 'number') {
560
+ return error.status;
561
+ }
562
+ if (typeof error?.response?.status === 'number') {
563
+ return error.response.status;
564
+ }
565
+ return undefined;
566
+ }
493
567
  markCodeParents(node) {
494
568
  if (!node)
495
569
  return;
@@ -812,6 +886,257 @@ class ContentProcessor {
812
886
  logger.error(`Error reading directory ${dirPath}:`, error);
813
887
  }
814
888
  }
889
+ async processCodeDirectory(dirPath, config, processFileContent, parentLogger, visitedPaths = new Set(), options) {
890
+ const logger = parentLogger.child('code-directory-processor');
891
+ logger.info(`Processing code directory: ${dirPath}`);
892
+ const recursive = config.recursive !== undefined ? config.recursive : true;
893
+ const includeExtensions = config.include_extensions || [
894
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
895
+ '.py', '.go', '.rs', '.java', '.kt', '.kts', '.swift',
896
+ '.c', '.cc', '.cpp', '.h', '.hpp', '.cs',
897
+ '.rb', '.php', '.scala', '.sql', '.sh', '.bash', '.zsh',
898
+ '.html', '.css', '.scss', '.sass', '.less',
899
+ '.json', '.yaml', '.yml', '.md'
900
+ ];
901
+ const excludeExtensions = config.exclude_extensions || [];
902
+ const encoding = config.encoding || 'utf8';
903
+ let maxMtime = 0;
904
+ try {
905
+ const files = fs.readdirSync(dirPath);
906
+ let processedFiles = 0;
907
+ let skippedFiles = 0;
908
+ const allowedFiles = options?.allowedFiles;
909
+ const mtimeCutoff = options?.mtimeCutoff;
910
+ const trackFiles = options?.trackFiles;
911
+ for (const file of files) {
912
+ const filePath = path.join(dirPath, file);
913
+ const stat = fs.statSync(filePath);
914
+ if (visitedPaths.has(filePath)) {
915
+ logger.debug(`Skipping already visited path: ${filePath}`);
916
+ continue;
917
+ }
918
+ visitedPaths.add(filePath);
919
+ if (stat.isDirectory()) {
920
+ if (recursive) {
921
+ const childResult = await this.processCodeDirectory(filePath, config, processFileContent, logger, visitedPaths, options);
922
+ processedFiles += childResult.processedFiles;
923
+ skippedFiles += childResult.skippedFiles;
924
+ maxMtime = Math.max(maxMtime, childResult.maxMtime);
925
+ }
926
+ else {
927
+ logger.debug(`Skipping directory ${filePath} (recursive=false)`);
928
+ }
929
+ }
930
+ else if (stat.isFile()) {
931
+ const extension = path.extname(file).toLowerCase();
932
+ if (excludeExtensions.includes(extension)) {
933
+ logger.debug(`Skipping file with excluded extension: ${filePath}`);
934
+ skippedFiles++;
935
+ continue;
936
+ }
937
+ if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
938
+ logger.debug(`Skipping file with non-included extension: ${filePath}`);
939
+ skippedFiles++;
940
+ continue;
941
+ }
942
+ trackFiles?.add(filePath);
943
+ maxMtime = Math.max(maxMtime, stat.mtimeMs);
944
+ if (allowedFiles && !allowedFiles.has(filePath)) {
945
+ skippedFiles++;
946
+ continue;
947
+ }
948
+ if (mtimeCutoff !== undefined && stat.mtimeMs <= mtimeCutoff) {
949
+ skippedFiles++;
950
+ continue;
951
+ }
952
+ try {
953
+ logger.info(`Reading file: ${filePath}`);
954
+ const content = fs.readFileSync(filePath, { encoding: encoding });
955
+ if (content.length > config.max_size) {
956
+ logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
957
+ skippedFiles++;
958
+ continue;
959
+ }
960
+ await processFileContent(filePath, content);
961
+ processedFiles++;
962
+ }
963
+ catch (error) {
964
+ logger.error(`Error processing file ${filePath}:`, error);
965
+ }
966
+ }
967
+ }
968
+ logger.info(`Code directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
969
+ return { processedFiles, skippedFiles, maxMtime };
970
+ }
971
+ catch (error) {
972
+ logger.error(`Error reading code directory ${dirPath}:`, error);
973
+ return { processedFiles: 0, skippedFiles: 0, maxMtime };
974
+ }
975
+ }
976
+ async getTokenChunker(chunkSize) {
977
+ const cacheKey = `${chunkSize || 'default'}`;
978
+ const cached = this.tokenChunkerCache.get(cacheKey);
979
+ if (cached) {
980
+ return cached;
981
+ }
982
+ const chunkerPromise = (async () => {
983
+ const { TokenChunker } = await this.importChonkieModule('@chonkiejs/core');
984
+ return await TokenChunker.create({ chunkSize, tokenizer: 'character' });
985
+ })();
986
+ this.tokenChunkerCache.set(cacheKey, chunkerPromise);
987
+ return chunkerPromise;
988
+ }
989
+ async getCodeChunker(lang, chunkSize) {
990
+ const cacheKey = `${lang}:${chunkSize || 'default'}`;
991
+ const cached = this.codeChunkerCache.get(cacheKey);
992
+ if (cached) {
993
+ return cached;
994
+ }
995
+ const chunkerPromise = (async () => {
996
+ const tokenizer = await this.getTokenizer();
997
+ return await code_chunker_1.CodeChunker.create({
998
+ lang,
999
+ chunkSize,
1000
+ tokenCounter: async (text) => tokenizer.countTokens(text)
1001
+ });
1002
+ })();
1003
+ this.codeChunkerCache.set(cacheKey, chunkerPromise);
1004
+ return chunkerPromise;
1005
+ }
1006
+ async getTokenizer() {
1007
+ if (!this.tokenizerCache) {
1008
+ this.tokenizerCache = (async () => {
1009
+ const { Tokenizer } = await this.importChonkieModule('@chonkiejs/core');
1010
+ return await Tokenizer.create('character');
1011
+ })();
1012
+ }
1013
+ return this.tokenizerCache;
1014
+ }
1015
+ detectCodeLanguage(filePath) {
1016
+ const extension = path.extname(filePath).toLowerCase();
1017
+ const languageMap = {
1018
+ '.ts': 'typescript',
1019
+ '.tsx': 'typescript',
1020
+ '.js': 'javascript',
1021
+ '.jsx': 'javascript',
1022
+ '.mjs': 'javascript',
1023
+ '.cjs': 'javascript',
1024
+ '.py': 'python',
1025
+ '.go': 'go',
1026
+ '.rs': 'rust',
1027
+ '.java': 'java',
1028
+ '.kt': 'kotlin',
1029
+ '.kts': 'kotlin',
1030
+ '.swift': 'swift',
1031
+ '.c': 'c',
1032
+ '.cc': 'cpp',
1033
+ '.cpp': 'cpp',
1034
+ '.h': 'cpp',
1035
+ '.hpp': 'cpp',
1036
+ '.cs': 'csharp',
1037
+ '.rb': 'ruby',
1038
+ '.php': 'php',
1039
+ '.scala': 'scala',
1040
+ '.sql': 'sql',
1041
+ '.sh': 'bash',
1042
+ '.bash': 'bash',
1043
+ '.zsh': 'bash',
1044
+ '.html': 'html',
1045
+ '.css': 'css',
1046
+ '.scss': 'scss',
1047
+ '.sass': 'scss',
1048
+ '.less': 'css',
1049
+ '.json': 'json',
1050
+ '.yaml': 'yaml',
1051
+ '.yml': 'yaml',
1052
+ '.md': 'markdown'
1053
+ };
1054
+ return languageMap[extension];
1055
+ }
1056
+ async importChonkieModule(specifier) {
1057
+ // Use dynamic import() directly - preserved by TypeScript with target ES2020+
1058
+ // even in CommonJS mode, as Node.js supports import() in CJS contexts
1059
+ return Promise.resolve(`${specifier}`).then(s => __importStar(require(s)));
1060
+ }
1061
+ async chunkCode(code, sourceConfig, url, filePath, branch, repo) {
1062
+ const logger = this.logger.child('code-chunker');
1063
+ const normalizedPath = filePath.replace(/\\/g, '/');
1064
+ const lang = this.detectCodeLanguage(filePath);
1065
+ let chunks;
1066
+ if (lang === 'markdown') {
1067
+ const markdownChunks = await this.chunkMarkdown(code, sourceConfig, url);
1068
+ for (const chunk of markdownChunks) {
1069
+ if (normalizedPath) {
1070
+ const filePrefix = `[File: ${normalizedPath}]\n`;
1071
+ const searchableText = filePrefix + chunk.content;
1072
+ const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
1073
+ chunk.content = searchableText;
1074
+ chunk.metadata.heading_hierarchy = [normalizedPath, ...chunk.metadata.heading_hierarchy.filter(Boolean)];
1075
+ chunk.metadata.section = normalizedPath;
1076
+ chunk.metadata.chunk_id = chunkId;
1077
+ chunk.metadata.hash = chunkId;
1078
+ }
1079
+ if (branch) {
1080
+ chunk.metadata.branch = branch;
1081
+ }
1082
+ if (repo) {
1083
+ chunk.metadata.repo = repo;
1084
+ }
1085
+ }
1086
+ logger.debug(`Chunked ${normalizedPath || url}: ${markdownChunks.length} chunks created.`);
1087
+ return markdownChunks;
1088
+ }
1089
+ if (lang) {
1090
+ try {
1091
+ const codeChunker = await this.getCodeChunker(lang, sourceConfig.chunk_size);
1092
+ chunks = await codeChunker.chunk(code);
1093
+ }
1094
+ catch (error) {
1095
+ logger.warn(`CodeChunker failed for ${normalizedPath || url}, falling back to token chunking:`, error);
1096
+ const chunker = await this.getTokenChunker(sourceConfig.chunk_size);
1097
+ chunks = await chunker.chunk(code);
1098
+ }
1099
+ }
1100
+ else {
1101
+ const chunker = await this.getTokenChunker(sourceConfig.chunk_size);
1102
+ chunks = await chunker.chunk(code);
1103
+ }
1104
+ const documentChunks = [];
1105
+ let chunkCounter = 0;
1106
+ const headingHierarchy = normalizedPath ? [normalizedPath] : [];
1107
+ const contextPrefix = normalizedPath ? `[File: ${normalizedPath}]\n` : '';
1108
+ for (const chunk of chunks) {
1109
+ const content = chunk.text?.trim();
1110
+ if (!content) {
1111
+ continue;
1112
+ }
1113
+ const searchableText = contextPrefix + content;
1114
+ const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
1115
+ documentChunks.push({
1116
+ content: searchableText,
1117
+ metadata: {
1118
+ product_name: sourceConfig.product_name,
1119
+ version: sourceConfig.version,
1120
+ ...(branch ? { branch } : {}),
1121
+ ...(repo ? { repo } : {}),
1122
+ heading_hierarchy: headingHierarchy,
1123
+ section: normalizedPath || 'Code',
1124
+ chunk_id: chunkId,
1125
+ url: url,
1126
+ hash: chunkId,
1127
+ chunk_index: chunkCounter,
1128
+ total_chunks: 0
1129
+ }
1130
+ });
1131
+ chunkCounter++;
1132
+ }
1133
+ const totalChunks = documentChunks.length;
1134
+ for (const chunk of documentChunks) {
1135
+ chunk.metadata.total_chunks = totalChunks;
1136
+ }
1137
+ logger.debug(`Chunked ${normalizedPath || url}: ${documentChunks.length} chunks created.`);
1138
+ return documentChunks;
1139
+ }
815
1140
  async chunkMarkdown(markdown, sourceConfig, url) {
816
1141
  const logger = this.logger.child('chunker');
817
1142
  // --- Configuration ---