# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/elements.py : connecteur Quartier les Éléments
# (quartierleselements.com — Lévis, secteur Saint-Romuald, 5 phases).
# Navigation : phase -> étage (plan interactif )
# -> fiche d'unité (type, superficie, prix).
# -----------------------------------------------------------------------------
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from ..schema import Listing, infer_city, normalize_unit_type, parse_price
from .base import BaseConnector
BASE = "https://www.quartierleselements.com"
ROOT = f"{BASE}/appartements-condos-locatifs-levis"
SECTOR = "Saint-Romuald"
ADDRESS = "1432, rue de Jupiter, Lévis"
# Bureau de location affiché sur chaque fiche d'unité
CONTACT = {"phone": "581-922-3334", "email": "info@quartierleselements.com"}
class ElementsConnector(BaseConnector):
source_id = "elements"
request_delay = 0.5
max_floor_pages = 40 # garde-fou
def fetch(self) -> list[Listing]:
# 1) Découvrir les pages d'étages de chaque phase
floor_urls: list[str] = []
for phase in range(1, 6):
try:
html = self.get(f"{ROOT}/phase-{phase}/").text
except Exception:
continue
for path in sorted(set(re.findall(
rf'href="(/appartements-condos-locatifs-levis/'
rf'phase-{phase}/etage-\d+/)"', html))):
floor_urls.append(BASE + path)
# 2) Repérer les unités disponibles sur les plans d'étages
unit_urls: list[str] = []
for url in floor_urls[:self.max_floor_pages]:
try:
html = self.get(url).text
except Exception:
continue
for tag in re.findall(r"]*>", html, re.S):
if 'data-available="1"' not in tag:
continue
m = re.search(r'href="([^"]+)"', tag)
if m and m.group(1) not in unit_urls:
unit_urls.append(m.group(1))
# 3) Fiche de chaque unité disponible
listings: list[Listing] = []
for path in unit_urls:
full_url = path if path.startswith("http") else BASE + path
try:
html = self.get(full_url).text
except Exception:
continue
try:
soup = BeautifulSoup(html, "html.parser")
text = soup.get_text("\n", strip=True)
num = re.search(r"Unité\s+(\w+)", text)
unit_no = num.group(1) if num else path.strip("/").split("/")[-1]
phase_m = re.search(r"phase-(\d)", path)
phase = phase_m.group(1) if phase_m else "?"
type_m = re.search(r"Grandeur\s*:\s*([^\n]+)", text)
unit_type = normalize_unit_type(type_m.group(1)) if type_m else ""
prix_line = ""
pm = re.search(r"Prix\s*:\s*([^\n]+)", text)
price = None
if pm:
prix_line = pm.group(1).strip()
price = parse_price(prix_line)
availability = "Disponible"
am = re.search(r"pour\s+([a-zû]+\s+20\d\d)", prix_line, re.I)
if am:
availability = f"Disponible ({am.group(1)})"
# superficies structurées : la brute = superficie du logement
# (le parsing générique prendrait le min, donc la terrasse)
area_sqft = None
desc_parts = []
for label in ("Superficie brute", "Superficie terrasse",
"Superficie totale"):
dm = re.search(rf"{label}\s*:\s*([^\n]+)", text)
if dm:
desc_parts.append(f"{label} : {dm.group(1).strip()}")
if label == "Superficie brute":
nm = re.search(r"(\d[\d\s]*(?:[.,]\d+)?)\s*pi",
dm.group(1))
if nm:
area_sqft = float(
nm.group(1).replace(" ", "").replace(",", "."))
extra = re.search(r"Avec boudoir|Avec bureau", text)
amenities = [extra.group(0)] if extra else []
tm = re.search(r"Superficie terrasse", text)
if tm:
amenities.append("Terrasse")
# étage structuré (segment /etage-N/ de l'URL)
details: dict = {"contact": dict(CONTACT)}
em = re.search(r"/etage-(\d+)/", full_url)
if em:
details["floor"] = int(em.group(1))
imgs = re.findall(
r'(?:src|href)="((?:https?://[^"]+|/)?uploads/[^"]+'
r'\.(?:jpg|jpeg|png|webp))"', html, re.I)
images = []
for u in dict.fromkeys(imgs):
if not u.startswith("http"):
u = BASE + ("/" + u.lstrip("/"))
images.append(u)
listings.append(Listing(
source=self.source_id,
external_id=f"phase-{phase}-unite-{unit_no}",
url=full_url,
title=f"Quartier les Éléments — Phase {phase}, "
f"unité {unit_no} ({unit_type})",
address=ADDRESS,
sector=SECTOR,
city=infer_city(SECTOR),
unit_type=unit_type,
price=price,
price_label=f"Prix : {prix_line}" if prix_line else "",
availability=availability,
area_sqft=area_sqft,
description=" | ".join(desc_parts),
amenities=amenities,
details=details,
images=images,
))
except Exception:
continue
return listings