Files
scrap/pricewatch/app/stores/base.py
2026-01-13 19:49:04 +01:00

157 lines
4.7 KiB
Python
Executable File

"""
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}>"