SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
8.2 KB · 197 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# 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