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/floria.py : connecteur Gestion Floria (gestionfloria.ca)5# Condos locatifs neufs à Sherbrooke, Magog (Mont-Orford) et Coaticook.6# Site WordPress (Avada) : chaque page /immeuble/<slug>/ embarque le module7# de plans interactifs Livya (app.livya.com, client « gestion-floria », un8# projet/entité par immeuble). Même patron que somex_saintnicolas/louis14 :9# la page Next.js du module est rendue serveur et son flux RSC contient10# l'inventaire JSON complet (numéro, statut, loyer, pièces, superficie,11# adresse, ville, GPS, photos, plans). Découverte des immeubles via la page12# /secteurs/. Certains immeubles partagent le même projet Livya (jumelés13# 52/58 Flanc-Sud, 753/765 Rachel-Hébert…) : dédoublonnage par unitId.14# Seules les unités AVAILABLE deviennent des annonces ; certains projets ne15# publient pas le loyer (rentalPrice = 0) -> prix absent, rien d'inventé.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from ..schema import Listing22from .base import BaseConnector23from .somex_saintnicolas import _livya_units, _unit_type2425BASE = "https://gestionfloria.ca"26SECTORS_URL = f"{BASE}/secteurs/"27LIVYA = "https://app.livya.com"2829_IMMEUBLE_RE = re.compile(r"https://gestionfloria\.ca/immeuble/[a-z0-9-]+/")30_MODULE_RE = re.compile(31 r'<div class="livya-module-container-plans"[^>]*'32 r'data-project="([^"]+)"[^>]*data-entity="([^"]+)"')33_CLIENT_RE = re.compile(r'data-client="([^"]+)"')343536class FloriaConnector(BaseConnector):37 source_id = "floria"38 request_delay = 0.739 max_buildings = 20 # garde-fou (12 immeubles publiés)4041 def fetch(self) -> list[Listing]:42 # 1) Découverte : pages immeuble listées sur /secteurs/43 sec = self.get(SECTORS_URL).text44 pages = sorted(set(_IMMEUBLE_RE.findall(sec)))4546 listings: dict[str, Listing] = {}47 for page_url in pages[: self.max_buildings]:48 try:49 html = self.get(page_url).text50 except Exception:51 continue52 m = _MODULE_RE.search(html)53 cm = _CLIENT_RE.search(html)54 if not (m and cm):55 continue # immeuble sans module Livya (ex. 2835 Mézy)56 project, entity, client = m.group(1), m.group(2), cm.group(1)57 try:58 livya_html = self.get(59 f"{LIVYA}/fr/{client}/projects/{project}/plans/{entity}"60 f"?noLayout=1").text61 except Exception:62 continue63 for u in _livya_units(livya_html):64 # dédoublonnage : les immeubles jumelés partagent le projet65 uid = str(u.get("unitId") or "")66 if not uid or uid in listings:67 continue68 if u.get("availability") != "AVAILABLE" or not u.get("rental", True):69 continue70 listings[uid] = self._listing(u, page_url)71 return list(listings.values())7273 # -- une annonce par unité disponible ------------------------------------------74 def _listing(self, u: dict, page_url: str) -> Listing:75 num = str(u.get("number") or "").strip()76 price = u.get("rentalPrice")77 price = float(price) if isinstance(price, (int, float)) and price > 0 else None78 area = u.get("unitSize")79 area = float(area) if isinstance(area, (int, float)) and area > 0 else None8081 street = (u.get("address") or "").strip()82 city = (u.get("city") or "").strip()83 postal = (u.get("postalCode") or "").strip()84 address = ", ".join(x for x in (street, postal) if x)8586 # description : étage, modèle, pièces, balcon, plan (champs structurés87 # du flux — le textmine central en tire les dérivés)88 desc: list[str] = []89 floor = str(u.get("floorNumber") or "").strip()90 if floor:91 desc.append(f"Étage {floor}")92 if u.get("typeName"):93 desc.append(f"Modèle {u['typeName']}")94 if u.get("roomsBed"):95 desc.append(f"{u['roomsBed']} chambre(s)")96 if u.get("roomsBath"):97 desc.append(f"{u['roomsBath']} salle(s) de bain")98 if u.get("balconySize"):99 desc.append(f"Balcon de {u['balconySize']} pi²")100 if u.get("floorPlanUrl"):101 desc.append(f"Plan : {u['floorPlanUrl']}")102103 images = [img.get("fullUrl") for img in (u.get("typeImages") or [])104 if isinstance(img, dict) and img.get("fullUrl")]105 if u.get("floorPlanImageUrl"):106 images.append(u["floorPlanImageUrl"])107108 future = u.get("futureAvailability")109 availability = str(future) if future else "Disponible"110111 details: dict = {}112 if floor.isdigit():113 details["floor"] = int(floor)114115 lat, lng = u.get("latitude"), u.get("longitude")116 unit_type = _unit_type(u.get("rooms"))117 title = f"{street} — unité {num}" if street else f"Unité {num}"118 if unit_type:119 title += f" ({unit_type})"120121 return Listing(122 source=self.source_id,123 external_id=str(u["unitId"]),124 url=page_url, # la page immeuble héberge le sélecteur125 title=title,126 address=address,127 sector="", # secteur non publié unité par unité128 city=city,129 unit_type=unit_type,130 price=price,131 price_label=f"{price:.0f} $ /mois" if price else "",132 availability=availability,133 area_sqft=area,134 description=" | ".join(desc),135 details=details,136 images=images[:12],137 lat=float(lat) if lat else None,138 lng=float(lng) if lng else None,139 )140