SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
12.8 KB · 296 lines python
Raw Blame History
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