# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/wandji.py : connecteur Gestion Immobilière Wandji # (wandji-immobilier.com — Gatineau : Hull, Aylmer, Plateau, Buckingham, # Masson-Angers + Papineauville). Le site est une SPA Angular 2 (2016) vide # sans JavaScript, mais son backend expose une API JSON publique : # GET /api/buildings -> liste des logements NON LOUÉS (rented=false) avec # prix, adresse, secteur, ville, lat/lng, chambres, salles de bain, # disponibilité (ISO), descriptions FR/EN, inclusions/exclusions, # proximité et galerie (/api/buildings//picture/). # Une annonce = un document Mongo (_id stable = external_id). Les locaux # commerciaux (type COMMERCIAL / subType LOCAL) et les adresses hors Québec # (secteur Ottawa) sont exclus. Le suffixe « /mois » n'est affiché par le # site que si monthly=true : le price_label reproduit ce comportement. # ----------------------------------------------------------------------------- from __future__ import annotations import re from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://wandji-immobilier.com" API_URL = f"{BASE}/api/buildings" MAX_IMAGES = 20 # « Buckingham-gatineau », « Plateau-hull », « Masson-angers - gatineau », # « Gatineau gatineau » -> secteur sans le suffixe ville accolé par le site _SECTOR_SUFFIX = re.compile(r"[\s-]+(gatineau|hull|aylmer|montreal|ottawa)\s*$", re.I) # libellés français des booléens structurés de l'API (affichés par la SPA) _FLAGS = [ ("airConditioning", "Climatisation"), ("garage", "Garage"), ("outdoorParking", "Stationnement extérieur"), ("interiorStorage", "Rangement intérieur"), ("exteriorStorage", "Rangement extérieur"), ("basement", "Sous-sol"), ] _TYPE_FR = {"APARTMENT": "Appartement", "CONDO": "Condo", "HOUSE": "Maison", "COMMERCIAL": "Commercial"} def _fmt_price(value: float) -> str: """1725 -> « 1 725 $ » (format d'affichage québécois usuel).""" s = f"{value:,.0f}".replace(",", " ") return f"{s} $" class WandjiConnector(BaseConnector): source_id = "wandji" request_delay = 0.6 def fetch(self) -> list[Listing]: buildings = self.get(API_URL).json() listings: dict[str, Listing] = {} for b in buildings: try: lst = self._parse(b) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst return list(listings.values()) def _parse(self, b: dict) -> Listing | None: ext_id = str(b.get("_id") or "").strip() if not ext_id: return None # locaux commerciaux exclus (non résidentiel) if (b.get("type") or "").upper() == "COMMERCIAL" or \ (b.get("subType") or "").upper() == "LOCAL": return None if b.get("rented"): return None # déjà loué (défensif) city = (b.get("city") or "").strip() province = (b.get("province") or "").strip() if province and "qu" not in province.lower(): return None # hors Québec (parc Ottawa exclu) # « Hull (Gatineau) » -> ville Gatineau ; Hull passe en secteur sector_from_city = "" m = re.match(r"^(.*?)\s*\((.+)\)$", city) if m: sector_from_city, city = m.group(1).strip(), m.group(2).strip() if re.search(r"ottawa", city, re.I): return None sector_raw = (b.get("sector") or "").strip() sector = _SECTOR_SUFFIX.sub("", sector_raw).strip(" -") if sector.lower() == city.lower(): sector = "" # « Gatineau-gatineau » etc. if not sector: sector = sector_from_city sector = sector[:1].upper() + sector[1:] if sector else "" address = (b.get("address") or "").strip() price = b.get("price") if isinstance(b.get("price"), (int, float)) else None price_label = "" if price: price_label = _fmt_price(price) + (" / mois" if b.get("monthly") else "") # type d'unité : Maison explicite, sinon n chambres -> (n+2)½ ; # jamais deviné quand l'API ne donne rien btype = (b.get("type") or "").upper() rooms = b.get("rooms") if btype == "HOUSE": unit_type = "Maison" elif isinstance(rooms, int) and rooms > 0: unit_type = normalize_unit_type(f"{rooms} chambres") else: unit_type = "" # disponibilité ISO de l'API (« 2026-07-01T04:00:00.000Z ») avail_iso = (b.get("availability") or "")[:10] # description française d'abord, complétée des inclusions/proximité # rédigées par l'agence (exploitées par textmine) parts = [] desc = (b.get("descriptionFR") or b.get("description") or "").strip() if desc: parts.append(desc) if (b.get("inclusionFR") or "").strip(): parts.append(f"Inclus : {b['inclusionFR'].strip()}") if (b.get("exclusionFR") or "").strip(): parts.append(f"Non inclus : {b['exclusionFR'].strip()}") if (b.get("proximityFR") or "").strip(): parts.append(f"À proximité : {b['proximityFR'].strip()}") description = "\n".join(parts)[:2500] amenities = [label for key, label in _FLAGS if b.get(key)] details: dict = {} if isinstance(rooms, int) and rooms > 0: details["bedrooms"] = rooms baths = b.get("bathrooms") if isinstance(baths, (int, float)) and baths > 0: details["bathrooms"] = baths if b.get("subType"): details["subtype"] = b["subType"] if b.get("levels"): details["levels"] = b["levels"] if b.get("postalCode"): details["postal_code"] = str(b["postalCode"]).strip() # superficie : l'API contient surtout des valeurs sentinelles (0/1) — # ne garder que les valeurs plausibles area = b.get("area") area_sqft = float(area) if isinstance(area, (int, float)) and area >= 80 else None images = [f"{BASE}/api/buildings/{ext_id}/picture/{img}" for img in (b.get("images") or [])[:MAX_IMAGES] if img] lat = lng = None try: lat, lng = float(b.get("latitude")), float(b.get("longitude")) except (TypeError, ValueError): pass return Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}/proprietes#{ext_id}", title=address, address=address, sector=sector, city=city, unit_type=unit_type, price=float(price) if price else None, price_label=price_label, availability=avail_iso, availability_date=avail_iso or None, area_sqft=area_sqft, description=description, amenities=amenities, details=details, images=images, lat=lat, lng=lng, )