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/boardwalk.py : connecteur Boardwalk REIT (bwalk.com)5# Pages de villes de la région de Montréal rendues serveur (HubSpot CMS) :6# Longueuil, Laval, Île-des-Sœurs/Verdun, Ville Saint-Laurent. Fiches7# propriétés avec cartes de types de suites (« À partir de … $ ») qui8# portent aussi superficie (pi ca), c. à c./sdb et le statut (« N logements9# disponibles » / « liste d'attente ») ; adresse complète + téléphone dans10# l'en-tête (p.address-and-phone). Les pages de suites ne servent plus qu'aux11# photos spécifiques, via le cache self.detail().12# REIT pancanadien : seules les propriétés du Grand Montréal sont couvertes.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price22from .base import BaseConnector2324BASE = "https://www.bwalk.com"2526# Pages de villes du Grand Montréal -> (ville réelle, secteur par défaut)27CITY_PAGES = {28 f"{BASE}/fr-ca/appartements-a-louer-a-montreal-longueuil":29 ("Longueuil", ""),30 f"{BASE}/fr-ca/appartements-a-louer-a-montreal-laval":31 ("Laval", ""),32 f"{BASE}/fr-ca/appartements-a-louer-a-montreal-nuns-island-verdun":33 ("Montréal", "Île-des-Sœurs (Verdun)"),34 f"{BASE}/fr-ca/appartements-a-louer-a-montreal-ville-saint-laurent":35 ("Montréal", "Saint-Laurent"),36}3738PROP_RE = re.compile(39 r'href="(/fr-ca/appartements-a-louer-a-montreal-[a-z0-9\-]+/'40 r'[a-z0-9\-]+/[a-z0-9\-]+)"')41PRICE_RE = re.compile(r'À partir de\s*([\d\s\u00a0,.]+\$)')42# superficie : « 900 », « 648 - 760 », « 1 408 » (séparateur de milliers) —43# le nombre de chambres qui suit (« 3 c. à c. ») ne doit pas être aspiré44AREA_RE = re.compile(45 r'Superficie\s*\(pi\s*ca\)\s*:?\s*([\d\s\u00a0-]+?)'46 r'\s*(?:\d+\s*c\.\s*à\s*c\.|\||sdb|[A-Za-z]|$)')47BEDS_RE = re.compile(r'(\d+)\s*c\.\s*à\s*c\.')48BATHS_RE = re.compile(r'(\d+)\s*sdb')49PHONE_RE = re.compile(r'\(?(\d{3})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})')505152_BED_TYPES = {1: "3½", 2: "4½", 3: "5½", 4: "6½"}535455def _suite_type(name: str) -> str:56 """« 3 1/2 Pièces » -> 3½ ; « Maison en Rangée avec N Chambres » -> Maison ;57 « 2 Chambres » -> 4½."""58 low = name.lower()59 if "maison" in low or "rang" in low:60 return "Maison"61 m = re.search(r"(\d)\s*chambre", low)62 if m:63 return _BED_TYPES.get(int(m.group(1)), "")64 return normalize_unit_type(name)656667class BoardwalkConnector(BaseConnector):68 source_id = "boardwalk"69 request_delay = 0.670 max_properties = 25 # garde-fou71 max_images = 257273 def fetch(self) -> list[Listing]:74 listings: list[Listing] = []75 seen: set[str] = set()76 count = 077 for city_url, (city, default_sector) in CITY_PAGES.items():78 try:79 html = self.get(city_url).text80 except Exception:81 continue82 for path in dict.fromkeys(PROP_RE.findall(html)):83 if path in seen:84 continue85 seen.add(path)86 if count >= self.max_properties:87 break88 count += 189 try:90 listings.extend(91 self._property_listings(path, city, default_sector))92 except Exception:93 continue9495 # Unicité des external_id (deux cartes peuvent pointer vers la même96 # page de type de suite avec des prix différents)97 used: dict[str, int] = {}98 for lst in listings:99 n = used.get(lst.external_id, 0)100 used[lst.external_id] = n + 1101 if n:102 lst.external_id = f"{lst.external_id}-{n + 1}"103 return listings104105 def _property_listings(self, path: str, city: str,106 default_sector: str) -> list[Listing]:107 url = BASE + path108 prop_slug = path.rstrip("/").split("/")[-1]109 html = self.get(url).text110 soup = BeautifulSoup(html, "html.parser")111112 t = soup.find("title")113 name = (t.get_text(strip=True).split("|")[0].strip()114 if t else prop_slug.replace("-", " "))115116 # Adresse complète + téléphone : en-tête <p class="address-and-phone">117 address = ""118 phone = ""119 ap = soup.select_one("p.address-and-phone")120 if ap:121 ap_text = re.sub(r"\s+", " ", ap.get_text(" ", strip=True))122 pm_ = PHONE_RE.search(ap_text)123 if pm_:124 phone = "-".join(pm_.groups())125 ap_text = ap_text[: pm_.start()].strip(" ,")126 address = ap_text127 if not address:128 # repli : texte alt des photos « photo de la propriété pour le … »129 am = re.search(130 r'photo de la propri[ée]t[ée] pour le\s*([^"<>]{5,90})', html)131 if am:132 address = am.group(1).strip()133134 # Galerie photo (swiper)135 images: list[str] = []136 for img in soup.select("img[src]"):137 src = img.get("src", "")138 if re.search(r"hubfs/.*(bw_properties|Web%20Photos|Web Photos)",139 src) and src.startswith("http"):140 if src not in images:141 images.append(src)142 images = images[: self.max_images]143144 # Description de la propriété145 desc = ""146 dh = soup.find(string=re.compile("Description de la propriété"))147 if dh:148 sec = dh.find_parent()149 nxt = sec.find_next("p") if sec else None150 if nxt:151 desc = nxt.get_text(" ", strip=True)[:600]152 if not desc:153 og = soup.find("meta", attrs={"property": "og:description"})154 if og and og.get("content"):155 desc = og["content"].strip()[:600]156157 # Commodités (« Caractéristiques et espaces d'agrément »)158 amenities: list[str] = []159 ah = soup.find(string=re.compile("Caractéristiques et espaces"))160 if ah:161 cont = ah.find_parent()162 for _ in range(3):163 if cont is None:164 break165 lis = cont.find_all("li")166 if lis:167 break168 cont = cont.parent169 if cont:170 for li in cont.find_all("li")[:25]:171 txt = li.get_text(" ", strip=True)172 if txt and len(txt) < 50 and txt not in amenities:173 amenities.append(txt)174175 # Cartes de types de suites : <h4 class="highlight-suite"><a href=...>176 # La carte porte prix, superficie (pi ca), c. à c. et sdb ; le groupe177 # parent (div.unit_type) porte le statut (« N logements disponibles »178 # ou « liste d'attente »). Plus besoin de visiter la fiche de suite179 # pour ces champs — elle ne sert qu'aux photos (via self.detail).180 listings: list[Listing] = []181 for h4 in soup.select("h4.highlight-suite"):182 try:183 a = h4.find("a")184 if not a:185 continue186 suite_name = a.get_text(" ", strip=True)187 suite_href = a.get("href") or ""188 suite_url = (BASE + suite_href189 if suite_href.startswith("/") else suite_href)190 card = h4.parent191 card_text = re.sub(r"\s+", " ", card.get_text(" ", strip=True)192 if card else "")193 price_label = ""194 pm = PRICE_RE.search(card_text)195 if pm:196 price_label = "À partir de " + pm.group(1).strip()197 price = parse_price(price_label)198199 # Superficie / chambres / sdb depuis la carte elle-même200 detail_bits: list[str] = []201 suite_amenities = amenities202 am_ = AREA_RE.search(card_text)203 if am_:204 rng = re.sub(r"[\s ]+", " ", am_.group(1)).strip()205 detail_bits.append(f"Superficie (pi ca) : {rng}")206 lo = re.sub(r"[\s\u00a0]", "", rng.split("-")[0])207 if lo.isdigit():208 # borne basse -> texte brut, finalize() dérive area_sqft209 suite_amenities = amenities + [f"{lo} pi ca"]210 bm = BEDS_RE.search(card_text)211 if bm:212 detail_bits.append(f"{bm.group(1)} c. à c.")213 sm = BATHS_RE.search(card_text)214 if sm:215 detail_bits.append(f"{sm.group(1)} sdb")216217 # Statut du groupe : disponible ou liste d'attente218 availability = "Disponible"219 if re.search(r"liste d.attente", card_text, re.I):220 availability = "Sur la liste d'attente"221 else:222 group = card.find_parent(class_="unit_type") if card else None223 if group:224 gm = re.search(r"(\d+)\s*logements?\s*disponibles?",225 group.get_text(" ", strip=True), re.I)226 if gm:227 availability = f"{gm.group(1)} logement(s) disponible(s)"228229 # Photos spécifiques de la suite (page détail, cache BD)230 suite_imgs: list[str] = []231 if suite_url.startswith(BASE) and self.use_detail_cache:232 key = hashlib.sha1(233 (suite_url + "|" + card_text).encode("utf-8")).hexdigest()234 payload = self.detail(235 f"{prop_slug}-{suite_href.rstrip('/').split('/')[-1]}",236 key, lambda u=suite_url: self._suite_images(u))237 suite_imgs = payload.get("images") or []238239 type_slug = (suite_href.rstrip("/").split("/")[-1]240 or re.sub(r"[^a-z0-9]+", "-",241 suite_name.lower()).strip("-"))242 details = {"contact": {"phone": phone}} if phone else {}243 listings.append(Listing(244 source=self.source_id,245 external_id=f"{prop_slug}-{type_slug}",246 url=suite_url or url,247 title=f"{name} — {suite_name}",248 address=address,249 sector=default_sector,250 city=city,251 unit_type=_suite_type(suite_name),252 price=price,253 price_label=price_label,254 availability=availability,255 description=" — ".join(256 x for x in [desc] + detail_bits if x)[:600],257 amenities=suite_amenities,258 details=details,259 images=(suite_imgs or images)[: self.max_images],260 ))261 except Exception:262 continue263 return listings264265 def _suite_images(self, suite_url: str) -> dict:266 """Photos propres à un type de suite (page détail de la suite)."""267 try:268 sh = self.get(suite_url).text269 except Exception:270 return {}271 imgs: list[str] = []272 ssoup = BeautifulSoup(sh, "html.parser")273 for img in ssoup.select("img[src]"):274 src = img.get("src", "")275 if (re.search(r"hubfs/.*(bw_properties|Web%20Photos|Web Photos)",276 src) and src.startswith("http")277 and src not in imgs):278 imgs.append(src)279 return {"images": imgs[: self.max_images]}280