# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/loftsmtl.py : connecteur Lofts MTL (loftsmtl.com) # Lofts et appartements — Mile End, Plateau, Vieux-Montréal, # Ville Mont-Royal. Plateforme Rentsync/LiftSystem : l'inventaire des # immeubles vient de l'API publique api.theliftsystem.com (client_id 773, # jeton public embarqué dans le site), puis chaque page immeuble # (rendu serveur) expose les unités disponibles (div.suite : type, prix, # pi², photos, disponibilité) et la section « Amenities » (commodités de # l'unité et de l'immeuble). L'API fournit aussi lat/lng, code postal, # pet_friendly (booléen) et le téléphone du gestionnaire. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector SITE = "https://www.loftsmtl.com" API_URL = ("https://api.theliftsystem.com/v2/search" "?locale=en&client_id=773&auth_token=sswpREkUtyeYjeoahA2i" "&show_all_properties=true&limit=200") # Villes de la région de Montréal telles que renvoyées par l'API _CITY_MAP = { "montréal": "Montréal", "montreal": "Montréal", "mont-royal": "Mont-Royal", "mount royal": "Mont-Royal", "westmount": "Westmount", "outremont": "Montréal", } def _suite_type(raw: str) -> str: s = (raw or "").strip().lower() if "studio" in s or "loft" in s and not re.search(r"\d", s): return "Studio" if "studio" in s else "Loft" m = re.search(r"(\d)\s*(?:1/2|½)", s) if m: return f"{m.group(1)}½" m = re.search(r"(\d)\s*bed", s) if m: return {0: "Studio", 1: "3½", 2: "4½", 3: "5½"}.get( int(m.group(1)), f"{m.group(1)} chambres") return normalize_unit_type(raw) def _parse_price_us(raw: str) -> float | None: m = re.search(r"\$\s*([\d,\s]+(?:\.\d{2})?)", raw or "") if not m: return None try: val = float(m.group(1).replace(",", "").replace(" ", "").replace(" ", "")) except ValueError: return None return val if 100 <= val <= 20000 else None def _strip_html(raw: str) -> str: return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", raw or "")).strip() class LoftsMtlConnector(BaseConnector): source_id = "loftsmtl" request_delay = 0.6 max_buildings = 25 # garde-fou def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: buildings = self.get(API_URL).json() except Exception: return listings if not isinstance(buildings, list): return listings for i, b in enumerate(buildings): if i >= self.max_buildings: break try: listings.extend(self._building_listings(b)) except Exception: continue return listings # -- une page immeuble -> annonces par unité disponible --------------------- def _building_listings(self, b: dict) -> list[Listing]: addr = b.get("address") or {} raw_city = (addr.get("city") or "").strip() city = _CITY_MAP.get(raw_city.lower()) if not city: return [] # hors région de Montréal sector = (addr.get("neighbourhood") or "").strip() if sector.lower() == "town of mount royal": sector = "" if city == "Mont-Royal" else sector bid = b.get("id") name = (b.get("name") or "").strip() address = (addr.get("address") or "").strip() postal = (addr.get("postal_code") or "").strip() if postal: address = f"{address}, {postal}" if address else postal permalink = (b.get("permalink") or "").strip() or SITE desc = _strip_html((b.get("details") or {}).get("overview", ""))[:600] # lat/lng structurés de l'API geo = b.get("geocode") or {} try: lat, lng = float(geo.get("latitude")), float(geo.get("longitude")) except (TypeError, ValueError): lat = lng = None # politique animaux structurée de l'API pf = b.get("pet_friendly") pets = {True: "oui", False: "non"}.get(pf) # téléphone du gestionnaire (structuré) phone = ((b.get("contact") or {}).get("phone") or (b.get("client") or {}).get("phone") or "").strip() # La page immeuble sert /apartments/ ou /residential/ html = "" for url in (permalink, permalink.replace("/apartments/", "/residential/")): try: html = self.get(url).text break except Exception: continue if not html: return [] soup = BeautifulSoup(html, "html.parser") # section « Amenities » : commodités de l'unité + de l'immeuble bldg_amenities: list[str] = [] for holder in soup.select(".amenities .amenity-holder"): label = " ".join(holder.get_text(" ", strip=True).split()) if label and label not in bldg_amenities: bldg_amenities.append(label) results: list[Listing] = [] for suite in soup.select("div.suite"): try: lst = self._parse_suite(suite, b, city, sector, name, address, permalink, desc, bldg_amenities, lat, lng, pets, phone) except Exception: continue if lst: results.append(lst) return results def _parse_suite(self, suite, b, city, sector, name, address, permalink, desc, bldg_amenities, lat, lng, pets, phone) -> Listing | None: type_el = suite.select_one(".suite-type") if not type_el: return None raw_type = type_el.get_text(" ", strip=True) num_el = suite.select_one(".suite-number") number = num_el.get_text(" ", strip=True) if num_el else "" rate_el = suite.select_one(".suite-rate .value") or \ suite.select_one(".suite-rate") price_label = rate_el.get_text(" ", strip=True) if rate_el else "" price = _parse_price_us(price_label) sqft_el = suite.select_one(".suite-sqft .value") sqft = sqft_el.get_text(strip=True) if sqft_el else "" bath_el = suite.select_one(".suite-bath .value") baths = bath_el.get_text(strip=True) if bath_el else "" avail_el = suite.select_one(".suite-availability") availability = avail_el.get_text(" ", strip=True) if avail_el else "" availability = re.sub(r"^Availab\w*\s*", "", availability).strip() photos = [a.get("href") for a in suite.select("a.suite-photo") if a.get("href")] photos = list(dict.fromkeys(photos))[:30] # id stable : rel="suite-995148-photos" sinon immeuble+numéro sid = "" first = suite.select_one("a.suite-photo[rel]") if first: rel = first.get("rel") or "" if isinstance(rel, (list, tuple)): rel = " ".join(rel) m = re.match(r"suite-(\d+)", rel) if m: sid = m.group(1) ext_id = sid or f"{b.get('id')}-{re.sub(r'[^0-9A-Za-z-]', '', number)}" amenities = [] if baths: amenities.append(f"{baths} salle(s) de bain") if sqft and sqft != "0": amenities.append(f"{sqft} pi²") for label in bldg_amenities: if label not in amenities: amenities.append(label) # superficie structurée (cellule .suite-sqft de la page immeuble) area_sqft = None try: v = float(sqft.replace(",", "")) if v >= 80: area_sqft = v except (AttributeError, ValueError): pass details: dict = {} if phone: details["contact"] = {"phone": phone} return Listing( source=self.source_id, external_id=str(ext_id), url=permalink, title=f"{name} — unité {number}" if number else name, address=address, sector=sector, city=city, unit_type=_suite_type(raw_type), price=price, price_label=f"{price_label}/mo" if price_label else "", availability=availability or "Disponible", area_sqft=area_sqft, pets=pets, description=desc, amenities=amenities, details=details, images=photos, lat=lat, lng=lng, )