spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/huma.py : connecteur HUMĀ Condos locatifs (humalevis.com)5# Deux phases de 130/132 unités à Lévis (Saint-Romuald). Plans d'étages6# interactifs (<area data-color-scheme="disponible">) -> fiches d'unités7# (type, superficies, date de disponibilité, plan). Les pages de phases8# listent les services/inclusions ; la page contact donne l'adresse civique9# de chaque phase, le contact et le marqueur GPS. Aucun prix sur le site.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, infer_city, normalize_unit_type, parse_area_sqft18from .base import BaseConnector1920BASE = "https://humalevis.com"21SECTOR = "Saint-Romuald"2223# fichiers d'images à ignorer (logos, partenaires)24_SKIP_IMG = re.compile(r"logo|rvb[_-]?huma|ftq|edifia|favicon|icon", re.I)252627class HumaConnector(BaseConnector):28 source_id = "huma"29 request_delay = 0.530 max_floor_pages = 24 # garde-fou (2 phases x 10 étages)3132 # -- pages de phase : listes « Services et commodité » + inclusions ----------33 @staticmethod34 def _phase_amenities(html: str) -> list[str]:35 soup = BeautifulSoup(html, "html.parser")36 items: list[str] = []3738 def _lis_after(node) -> list[str]:39 ul = node.find_next("ul") if node else None40 if not ul:41 return []42 return [re.sub(r"\s+", " ", li.get_text(" ", strip=True))43 for li in ul.find_all("li")]4445 for h3 in soup.find_all("h3"):46 if "services et commodit" in h3.get_text(strip=True).lower():47 items.extend(_lis_after(h3))48 break49 for p in soup.find_all("p"):50 label = p.get_text(strip=True).upper()51 if label.startswith("INCLUSIONS MENSUELLES"):52 items.extend(f"Inclus : {t}" for t in _lis_after(p))53 elif label.startswith("OPTIONS OFFERTES"):54 items.extend(f"{t} — en option" for t in _lis_after(p))55 return [t for t in dict.fromkeys(items) if 3 <= len(t) <= 120][:25]5657 # -- page contact : adresse civique par phase, contact, GPS -----------------58 def _contact_info(self) -> tuple[dict[str, str], dict, tuple | None]:59 addresses: dict[str, str] = {}60 contact: dict = {}61 latlng: tuple | None = None62 try:63 html = self.get(f"{BASE}/contactez-nous/").text64 except Exception:65 return addresses, contact, latlng66 soup = BeautifulSoup(html, "html.parser")67 for h4 in soup.find_all("h4"):68 m = re.match(r"HUM[AĀ]\s+phase\s+(I{1,2}|1|2)\b",69 h4.get_text(strip=True), re.I)70 if not m:71 continue72 phase = {"I": "1", "II": "2"}.get(m.group(1).upper(), m.group(1))73 p = h4.find_next("p")74 if p:75 addresses[phase] = re.sub(76 r"\s+", " ", p.get_text(", ", strip=True))77 tel = soup.select_one('a[href^="tel:"]')78 if tel:79 contact["phone"] = tel.get_text(strip=True)80 mail = soup.select_one('a[href^="mailto:"]')81 if mail:82 contact["email"] = mail.get_text(strip=True)83 # marqueur Google Maps « HUMĀ condos locatifs » (phase I)84 m = re.search(r'"nom":"HUM[^"]*condos[^"]*","adresse":"[^"]*",\s*'85 r'"latitude":"([\d.\-]+)","longitude":"([\d.\-]+)"', html)86 if m:87 latlng = (float(m.group(1)), float(m.group(2)))88 return addresses, contact, latlng8990 def fetch(self) -> list[Listing]:91 # 0) Adresses par phase + contact + GPS (page contact)92 addresses, contact, latlng = self._contact_info()9394 # 1) Découvrir les pages d'étages depuis les pages de phases95 floor_urls: list[str] = []96 phase_amenities: dict[str, list[str]] = {}97 for phase in (1, 2):98 try:99 html = self.get(f"{BASE}/phase-{phase}/").text100 except Exception:101 continue102 phase_amenities[str(phase)] = self._phase_amenities(html)103 found = sorted(set(re.findall(104 rf'href="({re.escape(BASE)}/phase-{phase}/etage-\d+/?)"',105 html)), key=lambda u: int(re.search(r"etage-(\d+)", u).group(1)))106 floor_urls.extend(found)107108 # 2) Unités disponibles sur chaque plan d'étage109 unit_urls: list[str] = []110 for url in floor_urls[:self.max_floor_pages]:111 try:112 html = self.get(url).text113 except Exception:114 continue115 for tag in re.findall(r"<area\b.*?>", html, re.S):116 if 'data-color-scheme="disponible"' not in tag:117 continue118 m = re.search(r'href="(https?://[^"]+)"', tag)119 if m:120 u = m.group(1).rstrip("/")121 if u.startswith(BASE) and u not in unit_urls:122 unit_urls.append(u)123124 # 3) Fiche de chaque unité disponible125 listings: list[Listing] = []126 for url in unit_urls:127 try:128 html = self.get(url).text129 except Exception:130 continue131 try:132 soup = BeautifulSoup(html, "html.parser")133 text = soup.get_text("\n", strip=True)134135 num_m = re.search(r"N°\s*(\w+)", text)136 unit_no = (num_m.group(1) if num_m137 else url.rstrip("/").split("-")[-1])138 phase_m = re.search(r"unite-phase-(\d)", url)139 phase = phase_m.group(1) if phase_m else "?"140141 type_m = re.search(r"TYPE\s+([\w.]+)\s*\|\s*([^\n]+)", text)142 model = type_m.group(1) if type_m else ""143 unit_type = (normalize_unit_type(type_m.group(2))144 if type_m else "")145146 etat_m = re.search(r"ÉTAT\s*\n\s*([^\n]+)", text)147 availability = etat_m.group(1).strip() if etat_m else "Disponible"148 # date de disponibilité affichée sous le titre (div.date-title)149 date_el = soup.select_one(".date-unite .date-title")150 if date_el and date_el.get_text(strip=True):151 availability = date_el.get_text(strip=True).capitalize()152153 floor_m = re.search(r"ÉTAGE\s*\n\s*(\d+)", text)154 area_sqft = None155 desc_parts = []156 for label in ("SUPERFICIE DU LOGEMENT", "SUPERFICIE DU BALCON",157 "SUPERFICIE TOTALE"):158 dm = re.search(rf"{label}\s*\n\s*([^\n]+)", text)159 if dm:160 desc_parts.append(161 f"{label.capitalize().lower().capitalize()} : "162 f"{dm.group(1).strip()}")163 if label == "SUPERFICIE DU LOGEMENT":164 area_sqft = parse_area_sqft(dm.group(1))165 if model:166 desc_parts.insert(0, f"Modèle {model}")167 if floor_m:168 desc_parts.insert(0, f"Étage {floor_m.group(1)}")169170 imgs = re.findall(171 rf'(?:src|href|data-src)="({re.escape(BASE)}'172 rf'/wp-content/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"',173 html, re.I)174 images = [u for u in dict.fromkeys(imgs)175 if not _SKIP_IMG.search(u)][:15]176177 details: dict = {}178 if contact:179 details["contact"] = dict(contact)180 lat, lng = (latlng if latlng and phase == "1"181 else (None, None))182 listings.append(Listing(183 source=self.source_id,184 external_id=f"phase-{phase}-condo-{unit_no}",185 url=url,186 title=f"HUMĀ phase {phase} — Condo locatif N°{unit_no}"187 f" ({unit_type})",188 address=addresses.get(phase, ""),189 sector=SECTOR,190 city=infer_city(SECTOR),191 unit_type=unit_type,192 price=None, # aucun prix affiché sur le site193 price_label="",194 availability=availability,195 area_sqft=area_sqft,196 description=" | ".join(desc_parts),197 amenities=list(phase_amenities.get(phase, [])),198 details=details,199 images=images,200 lat=lat,201 lng=lng,202 ))203 except Exception:204 continue205206 return listings207