# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # schema.py : modèle de données standardisé (Listing) + enrichissement central # ----------------------------------------------------------------------------- """Schéma standard d'une annonce (Listing) et normalisation des champs. Chaque connecteur, peu importe le site source, doit produire des objets `Listing` conformes à ce schéma. La méthode `finalize()` applique ensuite la couche de normalisation commune (louka/normalize.py) : dates ISO, superficie en pi², commodités canoniques, prix… — les connecteurs peuvent donc rester simples et remplir les champs bruts. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass, field, asdict from .normalize import ( # ré-exportés pour les connecteurs existants clean_address, extract_details, merge_details, normalize_unit_type, parse_area_sqft, parse_availability_date, parse_price, price_is_from, strip_accents, ) __all__ = [ "Listing", "infer_city", "normalize_unit_type", "parse_price", "parse_availability_date", "parse_area_sqft", "clean_address", "strip_accents", ] @dataclass class Listing: """Annonce standardisée Lou-Ka.""" source: str # id de la source (voir data/sources.json) external_id: str # identifiant chez la source url: str # page de l'annonce chez la source title: str = "" # ex. "555, avenue du Fleuve" address: str = "" # adresse civique sector: str = "" # quartier/arrondissement (ex. Beauport) city: str = "" # Québec, Lévis, ... unit_type: str = "" # 1½ … 5½, 6½+, Studio, Loft, Chambre… price: float | None = None # loyer mensuel ($ CAD), le plus bas si "à partir de" price_label: str = "" # texte original (ex. "à partir de 799$") availability: str = "" # texte original (ex. "Libre immédiatement") availability_date: str | None = None # ISO "2026-07-01", "now", ou None area_sqft: float | None = None # superficie en pi² pets: str | None = None # "oui" | "non" | "conditions" | None furnished: bool | None = None # meublé (None = inconnu) description: str = "" digest: dict | None = None # description structurée (louka/textmine.py) amenities: list[str] = field(default_factory=list) # texte source, pour affichage 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) -> "Listing": """Applique la normalisation commune. Appelé par le pipeline d'ingestion. Idempotent ; ne remplace jamais une valeur explicite du connecteur. """ self.title = self.title.strip() self.address = clean_address(self.address) self.unit_type = normalize_unit_type(self.unit_type) if self.price is None: self.price = parse_price(self.price_label) if self.availability_date is None: self.availability_date = parse_availability_date(self.availability) if self.area_sqft is None: for texte in (self.description, " ".join(self.amenities), self.title): self.area_sqft = parse_area_sqft(texte) if self.area_sqft is not None: break # coordonnées fournies par la source : rejeter tout point hors de la # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra # le relais sur l'adresse plutôt que d'afficher un point au Kazakhstan 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 derived = extract_details(self.amenities, self.description, self.title) if self.price_label and price_is_from(self.price_label): derived["price_from"] = True self.details = merge_details(self.details, derived) # description structurée (nettoyage + extraction + sections) if self.digest is None and self.description: try: from .textmine import analyser self.digest = analyser(self.description, price=self.price, sector=self.sector, city=self.city) except ImportError: pass # module absent : la fiche affichera le texte brut if self.pets is None: self.pets = self.details.get("pets") else: self.details["pets"] = self.pets if self.furnished is None: furn = self.details.get("furnished") self.furnished = furn if isinstance(furn, bool) else None else: self.details["furnished"] = self.furnished return self # --------------------------------------------------------------------------- # Secteur -> ville (agglomération Québec / Lévis) # --------------------------------------------------------------------------- _LEVIS_SECTORS = { "levis", "saint-romuald", "st-romuald", "charny", "saint-nicolas", "st-nicolas", "saint-david", "st-david", "desjardins", "saint-redempteur", "st-redempteur", "breakeyville", "saint-jean-chrysostome", "st-jean-chrysostome", "pintendre", "saint-etienne", "vieux-levis", } def infer_city(sector: str, default: str = "Québec") -> str: key = strip_accents((sector or "").strip().lower()) if key in _LEVIS_SECTORS or "levis" in key: return "Lévis" return default