# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/sept_elements.py : connecteur Les 7 Éléments
# (condoles7elements.com — 1998, rue Marie-Anne-Gaudreau, Lebourgneuf,
# Québec ; condos locatifs Propriétés Mach). Plateforme Vortex Solution
# rendue serveur : la page d'accueil liste les unités disponibles en blocs
#
(type 3½/4½, numéro d'unité, date de
# disponibilité, plan PDF DATA/UNITE/.pdf). Pas de prix affiché sur le
# site — le plan PDF de chaque unité fournit par contre les superficies
# (nette/brute/balcon), extraites par position avec pdfplumber via le
# cache BD des pages détail. Sert aussi de classe de base au connecteur
# `consolata` (La Consolata, même plateforme Vortex).
# -----------------------------------------------------------------------------
from __future__ import annotations
import hashlib
import io
import re
from bs4 import BeautifulSoup
from ..schema import Listing, normalize_unit_type
from .base import BaseConnector
# éléments de navigation à exclure des listes de commodités
_NAV_RE = re.compile(r"accueil|nous joindre|galerie|unit[ée]s|plans|"
r"disponibilit|english|fran[çc]ais|politique", re.I)
PHONE_RE = re.compile(r"\b(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})\b")
class SeptElementsConnector(BaseConnector):
source_id = "sept_elements"
request_delay = 0.6
max_details = 30 # garde-fou plans PDF (vraies requêtes par sync)
BASE = "https://www.condoles7elements.com"
NAME = "Les 7 Éléments"
ADDRESS = "1998, rue Marie-Anne-Gaudreau, Québec, G2K 0M3"
SECTOR = "Lebourgneuf"
CITY = "Québec"
def fetch(self) -> list[Listing]:
listings: list[Listing] = []
html = self.get(f"{self.BASE}/").text
soup = BeautifulSoup(html, "html.parser")
self._fetched = 0
# photos de l'immeuble (DATA/PHOTO/*.jpg, hors logos/icônes)
images = []
for u in dict.fromkeys(re.findall(
r'(?:src|href)="((?:DATA/PHOTO|gx/m)/[^"]+'
r"\.(?:jpe?g|png|webp))\"", html, re.I)):
if re.search(r"logo|icon|favicon|Mob~", u, re.I):
continue
images.append(f"{self.BASE}/{u.lstrip('/')}")
images = images[:20]
# commodités : listes du contenu (filtrées de la navigation)
amenities = []
for li in soup.find_all("li"):
txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True))
if txt and len(txt) < 80 and not li.find("a") \
and not _NAV_RE.search(txt) and txt not in amenities:
amenities.append(txt)
amenities = amenities[:20]
# contact (téléphone affiché en texte)
details_common: dict = {}
pm = PHONE_RE.search(soup.get_text(" ", strip=True))
if pm:
details_common["contact"] = {
"phone": f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}"}
for item in soup.select("div.uniteItem"):
try:
type_el = item.select_one(".uniteType")
unit_type = normalize_unit_type(
type_el.get_text(strip=True)) if type_el else ""
vals = [v.get_text(strip=True)
for v in item.select(".uniteItemValeur")
if v.get_text(strip=True)]
unit_no = next((v for v in vals
if re.fullmatch(r"[\dA-Za-z\-]{1,12}", v)), "")
if not unit_no:
continue
availability = next(
(v for v in vals
if re.search(r"occupation|imm[ée]diate|libre|\d{4}",
v, re.I) and v != unit_no), "")
plan_el = item.select_one('a[href*="DATA/UNITE/"]')
plan_url = f"{self.BASE}/{plan_el['href'].lstrip('/')}" \
if plan_el and plan_el.get("href") else ""
details = dict(details_common)
area = None
if plan_url:
details["plan_pdf"] = plan_url
key = hashlib.sha1(
f"{plan_url}|{availability}".encode()).hexdigest()
try:
d = self.detail(unit_no, key,
lambda u=plan_url: self._plan_pdf(u))
area = d.get("area_net")
if d.get("area_gross"):
details["superficie_brute_pi2"] = d["area_gross"]
if d.get("balcony"):
details["balcon_pi2"] = d["balcony"]
except Exception:
pass
listings.append(Listing(
source=self.source_id,
external_id=unit_no,
url=f"{self.BASE}/",
title=f"{unit_type} — unité {unit_no}, {self.NAME}",
address=self.ADDRESS,
sector=self.SECTOR,
city=self.CITY,
unit_type=unit_type,
availability=availability,
area_sqft=area,
amenities=list(amenities),
details=details,
images=images,
))
except Exception:
continue
return listings
# -- plan PDF d'une unité : superficies extraites par position ------------------
def _plan_pdf(self, url: str) -> dict:
"""Superficie Nette / Brute / Balcon (pi²) du cartouche du plan.
Les libellés sont dans une colonne fixe ; la valeur est le nombre
aligné verticalement (±6 pt) à droite du libellé.
"""
if self._fetched >= self.max_details:
raise RuntimeError("budget de plans PDF atteint")
self._fetched += 1
import pdfplumber
raw = self.get(url).content
if not raw.startswith(b"%PDF"): # lien mort servi en page HTML 200
return {}
out: dict = {}
with pdfplumber.open(io.BytesIO(raw)) as pdf:
words = pdf.pages[0].extract_words()
for label, field, lo, hi in (("Nette", "area_net", 300, 3000),
("Brute", "area_gross", 300, 3000),
("Balcon", "balcony", 20, 500)):
anchor = next((w for w in words
if w["text"].startswith(label)), None)
if anchor is None:
continue
frags = sorted((w for w in words
if anchor["x1"] - 2 < w["x0"] < anchor["x1"] + 160
and abs(w["top"] - anchor["top"]) < 6
and re.fullmatch(r"\d{1,4}", w["text"])),
key=lambda w: w["x0"])
# certains cartouches scindent la valeur (« 1 » + « 189 » = 1189) :
# recoller les fragments de chiffres contigus (< 8 pt d'écart)
num = ""
prev_x1 = None
for w in frags:
if prev_x1 is not None and w["x0"] - prev_x1 > 8:
break
num += w["text"]
prev_x1 = w["x1"]
if num and lo <= float(num) <= hi:
out[field] = float(num)
return out