SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.5 KB · 181 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/logis_experts.py : connecteur Logis-Experts (logis-experts.com)5#   Gestionnaire-constructeur, centre-ville de Québec + Lévis (~190 immeubles).6#   Site Laravel/Alpine avec API JSON propre :7#     GET /api/search -> {"buildings": [{address{lat,lng,zipcode}, all_images,8#                          apartments: [{number, type, price, available_type}]}]}9#   La page détail d'une unité (/immeuble/<handle>/appartement/<n>) ajoute10#   superficie, date de disponibilité, frais d'énergie et commodités — visitée11#   via le cache self.detail() (seulement si l'unité est nouvelle/modifiée).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from ..schema import Listing, infer_city18from .base import BaseConnector1920BASE = "https://logis-experts.com"21API_SEARCH = f"{BASE}/api/search"2223# Ville renvoyée par l'API (en minuscules) -> nom d'affichage24CITY_MAP = {25    "quebec": "Québec", "québec": "Québec", "levis": "Lévis", "lévis": "Lévis",26    "saint-augustin-de-desmaures": "Saint-Augustin-de-Desmaures",27}2829# « 3.5+office » -> « 3½ », « maisondeville2chambres » -> « Maison », etc.30_TYPE_DEMI_RE = re.compile(r"^(\d)\.5")3132_TAG_RE = re.compile(r"<[^>]+>")33_SEP = r"[§\s]+"    # les balises deviennent « § » ; tolérer § et espaces mêlés34_AREA_RE = re.compile(rf"Superficie{_SEP}([\d\s]+?)\s*pi{_SEP}2", re.I)35_AVAIL_RE = re.compile(r"Disponible à partir du\s+([^§<]{3,40})", re.I)36_ENERGY_RE = re.compile(rf"Frais d'énergie{_SEP}([^§]{{3,60}})", re.I)37# sections « Services », « Commodités », « Télécommunications » : puces « - item »38_AMEN_ITEM_RE = re.compile(r"- ([^§]{2,60}?) §")394041def _unit_type(raw: str) -> str:42    s = (raw or "").strip().lower()43    m = _TYPE_DEMI_RE.match(s)44    if m:45        return f"{m.group(1)}½"46    if "maisondeville" in s:47        return "Maison"48    if "loft" in s:49        return "Loft"50    if "studio" in s or "bachelor" in s:51        return "Studio"52    if "chambre" in s:53        return "Chambre"54    return raw.strip()555657class _DetailBudgetExceeded(Exception):58    """Plafond de pages détail atteint pour cette sync (repris à la suivante)."""596061class LogisExpertsConnector(BaseConnector):62    source_id = "logis_experts"63    request_delay = 0.864    max_details = 150           # garde-fou : vraies visites détail par sync6566    def __init__(self) -> None:67        super().__init__()68        self._n_details = 06970    # -- page détail d'une unité ----------------------------------------------71    def _fetch_detail(self, url: str) -> dict:72        """Extrait superficie / disponibilité / énergie / commodités du HTML.7374        Seules les vraies requêtes comptent contre max_details : les unités75        déjà en cache (self.detail) ne passent pas par ici.76        """77        if self._n_details >= self.max_details:78            raise _DetailBudgetExceeded(url)79        self._n_details += 180        import html as _entities81        html = _entities.unescape(self.get(url).text)82        # texte aplati : les balises deviennent des « § » pour garder les83        # frontières de champs sans dépendre de la structure exacte84        flat = html.replace(" ", " ")85        flat = re.sub(r"\s+", " ", _TAG_RE.sub(" § ", flat))86        out: dict = {}87        m = _AREA_RE.search(flat)88        if m:89            try:90                out["area_sqft"] = float(m.group(1).replace(" ", ""))91            except ValueError:92                pass93        m = _AVAIL_RE.search(flat)94        if m:95            out["availability"] = m.group(1).strip(" .§")96        m = _ENERGY_RE.search(flat)97        if m:98            out["energy"] = m.group(1).strip(" .§")   # ex. « Chauffé & Éclairé »99        # commodités : entre le premier titre de section et « Suivez-nous »100        start = None101        for titre in ("Services", "Commodités", "Télécommunications"):102            j = flat.find(titre)103            if j >= 0 and (start is None or j < start):104                start = j105        if start is not None:106            window = flat[start:start + 2500]107            fin = window.find("Suivez-nous")108            if fin > 0:109                window = window[:fin]110            items = [it.strip() for it in _AMEN_ITEM_RE.findall(window)]111            if items:112                out["amenities"] = list(dict.fromkeys(items))113        return out114115    def fetch(self) -> list[Listing]:116        data = self.get(API_SEARCH,117                        headers={"X-Requested-With": "XMLHttpRequest"}).json()118        listings: list[Listing] = []119        self._n_details = 0120121        for b in data.get("buildings") or []:122            addr = b.get("address") or {}123            handle = b.get("handle") or ""124            images = []125            for img in b.get("all_images") or []:126                u = (img or {}).get("url")127                if u:128                    images.append(u)129            city_raw = (addr.get("city") or "").strip().lower()130            city = CITY_MAP.get(city_raw, (addr.get("city") or "").title())131            sector = addr.get("district_name") or ""132            civic = addr.get("address") or ""133            zipcode = addr.get("zipcode") or ""134            full_addr = ", ".join(x for x in (civic, city, zipcode) if x)135            lat = addr.get("latitude")136            lng = addr.get("longitude")137            b_name = b.get("name") or civic138139            for a in b.get("apartments") or []:140                if (a.get("available_type") or "").upper() != "AVAILABLE":141                    continue142                num = str(a.get("number") or "").strip()143                ext_id = str(a.get("id") or f"{handle}-{num}")144                url = f"{BASE}/immeuble/{handle}/appartement/{num}"145                price = a.get("price")146                price = float(price) if isinstance(price, (int, float)) and price else None147148                # page détail (superficie, dispo, énergie, commodités) — via149                # cache : re-visitée seulement si le contenu liste a changé150                key = f"{price}|{a.get('type')}|{a.get('available_type')}"151                try:152                    info = self.detail(ext_id, key,153                                       lambda u=url: self._fetch_detail(u))154                except Exception:   # budget épuisé ou page détail en erreur155                    info = {}156157                amenities = list(info.get("amenities") or [])158                if info.get("energy"):159                    amenities.insert(0, info["energy"])160161                listings.append(Listing(162                    source=self.source_id,163                    external_id=ext_id,164                    url=url,165                    title=f"{b_name} — app. {num}" if num else b_name,166                    address=full_addr,167                    sector=sector,168                    city=infer_city(sector, default=city or "Québec"),169                    unit_type=_unit_type(a.get("type") or ""),170                    price=price,171                    price_label=f"{price:.0f} $ /mois" if price else "",172                    availability=info.get("availability", ""),173                    area_sqft=info.get("area_sqft"),174                    description="",175                    amenities=amenities,176                    images=images[:15],177                    lat=float(lat) if lat else None,178                    lng=float(lng) if lng else None,179                ))180        return listings181