# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/promenade_des_forts.py : connecteur Promenade des Forts # (promenadedesforts.com, Groupe immobilier Brochu) — condos locatifs en # 2 phases au 6275 et 6375, boulevard Étienne-Dallaire (quartier Umano, # Lévis). Site WordPress rendu serveur : chaque page d'étage # (/projets/phase-N/etage-M/) contient un plan SVG dont les liens d'unités # portent la classe « disponible » ou « loue ». La fiche de chaque unité # disponible donne modèle, grandeur (4½…), superficie totale, prix avec date # (« disponible 1er septembre 2026 à 1485 $ »), spécifications, plan et PDF. # Peu d'unités libres à la fois : les fiches sont visitées directement à # chaque sync (pas de cache détail), ~15-20 requêtes au total. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_area_sqft from .base import BaseConnector BASE = "https://www.promenadedesforts.com" PHASES = (1, 2) SECTOR = "Quartier Umano (Lévis)" # lien d'unité disponible sur le plan SVG d'une page d'étage _UNIT_LINK_RE = re.compile( r']*href="(https?://[^"]*/projets/phase-\d+/etage-\d+/unite-\d+/?)"' r'[^>]*class="disponible"', re.I) # pages d'étages listées dans la navigation d'une page de phase _FLOOR_LINK_RE = re.compile( r'href="(https?://[^"]*/projets/phase-\d+/etage-\d+/?)"') # adresse civique du pied de page (« 6275 et 6375, Boulevard Étienne-Dallaire, # Levis, QC ») _ADDR_RE = re.compile(r"(\d{4}\s+et\s+\d{4},?\s+[Bb]oulevard[^,<]+,\s*L[eé]vis)") class PromenadeDesFortsConnector(BaseConnector): source_id = "promenade_des_forts" request_delay = 0.7 max_floor_pages = 20 # garde-fou (2 phases x 6 étages aujourd'hui) max_unit_pages = 40 # garde-fou sur les fiches d'unités # -- fiche d'une unité disponible ------------------------------------------- def _parse_unit(self, url: str, html: str) -> Listing | None: soup = BeautifulSoup(html, "html.parser") m = re.search(r"phase-(\d+)/etage-(\d+)/unite-(\d+)", url) if not m: return None phase, floor, num = m.group(1), m.group(2), m.group(3) # champs étiquetés de la fiche : « Modèle : », « Grandeur : », # « Superficie totale : », « Prix : » (p.margin-bottom-0 puis p.h3) fields: dict[str, str] = {} for p in soup.select(".infosUnite p.margin-bottom-0"): label = p.get_text(" ", strip=True).rstrip(" :").lower() val = p.find_next_sibling("p") if label and val is not None: fields[label] = re.sub(r"\s+", " ", val.get_text(" ", strip=True)) # spécifications : liste à puces qui suit l'étiquette « Spécifications » amenities: list[str] = [] for p in soup.select(".infosUnite p.margin-bottom-0"): if "spécifications" in p.get_text(strip=True).lower(): ul = p.find_next("ul") if ul: amenities = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)) for li in ul.find_all("li")] break # statut affiché sur la fiche (« Disponible » avec crochet vert) statut = soup.select_one(".infosUnite span.disponible") if statut is None: return None # l'étage l'annonçait libre, la fiche non # prix : « disponible 1er septembre 2026 à 1485 $ » ou « 1945 $ » — # le libellé complet sert aussi de disponibilité (date incluse) price_label = fields.get("prix", "") availability = (price_label if "disponible" in price_label.lower() else statut.get_text(" ", strip=True)) desc: list[str] = [] if fields.get("modèle"): desc.append(f"Modèle : {fields['modèle']}") pdf = soup.select_one('a.button[href$=".pdf"]') if pdf: desc.append(f"Fiche PDF : {pdf['href']}") # plan de l'unité (image de la colonne de droite) images = [] img = soup.select_one(".col-md-8 img[src]") if img and str(img["src"]).startswith("http"): images.append(str(img["src"])) # adresse et contact du pied de page (présents sur chaque page) addr_m = _ADDR_RE.search(html) address = addr_m.group(1) if addr_m else "" contact: dict = {} tel = re.search(r'href="tel:([\d\s\-.]{10,14})"', html) if tel: d = re.sub(r"\D", "", tel.group(1))[-10:] if len(d) == 10: contact["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}" mail = re.search(r'href="mailto:([^"?]+)"', html) if mail: contact["email"] = mail.group(1).strip() details: dict = {"floor": int(floor)} if contact: details["contact"] = contact unit_type = normalize_unit_type(fields.get("grandeur", "")) return Listing( source=self.source_id, external_id=f"phase-{phase}-unite-{num}", url=url, title=f"Promenade des Forts phase {phase} — unité {num}" f" ({unit_type})" if unit_type else f"Promenade des Forts phase {phase} — unité {num}", address=address, sector=SECTOR, city=infer_city(SECTOR, default="Lévis"), unit_type=unit_type, price=None, # dérivé du libellé par finalize() price_label=price_label, availability=availability, area_sqft=parse_area_sqft(fields.get("superficie totale", "")), description=" | ".join(desc), amenities=amenities, details=details, images=images, ) def fetch(self) -> list[Listing]: # 1) Pages d'étages découvertes depuis les pages de phases floor_urls: list[str] = [] for phase in PHASES: try: html = self.get(f"{BASE}/projets/phase-{phase}/").text except Exception: continue for u in _FLOOR_LINK_RE.findall(html): u = u.rstrip("/") + "/" if u not in floor_urls: floor_urls.append(u) # 2) Unités marquées « disponible » sur le plan SVG de chaque étage unit_urls: list[str] = [] for floor_url in floor_urls[:self.max_floor_pages]: try: html = self.get(floor_url).text except Exception: continue for u in _UNIT_LINK_RE.findall(html): u = u.rstrip("/") + "/" if u not in unit_urls: unit_urls.append(u) # 3) Fiche de chaque unité disponible (prix, date, superficie, specs) listings: list[Listing] = [] for url in unit_urls[:self.max_unit_pages]: try: lst = self._parse_unit(url, self.get(url).text) except Exception: continue if lst is not None: listings.append(lst) return listings