chore: sync project files

This commit is contained in:
Gilles Soulier
2026-01-13 19:49:04 +01:00
parent 53f8227941
commit ecda149a4b
149 changed files with 65272 additions and 1 deletions

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

234
pricewatch/app/core/io.py Executable file
View File

@@ -0,0 +1,234 @@
"""
Fonctions d'entrée/sortie pour PriceWatch.
Gère la lecture de la configuration YAML et l'écriture des résultats JSON.
"""
import json
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, Field, field_validator
from pricewatch.app.core.logging import get_logger
from pricewatch.app.core.schema import ProductSnapshot
logger = get_logger("core.io")
class ScrapingOptions(BaseModel):
"""Options de scraping depuis le fichier YAML."""
use_playwright: bool = Field(
default=True, description="Utiliser Playwright en fallback"
)
headful: bool = Field(default=False, description="Mode headful (voir le navigateur)")
save_html: bool = Field(
default=True, description="Sauvegarder HTML pour debug"
)
save_screenshot: bool = Field(
default=True, description="Sauvegarder screenshot pour debug"
)
timeout_ms: int = Field(
default=60000, description="Timeout par page en millisecondes", ge=1000
)
class ScrapingConfig(BaseModel):
"""Configuration complète du scraping depuis YAML."""
urls: list[str] = Field(description="Liste des URLs à scraper")
options: ScrapingOptions = Field(
default_factory=ScrapingOptions, description="Options de scraping"
)
@field_validator("urls")
@classmethod
def validate_urls(cls, v: list[str]) -> list[str]:
"""Valide et nettoie les URLs."""
if not v:
raise ValueError("Au moins une URL doit être fournie")
cleaned = [url.strip() for url in v if url and url.strip()]
if not cleaned:
raise ValueError("Aucune URL valide trouvée")
return cleaned
def read_yaml_config(yaml_path: str | Path) -> ScrapingConfig:
"""
Lit et valide le fichier YAML de configuration.
Args:
yaml_path: Chemin vers le fichier YAML
Returns:
Configuration validée
Raises:
FileNotFoundError: Si le fichier n'existe pas
ValueError: Si le YAML est invalide
Justification technique:
- Utilisation de Pydantic pour valider la structure YAML
- Cela évite des bugs si le fichier est mal formé
- Les erreurs sont explicites pour l'utilisateur
"""
yaml_path = Path(yaml_path)
if not yaml_path.exists():
logger.error(f"Fichier YAML introuvable: {yaml_path}")
raise FileNotFoundError(f"Fichier YAML introuvable: {yaml_path}")
logger.info(f"Lecture configuration: {yaml_path}")
try:
with open(yaml_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not data:
raise ValueError("Fichier YAML vide")
config = ScrapingConfig.model_validate(data)
logger.info(
f"Configuration chargée: {len(config.urls)} URL(s), "
f"playwright={config.options.use_playwright}"
)
return config
except yaml.YAMLError as e:
logger.error(f"Erreur parsing YAML: {e}")
raise ValueError(f"YAML invalide: {e}") from e
except Exception as e:
logger.error(f"Erreur validation config: {e}")
raise
def write_json_results(
snapshots: list[ProductSnapshot], json_path: str | Path, indent: int = 2
) -> None:
"""
Écrit les résultats du scraping dans un fichier JSON.
Args:
snapshots: Liste des ProductSnapshot à sauvegarder
json_path: Chemin du fichier JSON de sortie
indent: Indentation pour lisibilité (None = compact)
Justification technique:
- Serialization via Pydantic pour garantir la structure
- Pretty-print par défaut (indent=2) pour faciliter le debug manuel
- Création automatique des dossiers parents si nécessaire
"""
json_path = Path(json_path)
# Créer le dossier parent si nécessaire
json_path.parent.mkdir(parents=True, exist_ok=True)
logger.info(f"Écriture de {len(snapshots)} snapshot(s) dans: {json_path}")
try:
# Serialization via Pydantic
data = [snapshot.model_dump(mode="json") for snapshot in snapshots]
with open(json_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent, ensure_ascii=False)
logger.info(f"Résultats sauvegardés: {json_path} ({json_path.stat().st_size} bytes)")
except Exception as e:
logger.error(f"Erreur écriture JSON: {e}")
raise
def read_json_results(json_path: str | Path) -> list[ProductSnapshot]:
"""
Lit et valide un fichier JSON de résultats.
Args:
json_path: Chemin vers le fichier JSON
Returns:
Liste de ProductSnapshot validés
Raises:
FileNotFoundError: Si le fichier n'existe pas
ValueError: Si le JSON est invalide
"""
json_path = Path(json_path)
if not json_path.exists():
logger.error(f"Fichier JSON introuvable: {json_path}")
raise FileNotFoundError(f"Fichier JSON introuvable: {json_path}")
logger.info(f"Lecture résultats: {json_path}")
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
raise ValueError("Le JSON doit contenir une liste")
snapshots = [ProductSnapshot.model_validate(item) for item in data]
logger.info(f"{len(snapshots)} snapshot(s) chargé(s)")
return snapshots
except json.JSONDecodeError as e:
logger.error(f"Erreur parsing JSON: {e}")
raise ValueError(f"JSON invalide: {e}") from e
except Exception as e:
logger.error(f"Erreur validation snapshots: {e}")
raise
def save_debug_html(html: str, filename: str, output_dir: str | Path = "scraped") -> Path:
"""
Sauvegarde le HTML récupéré pour debug.
Args:
html: Contenu HTML
filename: Nom du fichier (sans extension)
output_dir: Dossier de sortie
Returns:
Chemin du fichier sauvegardé
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
filepath = output_dir / f"{filename}.html"
with open(filepath, "w", encoding="utf-8") as f:
f.write(html)
logger.debug(f"HTML sauvegardé: {filepath} ({len(html)} chars)")
return filepath
def save_debug_screenshot(
screenshot_bytes: bytes, filename: str, output_dir: str | Path = "scraped"
) -> Path:
"""
Sauvegarde un screenshot pour debug.
Args:
screenshot_bytes: Données binaires du screenshot
filename: Nom du fichier (sans extension)
output_dir: Dossier de sortie
Returns:
Chemin du fichier sauvegardé
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
filepath = output_dir / f"{filename}.png"
with open(filepath, "wb") as f:
f.write(screenshot_bytes)
logger.debug(f"Screenshot sauvegardé: {filepath} ({len(screenshot_bytes)} bytes)")
return filepath

112
pricewatch/app/core/logging.py Executable file
View File

@@ -0,0 +1,112 @@
"""
Configuration du système de logging pour PriceWatch.
Fournit un logger configuré avec formatage coloré et niveaux appropriés.
Les logs incluent : timestamp, niveau, module, et message.
"""
import logging
import sys
from typing import Optional
class ColoredFormatter(logging.Formatter):
"""Formatter avec couleurs pour améliorer la lisibilité en CLI."""
# Codes ANSI pour les couleurs
COLORS = {
"DEBUG": "\033[36m", # Cyan
"INFO": "\033[32m", # Vert
"WARNING": "\033[33m", # Jaune
"ERROR": "\033[31m", # Rouge
"CRITICAL": "\033[35m", # Magenta
}
RESET = "\033[0m"
BOLD = "\033[1m"
def format(self, record: logging.LogRecord) -> str:
"""Formate le log avec couleurs selon le niveau."""
# Copie pour éviter de modifier l'original
log_color = self.COLORS.get(record.levelname, self.RESET)
record.levelname = f"{log_color}{self.BOLD}{record.levelname}{self.RESET}"
# Colorer le nom du module
record.name = f"\033[90m{record.name}{self.RESET}"
return super().format(record)
def setup_logging(level: str = "INFO", enable_colors: bool = True) -> logging.Logger:
"""
Configure le logger racine de PriceWatch.
Args:
level: Niveau de log (DEBUG, INFO, WARNING, ERROR, CRITICAL)
enable_colors: Activer la colorisation (désactiver pour les logs fichier)
Returns:
Logger configuré
Justification technique:
- Handler unique sur stdout pour éviter les duplications
- Format détaillé avec timestamp ISO8601 pour faciliter le debug
- Colorisation optionnelle pour améliorer l'UX en CLI
"""
logger = logging.getLogger("pricewatch")
# Éviter d'ajouter plusieurs handlers si appelé plusieurs fois
if logger.handlers:
return logger
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
logger.propagate = False
# Handler console
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logger.level)
# Format avec timestamp ISO8601
log_format = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
date_format = "%Y-%m-%d %H:%M:%S"
if enable_colors and sys.stdout.isatty():
formatter = ColoredFormatter(log_format, datefmt=date_format)
else:
formatter = logging.Formatter(log_format, datefmt=date_format)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger
def get_logger(name: Optional[str] = None) -> logging.Logger:
"""
Retourne un logger enfant de 'pricewatch'.
Args:
name: Nom du sous-module (ex: 'scraping.http')
Returns:
Logger configuré
"""
if name:
return logging.getLogger(f"pricewatch.{name}")
return logging.getLogger("pricewatch")
def set_level(level: str) -> None:
"""
Change dynamiquement le niveau de log.
Args:
level: Nouveau niveau (DEBUG, INFO, WARNING, ERROR, CRITICAL)
"""
logger = logging.getLogger("pricewatch")
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
for handler in logger.handlers:
handler.setLevel(logger.level)
# Initialisation par défaut au premier import
_default_logger = setup_logging()

191
pricewatch/app/core/registry.py Executable file
View File

@@ -0,0 +1,191 @@
"""
Registry pour la détection automatique des stores.
Le Registry maintient une liste de tous les stores disponibles et
peut détecter automatiquement quel store correspond à une URL donnée.
"""
from typing import Optional
from pricewatch.app.core.logging import get_logger
from pricewatch.app.stores.base import BaseStore
logger = get_logger("core.registry")
class StoreRegistry:
"""
Registry central pour tous les stores.
Permet d'enregistrer des stores et de détecter automatiquement
le bon store depuis une URL via la méthode match().
Justification technique:
- Pattern Registry pour découpler la détection des stores du code métier
- Extensible: ajouter un nouveau store = juste register() un nouvel objet
- Pas de dépendances hardcodées entre modules
"""
def __init__(self):
"""Initialise un registry vide."""
self._stores: list[BaseStore] = []
logger.debug("Registry initialisé")
def register(self, store: BaseStore) -> None:
"""
Enregistre un nouveau store dans le registry.
Args:
store: Instance de BaseStore à enregistrer
"""
if not isinstance(store, BaseStore):
raise TypeError(f"Expected BaseStore, got {type(store)}")
# Éviter les doublons
if any(s.store_id == store.store_id for s in self._stores):
logger.warning(f"Store '{store.store_id}' déjà enregistré, remplacement")
self._stores = [s for s in self._stores if s.store_id != store.store_id]
self._stores.append(store)
logger.info(f"Store enregistré: {store.store_id}")
def unregister(self, store_id: str) -> bool:
"""
Retire un store du registry.
Args:
store_id: ID du store à retirer
Returns:
True si le store a été retiré, False s'il n'était pas présent
"""
initial_count = len(self._stores)
self._stores = [s for s in self._stores if s.store_id != store_id]
removed = len(self._stores) < initial_count
if removed:
logger.info(f"Store désenregistré: {store_id}")
else:
logger.warning(f"Store non trouvé pour désenregistrement: {store_id}")
return removed
def get_store(self, store_id: str) -> Optional[BaseStore]:
"""
Récupère un store par son ID.
Args:
store_id: ID du store à récupérer
Returns:
Instance du store ou None si non trouvé
"""
for store in self._stores:
if store.store_id == store_id:
return store
return None
def detect_store(self, url: str) -> Optional[BaseStore]:
"""
Détecte automatiquement le store correspondant à une URL.
Args:
url: URL à analyser
Returns:
Store avec le meilleur score, ou None si aucun match
Justification technique:
- Teste tous les stores enregistrés avec leur méthode match()
- Retourne celui avec le score le plus élevé (> 0)
- Permet de gérer les ambiguïtés (ex: sous-domaines multiples)
"""
if not url or not url.strip():
logger.warning("URL vide fournie pour détection")
return None
if not self._stores:
logger.warning("Aucun store enregistré dans le registry")
return None
best_store: Optional[BaseStore] = None
best_score = 0.0
logger.debug(f"Détection du store pour: {url}")
for store in self._stores:
try:
score = store.match(url)
logger.debug(f" {store.store_id}: score={score:.2f}")
if score > best_score:
best_score = score
best_store = store
except Exception as e:
logger.error(f"Erreur lors du match de {store.store_id}: {e}")
continue
if best_store:
logger.info(
f"Store détecté: {best_store.store_id} (score={best_score:.2f})"
)
else:
logger.warning(f"Aucun store trouvé pour: {url}")
return best_store
def list_stores(self) -> list[str]:
"""
Liste tous les stores enregistrés.
Returns:
Liste des IDs de stores
"""
return [store.store_id for store in self._stores]
def __len__(self) -> int:
"""Retourne le nombre de stores enregistrés."""
return len(self._stores)
def __repr__(self) -> str:
stores_list = ", ".join(self.list_stores())
return f"<StoreRegistry stores=[{stores_list}]>"
# Instance globale du registry
# Les stores s'y enregistreront lors de leur import
_global_registry = StoreRegistry()
def get_registry() -> StoreRegistry:
"""
Retourne l'instance globale du registry.
Returns:
Registry singleton
"""
return _global_registry
def register_store(store: BaseStore) -> None:
"""
Enregistre un store dans le registry global.
Args:
store: Instance de BaseStore
"""
_global_registry.register(store)
def detect_store(url: str) -> Optional[BaseStore]:
"""
Détecte le store depuis le registry global.
Args:
url: URL à analyser
Returns:
Store détecté ou None
"""
return _global_registry.detect_store(url)

197
pricewatch/app/core/schema.py Executable file
View File

@@ -0,0 +1,197 @@
"""
Modèles de données Pydantic pour PriceWatch.
Ce module définit ProductSnapshot, le modèle canonique représentant
toutes les informations récupérées lors du scraping d'un produit.
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, HttpUrl, field_validator
class StockStatus(str, Enum):
"""Statut de disponibilité du produit."""
IN_STOCK = "in_stock"
OUT_OF_STOCK = "out_of_stock"
UNKNOWN = "unknown"
class FetchMethod(str, Enum):
"""Méthode utilisée pour récupérer la page."""
HTTP = "http"
PLAYWRIGHT = "playwright"
class DebugStatus(str, Enum):
"""Statut technique de la récupération."""
SUCCESS = "success" # Récupération complète et parsing réussi
PARTIAL = "partial" # Récupération OK mais parsing incomplet
FAILED = "failed" # Échec complet (403, timeout, captcha, etc.)
class DebugInfo(BaseModel):
"""Informations de debug pour tracer les problèmes de scraping."""
method: FetchMethod = Field(
description="Méthode utilisée pour la récupération (http ou playwright)"
)
status: DebugStatus = Field(description="Statut de la récupération")
errors: list[str] = Field(
default_factory=list, description="Liste des erreurs rencontrées"
)
notes: list[str] = Field(
default_factory=list, description="Notes techniques sur la récupération"
)
duration_ms: Optional[int] = Field(
default=None, description="Durée de la récupération en millisecondes"
)
html_size_bytes: Optional[int] = Field(
default=None, description="Taille du HTML récupéré en octets"
)
class Config:
use_enum_values = True
class ProductSnapshot(BaseModel):
"""
Modèle canonique représentant un produit scraped à un instant T.
Ce modèle unifie les données de tous les stores (Amazon, Cdiscount, etc.)
dans une structure commune. Les champs peuvent être null si l'information
n'est pas disponible sur le site source.
"""
# Métadonnées
source: str = Field(
description="Identifiant du store source (amazon, cdiscount, unknown)"
)
url: str = Field(description="URL canonique du produit")
fetched_at: datetime = Field(
default_factory=datetime.now,
description="Date et heure de récupération (ISO 8601)",
)
# Données produit principales
title: Optional[str] = Field(default=None, description="Nom du produit")
price: Optional[float] = Field(default=None, description="Prix du produit", ge=0)
currency: str = Field(default="EUR", description="Devise (EUR, USD, etc.)")
shipping_cost: Optional[float] = Field(
default=None, description="Frais de port", ge=0
)
stock_status: StockStatus = Field(
default=StockStatus.UNKNOWN, description="Statut de disponibilité"
)
# Identifiants et catégorisation
reference: Optional[str] = Field(
default=None, description="Référence produit (ASIN, SKU, etc.)"
)
category: Optional[str] = Field(default=None, description="Catégorie du produit")
# Médias
images: list[str] = Field(
default_factory=list, description="Liste des URLs d'images du produit"
)
# Caractéristiques techniques
specs: dict[str, str] = Field(
default_factory=dict,
description="Caractéristiques techniques (clé/valeur)",
)
# Debug et traçabilité
debug: DebugInfo = Field(
description="Informations de debug pour traçabilité"
)
@field_validator("url")
@classmethod
def validate_url(cls, v: str) -> str:
"""Valide que l'URL n'est pas vide."""
if not v or not v.strip():
raise ValueError("URL cannot be empty")
return v.strip()
@field_validator("source")
@classmethod
def validate_source(cls, v: str) -> str:
"""Valide et normalise le nom du store."""
if not v or not v.strip():
raise ValueError("Source cannot be empty")
return v.strip().lower()
@field_validator("images")
@classmethod
def validate_images(cls, v: list[str]) -> list[str]:
"""Filtre les URLs d'images vides."""
return [url.strip() for url in v if url and url.strip()]
class Config:
use_enum_values = True
json_schema_extra = {
"example": {
"source": "amazon",
"url": "https://www.amazon.fr/dp/B08N5WRWNW",
"fetched_at": "2026-01-13T10:30:00Z",
"title": "Exemple de produit",
"price": 299.99,
"currency": "EUR",
"shipping_cost": 0.0,
"stock_status": "in_stock",
"reference": "B08N5WRWNW",
"category": "Electronics",
"images": [
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
],
"specs": {
"Marque": "ExampleBrand",
"Couleur": "Noir",
"Poids": "2.5 kg",
},
"debug": {
"method": "http",
"status": "success",
"errors": [],
"notes": ["Récupération réussie du premier coup"],
"duration_ms": 1250,
"html_size_bytes": 145000,
},
}
}
def to_dict(self) -> dict:
"""Serialize vers un dictionnaire Python natif."""
return self.model_dump(mode="json")
def to_json(self, **kwargs) -> str:
"""Serialize vers JSON."""
return self.model_dump_json(**kwargs)
@classmethod
def from_json(cls, json_str: str) -> "ProductSnapshot":
"""Désérialise depuis JSON."""
return cls.model_validate_json(json_str)
def is_complete(self) -> bool:
"""
Vérifie si le snapshot contient au minimum les données essentielles.
Retourne True si title ET price sont présents.
"""
return self.title is not None and self.price is not None
def add_error(self, error: str) -> None:
"""Ajoute une erreur au debug."""
self.debug.errors.append(error)
def add_note(self, note: str) -> None:
"""Ajoute une note au debug."""
self.debug.notes.append(note)