Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/proprio_direct.py : Proprio Direct (propriodirect.com) — LOCATION5# L'inventaire (alimenté par le feed DDF de CREA) est servi par une API JSON6# interne : POST https://propriodirect.com/fr/api/searchListings7# Corps { "from": <offset>, "filter": {"rentOrSale": "rent"} } — le filtre8# côté API fonctionne (~260 locations vs ~3200 annonces au total) ; 309# résultats/page, champ `total`. Chaque item porte forRent/forSale/sold,10# le loyer en cents (rentPriceInCents, rentPriceFrequency), adresse, ville,11# région, chambres, sdb, GPS, tags et galerie photos. On ne garde que le12# volet RÉSIDENTIEL (tag « residential ») : les locaux commerciaux et13# industriels (loyer en $/pi²/an) sont écartés. Adapté du connecteur14# « à vendre » d'Immo-Ka (agent-courtage/immoka).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import os19import re2021from ..schema import Listing22from .base import BaseConnector2324from . import _detailutil as du2526SITE = "https://propriodirect.com"27SEARCH_URL = f"{SITE}/fr/api/searchListings"28PAGE_SIZE = 3029MAX_PAGES = 60 # garde-fou (~260 locations = 9 pages)30DETAIL_LIMIT = int(os.environ.get("LOUKA_PD_DETAIL_LIMIT", "400"))3132# tags source (feed DDF) -> caractéristiques lisibles33_TAG_LABELS = {34 "car_garage": "Garage", "car_parking": "Stationnement", "car_foyer": "Foyer",35 "car_piscine": "Piscine", "car_climatisation": "Climatisation",36 "car_sous_sol": "Sous-sol", "car_bord_eau": "Bord de l'eau",37 "prox_park": "Parc à proximité", "prox_ecole_primaire": "École primaire",38 "prox_ecole_secondaire": "École secondaire", "prox_transport": "Transport en commun",39 "prox_garderie": "Garderie", "prox_piste_cyclable": "Piste cyclable",40 "prox_hopital": "Hôpital", "prox_autoroute": "Autoroute",41 "new_price": "Nouveau prix",42}434445class ProprioDirectConnector(BaseConnector):46 source_id = "proprio_direct"47 request_delay = 0.44849 def _headers(self) -> dict:50 return {51 "Accept": "application/json",52 "Content-Type": "application/json",53 "Origin": SITE,54 "Referer": f"{SITE}/propriete-a-louer/",55 }5657 def fetch(self) -> list[Listing]:58 out: list[Listing] = []59 seen: set[str] = set()60 offset = 061 for _ in range(MAX_PAGES):62 try:63 data = self.post(SEARCH_URL, headers=self._headers(),64 json={"from": offset,65 "includeGeoJson": False,66 "filter": {"rentOrSale": "rent"}}).json()67 except Exception:68 break69 items = data.get("listings", []) if isinstance(data, dict) else []70 if not items:71 break72 for it in items:73 lst = self._to_listing(it)74 if lst and lst.uid not in seen:75 seen.add(lst.uid)76 out.append(lst)77 total = data.get("total") or 078 offset += PAGE_SIZE79 if offset >= total:80 break81 # fiche détail : description (la galerie est déjà complète via l'API)82 du.enrich(self, out, DETAIL_LIMIT, _parse_pd_detail, key="v3")83 return out8485 def _to_listing(self, it: dict) -> Listing | None:86 rid = it.get("id")87 tags = it.get("tags") or []88 # location résidentielle seulement (ceinture + bretelles : le filtre89 # API renvoie déjà uniquement des locations, on revalide chaque item)90 if (not rid or it.get("sold") or not it.get("forRent")91 or "residential" not in tags):92 return None93 # loyer mensuel : rentPriceInCents (fallback priceInCents si le feed94 # met le loyer dans le prix principal) — on écarte les baux annuels95 # au pi² qui se seraient glissés dans le volet résidentiel96 freq = it.get("rentPriceFrequency") or "monthly"97 if freq != "monthly":98 return None99 cents = it.get("rentPriceInCents") or it.get("priceInCents")100 price = (cents / 100.0) if isinstance(cents, (int, float)) and cents > 0 else None101102 slug = it.get("slugURLFr") or it.get("slugURL") or ""103 url = f"{SITE}{slug}" if slug.startswith("/") else (slug or SITE)104 geo = it.get("geoLocation") or {}105106 # type d'unité : nb de chambres (normalisé n+2 ½ par finalize()) ;107 # le genre Loft/Studio (LS) sans chambre est un studio108 genre = (it.get("genre") or "").upper()109 rooms = _pos_int(it.get("numberOfRooms"))110 unit_type = f"{rooms} chambres" if rooms else ("Studio" if genre == "LS" else "")111112 details: dict = {"Courtier": "Proprio Direct"}113 if it.get("genreName"):114 details["Type"] = it["genreName"]115 if it.get("regionName"):116 details["Région"] = it["regionName"]117 baths = _pos_int(it.get("numberOfBathrooms"))118 if baths:119 details["Salles de bain"] = str(baths)120 if it.get("postalCode"):121 details["Code postal"] = it["postalCode"]122123 return Listing(124 source=self.source_id,125 external_id=str(rid),126 url=url,127 title=it.get("addressLine", ""),128 address=it.get("addressLine", ""),129 sector=it.get("neighbourhood", "") or "",130 city=it.get("cityName", ""),131 unit_type=unit_type,132 price=price,133 price_label=it.get("rentPriceText", "") or "",134 amenities=[_TAG_LABELS[t] for t in tags if t in _TAG_LABELS],135 details=details,136 images=list(it.get("photosOriginal") or it.get("photosSmall") or []),137 lat=geo.get("lat"),138 lng=geo.get("lon"),139 )140141142# libellés des « Points saillants » d'une fiche Proprio Direct — la page les143# rend en liste « libellé | valeur » stricte (contrairement aux fiches Centris,144# d'où un parseur dédié plutôt que du.centris_details qui appariait de travers)145_PD_LABELS = [146 "Année de construction", "Superficie habitable", "Niveau",147 "Équipement disponible", "Zonage", "Mode de chauffage",148 "Énergie pour le chauffage", "Approvisionnement en eau",149 "Système d'égouts", "Restrictions/Permissions",150 "Garage", "Piscine", "Foyer-Poêle", "Meublé", "Animaux",151 "Disponibilité", "Date d'emménagement", "Bail",152]153_PD_SECTIONS = {"points saillants", "détails", "pièces", "inclus", "exclus",154 "style de vie", "description"}155156157def _pd_details(text: str) -> dict:158 """Paires « libellé | valeur » des Points saillants (ordre strict)."""159 stop = {l.lower() for l in _PD_LABELS} | _PD_SECTIONS160 out: dict = {}161 for label in _PD_LABELS:162 m = re.search(re.escape(label) + r"\s*\|\s*([^|]{1,90})", text)163 if m:164 val = m.group(1).strip(" |")165 if val and val.lower() not in stop:166 out[label] = val167 return out168169170def _parse_pd_detail(html: str) -> dict:171 """Fiche Proprio Direct : description + points saillants172 (photos déjà fournies par l'API)."""173 out: dict = {}174 desc = du.ld_description(html)175 # le JSON-LD des fiches sans texte retombe sur la description « méta »176 # générique (« … | No. Inscription 12345678 | Proprio Direct ») — on l'écarte177 if desc and "No. Inscription" not in desc:178 out["description"] = desc179 det = _pd_details(du.flatten(html))180 if det:181 out.setdefault("details", {}).update(det)182 # « Animaux permis sous conditions » / « Animaux non admis »…183 restr = det.get("Restrictions/Permissions", "").lower()184 if "animaux" in restr:185 out["pets"] = ("conditions" if "condition" in restr186 else "non" if ("non" in restr or "pas" in restr)187 else "oui")188 return out189190191def _pos_int(v):192 try:193 n = int(v)194 return n if n > 0 else None195 except (TypeError, ValueError):196 return None197