157 lines
4.7 KiB
Python
Executable File
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}>"
|