SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
6.9 KB · 154 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    clean_description,24    clean_title,25    extract_bedrooms_bathrooms,26    normalize_property_type,27    parse_area_sqft,28    parse_int,29    parse_lot_sqft,30    parse_price,31    parse_year,32    price_is_from,33    strip_accents,34)3536__all__ = ["PropertyListing"]373839@dataclass40class PropertyListing:41    """Propriété à vendre standardisée Immo-Ka."""4243    source: str                       # id de l'agence (voir data/sources.json)44    external_id: str                  # identifiant chez la source (souvent le n° Centris/MLS)45    url: str                          # page de la propriété chez la source46    title: str = ""                   # ex. "Maison à étages à vendre — Lévis"47    address: str = ""                 # adresse civique48    sector: str = ""                  # quartier/arrondissement49    city: str = ""                    # Québec, Lévis, Montréal…50    region: str = ""                  # région administrative (Capitale-Nationale…)51    property_type: str = ""           # Maison, Condo, Duplex, Terrain… (canonique)52    price: float | None = None        # prix demandé ($ CAD)53    price_label: str = ""             # texte original (ex. "459 000 $ +tx")54    bedrooms: int | None = None       # chambres55    bathrooms: int | None = None      # salles de bains56    powder_rooms: int | None = None   # salles d'eau57    area_sqft: float | None = None    # superficie habitable (pi²)58    lot_sqft: float | None = None     # superficie du terrain (pi²)59    year_built: int | None = None60    mls: str = ""                     # numéro Centris/MLS si affiché par la source61    status: str = "a-vendre"          # a-vendre | vendu | conditionnel62    broker_name: str = ""             # courtier inscripteur63    broker_phone: str = ""64    agency: str = ""                  # sous-agence / bureau (ex. « Royal LePage Altitude »,65                                      #   « Groupe Sutton - Synergie ») — affichage des66                                      #   Sources par sous-agence67    description: str = ""68    features: list[str] = field(default_factory=list)  # caractéristiques (texte source)69    details: dict = field(default_factory=dict)        # champs structurés (JSON)70    images: list[str] = field(default_factory=list)    # URLs absolues71    lat: float | None = None72    lng: float | None = None7374    @property75    def uid(self) -> str:76        return f"{self.source}:{self.external_id}"7778    def content_hash(self) -> str:79        """Hash du contenu pour la détection de changements (pseudo-webhook)."""80        payload = asdict(self)81        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)82        return hashlib.sha256(blob.encode("utf-8")).hexdigest()8384    def finalize(self) -> "PropertyListing":85        """Applique la normalisation commune. Appelé par le pipeline d'ingestion.8687        Idempotent ; ne remplace jamais une valeur explicite du connecteur.88        """89        self.title = clean_title(self.title)90        self.address = clean_address(self.address)91        self.city = (self.city or "").strip()92        self.property_type = normalize_property_type(self.property_type)93        self.description = clean_description(self.description)9495        # galerie : URLs valides seulement, placeholders retirés, doublons96        # (même photo en deux tailles) dédupliqués — voir immoka/imgaudit.py97        from .imgaudit import clean_gallery98        self.images = clean_gallery(self.images)99100        if self.price is None:101            self.price = parse_price(self.price_label)102        if self.price_label and price_is_from(self.price_label):103            self.details.setdefault("price_from", True)104105        # promotion details -> colonnes : les champs structurés de la fiche106        # (tableau DDF/Centris) priment sur l'extraction de texte libre ci-dessous107        if not self.property_type:108            for k in ("Building Type", "Property Type", "Type",109                      "Type de propriété", "Genre de propriété"):110                if self.details.get(k):111                    self.property_type = normalize_property_type(str(self.details[k]))112                    break113        if self.year_built is None:114            for k in ("Constructed Date", "Année de construction"):115                if self.details.get(k):116                    self.year_built = parse_year(self.details[k])117                    break118        if self.area_sqft is None:119            for k in ("Size Interior", "Superficie habitable"):120                if self.details.get(k):121                    self.area_sqft = parse_area_sqft(str(self.details[k]))122                    break123        if self.lot_sqft is None:124            for k in ("Land Size", "Superficie du terrain"):125                if self.details.get(k):126                    self.lot_sqft = parse_lot_sqft(str(self.details[k]))127                    break128129        texte = " ".join(filter(None, (self.title, self.description,130                                       " ".join(self.features))))131        if self.bedrooms is None or self.bathrooms is None:132            beds, baths = extract_bedrooms_bathrooms(texte)133            if self.bedrooms is None:134                self.bedrooms = beds135            if self.bathrooms is None:136                self.bathrooms = baths137        if self.area_sqft is None:138            self.area_sqft = parse_area_sqft(texte)139        if self.lot_sqft is None and "terrain" in strip_accents(texte.lower()):140            self.lot_sqft = parse_lot_sqft(texte)141        self.year_built = parse_int(self.year_built)142143        # sous-agence : à défaut, on retombe sur le courtier/agence inscripteur144        if not self.agency:145            self.agency = self.broker_name146147        # coordonnées fournies par la source : rejeter tout point hors du148        # territoire couvert — le CANADA au complet (lat/lng inversés, 0/0, coquilles)149        if self.lat is not None and self.lng is not None:150            if not (41.6 <= self.lat <= 83.2 and -141.1 <= self.lng <= -52.5):151                self.lat = self.lng = None152153        return self154