SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.7 KB · 169 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/gestion_bloc.py : connecteur Gestion Bloc (gestionbloc.ca)5#   Gestionnaire immobilier de Québec / Lévis / Chaudière-Appalaches. Site Wix6#   MAIS la page /logements est rendue serveur (SSR Thunderbolt) : galeries7#   Pro Gallery avec une carte par logement — titre (rue), localité, puis8#   « Pièces / Chambre(s) / Salle(s) de bain ». Annonces sans prix ni date9#   (Tier B). Granularité : logement (une carte = une annonce).10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, strip_accents18from .base import BaseConnector1920BASE = "https://www.gestionbloc.ca"21LIST_URL = f"{BASE}/logements"2223ROOMS_RE = re.compile(r"pi[eè]ces?\s*:?\s*([\d]+(?:\s*1/2|\s*½|\.5)?)", re.I)24BED_RE = re.compile(r"chambres?\s*:?\s*(\d+(?:\.\d+)?)", re.I)25BATH_RE = re.compile(r"salles?\s*de\s*bain\s*:?\s*(\d+(?:\.\d+)?)", re.I)26LIT_RE = re.compile(r"lits?\s*:?\s*(\d+)", re.I)2728CONTACT = {"email": "reception@gestionbloc.com", "phone": "418-903-5585"}2930# quartiers/secteurs de la ville de Québec (pour distinguer « Val-Bélair,31# Québec » [= ville de Québec] de « Donnacona, Québec » [= province])32QUEBEC_SECTORS = {33    "val-belair", "cap-rouge", "limoilou", "charlesbourg", "sainte-foy",34    "ste-foy", "beauport", "sillery", "vanier", "duberger", "les-saules",35    "lebourgneuf", "loretteville", "neufchatel", "saint-sauveur",36    "saint-roch", "montcalm", "saint-jean-baptiste", "lairet", "maizerets",37}38LEVIS_SECTORS = {39    "levis", "saint-jean-chrysostome", "st-jean-chrysostome", "charny",40    "saint-romuald", "st-romuald", "saint-nicolas", "st-nicolas",41    "pintendre", "breakeyville", "saint-redempteur", "st-redempteur",42}434445def _slug(text: str) -> str:46    return re.sub(r"-+", "-",47                  re.sub(r"[^a-z0-9]+", "-", strip_accents(text.lower()))48                  ).strip("-")495051class GestionBlocConnector(BaseConnector):52    source_id = "gestion_bloc"53    request_delay = 0.65455    def fetch(self) -> list[Listing]:56        listings: list[Listing] = []57        try:58            html = self.get(LIST_URL).text59        except Exception:60            return listings61        soup = BeautifulSoup(html, "html.parser")6263        seen: set[str] = set()64        # 2 galeries : « Ville de Québec & Rive Nord » puis « Ville de Lévis65        # & Rive Sud » — sert de ville par défaut quand la carte n'en a pas.66        galleries = soup.select("div.pro-gallery-margin-container") or [soup]67        for gi, gallery in enumerate(galleries):68            default_city = "Québec" if gi == 0 else "Lévis"69            for title_el in gallery.select('[data-hook="item-title"]'):70                try:71                    lst = self._parse_card(title_el, default_city, seen)72                except Exception:73                    continue74                if lst is not None:75                    listings.append(lst)76        return listings7778    def _parse_card(self, title_el, default_city: str,79                    seen: set[str]) -> Listing | None:80        info = title_el.find_parent(class_="info-element-text")81        if info is None:82            return None83        lines = [ln.strip() for ln in84                 info.get_text("\n", strip=True).split("\n")85                 if ln.strip() and ln.strip() != "​"]86        if not lines:87            return None88        street = lines[0]89        locality = lines[1] if len(lines) > 1 else ""90        blob = " | ".join(lines[2:])9192        # localité « Secteur, Ville » — mais « X, Québec » peut vouloir dire93        # « quartier de la ville de Québec » OU « ville X, province de94        # Québec » : on tranche avec les listes de quartiers connus.95        parts = [p.strip(" .") for p in locality.split(",") if p.strip(" .")]96        sector, city = "", default_city97        if len(parts) >= 2:98            first = strip_accents(parts[0].lower()).replace(" ", "-")99            if first in QUEBEC_SECTORS:100                sector, city = parts[0], "Québec"101            elif first in LEVIS_SECTORS:102                sector = parts[0] if first != "levis" else ""103                city = "Lévis"104            else:105                city = parts[0]          # ville autonome (Donnacona…)106        elif parts:107            solo = strip_accents(parts[0].lower()).replace(" ", "-")108            m = re.match(r"^(.*)\((qu[ée]bec)\)$", parts[0], re.I)109            if m:                        # « Sainte-Foy (Québec) »110                sector, city = m.group(1).strip(), "Québec"111            elif solo in QUEBEC_SECTORS:112                sector, city = parts[0], "Québec"113            elif solo in LEVIS_SECTORS:114                city = "Lévis"115            else:116                city = parts[0]117        if sector and "(" in sector:118            sector = sector.split("(")[0].strip()119        if sector.lower() == city.lower():120            sector = ""121122        bedrooms = None123        m = BED_RE.search(blob) or LIT_RE.search(blob)124        if m:125            bedrooms = float(m.group(1))126        bathrooms = None127        m = BATH_RE.search(blob)128        if m:129            bathrooms = float(m.group(1))130        unit_type = ""131        m = ROOMS_RE.search(blob)132        if m:133            unit_type = m.group(1).replace("1/2", "½") \134                .replace(".5", "½").replace(" ", "")135            if "½" not in unit_type:136                unit_type += "½"137138        ext_id = _slug(f"{street}-{sector or city}")139        if not ext_id or ext_id in seen:140            return None141        seen.add(ext_id)142143        # photo de la carte (galerie Wix, URL nettoyée des transformations)144        images: list[str] = []145        container = title_el.find_parent(attrs={"data-hook": "item-container"})146        if container is not None:147            img = container.select_one(148                'img[data-hook="gallery-item-image-img"]')149            if img and img.get("src", "").startswith("http"):150                images = [img["src"].split("/v1/")[0]]151152        return Listing(153            source=self.source_id,154            external_id=ext_id,155            url=LIST_URL,156            title=street,157            address=f"{street}, {city}" if city else street,158            sector=sector,159            city=city,160            unit_type=unit_type,161            bedrooms=bedrooms,162            bathrooms=bathrooms,163            price_label="Prix sur demande",164            description=f"Logement à louer sur {street} "165                        f"({locality}) — Gestion Bloc. {blob}",166            details={"contact": dict(CONTACT)},167            images=images,168        )169