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/lequerre.py : connecteur Habitations L'Équerre5# (habitationslequerre.com) — OBNL d'habitation de Sherbrooke (34 immeubles6# abordables, logement locatif privé consultable, pas du HLM). WordPress,7# mais la page /immeubles/ est un gabarit maison (Tailwind) rendu serveur :8# une <section class="building-section"> par immeuble vedette, dont le9# data-category vaut « disponibles » quand l'immeuble a des unités à louer —10# badge « DISPONIBLE MAINTENANT - 735$ / mois », h2 (type de logements),11# h3 (adresse civique), paragraphe descriptif et visuel en arrière-plan.12# Seules les sections « disponibles » deviennent des annonces (granularité13# immeuble : le site ne publie pas d'unités individuelles datées).14# external_id = version normalisée de l'adresse (stable).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re19import unicodedata2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://habitationslequerre.com"27LIST_URL = f"{BASE}/immeubles/"282930def _slugify(text: str) -> str:31 t = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()32 t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")33 return t or "immeuble"343536def _clean_unit_type(raw: str) -> str:37 if re.search(r"\bstudios?\b", raw or "", re.I):38 return "Studio"39 ut = normalize_unit_type(raw or "")40 if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""):41 return ut42 return ""434445class LequerreConnector(BaseConnector):46 source_id = "lequerre"47 request_delay = 0.74849 def fetch(self) -> list[Listing]:50 html = self.get(LIST_URL).text51 soup = BeautifulSoup(html, "html.parser")5253 listings: dict[str, Listing] = {}54 for sec in soup.select("section.building-section"):55 cats = (sec.get("data-category") or "").lower()56 if "disponibles" not in cats:57 continue # immeuble sans unité à louer actuellement58 try:59 lst = self._parse_section(sec)60 except Exception:61 continue62 if lst and lst.external_id not in listings:63 listings[lst.external_id] = lst64 return list(listings.values())6566 def _parse_section(self, sec) -> Listing | None:67 h2 = sec.select_one("h2")68 h3 = sec.select_one("h3")69 title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)).strip() \70 if h2 else ""71 address_full = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip() \72 if h3 else ""73 if not (title or address_full):74 return None7576 # « 14, rue Jean-Maurice, Sherbrooke » -> adresse + ville77 address, city = address_full, ""78 m = re.match(r"^(.*),\s*(Sherbrooke)\s*$", address_full, re.I)79 if m:80 address, city = m.group(1).strip(), m.group(2)8182 # badge « DISPONIBLE MAINTENANT - 735$ / mois »83 badge = ""84 for div in sec.find_all("div"):85 t = div.get_text(" ", strip=True)86 if re.match(r"^\s*DISPONIBLE", t or "", re.I) and len(t) < 90:87 badge = re.sub(r"\s+", " ", t).strip()88 break89 availability, price_label = badge, ""90 if "-" in badge:91 availability, _, price_label = (p.strip() for p in92 badge.partition("-"))9394 # paragraphe descriptif95 description = ""96 p = sec.select_one("p")97 if p:98 description = re.sub(r"\s+", " ",99 p.get_text(" ", strip=True)).strip()[:1500]100101 # visuel : background-image de la colonne photo102 images: list[str] = []103 bg = sec.select_one("[style*=background-image]")104 if bg:105 m_img = re.search(r"url\('([^']+)'\)", bg.get("style") or "")106 if m_img and m_img.group(1).startswith("http"):107 images = [m_img.group(1)]108109 return Listing(110 source=self.source_id,111 external_id=_slugify(address or title),112 url=LIST_URL,113 title=title or address_full,114 address=address,115 city=city,116 unit_type=_clean_unit_type(title),117 price=parse_price(price_label),118 price_label=price_label,119 availability=availability,120 description=description,121 images=images,122 )123