# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lak_nature.py : connecteur LAK Appartements Nature # (lakappartementsnature.ca — 1004, boul. du Lac, Lac-Beauport, près de la # station Le Relais ; 62 unités locatives, gestion Acropole). WordPress + # Elementor avec sélecteur de plans « do-selecteur-plans » : chaque unité # est un SVG rendu serveur avec sa classe d'état # (disponible/indisponible) et ses data-* (data-numero=id interne, # data-etage, data-type, data-prix, data-promotion). On n'ingère que les # unités DISPONIBLES ; la fiche JSON /wp-json/do-selecteur-plans/v1/unite # ?id= (via cache détail) confirme l'état et fournit le vrai # numéro d'unité, superficies, plan PDF et galerie. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://lakappartementsnature.ca" JSON_URL = f"{BASE}/wp-json/do-selecteur-plans/v1/unite" ADDRESS = "1004, boulevard du Lac, Lac-Beauport, G3B 2T4" CITY = "Lac-Beauport" # photos de l'immeuble dans la page (hors logos/icônes/plans) IMG_RE = re.compile(r'(?:src|href)="(https://lakappartementsnature\.ca/' r'wp-content/uploads/[^"]+\.(?:jpe?g|png|webp))"', re.I) class LakNatureConnector(BaseConnector): source_id = "lak_nature" request_delay = 0.6 max_details = 40 # garde-fou fiches JSON (vraies requêtes par sync) def fetch(self) -> list[Listing]: listings: list[Listing] = [] html = self.get(f"{BASE}/").text soup = BeautifulSoup(html, "html.parser") self._fetched = 0 building_images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|favicon|plan|Lak_plan", u, re.I)][:12] details_common: dict = {} phones = dict.fromkeys(re.findall(r'href="tel:\+?1?(\d{10})"', html)) if phones: d10 = next(iter(phones)) details_common["contact"] = { "phone": f"{d10[:3]}-{d10[3:6]}-{d10[6:]}"} # polygones SVG du sélecteur : un par unité (dupliqués desktop/mobile) seen: set[str] = set() for poly in soup.find_all("polygon"): try: classes = " ".join(poly.get("class") or []) numero = (poly.get("data-numero") or "").strip() if not numero or numero in seen: continue seen.add(numero) # on n'ingère que les unités encore offertes if "indisponible" in classes or "disponible" not in classes: continue unit_type = normalize_unit_type( poly.get("data-type") or "") price = parse_price(poly.get("data-prix") or "") floor = (poly.get("data-etage") or "").strip() promo = (poly.get("data-promotion") or "").strip() # fiche JSON de l'unité : vrai numéro, état confirmé, # superficies, plan PDF, galerie ext_id = numero availability = "Disponible" area = None images = list(building_images) details = dict(details_common) amenities: list[str] = [] key = hashlib.sha1( f"{numero}|{classes}|{price}".encode()).hexdigest() try: d = self.detail(numero, key, lambda n=numero: self._unite_json(n)) except Exception: d = {} if d: if d.get("etat") and d["etat"] != "disponible": continue # l'API fait foi sur l'état if d.get("unite"): ext_id = str(d["unite"]) if d.get("date_libre"): availability = str(d["date_libre"]) try: v = float(str(d.get("superficie_habitable") or "") .replace(" ", "").replace(",", ".")) if 200 <= v <= 3000: area = v except ValueError: pass if d.get("superficie_terrasse"): amenities.append( f"Terrasse : {d['superficie_terrasse']} pi²") if d.get("style"): amenities.append(f"Style : {d['style']}") if d.get("pdf"): details["plan_pdf"] = d["pdf"] gal = [u for u in (d.get("gallery") or []) if isinstance(u, str)] if gal: images = gal + building_images if promo: amenities.append(f"Promotion : {promo}") if floor: amenities.append(f"Étage : {floor}") listings.append(Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}/", title=f"{unit_type} — unité {ext_id}, " f"LAK Appartements Nature", address=ADDRESS, city=CITY, unit_type=unit_type, price=price, availability=availability, area_sqft=area, amenities=amenities, details=details, images=images[:20], )) except Exception: continue return listings # -- fiche JSON d'une unité (endpoint du plugin do-selecteur-plans) ---------- def _unite_json(self, numero: str) -> dict: if self._fetched >= self.max_details: raise RuntimeError("budget de fiches JSON atteint") self._fetched += 1 d = self.get(f"{JSON_URL}?id={numero}").json() return d if isinstance(d, dict) else {}