SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.5 KB · 134 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/gestion_isr.py : connecteur Gestion ISR (gestion-isr.com)5#   Le site vitrine (SPA Vite/React) renvoie vers le portail public6#   location.gestion-isr.com, propulsé par Supabase. On lit l'URL et la clé7#   anonyme publiques embarquées dans le HTML du portail, puis l'API REST8#   /rest/v1/listings?statut=eq.Actif livre tout le parc actif en JSON9#   structuré (loyer, type, ville, GPS, photos, unités par immeuble).10#   2 requêtes par sync ; lien profond ?fiche=<id_app> par annonce.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re1516from ..schema import Listing, normalize_unit_type, strip_accents17from .base import BaseConnector1819PORTAL = "https://location.gestion-isr.com/"2021_URL_RE = re.compile(r"SUPABASE_URL\s*=\s*'([^']+)'")22_KEY_RE = re.compile(r"SUPABASE_ANON\s*=\s*'([^']+)'")232425def _pets_value(raw: str) -> str | None:26    """Champ « animaux » structuré -> oui/non/conditions (jamais deviné)."""27    k = strip_accents((raw or "").strip().lower())28    if not k:29        return None30    if k in ("aucun", "non") or k.startswith("non") or "refus" in k:31        return "non"32    if "animaux acceptes" in k or k in ("oui",):33        return "oui"34    # « Chat », « Chat, petit chien », « Petits compagnons »… = sous conditions35    if re.search(r"chat|chien|compagnon|condition|accepte", k):36        return "conditions"37    return None383940class GestionIsrConnector(BaseConnector):41    source_id = "gestion_isr"42    request_delay = 0.64344    def fetch(self) -> list[Listing]:45        html = self.get(PORTAL).text46        m_url, m_key = _URL_RE.search(html), _KEY_RE.search(html)47        if not (m_url and m_key):48            raise RuntimeError("clé/URL Supabase introuvables dans le portail")49        base, key = m_url.group(1).rstrip("/"), m_key.group(1)5051        rows = self.get(52            f"{base}/rest/v1/listings?select=*&statut=eq.Actif",53            headers={"apikey": key, "Authorization": f"Bearer {key}"},54        ).json()5556        listings: list[Listing] = []57        for row in rows:58            try:59                lst = self._parse_row(row)60            except Exception:61                continue62            if lst:63                listings.append(lst)64        return listings6566    def _parse_row(self, row: dict) -> Listing | None:67        ext_id = str(row.get("pk") or "")68        title = (row.get("titre") or "").strip()69        if not ext_id or not title:70            return None71        # exclusions : locaux commerciaux, stationnements, rangements72        if re.search(r"commercial|bureau|stationnement|rangement|entrep[ôo]t",73                     title, re.I):74            return None7576        id_app = (row.get("id_app") or "").strip()77        url = f"{PORTAL}?fiche={id_app}" if id_app else f"{PORTAL}#{ext_id}"7879        # loyer structuré (le plus bas des unités dispo pour un immeuble)80        loyer = row.get("loyer")81        price = float(loyer) if isinstance(loyer, (int, float)) else None8283        # GPS : champ « adresse_geo » = "45.754…, -72.501…"84        lat = lng = None85        geo = (row.get("adresse_geo") or "").split(",")86        if len(geo) == 2:87            try:88                lat, lng = float(geo[0]), float(geo[1])89            except ValueError:90                lat = lng = None9192        # description source + inventaire des unités disponibles (immeubles)93        description = (row.get("description") or "").strip()94        units = row.get("units") or []95        dispo = [u for u in units if (u or {}).get("statut") == "Disponible"]96        if dispo:97            groups: dict[tuple, int] = {}98            for u in dispo:99                groups[(u.get("type") or "", u.get("prix"))] = \100                    groups.get((u.get("type") or "", u.get("prix")), 0) + 1101            lines = [f"• {n} × {t} à {p} $" if n > 1 else f"• {t} à {p} $"102                     for (t, p), n in sorted(groups.items()) if t and p]103            if lines:104                description += "\n\nUnités disponibles :\n" + "\n".join(lines)105106        images = [u for u in (row.get("photos") or []) if str(u).startswith("http")]107        if not images:108            # repli : albums par modèle d'unité (photo_variants), puis main_photo109            for var in row.get("photo_variants") or []:110                for u in ([var.get("main")] + list(var.get("photos") or [])):111                    if str(u or "").startswith("http") and u not in images:112                        images.append(u)113        if not images and str(row.get("main_photo") or "").startswith("http"):114            images = [row["main_photo"]]115116        return Listing(117            source=self.source_id,118            external_id=ext_id,119            url=url,120            title=title,121            address=(row.get("id") or "").strip(),   # « id » = adresse civique complète122            sector=(row.get("secteur") or "").strip(),123            city=(row.get("ville") or "").strip(),124            unit_type=normalize_unit_type(row.get("grandeur") or ""),125            price=price,126            availability=(row.get("date_dispo_affichage")127                          or row.get("date_dispo") or "").strip(),128            pets=_pets_value(row.get("animaux") or ""),129            description=description[:2000],130            images=images[:30],131            lat=lat,132            lng=lng,133        )134