Files
scrap/pricewatch/app/core/schema.py
Gilles Soulier d0b73b9319 codex2
2026-01-14 21:54:55 +01:00

202 lines
6.6 KiB
Python
Executable File

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