# ----------------------------------------------------------------------------- # 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
  • ) amenities = [el.get("title").strip() for el in soup.select("div.icon-box[title]") if el.get("title")] for acc_cls in ("equipment", "aires"): for li in soup.select(f"li.wx-single-accordion.{acc_cls} ul li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t: amenities.append(t) amenities = list(dict.fromkeys(a for a in amenities if a))[:30] # Téléphone du bureau de location (lien tel:) details: dict = {} tel = soup.select_one('a[href^="tel:"]') if tel: num = re.sub(r"\D", "", tel.get("href", "")) num = num[1:] if len(num) == 11 and num.startswith("1") else num if len(num) == 10: details["contact"] = {"phone": f"{num[:3]}-{num[3:6]}-{num[6:10]}"} # Photos (exclure icônes .png des inclusions) images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|favicon|-150x150", u, re.I)][:25] # Types d'unités + prix : blocs .square-box (h5 = type, p = prix). # Dédupliqués par libellé BRUT : « 4 1/2 » et « 4 1/2 + DEN » restent # deux annonces (l'id de la 1re occurrence d'un type normalisé garde # son schéma historique ; les variantes reçoivent un suffixe du libellé). results: list[Listing] = [] seen_raw: set[str] = set() seen_norm: set[str] = set() for box in soup.select("div.square-box"): h5 = box.find("h5") if not h5: continue unit_raw = re.sub(r"\s+", " ", h5.get_text(" ", strip=True)).strip() unit_type = normalize_unit_type(unit_raw) raw_key = _slugify(unit_raw) if not unit_type or not raw_key or raw_key in seen_raw: continue seen_raw.add(raw_key) if unit_type not in seen_norm: seen_norm.add(unit_type) ext_id = f"{slug}-{unit_type.replace('½', '.5')}" else: # variante (« + DEN », parenthèse…) ext_id = f"{slug}-{raw_key}" price_label = re.sub(r"\s+", " ", box.get_text(" ", strip=True) .replace(unit_raw, "", 1)).strip() pm = re.search(r"(?:À partir de\s*)?([\d\s]+)\$\s*/?\s*mois", price_label) price_label = (f"À partir de {pm.group(1).strip()}$/mois" if pm else price_label[:60]) results.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{name} — {unit_raw}", address=address, sector=sector, city=city, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability="", description=desc, amenities=amenities, details=dict(details), images=images, lat=lat, lng=lng, )) # Immeuble sans grille de prix : annonce par immeuble quand même if not results: results.append(Listing( source=self.source_id, external_id=slug, url=url, title=name, address=address, sector=sector, city=city, description=desc, amenities=amenities, details=dict(details), images=images, lat=lat, lng=lng, )) return results