SPB Git

spb/lou-ka Public

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

HTML 99.7%
12.5 KB · 308 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/akelius.py : connecteur Akelius Residential (rent.akelius.com)5#   Page de recherche Canada rendue côté serveur (Angular SSR) : l'état6#   TransferState (<script id="akeliusWebsite-state">) contient le JSON complet7#   des unités canadiennes (adresse, loyer, pi², chambres, photos). On filtre8#   sur le Grand Montréal (Montréal, Westmount, Mont-Royal, Saint-Lambert,9#   Greenfield Park) — Toronto/Ottawa/Gatineau exclus.10#   Chaque unité a aussi un JSON détail (/lettings/marketing/v2/CA/<id>.json)11#   avec les keyfacts complets : inclusions (chauffage, eau chaude,12#   électricité, internet), électroménagers, animaux, meublé, ascenseur,13#   piscine, gym, année de construction, téléphone — visité via le cache14#   self.detail() (max ~150 nouveaux fetchs par synchronisation).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import json20import re2122from ..schema import Listing, strip_accents23from .base import BaseConnector2425BASE = "https://rent.akelius.com"26SEARCH_URL = f"{BASE}/en/search/canada/apartment/montreal"27DETAIL_URL = BASE + "/lettings/marketing/v2/CA/{uid}.json"28STATE_RE = re.compile(29    r'<script id="akeliusWebsite-state" type="application/json">(.*?)</script>',30    re.S)3132# Villes admissibles (Grand Montréal) -> (ville normalisée, secteur imposé)33_GM_CITIES = {34    "montreal": ("Montréal", None),          # secteur = borough du flux35    "westmount": ("Westmount", "Westmount"),36    "mont-royal": ("Mont-Royal", "Mont-Royal"),37    "saint-lambert": ("Saint-Lambert", "Saint-Lambert"),38    "greenfield park": ("Longueuil", "Greenfield Park"),39}4041_BED_TYPES = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}4243# keyfacts booléens du JSON détail -> libellé de commodité (si vrai)44_KF_AMENITIES = {45    "has-air-conditioning": "Climatisation",46    "has-central-air-conditioning": "Climatisation centrale",47    "has-balcony": "Balcon",48    "has-terrace": "Terrasse",49    "has-bicycle-racks": "Supports à vélos",50    "has-blinds": "Stores",51    "has-bosch-built-in-appliances": "Électroménagers encastrés Bosch",52    "has-canada-post-parcel-locker": "Casier à colis Postes Canada",53    "has-central-vacuuming-system": "Aspirateur central",54    "has-concierge": "Concierge",55    "has-dishwasher": "Lave-vaisselle",56    "has-dryer": "Sécheuse",57    "has-washing-machine": "Laveuse",58    "has-washer-dryer": "Laveuse-sécheuse",59    "has-elevator": "Ascenseur",60    "has-fitness-centre": "Salle d'entraînement",61    "has-heated-bathroom-floor": "Plancher de salle de bain chauffant",62    "has-indoor-pool": "Piscine intérieure",63    "has-outdoor-pool": "Piscine extérieure",64    "has-laundry-room": "Buanderie",65    "has-microwave": "Micro-ondes",66    "has-openplan-kitchen": "Cuisine à aire ouverte",67    "has-sauna": "Sauna",68    "has-wine-fridge": "Cellier à vin",69    "is-broadband-included-in-rent": "Internet inclus",70    "is-electricity-included-in-rent": "Électricité incluse",71    "is-gas-included-in-rent": "Gaz inclus",72    "is-heating-included-in-rent": "Chauffage inclus",73    "is-hot-water-included-in-rent": "Eau chaude incluse",74    "is-water-included-in-rent": "Eau incluse",75    "is-refurbished": "Rénové",76    "is-smart-home": "Logement intelligent",77}787980class _DetailBudget(Exception):81    """Budget de nouvelles pages détail épuisé pour cette synchronisation."""828384class AkeliusConnector(BaseConnector):85    source_id = "akelius"86    request_delay = 0.687    max_units = 400          # garde-fou88    max_details = 150        # nouveaux JSON détail max par synchronisation8990    def fetch(self) -> list[Listing]:91        html = self.get(SEARCH_URL).text92        m = STATE_RE.search(html)93        if not m:94            return []95        # TransferState Angular : les guillemets sont encodés « &q; »96        state = json.loads(m.group(1).replace("&q;", '"'))9798        # La clé du cache API est un hash variable : on repère la liste d'unités99        units: list[dict] = []100        for val in state.values():101            body = val.get("b") if isinstance(val, dict) else None102            if (isinstance(body, list) and body103                    and isinstance(body[0], dict) and "keyfacts" in body[0]):104                units = body105                break106107        self._detail_fetches = 0108        listings: list[Listing] = []109        for u in units[: self.max_units]:110            try:111                lst = self._unit_listing(u)112                if lst:113                    self._enrich(lst, u)114                    listings.append(lst)115            except Exception:116                continue117        return listings118119    # -- JSON détail (keyfacts complets) ---------------------------------------120    def _fetch_detail(self, uid: str) -> dict:121        try:122            resp = self.get(DETAIL_URL.format(uid=uid))123            data = json.loads(resp.content.decode("utf-8-sig"))124        except Exception:125            return {}126        if not isinstance(data, dict):127            return {}128        docs = data.get("documents") or []129        images = [d.get("mediumUrl") or d.get("originalImageUrl")130                  for d in docs if isinstance(d, dict) and not d.get("isExample")]131        return {132            "keyfacts": data.get("keyfacts") or {},133            "contact": data.get("contactDetails") or {},134            "images": [i for i in images if isinstance(i, str)][:30],135        }136137    def _enrich(self, lst: Listing, u: dict) -> None:138        """Complète l'annonce avec le JSON détail (via cache self.detail)."""139        key = hashlib.sha1(json.dumps(140            {"rent": (u.get("keyfacts") or {}).get("total-rent"),141             "avail": (u.get("keyfacts") or {}).get("available-from-date"),142             "pub": u.get("lastPublishedDate")},143            sort_keys=True).encode("utf-8")).hexdigest()144145        def fetch_fn():146            if self._detail_fetches >= self.max_details:147                raise _DetailBudget()148            self._detail_fetches += 1149            return self._fetch_detail(lst.external_id)150151        try:152            payload = self.detail(lst.external_id, key, fetch_fn)153        except _DetailBudget:154            return155        kf = payload.get("keyfacts") or {}156        if not kf:157            return158159        # commodités (libellés français, seulement les keyfacts vrais)160        for k, label in _KF_AMENITIES.items():161            if kf.get(k) and label not in lst.amenities:162                lst.amenities.append(label)163164        # animaux / meublé (valeurs structurées de la source)165        pets = str(kf.get("pets-allowed") or "").strip().lower()166        if pets == "yes":167            lst.pets = "oui"168        elif pets == "no":169            lst.pets = "non"170        elif pets:171            lst.pets = "conditions"172        furn = str(kf.get("furnished-state") or "").strip().lower()173        if furn == "furnished":174            lst.furnished = True175        elif furn == "unfurnished":176            lst.furnished = False177178        # details structurés (booléens explicites du JSON — jamais devinés)179        details: dict = {}180        inclusions = {}181        for src, dst in (("is-heating-included-in-rent", "heating"),182                         ("is-electricity-included-in-rent", "electricity"),183                         ("is-hot-water-included-in-rent", "hot_water"),184                         ("is-broadband-included-in-rent", "internet")):185            if isinstance(kf.get(src), bool):186                inclusions[dst] = kf[src]187        if inclusions:188            details["inclusions"] = inclusions189        appliances = {}190        if isinstance(kf.get("has-dishwasher"), bool):191            appliances["dishwasher"] = kf["has-dishwasher"]192        if isinstance(kf.get("has-washer-dryer"), bool):193            wd = kf["has-washer-dryer"] or (194                bool(kf.get("has-washing-machine")) and bool(kf.get("has-dryer")))195            appliances["washer_dryer"] = wd196        if appliances:197            details["appliances"] = appliances198        if isinstance(kf.get("has-air-conditioning"), bool):199            details["ac"] = (kf["has-air-conditioning"]200                             or bool(kf.get("has-central-air-conditioning")))201        if isinstance(kf.get("has-elevator"), bool):202            details["elevator"] = kf["has-elevator"]203        if isinstance(kf.get("has-balcony"), bool):204            details["balcony"] = kf["has-balcony"] or bool(kf.get("has-terrace"))205        if isinstance(kf.get("has-indoor-pool"), bool) or \206                isinstance(kf.get("has-outdoor-pool"), bool):207            details["pool"] = bool(kf.get("has-indoor-pool")) or \208                bool(kf.get("has-outdoor-pool"))209        if isinstance(kf.get("has-fitness-centre"), bool):210            details["gym"] = kf["has-fitness-centre"]211        if isinstance(kf.get("has-laundry-room"), bool):212            details["laundry"] = kf["has-laundry-room"]213        year = kf.get("construction-year")214        if isinstance(year, int):215            details["construction_year"] = year216        phone = ((payload.get("contact") or {}).get("phoneNumber") or "").strip()217        if phone:218            details["contact"] = {"phone": phone}219        if details:220            lst.details = details221222        # description libre éventuelle (keyfact « free-text »)223        free = str(kf.get("free-text") or "").strip()224        if free:225            lst.description = (lst.description + " — " + free)[:600] \226                if lst.description else free[:600]227228        # photos pleine résolution du détail (600 px au lieu de 400 px)229        if payload.get("images"):230            lst.images = payload["images"][:30]231232    def _unit_listing(self, u: dict) -> Listing | None:233        addr = u.get("address") or {}234        kf = u.get("keyfacts") or {}235        if (addr.get("province") or "").upper() != "QC":236            return None237        city_key = strip_accents((addr.get("city") or "").strip().lower())238        if city_key not in _GM_CITIES:239            return None240        city, forced_sector = _GM_CITIES[city_key]241        sector = forced_sector or (addr.get("borough") or "").strip()242243        uid = str(u.get("id") or "").strip()244        if not uid:245            return None246        street = (addr.get("streetName") or "").strip()247        postal = (addr.get("postalCode") or "").strip()248249        beds = kf.get("number-of-bedrooms")250        unit_type = _BED_TYPES.get(beds, "") if isinstance(beds, int) else ""251        apt_type = (kf.get("apartment-type") or "").strip()252        if not unit_type and apt_type == "loft":253            unit_type = "Loft"254255        rent = kf.get("total-rent")256        price = float(rent) if isinstance(rent, (int, float)) and rent else None257258        if kf.get("is-available-from-now-on"):259            availability = "Libre maintenant"260        else:261            availability = (kf.get("available-from-date") or "")[:10]262            if availability:263                availability = f"Disponible le {availability}"264265        size = kf.get("unit-size")266        baths = kf.get("number-of-bathrooms")267        floor = kf.get("floor")268        desc_bits = []269        if apt_type:270            desc_bits.append(f"Type : {apt_type}")271        if size:272            desc_bits.append(f"{size} pi²")273        if baths:274            desc_bits.append(f"{baths} salle(s) de bain")275        if floor is not None:276            desc_bits.append(f"étage {floor}")277        if kf.get("free-rent"):278            desc_bits.append(f"promotion : {kf['free-rent']}")279280        amenities = []281        if size:282            amenities.append(f"{size} pi²")283        if baths:284            amenities.append(f"{baths} sdb")285286        images = [i for i in (u.get("imageUrls") or [])287                  if isinstance(i, str) and i.startswith("http")][:30]288289        title = f"{street} — unité {uid.split('-')[-1]}" if street else uid290        return Listing(291            source=self.source_id,292            external_id=uid,293            url=f"{BASE}/en/search/canada/detail/{uid}",294            title=title,295            address=", ".join(x for x in [street, city, postal] if x),296            sector=sector,297            city=city,298            unit_type=unit_type,299            price=price,300            price_label=f"{int(rent)} $/mois" if price else "",301            availability=availability,302            description=" — ".join(desc_bits)[:600],303            amenities=amenities,304            images=images,305            lat=addr.get("latitude"),306            lng=addr.get("longitude"),307        )308