spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/gestion_laprise.py : connecteur Gestion Laprise5# (gestionlaprise.com/immobilier — Baie-Comeau et Haute-Côte-Nord, ~706# appartements, location 31 jours et plus). WordPress + thème Inspiry Real7# Places (plugin inspiry-real-estate), tout rendu serveur. Archive8# /logement-statut/a-louer/ (paginée) : cartes `article.property-listing-simple`9# — titre, adresse complète, prix (« $850 Par mois »), meta Bedrooms/10# Bathrooms/Type (1½–6½)/Status. Fiche /propriete/<immeuble>/<unité>/ (via11# cache BD) : description, caractéristiques, GPS (propertyMapData) et galerie12# pleine taille (envira-gallery) — le carrousel « propriétés similaires »13# (owl-carousel) est ignoré. Unités hôtelières (statut « Hôtel ») et locaux14# commerciaux exclus. Pas de robots.txt (= tout permis).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import json20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price25from .base import BaseConnector2627BASE = "https://gestionlaprise.com/immobilier"28LIST_URL = f"{BASE}/logement-statut/a-louer/"29MAX_PAGES = 83031_MAP_DATA_RE = re.compile(r"propertyMapData\s*=\s*(\{.*?\});", re.S)323334def _clean_price_label(label: str) -> str:35 """'$1,050 Par mois' -> '$1050 Par mois' compatible parse_price."""36 return re.sub(r"(\d),(\d{3})", r"\1\2", label)373839class GestionLapriseConnector(BaseConnector):40 source_id = "gestion_laprise"41 request_delay = 0.642 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync)4344 def fetch(self) -> list[Listing]:45 self._fetched = 046 listings: dict[str, Listing] = {}47 for page in range(1, MAX_PAGES + 1):48 url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/"49 try:50 html = self.get(url).text51 except Exception:52 break # /page/N/ inexistante -> 404, fin53 soup = BeautifulSoup(html, "html.parser")54 cards = soup.select("article.property-listing-simple")55 if not cards:56 break57 for card in cards:58 try:59 self._parse_card(card, listings)60 except Exception:61 continue62 return list(listings.values())6364 # -- carte (archive Inspiry Real Places) -------------------------------------65 def _parse_card(self, card, listings: dict[str, Listing]) -> None:66 link = card.select_one('h3.entry-title a[href*="/propriete/"]')67 if not link:68 return69 url = link["href"]70 title = link.get_text(strip=True)71 m = re.search(r"/propriete/([^/?#]+)/([^/?#]+)", url)72 if not m:73 return74 ext_id = f"{m.group(1)}--{m.group(2)}"75 if ext_id in listings:76 return7778 # meta structurée : Bedrooms / Bathrooms / Type / Status79 meta: dict[str, str] = {}80 for item in card.select(".property-meta .meta-item"):81 label = item.select_one(".meta-item-label")82 value = item.select_one(".meta-item-value")83 if label and value:84 meta[label.get_text(strip=True).lower()] = value.get_text(strip=True)85 unit_label = meta.get("type", "")86 status = meta.get("status", "")8788 # exclusions : unités hôtelières (nuitée) et locaux commerciaux89 if re.search(r"h[oô]tel", f"{status} {title}", re.I):90 return91 if re.search(r"local|commercial|bureau", unit_label, re.I):92 return9394 addr_el = card.select_one("p.property-address")95 address = re.sub(r"\s+", " ",96 addr_el.get_text(" ", strip=True)) if addr_el else ""97 # ville : « 112 Avenue le Gardeur, Baie-Comeau, QC G4Z 1H8, Canada »98 city = ""99 parts = [p.strip() for p in address.split(",")]100 if len(parts) >= 3:101 city = parts[-3]102103 price_el = card.select_one(".price-wrapper .price")104 postfix_el = card.select_one(".price-wrapper .postfix-text")105 price_label = price_el.get_text(strip=True) if price_el else ""106 if price_label and postfix_el and postfix_el.get_text(strip=True):107 price_label += f" {postfix_el.get_text(strip=True)}"108109 amenities: list[str] = []110 if meta.get("bedrooms"):111 amenities.append(f"{meta['bedrooms']} chambre(s)")112 if meta.get("bathrooms"):113 amenities.append(f"{meta['bathrooms']} salle(s) de bain")114115 images: list[str] = []116 thumb = card.select_one(".property-thumbnail img[src]")117 if thumb:118 images.append(thumb["src"])119120 lst = Listing(121 source=self.source_id,122 external_id=ext_id,123 url=url,124 title=title,125 address=address,126 city=city,127 unit_type=normalize_unit_type(unit_label),128 price=parse_price(_clean_price_label(price_label)),129 price_label=price_label,130 availability=status, # « À Louer » (statut brut du site)131 amenities=amenities,132 images=images,133 )134135 key = hashlib.sha1(136 f"{title}|{price_label}|{status}|{unit_label}".encode("utf-8")137 ).hexdigest()138 try:139 payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u))140 self._apply_detail(lst, payload)141 except Exception:142 pass143 listings[ext_id] = lst144145 # -- fiche détail (/propriete/<immeuble>/<unité>/) ------------------------------146 def _fetch_detail(self, url: str) -> dict:147 """Description, caractéristiques, GPS et galerie envira pleine taille."""148 if self._fetched >= self.max_details:149 raise RuntimeError("budget de fiches détail atteint")150 self._fetched += 1151 html = self.get(url).text152 soup = BeautifulSoup(html, "html.parser")153 out: dict = {}154155 desc_el = soup.select_one(".entry-content")156 if desc_el:157 txt = desc_el.get_text("\n", strip=True)158 txt = re.sub(r"^Description\n?", "", txt)159 out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]160161 out["features"] = [162 re.sub(r"\s+", " ", li.get_text(" ", strip=True))163 for li in soup.select(".property-features li")164 if li.get_text(strip=True)][:20]165166 # GPS : propertyMapData = {"lat":"49.23...","lang":"-68.15..."}167 m = _MAP_DATA_RE.search(html)168 if m:169 try:170 data = json.loads(m.group(1))171 out["lat"] = float(data.get("lat"))172 out["lng"] = float(data.get("lang"))173 except (TypeError, ValueError):174 pass175176 # galerie envira : liens vers les images pleine taille (« -scaled »)177 images: list[str] = []178 for a in soup.select('.envira-gallery-wrap a[href*="/uploads/"]'):179 u = a["href"]180 if u.startswith("http") and u not in images:181 images.append(u)182 out["images"] = images[:25]183 return out184185 def _apply_detail(self, lst: Listing, d: dict) -> None:186 """Reporte le payload (frais ou en cache) sur l'annonce."""187 if not d:188 return189 if d.get("description"):190 lst.description = d["description"]191 if d.get("features"):192 lst.amenities = list(dict.fromkeys(lst.amenities + d["features"]))193 if d.get("lat") is not None and d.get("lng") is not None:194 lst.lat, lst.lng = d["lat"], d["lng"]195 if d.get("images") and len(d["images"]) > len(lst.images):196 lst.images = d["images"]197