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:
+44
-1
@@ -18,10 +18,16 @@ api_bp = Blueprint('api', __name__)
|
|||||||
|
|
||||||
|
|
||||||
def clean_product_name(name: str) -> str:
|
def clean_product_name(name: str) -> str:
|
||||||
"""Fix common encoding issues in product names"""
|
"""Fix common encoding issues and garbage in product names"""
|
||||||
if not name:
|
if not name:
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
# Remove SVG/CSS class garbage (pikachu_svg__st1{fill:#7c888f}.review-full-...)
|
||||||
|
name = re.sub(r'[\w-]*_svg__[\w\s{}:#.;-]+', '', name, flags=re.IGNORECASE)
|
||||||
|
name = re.sub(r'\.review-full-[\w\s{}:#.;-]+', '', name, flags=re.IGNORECASE)
|
||||||
|
name = re.sub(r'\{[^}]*\}', '', name) # Remove any remaining {css} blocks
|
||||||
|
name = re.sub(r'\d+\s*Reviews?$', '', name, flags=re.IGNORECASE) # Remove trailing "X Reviews"
|
||||||
|
|
||||||
# Fix Pokémon encoding issues
|
# Fix Pokémon encoding issues
|
||||||
name = re.sub(r'Pok[éÃ\u00c3\u00a9]+mon', 'Pokémon', name, flags=re.IGNORECASE)
|
name = re.sub(r'Pok[éÃ\u00c3\u00a9]+mon', 'Pokémon', name, flags=re.IGNORECASE)
|
||||||
name = name.replace('Pokémon', 'Pokémon')
|
name = name.replace('Pokémon', 'Pokémon')
|
||||||
@@ -48,6 +54,9 @@ def clean_product_name(name: str) -> str:
|
|||||||
name = name.replace('®', '®')
|
name = name.replace('®', '®')
|
||||||
name = name.replace('Â', '')
|
name = name.replace('Â', '')
|
||||||
|
|
||||||
|
# Clean up multiple spaces
|
||||||
|
name = re.sub(r'\s+', ' ', name)
|
||||||
|
|
||||||
return name.strip()
|
return name.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -908,6 +917,40 @@ def cleanup_broken_pokemoncenter_urls():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/products/deduplicate', methods=['POST'])
|
||||||
|
def deduplicate_products():
|
||||||
|
"""
|
||||||
|
Remove duplicate products from the database.
|
||||||
|
Duplicates are identified by:
|
||||||
|
- Same product_id and site
|
||||||
|
- Same name (case-insensitive) and site
|
||||||
|
"""
|
||||||
|
db = get_database()
|
||||||
|
stats = db.deduplicate_products()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'duplicates_found': stats['duplicates_found'],
|
||||||
|
'products_removed': stats['products_removed'],
|
||||||
|
'by_site': stats['by_site']
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/events/deduplicate', methods=['POST'])
|
||||||
|
def deduplicate_events():
|
||||||
|
"""
|
||||||
|
Remove duplicate events from the database.
|
||||||
|
Duplicates are events for the same product/type within the same hour.
|
||||||
|
"""
|
||||||
|
db = get_database()
|
||||||
|
stats = db.deduplicate_events()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'events_removed': stats['events_removed']
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/extension/clear', methods=['POST'])
|
@api_bp.route('/extension/clear', methods=['POST'])
|
||||||
def clear_extension_data():
|
def clear_extension_data():
|
||||||
"""Clear all extension data"""
|
"""Clear all extension data"""
|
||||||
|
|||||||
@@ -674,6 +674,11 @@ function cleanProductName(name) {
|
|||||||
|
|
||||||
// Fix common UTF-8 encoding issues (mojibake)
|
// Fix common UTF-8 encoding issues (mojibake)
|
||||||
return name
|
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
|
// Fix Pokémon - multiple encoding patterns
|
||||||
.replace(/Pok[éÃ\u00c3\u00a9]+mon/gi, 'Pokémon')
|
.replace(/Pok[éÃ\u00c3\u00a9]+mon/gi, 'Pokémon')
|
||||||
.replace(/Pokémon/gi, 'Pokémon')
|
.replace(/Pokémon/gi, 'Pokémon')
|
||||||
@@ -1278,6 +1283,8 @@ async function loadSettings() {
|
|||||||
if (!settingsInitialized) {
|
if (!settingsInitialized) {
|
||||||
document.getElementById('checkBrokenUrlsBtn')?.addEventListener('click', checkBrokenUrls);
|
document.getElementById('checkBrokenUrlsBtn')?.addEventListener('click', checkBrokenUrls);
|
||||||
document.getElementById('cleanupBrokenUrlsBtn')?.addEventListener('click', cleanupBrokenUrls);
|
document.getElementById('cleanupBrokenUrlsBtn')?.addEventListener('click', cleanupBrokenUrls);
|
||||||
|
document.getElementById('deduplicateBtn')?.addEventListener('click', deduplicateProducts);
|
||||||
|
document.getElementById('deduplicateEventsBtn')?.addEventListener('click', deduplicateEvents);
|
||||||
settingsInitialized = true;
|
settingsInitialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1309,3 +1316,37 @@ async function cleanupBrokenUrls() {
|
|||||||
resultEl.textContent = 'Failed to cleanup broken URLs';
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -356,6 +356,16 @@
|
|||||||
<button id="checkBrokenUrlsBtn" class="btn-secondary">Check Broken URLs</button>
|
<button id="checkBrokenUrlsBtn" class="btn-secondary">Check Broken URLs</button>
|
||||||
<button id="cleanupBrokenUrlsBtn" class="btn-danger">Cleanup Broken URLs</button>
|
<button id="cleanupBrokenUrlsBtn" class="btn-danger">Cleanup Broken URLs</button>
|
||||||
<p id="brokenUrlsResult" class="help-text"></p>
|
<p id="brokenUrlsResult" class="help-text"></p>
|
||||||
|
|
||||||
|
<h3>Duplicate Products</h3>
|
||||||
|
<p class="help-text">Remove duplicate products from Target, GameStop, and other stores</p>
|
||||||
|
<button id="deduplicateBtn" class="btn-secondary">Remove Duplicates</button>
|
||||||
|
<p id="deduplicateResult" class="help-text"></p>
|
||||||
|
|
||||||
|
<h3>Duplicate Events</h3>
|
||||||
|
<p class="help-text">Remove duplicate events from the activity feed (same product/type within 1 hour)</p>
|
||||||
|
<button id="deduplicateEventsBtn" class="btn-secondary">Clean Up Events</button>
|
||||||
|
<p id="deduplicateEventsResult" class="help-text"></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+87
-3
@@ -3,13 +3,79 @@ Base scraper class with common functionality
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_product_name(name: str) -> str:
|
||||||
|
"""Clean garbage from product names (SVG classes, CSS, etc.)"""
|
||||||
|
if not name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Remove SVG/CSS class garbage (pikachu_svg__st1{fill:#7c888f}.review-full-...)
|
||||||
|
name = re.sub(r'[\w-]*_svg__[\w\s{}:#.;-]+', '', name, flags=re.IGNORECASE)
|
||||||
|
name = re.sub(r'\.review-full-[\w\s{}:#.;-]+', '', name, flags=re.IGNORECASE)
|
||||||
|
name = re.sub(r'\{[^}]*\}', '', name) # Remove any remaining {css} blocks
|
||||||
|
name = re.sub(r'\d+\s*Reviews?$', '', name, flags=re.IGNORECASE) # Remove trailing "X Reviews"
|
||||||
|
|
||||||
|
# Clean up multiple spaces
|
||||||
|
name = re.sub(r'\s+', ' ', name)
|
||||||
|
|
||||||
|
return name.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_url(url: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize a URL to prevent duplicates from minor variations.
|
||||||
|
- Removes tracking/session query parameters
|
||||||
|
- Removes trailing slashes
|
||||||
|
- Lowercases the domain
|
||||||
|
- Keeps only essential path components
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return url
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
|
||||||
|
# Lowercase the domain
|
||||||
|
netloc = parsed.netloc.lower()
|
||||||
|
|
||||||
|
# Remove trailing slash from path
|
||||||
|
path = parsed.path.rstrip('/')
|
||||||
|
|
||||||
|
# Filter out common tracking/non-essential query params
|
||||||
|
query_params = parse_qs(parsed.query)
|
||||||
|
exclude_params = {
|
||||||
|
'ref', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content',
|
||||||
|
'utm_term', 'gclid', 'fbclid', 'msclkid', 'dclid', 'zanpid',
|
||||||
|
'src', 'srsltid', 'skuId', 'preselect', 'intl', 'lnk',
|
||||||
|
'Nao', 'start', 'page', 'sort', 'sortBy', 'facets',
|
||||||
|
}
|
||||||
|
filtered_params = {
|
||||||
|
k: v for k, v in query_params.items()
|
||||||
|
if k.lower() not in {p.lower() for p in exclude_params}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rebuild query string (sorted for consistency)
|
||||||
|
query = urlencode(sorted(filtered_params.items()), doseq=True) if filtered_params else ''
|
||||||
|
|
||||||
|
# Rebuild URL without fragment
|
||||||
|
normalized = urlunparse((parsed.scheme, netloc, path, '', query, ''))
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error normalizing URL {url}: {e}")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Product:
|
class Product:
|
||||||
"""Represents a product listing"""
|
"""Represents a product listing"""
|
||||||
@@ -20,13 +86,31 @@ class Product:
|
|||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
site: str = ""
|
site: str = ""
|
||||||
product_id: Optional[str] = None # Unique identifier for tracking
|
product_id: Optional[str] = None # Unique identifier for tracking
|
||||||
|
_normalized_url: str = field(default="", repr=False, compare=False)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self._normalized_url = normalize_url(self.url)
|
||||||
|
# Clean the name of any SVG/CSS garbage
|
||||||
|
if self.name:
|
||||||
|
self.name = clean_product_name(self.name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def normalized_url(self) -> str:
|
||||||
|
"""Get the normalized URL for deduplication"""
|
||||||
|
if not self._normalized_url:
|
||||||
|
self._normalized_url = normalize_url(self.url)
|
||||||
|
return self._normalized_url
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
return hash(self.url)
|
# Use normalized URL for hashing to prevent duplicates
|
||||||
|
return hash(self.normalized_url)
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
if isinstance(other, Product):
|
if isinstance(other, Product):
|
||||||
return self.url == other.url
|
# Compare by normalized URL OR by product_id if both have one
|
||||||
|
if self.product_id and other.product_id and self.site == other.site:
|
||||||
|
return self.product_id == other.product_id
|
||||||
|
return self.normalized_url == other.normalized_url
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+184
-16
@@ -166,17 +166,45 @@ class Database:
|
|||||||
|
|
||||||
# ==================== Product Methods ====================
|
# ==================== Product Methods ====================
|
||||||
|
|
||||||
|
def _normalize_url(self, url: str) -> str:
|
||||||
|
"""Normalize URL to prevent duplicates from minor variations."""
|
||||||
|
if not url:
|
||||||
|
return url
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
||||||
|
parsed = urlparse(url)
|
||||||
|
netloc = parsed.netloc.lower()
|
||||||
|
path = parsed.path.rstrip('/')
|
||||||
|
# Filter out tracking/pagination params
|
||||||
|
query_params = parse_qs(parsed.query)
|
||||||
|
exclude_params = {
|
||||||
|
'ref', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content',
|
||||||
|
'utm_term', 'gclid', 'fbclid', 'msclkid', 'srsltid', 'skuId',
|
||||||
|
'preselect', 'intl', 'lnk', 'nao', 'start', 'page', 'sort', 'sortby',
|
||||||
|
}
|
||||||
|
filtered = {k: v for k, v in query_params.items() if k.lower() not in exclude_params}
|
||||||
|
query = urlencode(sorted(filtered.items()), doseq=True) if filtered else ''
|
||||||
|
return urlunparse((parsed.scheme, netloc, path, '', query, ''))
|
||||||
|
except Exception:
|
||||||
|
return url
|
||||||
|
|
||||||
def get_or_create_product(self, url: str, name: str, site: str,
|
def get_or_create_product(self, url: str, name: str, site: str,
|
||||||
product_id: str = None, image_url: str = None,
|
product_id: str = None, image_url: str = None,
|
||||||
price: str = None, in_stock: bool = False) -> int:
|
price: str = None, in_stock: bool = False) -> int:
|
||||||
"""Get existing product or create new one. Returns product ID."""
|
"""Get existing product or create new one. Returns product ID."""
|
||||||
category = self._detect_category(name)
|
category = self._detect_category(name)
|
||||||
|
normalized_url = self._normalize_url(url)
|
||||||
|
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Try to get existing product
|
# Try to get existing product by URL (check both original and normalized)
|
||||||
cursor.execute("SELECT id FROM products WHERE url = ?", (url,))
|
cursor.execute("SELECT id FROM products WHERE url = ? OR url = ?", (url, normalized_url))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
# Also check by product_id if provided (more reliable for some sites)
|
||||||
|
if not row and product_id and site:
|
||||||
|
cursor.execute("SELECT id FROM products WHERE product_id = ? AND site = ?", (product_id, site))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
@@ -190,12 +218,12 @@ class Database:
|
|||||||
""", (name, price, in_stock, image_url, category, row['id']))
|
""", (name, price, in_stock, image_url, category, row['id']))
|
||||||
return row['id']
|
return row['id']
|
||||||
else:
|
else:
|
||||||
# Create new product
|
# Create new product with normalized URL
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO products (url, name, site, product_id, image_url,
|
INSERT INTO products (url, name, site, product_id, image_url,
|
||||||
current_price, in_stock, last_seen, category)
|
current_price, in_stock, last_seen, category)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
|
||||||
""", (url, name, site, product_id, image_url, price, in_stock, category))
|
""", (normalized_url, name, site, product_id, image_url, price, in_stock, category))
|
||||||
return cursor.lastrowid
|
return cursor.lastrowid
|
||||||
|
|
||||||
def get_product(self, product_id: int) -> Optional[Dict]:
|
def get_product(self, product_id: int) -> Optional[Dict]:
|
||||||
@@ -207,10 +235,11 @@ class Database:
|
|||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def get_product_by_url(self, url: str) -> Optional[Dict]:
|
def get_product_by_url(self, url: str) -> Optional[Dict]:
|
||||||
"""Get product by URL"""
|
"""Get product by URL (checks both original and normalized)"""
|
||||||
|
normalized_url = self._normalize_url(url)
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("SELECT * FROM products WHERE url = ?", (url,))
|
cursor.execute("SELECT * FROM products WHERE url = ? OR url = ?", (url, normalized_url))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
@@ -369,9 +398,23 @@ class Database:
|
|||||||
# ==================== Stock Event Methods ====================
|
# ==================== Stock Event Methods ====================
|
||||||
|
|
||||||
def record_stock_event(self, product_id: int, event_type: str):
|
def record_stock_event(self, product_id: int, event_type: str):
|
||||||
"""Record a stock event (new_drop, restock, in_stock, out_of_stock)"""
|
"""Record a stock event (new_drop, restock, in_stock, out_of_stock).
|
||||||
|
Prevents duplicate events within 1 hour for the same product/event_type."""
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if same event was recorded recently (within 1 hour)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM stock_events
|
||||||
|
WHERE product_id = ? AND event_type = ?
|
||||||
|
AND recorded_at > datetime('now', '-1 hour')
|
||||||
|
LIMIT 1
|
||||||
|
""", (product_id, event_type))
|
||||||
|
|
||||||
|
if cursor.fetchone():
|
||||||
|
logger.debug(f"Skipping duplicate {event_type} event for product {product_id}")
|
||||||
|
return
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO stock_events (product_id, event_type)
|
INSERT INTO stock_events (product_id, event_type)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
@@ -379,23 +422,42 @@ class Database:
|
|||||||
logger.debug(f"Recorded {event_type} event for product {product_id}")
|
logger.debug(f"Recorded {event_type} event for product {product_id}")
|
||||||
|
|
||||||
def get_recent_events(self, limit: int = 20, event_types: List[str] = None) -> List[Dict]:
|
def get_recent_events(self, limit: int = 20, event_types: List[str] = None) -> List[Dict]:
|
||||||
"""Get recent stock events with product info"""
|
"""Get recent stock events with product info.
|
||||||
|
Deduplicates by product name to avoid showing same product multiple times."""
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
query = """
|
# Use a subquery to get only the most recent event per product
|
||||||
SELECT e.*, p.name, p.url, p.site, p.image_url, p.current_price
|
# This prevents the same product from appearing multiple times
|
||||||
FROM stock_events e
|
|
||||||
JOIN products p ON e.product_id = p.id
|
|
||||||
"""
|
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if event_types:
|
if event_types:
|
||||||
placeholders = ",".join("?" * len(event_types))
|
placeholders = ",".join("?" * len(event_types))
|
||||||
query += f" WHERE e.event_type IN ({placeholders})"
|
event_filter = f"e.event_type IN ({placeholders})"
|
||||||
params.extend(event_types)
|
event_filter2 = f"e2.event_type IN ({placeholders})"
|
||||||
|
params = list(event_types)
|
||||||
|
else:
|
||||||
|
event_filter = "1=1"
|
||||||
|
event_filter2 = "1=1"
|
||||||
|
|
||||||
query += " ORDER BY e.recorded_at DESC LIMIT ?"
|
query = f"""
|
||||||
|
SELECT e.*, p.name, p.url, p.site, p.image_url, p.current_price
|
||||||
|
FROM stock_events e
|
||||||
|
JOIN products p ON e.product_id = p.id
|
||||||
|
WHERE {event_filter}
|
||||||
|
AND e.id IN (
|
||||||
|
SELECT MAX(e2.id)
|
||||||
|
FROM stock_events e2
|
||||||
|
JOIN products p2 ON e2.product_id = p2.id
|
||||||
|
WHERE {event_filter2}
|
||||||
|
GROUP BY LOWER(TRIM(p2.name))
|
||||||
|
)
|
||||||
|
ORDER BY e.recorded_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
# Add params for subquery event filter if needed
|
||||||
|
if event_types:
|
||||||
|
params.extend(event_types)
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
|
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
@@ -834,6 +896,112 @@ class Database:
|
|||||||
|
|
||||||
logger.info(f"Migrated {migrated} products from {json_path}")
|
logger.info(f"Migrated {migrated} products from {json_path}")
|
||||||
|
|
||||||
|
def deduplicate_products(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Find and remove duplicate products based on normalized URL or product_id.
|
||||||
|
Keeps the product with the most recent last_seen date.
|
||||||
|
Returns stats about what was deduplicated.
|
||||||
|
"""
|
||||||
|
stats = {'duplicates_found': 0, 'products_removed': 0, 'by_site': {}}
|
||||||
|
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Find duplicates by product_id (same site)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT site, product_id, COUNT(*) as count, GROUP_CONCAT(id) as ids
|
||||||
|
FROM products
|
||||||
|
WHERE product_id IS NOT NULL AND product_id != ''
|
||||||
|
GROUP BY site, product_id
|
||||||
|
HAVING count > 1
|
||||||
|
""")
|
||||||
|
product_id_dupes = cursor.fetchall()
|
||||||
|
|
||||||
|
for row in product_id_dupes:
|
||||||
|
site = row['site']
|
||||||
|
ids = [int(i) for i in row['ids'].split(',')]
|
||||||
|
stats['duplicates_found'] += 1
|
||||||
|
|
||||||
|
# Keep the one with the most recent last_seen
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM products
|
||||||
|
WHERE id IN ({})
|
||||||
|
ORDER BY last_seen DESC, first_seen ASC
|
||||||
|
LIMIT 1
|
||||||
|
""".format(','.join('?' * len(ids))), ids)
|
||||||
|
keep_id = cursor.fetchone()['id']
|
||||||
|
|
||||||
|
# Delete the others
|
||||||
|
delete_ids = [i for i in ids if i != keep_id]
|
||||||
|
if delete_ids:
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM products WHERE id IN ({})
|
||||||
|
""".format(','.join('?' * len(delete_ids))), delete_ids)
|
||||||
|
stats['products_removed'] += len(delete_ids)
|
||||||
|
stats['by_site'][site] = stats['by_site'].get(site, 0) + len(delete_ids)
|
||||||
|
|
||||||
|
# Find duplicates by similar name (same site, fuzzy match)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT site, LOWER(TRIM(name)) as clean_name, COUNT(*) as count, GROUP_CONCAT(id) as ids
|
||||||
|
FROM products
|
||||||
|
GROUP BY site, clean_name
|
||||||
|
HAVING count > 1
|
||||||
|
""")
|
||||||
|
name_dupes = cursor.fetchall()
|
||||||
|
|
||||||
|
for row in name_dupes:
|
||||||
|
site = row['site']
|
||||||
|
ids = [int(i) for i in row['ids'].split(',')]
|
||||||
|
stats['duplicates_found'] += 1
|
||||||
|
|
||||||
|
# Keep the one with the most recent last_seen
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM products
|
||||||
|
WHERE id IN ({})
|
||||||
|
ORDER BY last_seen DESC, first_seen ASC
|
||||||
|
LIMIT 1
|
||||||
|
""".format(','.join('?' * len(ids))), ids)
|
||||||
|
keep_id = cursor.fetchone()['id']
|
||||||
|
|
||||||
|
# Delete the others
|
||||||
|
delete_ids = [i for i in ids if i != keep_id]
|
||||||
|
if delete_ids:
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM products WHERE id IN ({})
|
||||||
|
""".format(','.join('?' * len(delete_ids))), delete_ids)
|
||||||
|
stats['products_removed'] += len(delete_ids)
|
||||||
|
stats['by_site'][site] = stats['by_site'].get(site, 0) + len(delete_ids)
|
||||||
|
|
||||||
|
logger.info(f"Deduplication complete: {stats}")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def deduplicate_events(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Remove duplicate stock events (same product, same event type within 1 hour).
|
||||||
|
Keeps only the earliest event in each duplicate group.
|
||||||
|
Returns stats about what was removed.
|
||||||
|
"""
|
||||||
|
stats = {'duplicate_groups': 0, 'events_removed': 0}
|
||||||
|
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Find events that have duplicates within 1 hour
|
||||||
|
# We'll keep the earliest one in each group
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM stock_events
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT MIN(id)
|
||||||
|
FROM stock_events
|
||||||
|
GROUP BY product_id, event_type,
|
||||||
|
strftime('%Y-%m-%d %H', recorded_at)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
stats['events_removed'] = cursor.rowcount
|
||||||
|
|
||||||
|
logger.info(f"Event deduplication complete: removed {stats['events_removed']} duplicate events")
|
||||||
|
return stats
|
||||||
|
|
||||||
# ==================== User Methods ====================
|
# ==================== User Methods ====================
|
||||||
|
|
||||||
def create_user(self, name: str, zip_code: str = None, radius_miles: int = 25,
|
def create_user(self, name: str, zip_code: str = None, radius_miles: int = 25,
|
||||||
|
|||||||
Reference in New Issue
Block a user