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/labonte.py : connecteur Gestion Labonté (gestionlabonte.com)5# Gestionnaire établi de Sherbrooke (66 ans). Site Wix : la page /logements6# est rendue serveur — répéteur Wix dont chaque item7# (div id="comp-…__<guid>") porte le prix (« À partir de N $/mois »),8# l'adresse (h2), le nombre de pièces, le secteur, la photo et le lien vers9# la page dynamique /logements/<guid> (le GUID du CMS Wix = external_id10# stable). Les fiches détail (via self.detail, cache BD) ajoutent la11# description, les paires « Détails du logement » (secteur, pièces,12# chambres, étage) et l'adresse complète du bloc « Emplacement ». La galerie13# photos des fiches est chargée côté client (pro-gallery, non SSR) : seule14# la photo de la carte est disponible. Aucun statut « loué » affiché — la15# liste ne montre que l'inventaire courant.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price25from .base import BaseConnector2627BASE = "https://www.gestionlabonte.com"28LIST_URL = f"{BASE}/logements"2930_GUID_RE = re.compile(r"/logements/([0-9a-f-]{36})")3132# villes possibles dans l'adresse « Emplacement » (jamais devinées)33_CITY_RE = re.compile(r"\b(Sherbrooke|Magog|East Angus|Lennoxville)\b", re.I)343536def _clean_unit_type(raw: str) -> str:37 """normalize_unit_type, mais seulement si le motif est net (« 3.5 »,38 « 4,5 », « studio »…) — jamais le texte brut en guise de type."""39 ut = normalize_unit_type(raw)40 if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""):41 return ut42 return ""434445def _lines(el) -> list[str]:46 """Lignes de texte nettoyées d'un sous-arbre."""47 out = []48 for raw in el.get_text("\n", strip=True).split("\n"):49 t = re.sub(r"\s+", " ", raw).strip()50 if t:51 out.append(t)52 return out535455class LabonteConnector(BaseConnector):56 source_id = "labonte"57 request_delay = 0.758 max_details = 25 # garde-fou fiches détail (vraies requêtes)5960 def fetch(self) -> list[Listing]:61 html = self.get(LIST_URL).text62 soup = BeautifulSoup(html, "html.parser")6364 listings: dict[str, Listing] = {}65 # items du répéteur : conteneurs suffixés par le GUID de l'item CMS66 for item in soup.select('div[id*="__"]'):67 link = item.select_one('a[href*="/logements/"]')68 if not link:69 continue70 m = _GUID_RE.search(link.get("href") or "")71 if not m:72 continue73 guid = m.group(1)74 iid = (item.get("id") or "")75 if not iid.endswith(f"__{guid}") or guid in listings:76 continue # garder le conteneur d'item complet, une fois7778 lines = _lines(item)79 # « À partir de | 1150 | $/mois | 40 rue St-François |80 # Nombre de pièces | 3.5 | Secteur | Centre-ville | En savoir plus »81 h2 = item.select_one("h2")82 address = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if h2 else ""8384 price = None85 price_label = ""86 for i, t in enumerate(lines):87 if t.lower().startswith("à partir de") and i + 1 < len(lines):88 amount = lines[i + 1]89 unit = lines[i + 2] if i + 2 < len(lines) else ""90 price_label = f"À partir de {amount} {unit}".strip()91 price = parse_price(f"{amount} $")92 break9394 pieces = sector = ""95 for i, t in enumerate(lines):96 if t.lower().startswith("nombre de pièces") and i + 1 < len(lines):97 pieces = lines[i + 1]98 elif t.lower() == "secteur" and i + 1 < len(lines):99 sector = lines[i + 1]100101 img = item.select_one('img[src*="static.wixstatic.com/media"]')102 images = []103 if img:104 images.append(img["src"].split("/v1/")[0])105106 listings[guid] = Listing(107 source=self.source_id,108 external_id=guid,109 url=f"{BASE}/logements/{guid}",110 title=address or f"Logement {guid[:8]}",111 address=address,112 sector=sector,113 city="Sherbrooke",114 unit_type=_clean_unit_type(pieces),115 price=price,116 price_label=price_label,117 availability="", # non publié par la source118 images=images,119 )120121 # fiches détail (cache BD) : description, chambres/étage, adresse complète122 self._fetched = 0123 for lst in listings.values():124 key = hashlib.sha1(125 f"{lst.title}|{lst.price_label}|{lst.sector}"126 .encode("utf-8")).hexdigest()127 try:128 payload = self.detail(lst.external_id, key,129 lambda u=lst.url: self._fetch_detail(u))130 except Exception:131 continue132 self._apply_detail(lst, payload)133 return list(listings.values())134135 # -- fiche détail (page dynamique Wix) -------------------------------------------136 def _fetch_detail(self, url: str) -> dict:137 if self._fetched >= self.max_details:138 raise RuntimeError("budget de fiches détail atteint")139 self._fetched += 1140 html = self.get(url).text141 soup = BeautifulSoup(html, "html.parser")142 lines = _lines(soup.body or soup)143 out: dict = {}144145 # description : entre « Description » et « Détails du logement »146 try:147 i = lines.index("Description")148 j = lines.index("Détails du logement")149 if 0 <= i < j:150 out["description"] = " ".join(lines[i + 1:j])[:1500]151 except ValueError:152 pass153154 # paires « Détails du logement » : Secteur / pièces / chambres / Étage155 for i, t in enumerate(lines):156 low = t.lower()157 if i + 1 >= len(lines):158 break159 if low == "secteur":160 out["sector"] = lines[i + 1]161 elif low.startswith("nombre de pièces"):162 out["pieces"] = lines[i + 1]163 elif low.startswith("nombre de chambres"):164 out["bedrooms"] = lines[i + 1]165 elif low == "étage":166 out["floor"] = lines[i + 1]167 elif low == "emplacement":168 out["full_address"] = lines[i + 1]169 return out170171 def _apply_detail(self, lst: Listing, d: dict) -> None:172 if not d:173 return174 if d.get("description"):175 lst.description = d["description"]176 if d.get("sector") and not lst.sector:177 lst.sector = d["sector"]178 if d.get("full_address"):179 full = d["full_address"]180 # « 49 Rue King Ouest, Sherbrooke, QC J1H 1P1, Canada »181 lst.address = ", ".join(p.strip() for p in full.split(",")[:2])182 m = _CITY_RE.search(full)183 if m:184 lst.city = m.group(1).title()185 if not lst.unit_type and d.get("pieces"):186 lst.unit_type = _clean_unit_type(d["pieces"])187 details: dict = {}188 if str(d.get("bedrooms") or "").isdigit():189 details["bedrooms"] = int(d["bedrooms"])190 if str(d.get("floor") or "").isdigit():191 details["floor"] = int(d["floor"])192 if details:193 lst.details = {**lst.details, **details}194