# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/hapopex.py : HAPOPEX (hapopex.org) — OBNL d'habitation montréalais # (parcs à Montréal-Nord, Lachine, Côte-des-Neiges…), page « Logements à # louer » avec une carte par parc d'habitation. # Techno : Wix rendu côté serveur. ⚠️ Les slugs des fiches sont trompeurs # (copy-of-industriel-1-… = fiche ARCHEVÊQUE, etc.) : on apparie carte -> # fiche PAR POSITION dans le document, jamais par le slug. La fiche fournit # le quartier et les « Types d'unités » (pas de prix ni d'adresse civique). # Granularité : PARC D'HABITATION (une annonce par parc). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as _html import re import requests from .base import BaseConnector from ..schema import Listing, strip_accents BASE = "https://www.hapopex.org" LIST_URL = f"{BASE}/logements-a-louer" # ancre « En savoir plus » de chaque carte (ordre du document) _CARD_RE = re.compile( r']+href="(https://www\.hapopex\.org/[^"]+)"[^>]*>' r'(?:(?!).)*?En savoir plus') # dans le texte qui précède l'ancre : « Nom | Quartier X | N logements » _CARD_TXT_RE = re.compile( r'([^|]{2,60})\|\s*\|(?:Quartier)?\s*([^|]{2,40})\|\s*(\d+)\s*logements?\s*\|?\s*$') _IMG_RE = re.compile(r']+src="(https://static\.wixstatic\.com/media/[^"]+)"') _TYPES_RE = re.compile(r"Types d'unités\s*:?\s*\|?([^|]*(?:\|[^|]*){0,6})") _TYPE_TOKEN_RE = re.compile(r'\d\s*½|\bstudio\b', re.I) def _slug(name: str) -> str: s = strip_accents(name.lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") def _to_text(seg: str) -> str: txt = _html.unescape(re.sub(r"<[^>]+>", "|", seg)) return re.sub(r"\|+", "|", txt) class HapopexConnector(BaseConnector): """HAPOPEX — une annonce par parc d'habitation (Montréal).""" source_id = "hapopex" request_delay = 0.8 def _fiche(self, url: str) -> dict: """Fiche du parc : types d'unités + photos (JSON-sérialisable).""" h = re.sub(r"\s+", " ", self.get(url).text) h = re.sub(r"", "", h) txt = _to_text(h) types: list[str] = [] m = _TYPES_RE.search(txt) if m: for tok in _TYPE_TOKEN_RE.findall(m.group(1)): t = re.sub(r"[\s ​]+", "", tok) t = "Studio" if t.lower() == "studio" else t if t not in types: types.append(t) images = [] for u in _IMG_RE.findall(h): if u not in images: images.append(u) return {"types": types, "images": images[:15]} def fetch(self) -> list[Listing]: page = re.sub(r"\s+", " ", self.get(LIST_URL).text) out: list[Listing] = [] seen: set[str] = set() for m in _CARD_RE.finditer(page): fiche_url = m.group(1) pre = page[max(0, m.start() - 3500):m.start()] tm = _CARD_TXT_RE.search(_to_text(pre).rstrip()) if not tm: continue name = tm.group(1).strip().strip("​").strip() sector = tm.group(2).replace("Quartier", "").strip() count = tm.group(3) if not name: continue ext_id = _slug(name) # nom du parc (stable) if ext_id in seen: continue seen.add(ext_id) imgs = _IMG_RE.findall(pre)[-1:] # vignette de la carte key = hashlib.sha256(f"{name}|{sector}|{count}".encode() ).hexdigest()[:16] try: det = self.detail(ext_id, key, lambda u=fiche_url: self._fiche(u)) except requests.RequestException: det = {} types = det.get("types") or [] for u in det.get("images") or []: if u not in imgs: imgs.append(u) desc = (f"Parc d'habitation {name} de HAPOPEX (OBNL) — " f"{count} logements, quartier {sector}, Montréal.") if types: desc += " Types d'unités : " + ", ".join(types) + "." desc += " Prix et disponibilités : communiquez avec l'organisme." out.append(Listing( source=self.source_id, external_id=ext_id, url=fiche_url, title=f"HAPOPEX — {name} ({sector})", sector=sector, city="Montréal", unit_type=types[0] if len(types) == 1 else "", availability="Communiquez avec nous", description=desc, amenities=([f"Types d'unités : {', '.join(types)}"] if types else []), details={"unit_types": types, "unit_count": int(count)}, images=imgs[:20], )) return out