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/hapopex.py : HAPOPEX (hapopex.org) — OBNL d'habitation montréalais5# (parcs à Montréal-Nord, Lachine, Côte-des-Neiges…), page « Logements à6# louer » avec une carte par parc d'habitation.7# Techno : Wix rendu côté serveur. ⚠️ Les slugs des fiches sont trompeurs8# (copy-of-industriel-1-… = fiche ARCHEVÊQUE, etc.) : on apparie carte ->9# fiche PAR POSITION dans le document, jamais par le slug. La fiche fournit10# le quartier et les « Types d'unités » (pas de prix ni d'adresse civique).11# Granularité : PARC D'HABITATION (une annonce par parc).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import html as _html17import re1819import requests2021from .base import BaseConnector22from ..schema import Listing, strip_accents2324BASE = "https://www.hapopex.org"25LIST_URL = f"{BASE}/logements-a-louer"2627# ancre « En savoir plus » de chaque carte (ordre du document)28_CARD_RE = re.compile(29 r'<a[^>]+href="(https://www\.hapopex\.org/[^"]+)"[^>]*>'30 r'(?:(?!</a>).)*?En savoir plus')31# dans le texte qui précède l'ancre : « Nom | Quartier X | N logements »32_CARD_TXT_RE = re.compile(33 r'([^|]{2,60})\|\s*\|(?:Quartier)?\s*([^|]{2,40})\|\s*(\d+)\s*logements?\s*\|?\s*$')34_IMG_RE = re.compile(r'<img[^>]+src="(https://static\.wixstatic\.com/media/[^"]+)"')35_TYPES_RE = re.compile(r"Types d'unités\s*:?\s*\|?([^|]*(?:\|[^|]*){0,6})")36_TYPE_TOKEN_RE = re.compile(r'\d\s*½|\bstudio\b', re.I)373839def _slug(name: str) -> str:40 s = strip_accents(name.lower())41 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")424344def _to_text(seg: str) -> str:45 txt = _html.unescape(re.sub(r"<[^>]+>", "|", seg))46 return re.sub(r"\|+", "|", txt)474849class HapopexConnector(BaseConnector):50 """HAPOPEX — une annonce par parc d'habitation (Montréal)."""5152 source_id = "hapopex"53 request_delay = 0.85455 def _fiche(self, url: str) -> dict:56 """Fiche du parc : types d'unités + photos (JSON-sérialisable)."""57 h = re.sub(r"\s+", " ", self.get(url).text)58 h = re.sub(r"<script.*?</script>", "", h)59 txt = _to_text(h)60 types: list[str] = []61 m = _TYPES_RE.search(txt)62 if m:63 for tok in _TYPE_TOKEN_RE.findall(m.group(1)):64 t = re.sub(r"[\s ]+", "", tok)65 t = "Studio" if t.lower() == "studio" else t66 if t not in types:67 types.append(t)68 images = []69 for u in _IMG_RE.findall(h):70 if u not in images:71 images.append(u)72 return {"types": types, "images": images[:15]}7374 def fetch(self) -> list[Listing]:75 page = re.sub(r"\s+", " ", self.get(LIST_URL).text)76 out: list[Listing] = []77 seen: set[str] = set()78 for m in _CARD_RE.finditer(page):79 fiche_url = m.group(1)80 pre = page[max(0, m.start() - 3500):m.start()]81 tm = _CARD_TXT_RE.search(_to_text(pre).rstrip())82 if not tm:83 continue84 name = tm.group(1).strip().strip("").strip()85 sector = tm.group(2).replace("Quartier", "").strip()86 count = tm.group(3)87 if not name:88 continue89 ext_id = _slug(name) # nom du parc (stable)90 if ext_id in seen:91 continue92 seen.add(ext_id)9394 imgs = _IMG_RE.findall(pre)[-1:] # vignette de la carte9596 key = hashlib.sha256(f"{name}|{sector}|{count}".encode()97 ).hexdigest()[:16]98 try:99 det = self.detail(ext_id, key,100 lambda u=fiche_url: self._fiche(u))101 except requests.RequestException:102 det = {}103 types = det.get("types") or []104 for u in det.get("images") or []:105 if u not in imgs:106 imgs.append(u)107108 desc = (f"Parc d'habitation {name} de HAPOPEX (OBNL) — "109 f"{count} logements, quartier {sector}, Montréal.")110 if types:111 desc += " Types d'unités : " + ", ".join(types) + "."112 desc += " Prix et disponibilités : communiquez avec l'organisme."113114 out.append(Listing(115 source=self.source_id,116 external_id=ext_id,117 url=fiche_url,118 title=f"HAPOPEX — {name} ({sector})",119 sector=sector,120 city="Montréal",121 unit_type=types[0] if len(types) == 1 else "",122 availability="Communiquez avec nous",123 description=desc,124 amenities=([f"Types d'unités : {', '.join(types)}"]125 if types else []),126 details={"unit_types": types, "unit_count": int(count)},127 images=imgs[:20],128 ))129 return out130