# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/condos18.py : connecteur Condos Locatifs 18 # (condoslocatifs18.ca — 1195, 18e Rue, Limoilou, Québec, à deux pas de # l'hôpital de l'Enfant-Jésus/CHU). One-pager maison rendu serveur : les # prix sont affichés PAR TYPE d'unité (« 3 1/2 … À partir de 1200$ par # mois ») dans des onglets #s312/#s412/#s512, chacun listant ses variantes # de plans (Emplacement / Description / Superficie). Une annonce par type # d'unité — la granularité du site — avec la fourchette de superficies des # variantes, les plans (lightbox) et les photos du modèle en galerie. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://condoslocatifs18.ca" PAGE_URL = f"{BASE}/" ADDRESS = "1195, 18e Rue, Québec, G1J 0G5" SECTOR = "Limoilou" CITY = "Québec" LAT, LNG = 46.835710, -71.227340 # marqueur Google Maps de la page AREA_RE = re.compile(r"Superficie\s*:\s*([\d\s]+)\s*pi", re.I) FLOOR_RE = re.compile(r"Emplacement\s*:\s*([^\n<]+)", re.I) DESC_RE = re.compile(r"Description\s*:\s*([^\n<]+)", re.I) # photos du modèle / de l'immeuble (hors icônes, logos et plans) PHOTO_RE = re.compile(r'(?:src|href)="(/images/condos-locatifs-18/' r'(?:modele-condo-interieur-exterieur/big/[^"]+|' r"condo-a-louer/\d+[^\"]*|img-exterieur[^\"]*))\"", re.I) class Condos18Connector(BaseConnector): source_id = "condos18" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] html = self.get(PAGE_URL).text soup = BeautifulSoup(html, "html.parser") # photos communes de l'immeuble/du modèle photos = [BASE + u for u in dict.fromkeys(PHOTO_RE.findall(html)) if not re.search(r"_tn|tn\.png|map-", u, re.I)][:15] details_common: dict = {} m = re.search(r'href="mailto:([^"?]+)"', html) contact = {"email": m.group(1).strip().lower()} if m else {} m = re.search(r'href="tel:\+?1?[\s.]*(\d{3})[\s.]*(\d{3})[\s.]*(\d{4})"', html) if not m: m = re.search(r"\b(418)[\s.]+(\d{3})[\s.]+(\d{4})\b", soup.get_text(" ", strip=True)) if m: contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" if contact: details_common["contact"] = contact # onglets prix par type : bouton « 3 1/2 » (id="#s312") + « À partir # de 1200$ par mois », puis panneau
avec les variantes for btn in soup.select("a.bouton.appart"): try: unit_type = normalize_unit_type(btn.get_text(strip=True)) if not unit_type: continue pane_id = (btn.get("id") or "").lstrip("#") info = btn.find_next(class_="info_cachee") price_label = re.sub( r"\s+", " ", info.get_text(" ", strip=True)) if info else "" price = parse_price(price_label) # variantes de plans du panneau correspondant pane = soup.find("div", id=pane_id) if pane_id else None areas: list[float] = [] floors: list[str] = [] desc = "" plans: list[str] = [] if pane is not None: pane_html = str(pane) for a in AREA_RE.findall(pane_html): try: v = float(a.replace(" ", "").replace(" ", "")) if 200 <= v <= 3000: areas.append(v) except ValueError: continue floors = [f.strip() for f in FLOOR_RE.findall(pane_html)] dm = DESC_RE.search(pane_html) if dm: desc = dm.group(1).strip().rstrip(".") + "." plans = [BASE + u for u in dict.fromkeys( re.findall(r'href="(/images/[^"]*plan-big[^"]+)"', pane_html, re.I))] amenities: list[str] = [] if areas: lo, hi = min(areas), max(areas) amenities.append( f"Superficies : {int(lo)} pi²" if lo == hi else f"Superficies : {int(lo)} à {int(hi)} pi²") if floors: amenities.append(f"Emplacement : {floors[0]}") amenities += ["Ascenseur", "Climatisation murale", "Insonorisation supérieure", "Stationnement intérieur (option)", "Gym"] listings.append(Listing( source=self.source_id, external_id=f"type-{unit_type.replace('½', '.5')}", url=PAGE_URL, title=f"{unit_type} — Condos Locatifs 18", address=ADDRESS, sector=SECTOR, city=CITY, unit_type=unit_type, price=price, price_label=price_label, area_sqft=min(areas) if areas else None, description=desc, amenities=amenities, details=dict(details_common), images=photos + plans, lat=LAT, lng=LNG, )) except Exception: continue return listings