SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.0 KB · 128 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/quartier_mosaique.py : connecteur Quartier Mosaïque (Constrobourg)5#   (quartiermosaique.com — 5 complexes : Rubis, Saphir, Opale 1-2, Perle,6#   secteur Lebourgneuf près des Galeries de la Capitale, Québec).7#   Page /unites-disponibles/ rendue serveur (WordPress, thème Browsy) : une8#   carte par plan d'unité disponible (div.cardib.unites-item) avec titre9#   (« Rubis – 3 ½ »), type (data-type), prix « À partir de X $ par mois »10#   (cardib-excerpt), description (data-desc, JSON) et galerie complète11#   (data-gallery, JSON). Aucune page détail nécessaire : tout est sur la liste.12#   Adresse du quartier publiée sur /contact/ : 2013, boulevard Lebourgneuf.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import re18from urllib.parse import unquote1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://quartiermosaique.com"26LIST_URL = f"{BASE}/unites-disponibles/"2728# Adresse du projet telle que publiée sur https://quartiermosaique.com/contact/29# (les 5 bâtiments partagent le même quadrilatère, aucune adresse par bâtiment30# n'est affichée sur le site).31PROJECT_ADDRESS = "2013, boulevard Lebourgneuf, Québec, G2K 0N8"323334def _strip_tags(html: str) -> str:35    """'<p>Condos locatifs…</p>' -> texte brut, espaces normalisés."""36    txt = re.sub(r"<[^>]+>", " ", html or "")37    return re.sub(r"\s+", " ", txt).strip()383940class QuartierMosaiqueConnector(BaseConnector):41    source_id = "quartier_mosaique"42    request_delay = 0.64344    def fetch(self) -> list[Listing]:45        html = self.get(LIST_URL).text46        soup = BeautifulSoup(html, "html.parser")4748        listings: dict[str, Listing] = {}49        # les cartes sont dupliquées (grille + carrousel swiper) : dédupliquer50        for card in soup.select("div.cardib"):51            try:52                self._parse_card(card, listings)53            except Exception:54                continue55        return list(listings.values())5657    # -- carte « unité disponible » (thème Browsy) ------------------------------58    def _parse_card(self, card, listings: dict[str, Listing]) -> None:59        link = card.select_one('a[href*="/unites/"]')60        title = " ".join((card.get("data-title") or "").split())61        if not link or not title:62            return63        url = link["href"]64        m = re.search(r"/unites/([^/]+)/?", url)65        if not m:66            return67        ext_id = unquote(m.group(1))         # slug WordPress stable (« rubis-3-½ »)68        if ext_id in listings:69            return7071        # prix rendu dans la page : « À partir de 1 457 $ par mois »72        exc = card.select_one(".cardib-excerpt")73        price_label = exc.get_text(" ", strip=True) if exc else ""7475        # type d'unité structuré (data-type : « 3 ½ », « 3 ½ Bureau », « Loft »)76        raw_type = " ".join((card.get("data-type") or "").split())77        unit_type = normalize_unit_type(raw_type or title)7879        # description (attribut data-desc : chaîne JSON contenant du HTML)80        description = ""81        raw_desc = card.get("data-desc") or ""82        if raw_desc:83            try:84                description = _strip_tags(json.loads(raw_desc))85            except (ValueError, TypeError):86                description = _strip_tags(raw_desc)8788        # galerie complète (attribut data-gallery : tableau JSON de médias)89        images: list[str] = []90        raw_gallery = card.get("data-gallery") or ""91        if raw_gallery:92            try:93                for media in json.loads(raw_gallery):94                    u = (media or {}).get("url") or ""95                    if u.startswith("http") and u not in images:96                        images.append(u)97            except (ValueError, TypeError):98                pass99        if not images:100            img = card.select_one("img[src]")101            if img and img["src"].startswith("http"):102                images = [img["src"]]103104        # bâtiment : préfixe du titre (« Rubis – 3 ½ » -> « Rubis »)105        building = title.split("–")[0].strip()106107        details: dict = {}108        if building:109            details["building"] = building110        if re.search(r"\bbureau\b", raw_type, re.I):111            details["office_nook"] = True    # variante avec coin bureau112113        listings[ext_id] = Listing(114            source=self.source_id,115            external_id=ext_id,116            url=url,117            title=title,118            address=PROJECT_ADDRESS,119            sector="Lebourgneuf",120            city="Québec",121            unit_type=unit_type,122            price=parse_price(price_label),123            price_label=price_label,124            description=description,125            details=details,126            images=images[:20],127        )128