SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
6.2 KB · 145 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# schema.py : modèle de données standardisé (Listing) + enrichissement central5# -----------------------------------------------------------------------------6"""Schéma standard d'une annonce (Listing) et normalisation des champs.78Chaque connecteur, peu importe le site source, doit produire des objets9`Listing` conformes à ce schéma. La méthode `finalize()` applique ensuite10la couche de normalisation commune (louka/normalize.py) : dates ISO,11superficie en pi², commodités canoniques, prix… — les connecteurs peuvent12donc rester simples et remplir les champs bruts.13"""14from __future__ import annotations1516import hashlib17import json18from dataclasses import dataclass, field, asdict1920from .normalize import (  # ré-exportés pour les connecteurs existants21    clean_address,22    extract_details,23    merge_details,24    normalize_unit_type,25    parse_area_sqft,26    parse_availability_date,27    parse_price,28    price_is_from,29    strip_accents,30)3132__all__ = [33    "Listing", "infer_city", "normalize_unit_type", "parse_price",34    "parse_availability_date", "parse_area_sqft", "clean_address",35    "strip_accents",36]373839@dataclass40class Listing:41    """Annonce standardisée Lou-Ka."""4243    source: str                      # id de la source (voir data/sources.json)44    external_id: str                 # identifiant chez la source45    url: str                         # page de l'annonce chez la source46    title: str = ""                  # ex. "555, avenue du Fleuve"47    address: str = ""                # adresse civique48    sector: str = ""                 # quartier/arrondissement (ex. Beauport)49    city: str = ""                   # Québec, Lévis, ...50    unit_type: str = ""              # 1½ … 5½, 6½+, Studio, Loft, Chambre…51    price: float | None = None       # loyer mensuel ($ CAD), le plus bas si "à partir de"52    price_label: str = ""            # texte original (ex. "à partir de 799$")53    availability: str = ""           # texte original (ex. "Libre immédiatement")54    availability_date: str | None = None  # ISO "2026-07-01", "now", ou None55    area_sqft: float | None = None   # superficie en pi²56    pets: str | None = None          # "oui" | "non" | "conditions" | None57    furnished: bool | None = None    # meublé (None = inconnu)58    description: str = ""59    digest: dict | None = None       # description structurée (louka/textmine.py)60    amenities: list[str] = field(default_factory=list)   # texte source, pour affichage61    details: dict = field(default_factory=dict)          # champs structurés (JSON)62    images: list[str] = field(default_factory=list)      # URLs absolues63    lat: float | None = None64    lng: float | None = None6566    @property67    def uid(self) -> str:68        return f"{self.source}:{self.external_id}"6970    def content_hash(self) -> str:71        """Hash du contenu pour la détection de changements (pseudo-webhook)."""72        payload = asdict(self)73        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)74        return hashlib.sha256(blob.encode("utf-8")).hexdigest()7576    def finalize(self) -> "Listing":77        """Applique la normalisation commune. Appelé par le pipeline d'ingestion.7879        Idempotent ; ne remplace jamais une valeur explicite du connecteur.80        """81        self.title = self.title.strip()82        self.address = clean_address(self.address)83        self.unit_type = normalize_unit_type(self.unit_type)8485        if self.price is None:86            self.price = parse_price(self.price_label)87        if self.availability_date is None:88            self.availability_date = parse_availability_date(self.availability)89        if self.area_sqft is None:90            for texte in (self.description, " ".join(self.amenities), self.title):91                self.area_sqft = parse_area_sqft(texte)92                if self.area_sqft is not None:93                    break9495        # coordonnées fournies par la source : rejeter tout point hors de la96        # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra97        # le relais sur l'adresse plutôt que d'afficher un point au Kazakhstan98        if self.lat is not None and self.lng is not None:99            if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):100                self.lat = self.lng = None101102        derived = extract_details(self.amenities, self.description, self.title)103        if self.price_label and price_is_from(self.price_label):104            derived["price_from"] = True105        self.details = merge_details(self.details, derived)106107        # description structurée (nettoyage + extraction + sections)108        if self.digest is None and self.description:109            try:110                from .textmine import analyser111                self.digest = analyser(self.description, price=self.price,112                                       sector=self.sector, city=self.city)113            except ImportError:114                pass   # module absent : la fiche affichera le texte brut115116        if self.pets is None:117            self.pets = self.details.get("pets")118        else:119            self.details["pets"] = self.pets120        if self.furnished is None:121            furn = self.details.get("furnished")122            self.furnished = furn if isinstance(furn, bool) else None123        else:124            self.details["furnished"] = self.furnished125        return self126127128# ---------------------------------------------------------------------------129# Secteur -> ville (agglomération Québec / Lévis)130# ---------------------------------------------------------------------------131132_LEVIS_SECTORS = {133    "levis", "saint-romuald", "st-romuald", "charny", "saint-nicolas",134    "st-nicolas", "saint-david", "st-david", "desjardins", "saint-redempteur",135    "st-redempteur", "breakeyville", "saint-jean-chrysostome", "st-jean-chrysostome",136    "pintendre", "saint-etienne", "vieux-levis",137}138139140def infer_city(sector: str, default: str = "Québec") -> str:141    key = strip_accents((sector or "").strip().lower())142    if key in _LEVIS_SECTORS or "levis" in key:143        return "Lévis"144    return default145