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%
6.5 KB · 168 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/gerik.py : connecteur Gerik (gerik.ca) — Outaouais5#   Promoteur/gestionnaire de Gatineau (Hull-Aylmer, Gatineau, Cantley,6#   Masson-Angers). WordPress/WPBakery rendu serveur : la page7#   /appartements-a-louer-gatineau/ liste les projets locatifs en cartes8#   (h2 nom du projet + méta « Hull-Aylmer · Studios, 1 et 2 chambres ·9#   Disponible dès maintenant » + description + lien /projets/<slug>/).10#   AUCUN prix publié nulle part (ni carte, ni fiche projet) : on remplit11#   availability et on laisse price/price_label vides. Granularité = projet ×12#   typologie (studio / n chambres) — le site n'expose pas d'inventaire par13#   unité. Seuls les projets marqués « Disponible » sont retenus (les projets14#   « À VENIR » / « En construction » / « en développement » sont exclus).15#   Photos : classes CSS vc_custom_* (background-image) mappées aux cartes.16#   external_id = <slug du projet>-<typologie> — stable.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, strip_accents25from .base import BaseConnector2627BASE = "https://gerik.ca"28LIST_URL = f"{BASE}/appartements-a-louer-gatineau/"2930BG_RE = re.compile(r"\.(vc_custom_\w+)\{background-image:\s*url\(([^)?\s]+)")31TYPES_RE = re.compile(r"Studios?|(\d+)(?:,\s*\d+)*(?:\s*et\s*\d+)?\s*chambres?",32                      re.I)33SEP = "·"3435# secteur affiché sur la carte -> (secteur Lou-Ka, ville)36PLACES = {37    "hull-aylmer": ("Aylmer", "Gatineau"),38    "aylmer": ("Aylmer", "Gatineau"),39    "hull": ("Hull", "Gatineau"),40    "gatineau": ("", "Gatineau"),41    "masson-angers": ("Masson-Angers", "Gatineau"),42    "cantley": ("", "Cantley"),43    "chelsea": ("", "Chelsea"),44}454647def _slug(text: str) -> str:48    s = strip_accents(text.lower())49    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")505152class GerikConnector(BaseConnector):53    source_id = "gerik"54    request_delay = 0.85556    def fetch(self) -> list[Listing]:57        html = self.get(LIST_URL).text58        soup = BeautifulSoup(html, "html.parser")59        bg_map = dict(BG_RE.findall(html))     # classe vc_custom_* -> photo6061        listings: list[Listing] = []62        seen: set[str] = set()63        for h2 in soup.find_all("h2"):64            name = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))65            if not name or "Aucun projet" in name or "Découvrez" in name \66                    or "partenaire" in name:67                continue68            row = h269            for _ in range(8):                 # remonter à la rangée du projet70                row = row.parent71                if row is None:72                    break73                if "vc_row" in (row.get("class") or []):74                    break75            if row is None or "vc_row" not in (row.get("class") or []):76                continue77            try:78                listings.extend(self._parse_project(name, row, bg_map, seen))79            except Exception:80                continue81        return listings8283    def _parse_project(self, name: str, row, bg_map: dict,84                       seen: set[str]) -> list[Listing]:85        proj_slug = _slug(name)86        if proj_slug in seen:87            return []88        seen.add(proj_slug)8990        text = re.sub(r"\s+", " ", row.get_text(" | ", strip=True))91        # méta « Hull-Aylmer · Studios, 1 et 2 chambres · Disponible … »92        meta_el = row.find(string=re.compile(SEP))93        meta = re.sub(r"\s+", " ", str(meta_el)).strip() if meta_el else ""94        parts = [p.strip() for p in meta.split(SEP) if p.strip()]9596        availability = next((p for p in parts97                             if re.search(r"disponible", p, re.I)), "")98        if not availability:99            return []                          # à venir / en construction100101        sector, city = "", "Gatineau"102        if parts:103            key = strip_accents(parts[0].lower()).strip()104            sector, city = PLACES.get(key, (parts[0], "Gatineau"))105106        # typologies : « Studios, 1 et 2 chambres » -> Studio, 1 ch, 2 ch107        types: list[tuple[str, float | None]] = []108        types_part = next((p for p in parts109                           if re.search(r"studio|chambre", p, re.I)), "")110        if re.search(r"studio", types_part, re.I):111            types.append(("Studio", None))112        nums = re.findall(r"\d+", re.sub(r"\d+\s*unités?", "", types_part))113        for n in nums:114            types.append((f"{n} chambres" if int(n) > 1 else "1 chambre",115                          float(n)))116        if not types:117            types = [("", None)]118119        # description : paragraphe le plus long de la carte120        description = ""121        for p in row.find_all("p"):122            t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))123            if len(t) > len(description) and SEP not in t:124                description = t125        description = description[:1200]126127        url = LIST_URL128        a = row.find("a", href=re.compile(r"/projets/"))129        if a:130            url = a["href"]131            if not url.startswith("http"):132                url = BASE + url133134        images: list[str] = []135        for el in row.select("[class*='vc_custom_']"):136            for cls in (el.get("class") or []):137                u = bg_map.get(cls)138                if u and u not in images:139                    images.append(u)140141        details: dict = {}142        units_part = next((p for p in parts143                           if re.search(r"\d+\s*unités?", p, re.I)), "")144        m = re.search(r"(\d+)\s*unités?", units_part, re.I)145        if m:146            details["building_units"] = int(m.group(1))147148        listings = []149        for label, beds in types:150            suffix = "studio" if label == "Studio" else \151                f"{int(beds)}ch" if beds else "logement"152            listings.append(Listing(153                source=self.source_id,154                external_id=f"{proj_slug}-{suffix}",155                url=url,156                title=f"{name} — {label}" if label else name,157                address="",158                sector=sector,159                city=city,160                unit_type="Studio" if label == "Studio" else "",161                bedrooms=beds,162                availability=availability,163                description=description,164                details=dict(details),165                images=list(images),166            ))167        return listings168