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/le_baronet.py : connecteur Le Baronet (lebaronet.com)5# Angle rue Notre-Dame Sud / rue Baronet, Sainte-Marie (Beauce,6# Chaudière-Appalaches) — 87 condos locatifs en 2 phases (phase 1 : 647# unités sur 4 étages, 2021 ; phase 2 : 23 unités sur 4 étages, 2023).8# Site WordPress (Yoast) « one-pager » + pages d'étage rendues CÔTÉ SERVEUR :9# - phase 1 : /etage-1 … /etage-4 ; phase 2 : /phase2-etages/niveau-1 … 4 ;10# - chaque page contient un plan SVG dont les ancres `a.c-app` portent11# data-app-no (n° d'unité), data-app-disponible (« dispo » / « louee »)12# et un <foreignobject> avec « Grandeur : 4 1/2 » et « Tarif : 1300 $13# /mois » ; seules les unités « dispo » sont retenues (légende du site :14# Disponible / Réservé / Loué) ;15# - ⚠ un popup (`div.c-popUp`) répète en fin de page une copie PÉRIMÉE du16# plan de l'étage 1 : on ne parse que le SVG de `main.l-etages` ;17# - fiche unité (/appartements/<no> ou /phase2/<no>, via cache BD) :18# plan/photo, « DISPONIBLE 1ER JUILLET 2026 » et description.19# Les numéros d'unités se répètent d'une phase à l'autre (101…, 201…) →20# external_id préfixé par le chemin de la fiche (appartements-106,21# phase2-305), stable car issu des slugs WordPress.22# Aucune adresse civique n'est publiée (« à l'angle de la rue Notre-Dame Sud23# et de la rue Baronet ») → adresse laissée vide, rien d'inventé.24# robots.txt : `Disallow:` (vide) → tout est permis ; sitemap Yoast public.25# -----------------------------------------------------------------------------26from __future__ import annotations2728import hashlib29import re3031from bs4 import BeautifulSoup3233from ..schema import Listing, parse_price34from .base import BaseConnector3536BASE = "https://lebaronet.com"37CITY = "Sainte-Marie"3839# pages d'étage : phase 1 puis phase 2 (libellés pour la description)40FLOOR_PAGES: list[tuple[str, str]] = (41 [(f"{BASE}/etage-{i}/", f"Phase 1 — Étage {i}") for i in range(1, 5)]42 + [(f"{BASE}/phase2-etages/niveau-{i}/", f"Phase 2 — Niveau {i}")43 for i in range(1, 5)]44)4546_TYPE_RE = re.compile(r"Grandeur\s*:\s*(\d)\s*1/2\s*(\+?)", re.I)47_PRICE_RE = re.compile(r"Tarif\s*:\s*([\d\s]{2,9})\$", re.I)48_AVAIL_RE = re.compile(r"(?i)^disponible\b")49_IMG_RE = re.compile(50 r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+'51 r'\.(?:jpg|jpeg|png|webp)', re.I)52_SKIP_IMG = re.compile(r"logo|icon|favicon|hero|-\d{2,4}x\d{2,4}\.", re.I)535455class LeBaronetConnector(BaseConnector):56 source_id = "le_baronet"57 request_delay = 0.658 max_details = 30 # plafond de fiches unité (vraies requêtes par sync)59 max_images = 106061 # -- pages du projet ---------------------------------------------------------62 def _project_info(self) -> tuple[list[str], list[str]]:63 """Commodités (liste « Des services et commodités » de l'accueil,64 `div.o-whiteCol ul li`) et photos du projet (uploads de l'accueil)."""65 amenities: list[str] = []66 images: list[str] = []67 try:68 html = self.get(f"{BASE}/").text69 except Exception:70 return amenities, images71 soup = BeautifulSoup(html, "html.parser")72 for li in soup.select("div.o-whiteCol ul li"):73 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()74 if 3 <= len(t) <= 90 and t not in amenities:75 amenities.append(t)76 for u in dict.fromkeys(_IMG_RE.findall(html)):77 if not _SKIP_IMG.search(u) and u not in images:78 images.append(u)79 return amenities[:30], images[: self.max_images]8081 # -- fiche unité ---------------------------------------------------------------82 def _fetch_detail(self, url: str) -> dict:83 """Fiche /appartements/<no> ou /phase2/<no> : disponibilité datée84 (« DISPONIBLE 1ER JUILLET 2026 »), description et plan/photo."""85 if self._fetched >= self.max_details:86 raise RuntimeError("budget de fiches unité atteint")87 self._fetched += 188 soup = BeautifulSoup(self.get(url).text, "html.parser")89 main = soup.select_one("main.l-app") or soup.select_one("main")90 out: dict = {}91 if main is None:92 return out9394 paras: list[str] = []95 for p in main.select("p"):96 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()97 if not t:98 continue99 # 1er paragraphe en gras « DISPONIBLE … » = disponibilité affichée100 if not paras and not out.get("availability") and _AVAIL_RE.match(t):101 out["availability"] = t.capitalize()102 continue103 paras.append(t)104 if paras:105 out["description"] = "\n".join(paras)[:1200]106107 img = main.select_one("img[src]")108 if img and img["src"].startswith("http"):109 out["image"] = img["src"]110 return out111112 # -- fetch -----------------------------------------------------------------113 def fetch(self) -> list[Listing]:114 amenities, project_images = self._project_info()115 self._fetched = 0116117 listings: dict[str, Listing] = {}118 for page_url, floor_label in FLOOR_PAGES:119 try:120 soup = BeautifulSoup(self.get(page_url).text, "html.parser")121 except Exception:122 continue123 # SVG du contenu principal seulement (le popup répète l'étage 1)124 for a in soup.select("main.l-etages a.c-app"):125 try:126 self._parse_unit(a, page_url, floor_label,127 amenities, project_images, listings)128 except Exception:129 continue130 return list(listings.values())131132 def _parse_unit(self, a, page_url: str, floor_label: str,133 amenities: list[str], project_images: list[str],134 listings: dict[str, Listing]) -> None:135 status = (a.get("data-app-disponible") or "").strip().lower()136 num = (a.get("data-app-no") or "").strip()137 href = (a.get("href") or "").strip()138 if not (num and href):139 return140 # seules les unités « dispo » sont des annonces (Réservé/Loué exclus)141 if status != "dispo":142 return143144 # external_id stable : chemin de la fiche (appartements-106, phase2-305)145 ext = href.strip("/").replace("/", "-")146 if ext in listings:147 return148 url = href if href.startswith("http") else f"{BASE}{href}"149150 cell_txt = re.sub(r"\s+", " ", a.get_text(" ", strip=True))151 m = _TYPE_RE.search(cell_txt)152 unit_type = f"{m.group(1)}½" if m else ""153 plus = bool(m and m.group(2)) # « 4½ + » = pièce bureau en sus154 m = _PRICE_RE.search(cell_txt)155 price_label = (re.sub(r"\s+", " ", m.group(0)).strip() + "/mois"156 if m else "")157158 # fiche unité (cache BD : re-téléchargée seulement si la cellule change)159 cell_key = hashlib.sha1(160 f"{status}|{cell_txt}".encode("utf-8")).hexdigest()161 try:162 detail = self.detail(ext, cell_key,163 lambda u=url: self._fetch_detail(u))164 except Exception:165 detail = {}166167 images = list(project_images)168 if detail.get("image") and detail["image"] not in images:169 images.insert(0, detail["image"])170171 desc_bits = [floor_label]172 if plus:173 desc_bits.append("4½ + (pièce pour bureau supplémentaire)")174 if detail.get("description"):175 desc_bits.append(detail["description"])176177 # n° affiché : « 402-2 » est un artefact de slug WordPress, la fiche178 # titre « Appartement no. : 402 » (l'external_id garde le slug complet)179 display_num = re.sub(r"-\d$", "", num)180181 listings[ext] = Listing(182 source=self.source_id,183 external_id=ext,184 url=url,185 title=f"Le Baronet — Appartement {display_num}",186 address="", # aucune adresse civique publiée187 sector="",188 city=CITY,189 unit_type=unit_type,190 price=parse_price(price_label),191 price_label=price_label,192 # texte de la fiche (« Disponible 1er juillet 2026 »), sinon le193 # statut de la légende du plan (« Disponible »)194 availability=detail.get("availability", "Disponible"),195 description="\n".join(desc_bits)[:1400],196 amenities=list(amenities),197 images=images[: self.max_images + 1],198 )199