SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
12.6 KB · 297 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/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&amp;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