SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.4 KB · 221 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/lr_gestion.py : connecteur LR Gestion immobilière / LR Location5#   (lrgestion.com ; portail d'annonces lrlocation.ca) — plus gros acteur6#   multi-secteurs de l'Outaouais : Gatineau, Hull, Aylmer, Buckingham,7#   Cantley, Thurso, Perkins (Val-des-Monts)… Portail RentSync rendu serveur,8#   même plateforme que Girescom (girescom.py). Liste /fr/residential : une9#   carte par immeuble (adresse avec ville, prix « Louer de », chambres).10#   Page immeuble /fr/residential/<slug> : tableau des unités disponibles11#   (li.unit-table-desktop__units--unit, colonnes column-field : numéro,12#   salles de bain, prix, pi², photos, disponibilité — pas de colonne13#   chambres sur ce portail), description « À propos de la propriété »,14#   texte « Quartier », téléphone du bureau de location et galerie.15#   Une annonce Lou-Ka = une unité. Les immeubles d'Ottawa (Ontario) sont16#   exclus ; Hull/Aylmer/Buckingham/Masson-Angers sont des secteurs de17#   Gatineau (fusion 2002) -> sector = secteur, city = Gatineau.18#   Fiches immeuble via self.detail() (cache BD, max max_details/sync).19# -----------------------------------------------------------------------------20from __future__ import annotations2122import hashlib23import re2425from bs4 import BeautifulSoup2627from ..schema import Listing, parse_price, strip_accents28from .base import BaseConnector2930BASE = "https://www.lrlocation.ca"31LIST_URL = f"{BASE}/fr/residential"3233# secteurs de la ville de Gatineau (fusion municipale de 2002)34_GATINEAU_SECTORS = {"hull", "aylmer", "buckingham", "masson-angers", "masson"}35# hors Québec : parc ontarien du gestionnaire, exclu du périmètre Lou-Ka36_EXCLUDED_CITIES = {"ottawa"}373839def _city_key(city: str) -> str:40    return re.sub(r"[\s']+", "-", strip_accents(city.strip().lower()))414243class LRGestionConnector(BaseConnector):44    source_id = "lr_gestion"45    request_delay = 0.646    max_details = 60     # garde-fou pages immeuble (~48 immeubles QC au 2026-08)4748    def fetch(self) -> list[Listing]:49        # le serveur RentSync n'annonce pas charset=utf-8 : forcer le décodage50        resp = self.get(LIST_URL)51        resp.encoding = "utf-8"52        soup = BeautifulSoup(resp.text, "html.parser")5354        self._fetched = 055        listings: list[Listing] = []56        for card in soup.select("ul.card-grouping-grid li.card-grouping-grid__item"):57            try:58                listings.extend(self._parse_building(card))59            except Exception:60                continue61        return listings6263    # -- carte immeuble (liste /fr/residential) -----------------------------------64    def _parse_building(self, card) -> list[Listing]:65        link = card.select_one('a[href*="/residential/"]')66        if not link:67            return []68        slug = link["href"].rstrip("/").rsplit("/", 1)[-1]69        url = f"{BASE}/fr/residential/{slug}"7071        title_el = card.select_one(".card-property__title")72        title = title_el.get_text(strip=True) if title_el else slug73        addr_el = card.select_one(".card-property__address--text")74        address = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)) \75            if addr_el else ""7677        # « 1072 Boul. Greber, Gatineau, QC » -> ville = avant-dernier segment78        parts = [p.strip() for p in address.split(",") if p.strip()]79        city = parts[-2] if len(parts) >= 2 else ""80        key = _city_key(city)81        if key in _EXCLUDED_CITIES:82            return []                        # Ottawa (Ontario) : hors périmètre83        sector = ""84        if key in _GATINEAU_SECTORS:85            sector, city = city, "Gatineau"  # secteur de la ville fusionnée8687        rate_el = card.select_one(".card-property__rate-min")88        beds_el = card.select_one(".card-property__beds-content")89        card_sig = (f"{address}|{rate_el.get_text(strip=True) if rate_el else ''}"90                    f"|{beds_el.get_text(strip=True) if beds_el else ''}")9192        # page immeuble (cache BD, invalidée quand la carte liste change)93        cache_key = hashlib.sha1(card_sig.encode("utf-8")).hexdigest()9495        def fetch_fn():96            if self._fetched >= self.max_details:97                raise RuntimeError("budget de pages immeuble atteint")98            self._fetched += 199            return self._fetch_building(url)100101        payload = self.detail(slug, cache_key, fetch_fn)102        if not payload:103            return []104105        building_addr = payload.get("address") or address106        details_base: dict = {"building": title}107        if payload.get("phone"):108            details_base["contact"] = {"phone": payload["phone"]}109110        out: list[Listing] = []111        for u in payload.get("units", []):112            numero = u.get("unit") or ""113            if not numero:114                continue115            # pas de colonne chambres par unité sur ce portail RentSync :116            # seule la salle de bain est structurée (texte source conservé)117            amenities: list[str] = []118            if u.get("bath"):119                amenities.append(u["bath"])120121            area = None122            sqft = re.sub(r"[\s,  ]", "", u.get("sqft") or "")123            if re.fullmatch(r"\d{2,5}", sqft) and 80 <= float(sqft) <= 20000:124                area = float(sqft)125126            out.append(Listing(127                source=self.source_id,128                external_id=f"{slug}--{numero}",129                url=url,130                title=f"{title} — unité {numero}",131                address=building_addr,132                sector=sector,133                city=city,134                unit_type="",                # chambres non publiées par unité135                price=parse_price(u.get("rate", "")),136                price_label=u.get("rate", ""),137                availability=u.get("availability", ""),138                area_sqft=area,139                description=payload.get("description", ""),140                amenities=amenities,141                details=dict(details_base),142                images=(u.get("photos") or payload.get("gallery") or [])[:20],143            ))144        return out145146    # -- page immeuble --------------------------------------------------------------147    def _fetch_building(self, url: str) -> dict:148        """Payload JSON-sérialisable : unités disponibles + infos immeuble."""149        resp = self.get(url)150        resp.encoding = "utf-8"              # charset absent des en-têtes151        html = resp.text152        soup = BeautifulSoup(html, "html.parser")153        payload: dict = {}154155        # adresse de la bannière (identique à la liste, sans code postal)156        addr = soup.select_one(".banner-property__address--text")157        if addr:158            payload["address"] = re.sub(r"\s+", " ",159                                        addr.get_text(" ", strip=True))160161        # description « À propos de la propriété » + texte « Quartier »162        blocs: list[str] = []163        about = soup.select_one(".property-about__content")164        if about:165            blocs.append(re.sub(r"\s+", " ", about.get_text(" ", strip=True)))166        quartier = soup.select_one(".property-neighbourhood__content")167        if quartier:168            blocs.append("Quartier : " + re.sub(169                r"\s+", " ", quartier.get_text(" ", strip=True)))170        payload["description"] = " | ".join(b for b in blocs if b)[:1500]171172        # téléphone du bureau de location (liens tel:)173        for a in soup.select('a[href^="tel:"]'):174            digits = re.sub(r"\D", "", a["href"])175            if len(digits) >= 10:176                d = digits[-10:]177                payload["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}"178                break179180        # galerie de l'immeuble (S3 RentSync) : variante /512/, dédupliquée181        gallery: list[str] = []182        seen: set[str] = set()183        for u in re.findall(184                r"https://s3\.amazonaws\.com/lws_lift/gestion_lr/images/"185                r"gallery/512/[^\"'\s]+", html):186            fname = u.rsplit("/", 1)[-1]187            if fname not in seen:188                seen.add(fname)189                gallery.append(u)190        payload["gallery"] = gallery[:20]191192        # tableau des unités disponibles (colonnes structurées column-field)193        units: list[dict] = []194        for row in soup.select("li.unit-table-desktop__units--unit"):195            cols = {li.get("column-field"): li196                    for li in row.select("li[column-field]")}197198            def txt(field: str) -> str:199                el = cols.get(field)200                return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else ""201202            unit = {203                "unit": txt("typeName"),204                "bath": txt("bath"),205                "rate": txt("rate"),206                "sqft": txt("sqFt"),207                "availability": txt("availability"),208            }209            photos_el = cols.get("photos")210            if photos_el:211                unit["photos"] = list(dict.fromkeys(212                    a["href"] for a in photos_el.select("a[href^='http']")))213            if unit["unit"] and unit["unit"] != "-":214                # colonnes « - » (non publiées) -> chaîne vide, jamais inventé215                for k, v in list(unit.items()):216                    if v == "-":217                        unit[k] = ""218                units.append(unit)219        payload["units"] = units220        return payload221