Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/logiluxx.py : connecteur Logiluxx / Investissements Cleary5# (logiluxx.com — habitations locatives tout inclus LEED, 50 ans et mieux).6# Trois immeubles, chacun avec son mini-site statique (HTML iso-8859-1) :7# St-Hubert I et II (Faubourg Cousineau, Longueuil) et Boucherville.8# Les sites St-Hubert affichent une table « PRIX » par typologie9# (superficie @ superficie, loyer « à partir de X$ ») ; Boucherville n'a10# que les plans (superficies) et fonctionne par liste d'attente.11# Granularité : une annonce par immeuble × typologie.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122# (id, base URL, nom, secteur, ville) — les mini-sites existent aussi en23# sous-domaines (<id>.logiluxx.com) mais leur certificat TLS est invalide :24# on passe par les miroirs sur le domaine principal.25BUILDINGS = [26 ("sthubert", "https://logiluxx.com/sthubert/",27 "Logiluxx St-Hubert I", "Saint-Hubert", "Longueuil"),28 ("sthubert2", "https://logiluxx.com/sthubert2/",29 "Logiluxx St-Hubert II", "Saint-Hubert", "Longueuil"),30 ("boucherville", "https://logiluxx.com/boucherville/",31 "Logiluxx Boucherville", "", "Boucherville"),32]3334# ligne de la table PRIX : « 3 1/2 817 @ 1056 160 977 @ 1216 à partir de 1941$ »35PRICE_ROW_RE = re.compile(36 r"(\d)\s*1/2\s+([\d]+(?:\s*@\s*\d+)?)\s+(\d+)\s+[\d]+(?:\s*@\s*\d+)?\s+"37 r"[àa] partir de\s*([\d\s,]{3,9})\s*\$", re.I)38# ligne d'un plan : « 3 1/2 770 140 910 » (superficie, balcon, totale)39PLAN_ROW_RE = re.compile(r"(\d)\s*1/2\s+(\d{3,4})\s+(\d{2,3})\s+(\d{3,4})")40# typologies annoncées (« LA SUITE 3 1/2 », « LES PENTHOUSES 5 1/2 »)41SUITE_RE = re.compile(r"(?:SUITES?|PENTHOUSES?)\s+(\d)\s*1/2", re.I)42IMG_RE = re.compile(r'src="(images/projets/[^"]+\.(?:jpe?g|png))"', re.I)43INCLUSION_RE = re.compile(r"^-\s*(.{4,90})$")44PHONE_RE = re.compile(r"(\d{3})[.\s](\d{3})[.\s](\d{4})")4546PLAN_PAGE_OF = {"3": "plan3demi.html", "4": "plan4demi.html",47 "5": "plan5demi.html"}484950class LogiluxxConnector(BaseConnector):51 source_id = "logiluxx"52 request_delay = 0.85354 def _get_text(self, url: str) -> tuple[str, str]:55 """(html, texte aplati) — les mini-sites sont encodés iso-8859-156 (mais un fetch de secours via proxy peut renvoyer de l'UTF-8)."""57 resp = self.get(url)58 try:59 html = resp.content.decode("utf-8")60 except UnicodeDecodeError:61 html = resp.content.decode("iso-8859-1", errors="replace")62 soup = BeautifulSoup(html, "html.parser")63 for tag in soup(["script", "style", "noscript"]):64 tag.decompose()65 return html, re.sub(r"\s+", " ", soup.get_text(" ", strip=True))6667 def fetch(self) -> list[Listing]:68 # échec HONNÊTE : une erreur réseau/parse sur un immeuble doit lever69 # (ok=0 dans sync_log) et non être avalée — un « 0 trouvé ok » déclenche70 # la dérive et menace les 9 annonces (vu le 2026-09-07 12:10)71 listings: list[Listing] = []72 for bid, base, name, sector, city in BUILDINGS:73 found = self._fetch_building(bid, base, name, sector, city)74 if not found:75 raise RuntimeError(76 f"logiluxx : aucune typologie parsée sur {base} "77 "(rendu partiel ou site restructuré)")78 listings.extend(found)79 return listings8081 def _fetch_building(self, bid: str, base: str, name: str,82 sector: str, city: str) -> list[Listing]:83 html, text = self._get_text(base)8485 # inclusions (« - Chauffage, électricité et eau chaude », …)86 amenities = ["Formule tout inclus (chauffage, électricité, eau chaude)",87 "5 électroménagers en acier inoxydable",88 "Climatisation", "Immeuble LEED",89 "Pour les 50 ans et mieux"]90 for kw, label in [91 (r"[Pp]iscine", "Piscine chauffée"),92 (r"entra[îi]nement", "Centre d'entraînement"),93 (r"[Aa]scenseur", "Ascenseurs"),94 (r"[Cc][âa]blodistribution", "Câblodistribution (base) incluse"),95 ]:96 if re.search(kw, text):97 amenities.append(label)9899 contact: dict = {}100 m = PHONE_RE.search(text)101 if m:102 contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"103 m = re.search(r"[\w.\-]+@logiluxx\.com", text)104 if m:105 contact["email"] = m.group(0).lower()106107 images = [base + u for u in dict.fromkeys(IMG_RE.findall(html))108 if "logo" not in u.lower()][:12]109110 waitlist = bool(re.search(r"LISTE D.ATTENTE", text, re.I))111 availability = "Liste d'attente" if waitlist else ""112113 out: list[Listing] = []114 seen: set[str] = set()115116 # 1) table PRIX (St-Hubert) : typologie + superficies + loyer plancher117 for n, area_rng, balcon, price_txt in PRICE_ROW_RE.findall(text):118 if n in seen:119 continue120 seen.add(n)121 price_label = f"à partir de {price_txt.strip()}$"122 area_min = float(area_rng.split("@")[0].strip())123 out.append(self._listing(124 bid, base, name, sector, city, n, price_label, area_min,125 f"Superficie : {area_rng.strip()} pi² + balcon {balcon} pi²",126 availability, amenities, contact, images))127128 # 2) sinon (Boucherville) : typologies des sections « PLANS DES129 # SUITES », superficies lues sur la page plan de chaque typologie130 if not out:131 for n in dict.fromkeys(SUITE_RE.findall(html)):132 if n in seen:133 continue134 seen.add(n)135 area_min, area_note = None, ""136 page = PLAN_PAGE_OF.get(n)137 if page:138 try:139 _, ptext = self._get_text(base + page)140 areas = [float(a) for t, a, _b, _tot141 in PLAN_ROW_RE.findall(ptext) if t == n]142 if areas:143 area_min = min(areas)144 area_note = (f"Superficie : "145 f"{int(min(areas))}–{int(max(areas))} pi²"146 if len(set(areas)) > 1 else147 f"Superficie : {int(areas[0])} pi²")148 except Exception:149 pass150 out.append(self._listing(151 bid, base, name, sector, city, n, "", area_min,152 area_note, availability, amenities, contact, images))153 return out154155 def _listing(self, bid, base, name, sector, city, n, price_label,156 area_min, area_note, availability, amenities, contact,157 images) -> Listing:158 ut = normalize_unit_type(f"{n}½")159 penthouse = (n == "5")160 desc = (f"{name} — habitations locatives haut de gamme tout inclus "161 f"pour les 50 ans et mieux (Investissements Cleary), "162 f"certification LEED. "163 + ("Penthouse. " if penthouse else "")164 + (area_note or ""))165 return Listing(166 source=self.source_id,167 external_id=f"{bid}-{n}.5",168 url=base,169 title=f"{name} — {ut}" + (" (penthouse)" if penthouse else ""),170 address="",171 sector=sector,172 city=city,173 unit_type=ut,174 price=parse_price(price_label),175 price_label=price_label,176 availability=availability,177 area_sqft=area_min,178 description=desc.strip(),179 amenities=list(dict.fromkeys(amenities)),180 details={"contact": dict(contact)} if contact else {},181 images=images,182 )183