# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lobato.py : connecteur Groupe Lobato (groupelobato.com — # promoteur/gestionnaire de la vallée du Richelieu : Beloeil, # McMasterville, Mont-Saint-Hilaire, Saint-Jean-sur-Richelieu). # WordPress (thème Vortex Solution) rendu serveur : chaque page # /project// contient un tableau HTML des unités (Unit | Bed. | # S.F | Price | Availability | Plan). Seules les lignes « Available » # sont retenues ; les locaux « Commercial » du Carrefour Laurier sont # exclus. Granularité = UNITÉ. external_id = - # (slug du lien /unit/ si présent, sinon nom affiché slugifié). # Colonne Bed. hétérogène : « 1 », « 2 », « 3 », « 1 + den », « 4 1/2 ». # Photos servies via le proxy PhastPress (phast.php/.q.jpg) — # on décode le base64 pour retrouver l'URL wp-content/uploads originale. # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import html as _html import re from urllib.parse import unquote from ..schema import Listing from .base import BaseConnector BASE = "https://groupelobato.com" # (slug de page projet, nom du projet, adresse, ville) PROJECTS: tuple[tuple[str, str, str, str], ...] = ( ("beloeil-faubourg-du-richelieu", "Le Faubourg du Richelieu", "2000, rue Richelieu, Beloeil", "Beloeil"), ("carrefour-laurier", "Carrefour Laurier", "831, boulevard Laurier, McMasterville", "McMasterville"), ("mont-st-hilaire-condos-a-louer-2", "Condos rue de la Sucrerie", "rue de la Sucrerie, Mont-Saint-Hilaire", "Mont-Saint-Hilaire"), ("mcmasterville-rue-constable", "781-783, rue Constable", "781-783, rue Constable, McMasterville", "McMasterville"), ("havre-du-richelieu-condos-a-louer", "Havre du Richelieu", "81-83, rue Richelieu, Saint-Jean-sur-Richelieu", "Saint-Jean-sur-Richelieu"), ) ROW_RE = re.compile(r"]*>[\s\S]*?", re.I) CELL_RE = re.compile(r"]*>([\s\S]*?)", re.I) UNIT_LINK_RE = re.compile(r'href="https://groupelobato\.com/unit/([^/"]+)/?"') PDF_RE = re.compile( r'href="(https://groupelobato\.com/wp-content/uploads/[^"]+\.pdf)"', re.I) TAG_RE = re.compile(r"<[^>]+>") PHAST_RE = re.compile(r"phast\.php/([A-Za-z0-9_=-]+)\.q\.(?:jpe?g|png|webp)", re.I) SIZE_SUFFIX_RE = re.compile(r"-\d+x\d+(\.(?:jpe?g|png|webp))$", re.I) def _clean(txt: str) -> str: return re.sub(r"\s+", " ", _html.unescape(TAG_RE.sub(" ", txt))).strip() def _slug(txt: str) -> str: return re.sub(r"[^a-z0-9]+", "-", txt.lower()).strip("-") def _images(html: str) -> list[str]: """Décode les URLs PhastPress -> originaux wp-content/uploads (photos).""" urls: list[str] = [] for tok in dict.fromkeys(PHAST_RE.findall(html)): try: raw = base64.urlsafe_b64decode( tok + "=" * (-len(tok) % 4)).decode("utf-8", "replace") except Exception: continue m = re.search(r"src=([^&]+)", raw) if not m: continue u = unquote(m.group(1)) if "/wp-content/uploads/" not in u: continue # images de thème/icônes if re.search(r"logo|icon|favicon", u, re.I): continue u = SIZE_SUFFIX_RE.sub(r"\1", u) # variante -WxH -> original if u not in urls: urls.append(u) return urls[:10] class LobatoConnector(BaseConnector): source_id = "lobato" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] seen: set[str] = set() for slug, name, address, city in PROJECTS: url = f"{BASE}/project/{slug}/" try: html = self.get(url).text except Exception: continue images = _images(html) for row in ROW_RE.finditer(html): chunk = row.group(0) cells = CELL_RE.findall(chunk) if len(cells) < 5: continue unit = _clean(cells[0]) beds_txt = _clean(cells[1]) sf_txt = _clean(cells[2]) price_txt = _clean(cells[3]) avail_txt = _clean(cells[4]) if not unit or "commercial" in unit.lower(): continue # local commercial exclu if not re.search(r"\bAvailable\b", avail_txt, re.I): continue # unité louée (Leased) lm = UNIT_LINK_RE.search(cells[0]) ext_id = f"{slug}-{lm.group(1) if lm else _slug(unit)}" if ext_id in seen: continue seen.add(ext_id) price = None pm = re.search(r"(\d[\d\s ]{2,8})\$", price_txt) if pm: price = float(re.sub(r"[\s ]", "", pm.group(1))) area = None am = re.search(r"(\d{3,5})", sf_txt.replace(" ", "")) if am: area = float(am.group(1)) bedrooms = None unit_type = "" bm = re.match(r"^(\d+)(?:\s*\+\s*den)?$", beds_txt, re.I) if bm: bedrooms = float(bm.group(1)) if "den" in beds_txt.lower(): unit_type = f"{bm.group(1)} ch. + den" elif re.match(r"^\d+\s*1/2$", beds_txt): unit_type = beds_txt # « 4 1/2 » -> finalize pdf = PDF_RE.search(chunk) listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{name} — unité {unit}", address=address, city=city, unit_type=unit_type, bedrooms=bedrooms, price=price, price_label=price_txt if price else "", availability="Disponible", area_sqft=area, description=f"Unité {unit} au {name} ({address}) — " "immeuble locatif du Groupe Lobato, " "promoteur de la vallée du Richelieu.", details={"plan_pdf": pdf.group(1)} if pdf else {}, images=images, )) return listings