spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/promenade_des_forts.py : connecteur Promenade des Forts5# (promenadedesforts.com, Groupe immobilier Brochu) — condos locatifs en6# 2 phases au 6275 et 6375, boulevard Étienne-Dallaire (quartier Umano,7# Lévis). Site WordPress rendu serveur : chaque page d'étage8# (/projets/phase-N/etage-M/) contient un plan SVG dont les liens d'unités9# portent la classe « disponible » ou « loue ». La fiche de chaque unité10# disponible donne modèle, grandeur (4½…), superficie totale, prix avec date11# (« disponible 1er septembre 2026 à 1485 $ »), spécifications, plan et PDF.12# Peu d'unités libres à la fois : les fiches sont visitées directement à13# chaque sync (pas de cache détail), ~15-20 requêtes au total.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, infer_city, normalize_unit_type, parse_area_sqft22from .base import BaseConnector2324BASE = "https://www.promenadedesforts.com"25PHASES = (1, 2)26SECTOR = "Quartier Umano (Lévis)"2728# lien d'unité disponible sur le plan SVG d'une page d'étage29_UNIT_LINK_RE = re.compile(30 r'<a\s[^>]*href="(https?://[^"]*/projets/phase-\d+/etage-\d+/unite-\d+/?)"'31 r'[^>]*class="disponible"', re.I)32# pages d'étages listées dans la navigation d'une page de phase33_FLOOR_LINK_RE = re.compile(34 r'href="(https?://[^"]*/projets/phase-\d+/etage-\d+/?)"')35# adresse civique du pied de page (« 6275 et 6375, Boulevard Étienne-Dallaire,36# Levis, QC »)37_ADDR_RE = re.compile(r"(\d{4}\s+et\s+\d{4},?\s+[Bb]oulevard[^,<]+,\s*L[eé]vis)")383940class PromenadeDesFortsConnector(BaseConnector):41 source_id = "promenade_des_forts"42 request_delay = 0.743 max_floor_pages = 20 # garde-fou (2 phases x 6 étages aujourd'hui)44 max_unit_pages = 40 # garde-fou sur les fiches d'unités4546 # -- fiche d'une unité disponible -------------------------------------------47 def _parse_unit(self, url: str, html: str) -> Listing | None:48 soup = BeautifulSoup(html, "html.parser")49 m = re.search(r"phase-(\d+)/etage-(\d+)/unite-(\d+)", url)50 if not m:51 return None52 phase, floor, num = m.group(1), m.group(2), m.group(3)5354 # champs étiquetés de la fiche : « Modèle : », « Grandeur : »,55 # « Superficie totale : », « Prix : » (p.margin-bottom-0 puis p.h3)56 fields: dict[str, str] = {}57 for p in soup.select(".infosUnite p.margin-bottom-0"):58 label = p.get_text(" ", strip=True).rstrip(" :").lower()59 val = p.find_next_sibling("p")60 if label and val is not None:61 fields[label] = re.sub(r"\s+", " ", val.get_text(" ", strip=True))6263 # spécifications : liste à puces qui suit l'étiquette « Spécifications »64 amenities: list[str] = []65 for p in soup.select(".infosUnite p.margin-bottom-0"):66 if "spécifications" in p.get_text(strip=True).lower():67 ul = p.find_next("ul")68 if ul:69 amenities = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))70 for li in ul.find_all("li")]71 break7273 # statut affiché sur la fiche (« Disponible » avec crochet vert)74 statut = soup.select_one(".infosUnite span.disponible")75 if statut is None:76 return None # l'étage l'annonçait libre, la fiche non7778 # prix : « disponible 1er septembre 2026 à 1485 $ » ou « 1945 $ » —79 # le libellé complet sert aussi de disponibilité (date incluse)80 price_label = fields.get("prix", "")81 availability = (price_label if "disponible" in price_label.lower()82 else statut.get_text(" ", strip=True))8384 desc: list[str] = []85 if fields.get("modèle"):86 desc.append(f"Modèle : {fields['modèle']}")87 pdf = soup.select_one('a.button[href$=".pdf"]')88 if pdf:89 desc.append(f"Fiche PDF : {pdf['href']}")9091 # plan de l'unité (image de la colonne de droite)92 images = []93 img = soup.select_one(".col-md-8 img[src]")94 if img and str(img["src"]).startswith("http"):95 images.append(str(img["src"]))9697 # adresse et contact du pied de page (présents sur chaque page)98 addr_m = _ADDR_RE.search(html)99 address = addr_m.group(1) if addr_m else ""100 contact: dict = {}101 tel = re.search(r'href="tel:([\d\s\-.]{10,14})"', html)102 if tel:103 d = re.sub(r"\D", "", tel.group(1))[-10:]104 if len(d) == 10:105 contact["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}"106 mail = re.search(r'href="mailto:([^"?]+)"', html)107 if mail:108 contact["email"] = mail.group(1).strip()109110 details: dict = {"floor": int(floor)}111 if contact:112 details["contact"] = contact113114 unit_type = normalize_unit_type(fields.get("grandeur", ""))115 return Listing(116 source=self.source_id,117 external_id=f"phase-{phase}-unite-{num}",118 url=url,119 title=f"Promenade des Forts phase {phase} — unité {num}"120 f" ({unit_type})" if unit_type else121 f"Promenade des Forts phase {phase} — unité {num}",122 address=address,123 sector=SECTOR,124 city=infer_city(SECTOR, default="Lévis"),125 unit_type=unit_type,126 price=None, # dérivé du libellé par finalize()127 price_label=price_label,128 availability=availability,129 area_sqft=parse_area_sqft(fields.get("superficie totale", "")),130 description=" | ".join(desc),131 amenities=amenities,132 details=details,133 images=images,134 )135136 def fetch(self) -> list[Listing]:137 # 1) Pages d'étages découvertes depuis les pages de phases138 floor_urls: list[str] = []139 for phase in PHASES:140 try:141 html = self.get(f"{BASE}/projets/phase-{phase}/").text142 except Exception:143 continue144 for u in _FLOOR_LINK_RE.findall(html):145 u = u.rstrip("/") + "/"146 if u not in floor_urls:147 floor_urls.append(u)148149 # 2) Unités marquées « disponible » sur le plan SVG de chaque étage150 unit_urls: list[str] = []151 for floor_url in floor_urls[:self.max_floor_pages]:152 try:153 html = self.get(floor_url).text154 except Exception:155 continue156 for u in _UNIT_LINK_RE.findall(html):157 u = u.rstrip("/") + "/"158 if u not in unit_urls:159 unit_urls.append(u)160161 # 3) Fiche de chaque unité disponible (prix, date, superficie, specs)162 listings: list[Listing] = []163 for url in unit_urls[:self.max_unit_pages]:164 try:165 lst = self._parse_unit(url, self.get(url).text)166 except Exception:167 continue168 if lst is not None:169 listings.append(lst)170 return listings171