SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
8.9 KB · 207 lines python
Raw Blame History
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"\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