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/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