# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/gparadis.py : connecteur GParadis (gparadis.com)
# Secteurs : Montcalm, St-Sauveur, St-Roch, Limoilou, Vieux-Québec,
# Ste-Foy, Duberger, Lévis. Les pages de secteurs (WordPress) décrivent
# chaque immeuble (bloc wp-block-group + h5) avec adresses, disponibilités
# (« 4 1/2 disponible MAINTENANT »), commodités et galeries de photos.
# Une annonce par immeuble ayant des unités disponibles.
# -----------------------------------------------------------------------------
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from ..schema import Listing, infer_city, normalize_unit_type, strip_accents
from .base import BaseConnector
BASE = "https://gparadis.com"
SECTOR_PAGES = {
"montcalm": "Montcalm",
"saint-sauveur": "Saint-Sauveur",
"limoilou": "Limoilou",
"levis": "Lévis",
"vieux-quebec": "Vieux-Québec",
"ste-foy": "Ste-Foy",
"duberger": "Duberger",
"saint-roch": "Saint-Roch",
}
BAD_IMG_RE = re.compile(r"logo|icon|favicon|cropped-|gparadis-69fb", re.I)
# Contact « Pour louer » du pied de page (numéro dédié aux locations ;
# le Vieux-Québec a son propre numéro)
PHONE_VQ_RE = re.compile(
r"Pour louer dans Vieux-Qu[ée]bec\s*:?\s*(\d{3})\s*-?\s*(\d{3})\s*-?\s*(\d{4})", re.I)
PHONE_RE = re.compile(
r"Pour louer\s*:?\s*(\d{3})\s*-?\s*(\d{3})\s*-?\s*(\d{4})", re.I)
EMAIL_RE = re.compile(r"\b[\w.+-]+@gparadis\.com\b", re.I)
def _slugify(s: str) -> str:
s = strip_accents(s.lower())
return re.sub(r"[^a-z0-9]+", "-", s).strip("-")[:60]
class GParadisConnector(BaseConnector):
source_id = "gparadis"
request_delay = 0.6
def fetch(self) -> list[Listing]:
listings: dict[str, Listing] = {}
for slug, sector in SECTOR_PAGES.items():
url = f"{BASE}/{slug}/"
try:
html = self.get(url).text
except Exception:
continue
try:
self._parse_sector(html, url, sector, listings)
except Exception:
continue
return list(listings.values())
def _parse_sector(self, html: str, url: str, sector: str,
listings: dict[str, Listing]) -> None:
soup = BeautifulSoup(html, "html.parser")
seen_groups: set[int] = set()
# contact « Pour louer » du pied de page (structuré à la source) —
# le Vieux-Québec a son numéro dédié
page_text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
contact: dict = {}
pm = (PHONE_VQ_RE.search(page_text)
if "vieux" in strip_accents(sector.lower()) else None) or \
PHONE_RE.search(page_text)
if pm:
contact["phone"] = f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}"
em = EMAIL_RE.search(page_text)
if em:
contact["email"] = em.group(0).lower()
for h in soup.select("h5.wp-block-heading"):
# le bloc « carte d'immeuble » = plus proche ancêtre wp-block-group
# (le nom et l'adresse peuvent être dans des h5 distincts du bloc)
group = h.find_parent("div", class_="wp-block-group")
if group is None or id(group) in seen_groups:
continue
seen_groups.add(id(group))
headings = group.find_all("h5", class_="wp-block-heading")
text = re.sub(r"\s+", " ", group.get_text(" ", strip=True))
# Immeubles complets : rien à annoncer
if re.search(r"\bComplet\b", text) and \
not re.search(r"disponible", text, re.I):
continue
# Disponibilités : « 5 1/2 disponible MAINTENANT », « ... le 1er
# juillet », « lofts disponibles le 1 er juillet »
avail_parts = re.findall(
r"((?:\d\s*1/2|\d\s*½|Studios?|Lofts?)"
r"(?:\s*(?:,|et)\s*(?:\d\s*1/2|\d\s*½))*"
r"\s*disponibles?\s*(?:MAINTENANT|le\s*1\s*er\s*[a-zû]+|"
r"d[èe]s\s+maintenant)?)", text, re.I)
if not avail_parts:
continue # aucune unité disponible décrite
availability = " ; ".join(p.strip() for p in avail_parts)[:200]
# nom + adresse(s) : lignes des h5 du bloc (séparées par
)
lines: list[str] = []
for hh in headings:
for l in re.split(r"
", hh.decode_contents()):
l = re.sub(r"<[^>]+>", "", l)
l = (l.replace("–", "–").replace("&", "&")
.replace("’", "'").strip())
if l:
lines.append(l)
name = ""
addresses = []
for l in lines:
if re.match(r"^\d", l):
addresses.append(l)
elif not name:
name = l
address = addresses[0] if addresses else ""
title = name or address
if not title:
continue
# type d'unité : premier type disponible mentionné
tm = re.search(r"(\d)\s*(?:1/2|½)", availability)
if tm:
unit_type = f"{tm.group(1)}½"
elif re.search(r"lofts?", availability, re.I):
unit_type = "Loft"
elif re.search(r"studios?", availability, re.I):
unit_type = "Studio"
else:
unit_type = ""
# commodités
amenities = [li.get_text(" ", strip=True)
for li in group.select("li")
if li.get_text(strip=True)][:15]
# images de la galerie de l'immeuble
images = []
for im in group.select("img"):
u = im.get("data-orig-file") or im.get("src") or ""
u = u.split("?")[0]
if u and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I) \
and not BAD_IMG_RE.search(u):
images.append(u)
images = list(dict.fromkeys(images))[:25]
if not images and len(soup.select("h5.wp-block-heading")) == 1:
# page à immeuble unique : galerie au niveau de la page
for im in soup.select("img"):
u = (im.get("data-orig-file") or im.get("src") or "")
u = u.split("?")[0]
if u and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I) \
and not BAD_IMG_RE.search(u):
images.append(u)
images = list(dict.fromkeys(images))[:25]
ext_id = f"{_slugify(sector)}-{_slugify(title if not addresses else f'{title}-{address}')}"
if ext_id in listings:
continue
listings[ext_id] = Listing(
source=self.source_id,
external_id=ext_id,
url=url,
title=title,
address=address,
sector=sector,
city=infer_city(sector),
unit_type=normalize_unit_type(unit_type),
price=None, # GParadis n'affiche pas les prix
price_label="",
availability=availability,
description=text[:600],
amenities=amenities,
details={"contact": dict(contact)} if contact else {},
images=images,
)