# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gerik.py : connecteur Gerik (gerik.ca) — Outaouais # Promoteur/gestionnaire de Gatineau (Hull-Aylmer, Gatineau, Cantley, # Masson-Angers). WordPress/WPBakery rendu serveur : la page # /appartements-a-louer-gatineau/ liste les projets locatifs en cartes # (h2 nom du projet + méta « Hull-Aylmer · Studios, 1 et 2 chambres · # Disponible dès maintenant » + description + lien /projets//). # AUCUN prix publié nulle part (ni carte, ni fiche projet) : on remplit # availability et on laisse price/price_label vides. Granularité = projet × # typologie (studio / n chambres) — le site n'expose pas d'inventaire par # unité. Seuls les projets marqués « Disponible » sont retenus (les projets # « À VENIR » / « En construction » / « en développement » sont exclus). # Photos : classes CSS vc_custom_* (background-image) mappées aux cartes. # external_id = - — stable. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, strip_accents from .base import BaseConnector BASE = "https://gerik.ca" LIST_URL = f"{BASE}/appartements-a-louer-gatineau/" BG_RE = re.compile(r"\.(vc_custom_\w+)\{background-image:\s*url\(([^)?\s]+)") TYPES_RE = re.compile(r"Studios?|(\d+)(?:,\s*\d+)*(?:\s*et\s*\d+)?\s*chambres?", re.I) SEP = "·" # secteur affiché sur la carte -> (secteur Lou-Ka, ville) PLACES = { "hull-aylmer": ("Aylmer", "Gatineau"), "aylmer": ("Aylmer", "Gatineau"), "hull": ("Hull", "Gatineau"), "gatineau": ("", "Gatineau"), "masson-angers": ("Masson-Angers", "Gatineau"), "cantley": ("", "Cantley"), "chelsea": ("", "Chelsea"), } def _slug(text: str) -> str: s = strip_accents(text.lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") class GerikConnector(BaseConnector): source_id = "gerik" request_delay = 0.8 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") bg_map = dict(BG_RE.findall(html)) # classe vc_custom_* -> photo listings: list[Listing] = [] seen: set[str] = set() for h2 in soup.find_all("h2"): name = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if not name or "Aucun projet" in name or "Découvrez" in name \ or "partenaire" in name: continue row = h2 for _ in range(8): # remonter à la rangée du projet row = row.parent if row is None: break if "vc_row" in (row.get("class") or []): break if row is None or "vc_row" not in (row.get("class") or []): continue try: listings.extend(self._parse_project(name, row, bg_map, seen)) except Exception: continue return listings def _parse_project(self, name: str, row, bg_map: dict, seen: set[str]) -> list[Listing]: proj_slug = _slug(name) if proj_slug in seen: return [] seen.add(proj_slug) text = re.sub(r"\s+", " ", row.get_text(" | ", strip=True)) # méta « Hull-Aylmer · Studios, 1 et 2 chambres · Disponible … » meta_el = row.find(string=re.compile(SEP)) meta = re.sub(r"\s+", " ", str(meta_el)).strip() if meta_el else "" parts = [p.strip() for p in meta.split(SEP) if p.strip()] availability = next((p for p in parts if re.search(r"disponible", p, re.I)), "") if not availability: return [] # à venir / en construction sector, city = "", "Gatineau" if parts: key = strip_accents(parts[0].lower()).strip() sector, city = PLACES.get(key, (parts[0], "Gatineau")) # typologies : « Studios, 1 et 2 chambres » -> Studio, 1 ch, 2 ch types: list[tuple[str, float | None]] = [] types_part = next((p for p in parts if re.search(r"studio|chambre", p, re.I)), "") if re.search(r"studio", types_part, re.I): types.append(("Studio", None)) nums = re.findall(r"\d+", re.sub(r"\d+\s*unités?", "", types_part)) for n in nums: types.append((f"{n} chambres" if int(n) > 1 else "1 chambre", float(n))) if not types: types = [("", None)] # description : paragraphe le plus long de la carte description = "" for p in row.find_all("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if len(t) > len(description) and SEP not in t: description = t description = description[:1200] url = LIST_URL a = row.find("a", href=re.compile(r"/projets/")) if a: url = a["href"] if not url.startswith("http"): url = BASE + url images: list[str] = [] for el in row.select("[class*='vc_custom_']"): for cls in (el.get("class") or []): u = bg_map.get(cls) if u and u not in images: images.append(u) details: dict = {} units_part = next((p for p in parts if re.search(r"\d+\s*unités?", p, re.I)), "") m = re.search(r"(\d+)\s*unités?", units_part, re.I) if m: details["building_units"] = int(m.group(1)) listings = [] for label, beds in types: suffix = "studio" if label == "Studio" else \ f"{int(beds)}ch" if beds else "logement" listings.append(Listing( source=self.source_id, external_id=f"{proj_slug}-{suffix}", url=url, title=f"{name} — {label}" if label else name, address="", sector=sector, city=city, unit_type="Studio" if label == "Studio" else "", bedrooms=beds, availability=availability, description=description, details=dict(details), images=list(images), )) return listings