doc2vec 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -1
- package/dist/content-processor.js +49 -14
- package/dist/doc2vec.js +65 -1
- package/dist/markdown-store.js +105 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -36,6 +36,10 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
|
|
|
36
36
|
* **Vector Storage:** Supports storing chunks, metadata, and embeddings in:
|
|
37
37
|
* **SQLite:** Using `better-sqlite3` and the `sqlite-vec` extension for efficient vector search.
|
|
38
38
|
* **Qdrant:** A dedicated vector database, using the `@qdrant/js-client-rest`.
|
|
39
|
+
* **[Postgres Markdown Store](MARKDOWN_STORE.md):** Optionally stores the generated markdown for each crawled URL in a Postgres table. Useful for maintaining a searchable, raw-text copy of all documentation pages alongside the vector embeddings.
|
|
40
|
+
* **Automatic population:** On the first sync, all pages are force-processed (bypassing lastmod/ETag caching) to fully populate the store. Subsequent syncs only update rows when a change is detected.
|
|
41
|
+
* **404 cleanup:** Pages that return 404 are automatically removed from the store.
|
|
42
|
+
* **Shared table:** A single table (configurable name, default `markdown_pages`) is shared across all sources, with a `product_name` column to distinguish them.
|
|
39
43
|
* **Multi-Layer Change Detection:** Four layers of change detection minimize unnecessary re-processing:
|
|
40
44
|
1. **Sitemap `lastmod`:** When available, compares the sitemap's `<lastmod>` date against the stored value — skips without any HTTP request. Child URLs inherit `lastmod` from their most specific parent directory.
|
|
41
45
|
2. **ETag via HEAD request:** For URLs without `lastmod`, sends a lightweight HEAD request and compares the ETag header against the stored value. Adaptive backoff prevents rate limiting (starts at 0ms delay, increases on 429 responses, decays on success).
|
|
@@ -43,6 +47,8 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
|
|
|
43
47
|
4. **Embedding:** Only re-embeds chunks when content has actually changed.
|
|
44
48
|
|
|
45
49
|
ETag and lastmod values are only stored when chunking and embedding succeed, ensuring failed pages are retried on the next run.
|
|
50
|
+
|
|
51
|
+
A `sync_complete` metadata flag tracks whether a full sync has ever completed successfully. If a sync is interrupted (process killed), the next run force-processes all pages regardless of lastmod/ETag values, ensuring no pages are permanently skipped.
|
|
46
52
|
* **Incremental Updates:** For GitHub and Zendesk sources, tracks the last run date to only fetch new or updated issues/tickets.
|
|
47
53
|
* **Cleanup:** Removes obsolete chunks from the database corresponding to pages or files that are no longer found during processing.
|
|
48
54
|
* **Configuration:** Driven by a YAML configuration file (`config.yaml`) specifying sites, repositories, local directories, Zendesk instances, database types, metadata, and other parameters.
|
|
@@ -175,6 +181,7 @@ Configuration is managed through two files:
|
|
|
175
181
|
For websites (`type: 'website'`):
|
|
176
182
|
* `url`: The starting URL for crawling the documentation site.
|
|
177
183
|
* `sitemap_url`: (Optional) URL to the site's XML sitemap for discovering additional pages not linked in navigation.
|
|
184
|
+
* `markdown_store`: (Optional) Set to `true` to store generated markdown in the Postgres markdown store (requires top-level `markdown_store` config). Defaults to `false`.
|
|
178
185
|
|
|
179
186
|
For GitHub repositories (`type: 'github'`):
|
|
180
187
|
* `repo`: Repository name in the format `'owner/repo'` (e.g., `'istio/istio'`).
|
|
@@ -230,6 +237,17 @@ Configuration is managed through two files:
|
|
|
230
237
|
* `embedding.provider`: Provider for embeddings (`openai` or `azure`).
|
|
231
238
|
* `embedding.dimension`: Embedding vector size. Defaults to `3072` when not set.
|
|
232
239
|
|
|
240
|
+
Optional Postgres markdown store (top-level):
|
|
241
|
+
* `markdown_store.connection_string`: (Optional) Full Postgres connection string (e.g., `'postgres://user:pass@host:5432/db'`). Takes priority over individual fields.
|
|
242
|
+
* `markdown_store.host`: (Optional) Postgres host.
|
|
243
|
+
* `markdown_store.port`: (Optional) Postgres port.
|
|
244
|
+
* `markdown_store.database`: (Optional) Postgres database name.
|
|
245
|
+
* `markdown_store.user`: (Optional) Postgres user.
|
|
246
|
+
* `markdown_store.password`: (Optional) Postgres password. Supports `${PG_PASSWORD}` env var substitution.
|
|
247
|
+
* `markdown_store.table_name`: (Optional) Table name. Defaults to `'markdown_pages'`.
|
|
248
|
+
|
|
249
|
+
When configured, website sources with `markdown_store: true` will store the generated markdown for each URL in this Postgres table. On the first sync, all pages are force-processed (bypassing lastmod/ETag skip logic) to populate the table. On subsequent syncs, only pages with detected changes get their rows updated.
|
|
250
|
+
|
|
233
251
|
**Example (`config.yaml`):**
|
|
234
252
|
```yaml
|
|
235
253
|
# Optional: Configure embedding provider
|
|
@@ -248,13 +266,25 @@ Configuration is managed through two files:
|
|
|
248
266
|
# deployment_name: 'text-embedding-3-large'
|
|
249
267
|
# api_version: '2024-10-21' # Optional
|
|
250
268
|
|
|
269
|
+
# Optional: Store generated markdown in Postgres
|
|
270
|
+
# markdown_store:
|
|
271
|
+
# connection_string: 'postgres://user:pass@host:5432/db'
|
|
272
|
+
# # OR use individual fields:
|
|
273
|
+
# # host: 'localhost'
|
|
274
|
+
# # port: 5432
|
|
275
|
+
# # database: 'doc2vec'
|
|
276
|
+
# # user: 'myuser'
|
|
277
|
+
# # password: '${PG_PASSWORD}'
|
|
278
|
+
# # table_name: 'markdown_pages' # Optional, defaults to 'markdown_pages'
|
|
279
|
+
|
|
251
280
|
sources:
|
|
252
|
-
# Website source example
|
|
281
|
+
# Website source example (with markdown store enabled)
|
|
253
282
|
- type: 'website'
|
|
254
283
|
product_name: 'argo'
|
|
255
284
|
version: 'stable'
|
|
256
285
|
url: 'https://argo-cd.readthedocs.io/en/stable/'
|
|
257
286
|
sitemap_url: 'https://argo-cd.readthedocs.io/en/stable/sitemap.xml'
|
|
287
|
+
markdown_store: true # Store generated markdown in Postgres
|
|
258
288
|
max_size: 1048576
|
|
259
289
|
database_config:
|
|
260
290
|
type: 'sqlite'
|
|
@@ -548,6 +578,20 @@ If you don't specify a config path, it will look for config.yaml in the current
|
|
|
548
578
|
|
|
549
579
|
## Recent Changes
|
|
550
580
|
|
|
581
|
+
### Postgres Markdown Store
|
|
582
|
+
- **New feature:** Optionally store the generated markdown for each crawled website URL in a Postgres table (`url`, `product_name`, `markdown`, `updated_at`)
|
|
583
|
+
- **Top-level configuration:** Configure Postgres connection once at the top level via `connection_string` or individual `host`/`port`/`database`/`user`/`password` fields, with environment variable substitution support
|
|
584
|
+
- **Per-source opt-in:** Enable per website source with `markdown_store: true` (disabled by default)
|
|
585
|
+
- **First-sync force-processing:** When the markdown store is enabled, pages that aren't yet in the Postgres table bypass lastmod and ETag skip logic, ensuring all pages are stored on the first sync
|
|
586
|
+
- **Change-only updates:** On subsequent syncs, only pages with detected content changes (via lastmod/ETag) have their Postgres rows updated
|
|
587
|
+
- **404 cleanup:** Pages that return 404 during HEAD checks are automatically removed from the Postgres store
|
|
588
|
+
|
|
589
|
+
### Incomplete Sync Recovery
|
|
590
|
+
- **New feature:** Tracks whether a full sync has ever completed successfully for each website source via a `sync_complete:<url_prefix>` metadata key
|
|
591
|
+
- **Interrupted sync handling:** If a sync is killed mid-crawl (process terminated, crash, etc.), the stored ETags/lastmods from the partial run would otherwise cause remaining pages to be skipped permanently. The `sync_complete` flag prevents this — when absent, all pages are force-processed regardless of caching signals
|
|
592
|
+
- **Gated on clean completion:** The flag is only set when the crawl completes without network errors (DNS failures, connection refused, timeouts). If the site is unreachable, the next run will force a full sync again
|
|
593
|
+
- **Scoped per source:** Each website source has its own `sync_complete` key based on its URL prefix. Changing the source URL naturally triggers a new full sync
|
|
594
|
+
|
|
551
595
|
### Multi-Layer Change Detection for Websites
|
|
552
596
|
- **Sitemap `lastmod` support:** When a sitemap includes `<lastmod>` dates, pages are skipped entirely if the date hasn't changed — no HEAD request, no Puppeteer load, no chunking. One sitemap fetch replaces hundreds of individual HEAD requests.
|
|
553
597
|
- **`lastmod` inheritance:** Child URLs without their own `<lastmod>` inherit from the most specific parent directory URL in the sitemap (e.g., `/docs/2.10.x/reference/cli/` inherits from `/docs/2.10.x/`).
|
|
@@ -218,6 +218,7 @@ class ContentProcessor {
|
|
|
218
218
|
const queue = [baseUrl];
|
|
219
219
|
const brokenLinks = [];
|
|
220
220
|
const brokenLinkKeys = new Set();
|
|
221
|
+
const notFoundUrls = new Set();
|
|
221
222
|
const referrers = new Map();
|
|
222
223
|
const addReferrer = (targetUrl, sourceUrl) => {
|
|
223
224
|
const existing = referrers.get(targetUrl);
|
|
@@ -236,7 +237,7 @@ class ContentProcessor {
|
|
|
236
237
|
brokenLinks.push({ source: sourceUrl, target: targetUrl });
|
|
237
238
|
};
|
|
238
239
|
addReferrer(baseUrl, baseUrl);
|
|
239
|
-
const { knownUrls, etagStore, lastmodStore } = options ?? {};
|
|
240
|
+
const { knownUrls, etagStore, lastmodStore, markdownStoreUrls, forceFullSync } = options ?? {};
|
|
240
241
|
// Pre-seed queue with previously-known URLs so link discovery isn't lost
|
|
241
242
|
// when pages are skipped via ETag/lastmod matching on re-runs
|
|
242
243
|
if (knownUrls && knownUrls.size > 0) {
|
|
@@ -422,14 +423,26 @@ class ContentProcessor {
|
|
|
422
423
|
// this URL and it matches the stored value, skip entirely. This avoids
|
|
423
424
|
// both HEAD requests and full page loads — the sitemap is fetched once
|
|
424
425
|
// for the entire crawl.
|
|
426
|
+
// Bypass this skip when:
|
|
427
|
+
// - forceFullSync is true (previous sync never completed)
|
|
428
|
+
// - markdownStoreUrls is set and the URL is not in it (markdown store gap)
|
|
425
429
|
const urlLastmod = sitemapLastmod.get(url);
|
|
430
|
+
const urlInMarkdownStore = !markdownStoreUrls || markdownStoreUrls.has(url);
|
|
426
431
|
if (urlLastmod && lastmodStore) {
|
|
427
432
|
const storedLastmod = await lastmodStore.get(url);
|
|
428
433
|
if (storedLastmod && storedLastmod === urlLastmod) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
434
|
+
if (forceFullSync) {
|
|
435
|
+
logger.info(`Lastmod unchanged but full sync not yet completed, forcing processing: ${url}`);
|
|
436
|
+
}
|
|
437
|
+
else if (!urlInMarkdownStore) {
|
|
438
|
+
logger.info(`Lastmod unchanged but URL not in markdown store, forcing processing: ${url}`);
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
logger.info(`Lastmod unchanged (${urlLastmod}), skipping: ${url}`);
|
|
442
|
+
visitedUrls.add(url);
|
|
443
|
+
lastmodSkippedCount++;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
433
446
|
}
|
|
434
447
|
else if (storedLastmod) {
|
|
435
448
|
logger.debug(`Lastmod changed (stored: ${storedLastmod}, sitemap: ${urlLastmod}), processing: ${url}`);
|
|
@@ -494,10 +507,21 @@ class ContentProcessor {
|
|
|
494
507
|
decayBackoff();
|
|
495
508
|
const result = await checkEtag(headResponse.headers['etag']);
|
|
496
509
|
if (result === 'skip') {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
510
|
+
// Bypass ETag skip when:
|
|
511
|
+
// - forceFullSync is true (previous sync never completed)
|
|
512
|
+
// - markdownStoreUrls is set and URL is not in it
|
|
513
|
+
if (forceFullSync) {
|
|
514
|
+
logger.info(`ETag unchanged but full sync not yet completed, forcing processing: ${url}`);
|
|
515
|
+
}
|
|
516
|
+
else if (!urlInMarkdownStore) {
|
|
517
|
+
logger.info(`ETag unchanged but URL not in markdown store, forcing processing: ${url}`);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
logger.info(`ETag unchanged, skipping: ${url}`);
|
|
521
|
+
visitedUrls.add(url);
|
|
522
|
+
etagSkippedCount++;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
501
525
|
}
|
|
502
526
|
else if (result === 'no-etag') {
|
|
503
527
|
consecutiveMissingEtag++;
|
|
@@ -524,10 +548,18 @@ class ContentProcessor {
|
|
|
524
548
|
decayBackoff();
|
|
525
549
|
const result = await checkEtag(retryResponse.headers['etag']);
|
|
526
550
|
if (result === 'skip') {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
551
|
+
if (forceFullSync) {
|
|
552
|
+
logger.info(`ETag unchanged but full sync not yet completed, forcing processing: ${url}`);
|
|
553
|
+
}
|
|
554
|
+
else if (!urlInMarkdownStore) {
|
|
555
|
+
logger.info(`ETag unchanged but URL not in markdown store, forcing processing: ${url}`);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
logger.info(`ETag unchanged, skipping: ${url}`);
|
|
559
|
+
visitedUrls.add(url);
|
|
560
|
+
etagSkippedCount++;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
531
563
|
}
|
|
532
564
|
// 'process' or 'no-etag' — fall through to full processing
|
|
533
565
|
}
|
|
@@ -542,6 +574,9 @@ class ContentProcessor {
|
|
|
542
574
|
// 405 (Method Not Allowed) is excluded because the server
|
|
543
575
|
// may not support HEAD but the page may still exist via GET.
|
|
544
576
|
logger.debug(`HEAD returned ${headStatus} for ${url}, skipping`);
|
|
577
|
+
if (headStatus === 404) {
|
|
578
|
+
notFoundUrls.add(url);
|
|
579
|
+
}
|
|
545
580
|
skippedCount++;
|
|
546
581
|
continue;
|
|
547
582
|
}
|
|
@@ -707,7 +742,7 @@ class ContentProcessor {
|
|
|
707
742
|
if (hasNetworkErrors) {
|
|
708
743
|
logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
|
|
709
744
|
}
|
|
710
|
-
return { hasNetworkErrors, brokenLinks };
|
|
745
|
+
return { hasNetworkErrors, brokenLinks, notFoundUrls };
|
|
711
746
|
}
|
|
712
747
|
/**
|
|
713
748
|
* Detects Chrome DevTools Protocol-level errors that indicate the browser
|
package/dist/doc2vec.js
CHANGED
|
@@ -53,6 +53,7 @@ const logger_1 = require("./logger");
|
|
|
53
53
|
const utils_1 = require("./utils");
|
|
54
54
|
const database_1 = require("./database");
|
|
55
55
|
const content_processor_1 = require("./content-processor");
|
|
56
|
+
const markdown_store_1 = require("./markdown-store");
|
|
56
57
|
const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
57
58
|
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
58
59
|
dotenv.config();
|
|
@@ -103,6 +104,10 @@ class Doc2Vec {
|
|
|
103
104
|
this.logger.info(`Using OpenAI with model: ${openaiModel} (${this.embeddingDimension} dimensions)`);
|
|
104
105
|
}
|
|
105
106
|
this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
|
|
107
|
+
// Initialize Postgres markdown store if configured
|
|
108
|
+
if (this.config.markdown_store) {
|
|
109
|
+
this.markdownStore = new markdown_store_1.MarkdownStore(this.config.markdown_store, this.logger);
|
|
110
|
+
}
|
|
106
111
|
}
|
|
107
112
|
loadConfig(configPath) {
|
|
108
113
|
try {
|
|
@@ -161,6 +166,10 @@ class Doc2Vec {
|
|
|
161
166
|
return parsedValue;
|
|
162
167
|
}
|
|
163
168
|
async run() {
|
|
169
|
+
// Initialize Postgres markdown store table if configured
|
|
170
|
+
if (this.markdownStore) {
|
|
171
|
+
await this.markdownStore.init();
|
|
172
|
+
}
|
|
164
173
|
this.logger.section('PROCESSING SOURCES');
|
|
165
174
|
for (const sourceConfig of this.config.sources) {
|
|
166
175
|
const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
|
|
@@ -184,6 +193,10 @@ class Doc2Vec {
|
|
|
184
193
|
sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
|
|
185
194
|
}
|
|
186
195
|
}
|
|
196
|
+
// Close the Postgres markdown store connection pool
|
|
197
|
+
if (this.markdownStore) {
|
|
198
|
+
await this.markdownStore.close();
|
|
199
|
+
}
|
|
187
200
|
this.logger.section('PROCESSING COMPLETE');
|
|
188
201
|
}
|
|
189
202
|
async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
|
|
@@ -447,6 +460,28 @@ class Doc2Vec {
|
|
|
447
460
|
await database_1.DatabaseManager.setMetadataValue(dbConnection, `lastmod:${url}`, lastmod, logger, this.embeddingDimension);
|
|
448
461
|
},
|
|
449
462
|
};
|
|
463
|
+
// If Postgres markdown store is enabled for this source, load URLs that
|
|
464
|
+
// already have markdown stored. URLs NOT in this set will bypass
|
|
465
|
+
// lastmod/ETag skip logic so that the store is fully populated on the
|
|
466
|
+
// first sync.
|
|
467
|
+
const useMarkdownStore = config.markdown_store === true && this.markdownStore != null;
|
|
468
|
+
let markdownStoreUrls;
|
|
469
|
+
if (useMarkdownStore) {
|
|
470
|
+
markdownStoreUrls = await this.markdownStore.getUrlsWithMarkdown(urlPrefix);
|
|
471
|
+
logger.info(`Markdown store: ${markdownStoreUrls.size} URLs already stored for prefix ${urlPrefix}`);
|
|
472
|
+
}
|
|
473
|
+
// Check whether a full sync has ever completed successfully for this
|
|
474
|
+
// source. If not, bypass all lastmod/ETag skip logic so that every
|
|
475
|
+
// page is processed at least once. This handles the case where a
|
|
476
|
+
// previous sync was interrupted (killed mid-crawl) — the stored
|
|
477
|
+
// ETags/lastmods from the partial run would otherwise cause the
|
|
478
|
+
// remaining pages to be skipped indefinitely.
|
|
479
|
+
const syncCompleteKey = `sync_complete:${urlPrefix}`;
|
|
480
|
+
const syncCompleteValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, syncCompleteKey, undefined, logger);
|
|
481
|
+
const forceFullSync = syncCompleteValue !== 'true';
|
|
482
|
+
if (forceFullSync) {
|
|
483
|
+
logger.info('Full sync has not yet completed for this source — forcing processing of all pages (bypassing lastmod/ETag skip)');
|
|
484
|
+
}
|
|
450
485
|
const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
|
|
451
486
|
visitedUrls.add(url);
|
|
452
487
|
logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
|
|
@@ -457,21 +492,50 @@ class Doc2Vec {
|
|
|
457
492
|
validChunkIds.add(chunk.metadata.chunk_id);
|
|
458
493
|
}
|
|
459
494
|
await this.processChunksForUrl(chunks, url, dbConnection, logger);
|
|
495
|
+
// Store the generated markdown in Postgres
|
|
496
|
+
if (useMarkdownStore) {
|
|
497
|
+
try {
|
|
498
|
+
await this.markdownStore.upsertMarkdown(url, config.product_name, content);
|
|
499
|
+
}
|
|
500
|
+
catch (pgError) {
|
|
501
|
+
logger.error(`Failed to store markdown in Postgres for ${url}:`, pgError);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
460
504
|
return true;
|
|
461
505
|
}
|
|
462
506
|
catch (error) {
|
|
463
507
|
logger.error(`Error during chunking or embedding for ${url}:`, error);
|
|
464
508
|
return false;
|
|
465
509
|
}
|
|
466
|
-
}, logger, visitedUrls, { knownUrls, etagStore, lastmodStore });
|
|
510
|
+
}, logger, visitedUrls, { knownUrls, etagStore, lastmodStore, markdownStoreUrls, forceFullSync });
|
|
467
511
|
this.recordBrokenLinks(config.url, crawlResult.brokenLinks);
|
|
468
512
|
this.writeBrokenLinksReport();
|
|
469
513
|
logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
|
|
470
514
|
logger.section('CLEANUP');
|
|
515
|
+
// Remove 404 URLs from the Postgres markdown store
|
|
516
|
+
if (useMarkdownStore && crawlResult.notFoundUrls && crawlResult.notFoundUrls.size > 0) {
|
|
517
|
+
logger.info(`Removing ${crawlResult.notFoundUrls.size} not-found URLs from markdown store`);
|
|
518
|
+
for (const url of crawlResult.notFoundUrls) {
|
|
519
|
+
try {
|
|
520
|
+
await this.markdownStore.deleteMarkdown(url);
|
|
521
|
+
}
|
|
522
|
+
catch (pgError) {
|
|
523
|
+
logger.error(`Failed to delete markdown from Postgres for ${url}:`, pgError);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
471
527
|
if (crawlResult.hasNetworkErrors) {
|
|
472
528
|
logger.warn('Skipping cleanup due to network errors encountered during crawling. This prevents removal of valid chunks when the site is temporarily unreachable.');
|
|
473
529
|
}
|
|
474
530
|
else {
|
|
531
|
+
// Mark this source as having completed a full sync. On subsequent
|
|
532
|
+
// runs, lastmod/ETag skip logic will function normally. If the
|
|
533
|
+
// process is killed before reaching this point, the flag stays
|
|
534
|
+
// unset and the next run will force-process all pages again.
|
|
535
|
+
if (forceFullSync) {
|
|
536
|
+
await database_1.DatabaseManager.setMetadataValue(dbConnection, syncCompleteKey, 'true', logger, this.embeddingDimension);
|
|
537
|
+
logger.info('Full sync completed successfully — subsequent runs will use normal caching');
|
|
538
|
+
}
|
|
475
539
|
if (dbConnection.type === 'sqlite') {
|
|
476
540
|
logger.info(`Running SQLite cleanup for ${urlPrefix}`);
|
|
477
541
|
database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MarkdownStore = void 0;
|
|
4
|
+
const pg_1 = require("pg");
|
|
5
|
+
/**
|
|
6
|
+
* Stores generated markdown for website pages in a Postgres table.
|
|
7
|
+
*
|
|
8
|
+
* Table schema (single shared table, default name `markdown_pages`):
|
|
9
|
+
* url TEXT PRIMARY KEY
|
|
10
|
+
* product_name TEXT NOT NULL
|
|
11
|
+
* markdown TEXT NOT NULL
|
|
12
|
+
* updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
13
|
+
*
|
|
14
|
+
* When a website source has `markdown_store: true`, the crawl loop uses this
|
|
15
|
+
* store to decide whether to force-process a page that would otherwise be
|
|
16
|
+
* skipped by lastmod / ETag caching. If the URL is not yet in Postgres the
|
|
17
|
+
* page is processed regardless of cache signals, ensuring the table is fully
|
|
18
|
+
* populated after the first sync. On subsequent syncs only pages with
|
|
19
|
+
* detected changes are updated.
|
|
20
|
+
*/
|
|
21
|
+
class MarkdownStore {
|
|
22
|
+
constructor(config, logger) {
|
|
23
|
+
this.logger = logger.child('markdown-store');
|
|
24
|
+
this.tableName = config.table_name ?? 'markdown_pages';
|
|
25
|
+
const poolConfig = {};
|
|
26
|
+
if (config.connection_string) {
|
|
27
|
+
poolConfig.connectionString = config.connection_string;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
if (config.host)
|
|
31
|
+
poolConfig.host = config.host;
|
|
32
|
+
if (config.port)
|
|
33
|
+
poolConfig.port = config.port;
|
|
34
|
+
if (config.database)
|
|
35
|
+
poolConfig.database = config.database;
|
|
36
|
+
if (config.user)
|
|
37
|
+
poolConfig.user = config.user;
|
|
38
|
+
if (config.password)
|
|
39
|
+
poolConfig.password = config.password;
|
|
40
|
+
}
|
|
41
|
+
this.pool = new pg_1.Pool(poolConfig);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create the markdown table if it doesn't already exist.
|
|
45
|
+
*/
|
|
46
|
+
async init() {
|
|
47
|
+
const query = `
|
|
48
|
+
CREATE TABLE IF NOT EXISTS ${this.escapeIdentifier(this.tableName)} (
|
|
49
|
+
url TEXT PRIMARY KEY,
|
|
50
|
+
product_name TEXT NOT NULL,
|
|
51
|
+
markdown TEXT NOT NULL,
|
|
52
|
+
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
53
|
+
);
|
|
54
|
+
`;
|
|
55
|
+
await this.pool.query(query);
|
|
56
|
+
this.logger.info(`Initialized Postgres markdown store (table: ${this.tableName})`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Return the set of URLs that already have markdown stored for a given URL
|
|
60
|
+
* prefix (e.g., "https://istio.io/latest/docs/"). This is called once
|
|
61
|
+
* before the crawl starts so the crawler can decide which pages need
|
|
62
|
+
* force-processing.
|
|
63
|
+
*/
|
|
64
|
+
async getUrlsWithMarkdown(urlPrefix) {
|
|
65
|
+
const result = await this.pool.query(`SELECT url FROM ${this.escapeIdentifier(this.tableName)} WHERE url LIKE $1`, [urlPrefix + '%']);
|
|
66
|
+
return new Set(result.rows.map((row) => row.url));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Insert or update the markdown for a URL. Called after a page is
|
|
70
|
+
* successfully processed (fetched + converted to markdown).
|
|
71
|
+
*/
|
|
72
|
+
async upsertMarkdown(url, productName, markdown) {
|
|
73
|
+
const query = `
|
|
74
|
+
INSERT INTO ${this.escapeIdentifier(this.tableName)} (url, product_name, markdown, updated_at)
|
|
75
|
+
VALUES ($1, $2, $3, NOW())
|
|
76
|
+
ON CONFLICT (url) DO UPDATE SET
|
|
77
|
+
product_name = EXCLUDED.product_name,
|
|
78
|
+
markdown = EXCLUDED.markdown,
|
|
79
|
+
updated_at = NOW();
|
|
80
|
+
`;
|
|
81
|
+
await this.pool.query(query, [url, productName, markdown]);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Remove a URL from the store (e.g., when a HEAD request returns 404).
|
|
85
|
+
*/
|
|
86
|
+
async deleteMarkdown(url) {
|
|
87
|
+
await this.pool.query(`DELETE FROM ${this.escapeIdentifier(this.tableName)} WHERE url = $1`, [url]);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Close the connection pool. Should be called once after all sources have
|
|
91
|
+
* been processed.
|
|
92
|
+
*/
|
|
93
|
+
async close() {
|
|
94
|
+
await this.pool.end();
|
|
95
|
+
this.logger.info('Postgres markdown store connection pool closed');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Escape a SQL identifier (table name) to prevent injection.
|
|
99
|
+
* Uses double-quoting per the SQL standard.
|
|
100
|
+
*/
|
|
101
|
+
escapeIdentifier(identifier) {
|
|
102
|
+
return '"' + identifier.replace(/"/g, '""') + '"';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.MarkdownStore = MarkdownStore;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doc2vec",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"description": "",
|
|
6
6
|
"main": "dist/doc2vec.js",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"mammoth": "^1.11.0",
|
|
44
44
|
"openai": "^4.20.1",
|
|
45
45
|
"pdfjs-dist": "^5.3.31",
|
|
46
|
+
"pg": "^8.18.0",
|
|
46
47
|
"puppeteer": "^24.1.1",
|
|
47
48
|
"sanitize-html": "^2.11.0",
|
|
48
49
|
"sqlite-vec": "0.1.7-alpha.2",
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
"@types/js-yaml": "^4.0.9",
|
|
57
58
|
"@types/jsdom": "^21.1.7",
|
|
58
59
|
"@types/node": "^20.10.0",
|
|
60
|
+
"@types/pg": "^8.16.0",
|
|
59
61
|
"@types/sanitize-html": "^2.9.5",
|
|
60
62
|
"@types/turndown": "^5.0.4",
|
|
61
63
|
"@vitest/coverage-v8": "^4.0.18",
|