spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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