SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.7 KB · 147 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/lacite.py : connecteur La Cité Gatineau (lacitegatineau.com)5#   Tour de 200+ condos locatifs au 700, boulevard du Carrefour, Gatineau6#   (Adam Real Estate). WordPress/Elementor + Toolset Views : la page7#   /plans-des-appartements/ liste 14 TYPES d'unités (nom, chambres,8#   superficie, plan) et chaque fiche /type-unite/<slug>/ publie les9#   salles de bain, les superficies (unité/balcon/totale) et surtout la10#   liste « APPARTEMENT(S) SIMILAIRE(S) » = numéros d'appartements11#   actuellement offerts (« A – 702 ») ou « Aucun appartement disponible ».12#   -> une annonce par APPARTEMENT LISTÉ DISPONIBLE (uid = numéro d'unité).13#   AUCUN prix n'est publié nulle part -> price None, rien d'inventé.14#   ⚠ robots.txt : « Crawl-delay: 10 » -> request_delay = 10 s ; une sync =15#   1 + 14 requêtes ≈ 2,5 min. Pas de cache détail : la disponibilité vit16#   sur les fiches type et doit rester fraîche.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, strip_accents25from .base import BaseConnector2627BASE = "https://lacitegatineau.com"28LIST_URL = f"{BASE}/plans-des-appartements/"29ADDRESS = "700, boulevard du Carrefour, Gatineau"303132def _slugify(s: str) -> str:33    s = strip_accents(s.lower())34    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")353637class LaCiteConnector(BaseConnector):38    source_id = "lacite"39    request_delay = 10.0        # robots.txt : Crawl-delay: 1040    max_types = 204142    # -- fiche type ---------------------------------------------------------------43    def _parse_type(self, url: str) -> dict:44        """Salles de bain, superficies et numéros d'appartements offerts."""45        soup = BeautifulSoup(self.get(url).text, "html.parser")46        text = soup.get_text("\n", strip=True)47        out: dict = {"units": [], "baths": None, "sqft": None,48                     "sqft_balcony": None, "sqft_total": None, "pdf": ""}4950        m = re.search(r"(\d+)\s*\n?\s*salle\(s\) de bain", text)51        if m:52            out["baths"] = int(m.group(1))53        for label, key in (("SUPERFICIE :", "sqft"),54                           ("SUPERFICIE BALCON :", "sqft_balcony"),55                           ("SUPERFICIE TOTALE :", "sqft_total")):56            mm = re.search(rf"{re.escape(label)}\s*\n?\s*([\d\s,]+)\s*pi", text)57            if mm:58                try:59                    v = float(mm.group(1).replace(" ", "").replace(",", ""))60                    if 40 <= v <= 20000:61                        out[key] = v62                except ValueError:63                    pass6465        # « APPARTEMENT(S) SIMILAIRE(S) : A – 702 … » jusqu'aux chambres66        mm = re.search(r"SIMILAIRE\(S\)\s*:?\s*\n(.*?)\n\d+\s*\n?chambre",67                       text, re.S)68        if mm and not re.search(r"(?i)aucun appartement", mm.group(1)):69            for seg in mm.group(1).split("\n"):70                seg = re.sub(r"\s+", " ", seg).strip()71                if re.match(r"^[A-Z]\s*[–-]\s*\d+$", seg):72                    out["units"].append(seg)7374        a = soup.find("a", href=re.compile(r"\.pdf$", re.I))75        if a:76            out["pdf"] = a["href"]77        return out7879    # -- fetch -----------------------------------------------------------------80    def fetch(self) -> list[Listing]:81        soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")82        listings: dict[str, Listing] = {}83        items = soup.select("ul.wpv-loop > li")[: self.max_types]84        for li in items:85            try:86                a = li.select_one("a[href*='/type-unite/']")87                if not a:88                    continue89                type_url = a["href"]90                type_name = re.sub(r"\s+", " ", a.get_text(" ", strip=True))91                # « 1 chambre(s) – 742 pi² » sous le titre92                li_text = re.sub(r"\s+", " ", li.get_text(" ", strip=True))93                beds = None94                mb = re.search(r"(\d+)\s*chambre", li_text)95                if mb:96                    beds = int(mb.group(1))97                img = li.select_one("img[data-src], img[src^='http']")98                plan_img = (img.get("data-src") or img.get("src")99                            if img else "") or ""100101                info = self._parse_type(type_url)102                for unit in info["units"]:103                    num = re.sub(r"\s*[–-]\s*", "-", unit)104                    ext = _slugify(num)105                    if not ext or ext in listings:106                        continue107                    # les chaînes « 502-702-1102-1702 » du nom de type108                    # ressemblent à des téléphones pour l'extracteur commun :109                    # séparateur neutre « · » dans la description110                    safe_name = re.sub(r"(\d)-(?=\d)", r"\1·", type_name)111                    desc_bits = [f"Type {safe_name}"]112                    if info.get("sqft_balcony"):113                        desc_bits.append(114                            f"balcon {info['sqft_balcony']:.0f} pi²")115                    if info.get("sqft_total"):116                        desc_bits.append(117                            f"superficie totale {info['sqft_total']:.0f} pi²")118                    details: dict = {}119                    if info.get("baths"):120                        details["bathrooms"] = info["baths"]121                    if info.get("pdf"):122                        details["floorplan_pdf"] = info["pdf"]123124                    listings[ext] = Listing(125                        source=self.source_id,126                        external_id=ext,127                        url=type_url,128                        title=f"La Cité Gatineau — {unit}",129                        address=ADDRESS,130                        sector="",131                        city="Gatineau",132                        unit_type=(normalize_unit_type(f"{beds} chambres")133                                   if beds else ""),134                        price=None,          # aucun prix publié sur le site135                        price_label="",136                        # le site liste l'unité sous « APPARTEMENT(S)137                        # SIMILAIRE(S) » sans date : rien d'inventé138                        availability="",139                        area_sqft=info.get("sqft"),140                        description=" — ".join(desc_bits)[:600],141                        details=details,142                        images=[plan_img] if plan_img.startswith("http") else [],143                    )144            except Exception:145                continue146        return list(listings.values())147