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/lambert.py : connecteur Société Immobilière Lambert5# (lambertimmobilier.com — Louiseville, Yamachiche, Mauricie). WordPress6# avec thème custom « cognitif-starter » : la page /logements-disponibles/7# liste des <article class="apartment"> (type h2, prix, <address>, photo en8# background-image). On ne garde que la section « Nos logements à louer »9# (les <article> suivant le titre « Projets à venir » = terrains, exclus).10# Aucune page détail ; external_id = slug de l'adresse. 1 requête par sync.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re15import unicodedata1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "https://lambertimmobilier.com"23LIST_URL = f"{BASE}/logements-disponibles/"2425_BG_URL_RE = re.compile(r"background-image\s*:\s*url\(['\"]?([^'\")]+)")26_CITIES = [27 ("louiseville", "Louiseville"),28 ("yamachiche", "Yamachiche"),29 ("trois-rivieres", "Trois-Rivières"),30]313233def _strip_accents(s: str) -> str:34 return "".join(c for c in unicodedata.normalize("NFD", s)35 if unicodedata.category(c) != "Mn")363738def _slug(s: str) -> str:39 s = _strip_accents(s.lower())40 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")414243class LambertConnector(BaseConnector):44 source_id = "lambert"45 request_delay = 0.74647 def fetch(self) -> list[Listing]:48 html = self.get(LIST_URL).text49 soup = BeautifulSoup(html, "html.parser")50 listings: dict[str, Listing] = {}51 in_projects = False52 for el in soup.find_all(["h1", "h2", "article"]):53 if el.name in ("h1", "h2"):54 txt = el.get_text(" ", strip=True)55 if re.search(r"projets? à venir", txt, re.I):56 in_projects = True # terrains/projets : hors annonces57 continue58 if in_projects or "apartment" not in (el.get("class") or []):59 continue60 try:61 self._parse_card(el, listings)62 except Exception:63 continue64 return list(listings.values())6566 def _parse_card(self, card, listings: dict[str, Listing]) -> None:67 head = card.select_one("h2")68 head_txt = re.sub(r"\s+", " ", head.get_text(" ", strip=True)) if head else ""69 if re.search(r"terrain|commercial|local\b", head_txt, re.I):70 return71 addr_el = card.find("address")72 address_full = re.sub(r"\s+", " ",73 addr_el.get_text(" ", strip=True)) if addr_el else ""74 if not address_full and not head_txt:75 return7677 # ville en fin d'adresse (« 131 St-Ubald Louiseville »)78 city, address = "", address_full79 low = _strip_accents(address_full.lower())80 for key, name in _CITIES:81 if key in low:82 city = name83 address = re.sub(rf",?\s*{key}\s*$", "", address_full,84 flags=re.I).strip(" ,")85 break8687 price_el = card.select_one(".apartment--price")88 price_label = re.sub(r"\s+", " ",89 price_el.get_text(" ", strip=True)) if price_el else ""9091 images: list[str] = []92 img_div = card.select_one("div.img[style]")93 if img_div:94 m = _BG_URL_RE.search(img_div["style"])95 if m and m.group(1).startswith("http"):96 images.append(m.group(1))9798 ext = _slug(address_full or head_txt)99 if not ext or ext in listings:100 return101 title = f"{head_txt} — {address_full}" if head_txt else address_full102 listings[ext] = Listing(103 source=self.source_id,104 external_id=ext,105 url=LIST_URL,106 title=title,107 address=address,108 city=city,109 unit_type=normalize_unit_type(head_txt),110 price=parse_price(price_label),111 price_label=price_label,112 images=images,113 )114