# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/bati5.py : connecteur Bâti5 — Boisés Dion (www.bati5.ca) # Condos locatifs neufs (sixplex) au 7150, rue Jean-Claude-Langlois, # Boisbriand (quartier Boisés Dion). Zoho Sites rendu SERVEUR : la page # d'accueil contient des cartes typologie « n½ [+ bureau] (niveau X) … # à partir de NNNN $ [PROMO* $] … 1 unité disponible pour … » ou # « Tous loués… ». On ignore les segments « masqué: … » (contenu caché par # l'éditeur) et on NE GARDE que les typologies avec « n unité(s) # disponible(s) ». Granularité : TYPOLOGIE (prix « à partir de »). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import re from ..schema import Listing from .base import BaseConnector BASE = "https://www.bati5.ca" ADDRESS = "7150, rue Jean-Claude-Langlois, Boisbriand" CITY = "Boisbriand" # « 5½ + bureau (niveau RDC) 3 CHAMBRES + BUREAU + 2 SALLES DE BAIN # à partir de 2560 $ 2450* $ 1 unité disponible pour 1 er mai 2026 » CARD_RE = re.compile( r"(\d)\s*½\s*(\+\s*bureau)?\s*\(niveau\s*([^)]*)\)\s*" # typo, bureau, niv. r"(.{0,200}?)à\s*partir\s*de\s*(\d[\d\s]*)\s*\$" # desc, prix r"(?:\s*(\d[\d\s]*)\*\s*\$)?" # prix promo* r"(.{0,260}?)" # queue (dispo) r"(?=\d\s*½\s*(?:\+\s*bureau\s*)?\(niveau|Demander une visite|$)", re.S) MASQUE_RE = re.compile(r"masqué\s*:\s*.*?(?=Tous lou|Demander|$)", re.S | re.I) AVAIL_RE = re.compile( r"\d+\s*unités?\s*disponibles?[^.]{0,60}?(?:\d{4}|$)", re.I) IMG_RE = re.compile( r"https://www\.bati5\.ca/files/[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I) SKIP_IMG_RE = re.compile(r"logo|favicon|icon|bati5-01|_\d{2,4}x\d{2,4}\.", re.I) class Bati5Connector(BaseConnector): source_id = "bati5" request_delay = 0.6 max_images = 10 def fetch(self) -> list[Listing]: raw = self.get(f"{BASE}/").text images = [u for u in dict.fromkeys(IMG_RE.findall(raw)) if not SKIP_IMG_RE.search(u)][: self.max_images] text = _html.unescape(raw) text = re.sub(r"", " ", text, flags=re.S) text = re.sub(r"", " ", text, flags=re.S) text = re.sub(r"<[^>]+>", " ", text) text = re.sub(r"\s+", " ", text) listings: list[Listing] = [] seen: set[str] = set() for n, bureau, niveau, desc, price_raw, promo_raw, tail \ in CARD_RE.findall(text): tail = MASQUE_RE.sub(" ", tail) m = AVAIL_RE.search(tail) if m is None: # « Tous loués… » -> on saute continue availability = re.sub(r"\b(\d+)\s*er\b", r"\1er", re.sub(r"\s+", " ", m.group(0))).strip() unit_type = f"{n}½" variant = "bureau" if bureau else niveau.strip().lower() or "std" ext_id = f"bati5-{n}.5-{re.sub(r'[^a-z0-9]+', '-', variant)}" if ext_id in seen: continue seen.add(ext_id) price = None amount = (promo_raw or price_raw).replace(" ", "").replace(" ", "") try: v = float(amount) if 300 <= v <= 20000: price = v except ValueError: pass label_bits = [f"à partir de {price_raw.strip()}$"] if promo_raw: label_bits.append(f"{promo_raw.strip()}$ en promotion") beds = None m = re.search(r"(\d)\s*chambres?", desc, re.I) if m: beds = float(m.group(1)) title = f"Bâti5 — Boisés Dion — {unit_type}" \ + (" + bureau" if bureau else "") \ + (f" (niveau {niveau.strip()})" if niveau.strip() else "") listings.append(Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}/", title=title, address=ADDRESS, city=CITY, unit_type=unit_type, bedrooms=beds, price=price, price_label=" — ".join(label_bits), availability=availability, description=re.sub(r"\s+", " ", desc).strip().capitalize(), images=list(images), )) return listings