SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
6.7 KB · 166 lines python
Raw Blame History
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