# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/hestia.py : connecteur Hestia Groupe immobilier (gestionhestia.com) # WordPress Elementor, CPT « apartments » non exposé en REST : la page # /location/ liste des cartes
(type, # secteur, prix/mois, photo, lien fiche). Les fiches détail (cache BD) # ajoutent titre, adresse civique complète, description, caractéristiques # (dont « Animaux interdits »), services de proximité et galerie. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.gestionhestia.com" LIST_URL = f"{BASE}/location/" _BG_URL_RE = re.compile(r"url\((['\"]?)(https?://[^)'\"]+)\1\)") def _pets_from_amenities(amenities: list[str]) -> str | None: """« Animaux interdits » / « Animaux acceptés » (caractéristique structurée).""" for a in amenities: k = strip_accents(a.lower()) if "animaux" in k or "animal" in k: if "interdit" in k or "refus" in k: return "non" if "accept" in k or "permis" in k: return "oui" return None class HestiaConnector(BaseConnector): source_id = "hestia" request_delay = 0.7 max_details = 40 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for a in soup.select("ul.apartments a[href*='/apartments/']"): art = a.find("article", class_="apartment-single") if art is None: continue url = a["href"] m = re.search(r"/apartments/([^/]+)/?", url) if not m: continue ext_id = m.group(1) # slug WP du CPT « apartments » if ext_id in listings: continue rooms_el = art.select_one(".apartment-single__rooms") rooms = rooms_el.get_text(" ", strip=True) if rooms_el else "" if re.search(r"commercial|stationnement|rangement|chambre", rooms + " " + ext_id, re.I): continue sector_el = art.select_one(".apartment-single__sector") sector = sector_el.get_text(" ", strip=True) if sector_el else "" price_el = art.select_one(".apartment-single__price") price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) if price_el else "" images: list[str] = [] img_el = art.select_one(".apartment-single__img[style]") if img_el: mm = _BG_URL_RE.search(img_el["style"]) if mm: images.append(mm.group(2)) listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=rooms, # remplacé par le titre de la fiche # tout le parc Hestia (Domaine Cartier, Boisé Nature 3R) est à # Trois-Rivières ; l'adresse de la fiche confirme/écrase city=sector or "Trois-Rivières", unit_type=normalize_unit_type(rooms), price=parse_price(price_label), price_label=price_label, images=images, ) # Fiches détail (cache BD) : titre, adresse, description, # caractéristiques + services, galerie complète self._fetched = 0 for lst in listings.values(): card_key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.url}".encode("utf-8") ).hexdigest() try: payload = self.detail(lst.external_id, card_key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- fiche détail -------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 soup = BeautifulSoup(self.get(url).text, "html.parser") out: dict = {} title_el = soup.select_one(".apartment__title") if title_el: out["title"] = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True)) addr_el = soup.select_one("address.apartment__address") if addr_el: out["address"] = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)) desc_el = soup.select_one(".apartment__description") if desc_el: out["description"] = desc_el.get_text("\n", strip=True)[:1200] # onglets « Caractéristiques » / « Services de proximité » out["amenities"] = [li.get_text(" ", strip=True) for li in soup.select(".tabs .tabs__content li") if li.get_text(strip=True)][:40] out["images"] = [] for img in soup.select(".apartment__gallery img[src]"): src = img["src"] if src.startswith("http") and src not in out["images"]: out["images"].append(src) out["images"] = out["images"][:30] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("title"): lst.title = d["title"] addr = d.get("address", "") if addr: # « 6005 rue de la Mattawin, Trois-Rivières, QC G8Y 0M8, Canada » parts = [p.strip() for p in addr.split(",") if p.strip()] lst.address = parts[0] if parts else addr if len(parts) >= 2 and not re.match(r"(?i)qc|q[ué]ebec|canada|[A-Z]\d[A-Z]", parts[1]): lst.city = parts[1] if d.get("description"): lst.description = d["description"] if d.get("amenities"): lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) pets = _pets_from_amenities(d["amenities"]) if pets: lst.pets = pets if d.get("images"): lst.images = list(dict.fromkeys(d["images"] + lst.images))[:30]