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/laberge.py : connecteur Gestion immobilière Laberge (laberge.qc.ca)5# Site rendu serveur. La page /recherche liste tous les complexes avec, pour6# chacun, un tableau des types d'unités ; les lignes cliquables7# (data-href="/complexe/{slug}/appartement/{code}") sont les unités8# disponibles. Une annonce par unité disponible ; la fiche unité fournit9# les photos, le secteur, la superficie, la description et les inclusions.10# Région retenue : Québec / Lévis / L'Ancienne-Lorette (les complexes de la11# région de Montréal — Côte St-Luc, LaSalle, Pierrefonds, etc. — sont exclus).12# Les fiches unités sont visitées via self.detail() (cache BD, max13# max_details nouveaux fetchs par synchronisation).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re19from urllib.parse import urljoin2021from bs4 import BeautifulSoup2223from ..schema import Listing, infer_city, normalize_unit_type, parse_price, strip_accents24from .base import BaseConnector2526BASE = "https://www.laberge.qc.ca"27SEARCH_URL = f"{BASE}/recherche"2829# villes de la région Québec/Lévis retenues (normalisées sans accents)30ALLOWED_CITIES = {31 "quebec", "levis", "l'ancienne-lorette", "ancienne-lorette",32 "saint-augustin-de-desmaures", "st-augustin-de-desmaures",33}3435IMG_RE = re.compile(r"^/image/\d+/\d+/")36EXCLUDE_UNIT_RE = re.compile(r"stationnement|parking|rangement|commercial|garage", re.I)3738# pictogrammes dont le libellé texte est générique ("Inclus")39_PICTO_NAMES = {40 "poele--frigo": "Poêle et réfrigérateur",41 "entree_laveuse_secheuse": "Entrée laveuse-sécheuse",42 "laveuse_secheuse": "Laveuse-sécheuse",43 "lave-vaisselle": "Lave-vaisselle",44 "lv": "Lave-vaisselle",45 "micro-onde": "Micro-ondes",46 "air_climatise": "Air climatisé",47}484950class _DetailBudget(Exception):51 """Budget de fiches unités atteint pour cette synchronisation."""525354class LabergeConnector(BaseConnector):55 source_id = "laberge"56 request_delay = 0.657 max_details = 150 # garde-fou : nb max de fiches unités visitées58 max_images = 255960 # -- helpers ---------------------------------------------------------------61 @staticmethod62 def _card_address_city(card) -> tuple[str, str]:63 """Adresse civique + ville depuis la carte complexe de /recherche."""64 p = card.select_one(".bg-white p")65 if not p:66 return "", ""67 for a in p.find_all("a"): # retirer le lien téléphone68 a.decompose()69 addr = p.get_text(" ", strip=True)70 # ex. « 224, rue seigneuriale, Québec, G1E 0M8 »71 m = re.search(r"^(.*?),\s*([^,]+?),?\s*[A-Z]\d[A-Z]\s*\d[A-Z]\d", addr)72 if m:73 return m.group(1).strip(), m.group(2).strip()74 parts = [x.strip() for x in addr.split(",") if x.strip()]75 if len(parts) >= 2:76 return ", ".join(parts[:-1]), parts[-1]77 return addr, ""7879 @staticmethod80 def _picto_labels(container) -> list[str]:81 """Libellés des pictogrammes non grisés (grisé = exclu/absent)."""82 labels: list[str] = []83 for cell in container.select("div.text-center"):84 p = cell.find("p")85 img = cell.find("img")86 if p is None:87 continue88 style = p.get("style") or ""89 src = (img.get("src") or "") if img else ""90 if "#ccc" in style or "_off." in src:91 continue # service exclu (picto grisé)92 label = p.get_text(" ", strip=True)93 if not label or label.lower() == "inclus":94 stem = src.rsplit("/", 1)[-1].rsplit(".", 1)[0]95 label = _PICTO_NAMES.get(stem, stem.replace("_", " ")96 .replace("--", " ").replace("-", " ")97 .strip().capitalize())98 if label and label not in labels:99 labels.append(label)100 return labels101102 def _fetch_detail(self, url: str) -> dict:103 """Fiche unité -> payload JSON-sérialisable (mis en cache BD)."""104 html = self.get(url).text105 soup = BeautifulSoup(html, "html.parser")106 payload: dict = {}107108 # secteur depuis le <title> : « Appartement - Beauport - Le 224 #… »109 if soup.title:110 parts = soup.title.get_text(strip=True).split(" - ")111 if len(parts) >= 2 and parts[0].lower().startswith("appartement"):112 payload["sector"] = parts[1].strip()113114 # photos de l'unité (URLs absolues)115 imgs = []116 for img in soup.find_all("img"):117 src = img.get("src") or img.get("data-src") or ""118 if IMG_RE.match(src):119 imgs.append(urljoin(BASE, src))120 payload["images"] = list(dict.fromkeys(imgs))[: self.max_images]121122 # bloc « Grandeur / Étage / Disponibilité / Superficie / Débutant » :123 # paires h3 -> p structurées de la fiche124 for block in soup.select(".caracteristiques-appart.description-appart"):125 for h3 in block.find_all("h3"):126 label = h3.get_text(" ", strip=True).lower()127 val_el = h3.find_next_sibling("p")128 if not val_el:129 continue130 val = " ".join(val_el.get_text(" ", strip=True).split())131 if label.startswith("superficie"):132 m = re.search(r"([\d\s.,]+?)\s*pi", val)133 if m:134 try:135 payload["area_sqft"] = float(136 m.group(1).replace(" ", "").replace(",", "."))137 except ValueError:138 pass139 elif label.startswith("étage") or label.startswith("etage"):140 if val.isdigit():141 payload["floor"] = int(val)142 elif label.startswith("disponibilit"):143 payload["availability"] = val144145 # description (paragraphe sous le h2 « Description »)146 for h2 in soup.find_all("h2"):147 if h2.get_text(strip=True).lower().startswith("description"):148 sib = h2.find_next("p")149 if sib:150 desc = sib.get_text(" ", strip=True)151 if len(desc) > 40:152 payload["description"] = desc[:600]153 break154155 amenities: list[str] = []156 # inclusions de l'unité (pictos non grisés = inclus)157 for block in soup.select(".caracteristiques-appart"):158 for label in self._picto_labels(block):159 if label not in amenities:160 amenities.append(label)161 # caractéristiques en liste (« Entrée laveuse, sécheuse »,162 # « Balcon privé : 1 », « Stationnement extérieur : … »)163 for li in block.select("ul li"):164 label = " ".join(li.get_text(" ", strip=True).split())165 if label and label not in amenities:166 amenities.append(label)167168 # services de l'immeuble + dispositifs de sécurité (pictos non grisés)169 for h2 in soup.find_all("h2"):170 titre = h2.get_text(strip=True).lower()171 if titre.startswith(("services de l", "dispositifs de s")):172 parent = h2.find_parent("div")173 row = parent.parent if parent else None174 if row is None:175 continue176 for label in self._picto_labels(row):177 if label not in amenities:178 amenities.append(label)179 payload["amenities"] = amenities180181 # adresse complète de l'immeuble (avec code postal) : h3 « Adresse »182 for h3 in soup.find_all("h3"):183 if h3.get_text(strip=True).lower() == "adresse":184 p = h3.find_next_sibling("p")185 if p:186 for a in p.find_all("a"): # lien « Autres adresses »187 a.decompose()188 lignes = [" ".join(x.split()) for x in p.stripped_strings]189 if lignes:190 payload["address"] = ", ".join(lignes)191 break192 return payload193194 def _enrich_from_detail(self, lst: Listing) -> None:195 """Complète l'annonce avec la fiche unité (via cache self.detail)."""196 key = hashlib.sha1(197 f"{lst.unit_type}|{lst.price_label}|{lst.availability}"198 .encode("utf-8")).hexdigest()199200 def fetch_fn():201 if self._detail_fetches >= self.max_details:202 raise _DetailBudget()203 self._detail_fetches += 1204 return self._fetch_detail(lst.url)205206 try:207 payload = self.detail(lst.external_id, key, fetch_fn)208 except _DetailBudget:209 return210 if not payload:211 return212213 if payload.get("sector"):214 lst.sector = payload["sector"]215 lst.city = infer_city(lst.sector, default=lst.city or "Québec")216 if payload.get("images"):217 lst.images = payload["images"]218 if payload.get("address"):219 lst.address = payload["address"]220 if payload.get("description"):221 lst.description = payload["description"]222 if payload.get("amenities"):223 lst.amenities = payload["amenities"]224 if payload.get("area_sqft"):225 lst.area_sqft = payload["area_sqft"] # structuré à la source226 if payload.get("floor"):227 lst.details["floor"] = payload["floor"]228 if payload.get("availability") and not lst.availability:229 lst.availability = payload["availability"]230231 # -- contrat ---------------------------------------------------------------232 def fetch(self) -> list[Listing]:233 html = self.get(SEARCH_URL).text234 soup = BeautifulSoup(html, "html.parser")235236 listings: dict[str, Listing] = {}237 for card in soup.select(".un-appart"):238 try:239 link = card.select_one("a.title-link")240 if not link or not link.get("href"):241 continue242 slug = link["href"].rstrip("/").rsplit("/", 1)[-1]243 name_el = link.find(["h2", "h3"])244 complex_name = (name_el.get_text(" ", strip=True)245 if name_el else slug.replace("-", " ").title())246247 address, city = self._card_address_city(card)248 city_key = strip_accents(city.lower().strip())249 if city_key not in ALLOWED_CITIES:250 continue # hors région Québec/Lévis (ex. Montréal)251252 # lignes cliquables du tableau = unités disponibles253 for row in card.select("tr.clickable-row[data-href]"):254 cells = [td.get_text(" ", strip=True)255 for td in row.find_all("td")]256 if len(cells) < 3:257 continue258 unit_type_raw, price_raw, avail_raw = cells[0], cells[1], cells[2]259 if EXCLUDE_UNIT_RE.search(unit_type_raw):260 continue261 href = row["data-href"]262 unit_code = href.rstrip("/").rsplit("/", 1)[-1]263 ext_id = f"{slug}-{unit_code}"264 if ext_id in listings:265 continue266 avail = re.sub(r"\s*chevron_right\s*$", "",267 avail_raw).strip()268 listings[ext_id] = Listing(269 source=self.source_id,270 external_id=ext_id,271 url=urljoin(BASE, href),272 title=f"{complex_name} — {unit_type_raw} (#{unit_code})",273 address=address,274 sector="",275 city=city,276 unit_type=normalize_unit_type(unit_type_raw),277 price=parse_price(price_raw),278 price_label=price_raw,279 availability=avail,280 )281 except Exception:282 continue # carte malformée : on passe à la suivante283284 # fiches détaillées (photos, secteur, description, inclusions) —285 # via self.detail() : seules les unités nouvelles/modifiées sont286 # réellement visitées (max self.max_details par synchronisation)287 self._detail_fetches = 0288 for lst in listings.values():289 try:290 self._enrich_from_detail(lst)291 except Exception:292 continue293294 return list(listings.values())295