Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/interrent.py : InterRent REIT (irent.com)5# Next.js site (app router): the «communities/city/toronto» page embeds in6# its RSC stream (self.__next_f.push) the full JSON of every community7# (address, neighbourhood, photos, Amenities/Utilities, PetFriendly*8# flags, parking, contact, description via $NN reference) with their9# available suites (type, beds, baths, sqft, rent, date). A single request10# is enough: the RSC stream embeds ALL of the REIT's communities (QC, ON,11# BC…) regardless of the city in the URL. Rent-Ka keeps every community12# outside Québec; the display map below normalizes former Toronto/Ottawa13# borough names, unknown cities pass through as-is.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as htmllib18import json19import os20import re2122from ..schema import Listing, strip_accents23from .base import BaseConnector2425BASE = "https://www.irent.com"26CITY_URL = f"{BASE}/communities/city/toronto"2728CHUNK_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)')29OBJ_START_RE = re.compile(r'\{"Id":\d+,"ImportId"')3031# Ontario display map: normalized key -> (display city, forced sector or32# None); former Toronto/Ottawa boroughs fold into the amalgamated city.33# Cities absent from the map are NOT rejected (whole-ROC coverage).34_ON_CITIES = {35 "toronto": ("Toronto", None),36 "etobicoke": ("Toronto", "Etobicoke"),37 "north york": ("Toronto", "North York"),38 "york": ("Toronto", "York"),39 "east york": ("Toronto", "East York"),40 "scarborough": ("Toronto", "Scarborough"),41 "mississauga": ("Mississauga", None),42 "brampton": ("Brampton", None),43 "markham": ("Markham", None),44 "vaughan": ("Vaughan", None),45 "richmond hill": ("Richmond Hill", None),46 "ajax": ("Ajax", None),47 "pickering": ("Pickering", None),48 "whitby": ("Whitby", None),49 "oshawa": ("Oshawa", None),50 "oakville": ("Oakville", None),51 "burlington": ("Burlington", None),52 "milton": ("Milton", None),53 "hamilton": ("Hamilton", None),54 "ottawa": ("Ottawa", None),55 "nepean": ("Ottawa", "Nepean"),56 "kanata": ("Ottawa", "Kanata"),57 "gloucester": ("Ottawa", "Gloucester"),58 "orleans": ("Ottawa", "Orléans"),59 "kitchener": ("Kitchener", None),60 "waterloo": ("Waterloo", None),61 "cambridge": ("Cambridge", None),62}6364_BED_TYPES = {0: "Studio", 1: "1 bedroom", 2: "2 bedrooms", 3: "3 bedrooms",65 4: "4 bedrooms"}666768def _unescape_js(s: str) -> str:69 """Déséchappe une chaîne JS du flux RSC (\\" \\n \\uXXXX...)."""70 try:71 return json.loads(f'"{s}"')72 except ValueError:73 try:74 return (s.encode("latin-1", "backslashreplace")75 .decode("unicode_escape"))76 except Exception:77 return s787980def _clean(s: str) -> str:81 """Déséchappe les entités HTML répétées (&amp;amp;...)."""82 s = s or ""83 for _ in range(4):84 t = htmllib.unescape(s)85 if t == s:86 break87 s = t88 return s.strip()899091def _as_list(node) -> list:92 """Les nœuds XML->JSON du flux : dict simple ou liste."""93 if isinstance(node, list):94 return node95 if isinstance(node, dict):96 return [node]97 return []9899100def _strip_html(s: str) -> str:101 """HTML -> texte plat (entités déjà gérées par _clean)."""102 return _clean(re.sub(r"<[^>]+>", " ", s or ""))103104105# Références texte du flux RSC : « \nNN:Txxx, » suivi de xxx (hex) caractères.106_TEXT_REF_RE = re.compile(r"\n(\d+):T([0-9a-f]+),")107108109def _text_refs(blob: str) -> dict[str, str]:110 """Table des chaînes référencées « $NN » (descriptions d'immeubles…)."""111 refs: dict[str, str] = {}112 for m in _TEXT_REF_RE.finditer(blob):113 n = int(m.group(2), 16)114 refs[m.group(1)] = blob[m.end():m.end() + n]115 return refs116117118# Utilities (services inclus au bail) -> clés canoniques d'inclusions119def _utility_key(u: str) -> str | None:120 s = u.lower()121 if "hot water" in s:122 return "hot_water"123 if "heat" in s:124 return "heating"125 if "hydro" in s or "electric" in s:126 return "electricity"127 if "internet" in s:128 return "internet"129 if "cable" in s:130 return "cable"131 return None # « Water » (eau froide) : pas de clé canonique132133134class InterrentConnector(BaseConnector):135 source_id = "interrent"136 request_delay = 0.6137 max_images = 20138139 def fetch(self) -> list[Listing]:140 page = self.get(CITY_URL).text141 blob = "".join(_unescape_js(c) for c in CHUNK_RE.findall(page))142143 # Objets communauté : {"Id":N,"ImportId":...,"PermaLink":...}144 communities: dict[int, dict] = {}145 pos = 0146 while True:147 m = OBJ_START_RE.search(blob, pos)148 if not m:149 break150 obj, end = self._read_object(blob, m.start())151 pos = end if end > m.start() else m.start() + 1152 if not obj or "PermaLink" not in obj or "Location" not in obj:153 continue154 cid = obj.get("Id")155 if isinstance(cid, int) and cid not in communities:156 communities[cid] = obj157158 refs = _text_refs(blob)159 listings: list[Listing] = []160 for c in communities.values():161 try:162 listings.extend(self._community_listings(c, refs))163 except Exception:164 continue165 return listings166167 @staticmethod168 def _read_object(blob: str, start: int) -> tuple[dict | None, int]:169 """Extrait un objet JSON par appariement d'accolades."""170 depth = 0171 in_str = False172 esc = False173 for i in range(start, min(len(blob), start + 400_000)):174 ch = blob[i]175 if in_str:176 if esc:177 esc = False178 elif ch == "\\":179 esc = True180 elif ch == '"':181 in_str = False182 continue183 if ch == '"':184 in_str = True185 elif ch == "{":186 depth += 1187 elif ch == "}":188 depth -= 1189 if depth == 0:190 try:191 return json.loads(blob[start:i + 1]), i + 1192 except ValueError:193 return None, i + 1194 return None, start + 1195196 def _community_listings(self, c: dict,197 refs: dict[str, str] | None = None) -> list[Listing]:198 refs = refs or {}199 loc = c.get("Location") or {}200 prov = (loc.get("ProvinceCode") or "").upper()201 raw_city = _clean(loc.get("City") or "")202 key = strip_accents(raw_city.lower()).strip()203 if not prov or prov == "QC" or not raw_city:204 return [] # Québec is Rent-Ka's territory205 forced_sector = None206 if prov == "ON":207 city, forced_sector = _ON_CITIES.get(key, (raw_city, None))208 else:209 city = raw_city210 sector = _clean(loc.get("Neighbourhood") or c.get("TagLine") or "")211 if strip_accents(sector.lower()) == strip_accents(city.lower()):212 sector = ""213 if forced_sector and not sector:214 sector = forced_sector # e.g. Etobicoke -> Toronto / Etobicoke215216 name = _clean(c.get("Name") or "")217 url = c.get("Url") or f"{BASE}/communities/{c.get('PermaLink', '')}"218 address = _clean(loc.get("Address") or "")219 postal = _clean(loc.get("PostalCode") or "")220 last = f"{prov} {postal}".strip()221 full_address = ", ".join(x for x in [address, city, last] if x)222223 # Photos de la communauté224 images: list[str] = []225 photos = (c.get("Photos") or {})226 for p in _as_list(photos.get("Photo") if isinstance(photos, dict)227 else photos):228 u = (p or {}).get("Url") or ""229 if u.startswith("http") and u not in images:230 images.append(u)231 images = images[: self.max_images]232233 # Commodités : nœud Amenities.Amenity (liste) + services inclus234 am_node = (c.get("Amenities") or {})235 amenities = [_clean(str(a)) for a in236 _as_list(am_node.get("Amenity") if isinstance(am_node, dict)237 else am_node) if a]238 if not amenities: # ancien champ, gardé en repli239 amenities = [a.strip() for a in240 (c.get("amenities_TextField") or "").split(",")241 if a.strip()]242 ut_node = (c.get("Utilities") or {})243 utilities = [_clean(str(u)) for u in244 _as_list(ut_node.get("Utility") if isinstance(ut_node, dict)245 else ut_node) if u]246 if utilities:247 amenities.append("Utilities included: " + ", ".join(utilities))248 amenities = amenities[:25]249250 # Détails structurés communs à la communauté251 details: dict = {}252 inclusions = {}253 for u in utilities:254 k = _utility_key(u)255 if k:256 inclusions[k] = True257 if inclusions:258 details["inclusions"] = inclusions259260 # Animaux : indicateurs PetFriendly* (structurés à la source)261 pets = None262 if c.get("PetFriendlyNotAllowed"):263 pets = "non"264 elif c.get("PetFriendly"):265 sub = [c.get("PetFriendlyCats"), c.get("PetFriendlySmallDogs"),266 c.get("PetFriendlyLargeDogs")]267 pets = "conditions" if any(sub) and not all(sub) else "oui"268269 # Contact du bureau de location270 ci = c.get("ContactInformation") or {}271 contact = {}272 if ci.get("Phone"):273 contact["phone"] = _clean(ci["Phone"])274 if ci.get("Email"):275 contact["email"] = _clean(ci["Email"])276 if contact:277 details["contact"] = contact278279 # Stationnement : champ ParkingDetails (« Indoor Parking: $150 / month »)280 parking_txt = _strip_html(c.get("ParkingDetails") or "")281 if parking_txt:282 if re.search(r"no parking|not available", parking_txt, re.I):283 details["parking"] = {"available": False}284 else:285 parking: dict = {"available": True}286 indoor = re.search(r"indoor|interior|underground",287 parking_txt, re.I)288 outdoor = re.search(r"outdoor|exterior|surface",289 parking_txt, re.I)290 if indoor and not outdoor:291 parking["type"] = "intérieur"292 elif outdoor and not indoor:293 parking["type"] = "extérieur"294 prices = [float(p) for p in295 re.findall(r"\$\s*(\d{2,4})", parking_txt)]296 if re.search(r"included|free", parking_txt, re.I):297 parking["included"] = True298 elif prices:299 parking["included"] = False300 parking["price"] = min(prices)301 details["parking"] = parking302303 # Description de l'immeuble (référence $NN du flux RSC) + promo + animaux304 desc_ref = str(c.get("BuildingDescription") or "")305 building_desc = ""306 if desc_ref.startswith("$"):307 building_desc = _strip_html(refs.get(desc_ref[1:], ""))308 elif desc_ref:309 building_desc = _strip_html(desc_ref)310 promo = (c.get("Promotions") or {})311 promo_title = _clean(promo.get("Title") or "") \312 if isinstance(promo, dict) else ""313 pet_details = _strip_html(c.get("PetDetails") or "")314 extra_desc = " — ".join(x for x in [315 f"Promotion : {promo_title}" if promo_title else "",316 building_desc,317 f"Stationnement : {parking_txt}" if parking_txt else "",318 f"Animaux : {pet_details}" if pet_details else ""] if x)319320 suites = (c.get("Suites") or {})321 out: list[Listing] = []322 for s in _as_list(suites.get("Suite") if isinstance(suites, dict)323 else suites):324 try:325 if (s.get("Available") or "").lower() != "yes":326 continue327 type_name = _clean(s.get("TypeName") or "")328 # pseudo-unités « Promotional Price » sans numéro : ignorées329 if "promotional" in type_name.lower() and not s.get("Number"):330 continue331 rate = s.get("Rate")332 price = float(rate) if isinstance(rate, (int, float)) \333 and rate else None334 beds = s.get("Bedrooms")335 unit_type = _BED_TYPES.get(beds, "") \336 if isinstance(beds, int) else ""337 sqft = s.get("SquareFeet")338 baths = s.get("Bathrooms")339 bits = []340 if sqft:341 bits.append(f"{sqft} pi²")342 if baths:343 bits.append(f"{baths} sdb")344 if type_name:345 bits.append(f"plan {type_name}")346 suite_desc = _strip_html(s.get("Description") or "")347 if suite_desc:348 bits.append(suite_desc)349 if extra_desc:350 bits.append(extra_desc)351352 # plan d'étage en tête de galerie s'il existe353 imgs = list(images)354 fps = (s.get("Floorplans") or {})355 for fp in _as_list(fps.get("Floorplan")356 if isinstance(fps, dict) else fps):357 fu = (fp or {}).get("Image") or ""358 if fu.startswith("http") and fu not in imgs:359 imgs.insert(0, fu)360361 num = s.get("Number") or ""362 label = f"{name} — {type_name}" if type_name else name363 if num:364 label += f" (app. {num})"365 out.append(Listing(366 source=self.source_id,367 external_id=str(s.get("Id")),368 url=url,369 title=label,370 address=full_address,371 sector=sector,372 city=city,373 province=prov,374 unit_type=unit_type,375 price=price,376 price_label=f"{int(rate)} $/mois" if price else "",377 area_sqft=float(sqft) if isinstance(sqft, (int, float))378 and 80 <= sqft <= 20000 else None,379 availability=_clean(s.get("AvailabilityDate") or ""),380 pets=pets,381 description=" — ".join(bits)[:900],382 amenities=list(amenities),383 details=dict(details),384 images=imgs[: self.max_images + 1],385 lat=loc.get("Latitude"),386 lng=loc.get("Longitude"),387 ))388 except Exception:389 continue390 return out391