Files
scrap/pricewatch/app/stores/base.py
Gilles Soulier 152c2724fc feat: improve SPA scraping and increase test coverage
- Add SPA support for Playwright with wait_for_network_idle and extra_wait_ms
- Add BaseStore.get_spa_config() and requires_playwright() methods
- Implement AliExpress SPA config with JSON price extraction patterns
- Fix Amazon price parsing to prioritize whole+fraction combination
- Fix AliExpress regex patterns (remove double backslashes)
- Add CLI tests: detect, doctor, fetch, parse, run commands
- Add API tests: auth, logs, products, scraping_logs, webhooks

Tests: 417 passed, 85% coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:46:55 +01:00

184 lines
5.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 get_spa_config(self) -> Optional[dict]:
"""
Retourne la configuration SPA pour Playwright si ce store est une SPA.
Returns:
dict avec les options Playwright ou None si pas une SPA:
- wait_for_selector: Sélecteur CSS à attendre avant scraping
- wait_for_network_idle: Attendre que le réseau soit inactif
- extra_wait_ms: Délai supplémentaire après chargement
Par défaut retourne None (pas de config SPA spécifique).
Les stores SPA doivent surcharger cette méthode.
"""
return None
def requires_playwright(self) -> bool:
"""
Indique si ce store nécessite obligatoirement Playwright.
Returns:
True si Playwright est requis, False sinon
Par défaut False. Les stores avec anti-bot agressif ou
rendu SPA obligatoire doivent surcharger cette méthode.
"""
return False
def __repr__(self) -> str:
return f"<{self.__class__.__name__} id={self.store_id}>"