SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
5.1 KB · 130 lines python
Raw Blame History
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