# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_bloc.py : connecteur Gestion Bloc (gestionbloc.ca) # Gestionnaire immobilier de Québec / Lévis / Chaudière-Appalaches. Site Wix # MAIS la page /logements est rendue serveur (SSR Thunderbolt) : galeries # Pro Gallery avec une carte par logement — titre (rue), localité, puis # « Pièces / Chambre(s) / Salle(s) de bain ». Annonces sans prix ni date # (Tier B). Granularité : logement (une carte = une annonce). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, strip_accents from .base import BaseConnector BASE = "https://www.gestionbloc.ca" LIST_URL = f"{BASE}/logements" ROOMS_RE = re.compile(r"pi[eè]ces?\s*:?\s*([\d]+(?:\s*1/2|\s*½|\.5)?)", re.I) BED_RE = re.compile(r"chambres?\s*:?\s*(\d+(?:\.\d+)?)", re.I) BATH_RE = re.compile(r"salles?\s*de\s*bain\s*:?\s*(\d+(?:\.\d+)?)", re.I) LIT_RE = re.compile(r"lits?\s*:?\s*(\d+)", re.I) CONTACT = {"email": "reception@gestionbloc.com", "phone": "418-903-5585"} # quartiers/secteurs de la ville de Québec (pour distinguer « Val-Bélair, # Québec » [= ville de Québec] de « Donnacona, Québec » [= province]) QUEBEC_SECTORS = { "val-belair", "cap-rouge", "limoilou", "charlesbourg", "sainte-foy", "ste-foy", "beauport", "sillery", "vanier", "duberger", "les-saules", "lebourgneuf", "loretteville", "neufchatel", "saint-sauveur", "saint-roch", "montcalm", "saint-jean-baptiste", "lairet", "maizerets", } LEVIS_SECTORS = { "levis", "saint-jean-chrysostome", "st-jean-chrysostome", "charny", "saint-romuald", "st-romuald", "saint-nicolas", "st-nicolas", "pintendre", "breakeyville", "saint-redempteur", "st-redempteur", } def _slug(text: str) -> str: return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", strip_accents(text.lower())) ).strip("-") class GestionBlocConnector(BaseConnector): source_id = "gestion_bloc" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") seen: set[str] = set() # 2 galeries : « Ville de Québec & Rive Nord » puis « Ville de Lévis # & Rive Sud » — sert de ville par défaut quand la carte n'en a pas. galleries = soup.select("div.pro-gallery-margin-container") or [soup] for gi, gallery in enumerate(galleries): default_city = "Québec" if gi == 0 else "Lévis" for title_el in gallery.select('[data-hook="item-title"]'): try: lst = self._parse_card(title_el, default_city, seen) except Exception: continue if lst is not None: listings.append(lst) return listings def _parse_card(self, title_el, default_city: str, seen: set[str]) -> Listing | None: info = title_el.find_parent(class_="info-element-text") if info is None: return None lines = [ln.strip() for ln in info.get_text("\n", strip=True).split("\n") if ln.strip() and ln.strip() != "​"] if not lines: return None street = lines[0] locality = lines[1] if len(lines) > 1 else "" blob = " | ".join(lines[2:]) # localité « Secteur, Ville » — mais « X, Québec » peut vouloir dire # « quartier de la ville de Québec » OU « ville X, province de # Québec » : on tranche avec les listes de quartiers connus. parts = [p.strip(" .") for p in locality.split(",") if p.strip(" .")] sector, city = "", default_city if len(parts) >= 2: first = strip_accents(parts[0].lower()).replace(" ", "-") if first in QUEBEC_SECTORS: sector, city = parts[0], "Québec" elif first in LEVIS_SECTORS: sector = parts[0] if first != "levis" else "" city = "Lévis" else: city = parts[0] # ville autonome (Donnacona…) elif parts: solo = strip_accents(parts[0].lower()).replace(" ", "-") m = re.match(r"^(.*)\((qu[ée]bec)\)$", parts[0], re.I) if m: # « Sainte-Foy (Québec) » sector, city = m.group(1).strip(), "Québec" elif solo in QUEBEC_SECTORS: sector, city = parts[0], "Québec" elif solo in LEVIS_SECTORS: city = "Lévis" else: city = parts[0] if sector and "(" in sector: sector = sector.split("(")[0].strip() if sector.lower() == city.lower(): sector = "" bedrooms = None m = BED_RE.search(blob) or LIT_RE.search(blob) if m: bedrooms = float(m.group(1)) bathrooms = None m = BATH_RE.search(blob) if m: bathrooms = float(m.group(1)) unit_type = "" m = ROOMS_RE.search(blob) if m: unit_type = m.group(1).replace("1/2", "½") \ .replace(".5", "½").replace(" ", "") if "½" not in unit_type: unit_type += "½" ext_id = _slug(f"{street}-{sector or city}") if not ext_id or ext_id in seen: return None seen.add(ext_id) # photo de la carte (galerie Wix, URL nettoyée des transformations) images: list[str] = [] container = title_el.find_parent(attrs={"data-hook": "item-container"}) if container is not None: img = container.select_one( 'img[data-hook="gallery-item-image-img"]') if img and img.get("src", "").startswith("http"): images = [img["src"].split("/v1/")[0]] return Listing( source=self.source_id, external_id=ext_id, url=LIST_URL, title=street, address=f"{street}, {city}" if city else street, sector=sector, city=city, unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price_label="Prix sur demande", description=f"Logement à louer sur {street} " f"({locality}) — Gestion Bloc. {blob}", details={"contact": dict(CONTACT)}, images=images, )