# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/souleymane.py : connecteur Gestion Souleymane (gestionsouleymane.com) # Gestionnaire local de Gatineau (secteurs est : Masson-Angers, Buckingham, # + Hull/Aylmer). WordPress + plugin immobilier ESTATIK : la page /a-louer # liste toutes les annonces (« 14 results », pas de pagination), chaque # carte `div.js-es-listing` portant data-post-id (external_id stable), # l'adresse civique en titre, le prix, chambres/salles de bain et la # galerie du carrousel (data-lazy). La fiche /property/ (cache BD) # ajoute la description longue rédigée par l'agence — qui contient # « 📅 Disponible immédiatement », « secteur Masson-Angers », inclusions — # exploitée pour availability et le secteur, le reste par textmine. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://gestionsouleymane.com" LIST_URL = f"{BASE}/a-louer" _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) # secteurs de Gatineau (fusion 2002) repérables dans l'adresse/description _SECTORS = ["Masson-Angers", "Masson", "Buckingham", "Aylmer", "Hull", "Templeton", "Pointe-Gatineau", "Limbour", "Plateau"] class SouleymaneConnector(BaseConnector): source_id = "souleymane" request_delay = 1.0 max_details = 25 max_images = 15 # -- helpers --------------------------------------------------------------- @staticmethod def _sector(*texts: str) -> str: for txt in texts: for s in _SECTORS: if re.search(rf"(?i)\b{re.escape(s)}\b", txt or ""): return "Masson-Angers" if s == "Masson" else s return "" # -- fiche détail ------------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: self._fetched += 1 soup = BeautifulSoup(self.get(url).text, "html.parser") out: dict = {} desc_el = soup.select_one(".es-description, [itemprop='description']") # la description complète vit dans la section « Description » ; repli # sur le texte principal de la fiche block = None for h in soup.find_all(["h2", "h3", "h4"]): if h.get_text(strip=True).lower().startswith("description"): block = h.parent break el = block or desc_el if el: txt = el.get_text("\n", strip=True) txt = re.sub(r"^(?:Description\s*:?\s*\n?)+", "", txt) txt = re.sub(r"\n{2,}", "\n", txt) out["description"] = txt.strip()[:2500] return out # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") self._fetched = 0 listings: dict[str, Listing] = {} for card in soup.select("div.js-es-listing"): try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) def _parse_card(self, card, listings: dict[str, Listing]) -> None: ext = str(card.get("data-post-id") or "") link = card.select_one("h3.es-listing__title a[href]") if not link: return url = link["href"] if not ext: m = re.search(r"/property/([^/]+)/?", url) ext = m.group(1) if m else "" if not ext or ext in listings: return address = re.sub(r"\s+", " ", link.get_text(" ", strip=True)) price_el = card.select_one(".es-price") price_label = price_el.get_text(" ", strip=True) if price_el else "" excerpt_el = card.select_one("p.es-excerpt") excerpt = (re.sub(r"\s+", " ", excerpt_el.get_text(" ", strip=True)) if excerpt_el else "") beds = "" beds_el = card.select_one(".es-listing__meta-bedrooms b") if beds_el: beds = beds_el.get_text(strip=True) baths_el = card.select_one(".es-listing__meta-bathrooms b") baths = baths_el.get_text(strip=True) if baths_el else "" unit_type = (normalize_unit_type(f"{beds} chambres") if beds.isdigit() else "") # les maisons restent des maisons, peu importe le compte de pièces if re.search(r"(?i)\bmaison\b", excerpt + " " + address): unit_type = "Maison" images: list[str] = [] for img in card.select(".es-listing__image img"): u = img.get("data-lazy") or img.get("src") or "" if u.startswith("http"): u = _SIZE_SUFFIX.sub("", u) if u not in images: images.append(u) # fiche détail : description complète (cache BD) payload: dict = {} key = hashlib.sha1(f"{address}|{price_label}|{excerpt}" .encode("utf-8")).hexdigest()[:20] if self._fetched < self.max_details: try: payload = self.detail(ext, key, lambda u=url: self._fetch_detail(u)) except Exception: payload = {} description = payload.get("description") or excerpt # non résidentiel : garages/entreposage/locaux annoncés sur la même page head = f"{address} {excerpt} {description[:200]}" if re.search(r"(?i)garage à louer|stationnement à louer|entreposage" r"|local commercial", head): return # « 📅 Disponible immédiatement » / « Disponible le 1er septembre » availability = "" m = re.search(r"(?i)disponible[^\n.!]{0,50}", description) if m: availability = m.group(0).strip() details: dict = {} if baths.isdigit(): details["bathrooms"] = int(baths) listings[ext] = Listing( source=self.source_id, external_id=ext, url=url, title=address, address=address, sector=self._sector(address, description), city="Gatineau", unit_type=unit_type, price=parse_price(price_label.replace(",", "")), price_label=price_label, availability=availability, description=description, details=details, images=images[: self.max_images], )