SPB Git

spb/immo-ka Public

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

Python 66.4% TypeScript 19.9% CSS 13.2% HTML 0.5%
5.4 KB · 141 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/proprio_direct.py : Proprio Direct (propriodirect.com)5#   L'inventaire (alimenté par le feed DDF de CREA) est servi par une API JSON6#   interne : POST https://propriodirect.com/fr/api/searchListings7#   Corps { "from": <offset> } ; 30 résultats/page, champ `total`. Chaque item8#   contient adresse, ville, région, prix, chambres, sdb, galerie photos et slug.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import os1314from .base import BaseConnector15from . import _detailutil as du16from ..schema import PropertyListing1718SITE = "https://propriodirect.com"19SEARCH_URL = f"{SITE}/fr/api/searchListings"20PAGE_SIZE = 3021MAX_PAGES = 30022DETAIL_LIMIT = int(os.environ.get("IMMOKA_PD_DETAIL_LIMIT", "400"))2324# tags source (feed DDF) -> caractéristiques lisibles25_TAG_LABELS = {26    "car_garage": "Garage", "car_parking": "Stationnement", "car_foyer": "Foyer",27    "car_piscine": "Piscine", "car_climatisation": "Climatisation",28    "car_sous_sol": "Sous-sol", "car_bord_eau": "Bord de l'eau",29    "prox_park": "Parc à proximité", "prox_ecole_primaire": "École primaire",30    "prox_ecole_secondaire": "École secondaire", "prox_transport": "Transport en commun",31    "prox_garderie": "Garderie", "prox_piste_cyclable": "Piste cyclable",32    "prox_hopital": "Hôpital", "prox_autoroute": "Autoroute",33    "new_price": "Nouveau prix",34}3536# genre (code CREA/DDF) -> vocabulaire brut (finalize() normalise)37_GENRE = {38    "PP": "Maison", "ET": "Maison", "MA": "Maison", "PL": "Maison",39    "CO": "Condo", "AP": "Condo", "LO": "Condo",40    "DX": "Duplex", "TX": "Triplex", "QX": "Quadruplex", "MX": "Multiplex",41    "TE": "Terrain", "TR": "Terrain",42    "CH": "Chalet", "FE": "Fermette/Agricole", "FR": "Fermette/Agricole",43    "CM": "Commercial", "IN": "Commercial",44}454647class ProprioDirectConnector(BaseConnector):48    source_id = "proprio_direct"49    request_delay = 0.450    use_detail_cache = False5152    def _headers(self) -> dict:53        return {54            "Accept": "application/json",55            "Content-Type": "application/json",56            "Origin": SITE,57            "Referer": f"{SITE}/propriete-a-vendre/",58        }5960    def fetch(self) -> list[PropertyListing]:61        out: list[PropertyListing] = []62        seen: set[str] = set()63        offset = 064        for _ in range(MAX_PAGES):65            try:66                data = self.post(SEARCH_URL, headers=self._headers(),67                                 json={"from": offset,68                                       "includeGeoJson": False}).json()69            except Exception:70                break71            items = data.get("listings", []) if isinstance(data, dict) else []72            if not items:73                break74            for it in items:75                lst = self._to_listing(it)76                if lst and lst.uid not in seen:77                    seen.add(lst.uid)78                    out.append(lst)79            total = data.get("total") or 080            offset += PAGE_SIZE81            if offset >= total:82                break83        # fiche détail : description (galerie déjà complète via l'API)84        du.enrich(self, out, DETAIL_LIMIT, _parse_pd_detail, key="v2")85        return out8687    def _to_listing(self, it: dict) -> PropertyListing | None:88        rid = it.get("id")89        if not rid or it.get("sold") or not it.get("forSale", True):90            return None91        cents = it.get("priceInCents")92        price = (cents / 100.0) if isinstance(cents, (int, float)) and cents > 0 else None93        slug = it.get("slugURLFr") or it.get("slugURL") or ""94        url = f"{SITE}{slug}" if slug.startswith("/") else (slug or SITE)95        geo = it.get("geoLocation") or {}9697        return PropertyListing(98            source=self.source_id,99            external_id=str(rid),100            url=url,101            title=it.get("addressLine", ""),102            address=it.get("addressLine", ""),103            sector=it.get("cityNeighbourhoodText", "") or "",104            city=it.get("cityName", ""),105            region=it.get("regionName", ""),106            property_type=_GENRE.get((it.get("genre") or "").upper(),107                                     it.get("genreName", "")),108            price=price,109            price_label=it.get("priceText", "") or "",110            bedrooms=_pos_int(it.get("numberOfRooms")),111            bathrooms=_pos_int(it.get("numberOfBathrooms")),112            mls=str(rid),113            images=list(it.get("photosOriginal") or it.get("photosSmall") or []),114            lat=geo.get("lat"),115            lng=geo.get("lon"),116            broker_name="Proprio Direct",117            features=[_TAG_LABELS[t] for t in (it.get("tags") or []) if t in _TAG_LABELS],118            details={"postal_code": it.get("postalCode", "")},119        )120121122def _parse_pd_detail(html: str) -> dict:123    """Fiche Proprio Direct : description + caractéristiques Centris détaillées124    (photos déjà fournies par l'API)."""125    out: dict = {}126    desc = du.ld_description(html)127    if desc:128        out["description"] = desc129    det = du.centris_details(du.flatten(html))130    if det:131        out.setdefault("details", {}).update(det)132    return out133134135def _pos_int(v):136    try:137        n = int(v)138        return n if n > 0 else None139    except (TypeError, ValueError):140        return None141