Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/kangalou.py : Kangalou (kangalou.com) — portail locatif québécois5# géré (Solutions Kangalou, ~8 700 annonces province entière). Découverte en6# UNE requête : l'endpoint carte `POST /fr/recherche/ajax/search-map/`7# (pinsOnly=true) renvoie tous les pins JSON — id, URL de fiche, GPS, ville,8# type (ti/tn ; 10=Commercial et 11=Garage exclus), « n ½ » (an), prix (mc),9# photo et l'état loué (ir, exclu). La fiche /fr/annonce/<slug>/<id>/ (SSR,10# aucun anti-bot) complète : adresse civique, chambres/salles de bain, loyer,11# étage, superficie, disponibilité, description longue, sections « toggler »12# (Inclusions, Caractéristiques, Animaux, Sécurité, À proximité, Transport…)13# et la galerie COMPLÈTE pleine résolution (gr-modal-gallery-src, sans14# paramètre de redimensionnement) — via TtlDetailCache (budget/TTL). Le15# budget par défaut couvre tout le parc (~8 000 fiches) : le premier sync est16# long (~1 h 30), les suivants ne re-visitent que les fiches périmées (TTL).17# L'API de liste HTML (POST /fr/api/search) existe aussi mais pagine par 2918# et masque l'état loué (CSS) : les pins sont la source fiable.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import os23import re2425from ..schema import Listing, normalize_unit_type26from .base import BaseConnector27from . import _detailutil as du2829BASE = "https://www.kangalou.com"30PINS_API = f"{BASE}/fr/recherche/ajax/search-map/"3132DETAIL_LIMIT = int(os.environ.get("LOUKA_KANGALOU_DETAIL_LIMIT", "9000"))33TTL_DAYS = float(os.environ.get("LOUKA_KANGALOU_TTL_DAYS", "7"))34MAX_PINS = int(os.environ.get("LOUKA_KANGALOU_MAX", "0")) # 0 = tout3536# type id -> type d'unité par défaut (le « n ½ » du pin prime quand présent)37_TYPES = {1: "", 2: "Condo", 3: "Maison", 4: "Chambre", 5: "Chalet",38 6: "Studio", 7: "Loft"}39_EXCLUDED_TI = {10, 11} # Commercial, Garage : pas du résidentiel4041# fiche détail (SSR)42_GPS_RE = re.compile(r'maps/search/\?query=(-?\d+\.\d+)%2C(-?\d+\.\d+)')43_ADDR_RE = re.compile(r'buds--separator[^>]*u-text-size-sm">\s*<span>([^<]+)</span>', re.S)44# galerie : pleine taille dans gr-modal-gallery-src (les <img> sont lazyload) ;45# hébergement variable (images/glide kangalou OU s3 shareimmo/lws…)46_GALLERY_RE = re.compile(r'gr-modal-gallery-src="(https?://[^"]+)"')47_FLAT_FIELD = {48 "loyer": re.compile(r"Loyer:\s*\|\s*([\d\s ,]+\$\s*/\s*mois)"),49 "etage": re.compile(r"Étage:\s*\|\s*(\d{1,2})\b"),50 "sdb": re.compile(r"Nombre de salles? de bain:\s*\|\s*(\d+(?:[.,]5)?)"),51 "chambres": re.compile(r"Nombre de chambres:\s*\|\s*(\d+)"),52 "superficie": re.compile(r"Superficie:\s*\|\s*([\d\s ,.]+)\s*m\s*\|\s*2"),53 "dispo": re.compile(r"Disponibilité:\s*\|\s*([^|]{1,60})"),54}55# description : le titre varie selon le type (« Description du logement /56# du condo / de la maison / du studio… à louer ») — matcher générique.57# Bloc « typography » complet ; borne préférée = le déclencheur « Voir plus »58# (gr-read-more-trigger) pour survivre aux <div> imbriqués, sinon repli sur59# le premier </div> (bloc simple <p>…</p>)60_DESC_FULL_RE = re.compile(61 r'Description d[^<]{0,60}</h2>.*?<div class="typography"[^>]*>'62 r'(.*?)</div>\s*<div gr-read-more-trigger', re.S)63_DESC_RE = re.compile(64 r'Description d[^<]{0,60}</h2>.*?<div class="typography"[^>]*>(.*?)</div>',65 re.S)6667# sections « toggler » de la fiche : <div … gr-toggler-trigger>Titre</div>68# suivi d'items <p><strong>libellé</strong>[<span>valeurs</span>]</p>69_SECTION_ITEM_RE = re.compile(70 r"<p>\s*<strong>(.*?)</strong>(?:\s*<span>(.*?)</span>)?", re.S)71# sections « à proximité » -> details ; le reste (Inclusions, Caractéristiques72# intérieures/extérieures, Détail, Animaux, Sécurité, Buanderie, Autres73# services, Critères importants…) -> amenities74_PROXIMITY_TITLES = {"À proximité", "Transport"}75_PETS_NO = {"Aucun animal autorisé", "Aucun animal accepté", "Animaux refusés"}76_PETS_YES = {"Animaux acceptés", "Animaux autorisés", "Tous animaux autorisés"}777879def _clean_text(fragment: str) -> str:80 """HTML -> texte propre (br/p/li = sauts de ligne, entités décodées)."""81 import html as _h82 txt = re.sub(r"<br\s*/?>", "\n", fragment)83 txt = re.sub(r"</(?:p|li|div|h\d)>", "\n", txt)84 txt = re.sub(r"<li[^>]*>", "- ", txt)85 txt = re.sub(r"<[^>]+>", " ", txt)86 txt = _h.unescape(txt)87 txt = re.sub(r"[ \t]+", " ", txt)88 txt = re.sub(r" ?\n ?", "\n", txt)89 return re.sub(r"\n{3,}", "\n\n", txt).strip()909192def _toggler_sections(html: str) -> list[tuple[str, list[tuple[str, str]]]]:93 """[(titre de section, [(libellé, valeur)])] depuis les blocs toggler."""94 import html as _h95 out = []96 for seg in re.split(r"gr-toggler-trigger>", html)[1:]:97 title = _h.unescape(seg.split("<", 1)[0]).strip()98 items = []99 for lab, val in _SECTION_ITEM_RE.findall(seg):100 lab = _h.unescape(re.sub(r"<[^>]+>", " ", lab)).strip()101 val = _h.unescape(re.sub(r"<[^>]+>", " ", val or "")).strip()102 if lab and len(lab) <= 60:103 items.append((lab, val))104 if title and items:105 out.append((title, items))106 return out107108109def _parse_fiche(html: str) -> dict:110 """Champs riches d'une fiche Kangalou (SSR)."""111 out: dict = {}112 m = _GPS_RE.search(html)113 if m:114 out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))115 m = _ADDR_RE.search(html)116 if m:117 import html as _h118 # certaines fiches double-encodent (« Carri&egrave;res ») : deux passes119 out["address"] = _h.unescape(_h.unescape(m.group(1))).strip()120121 flat = du.flatten(html)122 vals = {k: rx.search(flat) for k, rx in _FLAT_FIELD.items()}123 if vals["loyer"]:124 label = re.sub(r"\s+", " ", vals["loyer"].group(1)).strip()125 out["price_label"] = label126 try:127 out["price"] = float(re.sub(r"[^\d,.]", "", label.split("$")[0])128 .replace(",", ""))129 except ValueError:130 pass131 if not (100 <= (out.get("price") or 0) <= 20000):132 out.pop("price", None)133 if vals["chambres"]:134 out["bedrooms"] = float(vals["chambres"].group(1))135 if vals["sdb"]:136 out["bathrooms"] = float(vals["sdb"].group(1).replace(",", "."))137 if vals["dispo"]:138 out["availability"] = vals["dispo"].group(1).strip(" .")139 if vals["etage"]:140 out.setdefault("details", {})["floor"] = int(vals["etage"].group(1))141 if vals["superficie"]:142 try:143 m2 = float(vals["superficie"].group(1)144 .replace(" ", "").replace(" ", "").replace(",", "."))145 if 8 <= m2 <= 2000:146 out["area_sqft"] = round(m2 * 10.7639)147 except ValueError:148 pass149150 m = _DESC_FULL_RE.search(html) or _DESC_RE.search(html)151 if m:152 desc = _clean_text(m.group(1))153 if desc:154 out["description"] = desc[:6000]155156 # sections « toggler » : commodités structurées + proximité + animaux.157 # Libellés jamais interprétés (texte source) ; « libellé : valeurs »158 # quand la section fournit des valeurs (ex. Type de plancher : Céramique).159 amen: list[str] = []160 proximity: dict[str, str] = {}161 pets_labels: list[str] = []162 for title, items in _toggler_sections(html):163 if title in _PROXIMITY_TITLES:164 for lab, val in items:165 proximity[lab] = val166 continue167 for lab, val in items:168 entry = f"{lab} : {val}" if val else lab169 if entry not in amen:170 amen.append(entry)171 if title == "Animaux":172 pets_labels += [lab for lab, _ in items]173 if amen:174 out["amenities"] = amen175 if proximity:176 out.setdefault("details", {})["proximity"] = proximity177178 # animaux / meublé : depuis les libellés structurés seulement (le texte179 # libre est laissé à la normalisation centrale)180 if pets_labels:181 if any(l in _PETS_NO for l in pets_labels):182 out["pets"] = "non"183 elif any(l in _PETS_YES for l in pets_labels):184 out["pets"] = "oui"185 else: # chat seulement, petit chien, en cage…186 out["pets"] = "conditions"187 if "Meublé" in amen:188 out["furnished"] = True189190 # galerie (slider--gallery ; les vignettes asnav dupliquées sont exclues)191 ig = html.find("slider--gallery")192 if ig >= 0:193 seg = html[ig:]194 cut = seg.find("slider__asnav")195 if cut > 0:196 seg = seg[:cut]197 photos = []198 for u in _GALLERY_RE.findall(seg):199 if u not in photos:200 photos.append(u)201 if photos:202 out["images"] = photos203204 vt = du.virtual_tour(html)205 if vt:206 out.setdefault("details", {})["virtual_tour"] = vt207 return out208209210class KangalouConnector(BaseConnector):211 source_id = "kangalou"212 request_delay = 0.5213 timeout = 90 # la réponse pins fait ~6 Mo214215 def _pins(self) -> list[dict]:216 import time217 import requests218 # le POST n'a pas l'escalade résiliente de get() : retenter les219 # lenteurs transitoires du site (ReadTimeout constaté 2026-08-24)220 for attempt in range(3):221 try:222 resp = self.post(PINS_API, data={"pinsOnly": "true"},223 headers={"Accept": "application/json",224 "X-Requested-With": "XMLHttpRequest"})225 break226 except (requests.ConnectionError, requests.Timeout):227 if attempt == 2:228 raise229 time.sleep(15 * (attempt + 1))230 return (resp.json() or {}).get("pins") or []231232 @staticmethod233 def _unit_type(pin: dict) -> str:234 an = (pin.get("an") or "").strip() # « 4 ½ », « 9 ½ + »235 if an:236 ut = normalize_unit_type(an)237 if re.match(r"^\d½$|^6½\+$", ut or ""):238 return ut239 return _TYPES.get(pin.get("ti"), "")240241 def fetch(self) -> list[Listing]:242 pins = self._pins()243 if MAX_PINS:244 pins = pins[:MAX_PINS]245 # key=v2 : parseur enrichi 2026-08-18 (galerie complète déjà en v1 ;246 # ajout loyer/pets/meublé/proximité + description multi-blocs) — force247 # une re-visite du parc complet une seule fois248 cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,249 key="v2")250 out: list[Listing] = []251 try:252 for pin in pins:253 if pin.get("ir"):254 continue # déjà loué : pas une offre active255 if pin.get("ti") in _EXCLUDED_TI or not pin.get("id"):256 continue # commercial / garage / malformé257 url = pin.get("l") or ""258 if not url:259 continue260 eid = str(pin["id"])261 price = None262 try:263 price = float(str(pin.get("mc")).replace(",", ""))264 except (TypeError, ValueError):265 pass266 if price is not None and not (100 <= price <= 20000):267 price = None # affichage « sur demande » etc.268 lat = lng = None269 try:270 lat, lng = float(pin["lt"]), float(pin["lg"])271 except (KeyError, TypeError, ValueError):272 pass273 photo = pin.get("p") or ""274 lst = Listing(275 source=self.source_id,276 external_id=eid,277 url=url,278 title=(pin.get("n") or "").strip(),279 city=(pin.get("c") or "").strip(),280 unit_type=self._unit_type(pin),281 price=price,282 price_label=(f"{price:,.0f} $/mois".replace(",", " ")283 if price else ""),284 images=[photo] if photo else [],285 lat=lat,286 lng=lng,287 )288 payload = cache.get(eid, url, _parse_fiche)289 if payload:290 du.apply_detail(lst, payload)291 if payload.get("address"):292 lst.title = payload["address"]293 out.append(lst)294 finally:295 cache.close()296 return out297