# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/mountbridge.py : connecteur MountBridge (mountbridge.ca) # Gestionnaire montréalais (~75 immeubles : Montréal, Longueuil, # Sainte-Thérèse…), inventaire complet incluant le projet ELEVA (2330 rue # Tupper) — pas de connecteur séparé pour ELEVA. WordPress/Elementor + # FacetWP mais les 75 URLs /property// sont dans le HTML statique de # /properties/. Chaque fiche affiche ses types d'unités disponibles en # rangées .e-loop-item : séquence de h6 label -> valeur (Beds, Baths, # Surface Area, Price « Starting at $1,595 », Availability Date). Une # annonce par type disponible, id stable -b-sdb. # Les fiches sont mises en cache BD (self.detail) clé = hash des rangées. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://mountbridge.ca" LIST_URL = f"{BASE}/properties/" _PROP_RE = re.compile(r"https://mountbridge\.ca/property/([a-z0-9-]+)/") _IMG_RE = re.compile( r'https://mountbridge\.ca/wp-content/uploads/[^"\'\s\\)]+?' r'\.(?:jpg|jpeg|webp)', re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,4}x\d{2,4}\.", re.I) _ADDR_RE = re.compile( r"\d{1,5}[^<>{}|]{3,70}?(?:QC|Québec|Quebec),?\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d") _PRICE_RE = re.compile(r"\$\s*([\d][\d,\s]*\d|\d)") # étiquettes des rangées .e-loop-item (tout autre h6 = valeur) _LABELS = {"available units", "beds", "baths", "surface area", "price", "availability date", "plan", "see plan"} # Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres) _BED_TYPE = {"studio": "Studio", "0": "Studio", "1": "3½", "2": "4½", "3": "5½", "4": "6½"} class MountbridgeConnector(BaseConnector): source_id = "mountbridge" request_delay = 0.6 max_properties = 90 # garde-fou (75 fiches à l'écriture) max_images = 20 def fetch(self) -> list[Listing]: page = self.get(LIST_URL).text slugs = list(dict.fromkeys(_PROP_RE.findall(page))) listings: list[Listing] = [] for i, slug in enumerate(slugs): if i >= self.max_properties: break try: listings.extend(self._property_listings(slug)) except Exception: continue return listings def _property_listings(self, slug: str) -> list[Listing]: url = f"{BASE}/property/{slug}/" d = self._fetch_property(url) rows = d.get("rows", []) if not rows: return [] # cache BD : la fiche complète n'est re-parsée que si les rangées # d'unités changent (le hash des rangées sert de clé) key = hashlib.sha1(json.dumps(rows, sort_keys=True) .encode("utf-8")).hexdigest() payload = self.detail(slug, key, lambda: d) name = payload.get("name") or slug address = payload.get("address") or "" city = payload.get("city") or "Montréal" desc = payload.get("desc") or "" amenities = payload.get("amenities") or [] images = payload.get("images") or [] out: list[Listing] = [] seen: dict[str, int] = {} for r in payload.get("rows", []): beds = (r.get("beds") or "").strip().lower() baths = (r.get("baths") or "").strip() pm = _PRICE_RE.search(r.get("price") or "") price = None if pm: try: price = float(re.sub(r"[^\d]", "", pm.group(1))) except ValueError: price = None if price is not None and not (100 <= price <= 20000): price = None ext = f"{slug}-{beds or 'x'}b-{baths or 'x'}sdb" seen[ext] = seen.get(ext, 0) + 1 if seen[ext] > 1: # doublon de type sur la même fiche ext = f"{ext}-{seen[ext]}" unit_type = _BED_TYPE.get(beds, "") try: bedrooms = 0.0 if beds == "studio" else float(beds) except ValueError: bedrooms = None try: bathrooms = float(baths) if baths else None except ValueError: bathrooms = None label = ("Studio" if beds == "studio" else f"{beds} chambre(s)" if beds else "") out.append(Listing( source=self.source_id, external_id=ext, url=url, title=f"{name} — {label}" if label else name, address=address, sector="", city=city, unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price=price, price_label=(f"À partir de {int(price)} $/mois" if price else (r.get("price") or "")), availability=r.get("avail") or "", area_sqft=None, description=desc, amenities=list(amenities), images=list(images), )) return out def _fetch_property(self, url: str) -> dict: """Scrape la fiche : nom (h1), adresse, description « About … », commodités (icônes SVG + libellé), galerie et rangées d'unités.""" out: dict = {"name": "", "address": "", "city": "Montréal", "desc": "", "amenities": [], "images": [], "rows": []} page = self.get(url).text soup = BeautifulSoup(page, "html.parser") if soup.h1: out["name"] = soup.h1.get_text(" ", strip=True) # adresse civique « 9675 rue Papineau, Montreal QC, H2B 3C8 » am = _ADDR_RE.search(soup.get_text(" ", strip=True)) if am: out["address"] = re.sub(r"\s+", " ", am.group(0)).strip() cm = re.search(r",\s*([^,]+?)\s+(?:QC|Québec|Quebec)", out["address"]) if cm: c = cm.group(1).strip() out["city"] = ("Montréal" if c.lower() in ("montreal", "montréal") else c) # description (« About ») h = soup.find("h2", string=re.compile(r"^About\s|^À propos")) if h: nxt = h.find_next("p") if nxt: out["desc"] = nxt.get_text(" ", strip=True)[:600] # commodités : icône SVG suivie du libellé (span) amenities: list[str] = [] for img in soup.select('img[src$=".svg"]'): span = img.find_next("span") if not span: continue t = span.get_text(" ", strip=True) if t and len(t) < 60 and t not in amenities: amenities.append(t) out["amenities"] = amenities[:25] # galerie photos (jpg/webp du site, sans logos/miniatures) images: list[str] = [] for u in _IMG_RE.findall(page): if _SKIP_IMG.search(u): continue if u not in images: images.append(u) out["images"] = images[: self.max_images] # rangées d'unités disponibles : h6 label -> h6 valeur for li in soup.select(".e-loop-item"): texts = [h.get_text(" ", strip=True) for h in li.select("h6")] row: dict = {} i = 0 while i < len(texts): lab = texts[i].strip().lower() val = "" if (i + 1 < len(texts) and texts[i + 1].strip().lower() not in _LABELS): val = texts[i + 1].strip() i += 1 if lab == "beds": row["beds"] = val elif lab == "baths": row["baths"] = val elif lab == "surface area": row["sqft"] = val elif lab == "price": row["price"] = val elif lab == "availability date": row["avail"] = val i += 1 if row.get("beds") or row.get("price"): out["rows"].append(row) return out