SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.9 KB · 197 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/shdm.py : connecteur SHDM — Société d'habitation et de5#   développement de Montréal (shdm.org, logements abordables à Montréal).6#   Front Nuxt 3 + WordPress GraphQL : la page /fr/logements-disponibles7#   embarque les logements disponibles dans le payload __NUXT_DATA__8#   (format « devalue » : tableau plat où les valeurs sont des index).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import json13import re1415from ..schema import Listing, normalize_unit_type16from .base import BaseConnector1718BASE = "https://www.shdm.org"19LIST_URL = f"{BASE}/fr/logements-disponibles"2021# Champs booléens du CPT « logement » -> libellé de commodité22_AMENITY_FLAGS = {23    "chauffage": "Chauffage inclus",24    "electricite": "Électricité incluse",25    "eauChaude": "Eau chaude incluse",26    "balcon": "Balcon",27    "portePatio": "Porte-patio",28    "plancherBois": "Plancher de bois",29    "deuxEtage": "Sur deux étages",30    "adapte": "Logement adapté",31    "handicape": "Accessible (mobilité réduite)",32    "positionFenetreEst": "Fenêtres à l'est",33    "positionFenetreNord": "Fenêtres au nord",34    "positionFenetreOuest": "Fenêtres à l'ouest",35    "positionFenetreSud": "Fenêtres au sud",36}3738_BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}394041def _strip_html(s: str) -> str:42    s = re.sub(r"<[^>]+>", " ", s or "")43    s = s.replace("&rsquo;", "’").replace("&nbsp;", " ")44    s = re.sub(r"&[a-z]+;|&#\d+;", " ", s)45    return re.sub(r"\s+", " ", s).strip()464748class _Devalue:49    """Résolution des références du payload Nuxt 3 (format devalue)."""5051    def __init__(self, data: list):52        self.data = data5354    def resolve(self, ref, seen: frozenset = frozenset()):55        if not isinstance(ref, int) or not (0 <= ref < len(self.data)):56            return ref57        if ref in seen:58            return None59        seen = seen | {ref}60        val = self.data[ref]61        if isinstance(val, dict):62            return {k: self.resolve(v, seen) for k, v in val.items()}63        if isinstance(val, list):64            return [self.resolve(v, seen) for v in val]65        return val666768class SHDMConnector(BaseConnector):69    source_id = "shdm"70    request_delay = 0.67172    @staticmethod73    def _media_urls(medias) -> list[str]:74        """[{media:{node:{generic: url, ...}}}] -> URLs pleine taille."""75        urls: list[str] = []76        for m in medias or []:77            node = ((m or {}).get("media") or {}).get("node") or {}78            u = node.get("generic") or node.get("genericDesktop") or ""79            if u:80                urls.append(u)81        return urls8283    def fetch(self) -> list[Listing]:84        listings: list[Listing] = []85        try:86            html = self.get(LIST_URL).text87        except Exception:88            return listings8990        m = re.search(r'id="__NUXT_DATA__"[^>]*>(.*?)</script>', html, re.S)91        if not m:92            return listings93        try:94            data = json.loads(m.group(1))95        except ValueError:96            return listings97        if not isinstance(data, list):98            return listings99        dv = _Devalue(data)100101        seen_ids: set[str] = set()102        for i, raw in enumerate(data):103            if not (isinstance(raw, dict) and "logementFields" in raw104                    and "uri" in raw):105                continue106            try:107                node = dv.resolve(i)108                slug = node.get("slug") or ""109                lf = node.get("logementFields") or {}110                if not slug or slug in seen_ids or not isinstance(lf, dict):111                    continue112113                imm_nodes = ((lf.get("immeuble") or {}).get("nodes")) or [{}]114                imm = imm_nodes[0] if isinstance(imm_nodes[0], dict) else {}115                bf = imm.get("buildingFields") or {}116117                building_title = (imm.get("title") or "").strip()118                address = (bf.get("adresse") or building_title).strip()119                quartier_nodes = ((bf.get("quartier") or {}).get("nodes")) or []120                sector = (quartier_nodes[0].get("title") or "").strip() \121                    if quartier_nodes and isinstance(quartier_nodes[0], dict) else ""122123                unit_no = str(lf.get("numeroLogement") or "").strip()124                typologie = lf.get("typologie") or []125                typ_raw = typologie[0] if typologie else ""126                unit_type = normalize_unit_type(typ_raw) or \127                    _BEDROOMS_TO_TYPE.get(lf.get("chambre"), "")128129                price = lf.get("prix")130                price = float(price) if isinstance(price, (int, float)) else None131                price_label = f"{price:,.0f}".replace(",", " ") + " $ / mois" \132                    if price else ""133                if re.search(r"stationnement|commercial", typ_raw, re.I):134                    continue135136                amenities = [label for key, label in _AMENITY_FLAGS.items()137                             if lf.get(key)]138                # nombre de chambres (champ structuré du CPT)139                chambres = lf.get("chambre")140                if isinstance(chambres, int) and chambres > 0:141                    amenities.append(f"{chambres} chambre" +142                                     ("s" if chambres > 1 else ""))143                # clientèle cible (taxonomie « autonomie » : Pour tous, 60+, …)144                auto_edges = ((lf.get("autonomie") or {}).get("edges")) or []145                for edge in auto_edges:146                    name = (((edge or {}).get("node")) or {}).get("name") or ""147                    if name:148                        amenities.append(f"Clientèle : {name}")149                        break150151                # étage (champ structuré ; 0 = rez-de-chaussée -> non exposé)152                details: dict = {}153                etage = lf.get("etage")154                if isinstance(etage, int) and etage > 0:155                    details["floor"] = etage156                # NB : les booléens d'inclusion du CPT (chauffage, electricite,157                # eauChaude) sont peu fiables à false (cases non cochées alors158                # que la description de l'immeuble dit « inclus ») -> on ne159                # les publie qu'à true, via _AMENITY_FLAGS ci-dessus.160161                # date de disponibilité ISO structurée ("2026-09-01T00:00:00+00:00")162                date_raw = (lf.get("date") or "")[:10]163164                # images : photos du logement + photo(s) de l'immeuble165                images = (self._media_urls(lf.get("medias"))166                          + self._media_urls(bf.get("medias")))167168                title = building_title or address169                if unit_no:170                    title = f"{title} — app. {unit_no}"171172                # NB : les URI WordPress /fr/logement/<slug> répondent 404173                # (les fiches s'ouvrent en modale) -> on pointe vers la liste,174                # avec le slug en ancre pour garder une URL unique et valide.175                listings.append(Listing(176                    source=self.source_id,177                    external_id=slug,178                    url=f"{LIST_URL}#{slug}",179                    title=title,180                    address=address,181                    sector=sector,182                    city="Montréal",     # SHDM = société paramunicipale de Mtl183                    unit_type=unit_type,184                    price=price,185                    price_label=price_label,186                    availability=date_raw,187                    description=_strip_html(bf.get("content") or "")[:600],188                    amenities=amenities,189                    details=details,190                    images=list(dict.fromkeys(u for u in images if u))[:25],191                ))192                seen_ids.add(slug)193            except Exception:194                continue195196        return listings197