# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sourceimmo.py : base réutilisable pour les agences bâties sur la # plateforme « source.immo » (plugin WordPress source-immo). L'app AngularJS # appelle une API ASP.NET publique : # GET https://api-v1.source.immo/api/listing/view/{view}/{locale}/items # avec les en-têtes x-si-account / x-si-api / x-si-appId (exposés dans la # config publique {_configs.json} du site). Pagination par `next_token`. # Une sous-classe fournit account_id / api_key / app_id / view_id. # ----------------------------------------------------------------------------- from __future__ import annotations from .base import BaseConnector from ..schema import PropertyListing API_ROOT = "https://api-v1.source.immo/api" # Codes de catégorie source.immo -> vocabulaire brut (finalize() normalise) _CATEGORY = { "RESIDENTIAL": "Maison", "SINGLE-FAMILY": "Maison", "MULTI-FAMILY": "Multiplex", "CONDO": "Condo", "APARTMENT": "Condo", "LOT": "Terrain", "LAND": "Terrain", "FARM": "Fermette/Agricole", "COMMERCIAL": "Commercial", "INDUSTRIAL": "Commercial", } _SUBCATEGORY = { "2X": "Duplex", "3X": "Triplex", "4X": "Quadruplex", "5X": "Quintuplex", } class SourceImmoConnector(BaseConnector): """Base : définir source_id, account_id, api_key, app_id, view_id, site_url.""" account_id = "" api_key = "" app_id = "" view_id = "" site_url = "" locale = "fr" request_delay = 0.25 use_detail_cache = False max_pages = 400 # garde-fou (100 items/page) def _headers(self) -> dict: return { "x-si-account": self.account_id, "x-si-api": self.api_key, "x-si-appId": self.app_id, "Accept": "application/json", } def fetch(self) -> list[PropertyListing]: url = f"{API_ROOT}/listing/view/{self.view_id}/{self.locale}/items" out: list[PropertyListing] = [] params: dict = {} seen: set[str] = set() prev_token = None for _ in range(self.max_pages): try: data = self.get(url, headers=self._headers(), params=params).json() except Exception: break items = data.get("items", []) if isinstance(data, dict) else [] if not items: break before = len(out) for it in items: lst = self._to_listing(it) if lst and lst.uid not in seen: seen.add(lst.uid) out.append(lst) # Pagination source.immo : l'API attend `st` (search_token) + `nt` # (next_token) — c'est ce que fait l'app AngularJS. Avec le mauvais # nom de paramètre, l'API renvoie toujours la 1re page (faux plafond # de 100 items). Avec st+nt, on parcourt tout l'inventaire. meta = data.get("metadata") or {} token = meta.get("next_token") if not token or token == prev_token or len(out) == before: break prev_token = token params = {"st": meta.get("search_token") or "", "nt": token} return out # -- mapping --------------------------------------------------------------- def _to_listing(self, it: dict) -> PropertyListing | None: ref = it.get("ref_number") or it.get("id") if not ref or not it.get("for_sale_flag", True): return None if (it.get("status_code") or "").upper() not in ("", "AVAILABLE"): return None loc = it.get("location") or {} addr = loc.get("address") or {} street = " ".join(filter(None, [addr.get("street_number"), addr.get("street_name")])).strip() price = ((it.get("price") or {}).get("sell") or {}).get("amount") cat = (it.get("category_code") or "").upper() sub = (it.get("subcategory_code") or "").upper() prop_type = _SUBCATEGORY.get(sub) or _CATEGORY.get(cat, "") unit = it.get("main_unit") or {} building = it.get("building") or {} dim = building.get("dimension") or {} area = dim.get("area_sf") land = it.get("land") or {} lot = (land.get("dimension") or {}).get("area_sf") photo = it.get("photo_url") or "" images = [photo.replace("-sm.", "-lg.")] if photo else [] # caractéristiques disponibles dans l'item (aucune requête détail) details = {"postal_code": addr.get("postal_code", "")} b_ass = (building.get("assessment") or {}).get("amount") l_ass = (land.get("assessment") or {}).get("amount") if b_ass or l_ass: details["Évaluation municipale"] = ( f"{(b_ass or 0) + (l_ass or 0):,.0f} $".replace(",", " ")) if dim.get("area_sf"): details["Superficie du bâtiment (au sol)"] = f"{dim['area_sf']:,.0f} pi²".replace(",", " ") if unit.get("room_count"): details["Nombre de pièces"] = unit["room_count"] features = [] attrs = it.get("attributes") or {} if attrs.get("PARKING"): features.append("Stationnement") if it.get("virtual_tour_flag"): features.append("Visite virtuelle") if it.get("video_flag"): features.append("Vidéo") return PropertyListing( source=self.source_id, external_id=str(ref), url=self._listing_url(it), title=street, address=street, city="", # code ville source.immo non résolu ici sector="", region=str(loc.get("region_code") or ""), property_type=prop_type, price=float(price) if price is not None else None, price_label=(f"{float(price):,.0f} $".replace(",", " ") if price else ""), bedrooms=unit.get("bedroom_count"), bathrooms=unit.get("bathroom_count"), powder_rooms=unit.get("waterroom_count"), area_sqft=float(area) if area else None, lot_sqft=float(lot) if lot else None, year_built=building.get("construction_year"), mls=str(ref), lat=loc.get("latitude"), lng=loc.get("longitude"), images=images, features=features, details=details, ) def _listing_url(self, it: dict) -> str: # URL lisible non essentielle ; on pointe la recherche du site par défaut. return self.site_url