202 lines
6.6 KiB
Python
Executable File
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)
|