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%
5.6 KB · 155 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/central_rosemont.py : connecteur Central Rosemont5#   (centralrosemont.com) — condos locatifs au 1295, rue des Carrières,6#   Rosemont (Montréal), au bord de la piste des Carrières. WordPress rendu7#   serveur : la page /appartements-disponibles/ affiche un bloc8#   div.bloc_unite_dispo[data-unite] par unité disponible — numéro, étage,9#   superficie (pi²), nombre de chambres, prix « 2465$/mois » (parfois vide),10#   date de disponibilité (« juin 2026 ») et plan PDF. Une annonce par unité,11#   id stable = numéro d'unité (data-unite).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing20from .base import BaseConnector2122BASE = "https://www.centralrosemont.com"23LIST_URL = f"{BASE}/appartements-disponibles/"24ADDRESS = "1295, rue des Carrières, Montréal, QC H2S 0E1"2526# Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres)27_BED_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}2829_PRICE_RE = re.compile(r"([\d\s,]{3,7})\s*\$")30_SQFT_RE = re.compile(r"([\d\s]{2,6})\s*pi", re.I)31_FLOOR_RE = re.compile(r"Étage\s*(\d+)", re.I)32_IMG_RE = re.compile(33    r'https://(?:www\.)?centralrosemont\.com/wp-content/uploads/'34    r'[^"\'\s\\)]+?\.(?:jpg|jpeg|png|webp)', re.I)35_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,4}x\d{2,4}\.", re.I)363738class CentralRosemontConnector(BaseConnector):39    source_id = "central_rosemont"40    request_delay = 0.641    max_images = 204243    def fetch(self) -> list[Listing]:44        html = self.get(LIST_URL).text45        soup = BeautifulSoup(html, "html.parser")4647        # galerie du projet (page disponibilités, sans logos/miniatures)48        images: list[str] = []49        for u in _IMG_RE.findall(html):50            if _SKIP_IMG.search(u):51                continue52            if u not in images:53                images.append(u)54        images = images[: self.max_images]5556        desc = ""57        meta = soup.find("meta", attrs={"name": "description"})58        if meta and meta.get("content"):59            desc = meta["content"].strip()[:600]6061        listings: list[Listing] = []62        for bloc in soup.select("div.bloc_unite_dispo[data-unite]"):63            try:64                lst = self._bloc_listing(bloc, desc, images)65                if lst is not None:66                    listings.append(lst)67            except Exception:68                continue69        return listings7071    def _bloc_listing(self, bloc, desc: str,72                      images: list[str]) -> Listing | None:73        unite = (bloc.get("data-unite") or "").strip()74        num_el = bloc.select_one(".numero_unite .value")75        number = (num_el.get_text(" ", strip=True) if num_el else unite)76        if not (unite or number):77            return None7879        # étage (« Étage 1 »)80        floor = ""81        fl_el = bloc.select_one(".etage_unite")82        if fl_el:83            fm = _FLOOR_RE.search(fl_el.get_text(" ", strip=True))84            if fm:85                floor = fm.group(1)8687        # superficie (« 800 pi² »)88        sqft = None89        sq_el = bloc.select_one(".superficie_unite p")90        if sq_el:91            sm = _SQFT_RE.search(sq_el.get_text(" ", strip=True))92            if sm:93                try:94                    sqft = float(re.sub(r"\s", "", sm.group(1)))95                except ValueError:96                    sqft = None9798        # chambres fermées -> type d'unité99        bedrooms = None100        unit_type = ""101        ch_el = bloc.select_one(".chambres_unite p")102        if ch_el:103            ct = re.sub(r"[^\d]", "", ch_el.get_text(" ", strip=True))104            if ct:105                bedrooms = float(ct)106                unit_type = _BED_TYPE.get(int(bedrooms), "")107108        # prix (« 2465$/mois », parfois vide « $/mois »)109        price = None110        price_label = ""111        px_el = bloc.select_one(".prix_unite .prix")112        if px_el:113            raw = px_el.get_text(" ", strip=True)114            pm = _PRICE_RE.search(raw)115            if pm:116                digits = re.sub(r"[^\d]", "", pm.group(1))117                if digits:118                    price = float(digits)119                    price_label = f"{int(price)} $/mois"120        if price is not None and not (100 <= price <= 20000):121            price = None122123        # date de disponibilité (« juin 2026 » ou vide = maintenant)124        availability = ""125        dt_el = bloc.select_one(".date_unite .date")126        if dt_el:127            availability = dt_el.get_text(" ", strip=True)128129        details: dict = {}130        if floor:131            details["floor"] = floor132        plan = bloc.select_one(".actions_unite a[href$='.pdf']")133        if plan and plan.get("href"):134            details["floor_plan_pdf"] = plan["href"]135136        return Listing(137            source=self.source_id,138            external_id=unite or number,139            url=LIST_URL,140            title=f"Central Rosemont — Unité {number}"141                  + (f" ({unit_type})" if unit_type else ""),142            address=ADDRESS,143            sector="Rosemont",144            city="Montréal",145            unit_type=unit_type,146            bedrooms=bedrooms,147            price=price,148            price_label=price_label,149            availability=availability or "Disponible",150            area_sqft=sqft,151            description=desc,152            details=details,153            images=list(images),154        )155