SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
6.0 KB · 143 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    kind: str = "auto"               # verticale : auto | moto | scooter50    title: str = ""                  # ex. "Toyota RAV4 XLE 2019"51    make: str = ""                   # Toyota, Honda, ...52    model: str = ""                  # RAV4, Civic, ...53    trim: str = ""                   # XLE AWD, Sport, ...54    year: int | None = None55    price: float | None = None       # $ CAD affiché56    price_label: str = ""            # texte original (ex. "21 495 $ + tx")57    mileage_km: float | None = None58    mileage_label: str = ""          # texte original (ex. "45 678 km")59    transmission: str = ""           # Automatique | Manuelle60    fuel: str = ""                   # Essence | Diesel | Hybride | Hybride rechargeable | Électrique61    drivetrain: str = ""             # Traction | Propulsion | Intégrale / 4x462    body_type: str = ""              # VUS | Berline | Camionnette | ...63    exterior_color: str = ""64    interior_color: str = ""65    engine: str = ""                 # ex. "2.5L 4 cyl."66    doors: int | None = None67    seats: int | None = None68    vin: str = ""69    stock_number: str = ""70    dealer_name: str = ""            # nom d'affichage du commerce71    city: str = ""                   # ville du concessionnaire72    region: str = ""                 # région administrative (déduite de city)73    description: str = ""74    features: list[str] = field(default_factory=list)   # équipements (texte source)75    details: dict = field(default_factory=dict)         # champs structurés (JSON)76    images: list[str] = field(default_factory=list)     # URLs absolues77    carfax_url: str = ""7879    @property80    def uid(self) -> str:81        return f"{self.source}:{self.external_id}"8283    def content_hash(self) -> str:84        """Hash du contenu pour la détection de changements (pseudo-webhook)."""85        payload = asdict(self)86        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)87        return hashlib.sha256(blob.encode("utf-8")).hexdigest()8889    def finalize(self) -> "Vehicle":90        """Applique la normalisation commune. Appelé par le pipeline d'ingestion.9192        Idempotent ; ne remplace jamais une valeur explicite du connecteur.93        """94        self.title = " ".join((self.title or "").split())9596        # année / marque / modèle déduits du titre si absents97        t_year, t_make, t_model = split_title(self.title)98        if self.year is None:99            self.year = t_year100        if not self.make:101            self.make = t_make102        if not self.model and t_model:103            # retirer la version du modèle déduit si elle duplique trim104            self.model = t_model.split("  ")[0]105        self.make = normalize_make(self.make)106        self.model = " ".join(self.model.split())107108        if self.price is None:109            self.price = parse_price(self.price_label)110        if self.mileage_km is None:111            self.mileage_km = parse_mileage(self.mileage_label or self.description)112113        self.transmission = normalize_transmission(self.transmission) or self.transmission114        self.fuel = normalize_fuel(self.fuel) or self.fuel115        self.drivetrain = normalize_drivetrain(self.drivetrain) or self.drivetrain116        self.body_type = normalize_body(self.body_type) or self.body_type117118        # replis conservateurs depuis le titre/la version quand la source119        # n'expose pas le champ (jetons explicites seulement : AWD, PHEV…)120        badge = f"{self.title} {self.trim}"121        if not self.drivetrain:122            from .normalize import infer_drivetrain_from_text123            self.drivetrain = infer_drivetrain_from_text(badge)124        if not self.fuel:125            from .normalize import infer_fuel_from_text126            self.fuel = infer_fuel_from_text(badge)127128        if not self.region:129            self.region = infer_region(self.city)130131        if self.price_label and price_is_from(self.price_label):132            self.details["price_from"] = True133134        # année plausible seulement135        if self.year is not None and not (1980 <= self.year <= 2027):136            self.year = None137138        # dédoublonner les images en préservant l'ordre139        seen: set[str] = set()140        self.images = [u for u in self.images141                       if u and not (u in seen or seen.add(u))]142        return self143