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/mountbridge.py : connecteur MountBridge (mountbridge.ca)5# Gestionnaire montréalais (~75 immeubles : Montréal, Longueuil,6# Sainte-Thérèse…), inventaire complet incluant le projet ELEVA (2330 rue7# Tupper) — pas de connecteur séparé pour ELEVA. WordPress/Elementor +8# FacetWP mais les 75 URLs /property/<slug>/ sont dans le HTML statique de9# /properties/. Chaque fiche affiche ses types d'unités disponibles en10# rangées .e-loop-item : séquence de h6 label -> valeur (Beds, Baths,11# Surface Area, Price « Starting at $1,595 », Availability Date). Une12# annonce par type disponible, id stable <slug>-<beds>b-<baths>sdb.13# Les fiches sont mises en cache BD (self.detail) clé = hash des rangées.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import json19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing24from .base import BaseConnector2526BASE = "https://mountbridge.ca"27LIST_URL = f"{BASE}/properties/"2829_PROP_RE = re.compile(r"https://mountbridge\.ca/property/([a-z0-9-]+)/")30_IMG_RE = re.compile(31 r'https://mountbridge\.ca/wp-content/uploads/[^"\'\s\\)]+?'32 r'\.(?:jpg|jpeg|webp)', re.I)33_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,4}x\d{2,4}\.", re.I)34_ADDR_RE = re.compile(35 r"\d{1,5}[^<>{}|]{3,70}?(?:QC|Québec|Quebec),?\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d")36_PRICE_RE = re.compile(r"\$\s*([\d][\d,\s]*\d|\d)")37# étiquettes des rangées .e-loop-item (tout autre h6 = valeur)38_LABELS = {"available units", "beds", "baths", "surface area", "price",39 "availability date", "plan", "see plan"}4041# Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres)42_BED_TYPE = {"studio": "Studio", "0": "Studio", "1": "3½", "2": "4½",43 "3": "5½", "4": "6½"}444546class MountbridgeConnector(BaseConnector):47 source_id = "mountbridge"48 request_delay = 0.649 max_properties = 90 # garde-fou (75 fiches à l'écriture)50 max_images = 205152 def fetch(self) -> list[Listing]:53 page = self.get(LIST_URL).text54 slugs = list(dict.fromkeys(_PROP_RE.findall(page)))5556 listings: list[Listing] = []57 for i, slug in enumerate(slugs):58 if i >= self.max_properties:59 break60 try:61 listings.extend(self._property_listings(slug))62 except Exception:63 continue64 return listings6566 def _property_listings(self, slug: str) -> list[Listing]:67 url = f"{BASE}/property/{slug}/"68 d = self._fetch_property(url)69 rows = d.get("rows", [])70 if not rows:71 return []72 # cache BD : la fiche complète n'est re-parsée que si les rangées73 # d'unités changent (le hash des rangées sert de clé)74 key = hashlib.sha1(json.dumps(rows, sort_keys=True)75 .encode("utf-8")).hexdigest()76 payload = self.detail(slug, key, lambda: d)7778 name = payload.get("name") or slug79 address = payload.get("address") or ""80 city = payload.get("city") or "Montréal"81 desc = payload.get("desc") or ""82 amenities = payload.get("amenities") or []83 images = payload.get("images") or []8485 out: list[Listing] = []86 seen: dict[str, int] = {}87 for r in payload.get("rows", []):88 beds = (r.get("beds") or "").strip().lower()89 baths = (r.get("baths") or "").strip()90 pm = _PRICE_RE.search(r.get("price") or "")91 price = None92 if pm:93 try:94 price = float(re.sub(r"[^\d]", "", pm.group(1)))95 except ValueError:96 price = None97 if price is not None and not (100 <= price <= 20000):98 price = None99100 ext = f"{slug}-{beds or 'x'}b-{baths or 'x'}sdb"101 seen[ext] = seen.get(ext, 0) + 1102 if seen[ext] > 1: # doublon de type sur la même fiche103 ext = f"{ext}-{seen[ext]}"104105 unit_type = _BED_TYPE.get(beds, "")106 try:107 bedrooms = 0.0 if beds == "studio" else float(beds)108 except ValueError:109 bedrooms = None110 try:111 bathrooms = float(baths) if baths else None112 except ValueError:113 bathrooms = None114115 label = ("Studio" if beds == "studio"116 else f"{beds} chambre(s)" if beds else "")117 out.append(Listing(118 source=self.source_id,119 external_id=ext,120 url=url,121 title=f"{name} — {label}" if label else name,122 address=address,123 sector="",124 city=city,125 unit_type=unit_type,126 bedrooms=bedrooms,127 bathrooms=bathrooms,128 price=price,129 price_label=(f"À partir de {int(price)} $/mois"130 if price else (r.get("price") or "")),131 availability=r.get("avail") or "",132 area_sqft=None,133 description=desc,134 amenities=list(amenities),135 images=list(images),136 ))137 return out138139 def _fetch_property(self, url: str) -> dict:140 """Scrape la fiche : nom (h1), adresse, description « About … »,141 commodités (icônes SVG + libellé), galerie et rangées d'unités."""142 out: dict = {"name": "", "address": "", "city": "Montréal",143 "desc": "", "amenities": [], "images": [], "rows": []}144 page = self.get(url).text145 soup = BeautifulSoup(page, "html.parser")146147 if soup.h1:148 out["name"] = soup.h1.get_text(" ", strip=True)149150 # adresse civique « 9675 rue Papineau, Montreal QC, H2B 3C8 »151 am = _ADDR_RE.search(soup.get_text(" ", strip=True))152 if am:153 out["address"] = re.sub(r"\s+", " ", am.group(0)).strip()154 cm = re.search(r",\s*([^,]+?)\s+(?:QC|Québec|Quebec)",155 out["address"])156 if cm:157 c = cm.group(1).strip()158 out["city"] = ("Montréal" if c.lower() in159 ("montreal", "montréal") else c)160161 # description (« About <nom> »)162 h = soup.find("h2", string=re.compile(r"^About\s|^À propos"))163 if h:164 nxt = h.find_next("p")165 if nxt:166 out["desc"] = nxt.get_text(" ", strip=True)[:600]167168 # commodités : icône SVG suivie du libellé (span)169 amenities: list[str] = []170 for img in soup.select('img[src$=".svg"]'):171 span = img.find_next("span")172 if not span:173 continue174 t = span.get_text(" ", strip=True)175 if t and len(t) < 60 and t not in amenities:176 amenities.append(t)177 out["amenities"] = amenities[:25]178179 # galerie photos (jpg/webp du site, sans logos/miniatures)180 images: list[str] = []181 for u in _IMG_RE.findall(page):182 if _SKIP_IMG.search(u):183 continue184 if u not in images:185 images.append(u)186 out["images"] = images[: self.max_images]187188 # rangées d'unités disponibles : h6 label -> h6 valeur189 for li in soup.select(".e-loop-item"):190 texts = [h.get_text(" ", strip=True) for h in li.select("h6")]191 row: dict = {}192 i = 0193 while i < len(texts):194 lab = texts[i].strip().lower()195 val = ""196 if (i + 1 < len(texts)197 and texts[i + 1].strip().lower() not in _LABELS):198 val = texts[i + 1].strip()199 i += 1200 if lab == "beds":201 row["beds"] = val202 elif lab == "baths":203 row["baths"] = val204 elif lab == "surface area":205 row["sqft"] = val206 elif lab == "price":207 row["price"] = val208 elif lab == "availability date":209 row["avail"] = val210 i += 1211 if row.get("beds") or row.get("price"):212 out["rows"].append(row)213 return out214