Add deduplication and normalize names/URLs

Add product name cleanup and URL normalization across backend, scrapers, and frontend; introduce product/event deduplication. Key changes:
- Clean up SVG/CSS garbage, trailing "X Reviews", mojibake and extra spaces in product names (api, scrapers, JS).
- Normalize URLs (lowercase host, strip tracking/query params, trim trailing slashes) in scrapers and Database and use normalized URL for comparisons/storage.
- Database: use normalized URLs when creating/getting products, prevent duplicate stock events within 1 hour, adjust recent events query to avoid duplicate products.
- Implement deduplication routines in Database: deduplicate_products (by product_id and normalized/clean name) and deduplicate_events (remove duplicate events within same hour).
- API: add POST endpoints /products/deduplicate and /events/deduplicate to trigger deduplication and return stats.
- Frontend: add UI buttons and handlers to call deduplication endpoints and display results.

These changes reduce duplicate product records/events caused by minor URL/name variations and stray CSS/svg artifacts.
This commit is contained in:
2026-03-28 13:31:30 -04:00
parent ad5d179ba3
commit f01bde54a5
5 changed files with 366 additions and 20 deletions
+41
View File
@@ -674,6 +674,11 @@ function cleanProductName(name) {
// Fix common UTF-8 encoding issues (mojibake)
return name
// Remove SVG/CSS class garbage (pikachu_svg__st1{fill:#7c888f}.review-full-...)
.replace(/[\w-]*_svg__[\w\s{}:#.;-]+/gi, '')
.replace(/\.review-full-[\w\s{}:#.;-]+/gi, '')
.replace(/\{[^}]*\}/g, '') // Remove any remaining {css} blocks
.replace(/\d+\s*Reviews?$/i, '') // Remove trailing "X Reviews"
// Fix Pokémon - multiple encoding patterns
.replace(/Pok[éÃ\u00c3\u00a9]+mon/gi, 'Pokémon')
.replace(/Pokémon/gi, 'Pokémon')
@@ -1278,6 +1283,8 @@ async function loadSettings() {
if (!settingsInitialized) {
document.getElementById('checkBrokenUrlsBtn')?.addEventListener('click', checkBrokenUrls);
document.getElementById('cleanupBrokenUrlsBtn')?.addEventListener('click', cleanupBrokenUrls);
document.getElementById('deduplicateBtn')?.addEventListener('click', deduplicateProducts);
document.getElementById('deduplicateEventsBtn')?.addEventListener('click', deduplicateEvents);
settingsInitialized = true;
}
}
@@ -1309,3 +1316,37 @@ async function cleanupBrokenUrls() {
resultEl.textContent = 'Failed to cleanup broken URLs';
}
}
async function deduplicateProducts() {
if (!confirm('This will remove duplicate products. Continue?')) return;
const resultEl = document.getElementById('deduplicateResult');
resultEl.textContent = 'Removing duplicates...';
const result = await api('/products/deduplicate', { method: 'POST' });
if (result && result.success) {
let message = `Found ${result.duplicates_found} duplicate groups, removed ${result.products_removed} products.`;
if (Object.keys(result.by_site).length > 0) {
message += ' By site: ' + Object.entries(result.by_site).map(([site, count]) => `${site}: ${count}`).join(', ');
}
resultEl.textContent = message;
} else {
resultEl.textContent = 'Failed to deduplicate products';
}
}
async function deduplicateEvents() {
if (!confirm('This will remove duplicate events from the activity feed. Continue?')) return;
const resultEl = document.getElementById('deduplicateEventsResult');
resultEl.textContent = 'Cleaning up events...';
const result = await api('/events/deduplicate', { method: 'POST' });
if (result && result.success) {
resultEl.textContent = `Removed ${result.events_removed} duplicate events.`;
} else {
resultEl.textContent = 'Failed to clean up events';
}
}