spb/immo-ka Public
Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 64%
TypeScript 21.1%
CSS 14.3%
HTML 0.6%
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 description: str = ""62 features: list[str] = field(default_factory=list) # caractéristiques (texte source)63 details: dict = field(default_factory=dict) # champs structurés (JSON)64 images: list[str] = field(default_factory=list) # URLs absolues65 lat: float | None = None66 lng: float | None = None6768 @property69 def uid(self) -> str:70 return f"{self.source}:{self.external_id}"7172 def content_hash(self) -> str:73 """Hash du contenu pour la détection de changements (pseudo-webhook)."""74 payload = asdict(self)75 blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)76 return hashlib.sha256(blob.encode("utf-8")).hexdigest()7778 def finalize(self) -> "PropertyListing":79 """Applique la normalisation commune. Appelé par le pipeline d'ingestion.8081 Idempotent ; ne remplace jamais une valeur explicite du connecteur.82 """83 self.title = (self.title or "").strip()84 self.address = clean_address(self.address)85 self.city = (self.city or "").strip()86 self.property_type = normalize_property_type(self.property_type)8788 if self.price is None:89 self.price = parse_price(self.price_label)90 if self.price_label and price_is_from(self.price_label):91 self.details.setdefault("price_from", True)9293 texte = " ".join(filter(None, (self.title, self.description,94 " ".join(self.features))))95 if self.bedrooms is None or self.bathrooms is None:96 beds, baths = extract_bedrooms_bathrooms(texte)97 if self.bedrooms is None:98 self.bedrooms = beds99 if self.bathrooms is None:100 self.bathrooms = baths101 if self.area_sqft is None:102 self.area_sqft = parse_area_sqft(texte)103 if self.lot_sqft is None and "terrain" in strip_accents(texte.lower()):104 self.lot_sqft = parse_lot_sqft(texte)105 self.year_built = parse_int(self.year_built)106107 # coordonnées fournies par la source : rejeter tout point hors de la108 # province (lat/lng inversés, 0/0, coquilles)109 if self.lat is not None and self.lng is not None:110 if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):111 self.lat = self.lng = None112113 return self114