doc2vec 1.1.0 → 1.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.
package/dist/doc2vec.js CHANGED
@@ -37,23 +37,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
- const readability_1 = require("@mozilla/readability");
41
40
  const axios_1 = __importDefault(require("axios"));
42
- const cheerio_1 = require("cheerio");
43
41
  const crypto_1 = __importDefault(require("crypto"));
44
- const jsdom_1 = require("jsdom");
45
- const puppeteer_1 = __importDefault(require("puppeteer"));
46
- const sanitize_html_1 = __importDefault(require("sanitize-html"));
47
- const turndown_1 = __importDefault(require("turndown"));
48
42
  const yaml = __importStar(require("js-yaml"));
49
43
  const fs = __importStar(require("fs"));
50
44
  const path = __importStar(require("path"));
51
- const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
52
45
  const openai_1 = require("openai");
53
46
  const dotenv = __importStar(require("dotenv"));
54
- const sqliteVec = __importStar(require("sqlite-vec"));
55
- const js_client_rest_1 = require("@qdrant/js-client-rest");
56
47
  const logger_1 = require("./logger");
48
+ const utils_1 = require("./utils");
49
+ const database_1 = require("./database");
50
+ const content_processor_1 = require("./content-processor");
57
51
  const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
58
52
  dotenv.config();
59
53
  class Doc2Vec {
@@ -67,11 +61,7 @@ class Doc2Vec {
67
61
  this.logger.info('Initializing Doc2Vec');
68
62
  this.config = this.loadConfig(configPath);
69
63
  this.openai = new openai_1.OpenAI({ apiKey: process.env.OPENAI_API_KEY });
70
- this.turndownService = new turndown_1.default({
71
- codeBlockStyle: 'fenced',
72
- headingStyle: 'atx'
73
- });
74
- this.setupTurndownRules();
64
+ this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
75
65
  }
76
66
  loadConfig(configPath) {
77
67
  try {
@@ -88,89 +78,6 @@ class Doc2Vec {
88
78
  process.exit(1);
89
79
  }
90
80
  }
91
- setupTurndownRules() {
92
- const logger = this.logger.child('markdown');
93
- logger.debug('Setting up Turndown rules for markdown conversion');
94
- this.turndownService.addRule('codeBlocks', {
95
- filter: (node) => node.nodeName === 'PRE',
96
- replacement: (content, node) => {
97
- const htmlNode = node;
98
- const code = htmlNode.querySelector('code');
99
- let codeContent;
100
- if (code) {
101
- codeContent = code.textContent || '';
102
- }
103
- else {
104
- codeContent = htmlNode.textContent || '';
105
- }
106
- const lines = codeContent.split('\n');
107
- let minIndent = Infinity;
108
- for (const line of lines) {
109
- if (line.trim() === '')
110
- continue;
111
- const leadingWhitespace = line.match(/^\s*/)?.[0] || '';
112
- minIndent = Math.min(minIndent, leadingWhitespace.length);
113
- }
114
- const cleanedLines = lines.map(line => {
115
- return line.substring(minIndent);
116
- });
117
- let cleanContent = cleanedLines.join('\n');
118
- cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
119
- cleanContent = cleanContent.replace(/\n{2,}/g, '\n');
120
- return `\n\`\`\`\n${cleanContent}\n\`\`\`\n`;
121
- }
122
- });
123
- this.turndownService.addRule('tableCell', {
124
- filter: ['th', 'td'],
125
- replacement: (content, node) => {
126
- const htmlNode = node;
127
- let cellContent = '';
128
- if (htmlNode.querySelector('p')) {
129
- cellContent = Array.from(htmlNode.querySelectorAll('p'))
130
- .map(p => p.textContent || '')
131
- .join(' ')
132
- .trim();
133
- }
134
- else {
135
- cellContent = content.trim();
136
- }
137
- return ` ${cellContent.replace(/\|/g, '\\|')} |`;
138
- }
139
- });
140
- this.turndownService.addRule('tableRow', {
141
- filter: 'tr',
142
- replacement: (content, node) => {
143
- const htmlNode = node;
144
- const cells = Array.from(htmlNode.cells);
145
- const isHeader = htmlNode.parentNode?.nodeName === 'THEAD';
146
- let output = '|' + content.trimEnd();
147
- if (isHeader) {
148
- const separator = cells.map(() => '---').join(' | ');
149
- output += '\n|' + separator + '|';
150
- }
151
- if (!isHeader || !htmlNode.nextElementSibling) {
152
- output += '\n';
153
- }
154
- return output;
155
- }
156
- });
157
- this.turndownService.addRule('table', {
158
- filter: 'table',
159
- replacement: (content) => {
160
- return '\n' + content.replace(/\n+/g, '\n').trim() + '\n';
161
- }
162
- });
163
- this.turndownService.addRule('preserveTableWhitespace', {
164
- filter: (node) => {
165
- return ((node.nodeName === 'TD' || node.nodeName === 'TH') &&
166
- (node.textContent?.trim().length === 0));
167
- },
168
- replacement: () => {
169
- return ' |';
170
- }
171
- });
172
- logger.debug('Turndown rules setup complete');
173
- }
174
81
  async run() {
175
82
  this.logger.section('PROCESSING SOURCES');
176
83
  for (const sourceConfig of this.config.sources) {
@@ -191,134 +98,14 @@ class Doc2Vec {
191
98
  }
192
99
  this.logger.section('PROCESSING COMPLETE');
193
100
  }
194
- getUrlPrefix(url) {
195
- try {
196
- const parsedUrl = new URL(url);
197
- return parsedUrl.origin + parsedUrl.pathname;
198
- }
199
- catch (error) {
200
- return url;
201
- }
202
- }
203
- generateMetadataUUID(repo) {
204
- // Simple deterministic approach - hash the repo name and convert to UUID format
205
- const hash = crypto_1.default.createHash('md5').update(`metadata_${repo}`).digest('hex');
206
- // Format as UUID with version bits set correctly (version 4)
207
- return `${hash.substr(0, 8)}-${hash.substr(8, 4)}-4${hash.substr(13, 3)}-${hash.substr(16, 4)}-${hash.substr(20, 12)}`;
208
- }
209
- async initDatabaseMetadata(dbConnection) {
210
- const logger = this.logger.child('metadata');
211
- if (dbConnection.type === 'sqlite') {
212
- const db = dbConnection.db;
213
- logger.debug('Creating metadata table if it doesn\'t exist');
214
- db.exec(`
215
- CREATE TABLE IF NOT EXISTS vec_metadata (
216
- key TEXT PRIMARY KEY,
217
- value TEXT
218
- );
219
- `);
220
- logger.info('SQLite metadata table initialized');
221
- }
222
- else if (dbConnection.type === 'qdrant') {
223
- // For Qdrant, we'll use the same collection but verify it exists
224
- logger.info(`Using existing Qdrant collection for metadata: ${dbConnection.collectionName}`);
225
- // Nothing special to initialize as we'll use the same collection
226
- }
227
- }
228
- async getLastRunDate(dbConnection, repo, defaultDate) {
229
- const logger = this.logger.child('metadata');
230
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
231
- try {
232
- if (dbConnection.type === 'sqlite') {
233
- const stmt = dbConnection.db.prepare('SELECT value FROM vec_metadata WHERE key = ?');
234
- const result = stmt.get(metadataKey);
235
- if (result) {
236
- logger.info(`Retrieved last run date for ${repo}: ${result.value}`);
237
- return result.value;
238
- }
239
- }
240
- else if (dbConnection.type === 'qdrant') {
241
- // Generate a UUID for this repo's metadata
242
- const metadataUUID = this.generateMetadataUUID(repo);
243
- logger.debug(`Looking up metadata with UUID: ${metadataUUID}`);
244
- try {
245
- // Try to retrieve the metadata point for this repo
246
- const response = await dbConnection.client.retrieve(dbConnection.collectionName, {
247
- ids: [metadataUUID],
248
- with_payload: true,
249
- with_vector: false
250
- });
251
- if (response.length > 0 && response[0].payload?.metadata_value) {
252
- const lastRunDate = response[0].payload.metadata_value;
253
- logger.info(`Retrieved last run date for ${repo}: ${lastRunDate}`);
254
- return lastRunDate;
255
- }
256
- }
257
- catch (error) {
258
- logger.warn(`Failed to retrieve metadata for ${repo}:`, error);
259
- }
260
- }
261
- }
262
- catch (error) {
263
- logger.warn(`Error retrieving last run date:`, error);
264
- }
265
- logger.info(`No saved run date found for ${repo}, using default: ${defaultDate}`);
266
- return defaultDate;
267
- }
268
- async updateLastRunDate(dbConnection, repo) {
269
- const logger = this.logger.child('metadata');
270
- const now = new Date().toISOString();
271
- try {
272
- if (dbConnection.type === 'sqlite') {
273
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
274
- const stmt = dbConnection.db.prepare(`
275
- INSERT INTO vec_metadata (key, value) VALUES (?, ?)
276
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
277
- `);
278
- stmt.run(metadataKey, now);
279
- logger.info(`Updated last run date for ${repo} to ${now}`);
280
- }
281
- else if (dbConnection.type === 'qdrant') {
282
- // Generate UUID for this repo's metadata
283
- const metadataUUID = this.generateMetadataUUID(repo);
284
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
285
- logger.debug(`Using UUID: ${metadataUUID} for metadata`);
286
- // Generate a dummy embedding (all zeros)
287
- const dummyEmbeddingSize = 3072; // Same size as your content embeddings
288
- const dummyEmbedding = new Array(dummyEmbeddingSize).fill(0);
289
- // Create a point with special metadata payload
290
- const metadataPoint = {
291
- id: metadataUUID,
292
- vector: dummyEmbedding,
293
- payload: {
294
- metadata_key: metadataKey,
295
- metadata_value: now,
296
- is_metadata: true, // Flag to identify metadata points
297
- content: `Metadata: Last run date for ${repo}`,
298
- product_name: 'system',
299
- version: 'metadata',
300
- url: 'metadata://' + repo
301
- }
302
- };
303
- await dbConnection.client.upsert(dbConnection.collectionName, {
304
- wait: true,
305
- points: [metadataPoint]
306
- });
307
- logger.info(`Updated last run date for ${repo} to ${now}`);
308
- }
309
- }
310
- catch (error) {
311
- logger.error(`Failed to update last run date for ${repo}:`, error);
312
- }
313
- }
314
101
  async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
315
102
  const [owner, repoName] = repo.split('/');
316
103
  const GITHUB_API_URL = `https://api.github.com/repos/${owner}/${repoName}/issues`;
317
104
  // Initialize metadata storage if needed
318
- await this.initDatabaseMetadata(dbConnection);
105
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
319
106
  // Get the last run date from the database
320
107
  const startDate = sourceConfig.start_date || '2025-01-01';
321
- const lastRunDate = await this.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`);
108
+ const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`, logger);
322
109
  const fetchWithRetry = async (url, params = {}, retries = 5, delay = 5000) => {
323
110
  for (let attempt = 0; attempt < retries; attempt++) {
324
111
  try {
@@ -405,14 +192,14 @@ class Doc2Vec {
405
192
  product_name: sourceConfig.product_name || repo,
406
193
  max_size: sourceConfig.max_size || Infinity
407
194
  };
408
- const chunks = await this.chunkMarkdown(markdown, issueConfig, url);
195
+ const chunks = await this.contentProcessor.chunkMarkdown(markdown, issueConfig, url);
409
196
  logger.info(`Issue #${issueNumber}: Created ${chunks.length} chunks`);
410
197
  // Process and store each chunk immediately
411
198
  for (const chunk of chunks) {
412
- const chunkHash = this.generateHash(chunk.content);
199
+ const chunkHash = utils_1.Utils.generateHash(chunk.content);
413
200
  const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
414
201
  if (dbConnection.type === 'sqlite') {
415
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
202
+ const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
416
203
  const existing = checkHashStmt.get(chunk.metadata.chunk_id);
417
204
  if (existing && existing.hash === chunkHash) {
418
205
  logger.info(`Skipping unchanged chunk: ${chunkId}`);
@@ -420,7 +207,7 @@ class Doc2Vec {
420
207
  }
421
208
  const embeddings = await this.createEmbeddings([chunk.content]);
422
209
  if (embeddings.length) {
423
- this.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
210
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
424
211
  logger.debug(`Stored chunk ${chunkId} in SQLite`);
425
212
  }
426
213
  else {
@@ -432,8 +219,8 @@ class Doc2Vec {
432
219
  let pointId;
433
220
  try {
434
221
  pointId = chunk.metadata.chunk_id;
435
- if (!this.isValidUuid(pointId)) {
436
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
222
+ if (!utils_1.Utils.isValidUuid(pointId)) {
223
+ pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
437
224
  }
438
225
  }
439
226
  catch (e) {
@@ -450,7 +237,7 @@ class Doc2Vec {
450
237
  }
451
238
  const embeddings = await this.createEmbeddings([chunk.content]);
452
239
  if (embeddings.length) {
453
- await this.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
240
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
454
241
  logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
455
242
  }
456
243
  else {
@@ -472,15 +259,15 @@ class Doc2Vec {
472
259
  await processIssue(issues[i]);
473
260
  }
474
261
  // Update the last run date in the database after processing all issues
475
- await this.updateLastRunDate(dbConnection, repo);
262
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger);
476
263
  logger.info(`Successfully processed ${issues.length} issues`);
477
264
  }
478
265
  async processGithubRepo(config, parentLogger) {
479
266
  const logger = parentLogger.child('process');
480
267
  logger.info(`Starting processing for GitHub repo: ${config.repo}`);
481
- const dbConnection = await this.initDatabase(config, logger);
268
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
482
269
  // Initialize metadata storage
483
- await this.initDatabaseMetadata(dbConnection);
270
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
484
271
  logger.section('GITHUB ISSUES');
485
272
  // Process GitHub issues
486
273
  await this.fetchAndProcessGitHubIssues(config.repo, config, dbConnection, logger);
@@ -489,16 +276,16 @@ class Doc2Vec {
489
276
  async processWebsite(config, parentLogger) {
490
277
  const logger = parentLogger.child('process');
491
278
  logger.info(`Starting processing for website: ${config.url}`);
492
- const dbConnection = await this.initDatabase(config, logger);
279
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
493
280
  const validChunkIds = new Set();
494
281
  const visitedUrls = new Set();
495
- const urlPrefix = this.getUrlPrefix(config.url);
282
+ const urlPrefix = utils_1.Utils.getUrlPrefix(config.url);
496
283
  logger.section('CRAWL AND EMBEDDING');
497
- await this.crawlWebsite(config.url, config, async (url, content) => {
284
+ const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
498
285
  visitedUrls.add(url);
499
286
  logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
500
287
  try {
501
- const chunks = await this.chunkMarkdown(content, config, url);
288
+ const chunks = await this.contentProcessor.chunkMarkdown(content, config, url);
502
289
  logger.info(`Created ${chunks.length} chunks`);
503
290
  if (chunks.length > 0) {
504
291
  const chunkProgress = logger.progress(`Embedding chunks for ${url}`, chunks.length);
@@ -507,9 +294,9 @@ class Doc2Vec {
507
294
  validChunkIds.add(chunk.metadata.chunk_id);
508
295
  const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
509
296
  let needsEmbedding = true;
510
- const chunkHash = this.generateHash(chunk.content);
297
+ const chunkHash = utils_1.Utils.generateHash(chunk.content);
511
298
  if (dbConnection.type === 'sqlite') {
512
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
299
+ const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
513
300
  const existing = checkHashStmt.get(chunk.metadata.chunk_id);
514
301
  if (existing && existing.hash === chunkHash) {
515
302
  needsEmbedding = false;
@@ -522,8 +309,8 @@ class Doc2Vec {
522
309
  let pointId;
523
310
  try {
524
311
  pointId = chunk.metadata.chunk_id;
525
- if (!this.isValidUuid(pointId)) {
526
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
312
+ if (!utils_1.Utils.isValidUuid(pointId)) {
313
+ pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
527
314
  }
528
315
  }
529
316
  catch (e) {
@@ -549,11 +336,11 @@ class Doc2Vec {
549
336
  if (embeddings.length > 0) {
550
337
  const embedding = embeddings[0];
551
338
  if (dbConnection.type === 'sqlite') {
552
- this.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
339
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
553
340
  chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
554
341
  }
555
342
  else if (dbConnection.type === 'qdrant') {
556
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
343
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
557
344
  chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
558
345
  }
559
346
  }
@@ -572,24 +359,29 @@ class Doc2Vec {
572
359
  }, logger, visitedUrls);
573
360
  logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
574
361
  logger.section('CLEANUP');
575
- if (dbConnection.type === 'sqlite') {
576
- logger.info(`Running SQLite cleanup for ${urlPrefix}`);
577
- this.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
362
+ if (crawlResult.hasNetworkErrors) {
363
+ logger.warn('Skipping cleanup due to network errors encountered during crawling. This prevents removal of valid chunks when the site is temporarily unreachable.');
578
364
  }
579
- else if (dbConnection.type === 'qdrant') {
580
- logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
581
- await this.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
365
+ else {
366
+ if (dbConnection.type === 'sqlite') {
367
+ logger.info(`Running SQLite cleanup for ${urlPrefix}`);
368
+ database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
369
+ }
370
+ else if (dbConnection.type === 'qdrant') {
371
+ logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
372
+ await database_1.DatabaseManager.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
373
+ }
582
374
  }
583
375
  logger.info(`Finished processing website: ${config.url}`);
584
376
  }
585
377
  async processLocalDirectory(config, parentLogger) {
586
378
  const logger = parentLogger.child('process');
587
379
  logger.info(`Starting processing for local directory: ${config.path}`);
588
- const dbConnection = await this.initDatabase(config, logger);
380
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
589
381
  const validChunkIds = new Set();
590
382
  const processedFiles = new Set();
591
383
  logger.section('FILE SCANNING AND EMBEDDING');
592
- await this.processDirectory(config.path, config, async (filePath, content) => {
384
+ await this.contentProcessor.processDirectory(config.path, config, async (filePath, content) => {
593
385
  processedFiles.add(filePath);
594
386
  logger.info(`Processing content from ${filePath} (${content.length} chars)`);
595
387
  try {
@@ -618,7 +410,7 @@ class Doc2Vec {
618
410
  // Use default file:// URL
619
411
  fileUrl = `file://${filePath}`;
620
412
  }
621
- const chunks = await this.chunkMarkdown(content, config, fileUrl);
413
+ const chunks = await this.contentProcessor.chunkMarkdown(content, config, fileUrl);
622
414
  logger.info(`Created ${chunks.length} chunks`);
623
415
  if (chunks.length > 0) {
624
416
  const chunkProgress = logger.progress(`Embedding chunks for ${filePath}`, chunks.length);
@@ -627,9 +419,9 @@ class Doc2Vec {
627
419
  validChunkIds.add(chunk.metadata.chunk_id);
628
420
  const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
629
421
  let needsEmbedding = true;
630
- const chunkHash = this.generateHash(chunk.content);
422
+ const chunkHash = utils_1.Utils.generateHash(chunk.content);
631
423
  if (dbConnection.type === 'sqlite') {
632
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
424
+ const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
633
425
  const existing = checkHashStmt.get(chunk.metadata.chunk_id);
634
426
  if (existing && existing.hash === chunkHash) {
635
427
  needsEmbedding = false;
@@ -642,8 +434,8 @@ class Doc2Vec {
642
434
  let pointId;
643
435
  try {
644
436
  pointId = chunk.metadata.chunk_id;
645
- if (!this.isValidUuid(pointId)) {
646
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
437
+ if (!utils_1.Utils.isValidUuid(pointId)) {
438
+ pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
647
439
  }
648
440
  }
649
441
  catch (e) {
@@ -669,11 +461,11 @@ class Doc2Vec {
669
461
  if (embeddings.length > 0) {
670
462
  const embedding = embeddings[0];
671
463
  if (dbConnection.type === 'sqlite') {
672
- this.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
464
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
673
465
  chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
674
466
  }
675
467
  else if (dbConnection.type === 'qdrant') {
676
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
468
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
677
469
  chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
678
470
  }
679
471
  }
@@ -693,719 +485,14 @@ class Doc2Vec {
693
485
  logger.section('CLEANUP');
694
486
  if (dbConnection.type === 'sqlite') {
695
487
  logger.info(`Running SQLite cleanup for local directory ${config.path}`);
696
- this.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
488
+ database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
697
489
  }
698
490
  else if (dbConnection.type === 'qdrant') {
699
491
  logger.info(`Running Qdrant cleanup for local directory ${config.path} in collection ${dbConnection.collectionName}`);
700
- await this.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
492
+ await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
701
493
  }
702
494
  logger.info(`Finished processing local directory: ${config.path}`);
703
495
  }
704
- async processDirectory(dirPath, config, processFileContent, parentLogger, visitedPaths = new Set()) {
705
- const logger = parentLogger.child('directory-processor');
706
- logger.info(`Processing directory: ${dirPath}`);
707
- const recursive = config.recursive !== undefined ? config.recursive : true;
708
- const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm'];
709
- const excludeExtensions = config.exclude_extensions || [];
710
- const encoding = config.encoding || 'utf8';
711
- try {
712
- const files = fs.readdirSync(dirPath);
713
- let processedFiles = 0;
714
- let skippedFiles = 0;
715
- for (const file of files) {
716
- const filePath = path.join(dirPath, file);
717
- const stat = fs.statSync(filePath);
718
- // Skip already visited paths
719
- if (visitedPaths.has(filePath)) {
720
- logger.debug(`Skipping already visited path: ${filePath}`);
721
- continue;
722
- }
723
- visitedPaths.add(filePath);
724
- if (stat.isDirectory()) {
725
- if (recursive) {
726
- await this.processDirectory(filePath, config, processFileContent, logger, visitedPaths);
727
- }
728
- else {
729
- logger.debug(`Skipping directory ${filePath} (recursive=false)`);
730
- }
731
- }
732
- else if (stat.isFile()) {
733
- const extension = path.extname(file).toLowerCase();
734
- // Apply extension filters
735
- if (excludeExtensions.includes(extension)) {
736
- logger.debug(`Skipping file with excluded extension: ${filePath}`);
737
- skippedFiles++;
738
- continue;
739
- }
740
- if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
741
- logger.debug(`Skipping file with non-included extension: ${filePath}`);
742
- skippedFiles++;
743
- continue;
744
- }
745
- try {
746
- logger.info(`Reading file: ${filePath}`);
747
- const content = fs.readFileSync(filePath, { encoding: encoding });
748
- if (content.length > config.max_size) {
749
- logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
750
- skippedFiles++;
751
- continue;
752
- }
753
- // Convert HTML to Markdown if needed
754
- let processedContent;
755
- if (extension === '.html' || extension === '.htm') {
756
- logger.debug(`Converting HTML to Markdown for ${filePath}`);
757
- const cleanHtml = (0, sanitize_html_1.default)(content, {
758
- allowedTags: [
759
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
760
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
761
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
762
- ],
763
- allowedAttributes: {
764
- 'a': ['href'],
765
- 'pre': ['class', 'data-language'],
766
- 'code': ['class', 'data-language'],
767
- 'div': ['class'],
768
- 'span': ['class']
769
- }
770
- });
771
- processedContent = this.turndownService.turndown(cleanHtml);
772
- }
773
- else {
774
- processedContent = content;
775
- }
776
- await processFileContent(filePath, processedContent);
777
- processedFiles++;
778
- }
779
- catch (error) {
780
- logger.error(`Error processing file ${filePath}:`, error);
781
- }
782
- }
783
- }
784
- logger.info(`Directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
785
- }
786
- catch (error) {
787
- logger.error(`Error reading directory ${dirPath}:`, error);
788
- }
789
- }
790
- removeObsoleteFilesSQLite(db, processedFiles, pathConfig, logger) {
791
- const getChunksForPathStmt = db.prepare(`
792
- SELECT chunk_id, url FROM vec_items
793
- WHERE url LIKE ? || '%'
794
- `);
795
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
796
- // Determine if we're using URL rewriting or direct file paths
797
- const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
798
- // Set up the URL prefix for searching
799
- let urlPrefix;
800
- if (isRewriteMode) {
801
- // Handle URL rewriting case
802
- urlPrefix = pathConfig.url_rewrite_prefix || '';
803
- urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
804
- }
805
- else {
806
- // Handle direct file path case
807
- const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
808
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
809
- urlPrefix = `file://${cleanedDirPrefix}`;
810
- }
811
- logger.debug(`Searching for chunks with URL prefix: ${urlPrefix}`);
812
- const existingChunks = getChunksForPathStmt.all(urlPrefix);
813
- let deletedCount = 0;
814
- const transaction = db.transaction(() => {
815
- for (const { chunk_id, url } of existingChunks) {
816
- // Skip if it's not from our URL prefix (safety check)
817
- if (!url.startsWith(urlPrefix))
818
- continue;
819
- let filePath;
820
- let shouldDelete = false;
821
- if (isRewriteMode) {
822
- // URL rewrite mode: extract relative path and construct full file path
823
- const config = pathConfig;
824
- const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
825
- filePath = path.join(config.path, relativePath);
826
- shouldDelete = !processedFiles.has(filePath);
827
- }
828
- else {
829
- // Direct file path mode: remove file:// prefix to match with processedFiles
830
- filePath = url.substring(7); // Remove 'file://' prefix
831
- shouldDelete = !processedFiles.has(filePath);
832
- }
833
- if (shouldDelete) {
834
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (File not processed: ${filePath})`);
835
- deleteChunkStmt.run(chunk_id);
836
- deletedCount++;
837
- }
838
- }
839
- });
840
- transaction();
841
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL prefix ${urlPrefix}`);
842
- }
843
- async removeObsoleteFilesQdrant(db, processedFiles, pathConfig, logger) {
844
- const { client, collectionName } = db;
845
- try {
846
- // Determine if we're using URL rewriting or direct file paths
847
- const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
848
- // Set up the URL prefix for searching
849
- let urlPrefix;
850
- if (isRewriteMode) {
851
- // Handle URL rewriting case
852
- urlPrefix = pathConfig.url_rewrite_prefix || '';
853
- urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
854
- }
855
- else {
856
- // Handle direct file path case
857
- const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
858
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
859
- urlPrefix = `file://${cleanedDirPrefix}`;
860
- }
861
- logger.debug(`Checking for obsolete chunks with URL prefix: ${urlPrefix}`);
862
- const response = await client.scroll(collectionName, {
863
- limit: 10000,
864
- with_payload: true,
865
- with_vector: false,
866
- filter: {
867
- must: [
868
- {
869
- key: "url",
870
- match: {
871
- text: urlPrefix
872
- }
873
- }
874
- ],
875
- must_not: [
876
- {
877
- key: "is_metadata",
878
- match: {
879
- value: true
880
- }
881
- }
882
- ]
883
- }
884
- });
885
- const obsoletePointIds = response.points
886
- .filter((point) => {
887
- const url = point.payload?.url;
888
- // Double check it's not a metadata record
889
- if (point.payload?.is_metadata === true) {
890
- return false;
891
- }
892
- if (!url || !url.startsWith(urlPrefix)) {
893
- return false;
894
- }
895
- let filePath;
896
- if (isRewriteMode) {
897
- // URL rewrite mode: extract relative path and construct full file path
898
- const config = pathConfig;
899
- const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
900
- filePath = path.join(config.path, relativePath);
901
- }
902
- else {
903
- // Direct file path mode: remove file:// prefix to match with processedFiles
904
- filePath = url.startsWith('file://') ? url.substring(7) : '';
905
- }
906
- return filePath && !processedFiles.has(filePath);
907
- })
908
- .map((point) => point.id);
909
- if (obsoletePointIds.length > 0) {
910
- await client.delete(collectionName, {
911
- points: obsoletePointIds,
912
- });
913
- logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL prefix ${urlPrefix}`);
914
- }
915
- else {
916
- logger.info(`No obsolete chunks to delete from Qdrant for URL prefix ${urlPrefix}`);
917
- }
918
- }
919
- catch (error) {
920
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
921
- }
922
- }
923
- async initDatabase(config, parentLogger) {
924
- const logger = parentLogger.child('database');
925
- const dbConfig = config.database_config;
926
- if (dbConfig.type === 'sqlite') {
927
- const params = dbConfig.params;
928
- const dbPath = params.db_path || path.join(process.cwd(), `${config.product_name.replace(/\s+/g, '_')}-${config.version}.db`);
929
- logger.info(`Opening SQLite database at ${dbPath}`);
930
- const db = new better_sqlite3_1.default(dbPath, { allowExtension: true });
931
- sqliteVec.load(db);
932
- logger.debug(`Creating vec_items table if it doesn't exist`);
933
- db.exec(`
934
- CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0(
935
- embedding FLOAT[3072],
936
- product_name TEXT,
937
- version TEXT,
938
- heading_hierarchy TEXT,
939
- section TEXT,
940
- chunk_id TEXT UNIQUE,
941
- content TEXT,
942
- url TEXT,
943
- hash TEXT
944
- );
945
- `);
946
- logger.info(`SQLite database initialized successfully`);
947
- return { db, type: 'sqlite' };
948
- }
949
- else if (dbConfig.type === 'qdrant') {
950
- const params = dbConfig.params;
951
- const qdrantUrl = params.qdrant_url || 'http://localhost:6333';
952
- const qdrantPort = params.qdrant_port || 443;
953
- const collectionName = params.collection_name || `${config.product_name.toLowerCase().replace(/\s+/g, '_')}_${config.version}`;
954
- logger.info(`Connecting to Qdrant at ${qdrantUrl}:${qdrantPort}, collection: ${collectionName}`);
955
- const qdrantClient = new js_client_rest_1.QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
956
- await this.createCollectionQdrant(qdrantClient, collectionName, logger);
957
- logger.info(`Qdrant connection established successfully`);
958
- return { client: qdrantClient, collectionName, type: 'qdrant' };
959
- }
960
- else {
961
- const errMsg = `Unsupported database type: ${dbConfig.type}`;
962
- logger.error(errMsg);
963
- throw new Error(errMsg);
964
- }
965
- }
966
- async createCollectionQdrant(qdrantClient, collectionName, logger) {
967
- try {
968
- logger.debug(`Checking if collection ${collectionName} exists`);
969
- const collections = await qdrantClient.getCollections();
970
- const collectionExists = collections.collections.some((collection) => collection.name === collectionName);
971
- if (collectionExists) {
972
- logger.info(`Collection ${collectionName} already exists`);
973
- return;
974
- }
975
- logger.info(`Creating new collection ${collectionName}`);
976
- await qdrantClient.createCollection(collectionName, {
977
- vectors: {
978
- size: 3072,
979
- distance: "Cosine",
980
- },
981
- });
982
- logger.info(`Collection ${collectionName} created successfully`);
983
- }
984
- catch (error) {
985
- if (error instanceof Error) {
986
- const errorMsg = error.message.toLowerCase();
987
- const errorString = JSON.stringify(error).toLowerCase();
988
- if (errorMsg.includes("already exists") ||
989
- errorString.includes("already exists") ||
990
- error?.status === 409 ||
991
- errorString.includes("conflict")) {
992
- logger.info(`Collection ${collectionName} already exists (from error response)`);
993
- return;
994
- }
995
- }
996
- logger.error(`Error creating Qdrant collection:`, error);
997
- logger.warn(`Continuing with existing collection...`);
998
- }
999
- }
1000
- prepareSQLiteStatements(db) {
1001
- return {
1002
- insertStmt: db.prepare(`
1003
- INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash)
1004
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1005
- `),
1006
- checkHashStmt: db.prepare(`SELECT hash FROM vec_items WHERE chunk_id = ?`),
1007
- updateStmt: db.prepare(`
1008
- UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?
1009
- WHERE chunk_id = ?
1010
- `),
1011
- getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
1012
- deleteChunkStmt: db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`)
1013
- };
1014
- }
1015
- insertVectorsSQLite(db, chunk, embedding, logger, chunkHash) {
1016
- const { insertStmt, updateStmt } = this.prepareSQLiteStatements(db);
1017
- const hash = chunkHash || this.generateHash(chunk.content);
1018
- const transaction = db.transaction(() => {
1019
- const params = [
1020
- new Float32Array(embedding),
1021
- chunk.metadata.product_name,
1022
- chunk.metadata.version,
1023
- JSON.stringify(chunk.metadata.heading_hierarchy),
1024
- chunk.metadata.section,
1025
- chunk.metadata.chunk_id,
1026
- chunk.content,
1027
- chunk.metadata.url,
1028
- hash
1029
- ];
1030
- try {
1031
- insertStmt.run(params);
1032
- }
1033
- catch (error) {
1034
- updateStmt.run([...params.slice(0, 8), chunk.metadata.chunk_id]);
1035
- }
1036
- });
1037
- transaction();
1038
- }
1039
- async storeChunkInQdrant(db, chunk, embedding, chunkHash) {
1040
- const { client, collectionName } = db;
1041
- try {
1042
- let pointId;
1043
- try {
1044
- pointId = chunk.metadata.chunk_id;
1045
- if (!this.isValidUuid(pointId)) {
1046
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
1047
- }
1048
- }
1049
- catch (e) {
1050
- pointId = crypto_1.default.randomUUID();
1051
- }
1052
- const hash = chunkHash || this.generateHash(chunk.content);
1053
- const pointItem = {
1054
- id: pointId,
1055
- vector: embedding,
1056
- payload: {
1057
- content: chunk.content,
1058
- product_name: chunk.metadata.product_name,
1059
- version: chunk.metadata.version,
1060
- heading_hierarchy: chunk.metadata.heading_hierarchy,
1061
- section: chunk.metadata.section,
1062
- url: chunk.metadata.url,
1063
- hash: hash,
1064
- original_chunk_id: chunk.metadata.chunk_id,
1065
- },
1066
- };
1067
- await client.upsert(collectionName, {
1068
- wait: true,
1069
- points: [pointItem],
1070
- });
1071
- }
1072
- catch (error) {
1073
- this.logger.error("Error storing chunk in Qdrant:", error);
1074
- }
1075
- }
1076
- removeObsoleteChunksSQLite(db, visitedUrls, urlPrefix, logger) {
1077
- const getChunksForUrlStmt = db.prepare(`
1078
- SELECT chunk_id, url FROM vec_items
1079
- WHERE url LIKE ? || '%'
1080
- `);
1081
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
1082
- const existingChunks = getChunksForUrlStmt.all(urlPrefix);
1083
- let deletedCount = 0;
1084
- const transaction = db.transaction(() => {
1085
- for (const { chunk_id, url } of existingChunks) {
1086
- if (!visitedUrls.has(url)) {
1087
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (URL not visited)`);
1088
- deleteChunkStmt.run(chunk_id);
1089
- deletedCount++;
1090
- }
1091
- }
1092
- });
1093
- transaction();
1094
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL ${urlPrefix}`);
1095
- }
1096
- isValidUuid(str) {
1097
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1098
- return uuidRegex.test(str);
1099
- }
1100
- hashToUuid(hash) {
1101
- const truncatedHash = hash.substring(0, 32);
1102
- return [
1103
- truncatedHash.substring(0, 8),
1104
- truncatedHash.substring(8, 12),
1105
- '5' + truncatedHash.substring(13, 16),
1106
- '8' + truncatedHash.substring(17, 20),
1107
- truncatedHash.substring(20, 32)
1108
- ].join('-');
1109
- }
1110
- async removeObsoleteChunksQdrant(db, visitedUrls, urlPrefix, logger) {
1111
- const { client, collectionName } = db;
1112
- try {
1113
- // Get all points that match the URL prefix but are not metadata points
1114
- const response = await client.scroll(collectionName, {
1115
- limit: 10000,
1116
- with_payload: true,
1117
- with_vector: false,
1118
- filter: {
1119
- must: [
1120
- {
1121
- key: "url",
1122
- match: {
1123
- text: urlPrefix
1124
- }
1125
- }
1126
- ],
1127
- must_not: [
1128
- {
1129
- key: "is_metadata",
1130
- match: {
1131
- value: true
1132
- }
1133
- }
1134
- ]
1135
- }
1136
- });
1137
- const obsoletePointIds = response.points
1138
- .filter((point) => {
1139
- const url = point.payload?.url;
1140
- // Double check it's not a metadata record
1141
- if (point.payload?.is_metadata === true) {
1142
- return false;
1143
- }
1144
- return url && !visitedUrls.has(url);
1145
- })
1146
- .map((point) => point.id);
1147
- if (obsoletePointIds.length > 0) {
1148
- await client.delete(collectionName, {
1149
- points: obsoletePointIds,
1150
- });
1151
- logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL ${urlPrefix}`);
1152
- }
1153
- else {
1154
- logger.info(`No obsolete chunks to delete from Qdrant for URL ${urlPrefix}`);
1155
- }
1156
- }
1157
- catch (error) {
1158
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
1159
- }
1160
- }
1161
- async crawlWebsite(baseUrl, sourceConfig, processPageContent, parentLogger, visitedUrls) {
1162
- const logger = parentLogger.child('crawler');
1163
- const queue = [baseUrl];
1164
- logger.info(`Starting crawl from ${baseUrl}`);
1165
- let processedCount = 0;
1166
- let skippedCount = 0;
1167
- let skippedSizeCount = 0;
1168
- let errorCount = 0;
1169
- while (queue.length > 0) {
1170
- const url = queue.shift();
1171
- if (!url)
1172
- continue;
1173
- const normalizedUrl = this.normalizeUrl(url);
1174
- if (visitedUrls.has(normalizedUrl))
1175
- continue;
1176
- visitedUrls.add(normalizedUrl);
1177
- if (!this.shouldProcessUrl(url)) {
1178
- logger.debug(`Skipping URL with unsupported extension: ${url}`);
1179
- skippedCount++;
1180
- continue;
1181
- }
1182
- try {
1183
- logger.info(`Crawling: ${url}`);
1184
- const content = await this.processPage(url, sourceConfig);
1185
- if (content !== null) {
1186
- await processPageContent(url, content);
1187
- processedCount++;
1188
- }
1189
- else {
1190
- skippedSizeCount++;
1191
- }
1192
- const response = await axios_1.default.get(url);
1193
- const $ = (0, cheerio_1.load)(response.data);
1194
- logger.debug(`Finding links on page ${url}`);
1195
- let newLinksFound = 0;
1196
- $('a[href]').each((_, element) => {
1197
- const href = $(element).attr('href');
1198
- if (!href || href.startsWith('#') || href.startsWith('mailto:'))
1199
- return;
1200
- const fullUrl = this.buildUrl(href, url);
1201
- if (fullUrl.startsWith(sourceConfig.url) && !visitedUrls.has(this.normalizeUrl(fullUrl))) {
1202
- if (!queue.includes(fullUrl)) {
1203
- queue.push(fullUrl);
1204
- newLinksFound++;
1205
- }
1206
- }
1207
- });
1208
- logger.debug(`Found ${newLinksFound} new links on ${url}`);
1209
- }
1210
- catch (error) {
1211
- logger.error(`Failed during link discovery or initial fetch for ${url}:`, error);
1212
- errorCount++;
1213
- }
1214
- }
1215
- logger.info(`Crawl completed. Processed: ${processedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
1216
- }
1217
- shouldProcessUrl(url) {
1218
- const parsedUrl = new URL(url);
1219
- const pathname = parsedUrl.pathname;
1220
- const ext = path.extname(pathname);
1221
- if (!ext)
1222
- return true;
1223
- return ['.html', '.htm'].includes(ext.toLowerCase());
1224
- }
1225
- normalizeUrl(url) {
1226
- try {
1227
- const urlObj = new URL(url);
1228
- urlObj.hash = '';
1229
- urlObj.search = '';
1230
- return urlObj.toString();
1231
- }
1232
- catch (error) {
1233
- return url;
1234
- }
1235
- }
1236
- buildUrl(href, currentUrl) {
1237
- try {
1238
- return new URL(href, currentUrl).toString();
1239
- }
1240
- catch (error) {
1241
- this.logger.warn(`Invalid URL found: ${href}`);
1242
- return '';
1243
- }
1244
- }
1245
- async processPage(url, sourceConfig) {
1246
- const logger = this.logger.child('page-processor');
1247
- logger.debug(`Processing page content from ${url}`);
1248
- let browser = null;
1249
- try {
1250
- browser = await puppeteer_1.default.launch({
1251
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
1252
- });
1253
- const page = await browser.newPage();
1254
- logger.debug(`Navigating to ${url}`);
1255
- await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
1256
- const htmlContent = await page.evaluate(() => {
1257
- const mainContentElement = document.querySelector('div[role="main"].document') || document.querySelector('main') || document.body;
1258
- return mainContentElement.innerHTML;
1259
- });
1260
- if (htmlContent.length > sourceConfig.max_size) {
1261
- logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
1262
- await browser.close();
1263
- return null;
1264
- }
1265
- logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
1266
- const dom = new jsdom_1.JSDOM(htmlContent);
1267
- const document = dom.window.document;
1268
- document.querySelectorAll('pre').forEach((pre) => {
1269
- pre.classList.add('article-content');
1270
- pre.setAttribute('data-readable-content-score', '100');
1271
- this.markCodeParents(pre.parentElement);
1272
- });
1273
- logger.debug(`Applying Readability to extract main content`);
1274
- const reader = new readability_1.Readability(document, {
1275
- charThreshold: 20,
1276
- classesToPreserve: ['article-content'],
1277
- });
1278
- const article = reader.parse();
1279
- if (!article) {
1280
- logger.warn(`Failed to parse article content with Readability for ${url}`);
1281
- await browser.close();
1282
- return null;
1283
- }
1284
- logger.debug(`Sanitizing HTML (${article.content.length} chars)`);
1285
- const cleanHtml = (0, sanitize_html_1.default)(article.content, {
1286
- allowedTags: [
1287
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
1288
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
1289
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
1290
- ],
1291
- allowedAttributes: {
1292
- 'a': ['href'],
1293
- 'pre': ['class', 'data-language'],
1294
- 'code': ['class', 'data-language'],
1295
- 'div': ['class'],
1296
- 'span': ['class']
1297
- }
1298
- });
1299
- logger.debug(`Converting HTML to Markdown`);
1300
- const markdown = this.turndownService.turndown(cleanHtml);
1301
- logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
1302
- return markdown;
1303
- }
1304
- catch (error) {
1305
- logger.error(`Error processing page ${url}:`, error);
1306
- return null;
1307
- }
1308
- finally {
1309
- if (browser && browser.isConnected()) {
1310
- await browser.close();
1311
- logger.debug(`Browser closed for ${url}`);
1312
- }
1313
- }
1314
- }
1315
- markCodeParents(node) {
1316
- if (!node)
1317
- return;
1318
- if (node.querySelector('pre, code')) {
1319
- node.classList.add('article-content');
1320
- node.setAttribute('data-readable-content-score', '100');
1321
- }
1322
- this.markCodeParents(node.parentElement);
1323
- }
1324
- async chunkMarkdown(markdown, sourceConfig, url) {
1325
- const logger = this.logger.child('chunker');
1326
- logger.debug(`Chunking markdown from ${url} (${markdown.length} chars)`);
1327
- const MAX_TOKENS = 1000;
1328
- const chunks = [];
1329
- const lines = markdown.split("\n");
1330
- let currentChunk = "";
1331
- let headingHierarchy = [];
1332
- const processChunk = () => {
1333
- if (currentChunk.trim()) {
1334
- const tokens = this.tokenize(currentChunk);
1335
- if (tokens.length > MAX_TOKENS) {
1336
- logger.debug(`Chunk exceeds max token count (${tokens.length}), splitting into smaller chunks`);
1337
- let subChunk = "";
1338
- let tokenCount = 0;
1339
- const overlapSize = Math.floor(MAX_TOKENS * 0.05);
1340
- let lastTokens = [];
1341
- for (const token of tokens) {
1342
- if (tokenCount + 1 > MAX_TOKENS) {
1343
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
1344
- subChunk = lastTokens.join("") + token;
1345
- tokenCount = lastTokens.length + 1;
1346
- lastTokens = [];
1347
- }
1348
- else {
1349
- subChunk += token;
1350
- tokenCount++;
1351
- lastTokens.push(token);
1352
- if (lastTokens.length > overlapSize) {
1353
- lastTokens.shift();
1354
- }
1355
- }
1356
- }
1357
- if (subChunk) {
1358
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
1359
- }
1360
- }
1361
- else {
1362
- chunks.push(createDocumentChunk(currentChunk, headingHierarchy));
1363
- }
1364
- }
1365
- currentChunk = "";
1366
- };
1367
- const createDocumentChunk = (content, hierarchy) => {
1368
- const chunkId = this.generateHash(content);
1369
- logger.debug(`Created chunk ${chunkId.substring(0, 8)}... with ${content.length} chars`);
1370
- return {
1371
- content,
1372
- metadata: {
1373
- product_name: sourceConfig.product_name,
1374
- version: sourceConfig.version,
1375
- heading_hierarchy: [...hierarchy],
1376
- section: hierarchy[hierarchy.length - 1] || "Introduction",
1377
- chunk_id: chunkId,
1378
- url: url,
1379
- hash: this.generateHash(content)
1380
- }
1381
- };
1382
- };
1383
- for (const line of lines) {
1384
- if (line.startsWith("#")) {
1385
- processChunk();
1386
- const levelMatch = line.match(/^(#+)/);
1387
- let level = levelMatch ? levelMatch[1].length : 1;
1388
- const heading = line.replace(/^#+\s*/, "").trim();
1389
- logger.debug(`Found heading (level ${level}): ${heading}`);
1390
- while (headingHierarchy.length < level - 1) {
1391
- headingHierarchy.push("");
1392
- }
1393
- if (level <= headingHierarchy.length) {
1394
- headingHierarchy = headingHierarchy.slice(0, level - 1);
1395
- }
1396
- headingHierarchy[level - 1] = heading;
1397
- }
1398
- else {
1399
- currentChunk += `${line}\n`;
1400
- }
1401
- }
1402
- processChunk();
1403
- logger.debug(`Chunking complete, created ${chunks.length} chunks`);
1404
- return chunks;
1405
- }
1406
- tokenize(text) {
1407
- return text.split(/(\s+)/).filter(token => token.length > 0);
1408
- }
1409
496
  async createEmbeddings(texts) {
1410
497
  const logger = this.logger.child('embeddings');
1411
498
  try {
@@ -1422,9 +509,6 @@ class Doc2Vec {
1422
509
  return [];
1423
510
  }
1424
511
  }
1425
- generateHash(content) {
1426
- return crypto_1.default.createHash("sha256").update(content).digest("hex");
1427
- }
1428
512
  }
1429
513
  if (require.main === module) {
1430
514
  const configPath = process.argv[2] || 'config.yaml';