# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/laberge.py : connecteur Gestion immobilière Laberge (laberge.qc.ca) # Site rendu serveur. La page /recherche liste tous les complexes avec, pour # chacun, un tableau des types d'unités ; les lignes cliquables # (data-href="/complexe/{slug}/appartement/{code}") sont les unités # disponibles. Une annonce par unité disponible ; la fiche unité fournit # les photos, le secteur, la superficie, la description et les inclusions. # Région retenue : Québec / Lévis / L'Ancienne-Lorette (les complexes de la # région de Montréal — Côte St-Luc, LaSalle, Pierrefonds, etc. — sont exclus). # Les fiches unités sont visitées via self.detail() (cache BD, max # max_details nouveaux fetchs par synchronisation). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.laberge.qc.ca" SEARCH_URL = f"{BASE}/recherche" # villes de la région Québec/Lévis retenues (normalisées sans accents) ALLOWED_CITIES = { "quebec", "levis", "l'ancienne-lorette", "ancienne-lorette", "saint-augustin-de-desmaures", "st-augustin-de-desmaures", } IMG_RE = re.compile(r"^/image/\d+/\d+/") EXCLUDE_UNIT_RE = re.compile(r"stationnement|parking|rangement|commercial|garage", re.I) # pictogrammes dont le libellé texte est générique ("Inclus") _PICTO_NAMES = { "poele--frigo": "Poêle et réfrigérateur", "entree_laveuse_secheuse": "Entrée laveuse-sécheuse", "laveuse_secheuse": "Laveuse-sécheuse", "lave-vaisselle": "Lave-vaisselle", "lv": "Lave-vaisselle", "micro-onde": "Micro-ondes", "air_climatise": "Air climatisé", } class _DetailBudget(Exception): """Budget de fiches unités atteint pour cette synchronisation.""" class LabergeConnector(BaseConnector): source_id = "laberge" request_delay = 0.6 max_details = 150 # garde-fou : nb max de fiches unités visitées max_images = 25 # -- helpers --------------------------------------------------------------- @staticmethod def _card_address_city(card) -> tuple[str, str]: """Adresse civique + ville depuis la carte complexe de /recherche.""" p = card.select_one(".bg-white p") if not p: return "", "" for a in p.find_all("a"): # retirer le lien téléphone a.decompose() addr = p.get_text(" ", strip=True) # ex. « 224, rue seigneuriale, Québec, G1E 0M8 » m = re.search(r"^(.*?),\s*([^,]+?),?\s*[A-Z]\d[A-Z]\s*\d[A-Z]\d", addr) if m: return m.group(1).strip(), m.group(2).strip() parts = [x.strip() for x in addr.split(",") if x.strip()] if len(parts) >= 2: return ", ".join(parts[:-1]), parts[-1] return addr, "" @staticmethod def _picto_labels(container) -> list[str]: """Libellés des pictogrammes non grisés (grisé = exclu/absent).""" labels: list[str] = [] for cell in container.select("div.text-center"): p = cell.find("p") img = cell.find("img") if p is None: continue style = p.get("style") or "" src = (img.get("src") or "") if img else "" if "#ccc" in style or "_off." in src: continue # service exclu (picto grisé) label = p.get_text(" ", strip=True) if not label or label.lower() == "inclus": stem = src.rsplit("/", 1)[-1].rsplit(".", 1)[0] label = _PICTO_NAMES.get(stem, stem.replace("_", " ") .replace("--", " ").replace("-", " ") .strip().capitalize()) if label and label not in labels: labels.append(label) return labels def _fetch_detail(self, url: str) -> dict: """Fiche unité -> payload JSON-sérialisable (mis en cache BD).""" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") payload: dict = {} # secteur depuis le : « Appartement - Beauport - Le 224 #… » if soup.title: parts = soup.title.get_text(strip=True).split(" - ") if len(parts) >= 2 and parts[0].lower().startswith("appartement"): payload["sector"] = parts[1].strip() # photos de l'unité (URLs absolues) imgs = [] for img in soup.find_all("img"): src = img.get("src") or img.get("data-src") or "" if IMG_RE.match(src): imgs.append(urljoin(BASE, src)) payload["images"] = list(dict.fromkeys(imgs))[: self.max_images] # bloc « Grandeur / Étage / Disponibilité / Superficie / Débutant » : # paires h3 -> p structurées de la fiche for block in soup.select(".caracteristiques-appart.description-appart"): for h3 in block.find_all("h3"): label = h3.get_text(" ", strip=True).lower() val_el = h3.find_next_sibling("p") if not val_el: continue val = " ".join(val_el.get_text(" ", strip=True).split()) if label.startswith("superficie"): m = re.search(r"([\d\s.,]+?)\s*pi", val) if m: try: payload["area_sqft"] = float( m.group(1).replace(" ", "").replace(",", ".")) except ValueError: pass elif label.startswith("étage") or label.startswith("etage"): if val.isdigit(): payload["floor"] = int(val) elif label.startswith("disponibilit"): payload["availability"] = val # description (paragraphe sous le h2 « Description ») for h2 in soup.find_all("h2"): if h2.get_text(strip=True).lower().startswith("description"): sib = h2.find_next("p") if sib: desc = sib.get_text(" ", strip=True) if len(desc) > 40: payload["description"] = desc[:600] break amenities: list[str] = [] # inclusions de l'unité (pictos non grisés = inclus) for block in soup.select(".caracteristiques-appart"): for label in self._picto_labels(block): if label not in amenities: amenities.append(label) # caractéristiques en liste (« Entrée laveuse, sécheuse », # « Balcon privé : 1 », « Stationnement extérieur : … ») for li in block.select("ul li"): label = " ".join(li.get_text(" ", strip=True).split()) if label and label not in amenities: amenities.append(label) # services de l'immeuble + dispositifs de sécurité (pictos non grisés) for h2 in soup.find_all("h2"): titre = h2.get_text(strip=True).lower() if titre.startswith(("services de l", "dispositifs de s")): parent = h2.find_parent("div") row = parent.parent if parent else None if row is None: continue for label in self._picto_labels(row): if label not in amenities: amenities.append(label) payload["amenities"] = amenities # adresse complète de l'immeuble (avec code postal) : h3 « Adresse » for h3 in soup.find_all("h3"): if h3.get_text(strip=True).lower() == "adresse": p = h3.find_next_sibling("p") if p: for a in p.find_all("a"): # lien « Autres adresses » a.decompose() lignes = [" ".join(x.split()) for x in p.stripped_strings] if lignes: payload["address"] = ", ".join(lignes) break return payload def _enrich_from_detail(self, lst: Listing) -> None: """Complète l'annonce avec la fiche unité (via cache self.detail).""" key = hashlib.sha1( f"{lst.unit_type}|{lst.price_label}|{lst.availability}" .encode("utf-8")).hexdigest() def fetch_fn(): if self._detail_fetches >= self.max_details: raise _DetailBudget() self._detail_fetches += 1 return self._fetch_detail(lst.url) try: payload = self.detail(lst.external_id, key, fetch_fn) except _DetailBudget: return if not payload: return if payload.get("sector"): lst.sector = payload["sector"] lst.city = infer_city(lst.sector, default=lst.city or "Québec") if payload.get("images"): lst.images = payload["images"] if payload.get("address"): lst.address = payload["address"] if payload.get("description"): lst.description = payload["description"] if payload.get("amenities"): lst.amenities = payload["amenities"] if payload.get("area_sqft"): lst.area_sqft = payload["area_sqft"] # structuré à la source if payload.get("floor"): lst.details["floor"] = payload["floor"] if payload.get("availability") and not lst.availability: lst.availability = payload["availability"] # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Listing]: html = self.get(SEARCH_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for card in soup.select(".un-appart"): try: link = card.select_one("a.title-link") if not link or not link.get("href"): continue slug = link["href"].rstrip("/").rsplit("/", 1)[-1] name_el = link.find(["h2", "h3"]) complex_name = (name_el.get_text(" ", strip=True) if name_el else slug.replace("-", " ").title()) address, city = self._card_address_city(card) city_key = strip_accents(city.lower().strip()) if city_key not in ALLOWED_CITIES: continue # hors région Québec/Lévis (ex. Montréal) # lignes cliquables du tableau = unités disponibles for row in card.select("tr.clickable-row[data-href]"): cells = [td.get_text(" ", strip=True) for td in row.find_all("td")] if len(cells) < 3: continue unit_type_raw, price_raw, avail_raw = cells[0], cells[1], cells[2] if EXCLUDE_UNIT_RE.search(unit_type_raw): continue href = row["data-href"] unit_code = href.rstrip("/").rsplit("/", 1)[-1] ext_id = f"{slug}-{unit_code}" if ext_id in listings: continue avail = re.sub(r"\s*chevron_right\s*$", "", avail_raw).strip() listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=urljoin(BASE, href), title=f"{complex_name} — {unit_type_raw} (#{unit_code})", address=address, sector="", city=city, unit_type=normalize_unit_type(unit_type_raw), price=parse_price(price_raw), price_label=price_raw, availability=avail, ) except Exception: continue # carte malformée : on passe à la suivante # fiches détaillées (photos, secteur, description, inclusions) — # via self.detail() : seules les unités nouvelles/modifiées sont # réellement visitées (max self.max_details par synchronisation) self._detail_fetches = 0 for lst in listings.values(): try: self._enrich_from_detail(lst) except Exception: continue return list(listings.values())