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/hestia.py : connecteur Hestia Groupe immobilier (gestionhestia.com)5# WordPress Elementor, CPT « apartments » non exposé en REST : la page6# /location/ liste des cartes <article class="apartment-single"> (type,7# secteur, prix/mois, photo, lien fiche). Les fiches détail (cache BD)8# ajoutent titre, adresse civique complète, description, caractéristiques9# (dont « Animaux interdits »), services de proximité et galerie.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import hashlib14import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, normalize_unit_type, parse_price, strip_accents19from .base import BaseConnector2021BASE = "https://www.gestionhestia.com"22LIST_URL = f"{BASE}/location/"2324_BG_URL_RE = re.compile(r"url\((['\"]?)(https?://[^)'\"]+)\1\)")252627def _pets_from_amenities(amenities: list[str]) -> str | None:28 """« Animaux interdits » / « Animaux acceptés » (caractéristique structurée)."""29 for a in amenities:30 k = strip_accents(a.lower())31 if "animaux" in k or "animal" in k:32 if "interdit" in k or "refus" in k:33 return "non"34 if "accept" in k or "permis" in k:35 return "oui"36 return None373839class HestiaConnector(BaseConnector):40 source_id = "hestia"41 request_delay = 0.742 max_details = 40 # garde-fou fiches détail (vraies requêtes)4344 def fetch(self) -> list[Listing]:45 html = self.get(LIST_URL).text46 soup = BeautifulSoup(html, "html.parser")4748 listings: dict[str, Listing] = {}49 for a in soup.select("ul.apartments a[href*='/apartments/']"):50 art = a.find("article", class_="apartment-single")51 if art is None:52 continue53 url = a["href"]54 m = re.search(r"/apartments/([^/]+)/?", url)55 if not m:56 continue57 ext_id = m.group(1) # slug WP du CPT « apartments »58 if ext_id in listings:59 continue60 rooms_el = art.select_one(".apartment-single__rooms")61 rooms = rooms_el.get_text(" ", strip=True) if rooms_el else ""62 if re.search(r"commercial|stationnement|rangement|chambre",63 rooms + " " + ext_id, re.I):64 continue65 sector_el = art.select_one(".apartment-single__sector")66 sector = sector_el.get_text(" ", strip=True) if sector_el else ""67 price_el = art.select_one(".apartment-single__price")68 price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) if price_el else ""6970 images: list[str] = []71 img_el = art.select_one(".apartment-single__img[style]")72 if img_el:73 mm = _BG_URL_RE.search(img_el["style"])74 if mm:75 images.append(mm.group(2))7677 listings[ext_id] = Listing(78 source=self.source_id,79 external_id=ext_id,80 url=url,81 title=rooms, # remplacé par le titre de la fiche82 # tout le parc Hestia (Domaine Cartier, Boisé Nature 3R) est à83 # Trois-Rivières ; l'adresse de la fiche confirme/écrase84 city=sector or "Trois-Rivières",85 unit_type=normalize_unit_type(rooms),86 price=parse_price(price_label),87 price_label=price_label,88 images=images,89 )9091 # Fiches détail (cache BD) : titre, adresse, description,92 # caractéristiques + services, galerie complète93 self._fetched = 094 for lst in listings.values():95 card_key = hashlib.sha1(96 f"{lst.title}|{lst.price_label}|{lst.url}".encode("utf-8")97 ).hexdigest()98 try:99 payload = self.detail(lst.external_id, card_key,100 lambda u=lst.url: self._fetch_detail(u))101 except Exception:102 continue103 self._apply_detail(lst, payload)104 return list(listings.values())105106 # -- fiche détail --------------------------------------------------------107 def _fetch_detail(self, url: str) -> dict:108 if self._fetched >= self.max_details:109 raise RuntimeError("budget de fiches détail atteint")110 self._fetched += 1111 soup = BeautifulSoup(self.get(url).text, "html.parser")112 out: dict = {}113114 title_el = soup.select_one(".apartment__title")115 if title_el:116 out["title"] = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True))117 addr_el = soup.select_one("address.apartment__address")118 if addr_el:119 out["address"] = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True))120 desc_el = soup.select_one(".apartment__description")121 if desc_el:122 out["description"] = desc_el.get_text("\n", strip=True)[:1200]123 # onglets « Caractéristiques » / « Services de proximité »124 out["amenities"] = [li.get_text(" ", strip=True)125 for li in soup.select(".tabs .tabs__content li")126 if li.get_text(strip=True)][:40]127 out["images"] = []128 for img in soup.select(".apartment__gallery img[src]"):129 src = img["src"]130 if src.startswith("http") and src not in out["images"]:131 out["images"].append(src)132 out["images"] = out["images"][:30]133 return out134135 def _apply_detail(self, lst: Listing, d: dict) -> None:136 if not d:137 return138 if d.get("title"):139 lst.title = d["title"]140 addr = d.get("address", "")141 if addr:142 # « 6005 rue de la Mattawin, Trois-Rivières, QC G8Y 0M8, Canada »143 parts = [p.strip() for p in addr.split(",") if p.strip()]144 lst.address = parts[0] if parts else addr145 if len(parts) >= 2 and not re.match(r"(?i)qc|q[ué]ebec|canada|[A-Z]\d[A-Z]", parts[1]):146 lst.city = parts[1]147 if d.get("description"):148 lst.description = d["description"]149 if d.get("amenities"):150 lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"]))151 pets = _pets_from_amenities(d["amenities"])152 if pets:153 lst.pets = pets154 if d.get("images"):155 lst.images = list(dict.fromkeys(d["images"] + lst.images))[:30]156