# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lequerre.py : connecteur Habitations L'Équerre # (habitationslequerre.com) — OBNL d'habitation de Sherbrooke (34 immeubles # abordables, logement locatif privé consultable, pas du HLM). WordPress, # mais la page /immeubles/ est un gabarit maison (Tailwind) rendu serveur : # une
par immeuble vedette, dont le # data-category vaut « disponibles » quand l'immeuble a des unités à louer — # badge « DISPONIBLE MAINTENANT - 735$ / mois », h2 (type de logements), # h3 (adresse civique), paragraphe descriptif et visuel en arrière-plan. # Seules les sections « disponibles » deviennent des annonces (granularité # immeuble : le site ne publie pas d'unités individuelles datées). # external_id = version normalisée de l'adresse (stable). # ----------------------------------------------------------------------------- from __future__ import annotations import re import unicodedata from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://habitationslequerre.com" LIST_URL = f"{BASE}/immeubles/" def _slugify(text: str) -> str: t = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") return t or "immeuble" def _clean_unit_type(raw: str) -> str: if re.search(r"\bstudios?\b", raw or "", re.I): return "Studio" ut = normalize_unit_type(raw or "") if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): return ut return "" class LequerreConnector(BaseConnector): source_id = "lequerre" request_delay = 0.7 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for sec in soup.select("section.building-section"): cats = (sec.get("data-category") or "").lower() if "disponibles" not in cats: continue # immeuble sans unité à louer actuellement try: lst = self._parse_section(sec) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst return list(listings.values()) def _parse_section(self, sec) -> Listing | None: h2 = sec.select_one("h2") h3 = sec.select_one("h3") title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)).strip() \ if h2 else "" address_full = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip() \ if h3 else "" if not (title or address_full): return None # « 14, rue Jean-Maurice, Sherbrooke » -> adresse + ville address, city = address_full, "" m = re.match(r"^(.*),\s*(Sherbrooke)\s*$", address_full, re.I) if m: address, city = m.group(1).strip(), m.group(2) # badge « DISPONIBLE MAINTENANT - 735$ / mois » badge = "" for div in sec.find_all("div"): t = div.get_text(" ", strip=True) if re.match(r"^\s*DISPONIBLE", t or "", re.I) and len(t) < 90: badge = re.sub(r"\s+", " ", t).strip() break availability, price_label = badge, "" if "-" in badge: availability, _, price_label = (p.strip() for p in badge.partition("-")) # paragraphe descriptif description = "" p = sec.select_one("p") if p: description = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()[:1500] # visuel : background-image de la colonne photo images: list[str] = [] bg = sec.select_one("[style*=background-image]") if bg: m_img = re.search(r"url\('([^']+)'\)", bg.get("style") or "") if m_img and m_img.group(1).startswith("http"): images = [m_img.group(1)] return Listing( source=self.source_id, external_id=_slugify(address or title), url=LIST_URL, title=title or address_full, address=address, city=city, unit_type=_clean_unit_type(title), price=parse_price(price_label), price_label=price_label, availability=availability, description=description, images=images, )