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%
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