# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/leclif.py : connecteur Le Clif (leclif.ca) # Projet mono-immeuble de 151 condos locatifs à Charlesbourg (Québec). # Toutes les unités sont dans la page d'accueil (overlays « is-overlay-N »); # les unités louées sont marquées par un script jQuery addClass('non-dispo'). # La page fournit aussi la liste des avantages de l'immeuble # (ul.liste-icones : inclusions, piscine, gym, politique animaux…) et le # lien Google Maps (lat/lng). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://leclif.ca" SECTOR = "Charlesbourg" ADDRESS = "19700, boul. Henri-Bourassa, Québec, G1G 0L1" # lat/lng du lien « Voir sur la carte » (…!3d46.8934679!4d-71.2986717…) _LATLNG_RE = re.compile(r"!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)") _SQFT_RE = re.compile(r"([\d\s ,.]+?)\s*pi\s*2?", re.I) def _extract_price(label: str) -> float | None: """'À partir de 2366 (incluant électros) $ / mois' -> 2366.0""" m = re.search(r"(\d[\d\s ]{2,})", label or "") if not m: return None return parse_price(m.group(1).strip() + "$") class LeClifConnector(BaseConnector): source_id = "leclif" request_delay = 0.6 def fetch(self) -> list[Listing]: html = self.get(BASE + "/").text # Unités marquées non disponibles par le script de la page non_dispo = set(re.findall( r"jQuery\('\.unite-(\d+)'\)\.addClass\('non-dispo'\)", html)) soup = BeautifulSoup(html, "html.parser") # lat/lng depuis le lien Google Maps de l'immeuble lat = lng = None m = _LATLNG_RE.search(html) if m: lat, lng = float(m.group(1)), float(m.group(2)) # avantages de l'immeuble (« Eau chaude fournie », « Air climatisé # inclus », « Chiens et chats acceptés », piscine, gym, stationnement…) building_amenities: list[str] = [] pets = None for li in soup.select("ul.liste-icones > li"): p = li.select_one("p.texte") if not p: continue label = " ".join(p.get_text(" ", strip=True).split()) label = re.sub(r"\s+i$", "", label) # icône info « i » du picto # politique animaux : l'infobulle détaille des conditions # (chiens selon étage/poids, deux chats maximum) if re.search(r"chiens|chats|animaux", label, re.I): tooltip = li.select_one(".is-description") tip_txt = tooltip.get_text(" ", strip=True) if tooltip else "" if re.search(r"condition|moins de \d+ lb|maximum", tip_txt, re.I): pets = "conditions" label += " (sous conditions)" if label and label not in building_amenities: building_amenities.append(label) listings: list[Listing] = [] for overlay in soup.select("div.overlay-unite"): try: cls = " ".join(overlay.get("class", [])) m = re.search(r"is-overlay-(\d+)", cls) if not m: continue num = m.group(1) if num in non_dispo: continue # unité louée price_el = overlay.select_one("h3.price") price_label = (price_el.get_text(" ", strip=True) if price_el else "") price = _extract_price(price_label) # Liste des caractéristiques : Disponibilité / Type / Modèle / # Superficie / Terrasse availability = unit_type = model = "" area_sqft = None av = re.search(r"Disponibilité\s*:\s*([\wéûà]+)", overlay.get_text(" ", strip=True)) if av: availability = f"Disponible en {av.group(1)}" desc_parts: list[str] = [] for li in overlay.select("ul.flex li"): t = li.get_text(" ", strip=True) if t.startswith("Disponibilité"): availability = t.split(":", 1)[-1].strip() elif t.startswith("Type"): unit_type = normalize_unit_type(t.split(":", 1)[-1]) elif t.startswith("Modèle"): model = t.split(":", 1)[-1].strip() elif t.startswith("Superficie"): # champ étiqueté distinct de la terrasse -> area_sqft sm = _SQFT_RE.search(t.split(":", 1)[-1]) if sm: try: area_sqft = float(re.sub(r"[\s ,]", "", sm.group(1))) except ValueError: pass desc_parts.append(re.sub(r"\s+", " ", t)) elif t.startswith("Terrasse"): desc_parts.append(re.sub(r"\s+", " ", t)) if model: desc_parts.insert(0, f"Modèle {model}") # Images : aperçu du plan (jpg) dans l'overlay images: list[str] = [] for img in overlay.select("img[src]"): src = img["src"] if src.startswith("http") and re.search( r"\.(?:jpg|jpeg|png|webp)$", src, re.I): images.append(src) images = list(dict.fromkeys(images)) listings.append(Listing( source=self.source_id, external_id=f"unite-{num}", url=f"{BASE}/#unite-{num}", title=f"Le Clif — Unité {num}" f"{' (' + unit_type + ')' if unit_type else ''}", address=ADDRESS, sector=SECTOR, city=infer_city(SECTOR), unit_type=unit_type, price=price, price_label=price_label, availability=availability or "Disponible", area_sqft=area_sqft, pets=pets, description=" | ".join(desc_parts), amenities=list(building_amenities), images=images, lat=lat, lng=lng, )) except Exception: continue return listings