Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/serra.py : connecteur SERRA Montréal (serramtl.com)5# Tour locative « tout inclus » au 1124, rue De Bleury (Ville-Marie,6# centre-ville de Montréal) — studios, 3½ et 4½, bail minimal de 12 mois.7# Site Rentsync (assets.rentsync.com/bleury1124) RENDU SERVEUR : la page8# /availabilities publie le tableau des unités disponibles dans des9# div.suite structurées (data-beds, data-sqft, data-building,10# data-availability-date MMAAAA, numéro d'unité, plan d'étage PDF) et un11# identifiant RentSync stable par unité (data-suite-id du bouton modal)12# -> une annonce Lou-Ka par unité. Aucun prix public (ni sur le site ni13# dans le JS — pas de flux LiftSystem pour ce client) : price_label vide,14# la fiche reste utile (dispo, superficie, plan, tout inclus).15# La description vient de /apartments et la galerie de /gallery (variante16# /256/, la seule servie publiquement par le CDN pour ce client).17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re2122from bs4 import BeautifulSoup2324from ..schema import Listing25from .base import BaseConnector2627BASE = "https://www.serramtl.com"28AVAIL_URL = f"{BASE}/availabilities"29ABOUT_URL = f"{BASE}/apartments"30GALLERY_URL = f"{BASE}/gallery"3132# adresses vérifiées par immeuble (jamais devinées : inconnu -> vide)33_BUILDINGS = {34 "BLEURY": "1124, rue De Bleury, Montréal",35}36SECTOR = "Ville-Marie (centre-ville)"37PHONE = "514-788-4385"3839# chambres (data-beds) -> type d'unité Lou-Ka40_BED_TYPES = {"0": "Studio", "studio": "Studio", "1": "3½", "2": "4½",41 "3": "5½"}42# galerie Rentsync de la page /gallery (le CDN ne sert que /256/)43_IMG_RE = re.compile(44 r"https://assets\.rentsync\.com/bleury1124/images/gallery/[^\"'\\)\s]+"45 r"\.(?:jpg|jpeg|png|webp)", re.I)46_SKIP_IMG = re.compile(r"logo|icon|favicon|thumbnail|theme-settings", re.I)47# date structurée « 092026 » (MMAAAA) de data-availability-date48_MMYYYY_RE = re.compile(r"^(0[1-9]|1[0-2])(20\d{2})$")495051class SerraConnector(BaseConnector):52 source_id = "serra"53 request_delay = 0.654 max_images = 155556 def fetch(self) -> list[Listing]:57 html = self.get(AVAIL_URL).text58 soup = BeautifulSoup(html, "html.parser")5960 description = self._description()61 gallery = self._gallery()6263 listings: list[Listing] = []64 seen: set[str] = set()65 for suite in soup.select("div.suite"):66 try:67 lst = self._suite_listing(suite, description, gallery)68 except Exception:69 continue70 if lst and lst.external_id not in seen:71 seen.add(lst.external_id)72 listings.append(lst)73 return listings7475 # -- une annonce par unité disponible ---------------------------------------76 def _suite_listing(self, suite, description: str,77 gallery: list[str]) -> Listing | None:78 type_el = suite.select_one(".suite-type")79 unit = ""80 if type_el:81 unit = re.sub(r"^\s*Unit\s*", "",82 type_el.get_text(" ", strip=True)).strip()83 if not unit:84 return None8586 building = (suite.get("data-building") or "").strip().upper()87 beds_raw = (suite.get("data-beds") or "").strip().lower()88 unit_type = _BED_TYPES.get(beds_raw, "")8990 # superficie structurée (attribut + cellule)91 area = None92 try:93 v = float((suite.get("data-sqft") or "0").replace(",", ""))94 if 80 <= v <= 20000:95 area = v96 except ValueError:97 pass9899 # disponibilité : texte de la cellule + date structurée MMAAAA100 avail_el = suite.select_one(".suite-availability")101 availability = ""102 if avail_el:103 availability = re.sub(104 r"^\s*Availability\s*", "",105 avail_el.get_text(" ", strip=True)).strip()106 avail_date = None107 m = _MMYYYY_RE.match((suite.get("data-availability-date")108 or "").strip())109 if m:110 avail_date = f"{m.group(2)}-{m.group(1)}-01"111 elif (suite.get("data-availability-date") or "") == "000000" \112 and not availability:113 availability = "Disponible maintenant"114115 # identifiant RentSync stable de l'unité (bouton « Request Info »)116 sid = ""117 btn = suite.select_one("[data-suite-id]")118 if btn:119 sid = (btn.get("data-suite-id") or "").strip()120 ext_id = sid or f"{building.lower()}-{re.sub(r'[^0-9A-Za-z-]', '', unit)}"121122 # plan d'étage PDF de l'unité (colonne structurée)123 details: dict = {"contact": {"phone": PHONE}}124 if building:125 details["building"] = building.title()126 plan = suite.select_one(".suite-floorplans a[href^='http']")127 if plan:128 details["floorplan"] = plan["href"]129130 amenities = ["Tout inclus (chauffage, eau chaude, climatisation)"]131 if area:132 amenities.append(f"{int(area)} pi²")133134 return Listing(135 source=self.source_id,136 external_id=ext_id,137 url=AVAIL_URL,138 title=f"SERRA Montréal — unité {unit}",139 address=_BUILDINGS.get(building, ""),140 sector=SECTOR,141 city="Montréal",142 unit_type=unit_type,143 price=None, # aucun prix public chez SERRA144 price_label="",145 availability=availability,146 availability_date=avail_date,147 description=description,148 amenities=amenities,149 details=details,150 images=gallery[: self.max_images],151 )152153 # -- description du projet (/apartments) ------------------------------------154 def _description(self) -> str:155 default = ("SERRA Montréal — tour locative tout inclus au centre-ville "156 "(1124, rue De Bleury) : studios, 1 et 2 chambres, bail "157 "minimal de 12 mois.")158 try:159 soup = BeautifulSoup(self.get(ABOUT_URL).text, "html.parser")160 paras = [p.get_text(" ", strip=True) for p in soup.find_all("p")]161 paras = [p for p in paras if len(p) > 100]162 if paras:163 return " ".join(paras[:2])[:600]164 except Exception:165 pass166 return default167168 # -- galerie (/gallery + page dispo) ----------------------------------------169 def _gallery(self) -> list[str]:170 images: list[str] = []171 for url in (GALLERY_URL, AVAIL_URL):172 try:173 html = self.get(url).text174 except Exception:175 continue176 for u in _IMG_RE.findall(html):177 if not _SKIP_IMG.search(u) and u not in images:178 images.append(u)179 if images:180 break181 return images182