# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/huma.py : connecteur HUMĀ Condos locatifs (humalevis.com)
# Deux phases de 130/132 unités à Lévis (Saint-Romuald). Plans d'étages
# interactifs () -> fiches d'unités
# (type, superficies, date de disponibilité, plan). Les pages de phases
# listent les services/inclusions ; la page contact donne l'adresse civique
# de chaque phase, le contact et le marqueur GPS. Aucun prix sur le site.
# -----------------------------------------------------------------------------
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from ..schema import Listing, infer_city, normalize_unit_type, parse_area_sqft
from .base import BaseConnector
BASE = "https://humalevis.com"
SECTOR = "Saint-Romuald"
# fichiers d'images à ignorer (logos, partenaires)
_SKIP_IMG = re.compile(r"logo|rvb[_-]?huma|ftq|edifia|favicon|icon", re.I)
class HumaConnector(BaseConnector):
source_id = "huma"
request_delay = 0.5
max_floor_pages = 24 # garde-fou (2 phases x 10 étages)
# -- pages de phase : listes « Services et commodité » + inclusions ----------
@staticmethod
def _phase_amenities(html: str) -> list[str]:
soup = BeautifulSoup(html, "html.parser")
items: list[str] = []
def _lis_after(node) -> list[str]:
ul = node.find_next("ul") if node else None
if not ul:
return []
return [re.sub(r"\s+", " ", li.get_text(" ", strip=True))
for li in ul.find_all("li")]
for h3 in soup.find_all("h3"):
if "services et commodit" in h3.get_text(strip=True).lower():
items.extend(_lis_after(h3))
break
for p in soup.find_all("p"):
label = p.get_text(strip=True).upper()
if label.startswith("INCLUSIONS MENSUELLES"):
items.extend(f"Inclus : {t}" for t in _lis_after(p))
elif label.startswith("OPTIONS OFFERTES"):
items.extend(f"{t} — en option" for t in _lis_after(p))
return [t for t in dict.fromkeys(items) if 3 <= len(t) <= 120][:25]
# -- page contact : adresse civique par phase, contact, GPS -----------------
def _contact_info(self) -> tuple[dict[str, str], dict, tuple | None]:
addresses: dict[str, str] = {}
contact: dict = {}
latlng: tuple | None = None
try:
html = self.get(f"{BASE}/contactez-nous/").text
except Exception:
return addresses, contact, latlng
soup = BeautifulSoup(html, "html.parser")
for h4 in soup.find_all("h4"):
m = re.match(r"HUM[AĀ]\s+phase\s+(I{1,2}|1|2)\b",
h4.get_text(strip=True), re.I)
if not m:
continue
phase = {"I": "1", "II": "2"}.get(m.group(1).upper(), m.group(1))
p = h4.find_next("p")
if p:
addresses[phase] = re.sub(
r"\s+", " ", p.get_text(", ", strip=True))
tel = soup.select_one('a[href^="tel:"]')
if tel:
contact["phone"] = tel.get_text(strip=True)
mail = soup.select_one('a[href^="mailto:"]')
if mail:
contact["email"] = mail.get_text(strip=True)
# marqueur Google Maps « HUMĀ condos locatifs » (phase I)
m = re.search(r'"nom":"HUM[^"]*condos[^"]*","adresse":"[^"]*",\s*'
r'"latitude":"([\d.\-]+)","longitude":"([\d.\-]+)"', html)
if m:
latlng = (float(m.group(1)), float(m.group(2)))
return addresses, contact, latlng
def fetch(self) -> list[Listing]:
# 0) Adresses par phase + contact + GPS (page contact)
addresses, contact, latlng = self._contact_info()
# 1) Découvrir les pages d'étages depuis les pages de phases
floor_urls: list[str] = []
phase_amenities: dict[str, list[str]] = {}
for phase in (1, 2):
try:
html = self.get(f"{BASE}/phase-{phase}/").text
except Exception:
continue
phase_amenities[str(phase)] = self._phase_amenities(html)
found = sorted(set(re.findall(
rf'href="({re.escape(BASE)}/phase-{phase}/etage-\d+/?)"',
html)), key=lambda u: int(re.search(r"etage-(\d+)", u).group(1)))
floor_urls.extend(found)
# 2) Unités disponibles sur chaque plan d'étage
unit_urls: list[str] = []
for url in floor_urls[:self.max_floor_pages]:
try:
html = self.get(url).text
except Exception:
continue
for tag in re.findall(r"", html, re.S):
if 'data-color-scheme="disponible"' not in tag:
continue
m = re.search(r'href="(https?://[^"]+)"', tag)
if m:
u = m.group(1).rstrip("/")
if u.startswith(BASE) and u not in unit_urls:
unit_urls.append(u)
# 3) Fiche de chaque unité disponible
listings: list[Listing] = []
for url in unit_urls:
try:
html = self.get(url).text
except Exception:
continue
try:
soup = BeautifulSoup(html, "html.parser")
text = soup.get_text("\n", strip=True)
num_m = re.search(r"N°\s*(\w+)", text)
unit_no = (num_m.group(1) if num_m
else url.rstrip("/").split("-")[-1])
phase_m = re.search(r"unite-phase-(\d)", url)
phase = phase_m.group(1) if phase_m else "?"
type_m = re.search(r"TYPE\s+([\w.]+)\s*\|\s*([^\n]+)", text)
model = type_m.group(1) if type_m else ""
unit_type = (normalize_unit_type(type_m.group(2))
if type_m else "")
etat_m = re.search(r"ÉTAT\s*\n\s*([^\n]+)", text)
availability = etat_m.group(1).strip() if etat_m else "Disponible"
# date de disponibilité affichée sous le titre (div.date-title)
date_el = soup.select_one(".date-unite .date-title")
if date_el and date_el.get_text(strip=True):
availability = date_el.get_text(strip=True).capitalize()
floor_m = re.search(r"ÉTAGE\s*\n\s*(\d+)", text)
area_sqft = None
desc_parts = []
for label in ("SUPERFICIE DU LOGEMENT", "SUPERFICIE DU BALCON",
"SUPERFICIE TOTALE"):
dm = re.search(rf"{label}\s*\n\s*([^\n]+)", text)
if dm:
desc_parts.append(
f"{label.capitalize().lower().capitalize()} : "
f"{dm.group(1).strip()}")
if label == "SUPERFICIE DU LOGEMENT":
area_sqft = parse_area_sqft(dm.group(1))
if model:
desc_parts.insert(0, f"Modèle {model}")
if floor_m:
desc_parts.insert(0, f"Étage {floor_m.group(1)}")
imgs = re.findall(
rf'(?:src|href|data-src)="({re.escape(BASE)}'
rf'/wp-content/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"',
html, re.I)
images = [u for u in dict.fromkeys(imgs)
if not _SKIP_IMG.search(u)][:15]
details: dict = {}
if contact:
details["contact"] = dict(contact)
lat, lng = (latlng if latlng and phase == "1"
else (None, None))
listings.append(Listing(
source=self.source_id,
external_id=f"phase-{phase}-condo-{unit_no}",
url=url,
title=f"HUMĀ phase {phase} — Condo locatif N°{unit_no}"
f" ({unit_type})",
address=addresses.get(phase, ""),
sector=SECTOR,
city=infer_city(SECTOR),
unit_type=unit_type,
price=None, # aucun prix affiché sur le site
price_label="",
availability=availability,
area_sqft=area_sqft,
description=" | ".join(desc_parts),
amenities=list(phase_amenities.get(phase, [])),
details=details,
images=images,
lat=lat,
lng=lng,
))
except Exception:
continue
return listings