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/sibelanger.py : connecteur Société immobilière Bélanger5# (sibelanger.com) — page /appartements-a-louer/ : cartes d'unités avec6# prix, « Disponible dès… », secteur, commodités et carrousel de photos.7# Pages détail (cache BD self.detail) : adresse complète avec code postal,8# superficie (« Plan de l'unité … pi2 »), description riche, commodités9# de l'appartement et de l'immeuble, galerie complète.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import hashlib14import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, infer_city, normalize_unit_type, parse_price19from .base import BaseConnector2021BASE = "https://sibelanger.com"22LIST_URL = f"{BASE}/appartements-a-louer/"2324IMG_RE = re.compile(25 r"https://sibelanger\.com/app/uploads/[^\"'\\\s\)]+"26 r"\.(?:jpg|jpeg|png|webp)", re.I)27IMG_NOISE_RE = re.compile(r"logo|favicon|icon|sib_ico", re.I)282930class SibelangerConnector(BaseConnector):31 source_id = "sibelanger"32 request_delay = 0.633 max_details = 60 # garde-fou3435 def fetch(self) -> list[Listing]:36 html = self.get(LIST_URL).text37 soup = BeautifulSoup(html, "html.parser")3839 listings: dict[str, Listing] = {}40 for card in soup.select("div.listing__thumbnail"):41 try:42 lst = self._parse_card(card)43 except Exception:44 continue45 if lst and lst.external_id not in listings:46 listings[lst.external_id] = lst4748 # Pages détail (cache BD) : adresse complète, superficie, description,49 # commodités, galerie complète50 fetched = 051 for i, lst in enumerate(listings.values()):52 if i >= self.max_details:53 break54 key = hashlib.sha1(55 f"{lst.title}|{lst.price_label}|{lst.availability}"56 .encode("utf-8")).hexdigest()5758 def _fetch(url=lst.url):59 nonlocal fetched60 if fetched >= self.max_details:61 raise RuntimeError("plafond de requêtes détail atteint")62 fetched += 163 return self._fetch_detail(url)6465 try:66 payload = self.detail(lst.external_id, key, _fetch)67 except Exception:68 payload = {}69 if payload:70 self._apply_detail(lst, payload)7172 return list(listings.values())7374 def _parse_card(self, card) -> Listing | None:75 link = card.select_one("a.listing__thumbnail__content__title-wrapper") \76 or card.select_one("a[href*='/appartements-a-louer/']")77 if not link:78 return None79 url = link.get("href", "").split("?")[0]80 m = re.search(r"/appartements-a-louer/([a-z0-9\-]+)/?$", url)81 if not m:82 return None83 slug = m.group(1)8485 fav = card.select_one("[data-unit-id]")86 ext_id = (fav.get("data-unit-id", "").strip() if fav else "") or slug8788 h3 = card.select_one("h3")89 title = h3.get_text(" ", strip=True) if h3 else slug90 sector_el = card.select_one(91 ".listing__thumbnail__content__title-wrapper p")92 sector = sector_el.get_text(strip=True) if sector_el else ""9394 price_el = card.select_one(".listing__thumbnail__price")95 price_label = price_el.get_text(" ", strip=True) if price_el else ""96 avail_el = card.select_one(".listing__thumbnail__availability")97 avail = avail_el.get_text(" ", strip=True) if avail_el else ""98 size_el = card.select_one(".listing__thumbnail__size")99 unit_raw = size_el.get_text(" ", strip=True) if size_el else ""100101 # Exclusions (prudence : le site est résidentiel)102 if re.search(r"stationnement|commercial|rangement|entrepos",103 f"{title} {unit_raw}", re.I):104 return None105106 # Adresse dérivée du slug : « 350-101-chemin-ste-foy-… » ->107 # « 350, Chemin Ste-Foy » (n° d'immeuble, n° d'unité, rue)108 address = ""109 s = re.sub(r"^copie-de-", "", slug)110 ma = re.match(r"^(\d+)-\d+[a-z]?-([a-z\-]+?)"111 r"(?:-selection|-modele|-app|$)", s)112 if ma:113 street = " ".join(w.capitalize() for w in ma.group(2).split("-"))114 address = f"{ma.group(1)}, {street}"115116 amenities = [img.get("title") or img.get("alt", "")117 for img in card.select(118 ".listing__thumbnail__content__features img")]119 amenities = [a.strip() for a in amenities if a and a.strip()]120121 images = []122 for img in card.select(".swiper-slide img"):123 src = img.get("src") or img.get("data-src") or ""124 if src.startswith("http") and not IMG_NOISE_RE.search(src):125 images.append(src)126127 return Listing(128 source=self.source_id,129 external_id=ext_id,130 url=url,131 title=title,132 address=address,133 sector=sector,134 city=infer_city(sector),135 unit_type=normalize_unit_type(unit_raw),136 price=parse_price(price_label),137 price_label=price_label,138 availability=avail,139 amenities=amenities,140 images=list(dict.fromkeys(images)),141 )142143 def _fetch_detail(self, url: str) -> dict:144 """Télécharge une page détail et en extrait le payload brut (cacheable)."""145 html = self.get(url).text146 soup = BeautifulSoup(html, "html.parser")147148 # entête : adresse complète (code postal) + disponibilité149 address = availability = ""150 info = soup.select_one("div.single-unit__info")151 if info:152 paras = [p.get_text(" ", strip=True) for p in info.select("p")]153 for t in paras:154 if not address and re.search(r"\d.+(?:Québec|Lévis|G\d[A-Z]\s?\d[A-Z]\d)", t):155 address = t156 elif re.search(r"Disponible|Libre|Loué", t, re.I):157 availability = t.strip(" .")158159 # description riche (sans les invites « Demander une visite »)160 desc = ""161 sec = soup.select_one("section.single-unit__description")162 if sec:163 desc = re.sub(r"\s+", " ", sec.get_text(" ", strip=True))164 desc = re.sub(r"Cliquer sur le lien ici pour\s*(Demander une visite)?\s*",165 "", desc).strip()166 if not desc:167 og = soup.find("meta", attrs={"property": "og:description"}) or \168 soup.find("meta", attrs={"name": "description"})169 if og and og.get("content"):170 desc = og["content"].strip()171172 # superficie affichée sous le plan (« 1360 pi2 ») — texte brut,173 # normalisé ensuite par finalize()174 surf_el = soup.select_one("span.single-unit__plan-surface")175 surface = surf_el.get_text(" ", strip=True) if surf_el else ""176177 # caractéristiques de l'appartement + commodités de l'immeuble178 # (les « Services de proximité » — épicerie, parc… — sont exclus)179 amenities: list[str] = []180 for section in soup.select("section.single-unit__features"):181 title_el = section.select_one("h2")182 title = title_el.get_text(" ", strip=True) if title_el else ""183 if re.search(r"proximit", title, re.I):184 continue185 for li in section.select("li"):186 t = li.get_text(" ", strip=True)187 if t and len(t) < 60 and t not in amenities:188 amenities.append(t)189190 images = [u for u in dict.fromkeys(IMG_RE.findall(html))191 if not IMG_NOISE_RE.search(u)192 and not re.search(r"-\d+x\d+\.", u)]193194 return {"address": address, "availability": availability,195 "description": desc[:800], "surface": surface,196 "amenities": amenities, "images": images}197198 def _apply_detail(self, lst: Listing, payload: dict) -> None:199 """Applique le payload d'une page détail (frais ou depuis le cache)."""200 if payload.get("address"):201 lst.address = payload["address"]202 if payload.get("availability") and not lst.availability:203 lst.availability = payload["availability"]204 if payload.get("description"):205 lst.description = payload["description"]206 # superficie : texte source (« 1360 pi2 ») ajouté aux commodités pour207 # affichage + parsing par finalize()208 extra = list(payload.get("amenities") or [])209 if payload.get("surface"):210 extra.append(payload["surface"])211 lst.amenities = list(dict.fromkeys(lst.amenities + extra))212 merged = list(dict.fromkeys((payload.get("images") or []) + lst.images))213 if merged:214 lst.images = merged[:25]215