Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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