SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.6 KB · 177 lines python
Raw Blame History
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