# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/laberge.py : connecteur Gestion immobilière Laberge (laberge.qc.ca)
# Site rendu serveur. La page /recherche liste tous les complexes avec, pour
# chacun, un tableau des types d'unités ; les lignes cliquables
# (data-href="/complexe/{slug}/appartement/{code}") sont les unités
# disponibles. Une annonce par unité disponible ; la fiche unité fournit
# les photos, le secteur, la superficie, la description et les inclusions.
# Région retenue : Québec / Lévis / L'Ancienne-Lorette (les complexes de la
# région de Montréal — Côte St-Luc, LaSalle, Pierrefonds, etc. — sont exclus).
# Les fiches unités sont visitées via self.detail() (cache BD, max
# max_details nouveaux fetchs par synchronisation).
# -----------------------------------------------------------------------------
from __future__ import annotations
import hashlib
import re
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from ..schema import Listing, infer_city, normalize_unit_type, parse_price, strip_accents
from .base import BaseConnector
BASE = "https://www.laberge.qc.ca"
SEARCH_URL = f"{BASE}/recherche"
# villes de la région Québec/Lévis retenues (normalisées sans accents)
ALLOWED_CITIES = {
"quebec", "levis", "l'ancienne-lorette", "ancienne-lorette",
"saint-augustin-de-desmaures", "st-augustin-de-desmaures",
}
IMG_RE = re.compile(r"^/image/\d+/\d+/")
EXCLUDE_UNIT_RE = re.compile(r"stationnement|parking|rangement|commercial|garage", re.I)
# pictogrammes dont le libellé texte est générique ("Inclus")
_PICTO_NAMES = {
"poele--frigo": "Poêle et réfrigérateur",
"entree_laveuse_secheuse": "Entrée laveuse-sécheuse",
"laveuse_secheuse": "Laveuse-sécheuse",
"lave-vaisselle": "Lave-vaisselle",
"lv": "Lave-vaisselle",
"micro-onde": "Micro-ondes",
"air_climatise": "Air climatisé",
}
class _DetailBudget(Exception):
"""Budget de fiches unités atteint pour cette synchronisation."""
class LabergeConnector(BaseConnector):
source_id = "laberge"
request_delay = 0.6
max_details = 150 # garde-fou : nb max de fiches unités visitées
max_images = 25
# -- helpers ---------------------------------------------------------------
@staticmethod
def _card_address_city(card) -> tuple[str, str]:
"""Adresse civique + ville depuis la carte complexe de /recherche."""
p = card.select_one(".bg-white p")
if not p:
return "", ""
for a in p.find_all("a"): # retirer le lien téléphone
a.decompose()
addr = p.get_text(" ", strip=True)
# ex. « 224, rue seigneuriale, Québec, G1E 0M8 »
m = re.search(r"^(.*?),\s*([^,]+?),?\s*[A-Z]\d[A-Z]\s*\d[A-Z]\d", addr)
if m:
return m.group(1).strip(), m.group(2).strip()
parts = [x.strip() for x in addr.split(",") if x.strip()]
if len(parts) >= 2:
return ", ".join(parts[:-1]), parts[-1]
return addr, ""
@staticmethod
def _picto_labels(container) -> list[str]:
"""Libellés des pictogrammes non grisés (grisé = exclu/absent)."""
labels: list[str] = []
for cell in container.select("div.text-center"):
p = cell.find("p")
img = cell.find("img")
if p is None:
continue
style = p.get("style") or ""
src = (img.get("src") or "") if img else ""
if "#ccc" in style or "_off." in src:
continue # service exclu (picto grisé)
label = p.get_text(" ", strip=True)
if not label or label.lower() == "inclus":
stem = src.rsplit("/", 1)[-1].rsplit(".", 1)[0]
label = _PICTO_NAMES.get(stem, stem.replace("_", " ")
.replace("--", " ").replace("-", " ")
.strip().capitalize())
if label and label not in labels:
labels.append(label)
return labels
def _fetch_detail(self, url: str) -> dict:
"""Fiche unité -> payload JSON-sérialisable (mis en cache BD)."""
html = self.get(url).text
soup = BeautifulSoup(html, "html.parser")
payload: dict = {}
# secteur depuis le
: « Appartement - Beauport - Le 224 #… »
if soup.title:
parts = soup.title.get_text(strip=True).split(" - ")
if len(parts) >= 2 and parts[0].lower().startswith("appartement"):
payload["sector"] = parts[1].strip()
# photos de l'unité (URLs absolues)
imgs = []
for img in soup.find_all("img"):
src = img.get("src") or img.get("data-src") or ""
if IMG_RE.match(src):
imgs.append(urljoin(BASE, src))
payload["images"] = list(dict.fromkeys(imgs))[: self.max_images]
# bloc « Grandeur / Étage / Disponibilité / Superficie / Débutant » :
# paires h3 -> p structurées de la fiche
for block in soup.select(".caracteristiques-appart.description-appart"):
for h3 in block.find_all("h3"):
label = h3.get_text(" ", strip=True).lower()
val_el = h3.find_next_sibling("p")
if not val_el:
continue
val = " ".join(val_el.get_text(" ", strip=True).split())
if label.startswith("superficie"):
m = re.search(r"([\d\s.,]+?)\s*pi", val)
if m:
try:
payload["area_sqft"] = float(
m.group(1).replace(" ", "").replace(",", "."))
except ValueError:
pass
elif label.startswith("étage") or label.startswith("etage"):
if val.isdigit():
payload["floor"] = int(val)
elif label.startswith("disponibilit"):
payload["availability"] = val
# description (paragraphe sous le h2 « Description »)
for h2 in soup.find_all("h2"):
if h2.get_text(strip=True).lower().startswith("description"):
sib = h2.find_next("p")
if sib:
desc = sib.get_text(" ", strip=True)
if len(desc) > 40:
payload["description"] = desc[:600]
break
amenities: list[str] = []
# inclusions de l'unité (pictos non grisés = inclus)
for block in soup.select(".caracteristiques-appart"):
for label in self._picto_labels(block):
if label not in amenities:
amenities.append(label)
# caractéristiques en liste (« Entrée laveuse, sécheuse »,
# « Balcon privé : 1 », « Stationnement extérieur : … »)
for li in block.select("ul li"):
label = " ".join(li.get_text(" ", strip=True).split())
if label and label not in amenities:
amenities.append(label)
# services de l'immeuble + dispositifs de sécurité (pictos non grisés)
for h2 in soup.find_all("h2"):
titre = h2.get_text(strip=True).lower()
if titre.startswith(("services de l", "dispositifs de s")):
parent = h2.find_parent("div")
row = parent.parent if parent else None
if row is None:
continue
for label in self._picto_labels(row):
if label not in amenities:
amenities.append(label)
payload["amenities"] = amenities
# adresse complète de l'immeuble (avec code postal) : h3 « Adresse »
for h3 in soup.find_all("h3"):
if h3.get_text(strip=True).lower() == "adresse":
p = h3.find_next_sibling("p")
if p:
for a in p.find_all("a"): # lien « Autres adresses »
a.decompose()
lignes = [" ".join(x.split()) for x in p.stripped_strings]
if lignes:
payload["address"] = ", ".join(lignes)
break
return payload
def _enrich_from_detail(self, lst: Listing) -> None:
"""Complète l'annonce avec la fiche unité (via cache self.detail)."""
key = hashlib.sha1(
f"{lst.unit_type}|{lst.price_label}|{lst.availability}"
.encode("utf-8")).hexdigest()
def fetch_fn():
if self._detail_fetches >= self.max_details:
raise _DetailBudget()
self._detail_fetches += 1
return self._fetch_detail(lst.url)
try:
payload = self.detail(lst.external_id, key, fetch_fn)
except _DetailBudget:
return
if not payload:
return
if payload.get("sector"):
lst.sector = payload["sector"]
lst.city = infer_city(lst.sector, default=lst.city or "Québec")
if payload.get("images"):
lst.images = payload["images"]
if payload.get("address"):
lst.address = payload["address"]
if payload.get("description"):
lst.description = payload["description"]
if payload.get("amenities"):
lst.amenities = payload["amenities"]
if payload.get("area_sqft"):
lst.area_sqft = payload["area_sqft"] # structuré à la source
if payload.get("floor"):
lst.details["floor"] = payload["floor"]
if payload.get("availability") and not lst.availability:
lst.availability = payload["availability"]
# -- contrat ---------------------------------------------------------------
def fetch(self) -> list[Listing]:
html = self.get(SEARCH_URL).text
soup = BeautifulSoup(html, "html.parser")
listings: dict[str, Listing] = {}
for card in soup.select(".un-appart"):
try:
link = card.select_one("a.title-link")
if not link or not link.get("href"):
continue
slug = link["href"].rstrip("/").rsplit("/", 1)[-1]
name_el = link.find(["h2", "h3"])
complex_name = (name_el.get_text(" ", strip=True)
if name_el else slug.replace("-", " ").title())
address, city = self._card_address_city(card)
city_key = strip_accents(city.lower().strip())
if city_key not in ALLOWED_CITIES:
continue # hors région Québec/Lévis (ex. Montréal)
# lignes cliquables du tableau = unités disponibles
for row in card.select("tr.clickable-row[data-href]"):
cells = [td.get_text(" ", strip=True)
for td in row.find_all("td")]
if len(cells) < 3:
continue
unit_type_raw, price_raw, avail_raw = cells[0], cells[1], cells[2]
if EXCLUDE_UNIT_RE.search(unit_type_raw):
continue
href = row["data-href"]
unit_code = href.rstrip("/").rsplit("/", 1)[-1]
ext_id = f"{slug}-{unit_code}"
if ext_id in listings:
continue
avail = re.sub(r"\s*chevron_right\s*$", "",
avail_raw).strip()
listings[ext_id] = Listing(
source=self.source_id,
external_id=ext_id,
url=urljoin(BASE, href),
title=f"{complex_name} — {unit_type_raw} (#{unit_code})",
address=address,
sector="",
city=city,
unit_type=normalize_unit_type(unit_type_raw),
price=parse_price(price_raw),
price_label=price_raw,
availability=avail,
)
except Exception:
continue # carte malformée : on passe à la suivante
# fiches détaillées (photos, secteur, description, inclusions) —
# via self.detail() : seules les unités nouvelles/modifiées sont
# réellement visitées (max self.max_details par synchronisation)
self._detail_fetches = 0
for lst in listings.values():
try:
self._enrich_from_detail(lst)
except Exception:
continue
return list(listings.values())