# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/serra.py : connecteur SERRA Montréal (serramtl.com) # Tour locative « tout inclus » au 1124, rue De Bleury (Ville-Marie, # centre-ville de Montréal) — studios, 3½ et 4½, bail minimal de 12 mois. # Site Rentsync (assets.rentsync.com/bleury1124) RENDU SERVEUR : la page # /availabilities publie le tableau des unités disponibles dans des # div.suite structurées (data-beds, data-sqft, data-building, # data-availability-date MMAAAA, numéro d'unité, plan d'étage PDF) et un # identifiant RentSync stable par unité (data-suite-id du bouton modal) # -> une annonce Lou-Ka par unité. Aucun prix public (ni sur le site ni # dans le JS — pas de flux LiftSystem pour ce client) : price_label vide, # la fiche reste utile (dispo, superficie, plan, tout inclus). # La description vient de /apartments et la galerie de /gallery (variante # /256/, la seule servie publiquement par le CDN pour ce client). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://www.serramtl.com" AVAIL_URL = f"{BASE}/availabilities" ABOUT_URL = f"{BASE}/apartments" GALLERY_URL = f"{BASE}/gallery" # adresses vérifiées par immeuble (jamais devinées : inconnu -> vide) _BUILDINGS = { "BLEURY": "1124, rue De Bleury, Montréal", } SECTOR = "Ville-Marie (centre-ville)" PHONE = "514-788-4385" # chambres (data-beds) -> type d'unité Lou-Ka _BED_TYPES = {"0": "Studio", "studio": "Studio", "1": "3½", "2": "4½", "3": "5½"} # galerie Rentsync de la page /gallery (le CDN ne sert que /256/) _IMG_RE = re.compile( r"https://assets\.rentsync\.com/bleury1124/images/gallery/[^\"'\\)\s]+" r"\.(?:jpg|jpeg|png|webp)", re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|thumbnail|theme-settings", re.I) # date structurée « 092026 » (MMAAAA) de data-availability-date _MMYYYY_RE = re.compile(r"^(0[1-9]|1[0-2])(20\d{2})$") class SerraConnector(BaseConnector): source_id = "serra" request_delay = 0.6 max_images = 15 def fetch(self) -> list[Listing]: html = self.get(AVAIL_URL).text soup = BeautifulSoup(html, "html.parser") description = self._description() gallery = self._gallery() listings: list[Listing] = [] seen: set[str] = set() for suite in soup.select("div.suite"): try: lst = self._suite_listing(suite, description, gallery) except Exception: continue if lst and lst.external_id not in seen: seen.add(lst.external_id) listings.append(lst) return listings # -- une annonce par unité disponible --------------------------------------- def _suite_listing(self, suite, description: str, gallery: list[str]) -> Listing | None: type_el = suite.select_one(".suite-type") unit = "" if type_el: unit = re.sub(r"^\s*Unit\s*", "", type_el.get_text(" ", strip=True)).strip() if not unit: return None building = (suite.get("data-building") or "").strip().upper() beds_raw = (suite.get("data-beds") or "").strip().lower() unit_type = _BED_TYPES.get(beds_raw, "") # superficie structurée (attribut + cellule) area = None try: v = float((suite.get("data-sqft") or "0").replace(",", "")) if 80 <= v <= 20000: area = v except ValueError: pass # disponibilité : texte de la cellule + date structurée MMAAAA avail_el = suite.select_one(".suite-availability") availability = "" if avail_el: availability = re.sub( r"^\s*Availability\s*", "", avail_el.get_text(" ", strip=True)).strip() avail_date = None m = _MMYYYY_RE.match((suite.get("data-availability-date") or "").strip()) if m: avail_date = f"{m.group(2)}-{m.group(1)}-01" elif (suite.get("data-availability-date") or "") == "000000" \ and not availability: availability = "Disponible maintenant" # identifiant RentSync stable de l'unité (bouton « Request Info ») sid = "" btn = suite.select_one("[data-suite-id]") if btn: sid = (btn.get("data-suite-id") or "").strip() ext_id = sid or f"{building.lower()}-{re.sub(r'[^0-9A-Za-z-]', '', unit)}" # plan d'étage PDF de l'unité (colonne structurée) details: dict = {"contact": {"phone": PHONE}} if building: details["building"] = building.title() plan = suite.select_one(".suite-floorplans a[href^='http']") if plan: details["floorplan"] = plan["href"] amenities = ["Tout inclus (chauffage, eau chaude, climatisation)"] if area: amenities.append(f"{int(area)} pi²") return Listing( source=self.source_id, external_id=ext_id, url=AVAIL_URL, title=f"SERRA Montréal — unité {unit}", address=_BUILDINGS.get(building, ""), sector=SECTOR, city="Montréal", unit_type=unit_type, price=None, # aucun prix public chez SERRA price_label="", availability=availability, availability_date=avail_date, description=description, amenities=amenities, details=details, images=gallery[: self.max_images], ) # -- description du projet (/apartments) ------------------------------------ def _description(self) -> str: default = ("SERRA Montréal — tour locative tout inclus au centre-ville " "(1124, rue De Bleury) : studios, 1 et 2 chambres, bail " "minimal de 12 mois.") try: soup = BeautifulSoup(self.get(ABOUT_URL).text, "html.parser") paras = [p.get_text(" ", strip=True) for p in soup.find_all("p")] paras = [p for p in paras if len(p) > 100] if paras: return " ".join(paras[:2])[:600] except Exception: pass return default # -- galerie (/gallery + page dispo) ---------------------------------------- def _gallery(self) -> list[str]: images: list[str] = [] for url in (GALLERY_URL, AVAIL_URL): try: html = self.get(url).text except Exception: continue for u in _IMG_RE.findall(html): if not _SKIP_IMG.search(u) and u not in images: images.append(u) if images: break return images