SPB Git

spb/lou-ka Public

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

HTML 99.7%
15.4 KB · 372 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/brigil.py : connecteur Brigil (brigil.com — Gatineau/Outaouais)5#   Promoteur-gestionnaire dominant de Gatineau. Site = portail Yardi RentCafe6#   protégé par Cloudflare : curl/requests reçoivent 403 (« Just a moment »),7#   TOUT passe par Firecrawl avec attente de rendu — même patron que8#   realstar.py (RentCafe + Cloudflare) :9#     1) /searchlisting?province=Quebec -> cartes propriétés (nom, gamme10#        Aura/Apogee/Comfort, lits, fourchette de prix, vignette) ; le portail11#        ne filtre PAS par ville côté serveur et les cartes ne portent pas la12#        ville : elle est lue sur la fiche de chaque propriété ;13#     2) fiche propriété -> adresse structurée (itemprop=address), photos,14#        description, plus les plans quand ils y sont intégrés ;15#     3) /floorplans si la fiche n'a pas de plans -> plans structurés16#        (nom, chambres, sdb, pi², « Starting at »).17#   Les propriétés d'Ottawa/Ontario sont écartées : d'abord par l'URL18#   (/apartments/on/…), ensuite par la ville lue sur la fiche.19#   Une annonce par propriété. Les rendus passent par self.detail(...) (cache20#   BD) : Firecrawl n'est rappelé que si la carte a changé, et un plafond21#   (max_renders) borne le coût de chaque synchronisation.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import hashlib26import os27import re2829from bs4 import BeautifulSoup3031from ..schema import Listing, normalize_unit_type, parse_price, strip_accents32from .base import FIRECRAWL_API, BaseConnector3334SEARCH_URL = "https://www.brigil.com/searchlisting?province=Quebec"3536# Villes desservies côté québécois (Gatineau + couronne outaouaise). Les37# anciennes villes fusionnées deviennent des secteurs de Gatineau.38_GATINEAU_SECTORS = {39    "hull": "Hull", "aylmer": "Aylmer", "buckingham": "Buckingham",40    "masson-angers": "Masson-Angers", "templeton": "Templeton",41    "gatineau": "",42}43_QC_CITIES = {44    "gatineau": "Gatineau", "hull": "Gatineau", "aylmer": "Gatineau",45    "buckingham": "Gatineau", "chelsea": "Chelsea", "cantley": "Cantley",46    "val-des-monts": "Val-des-Monts", "thurso": "Thurso",47    "la peche": "La Pêche",48}4950_SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder", re.I)51_PRICE_RE = re.compile(r"\$[\d,]+(?:\.\d{2})?")52_BED_LOW_RE = re.compile(r"(?i)^\s*(studio|\d+)")535455class _BudgetReached(Exception):56    """Plafond de rendus Firecrawl atteint pour cette synchronisation."""575859class BrigilConnector(BaseConnector):60    source_id = "brigil"61    request_delay = 1.062    max_images = 2063    max_renders = 26         # plafond d'appels Firecrawl par sync (hors cache)6465    # -- Firecrawl avec attente de rendu (RentCafe + Cloudflare) ---------------66    def _rendered(self, url: str, wait_ms: int = 9000) -> str:67        key = os.environ.get("FIRECRAWL_API_KEY", "")68        # via self.session : l'enregistreur de fixtures capture la réponse69        resp = self.session.post(70            FIRECRAWL_API,71            json={"url": url, "formats": ["html"], "waitFor": wait_ms},72            headers={"Authorization": f"Bearer {key}"},73            timeout=150,74        )75        resp.raise_for_status()76        return (resp.json().get("data") or {}).get("html", "")7778    # -- helpers ---------------------------------------------------------------79    @staticmethod80    def _slug(url: str) -> str:81        """URL de propriété -> identifiant stable (1er segment du chemin)."""82        m = re.search(r"brigil\.com/(?:apartments/[a-z]{2}/[^/]+/)?([^/?#]+)",83                      url)84        if not m:85            return ""86        slug = m.group(1)87        return "" if slug in ("searchlisting", "default.aspx") else slug8889    @staticmethod90    def _city_sector(address: str) -> tuple[str, str]:91        """Ville et secteur lus DANS l'adresse publiée par la fiche.9293        Retourne ('', '') si l'adresse ne nomme aucune ville québécoise94        desservie (propriété ontarienne ou adresse illisible).95        """96        key = strip_accents(address or "").lower()97        if re.search(r"\bon\b|ontario", key) and not re.search(r"\bqc\b|quebec",98                                                               key):99            return "", ""100        city, sector = "", ""101        for token, canon in _QC_CITIES.items():102            if re.search(rf"\b{re.escape(token)}\b", key):103                city = canon104                break105        if city == "Gatineau":106            for token, canon in _GATINEAU_SECTORS.items():107                if canon and re.search(rf"\b{re.escape(token)}\b", key):108                    sector = canon109                    break110        return city, sector111112    @staticmethod113    def _unit_type(beds: str) -> str:114        """« Studio-2 Beds » -> Studio ; « 2-3 Beds » -> 4½ (borne basse, en115        cohérence avec le prix « à partir de » de la carte)."""116        m = _BED_LOW_RE.match(beds or "")117        if not m:118            return ""119        low = m.group(1).lower()120        if low == "studio":121            return "Studio"122        n = int(low)123        return "Studio" if n == 0 else normalize_unit_type(f"{n} chambres")124125    # -- fetch -----------------------------------------------------------------126    def fetch(self) -> list[Listing]:127        self._renders = 0128        html = self._rendered(SEARCH_URL, 10000)129        soup = BeautifulSoup(html, "html.parser")130        cards = soup.select("li.property-box")131        if not cards:                      # rendu incomplet : seconde chance132            html = self._rendered(SEARCH_URL, 15000)133            soup = BeautifulSoup(html, "html.parser")134            cards = soup.select("li.property-box")135136        # candidats : tout sauf les fiches explicitement ontariennes137        candidates: list[tuple[str, str, object]] = []138        seen: set[str] = set()139        for card in cards:140            a = card.select_one("a[href*='brigil.com']")141            if not a:142                continue143            url = (a.get("href") or "").split("?")[0].replace("http://",144                                                              "https://")145            if "/apartments/on/" in url:146                continue                   # Ottawa / Ontario147            slug = self._slug(url)148            if not slug or slug in seen:149                continue150            seen.add(slug)151            candidates.append((slug, url, card))152        # les URLs qui annoncent déjà /qc/ passent en premier (rendu utile sûr)153        candidates.sort(key=lambda c: 0 if "/apartments/qc/" in c[1] else 1)154155        listings: list[Listing] = []156        for slug, url, card in candidates:157            try:158                lst = self._property_listing(slug, url, card)159            except _BudgetReached:160                break                      # budget épuisé : au prochain sync161            except Exception:162                continue163            if lst is not None:164                listings.append(lst)165        return listings166167    def _property_listing(self, slug: str, url: str, card) -> Listing | None:168        name = ""169        fav = card.select_one("[data-property]")170        if fav:171            name = (fav.get("data-property") or "").strip()172        if not name:173            h = card.select_one(".property-name a")174            if h:175                name = h.get_text(" ", strip=True)176        name = re.sub(r"\s*opens in a new tab\s*", "", name).strip()177        name = name or slug.replace("-", " ").title()178179        types_el = card.select_one(".unit-types")180        unit_types = types_el.get_text(" ", strip=True) if types_el else ""181        beds = ""182        for li in card.select("ul.list-inline li"):183            txt = li.get_text(" ", strip=True)184            if re.search(r"(?i)bed|studio", txt):185                beds = txt186                break187188        card_text = card.get_text(" ", strip=True)189        price = None190        price_label = ""191        pm = re.search(r"\$[\d,]+(?:\.\d{2})?(?:(?:\s*(?:-|to))+\s*"192                       r"\$[\d,]+(?:\.\d{2})?)?", card_text)193        if pm:194            price_label = re.sub(r"\s*to\s*-\s*", " - ", pm.group(0))195            price = parse_price(price_label.split("-")[0])196            if "-" in price_label:197                price_label = "À partir de " + price_label198199        images: list[str] = []200        img = card.select_one("img[src*='rentcafe']")201        if img and img.get("src"):202            images.append(img["src"])203204        # fiche propriété (+ plans) via cache BD : Firecrawl n'est rappelé que205        # si la carte a changé (nom, lits, prix)206        key = hashlib.sha1(207            f"{name}|{unit_types}|{beds}|{price_label}".encode("utf-8")208        ).hexdigest()[:20]209        payload = self.detail(slug, key, lambda: self._fetch_detail(url))210211        city = payload.get("city", "")212        if not city:213            return None            # ville non québécoise ou fiche illisible214        sector = payload.get("sector", "")215        address = payload.get("address", "")216217        for im in (payload.get("images") or []):218            if im not in images:219                images.append(im)220221        area_sqft = None222        unit_type = self._unit_type(beds)223        plans = payload.get("floorplans") or []224        priced = [p for p in plans if p.get("price")]225        if priced:226            low = min(p["price"] for p in priced)227            if 100 <= low <= 20000:228                price = low229                price_label = (f"À partir de {low:,.0f} $/mois"230                               .replace(",", " ") if len(priced) > 1231                               else f"{low:,.0f} $/mois".replace(",", " "))232        if len(plans) == 1 and plans[0].get("sqft"):233            area_sqft = plans[0]["sqft"]234            if plans[0].get("unit_type"):235                unit_type = plans[0]["unit_type"]236237        plan_bits = []238        for p in plans[:8]:239            seg = p["name"]240            if p.get("sqft"):241                seg += f" ({p['sqft']:.0f} pi²)"242            if p.get("price"):243                seg += f" : {p['price']:,.0f} $/mois".replace(",", " ")244            plan_bits.append(seg)245246        desc_parts = [x for x in [payload.get("description", ""),247                                  unit_types, beds] if x]248        if plan_bits:249            desc_parts.append("Plans : " + " ; ".join(plan_bits))250251        details: dict = {}252        if payload.get("phone"):253            details["contact"] = {"phone": payload["phone"]}254255        return Listing(256            source=self.source_id,257            external_id=slug,258            url=url,259            title=name,260            address=address,261            sector=sector,262            city=city,263            unit_type=unit_type,264            price=price,265            price_label=price_label,266            availability="",       # RentCafe n'affiche pas de date par plan267            area_sqft=area_sqft,268            description=" — ".join(desc_parts)[:900],269            amenities=list(payload.get("amenities") or []),270            details=details,271            images=images[: self.max_images],272        )273274    # -- fiche propriété + plans (hors cache seulement) ------------------------275    def _fetch_detail(self, url: str) -> dict:276        """1 à 2 rendus Firecrawl : fiche (adresse, photos, description,277        commodités, plans intégrés) puis /floorplans si nécessaire.278279        La fiche est rendue AVANT tout : sans elle, la ville de la propriété280        est inconnue et l'annonce est écartée.281        """282        if self._renders + 1 > self.max_renders:283            raise _BudgetReached()284        self._renders += 1285286        payload: dict = {"city": "", "sector": "", "address": "",287                         "description": "", "amenities": [], "images": [],288                         "floorplans": [], "phone": ""}289        html = self._rendered(url, 8000)290        soup = BeautifulSoup(html, "html.parser")291292        addr_el = soup.select_one("[itemprop=address]")293        address = (addr_el.get_text(" ", strip=True) if addr_el else "")294        address = re.sub(r"\s+,", ",", re.sub(r"\s{2,}", " ", address)).strip()295        payload["address"] = address296        payload["city"], payload["sector"] = self._city_sector(address)297        if not payload["city"]:298            return payload            # hors Québec : on n'ira pas plus loin299300        for im in soup.select("img[src*='resource.rentcafe.com']"):301            src = im.get("src", "")302            if src and not _SKIP_IMG.search(src) \303                    and src not in payload["images"]:304                payload["images"].append(src)305306        paras = [p.get_text(" ", strip=True) for p in soup.find_all("p")]307        paras = [p for p in paras if len(p) > 80]308        if paras:309            payload["description"] = " ".join(paras[:2])[:600]310311        tel = soup.select_one("a[href^='tel:']")312        if tel:313            tm = re.search(r"(\d{3})\D*(\d{3})\D*(\d{4})", tel.get("href", ""))314            if tm:315                payload["phone"] = \316                    f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}"317318        payload["floorplans"] = self._parse_floorplans(soup)319        if not payload["floorplans"]:320            fp_url = re.sub(r"/default(?:\.aspx)?$", "", url).rstrip("/") \321                + "/floorplans"322            if self._renders + 1 <= self.max_renders:323                self._renders += 1324                try:325                    payload["floorplans"] = self._parse_floorplans(326                        BeautifulSoup(self._rendered(fp_url, 10000),327                                      "html.parser"))328                except Exception:329                    pass330        return payload331332    @staticmethod333    def _parse_floorplans(soup) -> list[dict]:334        """Cartes de plans RentCafe : nom (« 2 Bed 2 Bath »), chambres, pi²335        (borne basse de la fourchette), prix « Starting at » — « Call for336        details » = prix non publié (None, jamais deviné)."""337        plans: list[dict] = []338        for cont in soup.select("div[id^='fp-container-'], div.fp-container"):339            try:340                name_el = cont.select_one(".card-title, h2, h3")341                name = name_el.get_text(" ", strip=True) if name_el else ""342                if not name:343                    continue344                text = cont.get_text(" ", strip=True)345                sqft = None346                sm = re.search(r"([\d,]{3,6})\s*(?:-\s*)?(?:[\d,]{3,6}\s*)?"347                               r"Sq\.?\s*Ft", text, re.I)348                if sm:349                    v = float(sm.group(1).replace(",", ""))350                    if 80 <= v <= 20000:351                        sqft = v352                price = None353                if re.search(r"(?i)starting at", text):354                    pm = _PRICE_RE.search(text)355                    if pm:356                        v = float(pm.group(0).replace("$", "").replace(",", ""))357                        if 100 <= v <= 20000:358                            price = v359                unit_type = ""360                bm = re.search(r"(\d)\s*Bed", name, re.I)361                if bm:362                    unit_type = normalize_unit_type(f"{bm.group(1)} chambres")363                elif re.search(r"(?i)studio", name):364                    unit_type = "Studio"365                if any(p["name"] == name for p in plans):366                    continue367                plans.append({"name": name, "sqft": sqft, "price": price,368                              "unit_type": unit_type})369            except Exception:370                continue371        return plans372