""" Product tracker - keeps track of known products to detect new drops and restocks """ import json import logging from pathlib import Path from typing import Dict, List, Set, Optional from dataclasses import asdict from datetime import datetime from scrapers.base import Product logger = logging.getLogger(__name__) # File to store known products PRODUCTS_FILE = Path(__file__).parent / "products.json" class ProductTracker: """Tracks known products to detect new listings and stock changes""" def __init__(self, products_file: Path = PRODUCTS_FILE): self.products_file = products_file self.products: Dict[str, dict] = {} # URL -> product data self.load() def load(self): """Load products from file""" if self.products_file.exists(): try: with open(self.products_file, "r", encoding="utf-8") as f: self.products = json.load(f) logger.info(f"Loaded {len(self.products)} tracked products") except (json.JSONDecodeError, IOError) as e: logger.error(f"Error loading products file: {e}") self.products = {} else: self.products = {} logger.info("No existing products file, starting fresh") def save(self): """Save products to file""" try: with open(self.products_file, "w", encoding="utf-8") as f: json.dump(self.products, f, indent=2, ensure_ascii=False) logger.debug(f"Saved {len(self.products)} products") except IOError as e: logger.error(f"Error saving products file: {e}") def process_products(self, products: List[Product]) -> tuple[List[Product], List[Product]]: """ Process a list of scraped products and detect changes Args: products: List of products from scraper Returns: Tuple of (new_products, restocked_products) """ new_products = [] restocked_products = [] for product in products: url = product.url if url not in self.products: # New product! new_products.append(product) self.products[url] = { "name": product.name, "url": url, "price": product.price, "in_stock": product.in_stock, "image_url": product.image_url, "site": product.site, "product_id": product.product_id, "first_seen": datetime.now().isoformat(), "last_seen": datetime.now().isoformat(), "last_in_stock": datetime.now().isoformat() if product.in_stock else None, } logger.info(f"NEW PRODUCT: {product.name}") else: # Existing product - check for restock existing = self.products[url] was_in_stock = existing.get("in_stock", False) # Update last seen existing["last_seen"] = datetime.now().isoformat() existing["price"] = product.price or existing.get("price") existing["image_url"] = product.image_url or existing.get("image_url") if product.in_stock and not was_in_stock: # RESTOCK! restocked_products.append(product) existing["last_in_stock"] = datetime.now().isoformat() logger.info(f"RESTOCK: {product.name}") existing["in_stock"] = product.in_stock self.products[url] = existing self.save() return new_products, restocked_products def get_known_urls(self) -> Set[str]: """Get all known product URLs""" return set(self.products.keys()) def get_product(self, url: str) -> Optional[dict]: """Get a specific product by URL""" return self.products.get(url) def mark_out_of_stock(self, url: str): """Mark a product as out of stock""" if url in self.products: self.products[url]["in_stock"] = False self.save() def clear(self): """Clear all tracked products""" self.products = {} self.save() logger.info("Cleared all tracked products") def get_stats(self) -> dict: """Get tracking statistics""" total = len(self.products) in_stock = sum(1 for p in self.products.values() if p.get("in_stock", False)) out_of_stock = total - in_stock return { "total_products": total, "in_stock": in_stock, "out_of_stock": out_of_stock, }