# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/brigil.py : connecteur Brigil (brigil.com — Gatineau/Outaouais) # Promoteur-gestionnaire dominant de Gatineau. Site = portail Yardi RentCafe # protégé par Cloudflare : curl/requests reçoivent 403 (« Just a moment »), # TOUT passe par Firecrawl avec attente de rendu — même patron que # realstar.py (RentCafe + Cloudflare) : # 1) /searchlisting?province=Quebec -> cartes propriétés (nom, gamme # Aura/Apogee/Comfort, lits, fourchette de prix, vignette) ; le portail # ne filtre PAS par ville côté serveur et les cartes ne portent pas la # ville : elle est lue sur la fiche de chaque propriété ; # 2) fiche propriété -> adresse structurée (itemprop=address), photos, # description, plus les plans quand ils y sont intégrés ; # 3) /floorplans si la fiche n'a pas de plans -> plans structurés # (nom, chambres, sdb, pi², « Starting at »). # Les propriétés d'Ottawa/Ontario sont écartées : d'abord par l'URL # (/apartments/on/…), ensuite par la ville lue sur la fiche. # Une annonce par propriété. Les rendus passent par self.detail(...) (cache # BD) : Firecrawl n'est rappelé que si la carte a changé, et un plafond # (max_renders) borne le coût de chaque synchronisation. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import os import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import FIRECRAWL_API, BaseConnector SEARCH_URL = "https://www.brigil.com/searchlisting?province=Quebec" # Villes desservies côté québécois (Gatineau + couronne outaouaise). Les # anciennes villes fusionnées deviennent des secteurs de Gatineau. _GATINEAU_SECTORS = { "hull": "Hull", "aylmer": "Aylmer", "buckingham": "Buckingham", "masson-angers": "Masson-Angers", "templeton": "Templeton", "gatineau": "", } _QC_CITIES = { "gatineau": "Gatineau", "hull": "Gatineau", "aylmer": "Gatineau", "buckingham": "Gatineau", "chelsea": "Chelsea", "cantley": "Cantley", "val-des-monts": "Val-des-Monts", "thurso": "Thurso", "la peche": "La Pêche", } _SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder", re.I) _PRICE_RE = re.compile(r"\$[\d,]+(?:\.\d{2})?") _BED_LOW_RE = re.compile(r"(?i)^\s*(studio|\d+)") class _BudgetReached(Exception): """Plafond de rendus Firecrawl atteint pour cette synchronisation.""" class BrigilConnector(BaseConnector): source_id = "brigil" request_delay = 1.0 max_images = 20 max_renders = 26 # plafond d'appels Firecrawl par sync (hors cache) # -- Firecrawl avec attente de rendu (RentCafe + Cloudflare) --------------- def _rendered(self, url: str, wait_ms: int = 9000) -> str: key = os.environ.get("FIRECRAWL_API_KEY", "") # via self.session : l'enregistreur de fixtures capture la réponse resp = self.session.post( FIRECRAWL_API, json={"url": url, "formats": ["html"], "waitFor": wait_ms}, headers={"Authorization": f"Bearer {key}"}, timeout=150, ) resp.raise_for_status() return (resp.json().get("data") or {}).get("html", "") # -- helpers --------------------------------------------------------------- @staticmethod def _slug(url: str) -> str: """URL de propriété -> identifiant stable (1er segment du chemin).""" m = re.search(r"brigil\.com/(?:apartments/[a-z]{2}/[^/]+/)?([^/?#]+)", url) if not m: return "" slug = m.group(1) return "" if slug in ("searchlisting", "default.aspx") else slug @staticmethod def _city_sector(address: str) -> tuple[str, str]: """Ville et secteur lus DANS l'adresse publiée par la fiche. Retourne ('', '') si l'adresse ne nomme aucune ville québécoise desservie (propriété ontarienne ou adresse illisible). """ key = strip_accents(address or "").lower() if re.search(r"\bon\b|ontario", key) and not re.search(r"\bqc\b|quebec", key): return "", "" city, sector = "", "" for token, canon in _QC_CITIES.items(): if re.search(rf"\b{re.escape(token)}\b", key): city = canon break if city == "Gatineau": for token, canon in _GATINEAU_SECTORS.items(): if canon and re.search(rf"\b{re.escape(token)}\b", key): sector = canon break return city, sector @staticmethod def _unit_type(beds: str) -> str: """« Studio-2 Beds » -> Studio ; « 2-3 Beds » -> 4½ (borne basse, en cohérence avec le prix « à partir de » de la carte).""" m = _BED_LOW_RE.match(beds or "") if not m: return "" low = m.group(1).lower() if low == "studio": return "Studio" n = int(low) return "Studio" if n == 0 else normalize_unit_type(f"{n} chambres") # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: self._renders = 0 html = self._rendered(SEARCH_URL, 10000) soup = BeautifulSoup(html, "html.parser") cards = soup.select("li.property-box") if not cards: # rendu incomplet : seconde chance html = self._rendered(SEARCH_URL, 15000) soup = BeautifulSoup(html, "html.parser") cards = soup.select("li.property-box") # candidats : tout sauf les fiches explicitement ontariennes candidates: list[tuple[str, str, object]] = [] seen: set[str] = set() for card in cards: a = card.select_one("a[href*='brigil.com']") if not a: continue url = (a.get("href") or "").split("?")[0].replace("http://", "https://") if "/apartments/on/" in url: continue # Ottawa / Ontario slug = self._slug(url) if not slug or slug in seen: continue seen.add(slug) candidates.append((slug, url, card)) # les URLs qui annoncent déjà /qc/ passent en premier (rendu utile sûr) candidates.sort(key=lambda c: 0 if "/apartments/qc/" in c[1] else 1) listings: list[Listing] = [] for slug, url, card in candidates: try: lst = self._property_listing(slug, url, card) except _BudgetReached: break # budget épuisé : au prochain sync except Exception: continue if lst is not None: listings.append(lst) return listings def _property_listing(self, slug: str, url: str, card) -> Listing | None: name = "" fav = card.select_one("[data-property]") if fav: name = (fav.get("data-property") or "").strip() if not name: h = card.select_one(".property-name a") if h: name = h.get_text(" ", strip=True) name = re.sub(r"\s*opens in a new tab\s*", "", name).strip() name = name or slug.replace("-", " ").title() types_el = card.select_one(".unit-types") unit_types = types_el.get_text(" ", strip=True) if types_el else "" beds = "" for li in card.select("ul.list-inline li"): txt = li.get_text(" ", strip=True) if re.search(r"(?i)bed|studio", txt): beds = txt break card_text = card.get_text(" ", strip=True) price = None price_label = "" pm = re.search(r"\$[\d,]+(?:\.\d{2})?(?:(?:\s*(?:-|to))+\s*" r"\$[\d,]+(?:\.\d{2})?)?", card_text) if pm: price_label = re.sub(r"\s*to\s*-\s*", " - ", pm.group(0)) price = parse_price(price_label.split("-")[0]) if "-" in price_label: price_label = "À partir de " + price_label images: list[str] = [] img = card.select_one("img[src*='rentcafe']") if img and img.get("src"): images.append(img["src"]) # fiche propriété (+ plans) via cache BD : Firecrawl n'est rappelé que # si la carte a changé (nom, lits, prix) key = hashlib.sha1( f"{name}|{unit_types}|{beds}|{price_label}".encode("utf-8") ).hexdigest()[:20] payload = self.detail(slug, key, lambda: self._fetch_detail(url)) city = payload.get("city", "") if not city: return None # ville non québécoise ou fiche illisible sector = payload.get("sector", "") address = payload.get("address", "") for im in (payload.get("images") or []): if im not in images: images.append(im) area_sqft = None unit_type = self._unit_type(beds) plans = payload.get("floorplans") or [] priced = [p for p in plans if p.get("price")] if priced: low = min(p["price"] for p in priced) if 100 <= low <= 20000: price = low price_label = (f"À partir de {low:,.0f} $/mois" .replace(",", " ") if len(priced) > 1 else f"{low:,.0f} $/mois".replace(",", " ")) if len(plans) == 1 and plans[0].get("sqft"): area_sqft = plans[0]["sqft"] if plans[0].get("unit_type"): unit_type = plans[0]["unit_type"] plan_bits = [] for p in plans[:8]: seg = p["name"] if p.get("sqft"): seg += f" ({p['sqft']:.0f} pi²)" if p.get("price"): seg += f" : {p['price']:,.0f} $/mois".replace(",", " ") plan_bits.append(seg) desc_parts = [x for x in [payload.get("description", ""), unit_types, beds] if x] if plan_bits: desc_parts.append("Plans : " + " ; ".join(plan_bits)) details: dict = {} if payload.get("phone"): details["contact"] = {"phone": payload["phone"]} return Listing( source=self.source_id, external_id=slug, url=url, title=name, address=address, sector=sector, city=city, unit_type=unit_type, price=price, price_label=price_label, availability="", # RentCafe n'affiche pas de date par plan area_sqft=area_sqft, description=" — ".join(desc_parts)[:900], amenities=list(payload.get("amenities") or []), details=details, images=images[: self.max_images], ) # -- fiche propriété + plans (hors cache seulement) ------------------------ def _fetch_detail(self, url: str) -> dict: """1 à 2 rendus Firecrawl : fiche (adresse, photos, description, commodités, plans intégrés) puis /floorplans si nécessaire. La fiche est rendue AVANT tout : sans elle, la ville de la propriété est inconnue et l'annonce est écartée. """ if self._renders + 1 > self.max_renders: raise _BudgetReached() self._renders += 1 payload: dict = {"city": "", "sector": "", "address": "", "description": "", "amenities": [], "images": [], "floorplans": [], "phone": ""} html = self._rendered(url, 8000) soup = BeautifulSoup(html, "html.parser") addr_el = soup.select_one("[itemprop=address]") address = (addr_el.get_text(" ", strip=True) if addr_el else "") address = re.sub(r"\s+,", ",", re.sub(r"\s{2,}", " ", address)).strip() payload["address"] = address payload["city"], payload["sector"] = self._city_sector(address) if not payload["city"]: return payload # hors Québec : on n'ira pas plus loin for im in soup.select("img[src*='resource.rentcafe.com']"): src = im.get("src", "") if src and not _SKIP_IMG.search(src) \ and src not in payload["images"]: payload["images"].append(src) paras = [p.get_text(" ", strip=True) for p in soup.find_all("p")] paras = [p for p in paras if len(p) > 80] if paras: payload["description"] = " ".join(paras[:2])[:600] tel = soup.select_one("a[href^='tel:']") if tel: tm = re.search(r"(\d{3})\D*(\d{3})\D*(\d{4})", tel.get("href", "")) if tm: payload["phone"] = \ f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}" payload["floorplans"] = self._parse_floorplans(soup) if not payload["floorplans"]: fp_url = re.sub(r"/default(?:\.aspx)?$", "", url).rstrip("/") \ + "/floorplans" if self._renders + 1 <= self.max_renders: self._renders += 1 try: payload["floorplans"] = self._parse_floorplans( BeautifulSoup(self._rendered(fp_url, 10000), "html.parser")) except Exception: pass return payload @staticmethod def _parse_floorplans(soup) -> list[dict]: """Cartes de plans RentCafe : nom (« 2 Bed 2 Bath »), chambres, pi² (borne basse de la fourchette), prix « Starting at » — « Call for details » = prix non publié (None, jamais deviné).""" plans: list[dict] = [] for cont in soup.select("div[id^='fp-container-'], div.fp-container"): try: name_el = cont.select_one(".card-title, h2, h3") name = name_el.get_text(" ", strip=True) if name_el else "" if not name: continue text = cont.get_text(" ", strip=True) sqft = None sm = re.search(r"([\d,]{3,6})\s*(?:-\s*)?(?:[\d,]{3,6}\s*)?" r"Sq\.?\s*Ft", text, re.I) if sm: v = float(sm.group(1).replace(",", "")) if 80 <= v <= 20000: sqft = v price = None if re.search(r"(?i)starting at", text): pm = _PRICE_RE.search(text) if pm: v = float(pm.group(0).replace("$", "").replace(",", "")) if 100 <= v <= 20000: price = v unit_type = "" bm = re.search(r"(\d)\s*Bed", name, re.I) if bm: unit_type = normalize_unit_type(f"{bm.group(1)} chambres") elif re.search(r"(?i)studio", name): unit_type = "Studio" if any(p["name"] == name for p in plans): continue plans.append({"name": name, "sqft": sqft, "price": price, "unit_type": unit_type}) except Exception: continue return plans