# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/elite.py : connecteur Elite Immobilier (eliteimmobilier.ca — Gatineau) # Gestionnaire de projets locatifs neufs à Gatineau (Complexe Fraser à # Aylmer, Desrosiers rue Larabie, Nuvo au Plateau). WordPress/Elementor : # le hub /trouver-un-logement/ liste une page par projet, découverte à # chaque sync. Chaque page projet publie ses typologies avec prix dans des # boutons Elementor (« 1 CHAMBRE À PARTIR DE $1499/MOIS* ») -> une annonce # par typologie affichée AVEC prix. Le portail SecureCafe (Yardi) du site # est réservé aux résidents : aucune unité individuelle publique — la # granularité typologie est donc la donnée la plus fine disponible. # Adresses/secteurs : publiés en clair sur chaque page projet ; un mapping # des slugs connus fournit l'adresse vérifiée (jamais devinée pour un slug # inconnu -> address vide). # ----------------------------------------------------------------------------- from __future__ import annotations import re from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://eliteimmobilier.ca" HUB_URL = f"{BASE}/trouver-un-logement/" # métadonnées vérifiées à la main (2026-08) — les slugs inconnus passent # quand même, avec adresse/secteur vides plutôt qu'inventés KNOWN = { "complexe-chemin-fraser": { "name": "Complexe Fraser", "address": "475-515, chemin Fraser, Gatineau", "sector": "Aylmer"}, "desrosiers-rue-larabie": { "name": "Desrosiers", "address": "176, rue Larabie, Gatineau", "sector": ""}, "projet-nuvo-plateau": { "name": "Nuvo", "address": "699, boulevard du Plateau, Gatineau", "sector": "Plateau"}, } # « 1 CHAMBRE À PARTIR DE $1499/MOIS* » / « STUDIO À PARTIR DE $1399/MOIS* » _TYPO_RE = re.compile( r"^\s*(.{2,40}?)\s*[ÀA]\s+PARTIR\s+DE\s*\$?\s*([\d\s,.]+)\s*/\s*MOIS", re.I) _IMG_RE = re.compile( r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp|png)', re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|cropped|-\d{2,4}x\d{2,4}\.", re.I) def _slugify(s: str) -> str: s = strip_accents(s.lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") class EliteConnector(BaseConnector): source_id = "elite" request_delay = 1.0 max_projects = 12 max_images = 10 # -- typologie -> type d'unité ------------------------------------------------ @staticmethod def _unit_type(label: str) -> str: t = strip_accents(label.lower()) if "studio" in t: return "Studio" m = re.match(r"^(\d+)\s*ch", t) if m: return normalize_unit_type(f"{m.group(1)} chambres") return normalize_unit_type(label) # -- page projet ---------------------------------------------------------- def _parse_project(self, url: str, listings: list[Listing]) -> None: html = self.get(url).text soup = BeautifulSoup(html, "html.parser") slug = url.rstrip("/").rsplit("/", 1)[-1] meta = KNOWN.get(slug, {}) h1 = soup.find("h1") page_title = h1.get_text(" ", strip=True) if h1 else slug name = meta.get("name") or page_title.split(":")[0].strip() # description marketing du projet (og:description rédigé par l'agence) og = soup.find("meta", attrs={"property": "og:description"}) blurb = (og.get("content", "").strip() if og else "")[:600] # « Emménagez dès le 1er juillet » / « EMMÉNAGER À PARTIR DÈS MAINTENANT » text = soup.get_text("\n", strip=True) availability = "" m = re.search(r"(?i)emm[ée]nage[rz]?[^\n.!]{0,60}", text) if m: availability = re.sub(r"\s+", " ", m.group(0)).strip(" :*") images: list[str] = [] for u in dict.fromkeys(_IMG_RE.findall(html)): if not _SKIP_IMG.search(u) and u not in images: images.append(u) # boutons de typologie avec prix (donnée la plus fine publiée) seen: set[str] = set() for btn in soup.select("span.elementor-button-text"): raw = re.sub(r"\s+", " ", btn.get_text(" ", strip=True)).strip() m = _TYPO_RE.match(raw) if not m: continue typo = m.group(1).strip(" -–") key = _slugify(typo) if not key or key in seen: continue seen.add(key) price_label = raw.rstrip("*") listings.append(Listing( source=self.source_id, external_id=f"{slug}:{key}", url=url, title=f"{name} — {typo.title()}", address=meta.get("address", ""), sector=meta.get("sector", ""), city="Gatineau", unit_type=self._unit_type(typo), price=parse_price(price_label), price_label=price_label, availability=availability, description=blurb, images=images[: self.max_images], )) # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: html = self.get(HUB_URL).text soup = BeautifulSoup(html, "html.parser") urls: list[str] = [] for a in soup.select("a[href*='/trouver-un-logement/']"): u = urljoin(BASE, a["href"]).split("#")[0].split("?")[0] if not u.endswith("/"): u += "/" if u != HUB_URL and "/en/" not in u and u not in urls: urls.append(u) listings: list[Listing] = [] for url in urls[: self.max_projects]: try: self._parse_project(url, listings) except Exception: continue uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values())