# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/immeubles_db.py : connecteur Les Immeubles D.B. Busque-Massé
# (immeublesdb.com) — 12 immeubles / ~400 apparts à Sherbrooke, 4 secteurs.
# Site custom PHP (« pm_editor », derrière Cloudflare — le UA de base.py
# passe) : 4 pages statiques /logements-a-louer/secteur-{nord,est,
# centre-ville,ouest}/. Chaque logement offert = une section .container
# avec une cellule galerie (photos /modules/upload/…) et une cellule texte
# (h3 = adresse civique, liste
= type, prix « … $ par mois »,
# inclusions). Aucun identifiant publié : external_id = empreinte stable
# (secteur + adresse + ligne de type). Les logements loués sont simplement
# retirés de la page (aucun marqueur « complet » à filtrer).
# -----------------------------------------------------------------------------
from __future__ import annotations
import hashlib
import re
from bs4 import BeautifulSoup
from ..schema import Listing, normalize_unit_type, parse_price
from .base import BaseConnector
BASE = "https://immeublesdb.com"
SECTORS = {
"secteur-nord": "Nord",
"secteur-est": "Est",
"secteur-centre-ville": "Centre-ville",
"secteur-ouest": "Ouest",
}
_PRICE_LI = re.compile(r"\d[\d\s]{0,5}\s?\$\s*par mois", re.I)
def _clean_price_label(label: str) -> str:
"""« 1 125$ par mois » (espace de milliers) -> compatible parse_price."""
return re.sub(r"(\d)\s(\d{3})", r"\1\2", label)
class ImmeublesDbConnector(BaseConnector):
source_id = "immeubles_db"
request_delay = 0.6
def fetch(self) -> list[Listing]:
listings: dict[str, Listing] = {}
for slug, sector in SECTORS.items():
try:
html = self.get(f"{BASE}/logements-a-louer/{slug}/").text
except Exception:
continue
soup = BeautifulSoup(html, "html.parser")
for container in soup.select("div.container"):
try:
lst = self._parse_block(container, sector)
except Exception:
continue
if lst and lst.external_id not in listings:
listings[lst.external_id] = lst
return list(listings.values())
# -- une section .container = un logement offert --------------------------------
def _parse_block(self, container, sector: str) -> Listing | None:
h3 = container.select_one(".cell_container h3")
ul = container.select_one(".cell_container ul")
if not (h3 and ul):
return None
address_full = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip(" ,")
lis = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()
for li in ul.select("li")]
lis = [t for t in lis if t]
if not (address_full and lis):
return None
# « 250, Olivier, Sherbrooke, QC » -> adresse sans la province
parts = [p.strip() for p in address_full.split(",") if p.strip()]
address = ", ".join(p for p in parts if p.upper() != "QC")
city = "Sherbrooke"
type_line = lis[0]
# « 4 CHAMBRES disponibles à partir de 550 $ » = location à la chambre
# (normalize_unit_type transformerait « 4 chambres » en 6½+)
if re.search(r"chambres?\s+disponibles?", type_line, re.I):
unit_type = "Chambre"
else:
unit_type = normalize_unit_type(type_line)
if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison",
unit_type or ""):
unit_type = ""
# prix : première ligne « … $ par mois »
price = None
price_label = ""
for t in lis:
if _PRICE_LI.search(t):
price_label = t
price = parse_price(_clean_price_label(t))
break
# disponibilité : seulement si la source l'écrit (sinon vide)
availability = ""
m = re.search(r"libre\s+imm[ée]diatement", " ".join(lis), re.I)
if m:
availability = "Libre immédiatement"
# photos de la galerie jumelle (chemins relatifs /modules/upload/…)
images: list[str] = []
for img in container.select(".galerie_img_block a[href]"):
href = (img.get("href") or "").strip()
if href.startswith("/"):
href = BASE + href
if href.startswith("http") and href not in images:
images.append(href)
ext_id = hashlib.sha1(
f"{sector}|{address_full}|{type_line}".encode("utf-8")).hexdigest()[:16]
return Listing(
source=self.source_id,
external_id=ext_id,
url=f"{BASE}/logements-a-louer/"
f"{[k for k, v in SECTORS.items() if v == sector][0]}/",
title=f"{type_line} — {address}",
address=address,
sector=sector,
city=city,
unit_type=unit_type,
price=price,
price_label=price_label,
availability=availability,
description=" | ".join(lis)[:1500],
images=images[:20],
)