# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lokalia.py : connecteur Espaces Lokalia (espaceslokalia.ca) # Le gestionnaire couvre plusieurs régions (Québec/Lévis + Grand Montréal : # Laval, Longueuil/Rive-Sud, Montérégie, Rive-Nord proche...) # -> on interroge l'endpoint AJAX WordPress `get_immeubles_map_data` # région par région (slugs découverts dans le filtre du site), ce qui donne # la ville par défaut ET les lat/lng structurés de chaque immeuble. # Page immeuble : adresse structurée (lien Google Maps sous le H1), # accordéons Inclusions / Équipements & sécurité / Aires communes, # description (paragraphes réels + promotion + « N unités • N étages »), # téléphone du bureau de location, grille « Prix et types d'unités ». # Une annonce par type d'unité offert dans chaque immeuble (les variantes # « + DEN » ou avec parenthèse gardent leur propre annonce). # Ni disponibilité ni superficie ne sont publiées par la source. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import time from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.espaceslokalia.ca" AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php" # slug de région (filtre du site) -> ville par défaut des immeubles REGIONS = { # Région de Québec / Lévis "quebec": "Québec", "levis": "Lévis", # Laval "laval": "Laval", # Longueuil / Rive-Sud / Montérégie "longueuil": "Longueuil", "saint-hubert": "Longueuil", "brossard": "Brossard", "saint-lambert": "Saint-Lambert", "boucherville": "Boucherville", "saint-basile-le-grand": "Saint-Basile-le-Grand", "sainte-julie": "Sainte-Julie", "chambly": "Chambly", "saint-constant": "Saint-Constant", "delson": "Delson", "candiac": "Candiac", "chateauguay": "Châteauguay", "beauharnois": "Beauharnois", "salaberry-de-valleyfield": "Salaberry-de-Valleyfield", "granby": "Granby", # Rive-Nord proche "lachenaie": "Terrebonne", "terrebonne": "Terrebonne", "mascouche": "Mascouche", "saint-jerome": "Saint-Jérôme", } # repli si le lien Google Maps sous le H1 est absent ADDR_RE = re.compile( r"(?]{1,50},\s*[^,<>]{2,40},\s*" r"(?:QC|Qu[ée]bec)[^,<>]{0,12}(?:,\s*Canada)?", re.I) IMG_RE = re.compile(r"https://www\.espaceslokalia\.ca/wp-content/uploads/" r'[^"\s\\)]+\.(?:jpe?g|webp)', re.I) def _slugify(s: str) -> str: return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", s.lower().replace("½", ".5"))).strip("-") class LokaliaConnector(BaseConnector): source_id = "lokalia" request_delay = 0.6 max_buildings = 60 # garde-fou de crawl (toutes régions) def _buildings(self) -> list[tuple[str, str, float | None, float | None]]: """Endpoint AJAX interrogé région par région -> [(url immeuble, ville par défaut, lat, lng), ...] dédupliqués.""" out: list[tuple[str, str, float | None, float | None]] = [] seen: set[str] = set() for region, city in REGIONS.items(): try: time.sleep(self.request_delay) # politesse (POST direct) resp = self.session.post( AJAX_URL, data={"action": "get_immeubles_map_data", "regions": json.dumps([region])}, timeout=self.timeout, ) resp.raise_for_status() items = resp.json() except Exception: continue for item in items: content = (item.get("list_content") or "") + \ (item.get("popup_content") or "") m = re.search(r'href="(https://www\.espaceslokalia\.ca/' r'immeuble/[^"]+)"', content) if not m or m.group(1) in seen: continue seen.add(m.group(1)) # lat/lng structurés fournis par la carte lat = lng = None try: lat = float(item.get("lat")) lng = float(item.get("lng")) except (TypeError, ValueError): lat = lng = None out.append((m.group(1), city, lat, lng)) return out def fetch(self) -> list[Listing]: listings: list[Listing] = [] for url, default_city, lat, lng in self._buildings()[: self.max_buildings]: try: html = self.get(url).text except Exception: continue try: listings.extend( self._parse_building(url, html, default_city, lat, lng)) except Exception: continue return listings def _parse_building(self, url: str, html: str, default_city: str = "Québec", lat: float | None = None, lng: float | None = None) -> list[Listing]: soup = BeautifulSoup(html, "html.parser") slug = url.rstrip("/").split("/")[-1] h1 = soup.find("h1") title = h1.get_text(" ", strip=True) if h1 else slug # « Appartements à louer à Lévis - Vivaxcès Le Nicolas » -> nom court name = title.split(" - ")[-1].split("│")[-1].strip() or slug # Adresse structurée : lien Google Maps sous le H1 address = "" wrap = soup.select_one(".category-wrapper a.description[href*='maps.google']") if wrap: address = re.sub(r"\s+", " ", wrap.get_text(" ", strip=True)).strip() if not address: text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) m = ADDR_RE.search(text) address = m.group(0).strip() if m else "" # étiquettes de la fiche (« Nouveau », « Promotion ») tags = [t.get_text(strip=True) for t in soup.select(".category-wrapper span.category")] # secteur / ville : « 89 Rue du Doré-Jaune, Lachenaie, Terrebonne, QC » sector = "" city = default_city if address: parts = [p.strip() for p in address.split(",")] if len(parts) >= 2: sector = parts[1] city = infer_city(sector, default=city) if not sector and "Lévis" in title: city = "Lévis" if sector and sector.lower() == city.lower(): sector = "" # éviter secteur == ville (redondant) # Description : paragraphes réels de la fiche + « N unités • N étages » # + promotion, sinon og:description en repli desc_bits: list[str] = [] promo = soup.select_one(".promotion-main h2") if promo: desc_bits.append("Promotion : " + promo.get_text(" ", strip=True)) elif "Promotion" in tags: desc_bits.append("Promotion en cours") paras = [p.get_text(" ", strip=True) for p in soup.select(".wx-single-description .description-left p") if "wx-disclaimer" not in (p.get("class") or [])] if paras: desc_bits.append(" ".join(t for t in paras if t)) size = soup.select_one(".wx-single-description .category-wrap span") if size: desc_bits.append(re.sub(r"\s+", " ", size.get_text(" ", strip=True))) if not desc_bits: og = soup.find("meta", attrs={"property": "og:description"}) if og and og.get("content"): desc_bits.append(og["content"].strip()) desc = " — ".join(b for b in desc_bits if b)[:700] # Commodités : inclusions (icônes avec attribut title) + accordéons # « Équipements & sécurité » et « Aires communes » (items