# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/plantation.py : connecteur Domaine de la Plantation
# (domainedelaplantation.ca) — Shawinigan, secteur Saint-Georges-de-Champlain
# (entre Grand-Mère et le Lac-à-la-Tortue). Nouveau quartier de 4½ neufs de
# Construction Michael Massicotte. Site vitrine Pixpa page unique rendu
# serveur : section « Prix » avec paires
typologie (« Demi sous-sol
# 4 ½ » / « Étages 4 ½ ») + prix (« 1295$ par mois »), section
# « Inclusions » en items, photos Pixpa en data-src (lazy-load).
# Granularité = typologie (pas d'inventaire par unité publié).
# external_id = typologie en slug — stable.
# -----------------------------------------------------------------------------
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from ..schema import Listing, normalize_unit_type, strip_accents
from .base import BaseConnector
BASE = "https://www.domainedelaplantation.ca"
PAGE_URL = f"{BASE}/"
ADDRESS = "Saint-Georges-de-Champlain, Shawinigan"
PRICE_RE = re.compile(r"([\d\s ]{3,7})\$\s*par\s*mois", re.I)
UNIT_RE = re.compile(r"(\d)\s*(?:½|1/2|½)")
IMG_RE = re.compile(
r"https://px-web-images[\w.-]*\.pixpa\.com/[^\"'\s)]+", re.I)
def _slug(text: str) -> str:
s = strip_accents(text.lower())
return re.sub(r"[^a-z0-9]+", "-", s).strip("-")
def _num(txt: str) -> float | None:
n = re.sub(r"[\s ]", "", txt or "")
try:
return float(n)
except ValueError:
return None
class PlantationConnector(BaseConnector):
source_id = "plantation"
request_delay = 0.8
def fetch(self) -> list[Listing]:
html = self.get(PAGE_URL).text
soup = BeautifulSoup(html, "html.parser")
# Photos (lazy-load Pixpa) — communes au projet
images = [u for u in dict.fromkeys(
m.group(0) for el in soup.select("[data-src]")
for m in [IMG_RE.match(el["data-src"])] if m)][:12]
# Inclusions : items de la section « Inclusions »
amenities: list[str] = []
incl = soup.find("h2", string=re.compile(r"Inclusions", re.I))
if incl:
sec = incl.parent
for _ in range(4):
if sec is None:
break
txt = sec.get_text("\n", strip=True)
if len(txt) > 60:
break
sec = sec.parent
if sec is not None:
for line in sec.get_text("\n", strip=True).split("\n"):
line = re.sub(r"\s+", " ", line).strip()
if line and not re.match(r"Inclusions", line, re.I) \
and len(line) < 60 and line not in amenities:
amenities.append(line)
# Accroche du projet (texte héro)
blurb = ""
h3 = soup.find("h3", string=re.compile(r"Grand-Mère|Lac-à-la-Tortue"))
if h3:
blurb = re.sub(r"\s+", " ", h3.get_text(" ", strip=True))[:600]
# Section « Prix » : paires h3 (typologie) + h4 (prix)
listings: list[Listing] = []
prix_h2 = soup.find("h2", string=re.compile(r"^\s*Prix\s*$", re.I))
if not prix_h2:
return listings
sec = prix_h2
for _ in range(4):
sec = sec.parent
if sec is None:
return listings
if sec.find("h4"):
break
current_label = ""
for el in sec.find_all(["h3", "h4"]):
text = re.sub(r"\s+", " ", el.get_text(" ", strip=True))
if el.name == "h3":
current_label = text
continue
pm = PRICE_RE.search(text)
if not pm or not current_label:
continue
unit_type = ""
um = UNIT_RE.search(current_label)
if um:
unit_type = normalize_unit_type(f"{um.group(1)} ½")
listings.append(Listing(
source=self.source_id,
external_id=_slug(current_label),
url=PAGE_URL,
title=f"{current_label} — Domaine de la Plantation",
address=ADDRESS,
sector="Saint-Georges-de-Champlain",
city="Shawinigan",
unit_type=unit_type,
price=_num(pm.group(1)),
price_label=text,
description=blurb,
amenities=list(amenities),
images=list(images),
))
current_label = ""
return listings