# ----------------------------------------------------------------------------- # 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