SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.5 KB · 157 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/aalto.py : connecteur Aalto Suites (aaltosuites.ca — Zibi, Hull)5#   Tours locatives Aalto et Aalto II de Dream, premiers immeubles6#   résidentiels du quartier Zibi (rive québécoise, secteur Hull de7#   Gatineau — 10, rue Jos-Montferrand, J8X 0A6, adresse publiée par le8#   site). Site RentCafe/Yardi (gabarit « ritz ») derrière Cloudflare (4039#   en direct) : rendu via Firecrawl comme realstar.py/osgoode.py.10#   La page /floorplans (française) publie une carte par PLAN : nom11#   (« Aalto II | S2 »), typologie (« studio / 1 SdB »), superficie en pc,12#   prix « à partir de $1,520.00/mois » et image du plan -> une annonce par13#   plan. Aucun décompte d'unités disponibles publié -> availability vide.14#   NB : le Crawl-delay 10 de zibi.ca ne s'applique qu'à zibi.ca (jamais15#   requêté ici) ; aaltosuites.ca n'impose aucun délai (2 rendus par sync).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import os20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, strip_accents25from .base import FIRECRAWL_API, BaseConnector2627BASE = "https://www.aaltosuites.ca"28FLOORPLANS_URL = f"{BASE}/floorplans"2930# adresse du complexe publiée par le site (lien Google Maps du pied de page)31ADDRESS = "10, rue Jos-Montferrand, Gatineau"323334def _slugify(s: str) -> str:35    s = strip_accents(s.lower())36    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")373839class AaltoConnector(BaseConnector):40    source_id = "aalto"41    request_delay = 2.042    max_plans = 604344    # -- Firecrawl avec attente de rendu (Cloudflare + SPA RentCafe) -----------45    def _rendered(self, url: str, wait_ms: int = 9000) -> str:46        key = os.environ.get("FIRECRAWL_API_KEY", "")47        # via self.session : l'enregistreur de fixtures capture la réponse48        resp = self.session.post(49            FIRECRAWL_API,50            json={"url": url, "formats": ["html"], "waitFor": wait_ms},51            headers={"Authorization": f"Bearer {key}"},52            timeout=150,53        )54        resp.raise_for_status()55        return (resp.json().get("data") or {}).get("html", "")5657    @staticmethod58    def _unit_type(label: str) -> str:59        """« studio / 1 SdB » -> Studio ; « 2 Chambres à coucher / 2 SdB »60        ou « 1 chambre / 1 SdB » -> N½ par la couche commune."""61        t = strip_accents(label.lower())62        if "studio" in t:63            return "Studio"64        m = re.match(r"^(\d+)\s*chambre", t)65        if m:66            return normalize_unit_type(f"{m.group(1)} chambres")67        return ""6869    # -- fetch -----------------------------------------------------------------70    def fetch(self) -> list[Listing]:71        # description du complexe : premier paragraphe substantiel de l'accueil72        blurb = ""73        try:74            home = BeautifulSoup(self._rendered(BASE + "/", 8000),75                                 "html.parser")76            for p in home.find_all("p"):77                t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))78                if len(t) > 100:79                    blurb = t[:600]80                    break81        except Exception:82            pass8384        html = self._rendered(FLOORPLANS_URL, 10000)85        soup = BeautifulSoup(html, "html.parser")86        cards = soup.select("div[id^='fp-container-']")87        if not cards:   # rendu incomplet : une seconde chance88            html = self._rendered(FLOORPLANS_URL, 15000)89            soup = BeautifulSoup(html, "html.parser")90            cards = soup.select("div[id^='fp-container-']")9192        listings: dict[str, Listing] = {}93        for card in cards[: self.max_plans]:94            try:95                h2 = card.select_one("h2.property-title")96                name = re.sub(r"\s+", " ",97                              h2.get_text(" ", strip=True)) if h2 else ""98                if not name:99                    continue100                ext = _slugify(name)101                if not ext or ext in listings:102                    continue103104                # « studio / 1 SdB » ● « 483 pc »105                typo = sqft_txt = ""106                for span in card.select(".property-details span"):107                    t = re.sub(r"\s+", " ", span.get_text(" ", strip=True))108                    if re.search(r"(?i)sdb|chambre|studio", t):109                        typo = typo or t110                    elif re.search(r"\d\s*pc\b", t):111                        sqft_txt = sqft_txt or t112                area = None113                m = re.search(r"([\d\s,]{2,7})\s*pc", sqft_txt)114                if m:115                    v = float(m.group(1).replace(" ", "").replace(",", ""))116                    if 80 <= v <= 20000:117                        area = v118119                # « à partir de $1,520.00 /mois »120                price = None121                price_label = ""122                amt = card.select_one(".pricing-amount")123                if amt:124                    raw = amt.get_text(" ", strip=True)125                    pm = re.search(r"\$?([\d,]+)(?:\.\d{2})?", raw)126                    if pm:127                        price = float(pm.group(1).replace(",", ""))128                        price_label = f"à partir de {raw}/mois"129130                img_el = card.select_one("img[src*='resource.rentcafe.com']")131                images = [img_el["src"]] if img_el and img_el.get("src") else []132133                building = name.split("|")[0].strip()134                desc_bits = [x for x in [typo, sqft_txt, building] if x]135                if blurb:136                    desc_bits.append(blurb)137138                listings[ext] = Listing(139                    source=self.source_id,140                    external_id=ext,141                    url=FLOORPLANS_URL,142                    title=name,143                    address=ADDRESS,144                    sector="Hull",       # quartier Zibi, rive québécoise145                    city="Gatineau",146                    unit_type=self._unit_type(typo),147                    price=price,148                    price_label=price_label,149                    availability="",     # aucun décompte d'unités publié150                    area_sqft=area,151                    description=" — ".join(desc_bits)[:900],152                    images=images,153                )154            except Exception:155                continue156        return list(listings.values())157