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/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("’", "’").replace(" ", " ")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