# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/boardwalk.py : connecteur Boardwalk REIT (bwalk.com) # Pages de villes de la région de Montréal rendues serveur (HubSpot CMS) : # Longueuil, Laval, Île-des-Sœurs/Verdun, Ville Saint-Laurent. Fiches # propriétés avec cartes de types de suites (« À partir de … $ ») qui # portent aussi superficie (pi ca), c. à c./sdb et le statut (« N logements # disponibles » / « liste d'attente ») ; adresse complète + téléphone dans # l'en-tête (p.address-and-phone). Les pages de suites ne servent plus qu'aux # photos spécifiques, via le cache self.detail(). # REIT pancanadien : seules les propriétés du Grand Montréal sont couvertes. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.bwalk.com" # Pages de villes du Grand Montréal -> (ville réelle, secteur par défaut) CITY_PAGES = { f"{BASE}/fr-ca/appartements-a-louer-a-montreal-longueuil": ("Longueuil", ""), f"{BASE}/fr-ca/appartements-a-louer-a-montreal-laval": ("Laval", ""), f"{BASE}/fr-ca/appartements-a-louer-a-montreal-nuns-island-verdun": ("Montréal", "Île-des-Sœurs (Verdun)"), f"{BASE}/fr-ca/appartements-a-louer-a-montreal-ville-saint-laurent": ("Montréal", "Saint-Laurent"), } PROP_RE = re.compile( r'href="(/fr-ca/appartements-a-louer-a-montreal-[a-z0-9\-]+/' r'[a-z0-9\-]+/[a-z0-9\-]+)"') PRICE_RE = re.compile(r'À partir de\s*([\d\s\u00a0,.]+\$)') # superficie : « 900 », « 648 - 760 », « 1 408 » (séparateur de milliers) — # le nombre de chambres qui suit (« 3 c. à c. ») ne doit pas être aspiré AREA_RE = re.compile( r'Superficie\s*\(pi\s*ca\)\s*:?\s*([\d\s\u00a0-]+?)' r'\s*(?:\d+\s*c\.\s*à\s*c\.|\||sdb|[A-Za-z]|$)') BEDS_RE = re.compile(r'(\d+)\s*c\.\s*à\s*c\.') BATHS_RE = re.compile(r'(\d+)\s*sdb') PHONE_RE = re.compile(r'\(?(\d{3})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})') _BED_TYPES = {1: "3½", 2: "4½", 3: "5½", 4: "6½"} def _suite_type(name: str) -> str: """« 3 1/2 Pièces » -> 3½ ; « Maison en Rangée avec N Chambres » -> Maison ; « 2 Chambres » -> 4½.""" low = name.lower() if "maison" in low or "rang" in low: return "Maison" m = re.search(r"(\d)\s*chambre", low) if m: return _BED_TYPES.get(int(m.group(1)), "") return normalize_unit_type(name) class BoardwalkConnector(BaseConnector): source_id = "boardwalk" request_delay = 0.6 max_properties = 25 # garde-fou max_images = 25 def fetch(self) -> list[Listing]: listings: list[Listing] = [] seen: set[str] = set() count = 0 for city_url, (city, default_sector) in CITY_PAGES.items(): try: html = self.get(city_url).text except Exception: continue for path in dict.fromkeys(PROP_RE.findall(html)): if path in seen: continue seen.add(path) if count >= self.max_properties: break count += 1 try: listings.extend( self._property_listings(path, city, default_sector)) except Exception: continue # Unicité des external_id (deux cartes peuvent pointer vers la même # page de type de suite avec des prix différents) used: dict[str, int] = {} for lst in listings: n = used.get(lst.external_id, 0) used[lst.external_id] = n + 1 if n: lst.external_id = f"{lst.external_id}-{n + 1}" return listings def _property_listings(self, path: str, city: str, default_sector: str) -> list[Listing]: url = BASE + path prop_slug = path.rstrip("/").split("/")[-1] html = self.get(url).text soup = BeautifulSoup(html, "html.parser") t = soup.find("title") name = (t.get_text(strip=True).split("|")[0].strip() if t else prop_slug.replace("-", " ")) # Adresse complète + téléphone : en-tête
address = "" phone = "" ap = soup.select_one("p.address-and-phone") if ap: ap_text = re.sub(r"\s+", " ", ap.get_text(" ", strip=True)) pm_ = PHONE_RE.search(ap_text) if pm_: phone = "-".join(pm_.groups()) ap_text = ap_text[: pm_.start()].strip(" ,") address = ap_text if not address: # repli : texte alt des photos « photo de la propriété pour le … » am = re.search( r'photo de la propri[ée]t[ée] pour le\s*([^"<>]{5,90})', html) if am: address = am.group(1).strip() # Galerie photo (swiper) images: list[str] = [] for img in soup.select("img[src]"): src = img.get("src", "") if re.search(r"hubfs/.*(bw_properties|Web%20Photos|Web Photos)", src) and src.startswith("http"): if src not in images: images.append(src) images = images[: self.max_images] # Description de la propriété desc = "" dh = soup.find(string=re.compile("Description de la propriété")) if dh: sec = dh.find_parent() nxt = sec.find_next("p") if sec else None if nxt: desc = nxt.get_text(" ", strip=True)[:600] if not desc: og = soup.find("meta", attrs={"property": "og:description"}) if og and og.get("content"): desc = og["content"].strip()[:600] # Commodités (« Caractéristiques et espaces d'agrément ») amenities: list[str] = [] ah = soup.find(string=re.compile("Caractéristiques et espaces")) if ah: cont = ah.find_parent() for _ in range(3): if cont is None: break lis = cont.find_all("li") if lis: break cont = cont.parent if cont: for li in cont.find_all("li")[:25]: txt = li.get_text(" ", strip=True) if txt and len(txt) < 50 and txt not in amenities: amenities.append(txt) # Cartes de types de suites :