SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.8 KB · 193 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/stb_immobilier.py : connecteur STB Immobilier (stbimmobilier.com)5#   Gestionnaire de Matane (Bas-Saint-Laurent) : ~11 immeubles/projets à Matane6#   et Saint-Ulric (rue Fournier, Boisé St-Rédempteur, rue Marquis, maisons de7#   ville…). WordPress + Elementor, tout rendu serveur. Liste8#   /locations-residentielles/ : cartes `e-loop-item` par IMMEUBLE (granularité9#   immeuble — le site ne publie pas d'unités individuelles) : nombre de10#   logements/maisons, adresse civique avec ville, nom, « Unités en location »11#   (typologies réelles « 4 X 4 ½, 6 X 5 ½ »), « À partir de X $/mois » et12#   parfois « Date de disponibilité ». Fiche /location/<slug>/ (via cache BD) :13#   type de location, services associés/à proximité, photos.14#   robots.txt ouvert, sitemap Yoast.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://stbimmobilier.com"27LIST_URL = f"{BASE}/locations-residentielles/"2829_PRICE_RE = re.compile(r"À partir de[\s ]*([\d\s .,]+\$\s*/\s*mois)", re.I)30_DISPO_RE = re.compile(31    r"Date de disponibilité\s*:\s*(.+?)(?=Unités en location|Type de location|À partir de|$)",32    re.I | re.S)33_UNITES_RE = re.compile(34    r"(?:Unités en location|Type de location)\s*:\s*(.+?)(?=À partir de|$)",35    re.I | re.S)36_TYPE_RE = re.compile(r"(\d\s*½|Studio|Loft|maisons? de ville)", re.I)373839class STBImmobilierConnector(BaseConnector):40    source_id = "stb_immobilier"41    request_delay = 0.642    max_details = 15     # garde-fou fiches détail (vraies requêtes par sync)4344    def fetch(self) -> list[Listing]:45        html = self.get(LIST_URL).text46        soup = BeautifulSoup(html, "html.parser")4748        self._fetched = 049        listings: dict[str, Listing] = {}50        for card in soup.select("div.e-loop-item"):51            try:52                self._parse_card(card, listings)53            except Exception:54                continue55        return list(listings.values())5657    # -- carte immeuble (boucle Elementor) ------------------------------------------58    def _parse_card(self, card, listings: dict[str, Listing]) -> None:59        link = card.select_one('a[href*="/location/"]')60        if not link:61            return62        url = link["href"]63        m = re.search(r"/location/([^/?#]+)", url)64        if not m:65            return66        ext_id = m.group(1)67        if ext_id in listings:68            return6970        # entêtes : [nombre, « logements|maisons », adresse, nom de l'immeuble]71        # (widgets « heading » Elementor — balises variables, pas toujours h1-h6)72        heads = [re.sub(r"\s+", " ", h.get_text(" ", strip=True))73                 for h in card.select(".elementor-widget-heading")]74        count = kind = address = name = ""75        for h in heads:76            if re.search(r"(?i)unités en location|type de location"77                         r"|date de disponibilité|à partir de", h):78                continue                      # lignes d'infos, traitées plus bas79            if re.fullmatch(r"\d+", h):80                count = h81            elif re.fullmatch(r"(logements?|maisons?)", h, re.I):82                kind = h.lower()83            elif "," in h:84                address = h85            elif h:86                name = h8788        city = ""89        parts = [p.strip() for p in address.split(",") if p.strip()]90        if parts:91            city = re.sub(r"\s+", " ", parts[-1]).strip()9293        # texte de la carte : disponibilité, typologies, prix « À partir de »94        text = re.sub(r"\s+", " ", card.get_text(" ", strip=True))95        m_d = _DISPO_RE.search(text)96        availability = m_d.group(1).strip(" .|") if m_d else ""97        m_u = _UNITES_RE.search(text)98        unites = m_u.group(1).strip(" .|") if m_u else ""99        m_p = _PRICE_RE.search(text)100        price_label = f"À partir de {m_p.group(1).strip()}" if m_p else ""101102        # type d'unité : seulement si l'immeuble n'offre qu'une typologie103        unit_type = ""104        types = {normalize_unit_type(re.sub(r"(?i)^maisons\b", "maison", t))105                 for t in _TYPE_RE.findall(unites)}106        types.discard("")107        if len(types) == 1:108            unit_type = types.pop()109110        amenities: list[str] = []111        if count and kind:112            amenities.append(f"Immeuble de {count} {kind}")113        if unites:114            amenities.append(f"Unités en location : {unites}")115116        # vignette de la carte (ignorer le bandeau décoratif « À venir »)117        images: list[str] = []118        for img in card.select("img[src]"):119            src = img["src"]120            if src.startswith("http") and "Bandeau" not in src:121                images.append(src)122                break123124        lst = Listing(125            source=self.source_id,126            external_id=ext_id,127            url=url,128            title=f"{name}{address}".strip(" —") or ext_id,129            address=address,130            city=city,131            unit_type=unit_type,132            price=parse_price(price_label),133            price_label=price_label,134            availability=availability,135            amenities=amenities,136            images=images,137        )138139        key = hashlib.sha1(140            f"{name}|{price_label}|{availability}|{unites}"141            .encode("utf-8")).hexdigest()142        try:143            payload = self.detail(ext_id, key,144                                  lambda u=url: self._fetch_detail(u))145            self._apply_detail(lst, payload)146        except Exception:147            pass148        listings[ext_id] = lst149150    # -- fiche immeuble (/location/<slug>/) ------------------------------------------151    def _fetch_detail(self, url: str) -> dict:152        """Services associés / à proximité et photos de la fiche."""153        if self._fetched >= self.max_details:154            raise RuntimeError("budget de fiches détail atteint")155        self._fetched += 1156        html = self.get(url).text157        soup = BeautifulSoup(html, "html.parser")158        out: dict = {}159160        text = re.sub(r"\s+", " ", soup.get_text(" | ", strip=True))161        m = re.search(r"Services \|? ?associés \|? ?: \|(.+?)(?=Services \|? ?à \|"162                      r"|Formulaire|$)", text)163        if m:164            out["services"] = [s.strip() for s in m.group(1).split("|")165                               if s.strip()][:12]166        m = re.search(r"Services \|? ?à \|? ?proximité \|? ?: \|(.+?)"167                      r"(?=Formulaire|$)", text)168        if m:169            out["nearby"] = [s.strip() for s in m.group(1).split("|")170                             if s.strip()][:12]171172        images = []173        for img in soup.select("main img[src], div[data-elementor-type] img[src]"):174            src = img["src"]175            if (src.startswith("http") and "/uploads/" in src176                    and "Bandeau" not in src and "logo" not in src.lower()177                    and src not in images):178                images.append(src)179        out["images"] = images[:15]180        return out181182    def _apply_detail(self, lst: Listing, d: dict) -> None:183        """Reporte le payload (frais ou en cache) sur l'annonce."""184        if not d:185            return186        if d.get("services"):187            lst.amenities = list(dict.fromkeys(lst.amenities + d["services"]))188        if d.get("nearby"):189            lst.description = (lst.description + "\nÀ proximité : "190                               + ", ".join(d["nearby"])).strip()191        if d.get("images") and len(d["images"]) > len(lst.images):192            lst.images = d["images"]193