# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lakle.py : connecteur La Klé (lakle.ca) # Complexe de 559 appartements locatifs (4 phases) dans la Cité Verte # (Québec, secteur Saint-Sacrement). La page « disponibilité » expose : # 1) le sélecteur de plans WordPress de la tour la plus récente (phase 4) : # unités en + API REST # /wp-json/do-selecteur-plans/v1/unite?id=N (prix, type, superficie # habitable/terrasse, étage, style, date libre, plan) ; # 2) des cartes
  • « Unité B16 - Phase 3 » pour les unités disponibles # des phases 2 et 3 (superficie, chambres, description, plan — sans prix). # La page /contact fournit l'adresse civique et le lat/lng de chaque phase # (marqueurs Google Map structurés). # ----------------------------------------------------------------------------- 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://lakle.ca" PAGE_URL = f"{BASE}/phase-1-occupation-immediate/" CONTACT_URL = f"{BASE}/contact/" API_URL = f"{BASE}/wp-json/do-selecteur-plans/v1/unite" SECTOR = "Cité Verte (Saint-Sacrement)" CITY = "Québec" # le sélecteur de plans correspond à la tour phase 4 (calendly « la-kle-phase-4 », # visuels « phase-4 » — le slug « phase-1 » de l'URL est historique) SELECTOR_PHASE = 4 # adresses par phase (page /contact) — repli si la page devient inaccessible _FALLBACK_PHASES: dict[int, dict] = { 1: {"address": "1190, Louis-Adolphe-Robitaille, Québec, G1S 0C2"}, 2: {"address": "1180, Louis-Adolphe Robitaille, Québec, G1S 0C7"}, 3: {"address": "475, avenue Ernest-Gagnon, Québec, G1S 0E4"}, 4: {"address": "Avenue Ernest-Gagnon, Québec, G1S 0E4"}, } # inclusions du complexe (« Toutes les unités incluent », lakle.ca accueil) _BUILDING_AMENITIES = [ "Électroménagers inclus", "Électricité incluse", "Chauffage et climatisation centrale", "Accès à la piscine intérieure", "Accès à la salle d'entraînement", "Toiles solaires dans les fenêtres", "Espaces à vélos intérieurs sécurisés", "Stationnement intérieur sécurisé (en option)", "Interphone à l'entrée de l'immeuble", "Accès avec puce électronique", "Caméras de surveillance", ] class LaKleConnector(BaseConnector): source_id = "lakle" request_delay = 0.5 max_units = 120 # garde-fou # -- adresses / coordonnées par phase (page contact) ----------------------- def _phase_info(self) -> dict[int, dict]: """{phase: {address, lat, lng}} depuis les marqueurs de /contact.""" info = {k: dict(v) for k, v in _FALLBACK_PHASES.items()} try: soup = BeautifulSoup(self.get(CONTACT_URL).text, "html.parser") except Exception: return info for mk in soup.select(".ts-advanced-google-map-marker-single"): m = re.search(r"phase\s*(\d)", mk.get("data-title") or "", re.I) if not m: continue phase = int(m.group(1)) entry = info.setdefault(phase, {}) try: entry["lat"] = float(mk["data-latitude"]) entry["lng"] = float(mk["data-longitude"]) except (KeyError, ValueError): pass # contenu du popup : « Phase 3 | 475 Ernest-Gagnon | Ville de # Québec | QC G1S 0E4 » lignes = [t for t in mk.stripped_strings if not re.match(r"phase\s*\d", t, re.I)] if lignes: addr = ", ".join(lignes) addr = addr.replace("Ville de Québec", "Québec") addr = re.sub(r"\bQC\s+", "", addr) entry["address"] = addr return info # -- unités du sélecteur de plans (tour phase 4) --------------------------- def _selector_listings(self, html: str, phases: dict) -> list[Listing]: units: list[dict] = [] for poly in re.findall(r"]*>", html): if 'class="disponible"' not in poly: continue attrs = dict(re.findall(r'data-([a-z-]+)="([^"]*)"', poly)) if attrs.get("numero"): units.append(attrs) loc = phases.get(SELECTOR_PHASE, {}) listings: list[Listing] = [] seen: set[str] = set() for attrs in units[:self.max_units]: uid = attrs["numero"] if uid in seen: continue seen.add(uid) try: data = self.get(API_URL, params={"id": uid}).json() except Exception: data = {} try: if data.get("etat") not in ("disponible", None): continue unit_no = data.get("unite") or uid raw_type = data.get("type") or attrs.get("type", "") unit_type = normalize_unit_type(raw_type) price_label = (data.get("montant") or "").strip() if data.get("montant_suffixe"): price_label = f"{price_label} {data['montant_suffixe']}".strip() price = parse_price(price_label) if price is None and attrs.get("prix"): price = parse_price(attrs["prix"] + "$") price_label = price_label or f"{attrs['prix']}$" availability = (data.get("a_partir") or data.get("date_libre") or data.get("etat_titre") or "Disponible") # superficie habitable structurée (l'API distingue la terrasse) area_sqft = None try: area_sqft = float(str(data.get("superficie_habitable")) .replace(" ", "").replace(",", ".")) except (TypeError, ValueError): pass details: dict = {} etage = attrs.get("etage") if etage and etage.isdigit(): details["floor"] = int(etage) desc_parts = [] if data.get("nb_chambres"): desc_parts.append(f"{data['nb_chambres']} chambre(s)") if data.get("superficie_habitable"): desc_parts.append( f"Superficie {data['superficie_habitable']} " f"{data.get('superficie_suffixe', 'pi²')}") if data.get("superficie_terrasse"): desc_parts.append( f"{data.get('type_balcon', 'balcon').capitalize()} " f"{data['superficie_terrasse']} " f"{data.get('superficie_suffixe', 'pi²')}") if data.get("style"): desc_parts.append(f"Style {data['style']}") if etage: desc_parts.insert(0, f"Étage {etage}") if data.get("pdf"): desc_parts.append(f"Plan PDF : {data['pdf']}") amenities = list(_BUILDING_AMENITIES) promo = data.get("promotion") or attrs.get("promotion") if promo: amenities.append(f"Promotion : {promo}") images = [u for u in (data.get("gallery") or []) if isinstance(u, str) and re.search(r"\.(?:jpg|jpeg|png|webp)$", u, re.I)] listings.append(Listing( source=self.source_id, external_id=str(uid), url=PAGE_URL, title=f"La Klé — Unité {unit_no}" f"{' (' + unit_type + ')' if unit_type else ''}", address=loc.get("address", "Cité Verte, Québec"), sector=SECTOR, city=CITY, unit_type=unit_type, price=price, price_label=price_label, availability=availability, area_sqft=area_sqft, description=" | ".join(desc_parts), amenities=amenities, details=details, images=images, lat=loc.get("lat"), lng=loc.get("lng"), )) except Exception: continue return listings # -- cartes des phases 2 et 3 (« Unité B16 - Phase 3 ») -------------------- def _card_listings(self, soup: BeautifulSoup, phases: dict) -> list[Listing]: listings: list[Listing] = [] ul = soup.select_one("ul.liste-unite-disponible") if ul is None: return listings for li in ul.find_all("li", recursive=False): try: h4 = li.select_one(".informations-unite h4") if not h4: continue strong = h4.find("strong") name = strong.get_text(" ", strip=True) if strong else "" name = re.sub(r"^Unité\s+", "", name).strip() if not name: continue span = h4.find("span") phase = None if span: m = re.search(r"phase\s*(\d)", span.get_text(), re.I) if m: phase = int(m.group(1)) loc = phases.get(phase, {}) if phase else {} # « 1214pi² - 3 chambres » tech_el = li.select_one(".informations-technique") tech = " ".join(tech_el.get_text(" ", strip=True).split()) \ if tech_el else "" unit_type = normalize_unit_type(tech) # description : « Disponible en octobre … » + texte marketing desc_lines: list[str] = [] availability = "" p = li.select_one(".informations-unite p") if p: for seg in p.stripped_strings: seg = " ".join(seg.split()) if not availability and re.match(r"disponible\b", seg, re.I): availability = seg.rstrip("!").strip() desc_lines.append(seg) desc_parts = ([tech] if tech else []) + desc_lines plan = li.select_one("a.btn-voir-plan[href]") if plan: desc_parts.append(f"Plan PDF : {plan['href']}") images = [] gal = li.select_one("a[data-gallery], .image-apercu a[href]") if gal: for u in re.split(r"[,\s]+", gal.get("data-gallery") or gal.get("href") or ""): if re.search(r"\.(?:jpg|jpeg|png|webp)$", u, re.I): images.append(u) slug = re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", name.lower())).strip("-") titre_phase = f" (Phase {phase})" if phase else "" listings.append(Listing( source=self.source_id, external_id=f"p{phase or 'x'}-{slug}", url=PAGE_URL, title=f"La Klé — Unité {name}{titre_phase}", address=loc.get("address", "Cité Verte, Québec"), sector=SECTOR, city=CITY, unit_type=unit_type, price=None, # aucun prix affiché pour ces unités price_label="", # la liste s'intitule « Découvrez nos unités disponibles » availability=availability or "Disponible", description=" | ".join(desc_parts), amenities=list(_BUILDING_AMENITIES), images=list(dict.fromkeys(images)), lat=loc.get("lat"), lng=loc.get("lng"), )) except Exception: continue return listings # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Listing]: html = self.get(PAGE_URL).text soup = BeautifulSoup(html, "html.parser") phases = self._phase_info() listings = self._selector_listings(html, phases) listings.extend(self._card_listings(soup, phases)) # dédup par uid (prudence si une unité apparaît deux fois) uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values())