# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # schema.py : modèle de données standardisé (PropertyListing) # ----------------------------------------------------------------------------- """Schéma standard d'une propriété à vendre et normalisation des champs. Chaque connecteur, peu importe l'agence source (RE/MAX, Sutton, Via Capitale…), doit produire des objets `PropertyListing` conformes à ce schéma. La méthode `finalize()` applique ensuite la couche de normalisation commune (immoka/normalize.py) : prix, type de propriété canonique, superficies en pi², chambres/salles de bains… — les connecteurs restent simples et remplissent les champs bruts. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass, field, asdict from .normalize import ( clean_address, extract_bedrooms_bathrooms, normalize_property_type, parse_area_sqft, parse_int, parse_lot_sqft, parse_price, price_is_from, strip_accents, ) __all__ = ["PropertyListing"] @dataclass class PropertyListing: """Propriété à vendre standardisée Immo-Ka.""" source: str # id de l'agence (voir data/sources.json) external_id: str # identifiant chez la source (souvent le n° Centris/MLS) url: str # page de la propriété chez la source title: str = "" # ex. "Maison à étages à vendre — Lévis" address: str = "" # adresse civique sector: str = "" # quartier/arrondissement city: str = "" # Québec, Lévis, Montréal… region: str = "" # région administrative (Capitale-Nationale…) property_type: str = "" # Maison, Condo, Duplex, Terrain… (canonique) price: float | None = None # prix demandé ($ CAD) price_label: str = "" # texte original (ex. "459 000 $ +tx") bedrooms: int | None = None # chambres bathrooms: int | None = None # salles de bains powder_rooms: int | None = None # salles d'eau area_sqft: float | None = None # superficie habitable (pi²) lot_sqft: float | None = None # superficie du terrain (pi²) year_built: int | None = None mls: str = "" # numéro Centris/MLS si affiché par la source status: str = "a-vendre" # a-vendre | vendu | conditionnel broker_name: str = "" # courtier inscripteur broker_phone: str = "" agency: str = "" # sous-agence / bureau (ex. « Royal LePage Altitude », # « Groupe Sutton - Synergie ») — affichage des # Sources par sous-agence description: str = "" features: list[str] = field(default_factory=list) # caractéristiques (texte source) details: dict = field(default_factory=dict) # champs structurés (JSON) images: list[str] = field(default_factory=list) # URLs absolues lat: float | None = None lng: float | None = None @property def uid(self) -> str: return f"{self.source}:{self.external_id}" def content_hash(self) -> str: """Hash du contenu pour la détection de changements (pseudo-webhook).""" payload = asdict(self) blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) return hashlib.sha256(blob.encode("utf-8")).hexdigest() def finalize(self) -> "PropertyListing": """Applique la normalisation commune. Appelé par le pipeline d'ingestion. Idempotent ; ne remplace jamais une valeur explicite du connecteur. """ self.title = (self.title or "").strip() self.address = clean_address(self.address) self.city = (self.city or "").strip() self.property_type = normalize_property_type(self.property_type) if self.price is None: self.price = parse_price(self.price_label) if self.price_label and price_is_from(self.price_label): self.details.setdefault("price_from", True) texte = " ".join(filter(None, (self.title, self.description, " ".join(self.features)))) if self.bedrooms is None or self.bathrooms is None: beds, baths = extract_bedrooms_bathrooms(texte) if self.bedrooms is None: self.bedrooms = beds if self.bathrooms is None: self.bathrooms = baths if self.area_sqft is None: self.area_sqft = parse_area_sqft(texte) if self.lot_sqft is None and "terrain" in strip_accents(texte.lower()): self.lot_sqft = parse_lot_sqft(texte) self.year_built = parse_int(self.year_built) # sous-agence : à défaut, on retombe sur le courtier/agence inscripteur if not self.agency: self.agency = self.broker_name # coordonnées fournies par la source : rejeter tout point hors de la # province (lat/lng inversés, 0/0, coquilles) if self.lat is not None and self.lng is not None: if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0): self.lat = self.lng = None return self