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/lakle.py : connecteur La Klé (lakle.ca)5# Complexe de 559 appartements locatifs (4 phases) dans la Cité Verte6# (Québec, secteur Saint-Sacrement). La page « disponibilité » expose :7# 1) le sélecteur de plans WordPress de la tour la plus récente (phase 4) :8# unités en <polygon class="disponible" data-numero=...> + API REST9# /wp-json/do-selecteur-plans/v1/unite?id=N (prix, type, superficie10# habitable/terrasse, étage, style, date libre, plan) ;11# 2) des cartes <li> « Unité B16 - Phase 3 » pour les unités disponibles12# des phases 2 et 3 (superficie, chambres, description, plan — sans prix).13# La page /contact fournit l'adresse civique et le lat/lng de chaque phase14# (marqueurs Google Map structurés).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://lakle.ca"26PAGE_URL = f"{BASE}/phase-1-occupation-immediate/"27CONTACT_URL = f"{BASE}/contact/"28API_URL = f"{BASE}/wp-json/do-selecteur-plans/v1/unite"29SECTOR = "Cité Verte (Saint-Sacrement)"30CITY = "Québec"3132# le sélecteur de plans correspond à la tour phase 4 (calendly « la-kle-phase-4 »,33# visuels « phase-4 » — le slug « phase-1 » de l'URL est historique)34SELECTOR_PHASE = 43536# adresses par phase (page /contact) — repli si la page devient inaccessible37_FALLBACK_PHASES: dict[int, dict] = {38 1: {"address": "1190, Louis-Adolphe-Robitaille, Québec, G1S 0C2"},39 2: {"address": "1180, Louis-Adolphe Robitaille, Québec, G1S 0C7"},40 3: {"address": "475, avenue Ernest-Gagnon, Québec, G1S 0E4"},41 4: {"address": "Avenue Ernest-Gagnon, Québec, G1S 0E4"},42}4344# inclusions du complexe (« Toutes les unités incluent », lakle.ca accueil)45_BUILDING_AMENITIES = [46 "Électroménagers inclus",47 "Électricité incluse",48 "Chauffage et climatisation centrale",49 "Accès à la piscine intérieure",50 "Accès à la salle d'entraînement",51 "Toiles solaires dans les fenêtres",52 "Espaces à vélos intérieurs sécurisés",53 "Stationnement intérieur sécurisé (en option)",54 "Interphone à l'entrée de l'immeuble",55 "Accès avec puce électronique",56 "Caméras de surveillance",57]585960class LaKleConnector(BaseConnector):61 source_id = "lakle"62 request_delay = 0.563 max_units = 120 # garde-fou6465 # -- adresses / coordonnées par phase (page contact) -----------------------66 def _phase_info(self) -> dict[int, dict]:67 """{phase: {address, lat, lng}} depuis les marqueurs de /contact."""68 info = {k: dict(v) for k, v in _FALLBACK_PHASES.items()}69 try:70 soup = BeautifulSoup(self.get(CONTACT_URL).text, "html.parser")71 except Exception:72 return info73 for mk in soup.select(".ts-advanced-google-map-marker-single"):74 m = re.search(r"phase\s*(\d)", mk.get("data-title") or "", re.I)75 if not m:76 continue77 phase = int(m.group(1))78 entry = info.setdefault(phase, {})79 try:80 entry["lat"] = float(mk["data-latitude"])81 entry["lng"] = float(mk["data-longitude"])82 except (KeyError, ValueError):83 pass84 # contenu du popup : « Phase 3 | 475 Ernest-Gagnon | Ville de85 # Québec | QC G1S 0E4 »86 lignes = [t for t in mk.stripped_strings87 if not re.match(r"phase\s*\d", t, re.I)]88 if lignes:89 addr = ", ".join(lignes)90 addr = addr.replace("Ville de Québec", "Québec")91 addr = re.sub(r"\bQC\s+", "", addr)92 entry["address"] = addr93 return info9495 # -- unités du sélecteur de plans (tour phase 4) ---------------------------96 def _selector_listings(self, html: str, phases: dict) -> list[Listing]:97 units: list[dict] = []98 for poly in re.findall(r"<polygon[^>]*>", html):99 if 'class="disponible"' not in poly:100 continue101 attrs = dict(re.findall(r'data-([a-z-]+)="([^"]*)"', poly))102 if attrs.get("numero"):103 units.append(attrs)104105 loc = phases.get(SELECTOR_PHASE, {})106 listings: list[Listing] = []107 seen: set[str] = set()108 for attrs in units[:self.max_units]:109 uid = attrs["numero"]110 if uid in seen:111 continue112 seen.add(uid)113 try:114 data = self.get(API_URL, params={"id": uid}).json()115 except Exception:116 data = {}117 try:118 if data.get("etat") not in ("disponible", None):119 continue120 unit_no = data.get("unite") or uid121 raw_type = data.get("type") or attrs.get("type", "")122 unit_type = normalize_unit_type(raw_type)123124 price_label = (data.get("montant") or "").strip()125 if data.get("montant_suffixe"):126 price_label = f"{price_label} {data['montant_suffixe']}".strip()127 price = parse_price(price_label)128 if price is None and attrs.get("prix"):129 price = parse_price(attrs["prix"] + "$")130 price_label = price_label or f"{attrs['prix']}$"131132 availability = (data.get("a_partir")133 or data.get("date_libre")134 or data.get("etat_titre")135 or "Disponible")136137 # superficie habitable structurée (l'API distingue la terrasse)138 area_sqft = None139 try:140 area_sqft = float(str(data.get("superficie_habitable"))141 .replace(" ", "").replace(",", "."))142 except (TypeError, ValueError):143 pass144145 details: dict = {}146 etage = attrs.get("etage")147 if etage and etage.isdigit():148 details["floor"] = int(etage)149150 desc_parts = []151 if data.get("nb_chambres"):152 desc_parts.append(f"{data['nb_chambres']} chambre(s)")153 if data.get("superficie_habitable"):154 desc_parts.append(155 f"Superficie {data['superficie_habitable']} "156 f"{data.get('superficie_suffixe', 'pi²')}")157 if data.get("superficie_terrasse"):158 desc_parts.append(159 f"{data.get('type_balcon', 'balcon').capitalize()} "160 f"{data['superficie_terrasse']} "161 f"{data.get('superficie_suffixe', 'pi²')}")162 if data.get("style"):163 desc_parts.append(f"Style {data['style']}")164 if etage:165 desc_parts.insert(0, f"Étage {etage}")166 if data.get("pdf"):167 desc_parts.append(f"Plan PDF : {data['pdf']}")168169 amenities = list(_BUILDING_AMENITIES)170 promo = data.get("promotion") or attrs.get("promotion")171 if promo:172 amenities.append(f"Promotion : {promo}")173174 images = [u for u in (data.get("gallery") or [])175 if isinstance(u, str)176 and re.search(r"\.(?:jpg|jpeg|png|webp)$", u, re.I)]177178 listings.append(Listing(179 source=self.source_id,180 external_id=str(uid),181 url=PAGE_URL,182 title=f"La Klé — Unité {unit_no}"183 f"{' (' + unit_type + ')' if unit_type else ''}",184 address=loc.get("address", "Cité Verte, Québec"),185 sector=SECTOR,186 city=CITY,187 unit_type=unit_type,188 price=price,189 price_label=price_label,190 availability=availability,191 area_sqft=area_sqft,192 description=" | ".join(desc_parts),193 amenities=amenities,194 details=details,195 images=images,196 lat=loc.get("lat"),197 lng=loc.get("lng"),198 ))199 except Exception:200 continue201 return listings202203 # -- cartes des phases 2 et 3 (« Unité B16 - Phase 3 ») --------------------204 def _card_listings(self, soup: BeautifulSoup, phases: dict) -> list[Listing]:205 listings: list[Listing] = []206 ul = soup.select_one("ul.liste-unite-disponible")207 if ul is None:208 return listings209 for li in ul.find_all("li", recursive=False):210 try:211 h4 = li.select_one(".informations-unite h4")212 if not h4:213 continue214 strong = h4.find("strong")215 name = strong.get_text(" ", strip=True) if strong else ""216 name = re.sub(r"^Unité\s+", "", name).strip()217 if not name:218 continue219 span = h4.find("span")220 phase = None221 if span:222 m = re.search(r"phase\s*(\d)", span.get_text(), re.I)223 if m:224 phase = int(m.group(1))225 loc = phases.get(phase, {}) if phase else {}226227 # « 1214pi² - 3 chambres »228 tech_el = li.select_one(".informations-technique")229 tech = " ".join(tech_el.get_text(" ", strip=True).split()) \230 if tech_el else ""231 unit_type = normalize_unit_type(tech)232233 # description : « Disponible en octobre … » + texte marketing234 desc_lines: list[str] = []235 availability = ""236 p = li.select_one(".informations-unite p")237 if p:238 for seg in p.stripped_strings:239 seg = " ".join(seg.split())240 if not availability and re.match(r"disponible\b", seg, re.I):241 availability = seg.rstrip("!").strip()242 desc_lines.append(seg)243 desc_parts = ([tech] if tech else []) + desc_lines244 plan = li.select_one("a.btn-voir-plan[href]")245 if plan:246 desc_parts.append(f"Plan PDF : {plan['href']}")247248 images = []249 gal = li.select_one("a[data-gallery], .image-apercu a[href]")250 if gal:251 for u in re.split(r"[,\s]+", gal.get("data-gallery")252 or gal.get("href") or ""):253 if re.search(r"\.(?:jpg|jpeg|png|webp)$", u, re.I):254 images.append(u)255256 slug = re.sub(r"-+", "-",257 re.sub(r"[^a-z0-9]+", "-", name.lower())).strip("-")258 titre_phase = f" (Phase {phase})" if phase else ""259 listings.append(Listing(260 source=self.source_id,261 external_id=f"p{phase or 'x'}-{slug}",262 url=PAGE_URL,263 title=f"La Klé — Unité {name}{titre_phase}",264 address=loc.get("address", "Cité Verte, Québec"),265 sector=SECTOR,266 city=CITY,267 unit_type=unit_type,268 price=None, # aucun prix affiché pour ces unités269 price_label="",270 # la liste s'intitule « Découvrez nos unités disponibles »271 availability=availability or "Disponible",272 description=" | ".join(desc_parts),273 amenities=list(_BUILDING_AMENITIES),274 images=list(dict.fromkeys(images)),275 lat=loc.get("lat"),276 lng=loc.get("lng"),277 ))278 except Exception:279 continue280 return listings281282 # -- contrat ---------------------------------------------------------------283 def fetch(self) -> list[Listing]:284 html = self.get(PAGE_URL).text285 soup = BeautifulSoup(html, "html.parser")286 phases = self._phase_info()287288 listings = self._selector_listings(html, phases)289 listings.extend(self._card_listings(soup, phases))290291 # dédup par uid (prudence si une unité apparaît deux fois)292 uniq: dict[str, Listing] = {}293 for lst in listings:294 uniq.setdefault(lst.external_id, lst)295 return list(uniq.values())296