# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/le_baronet.py : connecteur Le Baronet (lebaronet.com) # Angle rue Notre-Dame Sud / rue Baronet, Sainte-Marie (Beauce, # Chaudière-Appalaches) — 87 condos locatifs en 2 phases (phase 1 : 64 # unités sur 4 étages, 2021 ; phase 2 : 23 unités sur 4 étages, 2023). # Site WordPress (Yoast) « one-pager » + pages d'étage rendues CÔTÉ SERVEUR : # - phase 1 : /etage-1 … /etage-4 ; phase 2 : /phase2-etages/niveau-1 … 4 ; # - chaque page contient un plan SVG dont les ancres `a.c-app` portent # data-app-no (n° d'unité), data-app-disponible (« dispo » / « louee ») # et un avec « Grandeur : 4 1/2 » et « Tarif : 1300 $ # /mois » ; seules les unités « dispo » sont retenues (légende du site : # Disponible / Réservé / Loué) ; # - ⚠ un popup (`div.c-popUp`) répète en fin de page une copie PÉRIMÉE du # plan de l'étage 1 : on ne parse que le SVG de `main.l-etages` ; # - fiche unité (/appartements/ ou /phase2/, via cache BD) : # plan/photo, « DISPONIBLE 1ER JUILLET 2026 » et description. # Les numéros d'unités se répètent d'une phase à l'autre (101…, 201…) → # external_id préfixé par le chemin de la fiche (appartements-106, # phase2-305), stable car issu des slugs WordPress. # Aucune adresse civique n'est publiée (« à l'angle de la rue Notre-Dame Sud # et de la rue Baronet ») → adresse laissée vide, rien d'inventé. # robots.txt : `Disallow:` (vide) → tout est permis ; sitemap Yoast public. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import BaseConnector BASE = "https://lebaronet.com" CITY = "Sainte-Marie" # pages d'étage : phase 1 puis phase 2 (libellés pour la description) FLOOR_PAGES: list[tuple[str, str]] = ( [(f"{BASE}/etage-{i}/", f"Phase 1 — Étage {i}") for i in range(1, 5)] + [(f"{BASE}/phase2-etages/niveau-{i}/", f"Phase 2 — Niveau {i}") for i in range(1, 5)] ) _TYPE_RE = re.compile(r"Grandeur\s*:\s*(\d)\s*1/2\s*(\+?)", re.I) _PRICE_RE = re.compile(r"Tarif\s*:\s*([\d\s]{2,9})\$", re.I) _AVAIL_RE = re.compile(r"(?i)^disponible\b") _IMG_RE = re.compile( r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+' r'\.(?:jpg|jpeg|png|webp)', re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|hero|-\d{2,4}x\d{2,4}\.", re.I) class LeBaronetConnector(BaseConnector): source_id = "le_baronet" request_delay = 0.6 max_details = 30 # plafond de fiches unité (vraies requêtes par sync) max_images = 10 # -- pages du projet --------------------------------------------------------- def _project_info(self) -> tuple[list[str], list[str]]: """Commodités (liste « Des services et commodités » de l'accueil, `div.o-whiteCol ul li`) et photos du projet (uploads de l'accueil).""" amenities: list[str] = [] images: list[str] = [] try: html = self.get(f"{BASE}/").text except Exception: return amenities, images soup = BeautifulSoup(html, "html.parser") for li in soup.select("div.o-whiteCol ul li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip() if 3 <= len(t) <= 90 and t not in amenities: amenities.append(t) for u in dict.fromkeys(_IMG_RE.findall(html)): if not _SKIP_IMG.search(u) and u not in images: images.append(u) return amenities[:30], images[: self.max_images] # -- fiche unité --------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Fiche /appartements/ ou /phase2/ : disponibilité datée (« DISPONIBLE 1ER JUILLET 2026 »), description et plan/photo.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches unité atteint") self._fetched += 1 soup = BeautifulSoup(self.get(url).text, "html.parser") main = soup.select_one("main.l-app") or soup.select_one("main") out: dict = {} if main is None: return out paras: list[str] = [] for p in main.select("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip() if not t: continue # 1er paragraphe en gras « DISPONIBLE … » = disponibilité affichée if not paras and not out.get("availability") and _AVAIL_RE.match(t): out["availability"] = t.capitalize() continue paras.append(t) if paras: out["description"] = "\n".join(paras)[:1200] img = main.select_one("img[src]") if img and img["src"].startswith("http"): out["image"] = img["src"] return out # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: amenities, project_images = self._project_info() self._fetched = 0 listings: dict[str, Listing] = {} for page_url, floor_label in FLOOR_PAGES: try: soup = BeautifulSoup(self.get(page_url).text, "html.parser") except Exception: continue # SVG du contenu principal seulement (le popup répète l'étage 1) for a in soup.select("main.l-etages a.c-app"): try: self._parse_unit(a, page_url, floor_label, amenities, project_images, listings) except Exception: continue return list(listings.values()) def _parse_unit(self, a, page_url: str, floor_label: str, amenities: list[str], project_images: list[str], listings: dict[str, Listing]) -> None: status = (a.get("data-app-disponible") or "").strip().lower() num = (a.get("data-app-no") or "").strip() href = (a.get("href") or "").strip() if not (num and href): return # seules les unités « dispo » sont des annonces (Réservé/Loué exclus) if status != "dispo": return # external_id stable : chemin de la fiche (appartements-106, phase2-305) ext = href.strip("/").replace("/", "-") if ext in listings: return url = href if href.startswith("http") else f"{BASE}{href}" cell_txt = re.sub(r"\s+", " ", a.get_text(" ", strip=True)) m = _TYPE_RE.search(cell_txt) unit_type = f"{m.group(1)}½" if m else "" plus = bool(m and m.group(2)) # « 4½ + » = pièce bureau en sus m = _PRICE_RE.search(cell_txt) price_label = (re.sub(r"\s+", " ", m.group(0)).strip() + "/mois" if m else "") # fiche unité (cache BD : re-téléchargée seulement si la cellule change) cell_key = hashlib.sha1( f"{status}|{cell_txt}".encode("utf-8")).hexdigest() try: detail = self.detail(ext, cell_key, lambda u=url: self._fetch_detail(u)) except Exception: detail = {} images = list(project_images) if detail.get("image") and detail["image"] not in images: images.insert(0, detail["image"]) desc_bits = [floor_label] if plus: desc_bits.append("4½ + (pièce pour bureau supplémentaire)") if detail.get("description"): desc_bits.append(detail["description"]) # n° affiché : « 402-2 » est un artefact de slug WordPress, la fiche # titre « Appartement no. : 402 » (l'external_id garde le slug complet) display_num = re.sub(r"-\d$", "", num) listings[ext] = Listing( source=self.source_id, external_id=ext, url=url, title=f"Le Baronet — Appartement {display_num}", address="", # aucune adresse civique publiée sector="", city=CITY, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, # texte de la fiche (« Disponible 1er juillet 2026 »), sinon le # statut de la légende du plan (« Disponible ») availability=detail.get("availability", "Disponible"), description="\n".join(desc_bits)[:1400], amenities=list(amenities), images=images[: self.max_images + 1], )