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%
14.5 KB · 339 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/realtor_ca.py : REALTOR.ca (CREA) — annonces de location publiées5#   par les COURTIERS immobiliers (MLS). C'est LA source pancanadienne des6#   logements mis en location via courtier (condos en particulier), absente des7#   portails de gestionnaires.8#9#   API interne : POST https://api2.realtor.ca/Listing.svc/PropertySearch_Post10#   (form-urlencoded, TransactionTypeId=3 = à louer, PropertySearchTypeId=1 =11#   résidentiel). Protégée par Incapsula/Imperva → chaque requête passe par12#   Scrapfly ASP + pool résidentiel (validé en live le 2026-08-28 : 200 OK,13#   7 779 locations dans le seul Grand Toronto).14#15#   L'API plafonne à MaxRecords=600 par zone (3 pages × 200) : le fetch part de16#   boîtes provinciales (ROC) et les DIVISE récursivement en quadrants tant que17#   TotalRecords > 600 — couverture complète garantie, dédup par Id (les boîtes18#   se chevauchent aux frontières). Les requêtes sont parallélisées (pool de19#   threads, compteur verrouillé, garde-fou max_requests).20#21#   Une annonce = un résultat de recherche : prix (LeaseRent), adresse complète22#   avec GPS, chambres/SDB, photo, remarques publiques, et surtout le COURTIER23#   (Individual + Organization = agence) exposé dans details.contact. Pas de24#   visite des pages détail (40 k fiches × Scrapfly = hors budget) — la photo25#   de couverture et les champs du résultat suffisent à publier.26#   Échec HONNÊTE : si une part significative des requêtes échoue, on lève27#   plutôt que de laisser l'ingestion archiver l'inventaire manquant.28# -----------------------------------------------------------------------------29from __future__ import annotations3031import json32import os33import re34import threading35import time36from concurrent.futures import ThreadPoolExecutor37from urllib.parse import urlencode3839import requests4041from ..schema import Listing, normalize_unit_type42from .base import SCRAPFLY_API, BaseConnector4344_API = "https://api2.realtor.ca/Listing.svc/PropertySearch_Post"45_MAX_RECORDS = 600          # plafond serveur (Paging.MaxRecords)4647# Boîtes de départ — Canada hors Québec, généreuses (le Québec limitrophe est48# filtré au parse via ProvinceName). Chevauchements sans conséquence : dédup49# par Id.50_SEED_BOXES: list[tuple[float, float, float, float]] = [51    # (lat_min, lat_max, lng_min, lng_max)52    (41.6, 47.6, -83.7, -74.3),     # Ontario sud (Windsor→Ottawa)53    (46.0, 57.0, -95.4, -79.0),     # Ontario nord54    (48.2, 60.0, -139.1, -114.0),   # Colombie-Britannique55    (48.9, 60.0, -120.0, -110.0),   # Alberta56    (48.9, 60.0, -110.0, -101.4),   # Saskatchewan57    (48.9, 60.0, -102.2, -95.0),    # Manitoba58    (43.3, 48.3, -69.1, -59.6),     # NB + NS + IPE59    (46.5, 60.5, -67.9, -52.5),     # Terre-Neuve-et-Labrador60    (60.0, 70.5, -141.0, -61.0),    # YT + TNO + NU61]6263_PROVINCE_CODES = {64    "ontario": "ON", "british columbia": "BC", "alberta": "AB",65    "saskatchewan": "SK", "manitoba": "MB", "new brunswick": "NB",66    "nova scotia": "NS", "prince edward island": "PE",67    "newfoundland & labrador": "NL", "newfoundland and labrador": "NL",68    "yukon": "YT", "northwest territories": "NT", "nunavut": "NU",69}7071# loyers non mensuels (rarissimes en résidentiel) : hors sujet72_NON_MONTHLY_RE = re.compile(r"/(week|night|dai|day|year|annum)", re.I)7374# « 600 sqft » ou « 500-599 sqft » — borne basse plausible seulement75_AREA_RE = re.compile(r"(\d[\d,]*)(?:\s*-\s*(\d[\d,]*))?\s*sqft", re.I)7677# types de bâtiment non résidentiels (filet — PropertySearchTypeId=1 est déjà78# résidentiel, mais quelques stationnements/locaux passent la maille)79_NON_RESIDENTIAL_RE = re.compile(80    r"\b(parking|locker|storage|office|retail|commercial|warehouse|"81    r"vacant land|land|farm|business|agriculture)\b", re.I)828384class RealtorCaConnector(BaseConnector):85    """REALTOR.ca — locations MLS des courtiers, Canada hors Québec."""8687    source_id = "realtor_ca"88    use_detail_cache = False    # tout vient des résultats de recherche89    workers = 6                 # requêtes Scrapfly simultanées90    max_requests = 900          # garde-fou budget Scrapfly par sync91    max_depth = 10              # profondeur de subdivision des quadrants92    max_images = 1              # les résultats n'exposent que la couverture9394    def fetch(self) -> list[Listing]:95        self._lock = threading.Lock()96        self._nreq = 097        self._failures = 098        seen: dict[str, Listing] = {}99        queue: list[tuple[tuple[float, float, float, float], int]] = \100            [(b, 0) for b in _SEED_BOXES]101        with ThreadPoolExecutor(max_workers=self.workers) as ex:102            while queue:103                wave, queue = queue[:24], queue[24:]104                probes = list(ex.map(105                    lambda bd: self._search(bd[0], 1), wave))106                page_jobs: list[tuple[tuple, int]] = []107                for (box, depth), data in zip(wave, probes):108                    paging = (data or {}).get("Paging") or {}109                    total = int(paging.get("TotalRecords") or 0)110                    if not total:111                        continue112                    if total > _MAX_RECORDS and depth < self.max_depth:113                        queue.extend((q, depth + 1)114                                     for q in _quadrants(box))115                        continue116                    self._collect(data, seen)117                    pages = min(int(paging.get("TotalPages") or 1), 3)118                    page_jobs += [(box, p) for p in range(2, pages + 1)]119                for d in ex.map(lambda bp: self._search(bp[0], bp[1]),120                                page_jobs):121                    self._collect(d, seen)122        # échec honnête : trop d'échecs = inventaire incomplet, on n'archive pas123        if self._failures > max(3, self._nreq // 20):124            raise RuntimeError(125                f"realtor_ca : {self._failures} échecs sur {self._nreq} "126                f"requêtes — sync abandonné (inventaire incomplet)")127        if self._nreq >= self.max_requests:128            raise RuntimeError(129                f"realtor_ca : garde-fou max_requests ({self.max_requests}) "130                f"atteint — couverture incomplète, sync abandonné")131        return list(seen.values())132133    # -- une requête de recherche (Scrapfly ASP, thread-safe) ------------------134    def _search(self, box: tuple, page: int) -> dict:135        with self._lock:136            if self._nreq >= self.max_requests:137                return {}138            self._nreq += 1139        key = os.environ.get("SCRAPFLY_KEY")140        if not key:141            raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")142        lat_min, lat_max, lng_min, lng_max = box143        body = urlencode({144            "ZoomLevel": "11",145            "LatitudeMin": f"{lat_min:.5f}", "LatitudeMax": f"{lat_max:.5f}",146            "LongitudeMin": f"{lng_min:.5f}", "LongitudeMax": f"{lng_max:.5f}",147            "Sort": "6-D", "PropertyTypeGroupID": "1",148            "TransactionTypeId": "3", "PropertySearchTypeId": "1",149            "Currency": "CAD", "IncludeHiddenListings": "false",150            "RecordsPerPage": "200", "ApplicationId": "1",151            "CultureId": "1", "Version": "7.0", "CurrentPage": str(page),152        })153        params = {154            "key": key, "url": _API, "asp": "true", "country": "ca",155            "proxy_pool": "public_residential_pool",156            "headers[Content-Type]":157                "application/x-www-form-urlencoded; charset=UTF-8",158            "headers[Referer]": "https://www.realtor.ca/",159            "headers[Origin]": "https://www.realtor.ca",160        }161        for attempt in range(3):162            try:163                resp = requests.post(SCRAPFLY_API, params=params, data=body,164                                     timeout=180)165                res = resp.json().get("result") or {}166                if (res.get("status_code") or 0) == 200:167                    return json.loads(res.get("content") or "{}")168            except (requests.RequestException, ValueError):169                pass170            if attempt < 2:171                time.sleep(4 * (attempt + 1))172        with self._lock:173            self._failures += 1174        return {}175176    # -- accumulation dédupliquée (les boîtes se chevauchent) ------------------177    def _collect(self, data: dict, seen: dict[str, Listing]) -> None:178        for r in (data or {}).get("Results") or []:179            try:180                lst = self._listing(r)181            except Exception:182                continue183            if lst is not None and lst.external_id not in seen:184                seen[lst.external_id] = lst185186    # -- un résultat de recherche -> Listing -----------------------------------187    def _listing(self, r: dict) -> Listing | None:188        prov = _PROVINCE_CODES.get(189            (r.get("ProvinceName") or "").strip().lower())190        if not prov:191            return None                       # Québec (ou inconnu) : hors champ192        prop = r.get("Property") or {}193        bld = r.get("Building") or {}194        btype = (bld.get("Type") or prop.get("Type") or "").strip()195        if _NON_RESIDENTIAL_RE.search(btype):196            return None197        rent = (prop.get("LeaseRent") or "").strip()198        if _NON_MONTHLY_RE.search(rent):199            return None                       # loyer non mensuel : hors sujet200201        pid = str(r.get("Id") or "").strip()202        mls = (r.get("MlsNumber") or "").strip()203        if not pid:204            return None205206        # adresse « unité - rue|Ville (Secteur), Province CodePostal »207        addr = prop.get("Address") or {}208        text = (addr.get("AddressText") or "").strip()209        street, _, tail = text.partition("|")210        city, sector = "", ""211        m = re.match(r"^([^(,]+?)\s*(?:\(([^)]+)\))?\s*,", tail)212        if m:213            city = m.group(1).strip()214            sector = (m.group(2) or "").strip()215        postal = (r.get("PostalCode") or "").strip()216        full_addr = ", ".join(x for x in (street.strip(), city) if x)217        if full_addr:218            full_addr += f", {prov} {postal}".rstrip()219220        try:221            lat = float(addr.get("Latitude") or "")222            lng = float(addr.get("Longitude") or "")223        except (TypeError, ValueError):224            lat = lng = None225226        # prix : valeur brute du flux (mensuelle)227        price = None228        try:229            v = float(str(prop.get("LeaseRentUnformattedValue") or "")230                      .replace(",", ""))231            if 200 <= v <= 100000:232                price = v233        except (TypeError, ValueError):234            pass235        price_label = rent.replace("/Monthly", "/mo") if rent else ""236237        # chambres « 2 + 1 » (TRREB : + den) -> 2 ; « 0 » -> studio238        bedrooms = None239        mb = re.match(r"\s*(\d+)", str(bld.get("Bedrooms") or ""))240        if mb:241            bedrooms = float(mb.group(1))242        bathrooms = None243        try:244            bathrooms = float(bld.get("BathroomTotal") or "")245        except (TypeError, ValueError):246            pass247        if bedrooms is not None:248            unit_type = "Studio" if bedrooms == 0 else \249                normalize_unit_type(f"{int(bedrooms)} bedrooms")250        else:251            unit_type = normalize_unit_type(btype)252253        # superficie : « 600 sqft » ou plage « 500-599 sqft » (borne basse)254        area = None255        for src in (bld.get("SizeInterior") or "",256                    " ".join((fm or {}).get("Area") or ""257                             for fm in bld.get("FloorAreaMeasurements")258                             or [])):259            ma = _AREA_RE.search(src)260            if ma:261                try:262                    v = float(ma.group(1).replace(",", ""))263                    if 80 <= v <= 20000:264                        area = v265                        break266                except ValueError:267                    pass268269        amenities = [a.strip() for a in270                     (bld.get("Ammenities") or "").split(",") if a.strip()]271272        # courtier + agence — la valeur ajoutée « annonce par courtier »273        details: dict = {"mls": mls} if mls else {}274        agents = []275        for ind in r.get("Individual") or []:276            nm = (ind.get("Name") or "").strip()277            if nm and nm not in agents:278                agents.append(nm)279            org = ind.get("Organization") or {}280            if org.get("Name") and "organization" not in details:281                details["organization"] = org["Name"].strip()282                for ph in org.get("Phones") or []:283                    if ph.get("PhoneNumber"):284                        details.setdefault("contact", {})["phone"] = \285                            f"{ph.get('AreaCode', '')}-{ph['PhoneNumber']}" \286                            .strip("-")287                        break288        if agents:289            details.setdefault("contact", {})["name"] = ", ".join(agents[:3])290        if prop.get("ParkingType"):291            details["parking"] = prop["ParkingType"]292        if prop.get("OwnershipType"):293            details["ownership"] = prop["OwnershipType"]294295        images = [p.get("HighResPath") or p.get("MedResPath") or ""296                  for p in prop.get("Photo") or []]297        images = [u for u in images if u][: self.max_images]298299        rel = r.get("RelativeDetailsURL") or r.get("RelativeURLEn") or ""300        title = street.strip() or text301        if unit_type and city:302            title = f"{title} — {city}"303304        return Listing(305            source=self.source_id,306            external_id=pid,307            url=f"https://www.realtor.ca{rel}" if rel else308                "https://www.realtor.ca",309            title=title,310            address=full_addr,311            sector=sector,312            city=city,313            province=prov,314            unit_type=unit_type,315            bedrooms=bedrooms,316            bathrooms=bathrooms,317            price=price,318            price_label=price_label,319            area_sqft=area,320            description=(r.get("PublicRemarks") or "").strip()[:900],321            amenities=amenities[:25],322            details=details,323            images=images,324            lat=lat,325            lng=lng,326        )327328329def _quadrants(box: tuple) -> list[tuple]:330    lat_min, lat_max, lng_min, lng_max = box331    lat_mid = (lat_min + lat_max) / 2332    lng_mid = (lng_min + lng_max) / 2333    return [334        (lat_min, lat_mid, lng_min, lng_mid),335        (lat_min, lat_mid, lng_mid, lng_max),336        (lat_mid, lat_max, lng_min, lng_mid),337        (lat_mid, lat_max, lng_mid, lng_max),338    ]339