# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/rentalys.py : connecteur Rentalys (location.rentalys.ca) # Agence de location du Grand Montréal (Verdun, Villeray, Rosemont, # Saint-Michel, Longueuil...). Site WordPress (thème Houzez) rendu # serveur : archives /property/page/N/ = cartes complètes (prix, adresse, # étiquettes de disponibilité), pages /property// = photos, détails, # description bilingue, superficie structurée (.h-area) et coordonnées # lat/lng (variable JS houzez_single_property_map). Les fiches passent par # self.detail(...) (cache BD) : re-téléchargées seulement si la carte change. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://location.rentalys.ca" # Arrondissements/quartiers de l'île de Montréal (partie avant ", QC" de # l'adresse Houzez) -> ville Montréal. Les autres villes du Grand Montréal # (Longueuil, Laval, Brossard...) restent des villes à part entière. MTL_BOROUGHS = { "montreal", "verdun", "villeray", "rosemont", "la petite-patrie", "petite-patrie", "rosemont/la petite patrie", "saint-michel", "st-michel", "ville-marie", "le plateau-mont-royal", "plateau-mont-royal", "plateau", "hochelaga-maisonneuve", "mercier", "ahuntsic", "ahuntsic-cartierville", "cote-des-neiges", "notre-dame-de-grace", "ndg", "outremont", "lachine", "lasalle", "saint-laurent", "st-laurent", "anjou", "saint-leonard", "st-leonard", "montreal-nord", "riviere-des-prairies", "pointe-aux-trembles", "griffintown", "le sud-ouest", "sud-ouest", "pointe-saint-charles", "saint-henri", "st-henri", "viau", "westmount", "mont-royal", } GRAND_MTL_CITIES = { "longueuil", "laval", "brossard", "saint-lambert", "boucherville", "candiac", "chateauguay", "repentigny", "terrebonne", "dorval", "pointe-claire", "dollard-des-ormeaux", "kirkland", "beaconsfield", "sainte-julie", "varennes", "chambly", "la prairie", "saint-constant", "sainte-catherine", "delson", "blainville", "mirabel", "saint-eustache", "deux-montagnes", "rosemere", "boisbriand", "sainte-therese", "vaudreuil-dorion", "charlemagne", "mascouche", "l'assomption", } def _city_sector(address: str, title: str) -> tuple[str, str]: """Déduit (ville, secteur) de l'adresse Houzez '..., Secteur, QC, Canada'.""" sector = "" parts = [p.strip() for p in (address or "").split(",")] # partie juste avant "QC" for i, p in enumerate(parts): if p.upper().startswith("QC") and i > 0: sector = parts[i - 1] break if not sector and len(parts) >= 2: sector = parts[1] key = strip_accents(sector.lower()) if key in GRAND_MTL_CITIES: return sector, "" # ville de banlieue, pas de secteur if key in MTL_BOROUGHS or key.replace("st-", "saint-") in MTL_BOROUGHS: return "Montréal", "" if key == "montreal" else sector # repli : préfixe du titre ("VERDUN – ...", "LONGUEUIL maison ...") t = strip_accents((title or "").lower()) for c in GRAND_MTL_CITIES: if t.startswith(c): return sector or c.title(), "" return "Montréal", sector def _unit_type(title: str, beds: str) -> str: t = normalize_unit_type(title) if re.match(r"^\d½$", t) or t in ("Studio", "Loft", "Maison", "Chambre"): return t m = re.search(r"(\d)\s*(?:1/2|½|%c2%bd)", (title or "").lower()) if m: return f"{m.group(1)}½" if "maison" in (title or "").lower(): return "Maison" if "studio" in (title or "").lower(): return "Studio" try: n = int(str(beds).strip()) return "Studio" if n == 0 else f"{n + 2}½" except (TypeError, ValueError): return "" def _parse_houzez_price(label: str) -> float | None: """'$2,177/mois' -> 2177.0 (format nord-américain, $ devant).""" m = re.search(r"\$\s*([\d][\d,\.\s]*)", label or "") if not m: return parse_price(label) try: val = float(m.group(1).replace(",", "").replace(" ", "")) except ValueError: return None return val if 100 <= val <= 20000 else None class _BudgetReached(Exception): """Plafond de requêtes « fiche » atteint pour cette synchronisation.""" class RentalysConnector(BaseConnector): source_id = "rentalys" request_delay = 0.6 max_pages = 10 # garde-fou d'archives max_details = 60 # plafond de vraies requêtes fiche par sync def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for page in range(1, self.max_pages + 1): url = f"{BASE}/property/" if page == 1 \ else f"{BASE}/property/page/{page}/" try: html = self.get(url).text except Exception: break found = self._parse_archive(html, listings) if not found: break # Fiches détaillées (cache BD) : photos, description, lat/lng, pi² self._fetches = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}|" f"{'|'.join(lst.amenities)}".encode("utf-8")).hexdigest() try: payload = self.detail( lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: # _BudgetReached inclus : rien de caché continue self._apply_detail(lst, payload) return list(listings.values()) # -- archive ------------------------------------------------------------------ def _parse_archive(self, html: str, listings: dict[str, Listing]) -> int: soup = BeautifulSoup(html, "html.parser") found = 0 for card in soup.select(".item-listing-wrap"): try: a = card.select_one('a[href*="/property/"]') if not a: continue href = (a.get("href") or "").split("?")[0] m = re.search(r"/property/([^/]+)/?$", href) if not m: continue slug = m.group(1) found += 1 if slug in listings: continue title_el = card.select_one(".item-title") title = title_el.get_text(" ", strip=True) if title_el else slug # exclure stationnements / commercial if re.search(r"parking|stationnement|commercial|garage", title, re.I): continue addr_el = card.select_one(".item-address") address = addr_el.get_text(" ", strip=True) if addr_el else "" price_el = card.select_one(".item-price") price_label = price_el.get_text(" ", strip=True) \ if price_el else "" labels = [el.get_text(" ", strip=True) for el in card.select(".hz-label, .label-status," " .labels-wrap a")] labels = [l for l in dict.fromkeys(labels) if l] availability = next( (l for l in labels if re.search(r"disponible", l, re.I)), "") amenities = [l for l in labels if not re.search(r"disponible", l, re.I)] beds = baths = area = "" for amen in card.select(".item-amenities li"): txt = amen.get_text(" ", strip=True) if re.search(r"Lits?|Bed", txt, re.I): beds = re.sub(r"\D", "", txt) elif re.search(r"Bains?|Bath", txt, re.I): baths = re.sub(r"\D", "", txt) elif re.search(r"\d{3,}", txt): area = re.search(r"(\d[\d,\s]*)", txt).group(1) if baths: amenities.append(f"{baths} salle(s) de bain") if area: amenities.append(f"{area.strip()} pi²") city, sector = _city_sector(address, title) # repli secteur : préfixe du titre ("SAINT-MICHEL – ...") if not sector and city == "Montréal": mt = re.match(r"^([A-ZÉÈÀÂÎÔÛÇ/\-\s\.]{3,30})[–\-—]", title) if mt: sector = mt.group(1).strip().title() img = card.select_one("img") cover = (img.get("src") or img.get("data-src") or "") \ if img else "" listings[slug] = Listing( source=self.source_id, external_id=slug, url=href, title=title, address=address.replace(", Canada", ""), sector=sector, city=city, unit_type=_unit_type(title, beds), price=_parse_houzez_price(price_label), price_label=price_label, availability=availability, amenities=amenities, images=[cover] if cover.startswith("http") else [], ) except Exception: continue return found # -- fiche -------------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Télécharge une fiche /property// (appelé seulement hors cache).""" if self._fetches >= self.max_details: raise _BudgetReached() self._fetches += 1 html = self.get(url).text payload: dict = {} imgs = re.findall( r'https://location\.rentalys\.ca/wp-content/uploads/' r'[^"\s\\\)]+\.(?:jpg|jpeg|png|webp)', html) imgs = [u for u in dict.fromkeys(imgs) if not re.search(r"logo|icon|favicon|avatar|-\d{2,3}x\d{2,3}\.", u, re.I)] if imgs: payload["images"] = imgs[:40] soup = BeautifulSoup(html, "html.parser") desc = soup.select_one("#property-description-wrap .block-content-wrap") if desc: payload["description"] = desc.get_text(" ", strip=True)[:900] else: og = soup.find("meta", attrs={"property": "og:description"}) if og and og.get("content"): payload["description"] = og["content"].strip()[:900] # Superficie structurée (bloc « Caractéristiques clés », libellé .h-area) area_ul = soup.select_one(".property-overview-data .h-area") if area_ul: strong = area_ul.find_parent("ul") strong = strong.find("strong") if strong else None if strong: m = re.search(r"([\d\s,]+)", strong.get_text(strip=True)) if m: try: v = float(m.group(1).replace(",", "").replace(" ", "")) if 80 <= v <= 20000: payload["area_sqft"] = v except ValueError: pass # Coordonnées lat/lng (variable JS de la carte Houzez) m = re.search(r"houzez_single_property_map\s*=\s*({.*?});", html, re.S) if m: try: data = json.loads(m.group(1)) lat, lng = float(data.get("lat")), float(data.get("lng")) if 44 <= lat <= 63 and -80 <= lng <= -57: # Québec plausible payload["lat"], payload["lng"] = lat, lng except (ValueError, TypeError): pass return payload @staticmethod def _apply_detail(lst: Listing, payload: dict) -> None: if payload.get("images"): lst.images = payload["images"] if payload.get("description"): lst.description = payload["description"] if payload.get("area_sqft") and lst.area_sqft is None: lst.area_sqft = payload["area_sqft"] if payload.get("lat") is not None: lst.lat, lst.lng = payload["lat"], payload["lng"]