spb/auto-ka Public
Python 82.3%
TypeScript 12%
CSS 5.4%
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