# -----------------------------------------------------------------------------
# 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