SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
16.4 KB · 376 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/realtor_ca.py : Realtor.ca (couverture MLS pan-bannières, QC)5#   API interne https://api2.realtor.ca/Listing.svc/PropertySearch_Post (POST6#   form). Protégée (DataDome/Incapsula) : l'accès direct renvoie 403, mais7#   l'API se REJOUE via Scrapfly ASP SANS render_js (1 crédit-appel ASP, pas de8#   navigateur) → JSON complet : n° MLS, GPS, prix, CAC/SDB, superficie, photos,9#   courtier + agence. Vérifié 2026-08-18 : 200 fiches/page, plafond serveur10#   600 fiches (MaxRecords) par recherche → SHARDING par boîtes lat/lng11#   couvrant le Québec, tri « plus récentes d'abord » (Sort=6-D).12#13#   BUDGET BORNÉ : IMMOKA_REALTOR_MAX fiches max par sync (défaut 2 000, soit14#   ~10 appels Scrapfly), réparties en balayant la page 1 de chaque shard puis15#   les pages 2-3 si le budget le permet. Les fiches plus anciennes sortent de16#   la fenêtre au fil des syncs (miss_count → active=0) : le connecteur vaut17#   surtout pour les inscriptions récentes des bannières non couvertes.18#   Mettre IMMOKA_REALTOR_MAX=0 pour désactiver le connecteur.19#20#   source_id « realtor_ag_ca » : l'infixe _ag_ + external_id = n° MLS Centris21#   activent la dédup existante — toute fiche déjà couverte par un connecteur22#   direct (RE/MAX, Royal LePage, Sutton…) est masquée, seules les fiches23#   UNIQUES (petites bannières sans connecteur) restent visibles.24#25#   ENRICHISSEMENT DÉTAIL (2026-08-18) : la réponse PropertySearch_Post ne26#   contient qu'UNE photo et un PublicRemarks VIDE (vérifié) — la galerie27#   complète, la description et les caractéristiques Building/Land viennent de28#   l'API Listing.svc/PropertyDetails (GET, mêmes protections → Scrapfly ASP29#   sans render_js, 1 crédit-appel), rejouée avec CultureId=2 (français) via30#   ReferenceNumber=<MLS> + PropertyID=<Id interne>. Cache BD detail_cache31#   (_detailutil.enrich) : chaque fiche n'est détaillée qu'UNE fois ; budget32#   IMMOKA_REALTOR_DETAIL_LIMIT appels/cycle (défaut 100, override ponctuel33#   IMMOKA_DETAIL_LIMIT) → rattrapage progressif du stock, nouveautés ensuite.34# -----------------------------------------------------------------------------35from __future__ import annotations3637import json38import os39import time4041import requests4243from . import _detailutil as du44from .base import BaseConnector45from ..schema import PropertyListing4647SEARCH_URL = "https://api2.realtor.ca/Listing.svc/PropertySearch_Post"48DETAIL_URL = "https://api2.realtor.ca/Listing.svc/PropertyDetails"49SITE = "https://www.realtor.ca"5051MAX_RECORDS = int(os.environ.get("IMMOKA_REALTOR_MAX", "2000"))52DETAIL_LIMIT = int(os.environ.get("IMMOKA_DETAIL_LIMIT")53                   or os.environ.get("IMMOKA_REALTOR_DETAIL_LIMIT", "100"))54PER_PAGE = 200            # maximum accepté par l'API55MAX_PAGES_PER_SHARD = 3   # plafond serveur : MaxRecords=600 par recherche5657# Boîtes (sud, ouest, nord, est) couvrant le Québec habité, par densité58# décroissante — le budget est d'abord dépensé sur les marchés actifs.59SHARDS: list[tuple[str, float, float, float, float]] = [60    ("montreal-laval",       45.35, -74.05, 45.75, -73.30),61    ("monteregie",           45.00, -74.40, 45.35, -72.80),62    ("rive-sud-est",         45.35, -73.30, 45.75, -72.80),63    ("laurentides-lanaudiere", 45.75, -74.80, 46.40, -73.20),64    ("quebec-metro",         46.55, -71.65, 47.10, -70.90),65    ("chaudiere-appalaches", 46.00, -71.80, 46.85, -70.00),66    ("estrie",               45.00, -72.80, 45.90, -71.50),67    ("mauricie-cdq",         45.90, -73.20, 46.90, -71.80),68    ("outaouais",            45.30, -77.60, 46.50, -74.80),69    ("saguenay-lac-st-jean", 48.00, -72.60, 48.80, -70.70),70    ("bas-st-laurent-gaspesie", 47.20, -70.60, 49.40, -64.00),71    ("charlevoix-cote-nord", 47.00, -71.00, 50.40, -65.90),72    ("abitibi-temiscamingue", 47.20, -79.60, 48.90, -77.40),73    ("hautes-laurentides",   46.40, -76.00, 47.20, -74.20),74]757677class RealtorCaConnector(BaseConnector):78    source_id = "realtor_ag_ca"79    request_delay = 0.2      # Scrapfly gère la politesse côté cible80    use_detail_cache = True  # galerie/description via PropertyDetails, cachées8182    def __init__(self) -> None:83        super().__init__()84        # external_id -> Id interne Realtor (PropertyID requis par l'API détail)85        self._prop_ids: dict[str, str] = {}8687    def scrapfly(self, url: str, **kw) -> dict:88        # Coupures réseau transitoires vers api.scrapfly.io (ConnectTimeout89        # 180 s observé 2×, 2026-09-13) : sans retentative, un seul raté tue90        # les ~11 appels du sync → 0 fiche. On retente avec pause avant91        # de laisser l'exception remonter.92        last: Exception | None = None93        for attempt in range(3):94            try:95                return super().scrapfly(url, **kw)96            except (requests.ConnectionError, requests.Timeout) as exc:97                last = exc98                if attempt < 2:99                    time.sleep(20 * (attempt + 1))100        raise last  # type: ignore[misc]101102    def _search(self, shard: tuple, page: int) -> dict:103        _, south, west, north, east = shard104        body = "&".join(f"{k}={v}" for k, v in {105            "ZoomLevel": 10,106            "LatitudeMax": north, "LongitudeMax": east,107            "LatitudeMin": south, "LongitudeMin": west,108            "Sort": "6-D",                # inscription la plus récente d'abord109            "PropertyTypeGroupID": 1,     # résidentiel110            "TransactionTypeId": 2,       # à vendre111            "PropertySearchTypeId": 0,112            "Currency": "CAD",113            "RecordsPerPage": PER_PAGE,114            "CurrentPage": page,115            "CultureId": 1,116            "ApplicationId": 1,117            "PropertyStatusId": 1,118            "Version": "7.0",119        }.items())120        result = self.scrapfly(121            SEARCH_URL, render_js=False, asp=True, method="POST", body=body,122            headers={123                "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",124                "Referer": "https://www.realtor.ca/",125                "Origin": "https://www.realtor.ca",126            })127        try:128            return json.loads(result.get("content") or "")129        except ValueError:130            return {}131132    def fetch(self) -> list[PropertyListing]:133        if MAX_RECORDS <= 0:134            return []135        by_id: dict[str, PropertyListing] = {}136        exhausted: set[str] = set()137        for page in range(1, MAX_PAGES_PER_SHARD + 1):138            for shard in SHARDS:139                name = shard[0]140                if name in exhausted or len(by_id) >= MAX_RECORDS:141                    continue142                data = self._search(shard, page)143                results = data.get("Results") or []144                if len(results) < PER_PAGE:145                    exhausted.add(name)146                if not results:147                    continue148                for item in results:149                    lst = self._to_listing(item)150                    if lst and lst.external_id not in by_id:151                        by_id[lst.external_id] = lst152            if len(by_id) >= MAX_RECORDS:153                break154        listings = list(by_id.values())155        self._enrich(listings)156        return listings157158    # -- détail : galerie complète + description + caractéristiques -----------159    def _fetch_detail(self, mls: str, pid: str) -> str:160        """JSON brut de l'API PropertyDetails (CultureId=2 → contenu français)."""161        result = self.scrapfly(162            f"{DETAIL_URL}?ReferenceNumber={mls}&PropertyID={pid}"163            "&ApplicationId=1&CultureId=2&PreferedMeasurementUnit=1",164            render_js=False, asp=True,165            headers={"Referer": "https://www.realtor.ca/",166                     "Origin": "https://www.realtor.ca"})167        if result.get("status_code") != 200:168            raise RuntimeError(f"PropertyDetails {mls}: "169                               f"HTTP {result.get('status_code')}")170        return result.get("content") or ""171172    def _enrich(self, listings: list[PropertyListing]) -> None:173        """Complète chaque fiche via PropertyDetails, avec cache BD + budget.174175        Contrairement à _detailutil.enrich, un échec (challenge ASP, JSON176        invalide) n'est JAMAIS mis en cache : la fiche est retentée au177        prochain cycle. Une fiche détaillée avec succès ne coûte plus rien.178        """179        if DETAIL_LIMIT <= 0:180            return181        from .. import db182        con = db.connect()183        budget = DETAIL_LIMIT184        try:185            for lst in listings:186                payload = db.get_cached_detail(con, self.source_id,187                                               lst.external_id, "v1")188                if payload is None:189                    pid = self._prop_ids.get(lst.external_id, "")190                    mls = lst.mls or lst.external_id191                    if budget <= 0 or not pid or not mls:192                        continue193                    budget -= 1194                    try:195                        payload = _parse_detail(self._fetch_detail(mls, pid))196                    except Exception:197                        payload = {}198                    if not payload:199                        continue     # échec transitoire : nouvel essai plus tard200                    db.put_cached_detail(con, self.source_id,201                                         lst.external_id, "v1", payload)202                du.apply_detail(lst, payload)203        finally:204            con.close()205206    def _to_listing(self, item: dict) -> PropertyListing | None:207        prop = item.get("Property") or {}208        addr = prop.get("Address") or {}209        text = addr.get("AddressText") or ""210        # « 680 Rue De Courcelle|#613|Montréal (Le Sud-Ouest), Quebec H4C0B8 »211        if ", Quebec" not in text and ", Québec" not in text:212            return None      # les shards frontaliers débordent (Ottawa, N.-B.)213        parts = [p.strip() for p in text.split("|")]214        locality = parts[-1] if parts else ""215        street = ", ".join(parts[:-1]) if len(parts) > 1 else ""216        city = sector = ""217        loc = locality.split(", Quebec")[0].split(", Québec")[0]218        if "(" in loc:219            city, _, rest = loc.partition("(")220            city, sector = city.strip(), rest.rstrip(")").strip()221        else:222            city = loc.strip()223        mls = str(item.get("MlsNumber") or "").strip()224        rid = str(item.get("Id") or "").strip()225        if not (mls or rid) or not street:226            return None227        try:228            price = float(prop.get("PriceUnformattedValue"))229        except (TypeError, ValueError):230            price = None231        building = item.get("Building") or {}232        lst = PropertyListing(233            source=self.source_id,234            external_id=mls or rid,235            url=f"{SITE}{item.get('RelativeDetailsURL') or ''}",236            title=f"{building.get('Type') or prop.get('Type') or ''} — {city}".strip(" —"),237            address=street,238            sector=sector,239            city=city,240            property_type=building.get("Type") or prop.get("Type") or "",241            price=price,242            price_label=prop.get("Price") or "",243            mls=mls,244            description=(item.get("PublicRemarks") or "").strip(),245        )246        try:247            lst.lat = float(addr.get("Latitude"))248            lst.lng = float(addr.get("Longitude"))249        except (TypeError, ValueError):250            pass251        beds = str(building.get("Bedrooms") or "")252        if beds:253            try:  # « 3 + 1 » → 4254                lst.bedrooms = sum(int(x) for x in beds.replace(" ", "").split("+") if x)255            except ValueError:256                pass257        try:258            lst.bathrooms = int(building.get("BathroomTotal"))259        except (TypeError, ValueError):260            pass261        try:262            lst.powder_rooms = int(building.get("HalfBathTotal"))263        except (TypeError, ValueError):264            pass265        size = building.get("SizeInterior") or ""266        if size:267            lst.details["Superficie habitable"] = size268        land = (item.get("Land") or {}).get("SizeTotal") or ""269        if land:270            lst.details["Superficie du terrain"] = land271        alt = (item.get("AlternateURL") or {}).get("DetailsLink") or ""272        if alt:273            lst.details["Annonce originale"] = alt274        lst.images = [p.get("HighResPath") or p.get("MedResPath")275                      for p in (prop.get("Photo") or []) if isinstance(p, dict)]276        lst.images = [u for u in lst.images if u]277        ind = (item.get("Individual") or [{}])[0]278        lst.broker_name = ind.get("Name") or ""279        org = ind.get("Organization") or {}280        lst.agency = org.get("Name") or ""281        phones = ind.get("Phones") or []282        if phones:283            p0 = phones[0]284            lst.broker_phone = f"{p0.get('AreaCode', '')} {p0.get('PhoneNumber', '')}".strip()285        if rid:  # Id interne Realtor, requis par l'API PropertyDetails286            self._prop_ids[lst.external_id] = rid287        return lst288289290# libellés français des caractéristiques structurées de PropertyDetails291_DETAIL_LABELS = [292    # (section, clé API, libellé Immo-Ka)293    ("Building", "ConstructedDate", "Année de construction"),294    ("Building", "SizeInterior", "Superficie habitable"),295    ("Building", "StoriesTotal", "Nombre d'étages"),296    ("Building", "ConstructionStyleAttachment", "Type de bâtiment"),297    ("Building", "HeatingType", "Système de chauffage"),298    ("Building", "HeatingFuel", "Énergie pour le chauffage"),299    ("Building", "CoolingType", "Climatisation"),300    ("Building", "ExteriorFinish", "Revêtement"),301    ("Building", "RoofMaterial", "Toiture"),302    ("Building", "FoundationType", "Fondations"),303    ("Building", "BasementType", "Sous-sol"),304    ("Building", "FireplaceTotal", "Foyers"),305    ("Building", "Water", "Approvisionnement en eau"),306    ("Land", "SizeTotal", "Superficie du terrain"),307    ("Land", "Sewer", "Système d'égouts"),308    ("Land", "LandscapeFeatures", "Aménagement paysager"),309    ("Property", "PoolType", "Piscine"),310    ("Property", "ZoningType", "Zonage"),311    ("Property", "ParkingSpaceTotal", "Stationnement (total)"),312    ("Property", "OwnershipType", "Type de copropriété"),313    ("Property", "TaxTotal", "Taxes annuelles"),314]315316317def _parse_detail(content: str) -> dict:318    """Réponse PropertyDetails (JSON) → payload compatible du.apply_detail."""319    try:320        d = json.loads(content or "")321    except ValueError:322        return {}323    if not isinstance(d, dict) or not d.get("Id"):324        return {}325    prop = d.get("Property") or {}326    sections = {"Property": prop, "Building": d.get("Building") or {},327                "Land": d.get("Land") or {}}328    out: dict = {}329    images = [p.get("HighResPath") or p.get("MedResPath")330              for p in (prop.get("Photo") or []) if isinstance(p, dict)]331    images = [u for u in images if u]332    if images:333        out["images"] = images334    desc = (d.get("PublicRemarks") or "").strip()335    if desc:336        out["description"] = desc337    details: dict = {}338    for section, key, label in _DETAIL_LABELS:339        val = sections[section].get(key)340        if val not in (None, "", 0, "0"):341            details[label] = str(val)342    parking = [p.get("Name") for p in (prop.get("Parking") or [])343               if isinstance(p, dict) and p.get("Name")]344    if parking:345        details["Stationnement"] = ", ".join(dict.fromkeys(parking))346    rooms = (sections["Building"].get("Room") or [])347    if rooms:348        details["Nombre de pièces"] = str(len(rooms))349    alt = (d.get("AlternateURL") or {}).get("DetailsLink") or ""350    if alt:351        details["Annonce originale"] = alt352    if details:353        out["details"] = details354    feats: list[str] = []355    for blob in (prop.get("Features"), prop.get("AmmenitiesNearBy")):356        for f in (blob or "").split(","):357            if f.strip():358                feats.append(f.strip())359    if feats:360        out["features"] = feats361    b = sections["Building"]362    for src, dst in (("ConstructedDate", "year_built"),363                     ("HalfBathTotal", "powder_rooms")):364        v = du._int(b.get(src))365        if v:366            out[dst] = v367    ind = (d.get("Individual") or [{}])[0]368    if ind.get("Name"):369        out["broker_name"] = ind["Name"]370    for p in ind.get("Phones") or []:371        if p.get("PhoneNumber"):372            out["broker_phone"] = (f"{p.get('AreaCode', '')} "373                                   f"{p['PhoneNumber']}").strip()374            break375    return out376