# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_laprise.py : connecteur Gestion Laprise # (gestionlaprise.com/immobilier — Baie-Comeau et Haute-Côte-Nord, ~70 # appartements, location 31 jours et plus). WordPress + thème Inspiry Real # Places (plugin inspiry-real-estate), tout rendu serveur. Archive # /logement-statut/a-louer/ (paginée) : cartes `article.property-listing-simple` # — titre, adresse complète, prix (« $850 Par mois »), meta Bedrooms/ # Bathrooms/Type (1½–6½)/Status. Fiche /propriete/// (via # cache BD) : description, caractéristiques, GPS (propertyMapData) et galerie # pleine taille (envira-gallery) — le carrousel « propriétés similaires » # (owl-carousel) est ignoré. Unités hôtelières (statut « Hôtel ») et locaux # commerciaux exclus. Pas de robots.txt (= tout permis). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://gestionlaprise.com/immobilier" LIST_URL = f"{BASE}/logement-statut/a-louer/" MAX_PAGES = 8 _MAP_DATA_RE = re.compile(r"propertyMapData\s*=\s*(\{.*?\});", re.S) def _clean_price_label(label: str) -> str: """'$1,050 Par mois' -> '$1050 Par mois' compatible parse_price.""" return re.sub(r"(\d),(\d{3})", r"\1\2", label) class GestionLapriseConnector(BaseConnector): source_id = "gestion_laprise" request_delay = 0.6 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: self._fetched = 0 listings: dict[str, Listing] = {} for page in range(1, MAX_PAGES + 1): url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/" try: html = self.get(url).text except Exception: break # /page/N/ inexistante -> 404, fin soup = BeautifulSoup(html, "html.parser") cards = soup.select("article.property-listing-simple") if not cards: break for card in cards: try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) # -- carte (archive Inspiry Real Places) ------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one('h3.entry-title a[href*="/propriete/"]') if not link: return url = link["href"] title = link.get_text(strip=True) m = re.search(r"/propriete/([^/?#]+)/([^/?#]+)", url) if not m: return ext_id = f"{m.group(1)}--{m.group(2)}" if ext_id in listings: return # meta structurée : Bedrooms / Bathrooms / Type / Status meta: dict[str, str] = {} for item in card.select(".property-meta .meta-item"): label = item.select_one(".meta-item-label") value = item.select_one(".meta-item-value") if label and value: meta[label.get_text(strip=True).lower()] = value.get_text(strip=True) unit_label = meta.get("type", "") status = meta.get("status", "") # exclusions : unités hôtelières (nuitée) et locaux commerciaux if re.search(r"h[oô]tel", f"{status} {title}", re.I): return if re.search(r"local|commercial|bureau", unit_label, re.I): return addr_el = card.select_one("p.property-address") address = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)) if addr_el else "" # ville : « 112 Avenue le Gardeur, Baie-Comeau, QC G4Z 1H8, Canada » city = "" parts = [p.strip() for p in address.split(",")] if len(parts) >= 3: city = parts[-3] price_el = card.select_one(".price-wrapper .price") postfix_el = card.select_one(".price-wrapper .postfix-text") price_label = price_el.get_text(strip=True) if price_el else "" if price_label and postfix_el and postfix_el.get_text(strip=True): price_label += f" {postfix_el.get_text(strip=True)}" amenities: list[str] = [] if meta.get("bedrooms"): amenities.append(f"{meta['bedrooms']} chambre(s)") if meta.get("bathrooms"): amenities.append(f"{meta['bathrooms']} salle(s) de bain") images: list[str] = [] thumb = card.select_one(".property-thumbnail img[src]") if thumb: images.append(thumb["src"]) lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=title, address=address, city=city, unit_type=normalize_unit_type(unit_label), price=parse_price(_clean_price_label(price_label)), price_label=price_label, availability=status, # « À Louer » (statut brut du site) amenities=amenities, images=images, ) key = hashlib.sha1( f"{title}|{price_label}|{status}|{unit_label}".encode("utf-8") ).hexdigest() try: payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u)) self._apply_detail(lst, payload) except Exception: pass listings[ext_id] = lst # -- fiche détail (/propriete///) ------------------------------ def _fetch_detail(self, url: str) -> dict: """Description, caractéristiques, GPS et galerie envira pleine taille.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} desc_el = soup.select_one(".entry-content") if desc_el: txt = desc_el.get_text("\n", strip=True) txt = re.sub(r"^Description\n?", "", txt) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] out["features"] = [ re.sub(r"\s+", " ", li.get_text(" ", strip=True)) for li in soup.select(".property-features li") if li.get_text(strip=True)][:20] # GPS : propertyMapData = {"lat":"49.23...","lang":"-68.15..."} m = _MAP_DATA_RE.search(html) if m: try: data = json.loads(m.group(1)) out["lat"] = float(data.get("lat")) out["lng"] = float(data.get("lang")) except (TypeError, ValueError): pass # galerie envira : liens vers les images pleine taille (« -scaled ») images: list[str] = [] for a in soup.select('.envira-gallery-wrap a[href*="/uploads/"]'): u = a["href"] if u.startswith("http") and u not in images: images.append(u) out["images"] = images[:25] return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return if d.get("description"): lst.description = d["description"] if d.get("features"): lst.amenities = list(dict.fromkeys(lst.amenities + d["features"])) if d.get("lat") is not None and d.get("lng") is not None: lst.lat, lst.lng = d["lat"], d["lng"] if d.get("images") and len(d["images"]) > len(lst.images): lst.images = d["images"]