SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.8 KB · 161 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/leclif.py : connecteur Le Clif (leclif.ca)5#   Projet mono-immeuble de 151 condos locatifs à Charlesbourg (Québec).6#   Toutes les unités sont dans la page d'accueil (overlays « is-overlay-N »);7#   les unités louées sont marquées par un script jQuery addClass('non-dispo').8#   La page fournit aussi la liste des avantages de l'immeuble9#   (ul.liste-icones : inclusions, piscine, gym, politique animaux…) et le10#   lien Google Maps (lat/lng).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, infer_city, normalize_unit_type, parse_price19from .base import BaseConnector2021BASE = "https://leclif.ca"22SECTOR = "Charlesbourg"23ADDRESS = "19700, boul. Henri-Bourassa, Québec, G1G 0L1"2425# lat/lng du lien « Voir sur la carte » (…!3d46.8934679!4d-71.2986717…)26_LATLNG_RE = re.compile(r"!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)")27_SQFT_RE = re.compile(r"([\d\s  ,.]+?)\s*pi\s*2?", re.I)282930def _extract_price(label: str) -> float | None:31    """'À partir de 2366 (incluant électros) $ / mois' -> 2366.0"""32    m = re.search(r"(\d[\d\s  ]{2,})", label or "")33    if not m:34        return None35    return parse_price(m.group(1).strip() + "$")363738class LeClifConnector(BaseConnector):39    source_id = "leclif"40    request_delay = 0.64142    def fetch(self) -> list[Listing]:43        html = self.get(BASE + "/").text4445        # Unités marquées non disponibles par le script de la page46        non_dispo = set(re.findall(47            r"jQuery\('\.unite-(\d+)'\)\.addClass\('non-dispo'\)", html))4849        soup = BeautifulSoup(html, "html.parser")5051        # lat/lng depuis le lien Google Maps de l'immeuble52        lat = lng = None53        m = _LATLNG_RE.search(html)54        if m:55            lat, lng = float(m.group(1)), float(m.group(2))5657        # avantages de l'immeuble (« Eau chaude fournie », « Air climatisé58        # inclus », « Chiens et chats acceptés », piscine, gym, stationnement…)59        building_amenities: list[str] = []60        pets = None61        for li in soup.select("ul.liste-icones > li"):62            p = li.select_one("p.texte")63            if not p:64                continue65            label = " ".join(p.get_text(" ", strip=True).split())66            label = re.sub(r"\s+i$", "", label)   # icône info « i » du picto67            # politique animaux : l'infobulle détaille des conditions68            # (chiens selon étage/poids, deux chats maximum)69            if re.search(r"chiens|chats|animaux", label, re.I):70                tooltip = li.select_one(".is-description")71                tip_txt = tooltip.get_text(" ", strip=True) if tooltip else ""72                if re.search(r"condition|moins de \d+ lb|maximum", tip_txt, re.I):73                    pets = "conditions"74                    label += " (sous conditions)"75            if label and label not in building_amenities:76                building_amenities.append(label)7778        listings: list[Listing] = []7980        for overlay in soup.select("div.overlay-unite"):81            try:82                cls = " ".join(overlay.get("class", []))83                m = re.search(r"is-overlay-(\d+)", cls)84                if not m:85                    continue86                num = m.group(1)87                if num in non_dispo:88                    continue          # unité louée8990                price_el = overlay.select_one("h3.price")91                price_label = (price_el.get_text(" ", strip=True)92                               if price_el else "")93                price = _extract_price(price_label)9495                # Liste des caractéristiques : Disponibilité / Type / Modèle /96                # Superficie / Terrasse97                availability = unit_type = model = ""98                area_sqft = None99                av = re.search(r"Disponibilité\s*:\s*([\wéûà]+)",100                               overlay.get_text(" ", strip=True))101                if av:102                    availability = f"Disponible en {av.group(1)}"103                desc_parts: list[str] = []104                for li in overlay.select("ul.flex li"):105                    t = li.get_text(" ", strip=True)106                    if t.startswith("Disponibilité"):107                        availability = t.split(":", 1)[-1].strip()108                    elif t.startswith("Type"):109                        unit_type = normalize_unit_type(t.split(":", 1)[-1])110                    elif t.startswith("Modèle"):111                        model = t.split(":", 1)[-1].strip()112                    elif t.startswith("Superficie"):113                        # champ étiqueté distinct de la terrasse -> area_sqft114                        sm = _SQFT_RE.search(t.split(":", 1)[-1])115                        if sm:116                            try:117                                area_sqft = float(re.sub(r"[\s  ,]", "",118                                                         sm.group(1)))119                            except ValueError:120                                pass121                        desc_parts.append(re.sub(r"\s+", " ", t))122                    elif t.startswith("Terrasse"):123                        desc_parts.append(re.sub(r"\s+", " ", t))124                if model:125                    desc_parts.insert(0, f"Modèle {model}")126127                # Images : aperçu du plan (jpg) dans l'overlay128                images: list[str] = []129                for img in overlay.select("img[src]"):130                    src = img["src"]131                    if src.startswith("http") and re.search(132                            r"\.(?:jpg|jpeg|png|webp)$", src, re.I):133                        images.append(src)134                images = list(dict.fromkeys(images))135136                listings.append(Listing(137                    source=self.source_id,138                    external_id=f"unite-{num}",139                    url=f"{BASE}/#unite-{num}",140                    title=f"Le Clif — Unité {num}"141                          f"{' (' + unit_type + ')' if unit_type else ''}",142                    address=ADDRESS,143                    sector=SECTOR,144                    city=infer_city(SECTOR),145                    unit_type=unit_type,146                    price=price,147                    price_label=price_label,148                    availability=availability or "Disponible",149                    area_sqft=area_sqft,150                    pets=pets,151                    description=" | ".join(desc_parts),152                    amenities=list(building_amenities),153                    images=images,154                    lat=lat,155                    lng=lng,156                ))157            except Exception:158                continue159160        return listings161