spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/wandji.py : connecteur Gestion Immobilière Wandji5# (wandji-immobilier.com — Gatineau : Hull, Aylmer, Plateau, Buckingham,6# Masson-Angers + Papineauville). Le site est une SPA Angular 2 (2016) vide7# sans JavaScript, mais son backend expose une API JSON publique :8# GET /api/buildings -> liste des logements NON LOUÉS (rented=false) avec9# prix, adresse, secteur, ville, lat/lng, chambres, salles de bain,10# disponibilité (ISO), descriptions FR/EN, inclusions/exclusions,11# proximité et galerie (/api/buildings/<id>/picture/<fichier>).12# Une annonce = un document Mongo (_id stable = external_id). Les locaux13# commerciaux (type COMMERCIAL / subType LOCAL) et les adresses hors Québec14# (secteur Ottawa) sont exclus. Le suffixe « /mois » n'est affiché par le15# site que si monthly=true : le price_label reproduit ce comportement.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from ..schema import Listing, normalize_unit_type22from .base import BaseConnector2324BASE = "https://wandji-immobilier.com"25API_URL = f"{BASE}/api/buildings"2627MAX_IMAGES = 202829# « Buckingham-gatineau », « Plateau-hull », « Masson-angers - gatineau »,30# « Gatineau gatineau » -> secteur sans le suffixe ville accolé par le site31_SECTOR_SUFFIX = re.compile(r"[\s-]+(gatineau|hull|aylmer|montreal|ottawa)\s*$", re.I)3233# libellés français des booléens structurés de l'API (affichés par la SPA)34_FLAGS = [35 ("airConditioning", "Climatisation"),36 ("garage", "Garage"),37 ("outdoorParking", "Stationnement extérieur"),38 ("interiorStorage", "Rangement intérieur"),39 ("exteriorStorage", "Rangement extérieur"),40 ("basement", "Sous-sol"),41]4243_TYPE_FR = {"APARTMENT": "Appartement", "CONDO": "Condo",44 "HOUSE": "Maison", "COMMERCIAL": "Commercial"}454647def _fmt_price(value: float) -> str:48 """1725 -> « 1 725 $ » (format d'affichage québécois usuel)."""49 s = f"{value:,.0f}".replace(",", " ")50 return f"{s} $"515253class WandjiConnector(BaseConnector):54 source_id = "wandji"55 request_delay = 0.65657 def fetch(self) -> list[Listing]:58 buildings = self.get(API_URL).json()59 listings: dict[str, Listing] = {}60 for b in buildings:61 try:62 lst = self._parse(b)63 except Exception:64 continue65 if lst and lst.external_id not in listings:66 listings[lst.external_id] = lst67 return list(listings.values())6869 def _parse(self, b: dict) -> Listing | None:70 ext_id = str(b.get("_id") or "").strip()71 if not ext_id:72 return None73 # locaux commerciaux exclus (non résidentiel)74 if (b.get("type") or "").upper() == "COMMERCIAL" or \75 (b.get("subType") or "").upper() == "LOCAL":76 return None77 if b.get("rented"):78 return None # déjà loué (défensif)7980 city = (b.get("city") or "").strip()81 province = (b.get("province") or "").strip()82 if province and "qu" not in province.lower():83 return None # hors Québec (parc Ottawa exclu)84 # « Hull (Gatineau) » -> ville Gatineau ; Hull passe en secteur85 sector_from_city = ""86 m = re.match(r"^(.*?)\s*\((.+)\)$", city)87 if m:88 sector_from_city, city = m.group(1).strip(), m.group(2).strip()89 if re.search(r"ottawa", city, re.I):90 return None9192 sector_raw = (b.get("sector") or "").strip()93 sector = _SECTOR_SUFFIX.sub("", sector_raw).strip(" -")94 if sector.lower() == city.lower():95 sector = "" # « Gatineau-gatineau » etc.96 if not sector:97 sector = sector_from_city98 sector = sector[:1].upper() + sector[1:] if sector else ""99100 address = (b.get("address") or "").strip()101 price = b.get("price") if isinstance(b.get("price"), (int, float)) else None102 price_label = ""103 if price:104 price_label = _fmt_price(price) + (" / mois" if b.get("monthly") else "")105106 # type d'unité : Maison explicite, sinon n chambres -> (n+2)½ ;107 # jamais deviné quand l'API ne donne rien108 btype = (b.get("type") or "").upper()109 rooms = b.get("rooms")110 if btype == "HOUSE":111 unit_type = "Maison"112 elif isinstance(rooms, int) and rooms > 0:113 unit_type = normalize_unit_type(f"{rooms} chambres")114 else:115 unit_type = ""116117 # disponibilité ISO de l'API (« 2026-07-01T04:00:00.000Z »)118 avail_iso = (b.get("availability") or "")[:10]119120 # description française d'abord, complétée des inclusions/proximité121 # rédigées par l'agence (exploitées par textmine)122 parts = []123 desc = (b.get("descriptionFR") or b.get("description") or "").strip()124 if desc:125 parts.append(desc)126 if (b.get("inclusionFR") or "").strip():127 parts.append(f"Inclus : {b['inclusionFR'].strip()}")128 if (b.get("exclusionFR") or "").strip():129 parts.append(f"Non inclus : {b['exclusionFR'].strip()}")130 if (b.get("proximityFR") or "").strip():131 parts.append(f"À proximité : {b['proximityFR'].strip()}")132 description = "\n".join(parts)[:2500]133134 amenities = [label for key, label in _FLAGS if b.get(key)]135136 details: dict = {}137 if isinstance(rooms, int) and rooms > 0:138 details["bedrooms"] = rooms139 baths = b.get("bathrooms")140 if isinstance(baths, (int, float)) and baths > 0:141 details["bathrooms"] = baths142 if b.get("subType"):143 details["subtype"] = b["subType"]144 if b.get("levels"):145 details["levels"] = b["levels"]146 if b.get("postalCode"):147 details["postal_code"] = str(b["postalCode"]).strip()148149 # superficie : l'API contient surtout des valeurs sentinelles (0/1) —150 # ne garder que les valeurs plausibles151 area = b.get("area")152 area_sqft = float(area) if isinstance(area, (int, float)) and area >= 80 else None153154 images = [f"{BASE}/api/buildings/{ext_id}/picture/{img}"155 for img in (b.get("images") or [])[:MAX_IMAGES] if img]156157 lat = lng = None158 try:159 lat, lng = float(b.get("latitude")), float(b.get("longitude"))160 except (TypeError, ValueError):161 pass162163 return Listing(164 source=self.source_id,165 external_id=ext_id,166 url=f"{BASE}/proprietes#{ext_id}",167 title=address,168 address=address,169 sector=sector,170 city=city,171 unit_type=unit_type,172 price=float(price) if price else None,173 price_label=price_label,174 availability=avail_iso,175 availability_date=avail_iso or None,176 area_sqft=area_sqft,177 description=description,178 amenities=amenities,179 details=details,180 images=images,181 lat=lat,182 lng=lng,183 )184