Files
mmcghen f01bde54a5 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.
2026-03-28 13:31:30 -04:00

204 lines
6.3 KiB
Python

"""
Base scraper class with common functionality
"""
import logging
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Optional
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
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
class Product:
"""Represents a product listing"""
name: str
url: str
price: Optional[str]
in_stock: bool
image_url: Optional[str] = None
site: str = ""
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):
# Use normalized URL for hashing to prevent duplicates
return hash(self.normalized_url)
def __eq__(self, other):
if isinstance(other, Product):
# 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
class BaseScraper(ABC):
"""Base class for site-specific scrapers"""
site_name: str = "unknown"
# Terms that indicate a Pokemon product
POKEMON_TERMS = [
"pokemon", "pokémon", "poke", "tcg",
"pikachu", "charizard", "mewtwo", "eevee", "snorlax",
"booster", "elite trainer", "etb",
"scarlet", "violet", "prismatic", "evolutions",
]
# Terms that indicate NOT a Pokemon product (false positives from search)
EXCLUDE_TERMS = [
"ice cube", "oven", "barbie", "hot wheels", "lego",
"furniture", "appliance", "kitchen", "bedding",
"glitter girls", "masters of the universe", "transformers",
"room essentials", "threshold",
]
def is_pokemon_product(self, product: Product) -> bool:
"""
Check if a product is actually a Pokemon product.
Filters out false positives from search results.
"""
name_lower = product.name.lower()
# Check for exclusion terms first
for term in self.EXCLUDE_TERMS:
if term in name_lower:
return False
# Check for Pokemon terms
for term in self.POKEMON_TERMS:
if term in name_lower:
return True
# If no Pokemon terms found, reject it
return False
def filter_pokemon_products(self, products: List[Product]) -> List[Product]:
"""Filter to only include valid Pokemon products"""
filtered = [p for p in products if self.is_pokemon_product(p)]
rejected = len(products) - len(filtered)
if rejected > 0:
logger.info(f"Filtered out {rejected} non-Pokemon products")
return filtered
@abstractmethod
def scrape_category_page(self, url: str) -> List[Product]:
"""
Scrape a category/search page and return all products found
Args:
url: URL of the category page
Returns:
List of Product objects
"""
pass
@abstractmethod
def check_product_stock(self, product_url: str) -> tuple[bool, Optional[str]]:
"""
Check if a specific product is in stock
Args:
product_url: URL of the product page
Returns:
Tuple of (is_in_stock, price)
"""
pass
def filter_by_keywords(self, products: List[Product], keywords: List[str]) -> List[Product]:
"""Filter products by keywords in name"""
if not keywords:
return products
filtered = []
for product in products:
name_lower = product.name.lower()
if any(kw.lower() in name_lower for kw in keywords):
filtered.append(product)
return filtered