# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/cent4.py : connecteur Terrasse Cent4 (terrassecent4.com —
# condos locatifs neufs 3½/4½ au 1700, chemin Saint-Jean, La Prairie,
# promoteur Vestric). WordPress (thème Voyou) rendu serveur : la page
# /les-unites liste les unités disponibles par bâtiment (Terrasse 2/3)
# dans des
— numéro, étage, typologie,
# pi², loyer, mois de disponibilité + lien « fiche de l'unité ».
# La fiche (via self.detail, cache BD) ajoute inclusions et options.
# Granularité = UNITÉ. external_id = slug de la fiche (terrasse-2-unite-202).
# -----------------------------------------------------------------------------
from __future__ import annotations
import html as _html
import re
from ..schema import Listing
from .base import BaseConnector
BASE = "https://terrassecent4.com"
LIST_URL = f"{BASE}/les-unites"
ADDRESS = "1700, chemin Saint-Jean, La Prairie"
CITY = "La Prairie"
BUILDING_RE = re.compile(
r"')
FIELD_RE = re.compile(
r'vy_units_listing_(unit|floor|type|square|price|availability)[\s\S]*?'
r'([\s\S]*?)')
FICHE_RE = re.compile(r'href="(https://terrassecent4\.com/units/[^"]+)"')
AVAILABLE_RE = re.compile(r'data-filter-available="1"')
INCL_RE = re.compile(r"Inclusions([\s\S]*?)(?:Options|Imprimez)", re.I)
OPT_RE = re.compile(r"Options([\s\S]*?)Imprimez", re.I)
SCRIPT_RE = re.compile(r"<(script|style)[\s\S]*?\1>", re.I)
TAG_RE = re.compile(r"<[^>]+>")
IMG_RE = re.compile( # Bedrock : médias sous /app/uploads/ (data-src)
r'https://terrassecent4\.com/app/uploads/[^"\s)]+'
r"\.(?:jpe?g|png|webp)", re.I)
def _flat(html: str) -> str:
t = _html.unescape(SCRIPT_RE.sub(" ", html))
return re.sub(r"[\s ]+", " ", TAG_RE.sub(" | ", t))
def _clean(txt: str) -> str:
return re.sub(r"\s+", " ", _html.unescape(TAG_RE.sub(" ", txt))).strip()
class Cent4Connector(BaseConnector):
source_id = "cent4"
request_delay = 0.8
def fetch(self) -> list[Listing]:
try:
html = self.get(LIST_URL).text
except Exception:
return []
listings: list[Listing] = []
seen: set[str] = set()
# les items sont regroupés par bloc bâtiment ; on suit le bâtiment
# courant en balayant le HTML dans l'ordre
pos_buildings = [(m.start(), m.group(1))
for m in BUILDING_RE.finditer(html)]
for m in ITEM_RE.finditer(html):
chunk = m.group(0)
if not AVAILABLE_RE.search(chunk):
continue
building = ""
for pos, b in pos_buildings:
if pos < m.start():
building = b.replace("-", " ").title()
fields = {k: _clean(v) for k, v in FIELD_RE.findall(chunk)}
unit = fields.get("unit", "")
fm = FICHE_RE.search(chunk)
url = fm.group(1) if fm else LIST_URL
ext_id = (url.rsplit("/", 1)[-1] if fm
else f"{building.lower().replace(' ', '-')}-{unit}")
if not unit or ext_id in seen:
continue
seen.add(ext_id)
price = None
pm = re.search(r"(\d[\d\s ]{2,8})\$", fields.get("price", ""))
if pm:
price = float(re.sub(r"[\s ]", "", pm.group(1)))
area = None
am = re.search(r"(\d{3,5})\s*pi", fields.get("square", ""))
if am:
area = float(am.group(1))
availability = re.sub(r"^Disponibilité\s*\|?\s*", "",
fields.get("availability", "")).strip()
detail = self.detail(ext_id, f"{price}|{availability}",
lambda u=url: self._detail(u))
listings.append(Listing(
source=self.source_id,
external_id=ext_id,
url=url,
title=f"Terrasse Cent4 ({building}) — unité {unit}",
address=ADDRESS,
city=CITY,
unit_type=fields.get("type", ""),
price=price,
price_label=f"{int(price)} $ par mois" if price else "",
availability=(f"Disponibilité : {availability}"
if availability else ""),
area_sqft=area,
description=detail.get("description", ""),
amenities=detail.get("amenities", []),
details={"building": building,
"floor": fields.get("floor", "")},
images=detail.get("images", []),
))
return listings
def _detail(self, url: str) -> dict:
"""Fiche d'unité : inclusions, options, photos."""
try:
html = self.get(url).text
except Exception:
return {}
flat = _flat(html)
amenities: list[str] = []
for rx in (INCL_RE, OPT_RE):
fm = rx.search(flat)
if fm:
for part in fm.group(1).split("|"):
part = part.strip(" .")
if 3 <= len(part) <= 120 and part not in amenities:
amenities.append(part)
images = [u for u in dict.fromkeys(IMG_RE.findall(html))
if not re.search(r"logo|icon|favicon|hummingbird|-\d+x\d+\.",
u, re.I)][:10]
desc = ("Condo locatif neuf à la Terrasse Cent4, complexe de "
f"Vestric à La Prairie ({ADDRESS}).")
return {"description": desc, "amenities": amenities, "images": images}