spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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