""" Classe abstraite BaseStore définissant l'interface des stores. Tous les stores (Amazon, Cdiscount, etc.) doivent hériter de BaseStore et implémenter ses méthodes abstraites. """ from abc import ABC, abstractmethod from pathlib import Path from typing import Optional import yaml from pricewatch.app.core.logging import get_logger from pricewatch.app.core.schema import ProductSnapshot logger = get_logger("stores.base") class BaseStore(ABC): """ Classe abstraite définissant l'interface d'un store. Chaque store (Amazon, Cdiscount, etc.) doit implémenter: - match(): Détection si une URL correspond au store - canonicalize(): Normalisation de l'URL - extract_reference(): Extraction de la référence produit - parse(): Parsing HTML vers ProductSnapshot Les sélecteurs CSS/XPath sont stockés dans selectors.yml pour faciliter la maintenance sans toucher au code Python. """ def __init__(self, store_id: str, selectors_path: Optional[Path] = None): """ Initialise le store. Args: store_id: Identifiant unique du store (ex: 'amazon', 'cdiscount') selectors_path: Chemin vers le fichier selectors.yml """ self.store_id = store_id self.selectors: dict = {} if selectors_path and selectors_path.exists(): self._load_selectors(selectors_path) def _load_selectors(self, path: Path) -> None: """ Charge les sélecteurs depuis le fichier YAML. Args: path: Chemin vers selectors.yml """ try: with open(path, "r", encoding="utf-8") as f: self.selectors = yaml.safe_load(f) or {} logger.debug(f"[{self.store_id}] Sélecteurs chargés: {len(self.selectors)} entrées") except Exception as e: logger.warning(f"[{self.store_id}] Erreur chargement sélecteurs: {e}") self.selectors = {} @abstractmethod def match(self, url: str) -> float: """ Retourne un score de correspondance entre l'URL et ce store. Args: url: URL à tester Returns: Score entre 0.0 (aucune correspondance) et 1.0 (correspondance parfaite) Exemple: - 'amazon.fr' dans l'URL → 0.9 - 'amazon.com' dans l'URL → 0.8 - Autres domaines → 0.0 Justification technique: - Score plutôt que booléen pour gérer les ambiguïtés (ex: sous-domaines) - Le Registry choisira le store avec le meilleur score """ pass @abstractmethod def canonicalize(self, url: str) -> str: """ Normalise l'URL vers sa forme canonique. Args: url: URL brute (peut contenir des query params, ref, etc.) Returns: URL canonique (ex: https://www.amazon.fr/dp/B08N5WRWNW) Justification technique: - Évite les doublons dans la base de données - Facilite le suivi d'un même produit dans le temps - Supprime les paramètres de tracking """ pass @abstractmethod def extract_reference(self, url: str) -> Optional[str]: """ Extrait la référence produit depuis l'URL. Args: url: URL du produit Returns: Référence (ASIN pour Amazon, SKU pour autres) ou None Exemple: - Amazon: https://amazon.fr/dp/B08N5WRWNW → "B08N5WRWNW" - Cdiscount: https://cdiscount.com/.../f-123-sku.html → "123-sku" """ pass @abstractmethod def parse(self, html: str, url: str) -> ProductSnapshot: """ Parse le HTML et retourne un ProductSnapshot. Args: html: Contenu HTML de la page produit url: URL canonique du produit Returns: ProductSnapshot avec toutes les données extraites Raises: Exception: Si le parsing échoue complètement Justification technique: - Utilise self.selectors (chargés depuis YAML) pour extraire les données - En cas d'échec partiel, retourne un snapshot avec debug.status=partial - En cas d'échec total, raise une exception pour fallback Playwright """ pass def get_selector(self, key: str, default: Optional[str] = None) -> Optional[str]: """ Récupère un sélecteur depuis self.selectors. Args: key: Clé du sélecteur (ex: 'title', 'price') default: Valeur par défaut si non trouvé Returns: Sélecteur CSS ou XPath, ou default """ return self.selectors.get(key, default) def __repr__(self) -> str: return f"<{self.__class__.__name__} id={self.store_id}>"