# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/gcs_gabriel.py : connecteur GCS Le Gabriel
# (gcs-legabriel.ca — 2860, rue Gabriel-Le Prévost, coin route de l'Église,
# plateau de Sainte-Foy, Québec : 121 condos locatifs sur 13 étages,
# promoteur GCS Développement immobilier). Site vitrine statique rendu
# serveur : les prix « UNITÉS À PARTIR DE » sont affichés PAR TYPE dans le
# tableau
du slider (Studios 1 241 $* …
# 5 1/2 2 848 $*). Une annonce par type d'unité — la granularité du site —
# avec les inclusions de la section « Inclusions » et les photos du slider.
# -----------------------------------------------------------------------------
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from ..schema import Listing, normalize_unit_type, parse_price
from .base import BaseConnector
BASE = "https://gcs-legabriel.ca"
PAGE_URL = f"{BASE}/"
ADDRESS = "2860, rue Gabriel-Le Prévost, Québec"
SECTOR = "Sainte-Foy"
CITY = "Québec"
LAT, LNG = 46.774762, -71.298140 # visite virtuelle Google Maps de la page
# type affiché -> suffixe d'identifiant stable
TYPE_RE = re.compile(r"^(Studios?|\d\s*1/2\s*\+?)$", re.I)
IMG_RE = re.compile(r'(?:src|href)="((?:images/content/[^"]+|'
r"images/[^\"]*slider[^\"]+)\.(?:jpe?g|png|webp))\"", re.I)
class GcsGabrielConnector(BaseConnector):
source_id = "gcs_gabriel"
request_delay = 0.6
def fetch(self) -> list[Listing]:
listings: list[Listing] = []
html = self.get(PAGE_URL).text
soup = BeautifulSoup(html, "html.parser")
# photos du slider / des sections contenu
images = [f"{BASE}/{u.lstrip('/')}" for u in
dict.fromkeys(IMG_RE.findall(html))
if not re.search(r"logo|icon|plan", u, re.I)][:20]
# inclusions de l'immeuble (accordéon « Inclusions » : div.acc-header
# suivi d'un div.acc-content avec la liste )
amenities: list[str] = []
for h in soup.select("div.acc-header"):
if h.get_text(strip=True).lower().startswith("inclusion"):
ul = h.find_next("ul")
if ul:
amenities = [li.get_text(" ", strip=True)
for li in ul.find_all("li")
if li.get_text(strip=True)][:20]
break
# contact (lien tel: + courriel du pied de page)
details_common: dict = {}
contact: dict = {}
m = re.search(r'href="tel:\+?1?(\d{10})"', html)
if m:
d10 = m.group(1)
contact["phone"] = f"{d10[:3]}-{d10[3:6]}-{d10[6:]}"
m = re.search(r'(?:href="mailto:)?([\w.\-]+@gcs[\w.\-]*\.com)', html)
if m:
contact["email"] = m.group(1).lower()
if contact:
details_common["contact"] = contact
# disponibilité affichée dans le slider (« Possibilité d'occupation
# immédiate ! »)
availability = ""
am = re.search(r"Possibilit[ée] d[’']occupation\s*(?:
)?\s*"
r"immédiate", html, re.I)
if am:
availability = "Possibilité d'occupation immédiate"
# tableau « UNITÉS À PARTIR DE » : une rangée par type
table = soup.select_one("table.prenezmaintable")
if table is None:
return listings
for tr in table.find_all("tr"):
try:
tds = tr.find_all("td")
if len(tds) < 2:
continue
type_txt = tds[0].get_text(strip=True)
if not TYPE_RE.match(type_txt):
continue
price_txt = tds[1].get_text(strip=True)
price = parse_price(price_txt)
if price is None:
continue
plus = type_txt.rstrip("*").strip().endswith("+")
unit_type = normalize_unit_type(
type_txt.rstrip("+*s ") or type_txt)
extra = [tds[2].get_text(" ", strip=True)] \
if len(tds) > 2 and tds[2].get_text(strip=True) else []
ext_id = f"type-{unit_type.replace('½', '.5').lower()}" + \
("-plus" if plus else "")
label = f"{unit_type}+" if plus else unit_type
listings.append(Listing(
source=self.source_id,
external_id=ext_id,
url=PAGE_URL,
title=f"{label} — Le Gabriel",
address=ADDRESS,
sector=SECTOR,
city=CITY,
unit_type=unit_type,
price=price,
price_label=f"À partir de {price_txt}".replace("*", ""),
availability=availability,
amenities=list(dict.fromkeys(extra + amenities)),
details=dict(details_common),
images=images,
lat=LAT,
lng=LNG,
))
except Exception:
continue
return listings