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.4 KB · 140 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/loggia.py : connecteur Loggia Saint-Lambert (loggiasaintlambert.com)5#   Trois immeubles locatifs (Loggia 1, 2, 3) sur l'avenue Saint-Charles à6#   Saint-Lambert. WordPress rendu serveur : la page /le-projet/ embarque une7#   carte interactive (blocs « div.available.bloc.appt-N-demi » libellés8#   « 111-$2495. - 4 ½ / Disponible ») et ~260 fiches d'unités (« Appartement9#   NNN / Loggia N / ÉTAGE N », p.dispo[.not] = Loué/Disponible/Réservé,10#   p.superficie, plan PNG/PDF). On ne retient que les unités « Disponible ».11#   Granularité : unité.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from .base import BaseConnector20from ..schema import Listing, normalize_unit_type2122BASE = "https://www.loggiasaintlambert.com"23PAGE_URL = f"{BASE}/le-projet/"2425CITY = "Saint-Lambert"26ADDRESS = "avenue Saint-Charles, Saint-Lambert"2728# « Appartement 111-$2495. » (prix parfois accolé au numéro)29TITLE_RE = re.compile(r"Appartement\s+(\d+)\s*(?:[-–]\s*\$\s*([\d\s,  ]+))?",30                      re.I)31# libellé de bloc carte : « 111-$2495. - 4 ½ »32BLOC_RE = re.compile(r"(\d+)\s*[-–]\s*\$\s*([\d\s,  ]+)[.\s]*[-–]\s*"33                     r"(\d\s*½|\d\s*1/2|studio)", re.I)34SQFT_RE = re.compile(r"([\d,\s  ]+)\s*pi", re.I)35LOGGIA_RE = re.compile(r"Loggia\s*(\d)", re.I)36ETAGE_RE = re.compile(r"[ÉE]TAGE\s*(\d+)", re.I)373839def _num(s: str) -> float | None:40    s = re.sub(r"[\s,  ]", "", s or "")41    try:42        return float(s)43    except ValueError:44        return None454647class LoggiaConnector(BaseConnector):48    source_id = "loggia"49    request_delay = 0.65051    def fetch(self) -> list[Listing]:52        listings: list[Listing] = []53        try:54            html = self.get(PAGE_URL).text55        except Exception:56            return listings57        soup = BeautifulSoup(html, "html.parser")5859        # 1) typologie par unité via les blocs de la carte (disponibles seulement)60        #    « 111-$2495. - 4 ½ » → {("<no>", "<prix>"): "4½"}61        types: dict[str, str] = {}62        for bloc in soup.select("div.available.bloc"):63            m = BLOC_RE.search(bloc.get_text(" ", strip=True))64            if m:65                types[m.group(1)] = normalize_unit_type(m.group(3))6667        # 2) fiches d'unités : on garde celles dont p.dispo = « Disponible »68        for p in soup.select("p.dispo"):69            try:70                if "not" in (p.get("class") or []):71                    continue72                if "disponible" not in p.get_text(strip=True).lower():73                    continue          # « Réservé » etc.74                card = p.find_parent("div", class_="col-md-8")75                if not card:76                    continue77                text = card.get_text(" ", strip=True)78                mt = TITLE_RE.search(text)79                if not mt:80                    continue81                unit_no = mt.group(1)82                price = _num(mt.group(2)) if mt.group(2) else None8384                building = ""85                mb = LOGGIA_RE.search(text)86                if mb:87                    building = f"Loggia {mb.group(1)}"88                floor = ""89                mf = ETAGE_RE.search(text)90                if mf:91                    floor = f"Étage {mf.group(1)}"9293                sqft = None94                sp = card.select_one("p.superficie")95                if sp:96                    ms = SQFT_RE.search(sp.get_text(strip=True))97                    if ms:98                        sqft = _num(ms.group(1))99100                images: list[str] = []101                for img in card.select("div.plan-unite img"):102                    src = img.get("src") or ""103                    if src.startswith("http"):104                        images.append(src)105                details: dict = {}106                a = card.select_one('a[href*=".pdf"]')107                if a and a.get("href"):108                    mp = re.search(r"(https://[^\s\"]+\.pdf)", a["href"])109                    details["plan_pdf"] = mp.group(1) if mp else a["href"]110                if building:111                    details["building"] = building112                if floor:113                    details["floor"] = floor114115                unit_type = types.get(unit_no, "")116                bslug = building.lower().replace(" ", "") or "x"117                ext_id = f"{bslug}-{unit_no}"118                if any(l.external_id == ext_id for l in listings):119                    continue120                listings.append(Listing(121                    source=self.source_id,122                    external_id=ext_id,123                    url=PAGE_URL,124                    title=f"Appartement {unit_no}"125                          + (f" ({unit_type})" if unit_type else "")126                          + (f" — {building}, Loggia Saint-Lambert" if building127                             else " — Loggia Saint-Lambert"),128                    address=ADDRESS,129                    city=CITY,130                    unit_type=unit_type,131                    price=price,132                    availability="Disponible",133                    area_sqft=sqft,134                    details=details,135                    images=images[:15],136                ))137            except Exception:138                continue139        return listings140