# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/sig.py : connecteur Société d'investissement Gilbert (immo-sig.ca)
# Résidences étudiantes en colocation à Québec (Université Laval, Cégep
# Sainte-Foy) : Le Sommet, Le Pavillon, La FAC, L'UNI. La page
# /residences-etudiantes/ (Elementor, rendue serveur) affiche pour chaque
# résidence le prix « à partir de » par chambre, l'adresse et une photo.
# Granularité = résidence (aucune unité détaillée publiée). Les fiches
# détaillées vivent sur residences-etudiantes.com, protégé par une
# vérification anti-bot reCAPTCHA -> immo-sig.ca est la source primaire ;
# le lien « DÉCOUVRIR » sert d'URL d'annonce pour l'utilisateur.
# -----------------------------------------------------------------------------
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from ..schema import Listing, strip_accents
from .base import BaseConnector
BASE = "https://immo-sig.ca"
INDEX_URL = f"{BASE}/residences-etudiantes/"
# variantes responsives WordPress (-300x200.jpg) à écarter
_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}\.(?:jpe?g|png|webp)$", re.I)
def _slug(name: str) -> str:
s = strip_accents((name or "").strip().lower())
return re.sub(r"[^a-z0-9]+", "-", s).strip("-")
class SigConnector(BaseConnector):
source_id = "sig"
request_delay = 0.6
def fetch(self) -> list[Listing]:
html = self.get(INDEX_URL).text
soup = BeautifulSoup(html, "html.parser")
listings: list[Listing] = []
seen: set[str] = set()
# une colonne Elementor par résidence : photo +
nom + texte
# « COLOCATION ÉTUDIANTE / Unités de N chambres à partir de X$/mois /
# adresse » + bouton DÉCOUVRIR
for col in soup.select(".elementor-column"):
h3 = col.select_one("h3.elementor-heading-title")
text_w = col.select_one(".elementor-widget-text-editor")
if not h3 or not text_w:
continue
name = re.sub(r"\s+", " ", h3.get_text(" ", strip=True))
sid = _slug(name)
if not name or sid in seen:
continue
paras = [re.sub(r"[ \t]+", " ", p.get_text("\n", strip=True))
for p in text_w.find_all("p")]
tagline = paras[0] if paras else ""
body = paras[1] if len(paras) > 1 else ""
if "à partir de" not in body.lower():
continue # pas un bloc résidence
seen.add(sid)
# « Unités de 3 chambres à partir de 650$/mois\n2333 Chapdelaine »
lines = [l.strip() for l in body.split("\n") if l.strip()]
price_line = lines[0] if lines else ""
address = lines[1] if len(lines) > 1 else ""
img = col.select_one("img[src]")
images = []
if img:
src = (img.get("src") or "").strip()
if src.startswith("http") and not _VARIANT_IMG.search(src):
images.append(src)
link = col.select_one("a.elementor-button[href]")
url = (link["href"] if link and
str(link["href"]).startswith("http") else INDEX_URL)
# « meublées » n'est indiqué que pour L'UNI
furnished = True if "meublé" in price_line.lower() else None
m = re.search(r"unités?\s+de\s+([\d\s aà-]+chambres?)",
price_line, re.I)
desc = " — ".join(x for x in [
tagline.capitalize(), price_line,
"Fiche détaillée : " + url if url != INDEX_URL else ""] if x)
details = {"residence": name}
listings.append(Listing(
source=self.source_id,
external_id=sid,
url=url,
title=f"{name} — Colocation étudiante",
address=address,
sector="", # non précisé à la source
city="Québec",
unit_type="Chambre", # colocation : location à la chambre
price_label=price_line, # « ... à partir de 545$/mois »
availability="", # aucune disponibilité affichée
furnished=furnished,
description=desc[:600],
amenities=[t for t in [
"Colocation étudiante",
f"Unités de {m.group(1).strip()}" if m else ""] if t],
details=details,
images=images,
))
return listings