SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.3 KB · 139 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/logement_mauricie.py : connecteur Logement Mauricie5#   (logementmauricie.com) — regroupement de propriétaires (+200 logements).6#   Builder type WebSelf : 4 pages secteur (Trois-Rivières, Cap-de-la-7#   Madeleine, Shawinigan, Shawinigan-Sud), chaque immeuble = un widget8#   texte (id GUID stable) avec champs libellés « Type / Actuellement9#   disponible / Date / Chauffé éclairé / Combien $ / Contact… ». On ne10#   retient que les immeubles avec une disponibilité réelle (≠ Complet).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re15from urllib.parse import urljoin1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "http://logementmauricie.com"2324# pages secteur -> (ville réelle, secteur) ; Cap-de-la-Madeleine et25# Shawinigan-Sud sont des secteurs des villes fusionnées (2002)26PAGES = [27    ("trois-rivieres", "Trois-Rivières", ""),28    ("cap-de-la-madeleine", "Trois-Rivières", "Cap-de-la-Madeleine"),29    ("shawinigan", "Shawinigan", ""),30    ("shawinigan-sud", "Shawinigan", "Shawinigan-Sud"),31]3233# libellés des champs du gabarit d'immeuble34_LABELS = re.compile(35    r"^(Type|Actuellement disponible|Date|Chauff[ée] [ée]clair[ée]|"36    r"Commodit[ée]s?|Entr[ée]e laveuse et s[ée]cheuse|Stationnements?|"37    r"Plus|Combien \$|Contact)\s*:?\s*$", re.I)383940def _parse_fields(lines: list[str]) -> dict[str, str]:41    """Paires libellé -> valeur (les valeurs suivent le libellé, ligne(s)42    suivante(s) jusqu'au prochain libellé)."""43    fields: dict[str, list[str]] = {}44    cur: str | None = None45    for ln in lines:46        m = _LABELS.match(ln)47        if m:48            cur = m.group(1).lower()49            fields.setdefault(cur, [])50        elif cur is not None:51            fields[cur].append(ln)52    return {k: " ".join(v).strip() for k, v in fields.items()}535455class LogementMauricieConnector(BaseConnector):56    source_id = "logement_mauricie"57    request_delay = 0.858    max_pages = 4          # 4 pages secteur, pas de pagination5960    def fetch(self) -> list[Listing]:61        listings: list[Listing] = []62        for slug, city, sector in PAGES[:self.max_pages]:63            url = f"{BASE}/{slug}/"64            try:65                html = self.get(url).text66            except Exception:67                continue68            soup = BeautifulSoup(html, "html.parser")69            for w in soup.select("div.widget.widget-text[id]"):70                try:71                    lst = self._parse_widget(w, url, city, sector)72                except Exception:73                    continue74                if lst is not None:75                    listings.append(lst)76        return listings7778    def _parse_widget(self, w, page_url: str, city: str,79                      sector: str) -> Listing | None:80        txt = w.get_text("\n", strip=True)81        if "Actuellement disponible" not in txt:82            return None       # widget décoratif (intro, bandeau…)83        lines = [re.sub(r"\s+", " ", ln).strip()84                 for ln in txt.split("\n") if ln.strip()]85        fields = _parse_fields(lines)8687        # disponibilité réelle seulement : « Complet », vide ou « - » = ignoré88        dispo = fields.get("actuellement disponible", "").strip(" - ")89        if not dispo or re.search(r"^complet", dispo, re.I):90            return None91        if re.search(r"commercial|stationnement seulement|local\b", dispo, re.I):92            return None9394        h2 = w.find("h2")95        address = h2.get_text(" ", strip=True) if h2 else ""96        if not address or not re.match(r"\d", address):97            return None       # bloc sans adresse civique = pas un immeuble9899        ext_id = w.get("id", "")   # GUID du widget, stable dans le builder100        if not ext_id:101            return None102103        date_txt = fields.get("date", "").strip(" - ")104        price_label = fields.get("combien $", "").strip(" - ")105106        # description : bloc de champs + paragraphe descriptif du secteur107        desc_lines = []108        started = False109        for ln in lines:110            if ln == address:111                started = True112                continue113            if started and ln.lower() not in ("revenir en haut",):114                desc_lines.append(ln)115116        images: list[str] = []117        for img in w.find_all("img", src=True):118            src = urljoin(page_url, img["src"])119            if re.search(r"google-maps|icon|logo", src, re.I):120                continue121            if src.startswith("http") and src not in images:122                images.append(src)123124        return Listing(125            source=self.source_id,126            external_id=ext_id,127            url=f"{page_url}#{ext_id}",     # pas de fiche individuelle128            title=f"{dispo}{address}",129            address=address,130            sector=sector,131            city=city,132            unit_type=normalize_unit_type(dispo),133            price=parse_price(price_label),134            price_label=price_label,135            availability=date_txt,136            description="\n".join(desc_lines)[:900],137            images=images[:10],138        )139