""" 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, ConfigDict, 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.""" model_config = ConfigDict(use_enum_values=True) 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 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) msrp: Optional[float] = Field(default=None, description="Prix conseille", 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") description: Optional[str] = Field(default=None, description="Description 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()] model_config = ConfigDict( 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, "msrp": 349.99, "currency": "EUR", "shipping_cost": 0.0, "stock_status": "in_stock", "reference": "B08N5WRWNW", "category": "Electronics", "description": "Chargeur USB-C multi-ports.", "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)