SPB Git

spb/auto-ka Public

Python 82.3% TypeScript 12% CSS 5.4%
5.9 KB · 142 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# schema.py : modèle de données standardisé (Vehicle) + normalisation centrale5# -----------------------------------------------------------------------------6"""Schéma standard d'une annonce de véhicule usagé.78Chaque connecteur, peu importe le site source, produit des objets `Vehicle`9conformes à ce schéma. `finalize()` applique la couche de normalisation10commune (autoka/normalize.py) : prix, kilométrage, année/marque/modèle11déduits du titre, transmission/carburant/carrosserie canoniques, région12administrative — les connecteurs restent simples et remplissent le brut.13"""14from __future__ import annotations1516import hashlib17import json18from dataclasses import dataclass, field, asdict1920from .normalize import (  # ré-exportés pour les connecteurs21    infer_region,22    normalize_body,23    normalize_drivetrain,24    normalize_fuel,25    normalize_make,26    normalize_transmission,27    parse_mileage,28    parse_price,29    parse_year,30    price_is_from,31    split_title,32    strip_accents,33)3435__all__ = [36    "Vehicle", "parse_price", "parse_mileage", "parse_year", "split_title",37    "normalize_make", "normalize_transmission", "normalize_fuel",38    "normalize_drivetrain", "normalize_body", "infer_region", "strip_accents",39]404142@dataclass43class Vehicle:44    """Annonce de véhicule usagé standardisée Auto-Ka."""4546    source: str                      # id de la source (voir data/sources.json)47    external_id: str                 # identifiant chez la source (stock, VIN, slug)48    url: str                         # page de l'annonce chez le concessionnaire49    title: str = ""                  # ex. "Toyota RAV4 XLE 2019"50    make: str = ""                   # Toyota, Honda, ...51    model: str = ""                  # RAV4, Civic, ...52    trim: str = ""                   # XLE AWD, Sport, ...53    year: int | None = None54    price: float | None = None       # $ CAD affiché55    price_label: str = ""            # texte original (ex. "21 495 $ + tx")56    mileage_km: float | None = None57    mileage_label: str = ""          # texte original (ex. "45 678 km")58    transmission: str = ""           # Automatique | Manuelle59    fuel: str = ""                   # Essence | Diesel | Hybride | Hybride rechargeable | Électrique60    drivetrain: str = ""             # Traction | Propulsion | Intégrale / 4x461    body_type: str = ""              # VUS | Berline | Camionnette | ...62    exterior_color: str = ""63    interior_color: str = ""64    engine: str = ""                 # ex. "2.5L 4 cyl."65    doors: int | None = None66    seats: int | None = None67    vin: str = ""68    stock_number: str = ""69    dealer_name: str = ""            # nom d'affichage du commerce70    city: str = ""                   # ville du concessionnaire71    region: str = ""                 # région administrative (déduite de city)72    description: str = ""73    features: list[str] = field(default_factory=list)   # équipements (texte source)74    details: dict = field(default_factory=dict)         # champs structurés (JSON)75    images: list[str] = field(default_factory=list)     # URLs absolues76    carfax_url: str = ""7778    @property79    def uid(self) -> str:80        return f"{self.source}:{self.external_id}"8182    def content_hash(self) -> str:83        """Hash du contenu pour la détection de changements (pseudo-webhook)."""84        payload = asdict(self)85        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)86        return hashlib.sha256(blob.encode("utf-8")).hexdigest()8788    def finalize(self) -> "Vehicle":89        """Applique la normalisation commune. Appelé par le pipeline d'ingestion.9091        Idempotent ; ne remplace jamais une valeur explicite du connecteur.92        """93        self.title = " ".join((self.title or "").split())9495        # année / marque / modèle déduits du titre si absents96        t_year, t_make, t_model = split_title(self.title)97        if self.year is None:98            self.year = t_year99        if not self.make:100            self.make = t_make101        if not self.model and t_model:102            # retirer la version du modèle déduit si elle duplique trim103            self.model = t_model.split("  ")[0]104        self.make = normalize_make(self.make)105        self.model = " ".join(self.model.split())106107        if self.price is None:108            self.price = parse_price(self.price_label)109        if self.mileage_km is None:110            self.mileage_km = parse_mileage(self.mileage_label or self.description)111112        self.transmission = normalize_transmission(self.transmission) or self.transmission113        self.fuel = normalize_fuel(self.fuel) or self.fuel114        self.drivetrain = normalize_drivetrain(self.drivetrain) or self.drivetrain115        self.body_type = normalize_body(self.body_type) or self.body_type116117        # replis conservateurs depuis le titre/la version quand la source118        # n'expose pas le champ (jetons explicites seulement : AWD, PHEV…)119        badge = f"{self.title} {self.trim}"120        if not self.drivetrain:121            from .normalize import infer_drivetrain_from_text122            self.drivetrain = infer_drivetrain_from_text(badge)123        if not self.fuel:124            from .normalize import infer_fuel_from_text125            self.fuel = infer_fuel_from_text(badge)126127        if not self.region:128            self.region = infer_region(self.city)129130        if self.price_label and price_is_from(self.price_label):131            self.details["price_from"] = True132133        # année plausible seulement134        if self.year is not None and not (1980 <= self.year <= 2027):135            self.year = None136137        # dédoublonner les images en préservant l'ordre138        seen: set[str] = set()139        self.images = [u for u in self.images140                       if u and not (u in seen or seen.add(u))]141        return self142