chore: sync project files
This commit is contained in:
0
pricewatch/app/core/__init__.py
Executable file
0
pricewatch/app/core/__init__.py
Executable file
BIN
pricewatch/app/core/__pycache__/__init__.cpython-313.pyc
Executable file
BIN
pricewatch/app/core/__pycache__/__init__.cpython-313.pyc
Executable file
Binary file not shown.
BIN
pricewatch/app/core/__pycache__/io.cpython-313.pyc
Executable file
BIN
pricewatch/app/core/__pycache__/io.cpython-313.pyc
Executable file
Binary file not shown.
BIN
pricewatch/app/core/__pycache__/logging.cpython-313.pyc
Executable file
BIN
pricewatch/app/core/__pycache__/logging.cpython-313.pyc
Executable file
Binary file not shown.
BIN
pricewatch/app/core/__pycache__/registry.cpython-313.pyc
Executable file
BIN
pricewatch/app/core/__pycache__/registry.cpython-313.pyc
Executable file
Binary file not shown.
BIN
pricewatch/app/core/__pycache__/schema.cpython-313.pyc
Executable file
BIN
pricewatch/app/core/__pycache__/schema.cpython-313.pyc
Executable file
Binary file not shown.
234
pricewatch/app/core/io.py
Executable file
234
pricewatch/app/core/io.py
Executable 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
112
pricewatch/app/core/logging.py
Executable 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
191
pricewatch/app/core/registry.py
Executable 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
197
pricewatch/app/core/schema.py
Executable 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)
|
||||
Reference in New Issue
Block a user