SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
6.6 KB · 156 lines python
Raw Blame History
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