SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.8 KB · 237 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/loftsmtl.py : connecteur Lofts MTL (loftsmtl.com)5#   Lofts et appartements — Mile End, Plateau, Vieux-Montréal,6#   Ville Mont-Royal. Plateforme Rentsync/LiftSystem : l'inventaire des7#   immeubles vient de l'API publique api.theliftsystem.com (client_id 773,8#   jeton public embarqué dans le site), puis chaque page immeuble9#   (rendu serveur) expose les unités disponibles (div.suite : type, prix,10#   pi², photos, disponibilité) et la section « Amenities » (commodités de11#   l'unité et de l'immeuble). L'API fournit aussi lat/lng, code postal,12#   pet_friendly (booléen) et le téléphone du gestionnaire.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223SITE = "https://www.loftsmtl.com"24API_URL = ("https://api.theliftsystem.com/v2/search"25           "?locale=en&client_id=773&auth_token=sswpREkUtyeYjeoahA2i"26           "&show_all_properties=true&limit=200")2728# Villes de la région de Montréal telles que renvoyées par l'API29_CITY_MAP = {30    "montréal": "Montréal", "montreal": "Montréal",31    "mont-royal": "Mont-Royal", "mount royal": "Mont-Royal",32    "westmount": "Westmount", "outremont": "Montréal",33}343536def _suite_type(raw: str) -> str:37    s = (raw or "").strip().lower()38    if "studio" in s or "loft" in s and not re.search(r"\d", s):39        return "Studio" if "studio" in s else "Loft"40    m = re.search(r"(\d)\s*(?:1/2|½)", s)41    if m:42        return f"{m.group(1)}½"43    m = re.search(r"(\d)\s*bed", s)44    if m:45        return {0: "Studio", 1: "3½", 2: "4½", 3: "5½"}.get(46            int(m.group(1)), f"{m.group(1)} chambres")47    return normalize_unit_type(raw)484950def _parse_price_us(raw: str) -> float | None:51    m = re.search(r"\$\s*([\d,\s]+(?:\.\d{2})?)", raw or "")52    if not m:53        return None54    try:55        val = float(m.group(1).replace(",", "").replace(" ", "").replace(" ", ""))56    except ValueError:57        return None58    return val if 100 <= val <= 20000 else None596061def _strip_html(raw: str) -> str:62    return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", raw or "")).strip()636465class LoftsMtlConnector(BaseConnector):66    source_id = "loftsmtl"67    request_delay = 0.668    max_buildings = 25       # garde-fou6970    def fetch(self) -> list[Listing]:71        listings: list[Listing] = []72        try:73            buildings = self.get(API_URL).json()74        except Exception:75            return listings76        if not isinstance(buildings, list):77            return listings7879        for i, b in enumerate(buildings):80            if i >= self.max_buildings:81                break82            try:83                listings.extend(self._building_listings(b))84            except Exception:85                continue86        return listings8788    # -- une page immeuble -> annonces par unité disponible ---------------------89    def _building_listings(self, b: dict) -> list[Listing]:90        addr = b.get("address") or {}91        raw_city = (addr.get("city") or "").strip()92        city = _CITY_MAP.get(raw_city.lower())93        if not city:94            return []                    # hors région de Montréal95        sector = (addr.get("neighbourhood") or "").strip()96        if sector.lower() == "town of mount royal":97            sector = "" if city == "Mont-Royal" else sector98        bid = b.get("id")99        name = (b.get("name") or "").strip()100        address = (addr.get("address") or "").strip()101        postal = (addr.get("postal_code") or "").strip()102        if postal:103            address = f"{address}, {postal}" if address else postal104        permalink = (b.get("permalink") or "").strip() or SITE105        desc = _strip_html((b.get("details") or {}).get("overview", ""))[:600]106107        # lat/lng structurés de l'API108        geo = b.get("geocode") or {}109        try:110            lat, lng = float(geo.get("latitude")), float(geo.get("longitude"))111        except (TypeError, ValueError):112            lat = lng = None113114        # politique animaux structurée de l'API115        pf = b.get("pet_friendly")116        pets = {True: "oui", False: "non"}.get(pf)117118        # téléphone du gestionnaire (structuré)119        phone = ((b.get("contact") or {}).get("phone")120                 or (b.get("client") or {}).get("phone") or "").strip()121122        # La page immeuble sert /apartments/<slug> ou /residential/<slug>123        html = ""124        for url in (permalink,125                    permalink.replace("/apartments/", "/residential/")):126            try:127                html = self.get(url).text128                break129            except Exception:130                continue131        if not html:132            return []133        soup = BeautifulSoup(html, "html.parser")134135        # section « Amenities » : commodités de l'unité + de l'immeuble136        bldg_amenities: list[str] = []137        for holder in soup.select(".amenities .amenity-holder"):138            label = " ".join(holder.get_text(" ", strip=True).split())139            if label and label not in bldg_amenities:140                bldg_amenities.append(label)141142        results: list[Listing] = []143        for suite in soup.select("div.suite"):144            try:145                lst = self._parse_suite(suite, b, city, sector, name,146                                        address, permalink, desc,147                                        bldg_amenities, lat, lng, pets, phone)148            except Exception:149                continue150            if lst:151                results.append(lst)152        return results153154    def _parse_suite(self, suite, b, city, sector, name, address,155                     permalink, desc, bldg_amenities, lat, lng, pets,156                     phone) -> Listing | None:157        type_el = suite.select_one(".suite-type")158        if not type_el:159            return None160        raw_type = type_el.get_text(" ", strip=True)161        num_el = suite.select_one(".suite-number")162        number = num_el.get_text(" ", strip=True) if num_el else ""163164        rate_el = suite.select_one(".suite-rate .value") or \165            suite.select_one(".suite-rate")166        price_label = rate_el.get_text(" ", strip=True) if rate_el else ""167        price = _parse_price_us(price_label)168169        sqft_el = suite.select_one(".suite-sqft .value")170        sqft = sqft_el.get_text(strip=True) if sqft_el else ""171        bath_el = suite.select_one(".suite-bath .value")172        baths = bath_el.get_text(strip=True) if bath_el else ""173174        avail_el = suite.select_one(".suite-availability")175        availability = avail_el.get_text(" ", strip=True) if avail_el else ""176        availability = re.sub(r"^Availab\w*\s*", "", availability).strip()177178        photos = [a.get("href") for a in suite.select("a.suite-photo")179                  if a.get("href")]180        photos = list(dict.fromkeys(photos))[:30]181182        # id stable : rel="suite-995148-photos" sinon immeuble+numéro183        sid = ""184        first = suite.select_one("a.suite-photo[rel]")185        if first:186            rel = first.get("rel") or ""187            if isinstance(rel, (list, tuple)):188                rel = " ".join(rel)189            m = re.match(r"suite-(\d+)", rel)190            if m:191                sid = m.group(1)192        ext_id = sid or f"{b.get('id')}-{re.sub(r'[^0-9A-Za-z-]', '', number)}"193194        amenities = []195        if baths:196            amenities.append(f"{baths} salle(s) de bain")197        if sqft and sqft != "0":198            amenities.append(f"{sqft} pi²")199        for label in bldg_amenities:200            if label not in amenities:201                amenities.append(label)202203        # superficie structurée (cellule .suite-sqft de la page immeuble)204        area_sqft = None205        try:206            v = float(sqft.replace(",", ""))207            if v >= 80:208                area_sqft = v209        except (AttributeError, ValueError):210            pass211212        details: dict = {}213        if phone:214            details["contact"] = {"phone": phone}215216        return Listing(217            source=self.source_id,218            external_id=str(ext_id),219            url=permalink,220            title=f"{name} — unité {number}" if number else name,221            address=address,222            sector=sector,223            city=city,224            unit_type=_suite_type(raw_type),225            price=price,226            price_label=f"{price_label}/mo" if price_label else "",227            availability=availability or "Disponible",228            area_sqft=area_sqft,229            pets=pets,230            description=desc,231            amenities=amenities,232            details=details,233            images=photos,234            lat=lat,235            lng=lng,236        )237