SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
15.0 KB · 391 lines python
Raw Blame History
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;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