SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
7.0 KB · 182 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/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