|
|
@@ -1,6 +1,7 @@
|
|
|
import Database from 'better-sqlite3';
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
import { logger } from '../utils/logger';
|
|
|
+import { normalizeUrl } from '../utils/url';
|
|
|
import { ContentType } from '../types';
|
|
|
import path from 'path';
|
|
|
|
|
|
@@ -1278,6 +1279,58 @@ export class SiteDatabase {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Remove rows whose URL is a non-canonical variant (e.g. trailing slash) of
|
|
|
+ * another row with the same content_hash for the same site. Keeps the row
|
|
|
+ * whose URL already equals normalizeUrl(url); if multiple already-canonical
|
|
|
+ * rows exist, the earliest one is kept.
|
|
|
+ *
|
|
|
+ * The FK CASCADE on site_embeddings clears per-content embedding records.
|
|
|
+ * Orphan Qdrant points (their IDs hash in the URL, so they differ from the
|
|
|
+ * canonical row's points) remain until a separate compact pass — they only
|
|
|
+ * waste storage and don't surface different content.
|
|
|
+ *
|
|
|
+ * Returns the URLs that were removed so callers can log them.
|
|
|
+ */
|
|
|
+ cleanupTrailingSlashDuplicates(siteId: string): string[] {
|
|
|
+ const rows = this.db.prepare(`
|
|
|
+ SELECT id, url, content_hash, created_at
|
|
|
+ FROM site_content
|
|
|
+ WHERE site_id = ?
|
|
|
+ ORDER BY content_hash, created_at ASC
|
|
|
+ `).all(siteId) as { id: string; url: string; content_hash: string; created_at: string }[];
|
|
|
+
|
|
|
+ const byHash = new Map<string, typeof rows>();
|
|
|
+ for (const r of rows) {
|
|
|
+ const arr = byHash.get(r.content_hash) ?? [];
|
|
|
+ arr.push(r);
|
|
|
+ byHash.set(r.content_hash, arr);
|
|
|
+ }
|
|
|
+
|
|
|
+ const idsToDelete: string[] = [];
|
|
|
+ const removedUrls: string[] = [];
|
|
|
+ for (const group of byHash.values()) {
|
|
|
+ if (group.length < 2) continue;
|
|
|
+ const canonical = group.filter(r => normalizeUrl(r.url) === r.url);
|
|
|
+ const keepers = canonical.length > 0 ? canonical : group;
|
|
|
+ const keepId = keepers[0].id;
|
|
|
+ for (const r of group) {
|
|
|
+ if (r.id !== keepId) {
|
|
|
+ idsToDelete.push(r.id);
|
|
|
+ removedUrls.push(r.url);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (idsToDelete.length > 0) {
|
|
|
+ const placeholders = idsToDelete.map(() => '?').join(',');
|
|
|
+ this.db.prepare(`DELETE FROM site_content WHERE id IN (${placeholders})`).run(...idsToDelete);
|
|
|
+ const preview = removedUrls.slice(0, 5).join(', ');
|
|
|
+ logger.warn(`Removed ${idsToDelete.length} duplicate URL variants for site ${siteId}: ${preview}${removedUrls.length > 5 ? `, +${removedUrls.length - 5} more` : ''}`);
|
|
|
+ }
|
|
|
+ return removedUrls;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Clean up duplicate content entries by content_hash
|
|
|
* When the same content appears in multiple categories, keep only the first occurrence
|