Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/omhm.py : OMHM — Office municipal d'habitation de Montréal.5# Le HLM classique fonctionne par liste d'attente (non agrégeable) ; SEULE6# la page « logements disponibles » (résidences abordables ENHARMONIE pour7# aînés autonomes 60 ans +) liste de vraies unités. Site Drupal SSR, aucun8# anti-bot. Chaque unité = un <article class="node--type-logement-disponible">9# avec adresse civique complète et grille de prix par type (« 3 ½ entre10# 1 357 $ et 1 497 $ »). Volume faible (~5) mais différenciateur social11# (filtre « logement abordable » de Lou-Ka).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type20from .base import BaseConnector2122URL = "https://www.omhm.qc.ca/fr/soumettre-une-demande/logements-disponibles"2324_POSTAL_RE = re.compile(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d")25# « 3 ½ entre 1 357 $ et 1 497 $ » ou « studio 730 $ à 925 $ » ou « 4 ½ à 2 167 $ »26_UNIT_PRICE_RE = re.compile(27 r"(studio|\d\s*½|\d\s*1/2)\s*(?:entre|à|de|:)?\s*"28 r"([\d\s]+)\s*\$(?:\s*(?:et|à|-)\s*([\d\s]+)\s*\$)?", re.I)293031class OmhmConnector(BaseConnector):32 source_id = "omhm"33 request_delay = 0.83435 def fetch(self) -> list[Listing]:36 soup = BeautifulSoup(self.get(URL).text, "html.parser")37 out: list[Listing] = []38 for art in soup.select("article.node--type-logement-disponible"):39 title_el = art.find(re.compile("^h[2-4]$"))40 name = title_el.get_text(" ", strip=True) if title_el else ""41 full = re.sub(r"\s+", " ", art.get_text(" | ", strip=True))4243 # adresse : « 12100, rue Rodolphe-Forget | Montréal | H1E 7S2 »44 m = re.search(r"(\d+[^|]+?)\s*\|\s*([A-ZÉÈÀ][^|]+?)\s*\|\s*"45 r"(" + _POSTAL_RE.pattern + r")", full)46 address = city = postal = ""47 if m:48 address = m.group(1).strip(" ,")49 city = m.group(2).strip()50 postal = m.group(3).strip()5152 slug = re.sub(r"[^a-z0-9]+", "-",53 (name or address).lower()).strip("-") or "omhm"54 # une annonce par type d'unité offert dans la résidence55 seen = set()56 for um in _UNIT_PRICE_RE.finditer(full):57 unit_type = normalize_unit_type(um.group(1))58 if unit_type in seen:59 continue60 seen.add(unit_type)61 try:62 price = float(um.group(2).replace(" ", ""))63 except ValueError:64 continue65 pmax = um.group(3)66 label = (f"Entre {price:,.0f} $ et {float(pmax.replace(' ','')):,.0f} $"67 .replace(",", " ") if pmax68 else f"À partir de {price:,.0f} $".replace(",", " "))69 out.append(Listing(70 source=self.source_id,71 external_id=f"{slug}-{unit_type}",72 url=URL,73 title=f"{name} — {unit_type}" if name else unit_type,74 address=address,75 city=city or "Montréal",76 unit_type=unit_type,77 price=price,78 price_label=label,79 availability="Disponible",80 details={"Code postal": postal,81 "Logement abordable": "oui",82 "Résidence pour aînés": "oui"} if postal else83 {"Logement abordable": "oui",84 "Résidence pour aînés": "oui"},85 ))86 return out87