# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/omhm.py : OMHM — Office municipal d'habitation de Montréal. # Le HLM classique fonctionne par liste d'attente (non agrégeable) ; SEULE # la page « logements disponibles » (résidences abordables ENHARMONIE pour # aînés autonomes 60 ans +) liste de vraies unités. Site Drupal SSR, aucun # anti-bot. Chaque unité = un
# avec adresse civique complète et grille de prix par type (« 3 ½ entre # 1 357 $ et 1 497 $ »). Volume faible (~5) mais différenciateur social # (filtre « logement abordable » de Lou-Ka). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector URL = "https://www.omhm.qc.ca/fr/soumettre-une-demande/logements-disponibles" _POSTAL_RE = re.compile(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d") # « 3 ½ entre 1 357 $ et 1 497 $ » ou « studio 730 $ à 925 $ » ou « 4 ½ à 2 167 $ » _UNIT_PRICE_RE = re.compile( r"(studio|\d\s*½|\d\s*1/2)\s*(?:entre|à|de|:)?\s*" r"([\d\s]+)\s*\$(?:\s*(?:et|à|-)\s*([\d\s]+)\s*\$)?", re.I) class OmhmConnector(BaseConnector): source_id = "omhm" request_delay = 0.8 def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(URL).text, "html.parser") out: list[Listing] = [] for art in soup.select("article.node--type-logement-disponible"): title_el = art.find(re.compile("^h[2-4]$")) name = title_el.get_text(" ", strip=True) if title_el else "" full = re.sub(r"\s+", " ", art.get_text(" | ", strip=True)) # adresse : « 12100, rue Rodolphe-Forget | Montréal | H1E 7S2 » m = re.search(r"(\d+[^|]+?)\s*\|\s*([A-ZÉÈÀ][^|]+?)\s*\|\s*" r"(" + _POSTAL_RE.pattern + r")", full) address = city = postal = "" if m: address = m.group(1).strip(" ,") city = m.group(2).strip() postal = m.group(3).strip() slug = re.sub(r"[^a-z0-9]+", "-", (name or address).lower()).strip("-") or "omhm" # une annonce par type d'unité offert dans la résidence seen = set() for um in _UNIT_PRICE_RE.finditer(full): unit_type = normalize_unit_type(um.group(1)) if unit_type in seen: continue seen.add(unit_type) try: price = float(um.group(2).replace(" ", "")) except ValueError: continue pmax = um.group(3) label = (f"Entre {price:,.0f} $ et {float(pmax.replace(' ','')):,.0f} $" .replace(",", " ") if pmax else f"À partir de {price:,.0f} $".replace(",", " ")) out.append(Listing( source=self.source_id, external_id=f"{slug}-{unit_type}", url=URL, title=f"{name} — {unit_type}" if name else unit_type, address=address, city=city or "Montréal", unit_type=unit_type, price=price, price_label=label, availability="Disponible", details={"Code postal": postal, "Logement abordable": "oui", "Résidence pour aînés": "oui"} if postal else {"Logement abordable": "oui", "Résidence pour aînés": "oui"}, )) return out