# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/osgoode.py : connecteur Osgoode Properties (osgoodeproperties.com) # Grand gestionnaire Ottawa-Gatineau ; côté Québec : 4 immeubles à Gatineau # (Le 700 St Joseph, Le Faubourg de l'Île, Village Cité-des-Jeunes, # Le Salaberry). Site RentCafe/Yardi protégé par Cloudflare (403 direct) : # tout passe par Firecrawl, comme realstar.py. # 1) pages recherche /1-bedroom|2-bedroom/qc/gatineau/apartments -> cartes # propriétés (li.property-box : nom, adresse, lits/sdb/pi², fourchette # de prix, téléphone, vignette) — les cartes hors Québec (liens /on/) # sont ignorées ; # 2) fiche propriété -> galerie resource.rentcafe.com ; # 3) /floorplans -> plans structurés (nom, chambres, sdb, pi², prix) ; # ⚠ contrairement à Realstar, AUCUN décompte d'unités disponibles n'est # publié -> availability reste vide (rien d'inventé). # Une annonce par propriété (uid stables). Les fiches passent par # self.detail(...) (cache BD) avec budget Firecrawl par sync. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import os import re from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import FIRECRAWL_API, BaseConnector BASE = "https://www.osgoodeproperties.com" # pages recherche par typologie (chaque carte affiche la fourchette complète # de l'immeuble : l'union 1-2 chambres couvre tout le parc résidentiel) SEARCH_PATHS = ["/1-bedroom/qc/gatineau/apartments", "/2-bedroom/qc/gatineau/apartments"] _BED_TYPES = {"0": "Studio", "1": "3½", "2": "4½", "3": "5½", "4": "6½"} _SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder", re.I) class _BudgetReached(Exception): """Plafond de requêtes Firecrawl atteint pour cette synchronisation.""" class OsgoodeConnector(BaseConnector): source_id = "osgoode" request_delay = 1.0 max_properties = 10 max_images = 20 max_renders = 14 # 2 recherches + 2 par propriété (hors cache) # -- Firecrawl avec attente de rendu (Cloudflare + SPA RentCafe) ----------- 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", "") # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: self._renders = 0 listings: list[Listing] = [] seen: set[str] = set() for path in SEARCH_PATHS: try: html = self._rendered(BASE + path, 10000) self._renders += 1 except Exception: continue soup = BeautifulSoup(html, "html.parser") for card in soup.select("li.property-box"): try: a = card.select_one("a[href*='/apartments/qc/']") if not a: continue # propriété ontarienne : exclue url = (a.get("href") or "").split("?")[0].rstrip("/") m = re.search(r"/apartments/qc/([a-z0-9\-.]+)/([a-z0-9\-]+)", url) if not m or m.group(2) in seen: continue if len(seen) >= self.max_properties: break seen.add(m.group(2)) listings.append( self._property_listing(card, url, m.group(2))) except Exception: continue return listings # -- carte propriété --------------------------------------------------------- def _property_listing(self, card, url: str, slug: str) -> Listing: 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() addr_el = card.select_one(".card-prop-address") address = addr_el.get_text(" ", strip=True) if addr_el else "" meta = card.select_one(".card-bed-bath-rent") beds = baths = sqft = "" if meta: for li in meta.select("li"): it = li.get_text(" ", strip=True) if "Bed" in it: beds = it elif "Bath" in it: baths = it elif "Sq" in it: sqft = re.sub(r"\s*to\s*-\s*", " - ", it) unit_type = "" bm = re.match(r"^(\d)(?:\s|-)?.*Bed", beds or "") if bm and "-" not in beds.split("Bed")[0]: unit_type = _BED_TYPES.get(bm.group(1), "") # fourchette « $1,015.00 to - $1,544.00 » de la carte price = None price_label = "" pm = re.search(r"\$[\d,]+(?:\.\d{2})?(?:(?:\s*(?:-|to))+\s*" r"\$[\d,]+(?:\.\d{2})?)?", card.get_text(" ", strip=True)) if pm: price_label = re.sub(r"\s*to\s*-\s*", " - ", pm.group(0)) first = (price_label.split("-")[0] .replace("$", "").replace(",", "").strip()) try: price = float(first) except ValueError: price = parse_price(price_label) if "-" in price_label: price_label = "À partir de " + price_label phone = "" tel = card.select_one("a[href^='tel:']") if tel: tm = re.search(r"\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})", tel.get("href", "")) if tm: phone = f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}" images: list[str] = [] img = card.select_one("img[src*='rentcafe']") if img and img.get("src"): images.append(img["src"]) # fiche + plans via cache BD (clé = contenu de la carte liste) key = hashlib.sha1( f"{name}|{address}|{beds}|{baths}|{sqft}|{price_label}" .encode("utf-8")).hexdigest() try: payload = self.detail(slug, key, lambda: self._fetch_detail(url)) except Exception: payload = {} for im in (payload.get("images") or []): if im not in images: images.append(im) # plans structurés : prix « à partir de » réel + résumé fidèle plans = payload.get("floorplans") or [] prices = [p["price"] for p in plans if p.get("price") and 100 <= p["price"] <= 20000] if prices: price = min(prices) price_label = (f"À partir de {price:,.0f} $/mois" .replace(",", " ") if len(plans) > 1 else f"{price:,.0f} $/mois".replace(",", " ")) 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) if len(plans) == 1 and plans[0].get("unit_type"): unit_type = plans[0]["unit_type"] details: dict = {} if phone: details["contact"] = {"phone": phone} desc_parts = ([payload["description"]] if payload.get("description") else []) desc_parts += [b for b in [beds, baths, sqft] if b] if plan_bits: desc_parts.append("Plans : " + " ; ".join(plan_bits)) return Listing( source=self.source_id, external_id=slug, url=url, title=name, address=address, sector="", # le site ne publie pas le secteur par immeuble city="Gatineau", unit_type=unit_type, price=price, price_label=price_label, availability="", # aucun décompte d'unités publié description=" — ".join(desc_parts)[:900], details=details, images=images[: self.max_images], ) # -- pages détail (fiche + plans) -------------------------------------------- def _fetch_detail(self, url: str) -> dict: if self._renders + 2 > self.max_renders: raise _BudgetReached() self._renders += 2 payload: dict = {"description": "", "images": [], "floorplans": []} try: psoup = BeautifulSoup(self._rendered(url, 8000), "html.parser") for im in psoup.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 psoup.find_all("p")] paras = [p for p in paras if len(p) > 80] if paras: payload["description"] = " ".join(paras[:2])[:600] except Exception: pass try: fh = self._rendered(url + "/floorplans", 10000) payload["floorplans"] = self._parse_floorplans(fh) except Exception: pass return payload @staticmethod def _parse_floorplans(html: str) -> list[dict]: """Cartes de plans RentCafe : nom, chambres, pi², prix (ou fourchette, borne basse retenue). Pas de décompte de disponibilité chez Osgoode.""" soup = BeautifulSoup(html, "html.parser") plans: list[dict] = [] for cont in soup.select("div[id^='fp-container-']"): try: name_el = cont.select_one("span[data-selenium-id$='Name']") name = name_el.get_text(" ", strip=True) if name_el else "" if not name: continue plan: dict = {"name": name} beds_el = cont.select_one("span[data-selenium-id$='Beds']") if beds_el: bm = re.match(r"^\s*(\d)\s*Bed", beds_el.get_text(" ", strip=True)) if bm: plan["unit_type"] = _BED_TYPES.get(bm.group(1), "") sq_el = cont.select_one("span[data-selenium-id$='SqFt']") if sq_el: sm = re.search(r"([\d,]{2,})", sq_el.get_text(" ", strip=True)) if sm: v = float(sm.group(1).replace(",", "")) if 80 <= v <= 20000: plan["sqft"] = v rent_el = cont.select_one("span[data-selenium-id$='Rent']") if rent_el: rm = re.search(r"\$([\d,]+)(?:\.\d{2})?", rent_el.get_text(" ", strip=True)) if rm: plan["price"] = float(rm.group(1).replace(",", "")) plans.append(plan) except Exception: continue return plans