# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/roussin.py : connecteur Immeubles Roussin (immeublesroussin.com)
# Immeubles/projets résidentiels (L'Aromate, L'Allié, La Vigie, etc.) —
# une annonce par type d'unité et par immeuble, prix « à partir de ».
# Les pages commerciales (locaux, bureaux, entrepôts) sont exclues.
# Commodités : paires icône+texte Elementor (Gym, Eau chaude, Animaux
# acceptés (20lb et moins)…) ; contact : liens tel:/mailto: structurés.
# -----------------------------------------------------------------------------
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from ..schema import Listing, infer_city, normalize_unit_type, parse_price
from .base import BaseConnector
BASE = "https://immeublesroussin.com"
INDEX = f"{BASE}/location-condo-appartement-quebec/"
# Lignes de prix : « Loft à partir de 1200$ », « 3 ½ à partir de 1595$ »...
PRICE_LINE_RE = re.compile(
r"((?:\d\s*(?:½|1/2))|Loft|Studio|Maison)[^<>$]{0,30}?"
r"à\s+partir\s+de\s*([\d][\d\s ,]*)\s*\$",
re.I,
)
IMG_RE = re.compile(
r"https://immeublesroussin\.com/wp-content/uploads/[^\"'\\\s\)]+"
r"\.(?:jpg|jpeg|png|webp)", re.I)
IMG_NOISE_RE = re.compile(r"favicon|logo|icon|cropped|header", re.I)
ADDR_RE = re.compile(
r"\d{2,5}[,]?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|place)"
r"\s[^<>{}\"]{3,60}", re.I)
BUILDING_RE = re.compile(r"/location-condo-appartement-quebec/([a-z0-9\-]+)/?$")
class RoussinConnector(BaseConnector):
source_id = "roussin"
request_delay = 0.6
max_buildings = 30 # garde-fou
def fetch(self) -> list[Listing]:
# 1) Découvrir les pages d'immeubles résidentiels
html = self.get(INDEX).text
slugs: list[str] = []
for href in re.findall(r'href="([^"]+)"', html):
m = BUILDING_RE.search(href.split("#")[0].split("?")[0])
if m and m.group(1) not in slugs:
slugs.append(m.group(1))
listings: list[Listing] = []
for slug in slugs[: self.max_buildings]:
url = f"{INDEX}{slug}/"
try:
page = self.get(url).text
except Exception:
continue
try:
listings.extend(self._parse_building(slug, url, page))
except Exception:
continue
return listings
def _parse_building(self, slug: str, url: str, page: str) -> list[Listing]:
soup = BeautifulSoup(page, "html.parser")
# Nom + secteur depuis le
:
# « L'Aromate - Location de condos à Ste-Foy | Immeubles Roussin »
title_tag = soup.find("title")
raw_title = title_tag.get_text(strip=True) if title_tag else slug
name = re.split(r"\s+[-–|]\s+", raw_title)[0].strip() or slug
if re.search(r"louer|condos? neufs?", name, re.I):
# Titre générique (« Condos à louer à Lévis ») : essayer la partie
# après « : », sinon un nom dérivé du slug.
m = re.search(r":\s*([^|]+)", raw_title)
name = (m.group(1).strip() if m
else slug.replace("-", " ").strip().title())
# Description (og:description)
desc = ""
og = soup.find("meta", attrs={"property": "og:description"})
if og and og.get("content"):
desc = og["content"].strip()[:600]
# Adresse civique (en excluant le bureau administratif de Roussin)
address = ""
for cand in ADDR_RE.findall(page):
if re.search(r"bureau", cand, re.I):
continue
address = re.sub(r"\s+", " ", cand).strip().rstrip(",")
break
# Secteur : mots-clés dans le titre puis dans l'adresse
sector = ""
m = re.search(r"(Sainte-Foy|Ste-Foy|L[ée]vis|Beauport|Sillery|"
r"Cap-Rouge|St-Augustin)", f"{raw_title} {address}", re.I)
if m:
sector = m.group(1)
# Images (galerie de l'immeuble)
images = [u for u in dict.fromkeys(IMG_RE.findall(page))
if not IMG_NOISE_RE.search(u)][:25]
# Commodités : paires icône (« iconeGym.jpg ») + widget texte Elementor
amenities: list[str] = []
for img in soup.select('img[src*="icone"]'):
cont = img.find_parent(class_="elementor-container")
if not cont:
continue
for te in cont.select(".elementor-widget-text-editor "
".elementor-widget-container"):
t = te.get_text(" ", strip=True)
if 2 < len(t) < 45 and t not in amenities:
amenities.append(t)
amenities = amenities[:20]
# Contact du gestionnaire (liens tel:/mailto: structurés)
details: dict = {}
contact: dict = {}
tel = soup.select_one('a[href^="tel:"]')
if tel:
tm = re.search(r"\(?([2-9]\d{2})\)?[\s.%20\-]*(\d{3})[\s.\-]?(\d{4})",
(tel.get("href") or "").replace("%20", ""))
if tm:
contact["phone"] = f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}"
mail = soup.select_one('a[href^="mailto:"]')
if mail:
contact["email"] = (mail.get("href") or "")[7:].split("?")[0]
if contact:
details["contact"] = contact
# Types d'unités avec prix « à partir de »
text = soup.get_text(" ", strip=True)
seen: set[str] = set()
out: list[Listing] = []
for type_raw, amount in PRICE_LINE_RE.findall(text):
unit_type = normalize_unit_type(type_raw)
if not unit_type or unit_type in seen:
continue
seen.add(unit_type)
price_label = f"{type_raw.strip()} à partir de {amount.strip()}$"
key = re.sub(r"[^a-z0-9]+", "-", unit_type.lower().replace("½", "5"))
out.append(Listing(
source=self.source_id,
external_id=f"{slug}--{key}",
url=url,
title=f"{name} — {unit_type}",
address=address,
sector=sector,
city=infer_city(sector),
unit_type=unit_type,
price=parse_price(f"{amount}$"),
price_label=price_label,
description=desc,
amenities=list(amenities),
details=dict(details),
images=images,
))
# Aucun prix affiché : une annonce par immeuble quand même
if not out:
out.append(Listing(
source=self.source_id,
external_id=slug,
url=url,
title=name,
address=address,
sector=sector,
city=infer_city(sector),
description=desc,
amenities=list(amenities),
details=dict(details),
images=images,
))
return out