Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/saint_michel.py : connecteur Le Saint-Michel (lesaintmichel.ca)5# 3250, boul. Henri-Bourassa Est, Montréal-Nord — studios à 5½ (unités A/B/C).6# Contrairement au repérage initial (« shell JS »), la page /appartements/7# est rendue SERVEUR : une carte <a class="unit js-unit"> par unité avec8# data-status="1" quand elle est disponible (étage, superficie « +/- 8299# p.ca », « à partir de *1749 », type dans l'alt du plan). La fiche10# /units/unite-<x>/ (cache BD) ajoute description, date de disponibilité11# et plan haute résolution. Repli Scrapfly si le GET direct est bloqué.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing21from .base import BaseConnector2223BASE = "https://lesaintmichel.ca"24LIST_URL = f"{BASE}/appartements/"25ADDRESS = "3250, boul. Henri-Bourassa Est, Montréal-Nord, Montréal, QC H1H 1H4"2627_TYPE_RE = re.compile(r"Plan\s+(\d\s*1/2|\d\s*½|Studio|Loft)", re.I)28_PRICE_RE = re.compile(r"\*?\s*([\d]{3,4})\s*\$?")29_SQFT_RE = re.compile(r"([\d\s]{3,6})\s*p\.?\s*ca", re.I)30_FLOOR_RE = re.compile(r"^([\d,\s]+)\s*Étage", re.I)31_IMG_RE = re.compile(32 r'https://lesaintmichel\.ca/wp-content/uploads/[^"\'\s\\)]+?'33 r'\.(?:jpg|jpeg|png|webp)', re.I)34_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,4}x\d{2,4}\.", re.I)353637class SaintMichelConnector(BaseConnector):38 source_id = "saint_michel"39 request_delay = 0.640 max_images = 204142 def _list_html(self) -> str:43 """HTML de la liste : direct d'abord, Scrapfly (ASP) en repli."""44 try:45 html = self.get(LIST_URL).text46 if "js-unit" in html:47 return html48 except Exception:49 pass50 return self.get_scrapfly(LIST_URL, render_js=False)5152 def fetch(self) -> list[Listing]:53 soup = BeautifulSoup(self._list_html(), "html.parser")5455 listings: list[Listing] = []56 for card in soup.select("a.unit.js-unit"):57 try:58 if card.get("data-status") != "1":59 continue # unité indisponible60 lst = self._card_listing(card)61 if lst is not None:62 listings.append(lst)63 except Exception:64 continue65 return listings6667 def _card_listing(self, card) -> Listing | None:68 url = card.get("href") or ""69 slug = url.rstrip("/").split("/")[-1] # ex. « unite-b1 »70 if not slug:71 return None7273 spans = [s.get_text(" ", strip=True) for s in card.select("span")]74 text = " | ".join(spans)7576 # nom de l'unité (« Unité B1 ») et étage(s) (« 5 Étage »)77 name = next((s for s in spans if s.lower().startswith("unité")), slug)78 floor = ""79 for s in spans:80 fm = _FLOOR_RE.match(s)81 if fm:82 floor = fm.group(1).strip(" ,")83 break8485 # type dans l'alt du plan (« Plan 4 1/2 - Unité B1 - … »)86 unit_type = ""87 img = card.select_one("img")88 alt = (img.get("alt") or "") if img else ""89 tm = _TYPE_RE.search(alt)90 if tm:91 unit_type = (tm.group(1).replace(" ", "").replace("1/2", "½")92 .capitalize() if "tudio" in tm.group(1).lower()93 or "oft" in tm.group(1).lower()94 else tm.group(1).replace(" ", "").replace("1/2", "½"))9596 # prix « à partir de | *1749 »97 price = None98 if "à partir de" in text.lower():99 for s in spans:100 pm = _PRICE_RE.fullmatch(s.strip())101 if pm:102 price = float(pm.group(1))103 break104 if price is not None and not (100 <= price <= 20000):105 price = None106107 # superficie « +/- 829 p.ca + loggia 107 p.ca » (surface principale)108 sqft = None109 sq_label = next((s for s in spans if "p.ca" in s), "")110 sm = _SQFT_RE.search(sq_label)111 if sm:112 try:113 sqft = float(re.sub(r"\s", "", sm.group(1)))114 except ValueError:115 sqft = None116117 # fiche de l'unité (cache BD) : description, dispo, plan hi-res118 key = hashlib.sha1(f"{text}|{price}".encode("utf-8")).hexdigest()119 d = self.detail(slug, key, lambda: self._fetch_unit(url))120121 details: dict = {}122 if floor:123 details["floor"] = floor124 if sq_label:125 details["Superficie"] = sq_label126127 return Listing(128 source=self.source_id,129 external_id=slug,130 url=url,131 title=f"Le Saint-Michel — {name}"132 + (f" ({unit_type})" if unit_type else ""),133 address=ADDRESS,134 sector="Montréal-Nord",135 city="Montréal",136 unit_type=unit_type,137 price=price,138 price_label=f"À partir de {int(price)} $/mois" if price else "",139 availability=d.get("availability") or "Disponible",140 area_sqft=sqft,141 description=d.get("desc", ""),142 details=details,143 images=d.get("images") or [],144 )145146 def _fetch_unit(self, url: str) -> dict:147 """Fiche /units/unite-<x>/ : description, disponibilité, images."""148 out: dict = {"desc": "", "availability": "", "images": []}149 try:150 html = self.get(url).text151 except Exception:152 html = self.get_scrapfly(url, render_js=False)153 if not html:154 return out155 soup = BeautifulSoup(html, "html.parser")156157 # description : premiers paragraphes substantiels de la fiche158 paras = [p.get_text(" ", strip=True) for p in soup.select("p")]159 paras = [p for p in paras if len(p) > 80]160 if paras:161 out["desc"] = " ".join(paras[:2])[:600]162163 # « Date de disponibilité | Occupation immédiate »164 txt = soup.get_text(" | ", strip=True)165 am = re.search(r"Date de disponibilité\s*\|\s*([^|]{3,60})", txt)166 if am:167 out["availability"] = am.group(1).strip()168169 images: list[str] = []170 for u in _IMG_RE.findall(html):171 if _SKIP_IMG.search(u):172 continue173 if u not in images:174 images.append(u)175 out["images"] = images[: self.max_images]176 return out177