Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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