# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # schema.py : modèle de données standardisé (Vehicle) + normalisation centrale # ----------------------------------------------------------------------------- """Schéma standard d'une annonce de véhicule usagé. Chaque connecteur, peu importe le site source, produit des objets `Vehicle` conformes à ce schéma. `finalize()` applique la couche de normalisation commune (autoka/normalize.py) : prix, kilométrage, année/marque/modèle déduits du titre, transmission/carburant/carrosserie canoniques, région administrative — les connecteurs restent simples et remplissent le brut. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass, field, asdict from .normalize import ( # ré-exportés pour les connecteurs infer_region, normalize_body, normalize_drivetrain, normalize_fuel, normalize_make, normalize_transmission, parse_mileage, parse_price, parse_year, price_is_from, split_title, strip_accents, ) __all__ = [ "Vehicle", "parse_price", "parse_mileage", "parse_year", "split_title", "normalize_make", "normalize_transmission", "normalize_fuel", "normalize_drivetrain", "normalize_body", "infer_region", "strip_accents", ] @dataclass class Vehicle: """Annonce de véhicule usagé standardisée Auto-Ka.""" source: str # id de la source (voir data/sources.json) external_id: str # identifiant chez la source (stock, VIN, slug) url: str # page de l'annonce chez le concessionnaire title: str = "" # ex. "Toyota RAV4 XLE 2019" make: str = "" # Toyota, Honda, ... model: str = "" # RAV4, Civic, ... trim: str = "" # XLE AWD, Sport, ... year: int | None = None price: float | None = None # $ CAD affiché price_label: str = "" # texte original (ex. "21 495 $ + tx") mileage_km: float | None = None mileage_label: str = "" # texte original (ex. "45 678 km") transmission: str = "" # Automatique | Manuelle fuel: str = "" # Essence | Diesel | Hybride | Hybride rechargeable | Électrique drivetrain: str = "" # Traction | Propulsion | Intégrale / 4x4 body_type: str = "" # VUS | Berline | Camionnette | ... exterior_color: str = "" interior_color: str = "" engine: str = "" # ex. "2.5L 4 cyl." doors: int | None = None seats: int | None = None vin: str = "" stock_number: str = "" dealer_name: str = "" # nom d'affichage du commerce city: str = "" # ville du concessionnaire region: str = "" # région administrative (déduite de city) description: str = "" features: list[str] = field(default_factory=list) # équipements (texte source) details: dict = field(default_factory=dict) # champs structurés (JSON) images: list[str] = field(default_factory=list) # URLs absolues carfax_url: str = "" @property def uid(self) -> str: return f"{self.source}:{self.external_id}" def content_hash(self) -> str: """Hash du contenu pour la détection de changements (pseudo-webhook).""" payload = asdict(self) blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) return hashlib.sha256(blob.encode("utf-8")).hexdigest() def finalize(self) -> "Vehicle": """Applique la normalisation commune. Appelé par le pipeline d'ingestion. Idempotent ; ne remplace jamais une valeur explicite du connecteur. """ self.title = " ".join((self.title or "").split()) # année / marque / modèle déduits du titre si absents t_year, t_make, t_model = split_title(self.title) if self.year is None: self.year = t_year if not self.make: self.make = t_make if not self.model and t_model: # retirer la version du modèle déduit si elle duplique trim self.model = t_model.split(" ")[0] self.make = normalize_make(self.make) self.model = " ".join(self.model.split()) if self.price is None: self.price = parse_price(self.price_label) if self.mileage_km is None: self.mileage_km = parse_mileage(self.mileage_label or self.description) self.transmission = normalize_transmission(self.transmission) or self.transmission self.fuel = normalize_fuel(self.fuel) or self.fuel self.drivetrain = normalize_drivetrain(self.drivetrain) or self.drivetrain self.body_type = normalize_body(self.body_type) or self.body_type # replis conservateurs depuis le titre/la version quand la source # n'expose pas le champ (jetons explicites seulement : AWD, PHEV…) badge = f"{self.title} {self.trim}" if not self.drivetrain: from .normalize import infer_drivetrain_from_text self.drivetrain = infer_drivetrain_from_text(badge) if not self.fuel: from .normalize import infer_fuel_from_text self.fuel = infer_fuel_from_text(badge) if not self.region: self.region = infer_region(self.city) if self.price_label and price_is_from(self.price_label): self.details["price_from"] = True # année plausible seulement if self.year is not None and not (1980 <= self.year <= 2027): self.year = None # dédoublonner les images en préservant l'ordre seen: set[str] = set() self.images = [u for u in self.images if u and not (u in seen or seen.add(u))] return self