SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
4.7 KB · 123 lines python
Raw Blame History
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½\+?|\+|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