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/souleymane.py : connecteur Gestion Souleymane (gestionsouleymane.com)5# Gestionnaire local de Gatineau (secteurs est : Masson-Angers, Buckingham,6# + Hull/Aylmer). WordPress + plugin immobilier ESTATIK : la page /a-louer7# liste toutes les annonces (« 14 results », pas de pagination), chaque8# carte `div.js-es-listing` portant data-post-id (external_id stable),9# l'adresse civique en titre, le prix, chambres/salles de bain et la10# galerie du carrousel (data-lazy). La fiche /property/<slug> (cache BD)11# ajoute la description longue rédigée par l'agence — qui contient12# « 📅 Disponible immédiatement », « secteur Masson-Angers », inclusions —13# exploitée pour availability et le secteur, le reste par textmine.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://gestionsouleymane.com"26LIST_URL = f"{BASE}/a-louer"2728_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)29# secteurs de Gatineau (fusion 2002) repérables dans l'adresse/description30_SECTORS = ["Masson-Angers", "Masson", "Buckingham", "Aylmer", "Hull",31 "Templeton", "Pointe-Gatineau", "Limbour", "Plateau"]323334class SouleymaneConnector(BaseConnector):35 source_id = "souleymane"36 request_delay = 1.037 max_details = 2538 max_images = 153940 # -- helpers ---------------------------------------------------------------41 @staticmethod42 def _sector(*texts: str) -> str:43 for txt in texts:44 for s in _SECTORS:45 if re.search(rf"(?i)\b{re.escape(s)}\b", txt or ""):46 return "Masson-Angers" if s == "Masson" else s47 return ""4849 # -- fiche détail ------------------------------------------------------------50 def _fetch_detail(self, url: str) -> dict:51 self._fetched += 152 soup = BeautifulSoup(self.get(url).text, "html.parser")53 out: dict = {}54 desc_el = soup.select_one(".es-description, [itemprop='description']")55 # la description complète vit dans la section « Description » ; repli56 # sur le texte principal de la fiche57 block = None58 for h in soup.find_all(["h2", "h3", "h4"]):59 if h.get_text(strip=True).lower().startswith("description"):60 block = h.parent61 break62 el = block or desc_el63 if el:64 txt = el.get_text("\n", strip=True)65 txt = re.sub(r"^(?:Description\s*:?\s*\n?)+", "", txt)66 txt = re.sub(r"\n{2,}", "\n", txt)67 out["description"] = txt.strip()[:2500]68 return out6970 # -- fetch -----------------------------------------------------------------71 def fetch(self) -> list[Listing]:72 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")73 self._fetched = 074 listings: dict[str, Listing] = {}75 for card in soup.select("div.js-es-listing"):76 try:77 self._parse_card(card, listings)78 except Exception:79 continue80 return list(listings.values())8182 def _parse_card(self, card, listings: dict[str, Listing]) -> None:83 ext = str(card.get("data-post-id") or "")84 link = card.select_one("h3.es-listing__title a[href]")85 if not link:86 return87 url = link["href"]88 if not ext:89 m = re.search(r"/property/([^/]+)/?", url)90 ext = m.group(1) if m else ""91 if not ext or ext in listings:92 return9394 address = re.sub(r"\s+", " ", link.get_text(" ", strip=True))95 price_el = card.select_one(".es-price")96 price_label = price_el.get_text(" ", strip=True) if price_el else ""9798 excerpt_el = card.select_one("p.es-excerpt")99 excerpt = (re.sub(r"\s+", " ", excerpt_el.get_text(" ", strip=True))100 if excerpt_el else "")101102 beds = ""103 beds_el = card.select_one(".es-listing__meta-bedrooms b")104 if beds_el:105 beds = beds_el.get_text(strip=True)106 baths_el = card.select_one(".es-listing__meta-bathrooms b")107 baths = baths_el.get_text(strip=True) if baths_el else ""108 unit_type = (normalize_unit_type(f"{beds} chambres")109 if beds.isdigit() else "")110 # les maisons restent des maisons, peu importe le compte de pièces111 if re.search(r"(?i)\bmaison\b", excerpt + " " + address):112 unit_type = "Maison"113114 images: list[str] = []115 for img in card.select(".es-listing__image img"):116 u = img.get("data-lazy") or img.get("src") or ""117 if u.startswith("http"):118 u = _SIZE_SUFFIX.sub("", u)119 if u not in images:120 images.append(u)121122 # fiche détail : description complète (cache BD)123 payload: dict = {}124 key = hashlib.sha1(f"{address}|{price_label}|{excerpt}"125 .encode("utf-8")).hexdigest()[:20]126 if self._fetched < self.max_details:127 try:128 payload = self.detail(ext, key,129 lambda u=url: self._fetch_detail(u))130 except Exception:131 payload = {}132 description = payload.get("description") or excerpt133134 # non résidentiel : garages/entreposage/locaux annoncés sur la même page135 head = f"{address} {excerpt} {description[:200]}"136 if re.search(r"(?i)garage à louer|stationnement à louer|entreposage"137 r"|local commercial", head):138 return139140 # « 📅 Disponible immédiatement » / « Disponible le 1er septembre »141 availability = ""142 m = re.search(r"(?i)disponible[^\n.!]{0,50}", description)143 if m:144 availability = m.group(0).strip()145146 details: dict = {}147 if baths.isdigit():148 details["bathrooms"] = int(baths)149150 listings[ext] = Listing(151 source=self.source_id,152 external_id=ext,153 url=url,154 title=address,155 address=address,156 sector=self._sector(address, description),157 city="Gatineau",158 unit_type=unit_type,159 price=parse_price(price_label.replace(",", "")),160 price_label=price_label,161 availability=availability,162 description=description,163 details=details,164 images=images[: self.max_images],165 )166