# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/saint_michel.py : connecteur Le Saint-Michel (lesaintmichel.ca) # 3250, boul. Henri-Bourassa Est, Montréal-Nord — studios à 5½ (unités A/B/C). # Contrairement au repérage initial (« shell JS »), la page /appartements/ # est rendue SERVEUR : une carte par unité avec # data-status="1" quand elle est disponible (étage, superficie « +/- 829 # p.ca », « à partir de *1749 », type dans l'alt du plan). La fiche # /units/unite-/ (cache BD) ajoute description, date de disponibilité # et plan haute résolution. Repli Scrapfly si le GET direct est bloqué. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://lesaintmichel.ca" LIST_URL = f"{BASE}/appartements/" ADDRESS = "3250, boul. Henri-Bourassa Est, Montréal-Nord, Montréal, QC H1H 1H4" _TYPE_RE = re.compile(r"Plan\s+(\d\s*1/2|\d\s*½|Studio|Loft)", re.I) _PRICE_RE = re.compile(r"\*?\s*([\d]{3,4})\s*\$?") _SQFT_RE = re.compile(r"([\d\s]{3,6})\s*p\.?\s*ca", re.I) _FLOOR_RE = re.compile(r"^([\d,\s]+)\s*Étage", re.I) _IMG_RE = re.compile( r'https://lesaintmichel\.ca/wp-content/uploads/[^"\'\s\\)]+?' r'\.(?:jpg|jpeg|png|webp)', re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,4}x\d{2,4}\.", re.I) class SaintMichelConnector(BaseConnector): source_id = "saint_michel" request_delay = 0.6 max_images = 20 def _list_html(self) -> str: """HTML de la liste : direct d'abord, Scrapfly (ASP) en repli.""" try: html = self.get(LIST_URL).text if "js-unit" in html: return html except Exception: pass return self.get_scrapfly(LIST_URL, render_js=False) def fetch(self) -> list[Listing]: soup = BeautifulSoup(self._list_html(), "html.parser") listings: list[Listing] = [] for card in soup.select("a.unit.js-unit"): try: if card.get("data-status") != "1": continue # unité indisponible lst = self._card_listing(card) if lst is not None: listings.append(lst) except Exception: continue return listings def _card_listing(self, card) -> Listing | None: url = card.get("href") or "" slug = url.rstrip("/").split("/")[-1] # ex. « unite-b1 » if not slug: return None spans = [s.get_text(" ", strip=True) for s in card.select("span")] text = " | ".join(spans) # nom de l'unité (« Unité B1 ») et étage(s) (« 5 Étage ») name = next((s for s in spans if s.lower().startswith("unité")), slug) floor = "" for s in spans: fm = _FLOOR_RE.match(s) if fm: floor = fm.group(1).strip(" ,") break # type dans l'alt du plan (« Plan 4 1/2 - Unité B1 - … ») unit_type = "" img = card.select_one("img") alt = (img.get("alt") or "") if img else "" tm = _TYPE_RE.search(alt) if tm: unit_type = (tm.group(1).replace(" ", "").replace("1/2", "½") .capitalize() if "tudio" in tm.group(1).lower() or "oft" in tm.group(1).lower() else tm.group(1).replace(" ", "").replace("1/2", "½")) # prix « à partir de | *1749 » price = None if "à partir de" in text.lower(): for s in spans: pm = _PRICE_RE.fullmatch(s.strip()) if pm: price = float(pm.group(1)) break if price is not None and not (100 <= price <= 20000): price = None # superficie « +/- 829 p.ca + loggia 107 p.ca » (surface principale) sqft = None sq_label = next((s for s in spans if "p.ca" in s), "") sm = _SQFT_RE.search(sq_label) if sm: try: sqft = float(re.sub(r"\s", "", sm.group(1))) except ValueError: sqft = None # fiche de l'unité (cache BD) : description, dispo, plan hi-res key = hashlib.sha1(f"{text}|{price}".encode("utf-8")).hexdigest() d = self.detail(slug, key, lambda: self._fetch_unit(url)) details: dict = {} if floor: details["floor"] = floor if sq_label: details["Superficie"] = sq_label return Listing( source=self.source_id, external_id=slug, url=url, title=f"Le Saint-Michel — {name}" + (f" ({unit_type})" if unit_type else ""), address=ADDRESS, sector="Montréal-Nord", city="Montréal", unit_type=unit_type, price=price, price_label=f"À partir de {int(price)} $/mois" if price else "", availability=d.get("availability") or "Disponible", area_sqft=sqft, description=d.get("desc", ""), details=details, images=d.get("images") or [], ) def _fetch_unit(self, url: str) -> dict: """Fiche /units/unite-/ : description, disponibilité, images.""" out: dict = {"desc": "", "availability": "", "images": []} try: html = self.get(url).text except Exception: html = self.get_scrapfly(url, render_js=False) if not html: return out soup = BeautifulSoup(html, "html.parser") # description : premiers paragraphes substantiels de la fiche paras = [p.get_text(" ", strip=True) for p in soup.select("p")] paras = [p for p in paras if len(p) > 80] if paras: out["desc"] = " ".join(paras[:2])[:600] # « Date de disponibilité | Occupation immédiate » txt = soup.get_text(" | ", strip=True) am = re.search(r"Date de disponibilité\s*\|\s*([^|]{3,60})", txt) if am: out["availability"] = am.group(1).strip() images: list[str] = [] for u in _IMG_RE.findall(html): if _SKIP_IMG.search(u): continue if u not in images: images.append(u) out["images"] = images[: self.max_images] return out