SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.0 KB · 137 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/beaulieu_groupe.py : connecteur Beaulieu Groupe Immobilier / BGI5#   (beaulieugroupeimmobilier.com — Rimouski, Bas-Saint-Laurent ; distinct des6#   Immeubles Beaulieu et Collin `immeubles_bc` et de Beaulieu Gestion à7#   Rivière-du-Loup). WordPress + Elementor avec un shortcode maison :8#     - archive /batiment/?category=residentiel : cartes `div.card-batiment`9#       par immeuble avec statut « COMPLET » ou « Locaux disponibles : N » ;10#     - fiche /batiment/<slug>/ : description de l'immeuble + cartes d'unités11#       `div.card-local` (« 418 Alcide-C.-Horth, app #206 | 4 1/2 ») dont le12#       `p.card-text` vaut « Indisponible » (image générique Complet.png) ou,13#       pour les unités libres, « Superficie : N pi² » avec photo réelle.14#   Le connecteur ne visite que les fiches des immeubles NON marqués COMPLET15#   (politesse) et n'émet que les unités non « Indisponible ». Aucun prix16#   publié -> price=None, rien d'inventé. Tout le parc affiché est à Rimouski.17#   external_id stable : slug de la fiche + numéro d'appartement18#   (418-rue-alcide-c-horth--app-206).19#   robots.txt Yoast ouvert (Disallow vide), sitemap public.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import re2425from bs4 import BeautifulSoup2627from ..schema import Listing, normalize_unit_type28from .base import BaseConnector2930BASE = "https://beaulieugroupeimmobilier.com"31LIST_URL = f"{BASE}/batiment/?category=residentiel"3233# « 418 Alcide-C.-Horth, app #206 | 4 1/2 » -> adresse, n° app, type34_UNIT_RE = re.compile(r"^(.*?),?\s*app\s*#\s*(\w+)\s*(?:\|\s*(.+))?$", re.I)35_BG_IMG_RE = re.compile(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)")36_AREA_RE = re.compile(r"superficie\s*:?\s*([\d\s,]+)\s*pi", re.I)373839class BeaulieuGroupeConnector(BaseConnector):40    source_id = "beaulieu_groupe"41    request_delay = 0.642    max_detail = 12                     # plafond de fiches visitées par sync4344    def fetch(self) -> list[Listing]:45        soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")4647        listings: dict[str, Listing] = {}48        visited = 049        for card in soup.select("div.card-batiment"):50            link = card.select_one('a[href*="/batiment/"]')51            if link is None:52                continue53            url = link["href"].strip()54            status = ""55            for p in card.select("p"):56                t = p.get_text(" ", strip=True)57                if t and "VOIR LA FICHE" not in t.upper():58                    status = t59                    break60            # les immeubles COMPLET n'ont aucune unité libre : fiche non visitée61            if "COMPLET" in status.upper() or visited >= self.max_detail:62                continue63            visited += 164            try:65                self._parse_building(url, listings)66            except Exception:67                continue68        return list(listings.values())6970    # -- fiche d'immeuble (/batiment/<slug>/) -------------------------------------71    def _parse_building(self, url: str, listings: dict[str, Listing]) -> None:72        m = re.search(r"/batiment/([^/?#]+)", url)73        if not m:74            return75        slug = m.group(1)76        soup = BeautifulSoup(self.get(url).text, "html.parser")7778        # description de l'immeuble (paragraphes Elementor avant la liste)79        paras = [p.get_text(" ", strip=True)80                 for p in soup.select(".elementor-widget-text-editor p")]81        description = "\n".join(t for t in paras if len(t) >= 40)[:1200]8283        for card in soup.select("div.card-local"):84            h5 = card.select_one("h5")85            info = card.select_one("p.card-text")86            if h5 is None:87                continue88            head = re.sub(r"\s+", " ", h5.get_text(" ", strip=True))89            info_txt = re.sub(90                r"\s+", " ", info.get_text(" ", strip=True)) if info else ""91            if re.search(r"(?i)indisponible", info_txt):92                continue                       # unité louée9394            m_unit = _UNIT_RE.match(head)95            if not m_unit:96                continue                       # carte sans n° d'app (bandeau)97            addr, app_no, type_txt = (m_unit.group(1).strip(),98                                      m_unit.group(2),99                                      (m_unit.group(3) or "").strip())100101            ext_id = f"{slug}--app-{app_no.lower()}"102            if ext_id in listings:103                continue104105            area = None106            m_area = _AREA_RE.search(info_txt)107            if m_area:108                try:109                    area = float(m_area.group(1).replace(" ", "")110                                 .replace(" ", "").replace(",", "."))111                except ValueError:112                    area = None113114            images: list[str] = []115            img = card.select_one(".card-img-top")116            if img is not None:117                m_bg = _BG_IMG_RE.search(img.get("style", ""))118                if m_bg and m_bg.group(1).startswith("http") \119                        and "Complet" not in m_bg.group(1):120                    images.append(m_bg.group(1))121122            listings[ext_id] = Listing(123                source=self.source_id,124                external_id=ext_id,125                url=url,126                title=head,127                address=f"{addr}, Rimouski" if addr else "",128                city="Rimouski",             # tout le parc affiché est à Rimouski129                unit_type=normalize_unit_type(type_txt),130                price=None,                   # aucun loyer publié sur le site131                price_label="",132                availability="",              # libre = non « Indisponible » ; pas de date publiée133                area_sqft=area,134                description=description,135                images=images,136            )137