# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/interrent.py : connecteur InterRent REIT (irent.com) # Site Next.js (app router) : la page « communities/city/montreal » embarque # dans son flux RSC (self.__next_f.push) le JSON complet des communautés # (adresse, quartier, photos, commodités Amenities/Utilities, animaux # PetFriendly*, stationnement, contact, description via référence $NN) # avec leurs suites disponibles (type, chambres, sdb, pi², loyer, date). # Une seule requête suffit. # REIT pancanadien : filtre strict sur les villes du Grand Montréal. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import json import re from ..schema import Listing, strip_accents from .base import BaseConnector BASE = "https://www.irent.com" CITY_URL = f"{BASE}/communities/city/montreal" CHUNK_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)') OBJ_START_RE = re.compile(r'\{"Id":\d+,"ImportId"') # Villes admissibles (Grand Montréal), clés sans accents/minuscules _GM_CITIES = { "montreal": "Montréal", "cote-saint-luc": "Côte-Saint-Luc", "cote saint-luc": "Côte-Saint-Luc", "brossard": "Brossard", "laval": "Laval", "longueuil": "Longueuil", "verdun": "Montréal", "lasalle": "Montréal", "pointe-claire": "Pointe-Claire", "dollard-des-ormeaux": "Dollard-des-Ormeaux", } _BED_TYPES = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"} def _unescape_js(s: str) -> str: """Déséchappe une chaîne JS du flux RSC (\\" \\n \\uXXXX...).""" try: return json.loads(f'"{s}"') except ValueError: try: return (s.encode("latin-1", "backslashreplace") .decode("unicode_escape")) except Exception: return s def _clean(s: str) -> str: """Déséchappe les entités HTML répétées (&amp;amp;...).""" s = s or "" for _ in range(4): t = htmllib.unescape(s) if t == s: break s = t return s.strip() def _as_list(node) -> list: """Les nœuds XML->JSON du flux : dict simple ou liste.""" if isinstance(node, list): return node if isinstance(node, dict): return [node] return [] def _strip_html(s: str) -> str: """HTML -> texte plat (entités déjà gérées par _clean).""" return _clean(re.sub(r"<[^>]+>", " ", s or "")) # Références texte du flux RSC : « \nNN:Txxx, » suivi de xxx (hex) caractères. _TEXT_REF_RE = re.compile(r"\n(\d+):T([0-9a-f]+),") def _text_refs(blob: str) -> dict[str, str]: """Table des chaînes référencées « $NN » (descriptions d'immeubles…).""" refs: dict[str, str] = {} for m in _TEXT_REF_RE.finditer(blob): n = int(m.group(2), 16) refs[m.group(1)] = blob[m.end():m.end() + n] return refs # Utilities (services inclus au bail) -> clés canoniques d'inclusions def _utility_key(u: str) -> str | None: s = u.lower() if "hot water" in s: return "hot_water" if "heat" in s: return "heating" if "hydro" in s or "electric" in s: return "electricity" if "internet" in s: return "internet" if "cable" in s: return "cable" return None # « Water » (eau froide) : pas de clé canonique class InterrentConnector(BaseConnector): source_id = "interrent" request_delay = 0.6 max_images = 20 def fetch(self) -> list[Listing]: page = self.get(CITY_URL).text blob = "".join(_unescape_js(c) for c in CHUNK_RE.findall(page)) # Objets communauté : {"Id":N,"ImportId":...,"PermaLink":...} communities: dict[int, dict] = {} pos = 0 while True: m = OBJ_START_RE.search(blob, pos) if not m: break obj, end = self._read_object(blob, m.start()) pos = end if end > m.start() else m.start() + 1 if not obj or "PermaLink" not in obj or "Location" not in obj: continue cid = obj.get("Id") if isinstance(cid, int) and cid not in communities: communities[cid] = obj refs = _text_refs(blob) listings: list[Listing] = [] for c in communities.values(): try: listings.extend(self._community_listings(c, refs)) except Exception: continue return listings @staticmethod def _read_object(blob: str, start: int) -> tuple[dict | None, int]: """Extrait un objet JSON par appariement d'accolades.""" depth = 0 in_str = False esc = False for i in range(start, min(len(blob), start + 400_000)): ch = blob[i] if in_str: if esc: esc = False elif ch == "\\": esc = True elif ch == '"': in_str = False continue if ch == '"': in_str = True elif ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: try: return json.loads(blob[start:i + 1]), i + 1 except ValueError: return None, i + 1 return None, start + 1 def _community_listings(self, c: dict, refs: dict[str, str] | None = None) -> list[Listing]: refs = refs or {} loc = c.get("Location") or {} if (loc.get("ProvinceCode") or "").upper() != "QC": return [] raw_city = _clean(loc.get("City") or "") key = strip_accents(raw_city.lower()).strip() city = _GM_CITIES.get(key) if not city: return [] # hors Grand Montréal sector = _clean(loc.get("Neighbourhood") or c.get("TagLine") or "") if strip_accents(sector.lower()) == strip_accents(city.lower()): sector = "" if city != "Montréal" else sector if key in ("cote-saint-luc", "verdun", "lasalle") and not sector: sector = raw_city if city == "Montréal" else "" name = _clean(c.get("Name") or "") url = c.get("Url") or f"{BASE}/communities/{c.get('PermaLink', '')}" address = _clean(loc.get("Address") or "") postal = _clean(loc.get("PostalCode") or "") # Photos de la communauté images: list[str] = [] photos = (c.get("Photos") or {}) for p in _as_list(photos.get("Photo") if isinstance(photos, dict) else photos): u = (p or {}).get("Url") or "" if u.startswith("http") and u not in images: images.append(u) images = images[: self.max_images] # Commodités : nœud Amenities.Amenity (liste) + services inclus am_node = (c.get("Amenities") or {}) amenities = [_clean(str(a)) for a in _as_list(am_node.get("Amenity") if isinstance(am_node, dict) else am_node) if a] if not amenities: # ancien champ, gardé en repli amenities = [a.strip() for a in (c.get("amenities_TextField") or "").split(",") if a.strip()] ut_node = (c.get("Utilities") or {}) utilities = [_clean(str(u)) for u in _as_list(ut_node.get("Utility") if isinstance(ut_node, dict) else ut_node) if u] if utilities: amenities.append("Utilities included: " + ", ".join(utilities)) amenities = amenities[:25] # Détails structurés communs à la communauté details: dict = {} inclusions = {} for u in utilities: k = _utility_key(u) if k: inclusions[k] = True if inclusions: details["inclusions"] = inclusions # Animaux : indicateurs PetFriendly* (structurés à la source) pets = None if c.get("PetFriendlyNotAllowed"): pets = "non" elif c.get("PetFriendly"): sub = [c.get("PetFriendlyCats"), c.get("PetFriendlySmallDogs"), c.get("PetFriendlyLargeDogs")] pets = "conditions" if any(sub) and not all(sub) else "oui" # Contact du bureau de location ci = c.get("ContactInformation") or {} contact = {} if ci.get("Phone"): contact["phone"] = _clean(ci["Phone"]) if ci.get("Email"): contact["email"] = _clean(ci["Email"]) if contact: details["contact"] = contact # Stationnement : champ ParkingDetails (« Indoor Parking: $150 / month ») parking_txt = _strip_html(c.get("ParkingDetails") or "") if parking_txt: if re.search(r"no parking|not available", parking_txt, re.I): details["parking"] = {"available": False} else: parking: dict = {"available": True} indoor = re.search(r"indoor|interior|underground", parking_txt, re.I) outdoor = re.search(r"outdoor|exterior|surface", parking_txt, re.I) if indoor and not outdoor: parking["type"] = "intérieur" elif outdoor and not indoor: parking["type"] = "extérieur" prices = [float(p) for p in re.findall(r"\$\s*(\d{2,4})", parking_txt)] if re.search(r"included|free", parking_txt, re.I): parking["included"] = True elif prices: parking["included"] = False parking["price"] = min(prices) details["parking"] = parking # Description de l'immeuble (référence $NN du flux RSC) + promo + animaux desc_ref = str(c.get("BuildingDescription") or "") building_desc = "" if desc_ref.startswith("$"): building_desc = _strip_html(refs.get(desc_ref[1:], "")) elif desc_ref: building_desc = _strip_html(desc_ref) promo = (c.get("Promotions") or {}) promo_title = _clean(promo.get("Title") or "") \ if isinstance(promo, dict) else "" pet_details = _strip_html(c.get("PetDetails") or "") extra_desc = " — ".join(x for x in [ f"Promotion : {promo_title}" if promo_title else "", building_desc, f"Stationnement : {parking_txt}" if parking_txt else "", f"Animaux : {pet_details}" if pet_details else ""] if x) suites = (c.get("Suites") or {}) out: list[Listing] = [] for s in _as_list(suites.get("Suite") if isinstance(suites, dict) else suites): try: if (s.get("Available") or "").lower() != "yes": continue type_name = _clean(s.get("TypeName") or "") # pseudo-unités « Promotional Price » sans numéro : ignorées if "promotional" in type_name.lower() and not s.get("Number"): continue rate = s.get("Rate") price = float(rate) if isinstance(rate, (int, float)) \ and rate else None beds = s.get("Bedrooms") unit_type = _BED_TYPES.get(beds, "") \ if isinstance(beds, int) else "" sqft = s.get("SquareFeet") baths = s.get("Bathrooms") bits = [] if sqft: bits.append(f"{sqft} pi²") if baths: bits.append(f"{baths} sdb") if type_name: bits.append(f"plan {type_name}") suite_desc = _strip_html(s.get("Description") or "") if suite_desc: bits.append(suite_desc) if extra_desc: bits.append(extra_desc) # plan d'étage en tête de galerie s'il existe imgs = list(images) fps = (s.get("Floorplans") or {}) for fp in _as_list(fps.get("Floorplan") if isinstance(fps, dict) else fps): fu = (fp or {}).get("Image") or "" if fu.startswith("http") and fu not in imgs: imgs.insert(0, fu) num = s.get("Number") or "" label = f"{name} — {type_name}" if type_name else name if num: label += f" (app. {num})" out.append(Listing( source=self.source_id, external_id=str(s.get("Id")), url=url, title=label, address=", ".join(x for x in [address, city, postal] if x), sector=sector, city=city, unit_type=unit_type, price=price, price_label=f"{int(rate)} $/mois" if price else "", area_sqft=float(sqft) if isinstance(sqft, (int, float)) and 80 <= sqft <= 20000 else None, availability=_clean(s.get("AvailabilityDate") or ""), pets=pets, description=" — ".join(bits)[:900], amenities=list(amenities), details=dict(details), images=imgs[: self.max_images + 1], lat=loc.get("Latitude"), lng=loc.get("Longitude"), )) except Exception: continue return out