spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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