# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gesteco.py : connecteur Gesteco (gesteco.ca) # Condos locatifs neufs — Granby (Irwin Nord, Faubourg du Séminaire), # Waterloo (Sommets de l'Horizon), Bromont, Cowansville. Umbraco (.NET) # + app Vue `homeRentalBrowsingApp` : API JSON publique # `/umbraco/api/units?locale=fr&page=N&pageSize=100` (pagination par # en-têtes X-Total-Pages). Chaque unité expose id, adresse complète, ville, # prix (rawBasePrice), date de disponibilité ISO, superficie (livingArea), # chambres/sdb, configuration (« 4 ½ + bureau »), chiens permis, promotion, # description HTML, images et l'URL de la fiche (`rentalUrl`). # Filtres : status == "Available" ET buildingListingType == 1 (location — # le type 0 = achat, prix de vente à 6 chiffres, hors périmètre). # Aucune page détail nécessaire : l'API contient tout. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://gesteco.ca" API_URL = f"{BASE}/umbraco/api/units" PAGE_SIZE = 100 def _strip_html(raw: str) -> str: """Description HTML (Word collé) -> texte lisible, longueur plafonnée.""" if not raw: return "" txt = BeautifulSoup(raw, "html.parser").get_text(" ", strip=True) return re.sub(r"\s+", " ", txt).strip()[:1500] class GestecoConnector(BaseConnector): source_id = "gesteco" request_delay = 0.6 max_pages = 10 # garde-fou de pagination API def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for page in range(1, self.max_pages + 1): resp = self.get(API_URL, params={ "locale": "fr", "page": page, "pageSize": PAGE_SIZE}) units = resp.json() if not units: break for unit in units: try: lst = self._parse_unit(unit) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # page incomplète = dernière page (l'en-tête X-Total-Pages n'est # pas rejouable par les fixtures — on s'appuie sur la taille) if len(units) < PAGE_SIZE: break return list(listings.values()) # -- unité (payload API) ----------------------------------------------------- def _parse_unit(self, u: dict) -> Listing | None: # location seulement : type 1 = louer ; type 0 = achat (prix de vente) if u.get("status") != "Available" or u.get("buildingListingType") != 1: return None if u.get("isActive") is False: return None rental_url = u.get("rentalUrl") or "" if not rental_url: return None address = (u.get("fullAddress") or "").strip() city = (u.get("city") or "").strip() # type d'unité : configuration structurée (« 4 ½ + bureau ») configs = u.get("unitUnitConfigurations") or [] raw_type = (configs[0].get("unitConfigurationName") or "") if configs else "" unit_type = normalize_unit_type(raw_type) price = None price_label = "" raw_price = u.get("rawBasePrice") if isinstance(raw_price, (int, float)) and 100 <= raw_price <= 20000: price = float(raw_price) price_label = f"{raw_price:g} $ /mois" # disponibilité ISO de l'API (« 2026-07-01T04:00:00 ») availability = (u.get("availabilityDate") or "").split("T")[0] area = None living = u.get("livingArea") if isinstance(living, (int, float)) and 80 <= living <= 20000: area = float(living) # commodités affichables issues des champs structurés de l'API amenities: list[str] = [] if raw_type: amenities.append(raw_type) bedrooms = u.get("bedrooms") if isinstance(bedrooms, (int, float)) and bedrooms: amenities.append(f"{bedrooms:g} chambre(s)") bathrooms = u.get("bathrooms") if isinstance(bathrooms, (int, float)) and bathrooms: amenities.append(f"{bathrooms:g} salle(s) de bain") if u.get("floorName"): amenities.append(f"Étage : {u['floorName']}") if u.get("hasChargingStation"): amenities.append("Borne de recharge disponible") # champs structurés fidèles (jamais déduits) details: dict = {} if u.get("dogAllowed") is not None: details["dog_allowed"] = bool(u["dogAllowed"]) if u.get("projectName"): details["project"] = u["projectName"] # description (HTML de l'API) + promotion affichée sur la fiche — # champs français natifs (les champs « localized » ressortent en anglais # malgré locale=fr) description = _strip_html(u.get("description") or u.get("localizedDescription") or "") promo = (u.get("promotions") or u.get("localizedPromotions") or "").strip() if promo: description = (f"Promotion : {promo}" + (f" | {description}" if description else ""))[:1500] # images : galerie de l'unité, photo de l'immeuble, plan de la config images: list[str] = [] for img in (u.get("images") or []): src = img.get("url") if isinstance(img, dict) else img if isinstance(src, str) and src.startswith("http") and src not in images: images.append(src) for src in ([u.get("buildingImageUrl")] + [c.get("planImageUrl") for c in configs]): if isinstance(src, str) and src.startswith("http") and src not in images: images.append(src) street = (u.get("street") or u.get("localizedStreet") or "").strip() unit_no = (u.get("unitNumber") or "").strip() title = address.rsplit(",", 1)[0] if address else street if unit_no and street and unit_no not in title: title = f"{title}, unité {unit_no}" return Listing( source=self.source_id, external_id=str(u["id"]), url=f"{BASE}{rental_url}", title=title, address=address, sector="", city=city, unit_type=unit_type, price=price, price_label=price_label, availability=availability, area_sqft=area, description=description, amenities=amenities, details=details, images=images[:30], )