# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/stb_immobilier.py : connecteur STB Immobilier (stbimmobilier.com) # Gestionnaire de Matane (Bas-Saint-Laurent) : ~11 immeubles/projets à Matane # et Saint-Ulric (rue Fournier, Boisé St-Rédempteur, rue Marquis, maisons de # ville…). WordPress + Elementor, tout rendu serveur. Liste # /locations-residentielles/ : cartes `e-loop-item` par IMMEUBLE (granularité # immeuble — le site ne publie pas d'unités individuelles) : nombre de # logements/maisons, adresse civique avec ville, nom, « Unités en location » # (typologies réelles « 4 X 4 ½, 6 X 5 ½ »), « À partir de X $/mois » et # parfois « Date de disponibilité ». Fiche /location// (via cache BD) : # type de location, services associés/à proximité, photos. # robots.txt ouvert, sitemap Yoast. # ----------------------------------------------------------------------------- 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://stbimmobilier.com" LIST_URL = f"{BASE}/locations-residentielles/" _PRICE_RE = re.compile(r"À partir de[\s ]*([\d\s .,]+\$\s*/\s*mois)", re.I) _DISPO_RE = re.compile( r"Date de disponibilité\s*:\s*(.+?)(?=Unités en location|Type de location|À partir de|$)", re.I | re.S) _UNITES_RE = re.compile( r"(?:Unités en location|Type de location)\s*:\s*(.+?)(?=À partir de|$)", re.I | re.S) _TYPE_RE = re.compile(r"(\d\s*½|Studio|Loft|maisons? de ville)", re.I) class STBImmobilierConnector(BaseConnector): source_id = "stb_immobilier" request_delay = 0.6 max_details = 15 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") self._fetched = 0 listings: dict[str, Listing] = {} for card in soup.select("div.e-loop-item"): try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) # -- carte immeuble (boucle Elementor) ------------------------------------------ def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one('a[href*="/location/"]') if not link: return url = link["href"] m = re.search(r"/location/([^/?#]+)", url) if not m: return ext_id = m.group(1) if ext_id in listings: return # entêtes : [nombre, « logements|maisons », adresse, nom de l'immeuble] # (widgets « heading » Elementor — balises variables, pas toujours h1-h6) heads = [re.sub(r"\s+", " ", h.get_text(" ", strip=True)) for h in card.select(".elementor-widget-heading")] count = kind = address = name = "" for h in heads: if re.search(r"(?i)unités en location|type de location" r"|date de disponibilité|à partir de", h): continue # lignes d'infos, traitées plus bas if re.fullmatch(r"\d+", h): count = h elif re.fullmatch(r"(logements?|maisons?)", h, re.I): kind = h.lower() elif "," in h: address = h elif h: name = h city = "" parts = [p.strip() for p in address.split(",") if p.strip()] if parts: city = re.sub(r"\s+", " ", parts[-1]).strip() # texte de la carte : disponibilité, typologies, prix « À partir de » text = re.sub(r"\s+", " ", card.get_text(" ", strip=True)) m_d = _DISPO_RE.search(text) availability = m_d.group(1).strip(" .|") if m_d else "" m_u = _UNITES_RE.search(text) unites = m_u.group(1).strip(" .|") if m_u else "" m_p = _PRICE_RE.search(text) price_label = f"À partir de {m_p.group(1).strip()}" if m_p else "" # type d'unité : seulement si l'immeuble n'offre qu'une typologie unit_type = "" types = {normalize_unit_type(re.sub(r"(?i)^maisons\b", "maison", t)) for t in _TYPE_RE.findall(unites)} types.discard("") if len(types) == 1: unit_type = types.pop() amenities: list[str] = [] if count and kind: amenities.append(f"Immeuble de {count} {kind}") if unites: amenities.append(f"Unités en location : {unites}") # vignette de la carte (ignorer le bandeau décoratif « À venir ») images: list[str] = [] for img in card.select("img[src]"): src = img["src"] if src.startswith("http") and "Bandeau" not in src: images.append(src) break lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{name} — {address}".strip(" —") or ext_id, address=address, city=city, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, amenities=amenities, images=images, ) key = hashlib.sha1( f"{name}|{price_label}|{availability}|{unites}" .encode("utf-8")).hexdigest() try: payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u)) self._apply_detail(lst, payload) except Exception: pass listings[ext_id] = lst # -- fiche immeuble (/location//) ------------------------------------------ def _fetch_detail(self, url: str) -> dict: """Services associés / à proximité et photos de la fiche.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} text = re.sub(r"\s+", " ", soup.get_text(" | ", strip=True)) m = re.search(r"Services \|? ?associés \|? ?: \|(.+?)(?=Services \|? ?à \|" r"|Formulaire|$)", text) if m: out["services"] = [s.strip() for s in m.group(1).split("|") if s.strip()][:12] m = re.search(r"Services \|? ?à \|? ?proximité \|? ?: \|(.+?)" r"(?=Formulaire|$)", text) if m: out["nearby"] = [s.strip() for s in m.group(1).split("|") if s.strip()][:12] images = [] for img in soup.select("main img[src], div[data-elementor-type] img[src]"): src = img["src"] if (src.startswith("http") and "/uploads/" in src and "Bandeau" not in src and "logo" not in src.lower() and src not in images): images.append(src) out["images"] = images[:15] return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return if d.get("services"): lst.amenities = list(dict.fromkeys(lst.amenities + d["services"])) if d.get("nearby"): lst.description = (lst.description + "\nÀ proximité : " + ", ".join(d["nearby"])).strip() if d.get("images") and len(d["images"]) > len(lst.images): lst.images = d["images"]