# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/helio.py : connecteur Entreprises Hélio (entrepriseshelio.ca) # Trois immeubles en Montérégie : 2417 Montarville et 335-345 Place du # Collège à Longueuil, 1065-1075 boul. de Palerme à La Prairie. # WordPress + Divi rendu serveur : la page /a-louer/ liste une section par # typologie (h3 « 3 1/2, Place du Collège », « 4 1/2, Palerme »…) suivie # d'un encadré « Disponibilité : » et des caractéristiques (li). # Une typologie marquée « COMPLET » n'est PAS ingérée : seules les sections # avec une vraie disponibilité (date ou « disponible ») deviennent des # annonces. Il est donc normal que ce connecteur retourne 0 annonce quand # tout le parc est loué (état observé au 2026-08-25). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.entrepriseshelio.ca" LIST_URL = f"{BASE}/a-louer/" # immeuble (mot-clé du titre de section) -> adresse et ville BUILDINGS = { "montarville": ("2417, rue de Montarville, Longueuil", "Longueuil"), "college": ("335-345, place du Collège, Longueuil", "Longueuil"), "palerme": ("1065-1075, boulevard de Palerme, La Prairie", "La Prairie"), } # titre de section d'unité : « 3 1/2, Place du Collège – Demi Sous-Sol » TITLE_RE = re.compile(r"^(\d\s*1/2)\s*,\s*(.+)$") COMPLET_RE = re.compile(r"\bCOMPLET\b", re.I) IMG_RE = re.compile( r"https://www\.entrepriseshelio\.ca/wp-content/uploads/" r"[^\"\s\\']+\.(?:jpe?g|png|webp)", re.I) SKIP_IMG_RE = re.compile(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", re.I) def _slug(text: str) -> str: from ..schema import strip_accents s = strip_accents(text.lower()) return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-") class HelioConnector(BaseConnector): source_id = "helio" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") for h3 in soup.find_all("h3"): try: if "et_pb_gallery_title" in (h3.get("class") or []): continue # légendes de galerie Divi title = h3.get_text(" ", strip=True) m = TITLE_RE.match(title) if not m: continue unit_type = normalize_unit_type(m.group(1)) where = m.group(2).strip() # section Divi englobante (colonne/row) : dispo + listes section = h3 for _ in range(8): section = section.parent if section is None: break cls = " ".join(section.get("class") or []) txt = section.get_text(" ", strip=True) if "et_pb_row" in cls and "Disponibilité" in txt: break if section is None: continue stext = section.get_text(" | ", strip=True) # disponibilité : valeur qui suit « Disponibilité : » availability = "" m2 = re.search(r"Disponibilité\s*\|?\s*:\s*\|?\s*([^|]+)", stext) if m2: availability = m2.group(1).strip() if COMPLET_RE.search(availability): continue # typologie complète : exclue if not availability: continue # rien d'affiché : prudence # immeuble -> adresse/ville key = _slug(where) address, city = "", "" for kw, (addr, ct) in BUILDINGS.items(): if kw in key: address, city = addr, ct break amenities = [li.get_text(" ", strip=True) for li in section.find_all("li") if li.get_text(strip=True) and "photos sont représentatives" not in li.get_text()][:20] images = [u for u in dict.fromkeys(IMG_RE.findall(str(section))) if not SKIP_IMG_RE.search(u)][:15] listings.append(Listing( source=self.source_id, external_id=f"{unit_type.replace('½', '.5')}-{key}", url=LIST_URL, title=title, address=address, city=city, unit_type=unit_type, availability=availability, amenities=amenities, images=images, )) except Exception: continue return listings