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%
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