SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.2 KB · 155 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/elite.py : connecteur Elite Immobilier (eliteimmobilier.ca — Gatineau)5#   Gestionnaire de projets locatifs neufs à Gatineau (Complexe Fraser à6#   Aylmer, Desrosiers rue Larabie, Nuvo au Plateau). WordPress/Elementor :7#   le hub /trouver-un-logement/ liste une page par projet, découverte à8#   chaque sync. Chaque page projet publie ses typologies avec prix dans des9#   boutons Elementor (« 1 CHAMBRE À PARTIR DE $1499/MOIS* ») -> une annonce10#   par typologie affichée AVEC prix. Le portail SecureCafe (Yardi) du site11#   est réservé aux résidents : aucune unité individuelle publique — la12#   granularité typologie est donc la donnée la plus fine disponible.13#   Adresses/secteurs : publiés en clair sur chaque page projet ; un mapping14#   des slugs connus fournit l'adresse vérifiée (jamais devinée pour un slug15#   inconnu -> address vide).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re20from urllib.parse import urljoin2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price, strip_accents25from .base import BaseConnector2627BASE = "https://eliteimmobilier.ca"28HUB_URL = f"{BASE}/trouver-un-logement/"2930# métadonnées vérifiées à la main (2026-08) — les slugs inconnus passent31# quand même, avec adresse/secteur vides plutôt qu'inventés32KNOWN = {33    "complexe-chemin-fraser": {34        "name": "Complexe Fraser",35        "address": "475-515, chemin Fraser, Gatineau", "sector": "Aylmer"},36    "desrosiers-rue-larabie": {37        "name": "Desrosiers",38        "address": "176, rue Larabie, Gatineau", "sector": ""},39    "projet-nuvo-plateau": {40        "name": "Nuvo",41        "address": "699, boulevard du Plateau, Gatineau", "sector": "Plateau"},42}4344# « 1 CHAMBRE À PARTIR DE $1499/MOIS* » / « STUDIO À PARTIR DE $1399/MOIS* »45_TYPO_RE = re.compile(46    r"^\s*(.{2,40}?)\s*[ÀA]\s+PARTIR\s+DE\s*\$?\s*([\d\s,.]+)\s*/\s*MOIS",47    re.I)48_IMG_RE = re.compile(49    r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp|png)',50    re.I)51_SKIP_IMG = re.compile(r"logo|icon|favicon|cropped|-\d{2,4}x\d{2,4}\.", re.I)525354def _slugify(s: str) -> str:55    s = strip_accents(s.lower())56    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")575859class EliteConnector(BaseConnector):60    source_id = "elite"61    request_delay = 1.062    max_projects = 1263    max_images = 106465    # -- typologie -> type d'unité ------------------------------------------------66    @staticmethod67    def _unit_type(label: str) -> str:68        t = strip_accents(label.lower())69        if "studio" in t:70            return "Studio"71        m = re.match(r"^(\d+)\s*ch", t)72        if m:73            return normalize_unit_type(f"{m.group(1)} chambres")74        return normalize_unit_type(label)7576    # -- page projet ----------------------------------------------------------77    def _parse_project(self, url: str, listings: list[Listing]) -> None:78        html = self.get(url).text79        soup = BeautifulSoup(html, "html.parser")80        slug = url.rstrip("/").rsplit("/", 1)[-1]81        meta = KNOWN.get(slug, {})8283        h1 = soup.find("h1")84        page_title = h1.get_text(" ", strip=True) if h1 else slug85        name = meta.get("name") or page_title.split(":")[0].strip()8687        # description marketing du projet (og:description rédigé par l'agence)88        og = soup.find("meta", attrs={"property": "og:description"})89        blurb = (og.get("content", "").strip() if og else "")[:600]9091        # « Emménagez dès le 1er juillet » / « EMMÉNAGER À PARTIR DÈS MAINTENANT »92        text = soup.get_text("\n", strip=True)93        availability = ""94        m = re.search(r"(?i)emm[ée]nage[rz]?[^\n.!]{0,60}", text)95        if m:96            availability = re.sub(r"\s+", " ", m.group(0)).strip(" :*")9798        images: list[str] = []99        for u in dict.fromkeys(_IMG_RE.findall(html)):100            if not _SKIP_IMG.search(u) and u not in images:101                images.append(u)102103        # boutons de typologie avec prix (donnée la plus fine publiée)104        seen: set[str] = set()105        for btn in soup.select("span.elementor-button-text"):106            raw = re.sub(r"\s+", " ", btn.get_text(" ", strip=True)).strip()107            m = _TYPO_RE.match(raw)108            if not m:109                continue110            typo = m.group(1).strip(" -–")111            key = _slugify(typo)112            if not key or key in seen:113                continue114            seen.add(key)115            price_label = raw.rstrip("*")116            listings.append(Listing(117                source=self.source_id,118                external_id=f"{slug}:{key}",119                url=url,120                title=f"{name}{typo.title()}",121                address=meta.get("address", ""),122                sector=meta.get("sector", ""),123                city="Gatineau",124                unit_type=self._unit_type(typo),125                price=parse_price(price_label),126                price_label=price_label,127                availability=availability,128                description=blurb,129                images=images[: self.max_images],130            ))131132    # -- fetch -----------------------------------------------------------------133    def fetch(self) -> list[Listing]:134        html = self.get(HUB_URL).text135        soup = BeautifulSoup(html, "html.parser")136        urls: list[str] = []137        for a in soup.select("a[href*='/trouver-un-logement/']"):138            u = urljoin(BASE, a["href"]).split("#")[0].split("?")[0]139            if not u.endswith("/"):140                u += "/"141            if u != HUB_URL and "/en/" not in u and u not in urls:142                urls.append(u)143144        listings: list[Listing] = []145        for url in urls[: self.max_projects]:146            try:147                self._parse_project(url, listings)148            except Exception:149                continue150151        uniq: dict[str, Listing] = {}152        for lst in listings:153            uniq.setdefault(lst.external_id, lst)154        return list(uniq.values())155