# ----------------------------------------------------------------------------- # 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 :

# La carte porte prix, superficie (pi ca), c. à c. et sdb ; le groupe # parent (div.unit_type) porte le statut (« N logements disponibles » # ou « liste d'attente »). Plus besoin de visiter la fiche de suite # pour ces champs — elle ne sert qu'aux photos (via self.detail). listings: list[Listing] = [] for h4 in soup.select("h4.highlight-suite"): try: a = h4.find("a") if not a: continue suite_name = a.get_text(" ", strip=True) suite_href = a.get("href") or "" suite_url = (BASE + suite_href if suite_href.startswith("/") else suite_href) card = h4.parent card_text = re.sub(r"\s+", " ", card.get_text(" ", strip=True) if card else "") price_label = "" pm = PRICE_RE.search(card_text) if pm: price_label = "À partir de " + pm.group(1).strip() price = parse_price(price_label) # Superficie / chambres / sdb depuis la carte elle-même detail_bits: list[str] = [] suite_amenities = amenities am_ = AREA_RE.search(card_text) if am_: rng = re.sub(r"[\s ]+", " ", am_.group(1)).strip() detail_bits.append(f"Superficie (pi ca) : {rng}") lo = re.sub(r"[\s\u00a0]", "", rng.split("-")[0]) if lo.isdigit(): # borne basse -> texte brut, finalize() dérive area_sqft suite_amenities = amenities + [f"{lo} pi ca"] bm = BEDS_RE.search(card_text) if bm: detail_bits.append(f"{bm.group(1)} c. à c.") sm = BATHS_RE.search(card_text) if sm: detail_bits.append(f"{sm.group(1)} sdb") # Statut du groupe : disponible ou liste d'attente availability = "Disponible" if re.search(r"liste d.attente", card_text, re.I): availability = "Sur la liste d'attente" else: group = card.find_parent(class_="unit_type") if card else None if group: gm = re.search(r"(\d+)\s*logements?\s*disponibles?", group.get_text(" ", strip=True), re.I) if gm: availability = f"{gm.group(1)} logement(s) disponible(s)" # Photos spécifiques de la suite (page détail, cache BD) suite_imgs: list[str] = [] if suite_url.startswith(BASE) and self.use_detail_cache: key = hashlib.sha1( (suite_url + "|" + card_text).encode("utf-8")).hexdigest() payload = self.detail( f"{prop_slug}-{suite_href.rstrip('/').split('/')[-1]}", key, lambda u=suite_url: self._suite_images(u)) suite_imgs = payload.get("images") or [] type_slug = (suite_href.rstrip("/").split("/")[-1] or re.sub(r"[^a-z0-9]+", "-", suite_name.lower()).strip("-")) details = {"contact": {"phone": phone}} if phone else {} listings.append(Listing( source=self.source_id, external_id=f"{prop_slug}-{type_slug}", url=suite_url or url, title=f"{name} — {suite_name}", address=address, sector=default_sector, city=city, unit_type=_suite_type(suite_name), price=price, price_label=price_label, availability=availability, description=" — ".join( x for x in [desc] + detail_bits if x)[:600], amenities=suite_amenities, details=details, images=(suite_imgs or images)[: self.max_images], )) except Exception: continue return listings def _suite_images(self, suite_url: str) -> dict: """Photos propres à un type de suite (page détail de la suite).""" try: sh = self.get(suite_url).text except Exception: return {} imgs: list[str] = [] ssoup = BeautifulSoup(sh, "html.parser") for img in ssoup.select("img[src]"): src = img.get("src", "") if (re.search(r"hubfs/.*(bw_properties|Web%20Photos|Web Photos)", src) and src.startswith("http") and src not in imgs): imgs.append(src) return {"images": imgs[: self.max_images]}