spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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