# ----------------------------------------------------------------------------- # Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/rentcafe.py : connecteur GÉNÉRIQUE Yardi RentCafe/SecureCafe # (multi-clients Ontario — levier n° 2 de l'expansion, voir # gestion-immobiliere-ontario.md §10) # # Une sous-classe est générée dynamiquement par client du registre # data/rentcafe_clients.json (statut « ok ») : source_id = rc_ # (vague 1 2026-08-26 : rc_effort, rc_osgoode, rc_gwlra, rc_oshanter ; # vague 2 2026-08-27 : rc_claridge, rc_richcraft, rc_arnon, rc_concert, # rc_caraco). Le registre auto-découvrant connectors/__init__.py les # ramasse dans vars(module). # # Pattern « searchlisting » (validé sur les 9 clients actifs) : # 1) /searchlisting.aspx via Scrapfly (Cloudflare 403 en direct, # contenu rendu côté serveur -> render_js inutile) : # - cartes li.property-box-hidden : nom, lien fiche, adresse complète # (« …, Kingston, ON K7P 1M8 »), lits/sdb/pi², fourchette de prix, # téléphone, vignette resource.rentcafe.com ; # - champ caché available_prop_map (JSON doublement encodé) : # propertyid -> lat/lng + fourchette de prix des épingles de carte. # Seules les cartes dont l'adresse est en Ontario sont conservées # (Osgoode/GWLRA listent aussi AB/BC ; le QC reste aux connecteurs QC). # 2) fiche propriété (+ /floorplans au besoin) via self.detail() (cache BD, # budget Scrapfly par synchronisation) : galerie, description, plans # structurés fp-container (2 gabarits : spans data-selenium-id ou cartes # h2.card-title + nu-bed/nu-bathroom/nu-area + data-floorplan-*). # Une annonce par propriété (uid stable = propertyid RentCafe) ; comme chez # Osgoode, AUCUN décompte d'unités disponibles n'est publié -> availability # reste vide (rien d'inventé). # # Pattern « securecafe » (.securecafe.com/residentservices/ # apartmentsforrent/…) : vérifié fermé derrière login chez Old Oak, # Paramount et Tricar — entrées « echec » du registre, aucune classe # générée (voir les notes du registre avant de réessayer). Les autres # impasses vérifiées (WordPress sans searchlisting, microsites par # immeuble, Entrata, Rentsync, sites custom) sont aussi documentées # en « echec » dans le registre. # # sans cette variable, disabled=True et le registre des connecteurs les # ignore (zéro impact sur la prod Québec). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as htmllib import json import os import re from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import BaseConnector # Gate expansion Ontario (voir en-tête) _ONTARIO = True # Rent-Ka: always on (ROC scope) _REGISTRY_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "rentcafe_clients.json") # « $1,449.00 - $1,899.00 » / « $1,499.00 » (format RentCafe, virgule = milliers) _PRICE_RE = re.compile(r"\$[\d,]+(?:\.\d{2})?(?:\s*(?:-|to|à)+\s*" r"\$[\d,]+(?:\.\d{2})?)?") _NUM_RE = re.compile(r"[\d,]+(?:\.\d+)?") _SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder|\.svg", re.I) _PHONE_RE = re.compile(r"\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})") # nombre de chambres -> type d'unité (normalize_unit_type fera la conversion # canonique n½ à l'ingestion ; on passe le texte source, rien d'inventé) _BED_TYPES = {0: "Studio", 1: "1 Bed", 2: "2 Beds", 3: "3 Beds", 4: "4 Beds"} def _to_float(txt: str) -> float | None: m = _NUM_RE.search(txt or "") if not m: return None try: return float(m.group(0).replace(",", "")) except ValueError: return None def _low_price(txt: str) -> float | None: """Borne basse d'une fourchette « $1,449.00 - $1,899.00 » (100-20000 $).""" vals = [_to_float(v) for v in re.findall(r"\$[\d,]+(?:\.\d{2})?", txt or "")] vals = [v for v in vals if v is not None and 100 <= v <= 20000] return min(vals) if vals else None class _DetailBudget(Exception): """Budget de nouvelles fiches Scrapfly épuisé pour cette synchronisation.""" class RentCafeClientConnector(BaseConnector): province = "ON" # overridden per client ("province" key in the registry) """Classe de base des clients RentCafe — ne PAS l'enregistrer telle quelle (source_id vide) : les sous-classes concrètes sont générées plus bas à partir du registre data/rentcafe_clients.json.""" source_id = "" # vide -> ignorée par connectors/__init__.py disabled = False request_delay = 1.5 # Scrapfly coûte : politesse renforcée client: dict = {} # entrée du registre (site, search_url…) max_properties = 160 # garde-fou (Effort Trust : 153 cartes ON) max_details = 8 # nouvelles fiches Scrapfly max par sync max_images = 20 # -- Scrapfly (Cloudflare -> ASP ; contenu rendu serveur, pas de JS) ------- def _page(self, url: str) -> str: res = self.scrapfly(url, render_js=False, asp=True, country="ca") if (res.get("status_code") or 0) != 200: return "" return res.get("content") or "" # -- fetch ------------------------------------------------------------------ def fetch(self) -> list[Listing]: if (self.client.get("pattern") or "searchlisting") != "searchlisting": return [] # « securecafe » public : aucun client validé html = self._page(self.client["search_url"]) if not html: raise RuntimeError( f"searchlisting inaccessible via Scrapfly ({self.source_id})") soup = BeautifulSoup(html, "html.parser") pins = self._map_pins(html) self._detail_fetches = 0 listings: list[Listing] = [] seen: set[str] = set() cards = soup.select("li.property-box-hidden") \ or soup.select("li.property-box, .property-box") for card in cards: if len(listings) >= self.max_properties: break try: lst = self._property_listing(card, pins) if lst and lst.external_id not in seen: seen.add(lst.external_id) listings.append(lst) except Exception: continue return listings # -- épingles de carte (champ caché available_prop_map) ---------------------- @staticmethod def _map_pins(html: str) -> dict[str, dict]: """propertyid -> {lat, lng, price, beds} (JSON doublement encodé).""" m = re.search(r"available_prop_map[^>]*value=(['\"])(.*?)\1", html, re.S) if not m: return {} try: data = json.loads(htmllib.unescape(m.group(2))) if isinstance(data, str): data = json.loads(data) pins = data.get("ListingsPins") if isinstance(pins, str): pins = json.loads(pins) except (ValueError, AttributeError): return {} out: dict[str, dict] = {} for grp in (pins or {}).get("groups") or []: for p in grp.get("points") or []: pid = str(p.get("propertyid") or p.get("id") or "") if not pid: continue hover = p.get("hover") or {} out[pid] = {"lat": p.get("y"), "lng": p.get("x"), "price": hover.get("Price") or "", "beds": hover.get("Beds") or ""} return out # -- carte propriété ---------------------------------------------------------- def _property_listing(self, card, pins: dict[str, dict]) -> Listing | None: a = card.select_one(".property-name a") or card.select_one("h3 a") if not a or not a.get("href"): return None url = (a.get("href") or "").strip() if url.startswith("/"): url = self.client["site"].rstrip("/") + url url = url.split("?")[0].rstrip("/") name = re.sub(r"\s*opens in a new tab\s*", "", a.get_text(" ", strip=True)).strip() addr_el = card.select_one(".card-prop-address") address = addr_el.get_text(" ", strip=True) if addr_el else "" # Ontario seulement (Osgoode/GWLRA listent aussi AB/BC ; QC = connecteurs QC) if not re.search(r",\s*ON(?:\s|,|$)", address): return None city = "" parts = [p.strip() for p in address.split(",")] for i, p in enumerate(parts): if re.match(r"^ON(\s|$)", p) and i > 0: city = parts[i - 1] break # propertyid RentCafe stable (classe track-propertyurl- ou épingle) pid = "" for el in card.select("[class*='track-propertyurl-']"): for cl in el.get("class") or []: if cl.startswith("track-propertyurl-"): pid = cl.rsplit("-", 1)[-1] break slug = re.sub(r"[^a-z0-9]+", "-", url.rstrip("/").rsplit("/", 1)[-1].lower()).strip("-") external_id = pid or slug if not external_id: return None # lits / sdb / pi² de la carte (« 1.0Beds - 2.0Beds », « 799 - 1,018 Sq. Ft. ») beds_txt = baths_txt = sqft_txt = "" meta = card.select_one(".card-bed-bath-rent") if meta: for li in meta.select("li"): it = li.get_text(" ", strip=True) if "Bed" in it: beds_txt = it elif "Bath" in it: baths_txt = it elif "Sq" in it: sqft_txt = re.sub(r"\s*to\s*-\s*", " - ", it) unit_type = "" bm = re.match(r"^(\d+)(?:\.\d+)?\s*Beds?", beds_txt or "") if bm and "-" not in beds_txt.split("Bed")[0]: unit_type = _BED_TYPES.get(int(bm.group(1)), "") # fourchette de prix : carte, sinon épingle de la carte interactive pin = pins.get(external_id) or {} pm = _PRICE_RE.search(card.get_text(" ", strip=True)) price_label = pm.group(0) if pm else (pin.get("price") or "") price_label = re.sub(r"\s*(?:to|à)\s*", " - ", price_label).strip() price = _low_price(price_label) if price is not None and "-" in price_label: price_label = "À partir de " + price_label if price is None: price_label = "" # « Call for Details » : rien d'inventé phone = "" tel = card.select_one("a[href^='tel:']") if tel: tm = _PHONE_RE.search(tel.get("href") or "") if tm: phone = f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}" images: list[str] = [] img = card.select_one("img[src*='rentcafe']") or card.select_one("img") if img and (img.get("src") or "").startswith("http") \ and not _SKIP_IMG.search(img.get("src") or ""): images.append(img["src"]) # fiche + plans via cache BD (clé = contenu de la carte liste) key = hashlib.sha1( f"{name}|{address}|{beds_txt}|{baths_txt}|{sqft_txt}|{price_label}" .encode("utf-8")).hexdigest() try: payload = self.detail(external_id, key, lambda: self._fetch_detail(url)) except _DetailBudget: payload = {} 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 or "-" in price_label else f"{price:,.0f} $/mois".replace(",", " ")) sqfts = [p["sqft"] for p in plans if p.get("sqft")] area_sqft = min(sqfts) if sqfts else None if len(plans) == 1 and plans[0].get("unit_type"): unit_type = plans[0]["unit_type"] plan_bits = [] for p in plans[:8]: seg = p.get("name") or "" if p.get("sqft"): seg += f" ({p['sqft']:.0f} pi²)" if p.get("price"): seg += f" : {p['price']:,.0f} $/mois".replace(",", " ") if seg: plan_bits.append(seg) bathrooms = None tb = re.match(r"^(\d+(?:\.\d+)?)\s*Bath", baths_txt or "") if tb and "-" not in baths_txt.split("Bath")[0]: bathrooms = float(tb.group(1)) details: dict = {} if phone: details["contact"] = {"phone": phone} desc_parts = ([payload["description"]] if payload.get("description") else []) desc_parts += [b for b in [beds_txt, baths_txt, sqft_txt] if b] if plan_bits: desc_parts.append("Plans : " + " ; ".join(plan_bits)) lat = pin.get("lat") lng = pin.get("lng") return Listing( source=self.source_id, external_id=str(external_id), url=url, title=name or slug.replace("-", " ").title(), address=address, sector="", # le gabarit RentCafe ne publie pas le quartier city=city, province=self.province, unit_type=unit_type, bathrooms=bathrooms, price=price, price_label=price_label, availability="", # aucun décompte d'unités publié (cf. en-tête) area_sqft=area_sqft, description=" — ".join(desc_parts)[:900], details=details, images=images[: self.max_images], lat=float(lat) if isinstance(lat, (int, float)) else None, lng=float(lng) if isinstance(lng, (int, float)) else None, ) # -- fiche propriété (galerie + description + plans) -------------------------- def _fetch_detail(self, url: str) -> dict: if self._detail_fetches >= self.max_details: raise _DetailBudget() self._detail_fetches += 1 payload: dict = {"description": "", "images": [], "floorplans": []} html = self._page(url) if html: soup = BeautifulSoup(html, "html.parser") for im in soup.select("img[src*='resource.rentcafe.com']"): src = im.get("src") or "" 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] payload["floorplans"] = self._parse_floorplans(soup) # plans absents de la fiche (gabarit Osgoode/GWLRA) -> page /floorplans if not payload["floorplans"] and not url.endswith("default.aspx") \ and self._detail_fetches < self.max_details: self._detail_fetches += 1 fp_html = self._page(url + "/floorplans") if fp_html: payload["floorplans"] = self._parse_floorplans( BeautifulSoup(fp_html, "html.parser")) return payload # -- plans fp-container (2 gabarits RentCafe) ---------------------------------- @staticmethod def _parse_floorplans(soup) -> list[dict]: """Cartes de plans : nom, chambres, sdb, pi², prix (borne basse d'une fourchette). Gabarits : spans data-selenium-id (Osgoode) OU cartes h2.card-title + icônes nu-bed/nu-bathroom/nu-area + attributs data-floorplan-* (Effort, GWLRA). Dédoublonnés par id (carrousels).""" plans: list[dict] = [] seen: set[str] = set() for cont in soup.select("div[id^='fp-container-']"): fpid = (cont.get("id") or "").rsplit("-", 1)[-1] if fpid in seen: continue seen.add(fpid) try: plan: dict = {} # gabarit 1 : spans data-selenium-id name_el = cont.select_one("span[data-selenium-id$='Name']") # gabarit 2 : cartes (titre + icônes) if name_el is None: name_el = cont.select_one("h2.card-title, .card-title") if name_el is not None: plan["name"] = name_el.get_text(" ", strip=True) beds_el = cont.select_one("span[data-selenium-id$='Beds']") beds_txt = (beds_el.get_text(" ", strip=True) if beds_el else "") if not beds_txt: ic = cont.select_one(".nu-bed") if ic and ic.parent: beds_txt = ic.parent.get_text(" ", strip=True) bm = re.search(r"(\d+)\s*Bed", beds_txt) if bm: plan["bedrooms"] = float(bm.group(1)) plan["unit_type"] = _BED_TYPES.get(int(bm.group(1)), "") elif re.search(r"studio", (plan.get("name") or "") + beds_txt, re.I): plan["bedrooms"] = 0.0 plan["unit_type"] = "Studio" baths_el = cont.select_one("span[data-selenium-id$='Baths']") baths_txt = (baths_el.get_text(" ", strip=True) if baths_el else "") if not baths_txt: ic = cont.select_one(".nu-bathroom") if ic and ic.parent: baths_txt = ic.parent.get_text(" ", strip=True) tm = re.search(r"(\d+(?:\.\d+)?)\s*Bath", baths_txt) if tm: plan["bathrooms"] = float(tm.group(1)) sq_el = cont.select_one("span[data-selenium-id$='SqFt']") sq_txt = sq_el.get_text(" ", strip=True) if sq_el else "" if not sq_txt: ic = cont.select_one(".nu-area") if ic and ic.parent: sq_txt = ic.parent.get_text(" ", strip=True) sv = _to_float(sq_txt) if sv and 80 <= sv <= 20000: plan["sqft"] = sv # prix : attribut structuré data-floorplan-price (« 2113 -2163 »), # sinon encadré « Starting at $2,113.00 /Month », sinon span Rent pv = None btn = cont.select_one("[data-floorplan-price]") if btn: nums = [_to_float(x) for x in _NUM_RE.findall( btn.get("data-floorplan-price") or "")] nums = [n for n in nums if n and 100 <= n <= 20000] if nums: pv = min(nums) if pv is None: rent_el = cont.select_one( "span[data-selenium-id$='Rent']") \ or cont.select_one(".fieldset .font-weight-bold, " ".fieldset span.font-weight-bold") if rent_el: pv = _low_price(rent_el.get_text(" ", strip=True)) if pv is None: pv = _low_price(" ".join( _PRICE_RE.findall(cont.get_text(" ", strip=True)))) if pv: plan["price"] = pv if plan.get("name") or plan.get("price"): plans.append(plan) except Exception: continue return plans # ============================================================================= # Génération des sous-classes concrètes à partir du registre # data/rentcafe_clients.json — une classe par client « ok », déposée dans les # globals du module pour que connectors/__init__.py la découvre. Un registre # absent/corrompu ne doit JAMAIS casser l'import du paquet (prod QC). # ============================================================================= def _load_clients() -> list[dict]: try: with open(_REGISTRY_PATH, encoding="utf-8") as f: return json.load(f).get("clients") or [] except (OSError, ValueError): return [] for _c in _load_clients(): if (_c.get("status") or "") != "ok" or not _c.get("id"): continue _cls_name = "RC" + "".join( w.capitalize() for w in re.split(r"[^a-z0-9]+", _c["id"]) if w) \ + "Connector" globals()[_cls_name] = type(_cls_name, (RentCafeClientConnector,), { "source_id": f"rc_{_c['id']}", "client": _c, "province": (_c.get("province") or "ON").upper(), "disabled": False, "__doc__": f"Client RentCafe « {_c.get('name') or _c['id']} » " f"({_c.get('regions') or 'Ontario'}) — généré depuis " "data/rentcafe_clients.json.", }) del _c