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%
5.3 KB · 121 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# schema.py : modèle de données standardisé (PropertyListing)5# -----------------------------------------------------------------------------6"""Schéma standard d'une propriété à vendre et normalisation des champs.78Chaque connecteur, peu importe l'agence source (RE/MAX, Sutton, Via Capitale…),9doit produire des objets `PropertyListing` conformes à ce schéma. La méthode10`finalize()` applique ensuite la couche de normalisation commune11(immoka/normalize.py) : prix, type de propriété canonique, superficies en pi²,12chambres/salles de bains… — les connecteurs restent simples et remplissent13les champs bruts.14"""15from __future__ import annotations1617import hashlib18import json19from dataclasses import dataclass, field, asdict2021from .normalize import (22    clean_address,23    extract_bedrooms_bathrooms,24    normalize_property_type,25    parse_area_sqft,26    parse_int,27    parse_lot_sqft,28    parse_price,29    price_is_from,30    strip_accents,31)3233__all__ = ["PropertyListing"]343536@dataclass37class PropertyListing:38    """Propriété à vendre standardisée Immo-Ka."""3940    source: str                       # id de l'agence (voir data/sources.json)41    external_id: str                  # identifiant chez la source (souvent le n° Centris/MLS)42    url: str                          # page de la propriété chez la source43    title: str = ""                   # ex. "Maison à étages à vendre — Lévis"44    address: str = ""                 # adresse civique45    sector: str = ""                  # quartier/arrondissement46    city: str = ""                    # Québec, Lévis, Montréal…47    region: str = ""                  # région administrative (Capitale-Nationale…)48    property_type: str = ""           # Maison, Condo, Duplex, Terrain… (canonique)49    price: float | None = None        # prix demandé ($ CAD)50    price_label: str = ""             # texte original (ex. "459 000 $ +tx")51    bedrooms: int | None = None       # chambres52    bathrooms: int | None = None      # salles de bains53    powder_rooms: int | None = None   # salles d'eau54    area_sqft: float | None = None    # superficie habitable (pi²)55    lot_sqft: float | None = None     # superficie du terrain (pi²)56    year_built: int | None = None57    mls: str = ""                     # numéro Centris/MLS si affiché par la source58    status: str = "a-vendre"          # a-vendre | vendu | conditionnel59    broker_name: str = ""             # courtier inscripteur60    broker_phone: str = ""61    agency: str = ""                  # sous-agence / bureau (ex. « Royal LePage Altitude »,62                                      #   « Groupe Sutton - Synergie ») — affichage des63                                      #   Sources par sous-agence64    description: str = ""65    features: list[str] = field(default_factory=list)  # caractéristiques (texte source)66    details: dict = field(default_factory=dict)        # champs structurés (JSON)67    images: list[str] = field(default_factory=list)    # URLs absolues68    lat: float | None = None69    lng: float | None = None7071    @property72    def uid(self) -> str:73        return f"{self.source}:{self.external_id}"7475    def content_hash(self) -> str:76        """Hash du contenu pour la détection de changements (pseudo-webhook)."""77        payload = asdict(self)78        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)79        return hashlib.sha256(blob.encode("utf-8")).hexdigest()8081    def finalize(self) -> "PropertyListing":82        """Applique la normalisation commune. Appelé par le pipeline d'ingestion.8384        Idempotent ; ne remplace jamais une valeur explicite du connecteur.85        """86        self.title = (self.title or "").strip()87        self.address = clean_address(self.address)88        self.city = (self.city or "").strip()89        self.property_type = normalize_property_type(self.property_type)9091        if self.price is None:92            self.price = parse_price(self.price_label)93        if self.price_label and price_is_from(self.price_label):94            self.details.setdefault("price_from", True)9596        texte = " ".join(filter(None, (self.title, self.description,97                                       " ".join(self.features))))98        if self.bedrooms is None or self.bathrooms is None:99            beds, baths = extract_bedrooms_bathrooms(texte)100            if self.bedrooms is None:101                self.bedrooms = beds102            if self.bathrooms is None:103                self.bathrooms = baths104        if self.area_sqft is None:105            self.area_sqft = parse_area_sqft(texte)106        if self.lot_sqft is None and "terrain" in strip_accents(texte.lower()):107            self.lot_sqft = parse_lot_sqft(texte)108        self.year_built = parse_int(self.year_built)109110        # sous-agence : à défaut, on retombe sur le courtier/agence inscripteur111        if not self.agency:112            self.agency = self.broker_name113114        # coordonnées fournies par la source : rejeter tout point hors de la115        # province (lat/lng inversés, 0/0, coquilles)116        if self.lat is not None and self.lng is not None:117            if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):118                self.lat = self.lng = None119120        return self121