# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/proprio_direct.py : Proprio Direct (propriodirect.com) # L'inventaire (alimenté par le feed DDF de CREA) est servi par une API JSON # interne : POST https://propriodirect.com/fr/api/searchListings # Corps { "from": } ; 30 résultats/page, champ `total`. Chaque item # contient adresse, ville, région, prix, chambres, sdb, galerie photos et slug. # ----------------------------------------------------------------------------- from __future__ import annotations import os from .base import BaseConnector from . import _detailutil as du from ..schema import PropertyListing SITE = "https://propriodirect.com" SEARCH_URL = f"{SITE}/fr/api/searchListings" PAGE_SIZE = 30 MAX_PAGES = 300 DETAIL_LIMIT = int(os.environ.get("IMMOKA_PD_DETAIL_LIMIT", "400")) # tags source (feed DDF) -> caractéristiques lisibles _TAG_LABELS = { "car_garage": "Garage", "car_parking": "Stationnement", "car_foyer": "Foyer", "car_piscine": "Piscine", "car_climatisation": "Climatisation", "car_sous_sol": "Sous-sol", "car_bord_eau": "Bord de l'eau", "prox_park": "Parc à proximité", "prox_ecole_primaire": "École primaire", "prox_ecole_secondaire": "École secondaire", "prox_transport": "Transport en commun", "prox_garderie": "Garderie", "prox_piste_cyclable": "Piste cyclable", "prox_hopital": "Hôpital", "prox_autoroute": "Autoroute", "new_price": "Nouveau prix", } # genre (code CREA/DDF) -> vocabulaire brut (finalize() normalise) _GENRE = { "PP": "Maison", "ET": "Maison", "MA": "Maison", "PL": "Maison", "CO": "Condo", "AP": "Condo", "LO": "Condo", "DX": "Duplex", "TX": "Triplex", "QX": "Quadruplex", "MX": "Multiplex", "TE": "Terrain", "TR": "Terrain", "CH": "Chalet", "FE": "Fermette/Agricole", "FR": "Fermette/Agricole", "CM": "Commercial", "IN": "Commercial", } class ProprioDirectConnector(BaseConnector): source_id = "proprio_direct" request_delay = 0.4 use_detail_cache = False def _headers(self) -> dict: return { "Accept": "application/json", "Content-Type": "application/json", "Origin": SITE, "Referer": f"{SITE}/propriete-a-vendre/", } def fetch(self) -> list[PropertyListing]: out: list[PropertyListing] = [] seen: set[str] = set() offset = 0 for _ in range(MAX_PAGES): try: data = self.post(SEARCH_URL, headers=self._headers(), json={"from": offset, "includeGeoJson": False}).json() except Exception: break items = data.get("listings", []) if isinstance(data, dict) else [] if not items: break for it in items: lst = self._to_listing(it) if lst and lst.uid not in seen: seen.add(lst.uid) out.append(lst) total = data.get("total") or 0 offset += PAGE_SIZE if offset >= total: break # fiche détail : description (galerie déjà complète via l'API) du.enrich(self, out, DETAIL_LIMIT, _parse_pd_detail, key="v2") return out def _to_listing(self, it: dict) -> PropertyListing | None: rid = it.get("id") if not rid or it.get("sold") or not it.get("forSale", True): return None cents = it.get("priceInCents") price = (cents / 100.0) if isinstance(cents, (int, float)) and cents > 0 else None slug = it.get("slugURLFr") or it.get("slugURL") or "" url = f"{SITE}{slug}" if slug.startswith("/") else (slug or SITE) geo = it.get("geoLocation") or {} return PropertyListing( source=self.source_id, external_id=str(rid), url=url, title=it.get("addressLine", ""), address=it.get("addressLine", ""), sector=it.get("cityNeighbourhoodText", "") or "", city=it.get("cityName", ""), region=it.get("regionName", ""), property_type=_GENRE.get((it.get("genre") or "").upper(), it.get("genreName", "")), price=price, price_label=it.get("priceText", "") or "", bedrooms=_pos_int(it.get("numberOfRooms")), bathrooms=_pos_int(it.get("numberOfBathrooms")), mls=str(rid), images=list(it.get("photosOriginal") or it.get("photosSmall") or []), lat=geo.get("lat"), lng=geo.get("lon"), broker_name="Proprio Direct", features=[_TAG_LABELS[t] for t in (it.get("tags") or []) if t in _TAG_LABELS], details={"postal_code": it.get("postalCode", "")}, ) def _parse_pd_detail(html: str) -> dict: """Fiche Proprio Direct : description + caractéristiques Centris détaillées (photos déjà fournies par l'API).""" out: dict = {} desc = du.ld_description(html) if desc: out["description"] = desc det = du.centris_details(du.flatten(html)) if det: out.setdefault("details", {}).update(det) return out def _pos_int(v): try: n = int(v) return n if n > 0 else None except (TypeError, ValueError): return None