SPB Git

spb/lou-ka Public

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

HTML 99.7%
12.7 KB · 295 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/laberge.py : connecteur Gestion immobilière Laberge (laberge.qc.ca)5#   Site rendu serveur. La page /recherche liste tous les complexes avec, pour6#   chacun, un tableau des types d'unités ; les lignes cliquables7#   (data-href="/complexe/{slug}/appartement/{code}") sont les unités8#   disponibles. Une annonce par unité disponible ; la fiche unité fournit9#   les photos, le secteur, la superficie, la description et les inclusions.10#   Région retenue : Québec / Lévis / L'Ancienne-Lorette (les complexes de la11#   région de Montréal — Côte St-Luc, LaSalle, Pierrefonds, etc. — sont exclus).12#   Les fiches unités sont visitées via self.detail() (cache BD, max13#   max_details nouveaux fetchs par synchronisation).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re19from urllib.parse import urljoin2021from bs4 import BeautifulSoup2223from ..schema import Listing, infer_city, normalize_unit_type, parse_price, strip_accents24from .base import BaseConnector2526BASE = "https://www.laberge.qc.ca"27SEARCH_URL = f"{BASE}/recherche"2829# villes de la région Québec/Lévis retenues (normalisées sans accents)30ALLOWED_CITIES = {31    "quebec", "levis", "l'ancienne-lorette", "ancienne-lorette",32    "saint-augustin-de-desmaures", "st-augustin-de-desmaures",33}3435IMG_RE = re.compile(r"^/image/\d+/\d+/")36EXCLUDE_UNIT_RE = re.compile(r"stationnement|parking|rangement|commercial|garage", re.I)3738# pictogrammes dont le libellé texte est générique ("Inclus")39_PICTO_NAMES = {40    "poele--frigo": "Poêle et réfrigérateur",41    "entree_laveuse_secheuse": "Entrée laveuse-sécheuse",42    "laveuse_secheuse": "Laveuse-sécheuse",43    "lave-vaisselle": "Lave-vaisselle",44    "lv": "Lave-vaisselle",45    "micro-onde": "Micro-ondes",46    "air_climatise": "Air climatisé",47}484950class _DetailBudget(Exception):51    """Budget de fiches unités atteint pour cette synchronisation."""525354class LabergeConnector(BaseConnector):55    source_id = "laberge"56    request_delay = 0.657    max_details = 150         # garde-fou : nb max de fiches unités visitées58    max_images = 255960    # -- helpers ---------------------------------------------------------------61    @staticmethod62    def _card_address_city(card) -> tuple[str, str]:63        """Adresse civique + ville depuis la carte complexe de /recherche."""64        p = card.select_one(".bg-white p")65        if not p:66            return "", ""67        for a in p.find_all("a"):          # retirer le lien téléphone68            a.decompose()69        addr = p.get_text(" ", strip=True)70        # ex. « 224, rue seigneuriale, Québec, G1E 0M8 »71        m = re.search(r"^(.*?),\s*([^,]+?),?\s*[A-Z]\d[A-Z]\s*\d[A-Z]\d", addr)72        if m:73            return m.group(1).strip(), m.group(2).strip()74        parts = [x.strip() for x in addr.split(",") if x.strip()]75        if len(parts) >= 2:76            return ", ".join(parts[:-1]), parts[-1]77        return addr, ""7879    @staticmethod80    def _picto_labels(container) -> list[str]:81        """Libellés des pictogrammes non grisés (grisé = exclu/absent)."""82        labels: list[str] = []83        for cell in container.select("div.text-center"):84            p = cell.find("p")85            img = cell.find("img")86            if p is None:87                continue88            style = p.get("style") or ""89            src = (img.get("src") or "") if img else ""90            if "#ccc" in style or "_off." in src:91                continue  # service exclu (picto grisé)92            label = p.get_text(" ", strip=True)93            if not label or label.lower() == "inclus":94                stem = src.rsplit("/", 1)[-1].rsplit(".", 1)[0]95                label = _PICTO_NAMES.get(stem, stem.replace("_", " ")96                                         .replace("--", " ").replace("-", " ")97                                         .strip().capitalize())98            if label and label not in labels:99                labels.append(label)100        return labels101102    def _fetch_detail(self, url: str) -> dict:103        """Fiche unité -> payload JSON-sérialisable (mis en cache BD)."""104        html = self.get(url).text105        soup = BeautifulSoup(html, "html.parser")106        payload: dict = {}107108        # secteur depuis le <title> : « Appartement - Beauport - Le 224 #… »109        if soup.title:110            parts = soup.title.get_text(strip=True).split(" - ")111            if len(parts) >= 2 and parts[0].lower().startswith("appartement"):112                payload["sector"] = parts[1].strip()113114        # photos de l'unité (URLs absolues)115        imgs = []116        for img in soup.find_all("img"):117            src = img.get("src") or img.get("data-src") or ""118            if IMG_RE.match(src):119                imgs.append(urljoin(BASE, src))120        payload["images"] = list(dict.fromkeys(imgs))[: self.max_images]121122        # bloc « Grandeur / Étage / Disponibilité / Superficie / Débutant » :123        # paires h3 -> p structurées de la fiche124        for block in soup.select(".caracteristiques-appart.description-appart"):125            for h3 in block.find_all("h3"):126                label = h3.get_text(" ", strip=True).lower()127                val_el = h3.find_next_sibling("p")128                if not val_el:129                    continue130                val = " ".join(val_el.get_text(" ", strip=True).split())131                if label.startswith("superficie"):132                    m = re.search(r"([\d\s.,]+?)\s*pi", val)133                    if m:134                        try:135                            payload["area_sqft"] = float(136                                m.group(1).replace(" ", "").replace(",", "."))137                        except ValueError:138                            pass139                elif label.startswith("étage") or label.startswith("etage"):140                    if val.isdigit():141                        payload["floor"] = int(val)142                elif label.startswith("disponibilit"):143                    payload["availability"] = val144145        # description (paragraphe sous le h2 « Description »)146        for h2 in soup.find_all("h2"):147            if h2.get_text(strip=True).lower().startswith("description"):148                sib = h2.find_next("p")149                if sib:150                    desc = sib.get_text(" ", strip=True)151                    if len(desc) > 40:152                        payload["description"] = desc[:600]153                        break154155        amenities: list[str] = []156        # inclusions de l'unité (pictos non grisés = inclus)157        for block in soup.select(".caracteristiques-appart"):158            for label in self._picto_labels(block):159                if label not in amenities:160                    amenities.append(label)161            # caractéristiques en liste (« Entrée laveuse, sécheuse »,162            # « Balcon privé : 1 », « Stationnement extérieur : … »)163            for li in block.select("ul li"):164                label = " ".join(li.get_text(" ", strip=True).split())165                if label and label not in amenities:166                    amenities.append(label)167168        # services de l'immeuble + dispositifs de sécurité (pictos non grisés)169        for h2 in soup.find_all("h2"):170            titre = h2.get_text(strip=True).lower()171            if titre.startswith(("services de l", "dispositifs de s")):172                parent = h2.find_parent("div")173                row = parent.parent if parent else None174                if row is None:175                    continue176                for label in self._picto_labels(row):177                    if label not in amenities:178                        amenities.append(label)179        payload["amenities"] = amenities180181        # adresse complète de l'immeuble (avec code postal) : h3 « Adresse »182        for h3 in soup.find_all("h3"):183            if h3.get_text(strip=True).lower() == "adresse":184                p = h3.find_next_sibling("p")185                if p:186                    for a in p.find_all("a"):   # lien « Autres adresses »187                        a.decompose()188                    lignes = [" ".join(x.split()) for x in p.stripped_strings]189                    if lignes:190                        payload["address"] = ", ".join(lignes)191                break192        return payload193194    def _enrich_from_detail(self, lst: Listing) -> None:195        """Complète l'annonce avec la fiche unité (via cache self.detail)."""196        key = hashlib.sha1(197            f"{lst.unit_type}|{lst.price_label}|{lst.availability}"198            .encode("utf-8")).hexdigest()199200        def fetch_fn():201            if self._detail_fetches >= self.max_details:202                raise _DetailBudget()203            self._detail_fetches += 1204            return self._fetch_detail(lst.url)205206        try:207            payload = self.detail(lst.external_id, key, fetch_fn)208        except _DetailBudget:209            return210        if not payload:211            return212213        if payload.get("sector"):214            lst.sector = payload["sector"]215            lst.city = infer_city(lst.sector, default=lst.city or "Québec")216        if payload.get("images"):217            lst.images = payload["images"]218        if payload.get("address"):219            lst.address = payload["address"]220        if payload.get("description"):221            lst.description = payload["description"]222        if payload.get("amenities"):223            lst.amenities = payload["amenities"]224        if payload.get("area_sqft"):225            lst.area_sqft = payload["area_sqft"]   # structuré à la source226        if payload.get("floor"):227            lst.details["floor"] = payload["floor"]228        if payload.get("availability") and not lst.availability:229            lst.availability = payload["availability"]230231    # -- contrat ---------------------------------------------------------------232    def fetch(self) -> list[Listing]:233        html = self.get(SEARCH_URL).text234        soup = BeautifulSoup(html, "html.parser")235236        listings: dict[str, Listing] = {}237        for card in soup.select(".un-appart"):238            try:239                link = card.select_one("a.title-link")240                if not link or not link.get("href"):241                    continue242                slug = link["href"].rstrip("/").rsplit("/", 1)[-1]243                name_el = link.find(["h2", "h3"])244                complex_name = (name_el.get_text(" ", strip=True)245                                if name_el else slug.replace("-", " ").title())246247                address, city = self._card_address_city(card)248                city_key = strip_accents(city.lower().strip())249                if city_key not in ALLOWED_CITIES:250                    continue  # hors région Québec/Lévis (ex. Montréal)251252                # lignes cliquables du tableau = unités disponibles253                for row in card.select("tr.clickable-row[data-href]"):254                    cells = [td.get_text(" ", strip=True)255                             for td in row.find_all("td")]256                    if len(cells) < 3:257                        continue258                    unit_type_raw, price_raw, avail_raw = cells[0], cells[1], cells[2]259                    if EXCLUDE_UNIT_RE.search(unit_type_raw):260                        continue261                    href = row["data-href"]262                    unit_code = href.rstrip("/").rsplit("/", 1)[-1]263                    ext_id = f"{slug}-{unit_code}"264                    if ext_id in listings:265                        continue266                    avail = re.sub(r"\s*chevron_right\s*$", "",267                                   avail_raw).strip()268                    listings[ext_id] = Listing(269                        source=self.source_id,270                        external_id=ext_id,271                        url=urljoin(BASE, href),272                        title=f"{complex_name}{unit_type_raw} (#{unit_code})",273                        address=address,274                        sector="",275                        city=city,276                        unit_type=normalize_unit_type(unit_type_raw),277                        price=parse_price(price_raw),278                        price_label=price_raw,279                        availability=avail,280                    )281            except Exception:282                continue  # carte malformée : on passe à la suivante283284        # fiches détaillées (photos, secteur, description, inclusions) —285        # via self.detail() : seules les unités nouvelles/modifiées sont286        # réellement visitées (max self.max_details par synchronisation)287        self._detail_fetches = 0288        for lst in listings.values():289            try:290                self._enrich_from_detail(lst)291            except Exception:292                continue293294        return list(listings.values())295