# ----------------------------------------------------------------------------- # 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