# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/loggia.py : connecteur Loggia Saint-Lambert (loggiasaintlambert.com) # Trois immeubles locatifs (Loggia 1, 2, 3) sur l'avenue Saint-Charles à # Saint-Lambert. WordPress rendu serveur : la page /le-projet/ embarque une # carte interactive (blocs « div.available.bloc.appt-N-demi » libellés # « 111-$2495. - 4 ½ / Disponible ») et ~260 fiches d'unités (« Appartement # NNN / Loggia N / ÉTAGE N », p.dispo[.not] = Loué/Disponible/Réservé, # p.superficie, plan PNG/PDF). On ne retient que les unités « Disponible ». # Granularité : unité. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from .base import BaseConnector from ..schema import Listing, normalize_unit_type BASE = "https://www.loggiasaintlambert.com" PAGE_URL = f"{BASE}/le-projet/" CITY = "Saint-Lambert" ADDRESS = "avenue Saint-Charles, Saint-Lambert" # « Appartement 111-$2495. » (prix parfois accolé au numéro) TITLE_RE = re.compile(r"Appartement\s+(\d+)\s*(?:[-–]\s*\$\s*([\d\s, ]+))?", re.I) # libellé de bloc carte : « 111-$2495. - 4 ½ » BLOC_RE = re.compile(r"(\d+)\s*[-–]\s*\$\s*([\d\s, ]+)[.\s]*[-–]\s*" r"(\d\s*½|\d\s*1/2|studio)", re.I) SQFT_RE = re.compile(r"([\d,\s ]+)\s*pi", re.I) LOGGIA_RE = re.compile(r"Loggia\s*(\d)", re.I) ETAGE_RE = re.compile(r"[ÉE]TAGE\s*(\d+)", re.I) def _num(s: str) -> float | None: s = re.sub(r"[\s, ]", "", s or "") try: return float(s) except ValueError: return None class LoggiaConnector(BaseConnector): source_id = "loggia" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(PAGE_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") # 1) typologie par unité via les blocs de la carte (disponibles seulement) # « 111-$2495. - 4 ½ » → {("", ""): "4½"} types: dict[str, str] = {} for bloc in soup.select("div.available.bloc"): m = BLOC_RE.search(bloc.get_text(" ", strip=True)) if m: types[m.group(1)] = normalize_unit_type(m.group(3)) # 2) fiches d'unités : on garde celles dont p.dispo = « Disponible » for p in soup.select("p.dispo"): try: if "not" in (p.get("class") or []): continue if "disponible" not in p.get_text(strip=True).lower(): continue # « Réservé » etc. card = p.find_parent("div", class_="col-md-8") if not card: continue text = card.get_text(" ", strip=True) mt = TITLE_RE.search(text) if not mt: continue unit_no = mt.group(1) price = _num(mt.group(2)) if mt.group(2) else None building = "" mb = LOGGIA_RE.search(text) if mb: building = f"Loggia {mb.group(1)}" floor = "" mf = ETAGE_RE.search(text) if mf: floor = f"Étage {mf.group(1)}" sqft = None sp = card.select_one("p.superficie") if sp: ms = SQFT_RE.search(sp.get_text(strip=True)) if ms: sqft = _num(ms.group(1)) images: list[str] = [] for img in card.select("div.plan-unite img"): src = img.get("src") or "" if src.startswith("http"): images.append(src) details: dict = {} a = card.select_one('a[href*=".pdf"]') if a and a.get("href"): mp = re.search(r"(https://[^\s\"]+\.pdf)", a["href"]) details["plan_pdf"] = mp.group(1) if mp else a["href"] if building: details["building"] = building if floor: details["floor"] = floor unit_type = types.get(unit_no, "") bslug = building.lower().replace(" ", "") or "x" ext_id = f"{bslug}-{unit_no}" if any(l.external_id == ext_id for l in listings): continue listings.append(Listing( source=self.source_id, external_id=ext_id, url=PAGE_URL, title=f"Appartement {unit_no}" + (f" ({unit_type})" if unit_type else "") + (f" — {building}, Loggia Saint-Lambert" if building else " — Loggia Saint-Lambert"), address=ADDRESS, city=CITY, unit_type=unit_type, price=price, availability="Disponible", area_sqft=sqft, details=details, images=images[:15], )) except Exception: continue return listings