spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/leclif.py : connecteur Le Clif (leclif.ca)5# Projet mono-immeuble de 151 condos locatifs à Charlesbourg (Québec).6# Toutes les unités sont dans la page d'accueil (overlays « is-overlay-N »);7# les unités louées sont marquées par un script jQuery addClass('non-dispo').8# La page fournit aussi la liste des avantages de l'immeuble9# (ul.liste-icones : inclusions, piscine, gym, politique animaux…) et le10# lien Google Maps (lat/lng).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, infer_city, normalize_unit_type, parse_price19from .base import BaseConnector2021BASE = "https://leclif.ca"22SECTOR = "Charlesbourg"23ADDRESS = "19700, boul. Henri-Bourassa, Québec, G1G 0L1"2425# lat/lng du lien « Voir sur la carte » (…!3d46.8934679!4d-71.2986717…)26_LATLNG_RE = re.compile(r"!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)")27_SQFT_RE = re.compile(r"([\d\s ,.]+?)\s*pi\s*2?", re.I)282930def _extract_price(label: str) -> float | None:31 """'À partir de 2366 (incluant électros) $ / mois' -> 2366.0"""32 m = re.search(r"(\d[\d\s ]{2,})", label or "")33 if not m:34 return None35 return parse_price(m.group(1).strip() + "$")363738class LeClifConnector(BaseConnector):39 source_id = "leclif"40 request_delay = 0.64142 def fetch(self) -> list[Listing]:43 html = self.get(BASE + "/").text4445 # Unités marquées non disponibles par le script de la page46 non_dispo = set(re.findall(47 r"jQuery\('\.unite-(\d+)'\)\.addClass\('non-dispo'\)", html))4849 soup = BeautifulSoup(html, "html.parser")5051 # lat/lng depuis le lien Google Maps de l'immeuble52 lat = lng = None53 m = _LATLNG_RE.search(html)54 if m:55 lat, lng = float(m.group(1)), float(m.group(2))5657 # avantages de l'immeuble (« Eau chaude fournie », « Air climatisé58 # inclus », « Chiens et chats acceptés », piscine, gym, stationnement…)59 building_amenities: list[str] = []60 pets = None61 for li in soup.select("ul.liste-icones > li"):62 p = li.select_one("p.texte")63 if not p:64 continue65 label = " ".join(p.get_text(" ", strip=True).split())66 label = re.sub(r"\s+i$", "", label) # icône info « i » du picto67 # politique animaux : l'infobulle détaille des conditions68 # (chiens selon étage/poids, deux chats maximum)69 if re.search(r"chiens|chats|animaux", label, re.I):70 tooltip = li.select_one(".is-description")71 tip_txt = tooltip.get_text(" ", strip=True) if tooltip else ""72 if re.search(r"condition|moins de \d+ lb|maximum", tip_txt, re.I):73 pets = "conditions"74 label += " (sous conditions)"75 if label and label not in building_amenities:76 building_amenities.append(label)7778 listings: list[Listing] = []7980 for overlay in soup.select("div.overlay-unite"):81 try:82 cls = " ".join(overlay.get("class", []))83 m = re.search(r"is-overlay-(\d+)", cls)84 if not m:85 continue86 num = m.group(1)87 if num in non_dispo:88 continue # unité louée8990 price_el = overlay.select_one("h3.price")91 price_label = (price_el.get_text(" ", strip=True)92 if price_el else "")93 price = _extract_price(price_label)9495 # Liste des caractéristiques : Disponibilité / Type / Modèle /96 # Superficie / Terrasse97 availability = unit_type = model = ""98 area_sqft = None99 av = re.search(r"Disponibilité\s*:\s*([\wéûà]+)",100 overlay.get_text(" ", strip=True))101 if av:102 availability = f"Disponible en {av.group(1)}"103 desc_parts: list[str] = []104 for li in overlay.select("ul.flex li"):105 t = li.get_text(" ", strip=True)106 if t.startswith("Disponibilité"):107 availability = t.split(":", 1)[-1].strip()108 elif t.startswith("Type"):109 unit_type = normalize_unit_type(t.split(":", 1)[-1])110 elif t.startswith("Modèle"):111 model = t.split(":", 1)[-1].strip()112 elif t.startswith("Superficie"):113 # champ étiqueté distinct de la terrasse -> area_sqft114 sm = _SQFT_RE.search(t.split(":", 1)[-1])115 if sm:116 try:117 area_sqft = float(re.sub(r"[\s ,]", "",118 sm.group(1)))119 except ValueError:120 pass121 desc_parts.append(re.sub(r"\s+", " ", t))122 elif t.startswith("Terrasse"):123 desc_parts.append(re.sub(r"\s+", " ", t))124 if model:125 desc_parts.insert(0, f"Modèle {model}")126127 # Images : aperçu du plan (jpg) dans l'overlay128 images: list[str] = []129 for img in overlay.select("img[src]"):130 src = img["src"]131 if src.startswith("http") and re.search(132 r"\.(?:jpg|jpeg|png|webp)$", src, re.I):133 images.append(src)134 images = list(dict.fromkeys(images))135136 listings.append(Listing(137 source=self.source_id,138 external_id=f"unite-{num}",139 url=f"{BASE}/#unite-{num}",140 title=f"Le Clif — Unité {num}"141 f"{' (' + unit_type + ')' if unit_type else ''}",142 address=ADDRESS,143 sector=SECTOR,144 city=infer_city(SECTOR),145 unit_type=unit_type,146 price=price,147 price_label=price_label,148 availability=availability or "Disponible",149 area_sqft=area_sqft,150 pets=pets,151 description=" | ".join(desc_parts),152 amenities=list(building_amenities),153 images=images,154 lat=lat,155 lng=lng,156 ))157 except Exception:158 continue159160 return listings161