SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.5 KB · 148 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/brio.py : connecteur Les Immeubles Brio (immeublesbrio.com)5#   Projet mono-immeuble « Le Brio » à Val-Bélair (boul. Pie-XI, Québec).6#   Les unités sont affichées par étage (hotspots Divi) avec statut7#   Disponible / Loué ; les prix « à partir de » par type (3½/4½/5½)8#   sont affichés sur la page d'accueil.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, infer_city, normalize_unit_type, parse_price17from .base import BaseConnector1819BASE = "https://immeublesbrio.com"20HOME_URL = f"{BASE}/"21LIST_URL = f"{BASE}/appartements-a-louer-val-belair/"22SECTOR = "Val-Bélair"23# Adresse civique affichée sur la page Contactez-nous (« Visitez-nous »)24ADDRESS = "1105, rue des Rigoles, Québec, QC G3K 0M7"252627class BrioConnector(BaseConnector):28    source_id = "brio"29    request_delay = 0.63031    def fetch(self) -> list[Listing]:32        # 1) Page d'accueil : prix « à partir de » par type (ex. « 3½ à33        #    partir de 1500$ »), services de l'immeuble (blurbs Divi) et34        #    contact (téléphone/courriel de l'en-tête).35        type_prices: dict[str, tuple[float | None, str]] = {}36        services: list[str] = []37        contact: dict = {}38        try:39            home = self.get(HOME_URL).text40            home_txt = re.sub(r"<[^>]+>", " ", home)41            for m in re.finditer(r"(\d)\s*½\s*à\s*partir\s*de\s*([\d\s  ]+)\$",42                                 home_txt):43                label = f"{m.group(1)}½ à partir de {m.group(2).strip()}$"44                type_prices[m.group(1)] = (parse_price(f"{m.group(2)}$"), label)45            hsoup = BeautifulSoup(home, "html.parser")46            # Services de l'immeuble (Wifi, ascenseur, stationnement…)47            services = [h.get_text(" ", strip=True)48                        for h in hsoup.select(".et_pb_blurb .et_pb_module_header")49                        if h.get_text(strip=True)][:12]50            # Contact : courriel (lien mailto:) + téléphone (en-tête)51            mail = hsoup.select_one('a[href^="mailto:"]')52            if mail:53                contact["email"] = mail["href"].removeprefix("mailto:").strip()54            m = re.search(r"\b(\d{3})[-.\s](\d{3})[-.\s](\d{4})\b", home_txt)55            if m:56                contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"57        except Exception:58            pass5960        # 2) Page des appartements : hotspots par étage61        html = self.get(LIST_URL).text62        soup = BeautifulSoup(html, "html.parser")6364        # Galerie générale de l'immeuble (photos des pièces)65        gallery = []66        for img in soup.select("img"):67            src = img.get("data-src") or img.get("src") or ""68            if re.search(r"/wp-content/uploads/.*(chambre|salle|salon|cuisine)"69                         r"[^\"]*\.(?:jpg|jpeg|png|webp)$", src, re.I):70                gallery.append(src if src.startswith("http") else BASE + src)71        gallery = list(dict.fromkeys(gallery))[:8]7273        listings: list[Listing] = []74        for info in soup.select("div.hotspot-info"):75            try:76                title_el = info.select_one(".hotspot-title")77                if not title_el:78                    continue79                title = title_el.get_text(" ", strip=True)80                m = re.match(r"(Disponible|Lou[ée])\s*-?\s*Appartement\s*(\d+)"81                             r"\s*:\s*(.+)", title, re.I)82                if not m:83                    continue84                status, num, raw_type = m.groups()85                if not status.lower().startswith("dispo"):86                    continue          # on ne garde que les unités disponibles8788                unit_type = normalize_unit_type(raw_type)89                digit = re.search(r"(\d)", unit_type or "")90                price, price_label = (None, "")91                if digit and digit.group(1) in type_prices:92                    price, price_label = type_prices[digit.group(1)]9394                # Contenu : superficie + caractéristiques95                content = info.select_one(".hotspot-content")96                amenities: list[str] = []97                description = ""98                if content:99                    sup = content.find("strong")100                    if sup:101                        description = sup.get_text(strip=True)102                    amenities = [li.get_text(" ", strip=True)103                                 for li in content.select("li")]104105                # Images : plan de l'unité (vignette + plan complet) + galerie106                images: list[str] = []107                thumb = info.select_one(".hotspot-thumb img")108                if thumb:109                    src = thumb.get("data-src") or thumb.get("src") or ""110                    if src.startswith("http"):111                        # version pleine grandeur (retirer le suffixe -300x284)112                        full = re.sub(r"-\d+x\d+(\.(?:jpg|jpeg|png|webp))$",113                                      r"\1", src)114                        images.append(full)115                if content:116                    for a in content.select("a[href]"):117                        href = a.get("href", "")118                        if re.search(r"\.(?:jpg|jpeg|png|webp)$", href, re.I):119                            images.append(href)120                images.extend(gallery)121                images = list(dict.fromkeys(images))122123                details: dict = {}124                if contact:125                    details["contact"] = dict(contact)126127                listings.append(Listing(128                    source=self.source_id,129                    external_id=f"appartement-{num}",130                    url=LIST_URL,131                    title=f"Le Brio — Appartement {num} ({unit_type})",132                    address=ADDRESS,133                    sector=SECTOR,134                    city=infer_city(SECTOR),135                    unit_type=unit_type,136                    price=price,137                    price_label=price_label,138                    availability="Disponible",139                    description=description,140                    amenities=amenities + services,141                    details=details,142                    images=images,143                ))144            except Exception:145                continue146147        return listings148