# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/logiluxx.py : connecteur Logiluxx / Investissements Cleary # (logiluxx.com — habitations locatives tout inclus LEED, 50 ans et mieux). # Trois immeubles, chacun avec son mini-site statique (HTML iso-8859-1) : # St-Hubert I et II (Faubourg Cousineau, Longueuil) et Boucherville. # Les sites St-Hubert affichent une table « PRIX » par typologie # (superficie @ superficie, loyer « à partir de X$ ») ; Boucherville n'a # que les plans (superficies) et fonctionne par liste d'attente. # Granularité : une annonce par immeuble × typologie. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector # (id, base URL, nom, secteur, ville) — les mini-sites existent aussi en # sous-domaines (.logiluxx.com) mais leur certificat TLS est invalide : # on passe par les miroirs sur le domaine principal. BUILDINGS = [ ("sthubert", "https://logiluxx.com/sthubert/", "Logiluxx St-Hubert I", "Saint-Hubert", "Longueuil"), ("sthubert2", "https://logiluxx.com/sthubert2/", "Logiluxx St-Hubert II", "Saint-Hubert", "Longueuil"), ("boucherville", "https://logiluxx.com/boucherville/", "Logiluxx Boucherville", "", "Boucherville"), ] # ligne de la table PRIX : « 3 1/2 817 @ 1056 160 977 @ 1216 à partir de 1941$ » PRICE_ROW_RE = re.compile( r"(\d)\s*1/2\s+([\d]+(?:\s*@\s*\d+)?)\s+(\d+)\s+[\d]+(?:\s*@\s*\d+)?\s+" r"[àa] partir de\s*([\d\s,]{3,9})\s*\$", re.I) # ligne d'un plan : « 3 1/2 770 140 910 » (superficie, balcon, totale) PLAN_ROW_RE = re.compile(r"(\d)\s*1/2\s+(\d{3,4})\s+(\d{2,3})\s+(\d{3,4})") # typologies annoncées (« LA SUITE 3 1/2 », « LES PENTHOUSES 5 1/2 ») SUITE_RE = re.compile(r"(?:SUITES?|PENTHOUSES?)\s+(\d)\s*1/2", re.I) IMG_RE = re.compile(r'src="(images/projets/[^"]+\.(?:jpe?g|png))"', re.I) INCLUSION_RE = re.compile(r"^-\s*(.{4,90})$") PHONE_RE = re.compile(r"(\d{3})[.\s](\d{3})[.\s](\d{4})") PLAN_PAGE_OF = {"3": "plan3demi.html", "4": "plan4demi.html", "5": "plan5demi.html"} class LogiluxxConnector(BaseConnector): source_id = "logiluxx" request_delay = 0.8 def _get_text(self, url: str) -> tuple[str, str]: """(html, texte aplati) — les mini-sites sont encodés iso-8859-1 (mais un fetch de secours via proxy peut renvoyer de l'UTF-8).""" resp = self.get(url) try: html = resp.content.decode("utf-8") except UnicodeDecodeError: html = resp.content.decode("iso-8859-1", errors="replace") soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style", "noscript"]): tag.decompose() return html, re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) def fetch(self) -> list[Listing]: # échec HONNÊTE : une erreur réseau/parse sur un immeuble doit lever # (ok=0 dans sync_log) et non être avalée — un « 0 trouvé ok » déclenche # la dérive et menace les 9 annonces (vu le 2026-09-07 12:10) listings: list[Listing] = [] for bid, base, name, sector, city in BUILDINGS: found = self._fetch_building(bid, base, name, sector, city) if not found: raise RuntimeError( f"logiluxx : aucune typologie parsée sur {base} " "(rendu partiel ou site restructuré)") listings.extend(found) return listings def _fetch_building(self, bid: str, base: str, name: str, sector: str, city: str) -> list[Listing]: html, text = self._get_text(base) # inclusions (« - Chauffage, électricité et eau chaude », …) amenities = ["Formule tout inclus (chauffage, électricité, eau chaude)", "5 électroménagers en acier inoxydable", "Climatisation", "Immeuble LEED", "Pour les 50 ans et mieux"] for kw, label in [ (r"[Pp]iscine", "Piscine chauffée"), (r"entra[îi]nement", "Centre d'entraînement"), (r"[Aa]scenseur", "Ascenseurs"), (r"[Cc][âa]blodistribution", "Câblodistribution (base) incluse"), ]: if re.search(kw, text): amenities.append(label) contact: dict = {} m = PHONE_RE.search(text) if m: contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" m = re.search(r"[\w.\-]+@logiluxx\.com", text) if m: contact["email"] = m.group(0).lower() images = [base + u for u in dict.fromkeys(IMG_RE.findall(html)) if "logo" not in u.lower()][:12] waitlist = bool(re.search(r"LISTE D.ATTENTE", text, re.I)) availability = "Liste d'attente" if waitlist else "" out: list[Listing] = [] seen: set[str] = set() # 1) table PRIX (St-Hubert) : typologie + superficies + loyer plancher for n, area_rng, balcon, price_txt in PRICE_ROW_RE.findall(text): if n in seen: continue seen.add(n) price_label = f"à partir de {price_txt.strip()}$" area_min = float(area_rng.split("@")[0].strip()) out.append(self._listing( bid, base, name, sector, city, n, price_label, area_min, f"Superficie : {area_rng.strip()} pi² + balcon {balcon} pi²", availability, amenities, contact, images)) # 2) sinon (Boucherville) : typologies des sections « PLANS DES # SUITES », superficies lues sur la page plan de chaque typologie if not out: for n in dict.fromkeys(SUITE_RE.findall(html)): if n in seen: continue seen.add(n) area_min, area_note = None, "" page = PLAN_PAGE_OF.get(n) if page: try: _, ptext = self._get_text(base + page) areas = [float(a) for t, a, _b, _tot in PLAN_ROW_RE.findall(ptext) if t == n] if areas: area_min = min(areas) area_note = (f"Superficie : " f"{int(min(areas))}–{int(max(areas))} pi²" if len(set(areas)) > 1 else f"Superficie : {int(areas[0])} pi²") except Exception: pass out.append(self._listing( bid, base, name, sector, city, n, "", area_min, area_note, availability, amenities, contact, images)) return out def _listing(self, bid, base, name, sector, city, n, price_label, area_min, area_note, availability, amenities, contact, images) -> Listing: ut = normalize_unit_type(f"{n}½") penthouse = (n == "5") desc = (f"{name} — habitations locatives haut de gamme tout inclus " f"pour les 50 ans et mieux (Investissements Cleary), " f"certification LEED. " + ("Penthouse. " if penthouse else "") + (area_note or "")) return Listing( source=self.source_id, external_id=f"{bid}-{n}.5", url=base, title=f"{name} — {ut}" + (" (penthouse)" if penthouse else ""), address="", sector=sector, city=city, unit_type=ut, price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=area_min, description=desc.strip(), amenities=list(dict.fromkeys(amenities)), details={"contact": dict(contact)} if contact else {}, images=images, )