SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.8 KB · 166 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/gesteco.py : connecteur Gesteco (gesteco.ca)5#   Condos locatifs neufs — Granby (Irwin Nord, Faubourg du Séminaire),6#   Waterloo (Sommets de l'Horizon), Bromont, Cowansville. Umbraco (.NET)7#   + app Vue `homeRentalBrowsingApp` : API JSON publique8#   `/umbraco/api/units?locale=fr&page=N&pageSize=100` (pagination par9#   en-têtes X-Total-Pages). Chaque unité expose id, adresse complète, ville,10#   prix (rawBasePrice), date de disponibilité ISO, superficie (livingArea),11#   chambres/sdb, configuration (« 4 ½ + bureau »), chiens permis, promotion,12#   description HTML, images et l'URL de la fiche (`rentalUrl`).13#   Filtres : status == "Available" ET buildingListingType == 1 (location —14#   le type 0 = achat, prix de vente à 6 chiffres, hors périmètre).15#   Aucune page détail nécessaire : l'API contient tout.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type24from .base import BaseConnector2526BASE = "https://gesteco.ca"27API_URL = f"{BASE}/umbraco/api/units"28PAGE_SIZE = 100293031def _strip_html(raw: str) -> str:32    """Description HTML (Word collé) -> texte lisible, longueur plafonnée."""33    if not raw:34        return ""35    txt = BeautifulSoup(raw, "html.parser").get_text(" ", strip=True)36    return re.sub(r"\s+", " ", txt).strip()[:1500]373839class GestecoConnector(BaseConnector):40    source_id = "gesteco"41    request_delay = 0.642    max_pages = 10           # garde-fou de pagination API4344    def fetch(self) -> list[Listing]:45        listings: dict[str, Listing] = {}46        for page in range(1, self.max_pages + 1):47            resp = self.get(API_URL, params={48                "locale": "fr", "page": page, "pageSize": PAGE_SIZE})49            units = resp.json()50            if not units:51                break52            for unit in units:53                try:54                    lst = self._parse_unit(unit)55                except Exception:56                    continue57                if lst and lst.external_id not in listings:58                    listings[lst.external_id] = lst59            # page incomplète = dernière page (l'en-tête X-Total-Pages n'est60            # pas rejouable par les fixtures — on s'appuie sur la taille)61            if len(units) < PAGE_SIZE:62                break63        return list(listings.values())6465    # -- unité (payload API) -----------------------------------------------------66    def _parse_unit(self, u: dict) -> Listing | None:67        # location seulement : type 1 = louer ; type 0 = achat (prix de vente)68        if u.get("status") != "Available" or u.get("buildingListingType") != 1:69            return None70        if u.get("isActive") is False:71            return None72        rental_url = u.get("rentalUrl") or ""73        if not rental_url:74            return None7576        address = (u.get("fullAddress") or "").strip()77        city = (u.get("city") or "").strip()7879        # type d'unité : configuration structurée (« 4 ½ + bureau »)80        configs = u.get("unitUnitConfigurations") or []81        raw_type = (configs[0].get("unitConfigurationName") or "") if configs else ""82        unit_type = normalize_unit_type(raw_type)8384        price = None85        price_label = ""86        raw_price = u.get("rawBasePrice")87        if isinstance(raw_price, (int, float)) and 100 <= raw_price <= 20000:88            price = float(raw_price)89            price_label = f"{raw_price:g} $ /mois"9091        # disponibilité ISO de l'API (« 2026-07-01T04:00:00 »)92        availability = (u.get("availabilityDate") or "").split("T")[0]9394        area = None95        living = u.get("livingArea")96        if isinstance(living, (int, float)) and 80 <= living <= 20000:97            area = float(living)9899        # commodités affichables issues des champs structurés de l'API100        amenities: list[str] = []101        if raw_type:102            amenities.append(raw_type)103        bedrooms = u.get("bedrooms")104        if isinstance(bedrooms, (int, float)) and bedrooms:105            amenities.append(f"{bedrooms:g} chambre(s)")106        bathrooms = u.get("bathrooms")107        if isinstance(bathrooms, (int, float)) and bathrooms:108            amenities.append(f"{bathrooms:g} salle(s) de bain")109        if u.get("floorName"):110            amenities.append(f"Étage : {u['floorName']}")111        if u.get("hasChargingStation"):112            amenities.append("Borne de recharge disponible")113114        # champs structurés fidèles (jamais déduits)115        details: dict = {}116        if u.get("dogAllowed") is not None:117            details["dog_allowed"] = bool(u["dogAllowed"])118        if u.get("projectName"):119            details["project"] = u["projectName"]120121        # description (HTML de l'API) + promotion affichée sur la fiche —122        # champs français natifs (les champs « localized » ressortent en anglais123        # malgré locale=fr)124        description = _strip_html(u.get("description")125                                  or u.get("localizedDescription") or "")126        promo = (u.get("promotions") or u.get("localizedPromotions") or "").strip()127        if promo:128            description = (f"Promotion : {promo}" +129                           (f" | {description}" if description else ""))[:1500]130131        # images : galerie de l'unité, photo de l'immeuble, plan de la config132        images: list[str] = []133        for img in (u.get("images") or []):134            src = img.get("url") if isinstance(img, dict) else img135            if isinstance(src, str) and src.startswith("http") and src not in images:136                images.append(src)137        for src in ([u.get("buildingImageUrl")] +138                    [c.get("planImageUrl") for c in configs]):139            if isinstance(src, str) and src.startswith("http") and src not in images:140                images.append(src)141142        street = (u.get("street") or u.get("localizedStreet") or "").strip()143        unit_no = (u.get("unitNumber") or "").strip()144        title = address.rsplit(",", 1)[0] if address else street145        if unit_no and street and unit_no not in title:146            title = f"{title}, unité {unit_no}"147148        return Listing(149            source=self.source_id,150            external_id=str(u["id"]),151            url=f"{BASE}{rental_url}",152            title=title,153            address=address,154            sector="",155            city=city,156            unit_type=unit_type,157            price=price,158            price_label=price_label,159            availability=availability,160            area_sqft=area,161            description=description,162            amenities=amenities,163            details=details,164            images=images[:30],165        )166