SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.7 KB · 199 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/le_baronet.py : connecteur Le Baronet (lebaronet.com)5#   Angle rue Notre-Dame Sud / rue Baronet, Sainte-Marie (Beauce,6#   Chaudière-Appalaches) — 87 condos locatifs en 2 phases (phase 1 : 647#   unités sur 4 étages, 2021 ; phase 2 : 23 unités sur 4 étages, 2023).8#   Site WordPress (Yoast) « one-pager » + pages d'étage rendues CÔTÉ SERVEUR :9#     - phase 1 : /etage-1 … /etage-4 ; phase 2 : /phase2-etages/niveau-1 … 4 ;10#     - chaque page contient un plan SVG dont les ancres `a.c-app` portent11#       data-app-no (n° d'unité), data-app-disponible (« dispo » / « louee »)12#       et un <foreignobject> avec « Grandeur : 4 1/2 » et « Tarif : 1300 $13#       /mois » ; seules les unités « dispo » sont retenues (légende du site :14#       Disponible / Réservé / Loué) ;15#     - ⚠ un popup (`div.c-popUp`) répète en fin de page une copie PÉRIMÉE du16#       plan de l'étage 1 : on ne parse que le SVG de `main.l-etages` ;17#     - fiche unité (/appartements/<no> ou /phase2/<no>, via cache BD) :18#       plan/photo, « DISPONIBLE 1ER JUILLET 2026 » et description.19#   Les numéros d'unités se répètent d'une phase à l'autre (101…, 201…) →20#   external_id préfixé par le chemin de la fiche (appartements-106,21#   phase2-305), stable car issu des slugs WordPress.22#   Aucune adresse civique n'est publiée (« à l'angle de la rue Notre-Dame Sud23#   et de la rue Baronet ») → adresse laissée vide, rien d'inventé.24#   robots.txt : `Disallow:` (vide) → tout est permis ; sitemap Yoast public.25# -----------------------------------------------------------------------------26from __future__ import annotations2728import hashlib29import re3031from bs4 import BeautifulSoup3233from ..schema import Listing, parse_price34from .base import BaseConnector3536BASE = "https://lebaronet.com"37CITY = "Sainte-Marie"3839# pages d'étage : phase 1 puis phase 2 (libellés pour la description)40FLOOR_PAGES: list[tuple[str, str]] = (41    [(f"{BASE}/etage-{i}/", f"Phase 1 — Étage {i}") for i in range(1, 5)]42    + [(f"{BASE}/phase2-etages/niveau-{i}/", f"Phase 2 — Niveau {i}")43       for i in range(1, 5)]44)4546_TYPE_RE = re.compile(r"Grandeur\s*:\s*(\d)\s*1/2\s*(\+?)", re.I)47_PRICE_RE = re.compile(r"Tarif\s*:\s*([\d\s]{2,9})\$", re.I)48_AVAIL_RE = re.compile(r"(?i)^disponible\b")49_IMG_RE = re.compile(50    r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+'51    r'\.(?:jpg|jpeg|png|webp)', re.I)52_SKIP_IMG = re.compile(r"logo|icon|favicon|hero|-\d{2,4}x\d{2,4}\.", re.I)535455class LeBaronetConnector(BaseConnector):56    source_id = "le_baronet"57    request_delay = 0.658    max_details = 30     # plafond de fiches unité (vraies requêtes par sync)59    max_images = 106061    # -- pages du projet ---------------------------------------------------------62    def _project_info(self) -> tuple[list[str], list[str]]:63        """Commodités (liste « Des services et commodités » de l'accueil,64        `div.o-whiteCol ul li`) et photos du projet (uploads de l'accueil)."""65        amenities: list[str] = []66        images: list[str] = []67        try:68            html = self.get(f"{BASE}/").text69        except Exception:70            return amenities, images71        soup = BeautifulSoup(html, "html.parser")72        for li in soup.select("div.o-whiteCol ul li"):73            t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()74            if 3 <= len(t) <= 90 and t not in amenities:75                amenities.append(t)76        for u in dict.fromkeys(_IMG_RE.findall(html)):77            if not _SKIP_IMG.search(u) and u not in images:78                images.append(u)79        return amenities[:30], images[: self.max_images]8081    # -- fiche unité ---------------------------------------------------------------82    def _fetch_detail(self, url: str) -> dict:83        """Fiche /appartements/<no> ou /phase2/<no> : disponibilité datée84        (« DISPONIBLE 1ER JUILLET 2026 »), description et plan/photo."""85        if self._fetched >= self.max_details:86            raise RuntimeError("budget de fiches unité atteint")87        self._fetched += 188        soup = BeautifulSoup(self.get(url).text, "html.parser")89        main = soup.select_one("main.l-app") or soup.select_one("main")90        out: dict = {}91        if main is None:92            return out9394        paras: list[str] = []95        for p in main.select("p"):96            t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()97            if not t:98                continue99            # 1er paragraphe en gras « DISPONIBLE … » = disponibilité affichée100            if not paras and not out.get("availability") and _AVAIL_RE.match(t):101                out["availability"] = t.capitalize()102                continue103            paras.append(t)104        if paras:105            out["description"] = "\n".join(paras)[:1200]106107        img = main.select_one("img[src]")108        if img and img["src"].startswith("http"):109            out["image"] = img["src"]110        return out111112    # -- fetch -----------------------------------------------------------------113    def fetch(self) -> list[Listing]:114        amenities, project_images = self._project_info()115        self._fetched = 0116117        listings: dict[str, Listing] = {}118        for page_url, floor_label in FLOOR_PAGES:119            try:120                soup = BeautifulSoup(self.get(page_url).text, "html.parser")121            except Exception:122                continue123            # SVG du contenu principal seulement (le popup répète l'étage 1)124            for a in soup.select("main.l-etages a.c-app"):125                try:126                    self._parse_unit(a, page_url, floor_label,127                                     amenities, project_images, listings)128                except Exception:129                    continue130        return list(listings.values())131132    def _parse_unit(self, a, page_url: str, floor_label: str,133                    amenities: list[str], project_images: list[str],134                    listings: dict[str, Listing]) -> None:135        status = (a.get("data-app-disponible") or "").strip().lower()136        num = (a.get("data-app-no") or "").strip()137        href = (a.get("href") or "").strip()138        if not (num and href):139            return140        # seules les unités « dispo » sont des annonces (Réservé/Loué exclus)141        if status != "dispo":142            return143144        # external_id stable : chemin de la fiche (appartements-106, phase2-305)145        ext = href.strip("/").replace("/", "-")146        if ext in listings:147            return148        url = href if href.startswith("http") else f"{BASE}{href}"149150        cell_txt = re.sub(r"\s+", " ", a.get_text(" ", strip=True))151        m = _TYPE_RE.search(cell_txt)152        unit_type = f"{m.group(1)}½" if m else ""153        plus = bool(m and m.group(2))          # « 4½ + » = pièce bureau en sus154        m = _PRICE_RE.search(cell_txt)155        price_label = (re.sub(r"\s+", " ", m.group(0)).strip() + "/mois"156                       if m else "")157158        # fiche unité (cache BD : re-téléchargée seulement si la cellule change)159        cell_key = hashlib.sha1(160            f"{status}|{cell_txt}".encode("utf-8")).hexdigest()161        try:162            detail = self.detail(ext, cell_key,163                                 lambda u=url: self._fetch_detail(u))164        except Exception:165            detail = {}166167        images = list(project_images)168        if detail.get("image") and detail["image"] not in images:169            images.insert(0, detail["image"])170171        desc_bits = [floor_label]172        if plus:173            desc_bits.append("4½ + (pièce pour bureau supplémentaire)")174        if detail.get("description"):175            desc_bits.append(detail["description"])176177        # n° affiché : « 402-2 » est un artefact de slug WordPress, la fiche178        # titre « Appartement no. : 402 » (l'external_id garde le slug complet)179        display_num = re.sub(r"-\d$", "", num)180181        listings[ext] = Listing(182            source=self.source_id,183            external_id=ext,184            url=url,185            title=f"Le Baronet — Appartement {display_num}",186            address="",                     # aucune adresse civique publiée187            sector="",188            city=CITY,189            unit_type=unit_type,190            price=parse_price(price_label),191            price_label=price_label,192            # texte de la fiche (« Disponible 1er juillet 2026 »), sinon le193            # statut de la légende du plan (« Disponible »)194            availability=detail.get("availability", "Disponible"),195            description="\n".join(desc_bits)[:1400],196            amenities=list(amenities),197            images=images[: self.max_images + 1],198        )199