SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
7.2 KB · 184 lines python
Raw Blame History
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