Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/sept_elements.py : connecteur Les 7 Éléments5# (condoles7elements.com — 1998, rue Marie-Anne-Gaudreau, Lebourgneuf,6# Québec ; condos locatifs Propriétés Mach). Plateforme Vortex Solution7# rendue serveur : la page d'accueil liste les unités disponibles en blocs8# <div class="uniteItem"> (type 3½/4½, numéro d'unité, date de9# disponibilité, plan PDF DATA/UNITE/<n>.pdf). Pas de prix affiché sur le10# site — le plan PDF de chaque unité fournit par contre les superficies11# (nette/brute/balcon), extraites par position avec pdfplumber via le12# cache BD des pages détail. Sert aussi de classe de base au connecteur13# `consolata` (La Consolata, même plateforme Vortex).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import io19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type24from .base import BaseConnector2526# éléments de navigation à exclure des listes <li> de commodités27_NAV_RE = re.compile(r"accueil|nous joindre|galerie|unit[ée]s|plans|"28 r"disponibilit|english|fran[çc]ais|politique", re.I)29PHONE_RE = re.compile(r"\b(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})\b")303132class SeptElementsConnector(BaseConnector):33 source_id = "sept_elements"34 request_delay = 0.635 max_details = 30 # garde-fou plans PDF (vraies requêtes par sync)3637 BASE = "https://www.condoles7elements.com"38 NAME = "Les 7 Éléments"39 ADDRESS = "1998, rue Marie-Anne-Gaudreau, Québec, G2K 0M3"40 SECTOR = "Lebourgneuf"41 CITY = "Québec"4243 def fetch(self) -> list[Listing]:44 listings: list[Listing] = []45 html = self.get(f"{self.BASE}/").text46 soup = BeautifulSoup(html, "html.parser")47 self._fetched = 04849 # photos de l'immeuble (DATA/PHOTO/*.jpg, hors logos/icônes)50 images = []51 for u in dict.fromkeys(re.findall(52 r'(?:src|href)="((?:DATA/PHOTO|gx/m)/[^"]+'53 r"\.(?:jpe?g|png|webp))\"", html, re.I)):54 if re.search(r"logo|icon|favicon|Mob~", u, re.I):55 continue56 images.append(f"{self.BASE}/{u.lstrip('/')}")57 images = images[:20]5859 # commodités : listes <li> du contenu (filtrées de la navigation)60 amenities = []61 for li in soup.find_all("li"):62 txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True))63 if txt and len(txt) < 80 and not li.find("a") \64 and not _NAV_RE.search(txt) and txt not in amenities:65 amenities.append(txt)66 amenities = amenities[:20]6768 # contact (téléphone affiché en texte)69 details_common: dict = {}70 pm = PHONE_RE.search(soup.get_text(" ", strip=True))71 if pm:72 details_common["contact"] = {73 "phone": f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}"}7475 for item in soup.select("div.uniteItem"):76 try:77 type_el = item.select_one(".uniteType")78 unit_type = normalize_unit_type(79 type_el.get_text(strip=True)) if type_el else ""80 vals = [v.get_text(strip=True)81 for v in item.select(".uniteItemValeur")82 if v.get_text(strip=True)]83 unit_no = next((v for v in vals84 if re.fullmatch(r"[\dA-Za-z\-]{1,12}", v)), "")85 if not unit_no:86 continue87 availability = next(88 (v for v in vals89 if re.search(r"occupation|imm[ée]diate|libre|\d{4}",90 v, re.I) and v != unit_no), "")9192 plan_el = item.select_one('a[href*="DATA/UNITE/"]')93 plan_url = f"{self.BASE}/{plan_el['href'].lstrip('/')}" \94 if plan_el and plan_el.get("href") else ""9596 details = dict(details_common)97 area = None98 if plan_url:99 details["plan_pdf"] = plan_url100 key = hashlib.sha1(101 f"{plan_url}|{availability}".encode()).hexdigest()102 try:103 d = self.detail(unit_no, key,104 lambda u=plan_url: self._plan_pdf(u))105 area = d.get("area_net")106 if d.get("area_gross"):107 details["superficie_brute_pi2"] = d["area_gross"]108 if d.get("balcony"):109 details["balcon_pi2"] = d["balcony"]110 except Exception:111 pass112113 listings.append(Listing(114 source=self.source_id,115 external_id=unit_no,116 url=f"{self.BASE}/",117 title=f"{unit_type} — unité {unit_no}, {self.NAME}",118 address=self.ADDRESS,119 sector=self.SECTOR,120 city=self.CITY,121 unit_type=unit_type,122 availability=availability,123 area_sqft=area,124 amenities=list(amenities),125 details=details,126 images=images,127 ))128 except Exception:129 continue130 return listings131132 # -- plan PDF d'une unité : superficies extraites par position ------------------133 def _plan_pdf(self, url: str) -> dict:134 """Superficie Nette / Brute / Balcon (pi²) du cartouche du plan.135136 Les libellés sont dans une colonne fixe ; la valeur est le nombre137 aligné verticalement (±6 pt) à droite du libellé.138 """139 if self._fetched >= self.max_details:140 raise RuntimeError("budget de plans PDF atteint")141 self._fetched += 1142 import pdfplumber143 raw = self.get(url).content144 if not raw.startswith(b"%PDF"): # lien mort servi en page HTML 200145 return {}146 out: dict = {}147 with pdfplumber.open(io.BytesIO(raw)) as pdf:148 words = pdf.pages[0].extract_words()149 for label, field, lo, hi in (("Nette", "area_net", 300, 3000),150 ("Brute", "area_gross", 300, 3000),151 ("Balcon", "balcony", 20, 500)):152 anchor = next((w for w in words153 if w["text"].startswith(label)), None)154 if anchor is None:155 continue156 frags = sorted((w for w in words157 if anchor["x1"] - 2 < w["x0"] < anchor["x1"] + 160158 and abs(w["top"] - anchor["top"]) < 6159 and re.fullmatch(r"\d{1,4}", w["text"])),160 key=lambda w: w["x0"])161 # certains cartouches scindent la valeur (« 1 » + « 189 » = 1189) :162 # recoller les fragments de chiffres contigus (< 8 pt d'écart)163 num = ""164 prev_x1 = None165 for w in frags:166 if prev_x1 is not None and w["x0"] - prev_x1 > 8:167 break168 num += w["text"]169 prev_x1 = w["x1"]170 if num and lo <= float(num) <= hi:171 out[field] = float(num)172 return out173