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/logis_experts.py : connecteur Logis-Experts (logis-experts.com)5# Gestionnaire-constructeur, centre-ville de Québec + Lévis (~190 immeubles).6# Site Laravel/Alpine avec API JSON propre :7# GET /api/search -> {"buildings": [{address{lat,lng,zipcode}, all_images,8# apartments: [{number, type, price, available_type}]}]}9# La page détail d'une unité (/immeuble/<handle>/appartement/<n>) ajoute10# superficie, date de disponibilité, frais d'énergie et commodités — visitée11# via le cache self.detail() (seulement si l'unité est nouvelle/modifiée).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from ..schema import Listing, infer_city18from .base import BaseConnector1920BASE = "https://logis-experts.com"21API_SEARCH = f"{BASE}/api/search"2223# Ville renvoyée par l'API (en minuscules) -> nom d'affichage24CITY_MAP = {25 "quebec": "Québec", "québec": "Québec", "levis": "Lévis", "lévis": "Lévis",26 "saint-augustin-de-desmaures": "Saint-Augustin-de-Desmaures",27}2829# « 3.5+office » -> « 3½ », « maisondeville2chambres » -> « Maison », etc.30_TYPE_DEMI_RE = re.compile(r"^(\d)\.5")3132_TAG_RE = re.compile(r"<[^>]+>")33_SEP = r"[§\s]+" # les balises deviennent « § » ; tolérer § et espaces mêlés34_AREA_RE = re.compile(rf"Superficie{_SEP}([\d\s]+?)\s*pi{_SEP}2", re.I)35_AVAIL_RE = re.compile(r"Disponible à partir du\s+([^§<]{3,40})", re.I)36_ENERGY_RE = re.compile(rf"Frais d'énergie{_SEP}([^§]{{3,60}})", re.I)37# sections « Services », « Commodités », « Télécommunications » : puces « - item »38_AMEN_ITEM_RE = re.compile(r"- ([^§]{2,60}?) §")394041def _unit_type(raw: str) -> str:42 s = (raw or "").strip().lower()43 m = _TYPE_DEMI_RE.match(s)44 if m:45 return f"{m.group(1)}½"46 if "maisondeville" in s:47 return "Maison"48 if "loft" in s:49 return "Loft"50 if "studio" in s or "bachelor" in s:51 return "Studio"52 if "chambre" in s:53 return "Chambre"54 return raw.strip()555657class _DetailBudgetExceeded(Exception):58 """Plafond de pages détail atteint pour cette sync (repris à la suivante)."""596061class LogisExpertsConnector(BaseConnector):62 source_id = "logis_experts"63 request_delay = 0.864 max_details = 150 # garde-fou : vraies visites détail par sync6566 def __init__(self) -> None:67 super().__init__()68 self._n_details = 06970 # -- page détail d'une unité ----------------------------------------------71 def _fetch_detail(self, url: str) -> dict:72 """Extrait superficie / disponibilité / énergie / commodités du HTML.7374 Seules les vraies requêtes comptent contre max_details : les unités75 déjà en cache (self.detail) ne passent pas par ici.76 """77 if self._n_details >= self.max_details:78 raise _DetailBudgetExceeded(url)79 self._n_details += 180 import html as _entities81 html = _entities.unescape(self.get(url).text)82 # texte aplati : les balises deviennent des « § » pour garder les83 # frontières de champs sans dépendre de la structure exacte84 flat = html.replace(" ", " ")85 flat = re.sub(r"\s+", " ", _TAG_RE.sub(" § ", flat))86 out: dict = {}87 m = _AREA_RE.search(flat)88 if m:89 try:90 out["area_sqft"] = float(m.group(1).replace(" ", ""))91 except ValueError:92 pass93 m = _AVAIL_RE.search(flat)94 if m:95 out["availability"] = m.group(1).strip(" .§")96 m = _ENERGY_RE.search(flat)97 if m:98 out["energy"] = m.group(1).strip(" .§") # ex. « Chauffé & Éclairé »99 # commodités : entre le premier titre de section et « Suivez-nous »100 start = None101 for titre in ("Services", "Commodités", "Télécommunications"):102 j = flat.find(titre)103 if j >= 0 and (start is None or j < start):104 start = j105 if start is not None:106 window = flat[start:start + 2500]107 fin = window.find("Suivez-nous")108 if fin > 0:109 window = window[:fin]110 items = [it.strip() for it in _AMEN_ITEM_RE.findall(window)]111 if items:112 out["amenities"] = list(dict.fromkeys(items))113 return out114115 def fetch(self) -> list[Listing]:116 data = self.get(API_SEARCH,117 headers={"X-Requested-With": "XMLHttpRequest"}).json()118 listings: list[Listing] = []119 self._n_details = 0120121 for b in data.get("buildings") or []:122 addr = b.get("address") or {}123 handle = b.get("handle") or ""124 images = []125 for img in b.get("all_images") or []:126 u = (img or {}).get("url")127 if u:128 images.append(u)129 city_raw = (addr.get("city") or "").strip().lower()130 city = CITY_MAP.get(city_raw, (addr.get("city") or "").title())131 sector = addr.get("district_name") or ""132 civic = addr.get("address") or ""133 zipcode = addr.get("zipcode") or ""134 full_addr = ", ".join(x for x in (civic, city, zipcode) if x)135 lat = addr.get("latitude")136 lng = addr.get("longitude")137 b_name = b.get("name") or civic138139 for a in b.get("apartments") or []:140 if (a.get("available_type") or "").upper() != "AVAILABLE":141 continue142 num = str(a.get("number") or "").strip()143 ext_id = str(a.get("id") or f"{handle}-{num}")144 url = f"{BASE}/immeuble/{handle}/appartement/{num}"145 price = a.get("price")146 price = float(price) if isinstance(price, (int, float)) and price else None147148 # page détail (superficie, dispo, énergie, commodités) — via149 # cache : re-visitée seulement si le contenu liste a changé150 key = f"{price}|{a.get('type')}|{a.get('available_type')}"151 try:152 info = self.detail(ext_id, key,153 lambda u=url: self._fetch_detail(u))154 except Exception: # budget épuisé ou page détail en erreur155 info = {}156157 amenities = list(info.get("amenities") or [])158 if info.get("energy"):159 amenities.insert(0, info["energy"])160161 listings.append(Listing(162 source=self.source_id,163 external_id=ext_id,164 url=url,165 title=f"{b_name} — app. {num}" if num else b_name,166 address=full_addr,167 sector=sector,168 city=infer_city(sector, default=city or "Québec"),169 unit_type=_unit_type(a.get("type") or ""),170 price=price,171 price_label=f"{price:.0f} $ /mois" if price else "",172 availability=info.get("availability", ""),173 area_sqft=info.get("area_sqft"),174 description="",175 amenities=amenities,176 images=images[:15],177 lat=float(lat) if lat else None,178 lng=float(lng) if lng else None,179 ))180 return listings181