SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.9 KB · 232 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/logisma.py : connecteur Logisma (logisma.ca)5#   Page de recherche /appartements-a-louer/ (boucle Elementor) : une carte6#   par unité disponible. Pages détail pour disponibilité, description,7#   promotion, liste « Détails : » (inclusions/animaux/fumeur), téléphone8#   (lien tel:) et toutes les images. L'API REST WP (/wp-json/wp/v2/location)9#   existe mais n'expose ni contenu ni ACF -> HTML seulement.10#   La section commerciale/industrielle est exclue.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, infer_city, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "https://logisma.ca"23LIST_URL = f"{BASE}/appartements-a-louer/"2425DETAIL_RE = re.compile(r"/appartements-a-louer/([a-z0-9\-]+)/?$")26IMG_RE = re.compile(27    r"https://logisma\.ca/wp-content/uploads/[^\"'\\\s\)]+"28    r"\.(?:jpg|jpeg|png|webp)", re.I)29IMG_NOISE_RE = re.compile(r"logo|favicon|icon|-\d+x\d+\.", re.I)303132class _DetailBudget(Exception):33    """Plafond de vraies requêtes de pages détail atteint pour cette synchro."""343536class LogismaConnector(BaseConnector):37    source_id = "logisma"38    request_delay = 0.639    max_details = 60             # plafond de VRAIES requêtes détail par synchro4041    def __init__(self) -> None:42        super().__init__()43        self._detail_fetches = 04445    def fetch(self) -> list[Listing]:46        html = self.get(LIST_URL).text47        soup = BeautifulSoup(html, "html.parser")4849        listings: dict[str, Listing] = {}50        # Cartes : <h3 class="... containsUrl" attr-url="...">51        #   <b>3 1/2</b> Québec, Ste-Foy<br>3 1/2 – 3003 avenue d'Entremont #552        for h3 in soup.select("h3.containsUrl[attr-url]"):53            try:54                lst = self._parse_card(h3)55            except Exception:56                continue57            if lst and lst.external_id not in listings:58                listings[lst.external_id] = lst5960        # Pages détail (avec cache BD) : clé = contenu de la carte, versionnée61        for lst in listings.values():62            key = "v3:" + hashlib.sha1(" | ".join(63                [lst.title, lst.price_label, lst.sector, lst.address])64                .encode("utf-8")).hexdigest()65            try:66                payload = self.detail(lst.external_id, key,67                                      lambda u=lst.url: self._fetch_detail(u))68            except _DetailBudget:69                continue70            except Exception:71                continue72            self._apply_detail(lst, payload)7374        return list(listings.values())7576    def _parse_card(self, h3) -> Listing | None:77        url = h3.get("attr-url", "").split("?")[0]78        m = DETAIL_RE.search(url)79        if not m:80            return None81        slug = m.group(1)8283        b = h3.find("b")84        unit_raw = b.get_text(" ", strip=True) if b else ""85        lines = [t.strip() for t in h3.get_text("\n", strip=True).split("\n")86                 if t.strip()]87        # lines ~ ['3 1/2', 'Québec, Ste-Foy', '3 1/2 – 3003 avenue …, Ste-Foy']88        sector = ""89        title = lines[-1] if lines else slug90        # Ligne ville/secteur : « Québec, Ste-Foy » (exclure hors Québec/Lévis)91        for ln in lines:92            mm = re.match(r"^([A-Za-zÀ-ÿ\-'’ ]+)\s*,\s*([A-Za-zÀ-ÿ\-'’ ]+)$", ln)93            if mm and "–" not in ln:94                city_raw = mm.group(1).strip().lower()95                if city_raw not in ("québec", "quebec", "lévis", "levis"):96                    return None          # hors agglomération Québec/Lévis97                sector = mm.group(2).strip()98                break99        # Adresse : après le tiret du titre (« 3 1/2 – 3003 avenue … #5, Ste-Foy »)100        address = ""101        if "–" in title or " - " in title:102            rest = re.split(r"|\s-\s", title, maxsplit=1)[-1].strip()103            segs = [s.strip() for s in rest.split(",")]104            if len(segs) > 1 and not re.search(r"\d", segs[-1]):105                segs = segs[:-1]         # retirer le secteur final106            address = ", ".join(s for s in segs if s)107108        # Prix : dans le conteneur de la carte (élément frère du titre)109        price_label = ""110        container = h3.find_parent(class_=re.compile(r"add-link-container")) or \111            h3.find_parent("div", attrs={"data-e-type": "container"})112        if container:113            mp = re.search(r"\d[\d\s ]*\$", container.get_text(" ", strip=True))114            if mp:115                price_label = mp.group(0).strip()116117        return Listing(118            source=self.source_id,119            external_id=slug,120            url=url,121            title=title,122            address=address,123            sector=sector,124            city=infer_city(sector),125            unit_type=normalize_unit_type(unit_raw),126            price=parse_price(price_label),127            price_label=price_label,128        )129130    def _fetch_detail(self, url: str) -> dict:131        if self._detail_fetches >= self.max_details:132            raise _DetailBudget(url)133        self._detail_fetches += 1134        resp = self.get(url)135        # lien mort : le site redirige vers la page liste -> garder la carte136        if resp.url.rstrip("/") != url.rstrip("/"):137            return {}138        html = resp.text139        soup = BeautifulSoup(html, "html.parser")140        body = soup.get_text(" ", strip=True)141        out: dict = {}142143        # Disponibilité : « Disponible le 1er juillet 2026 »144        m = re.search(r"[Dd]isponible\b(?!s)\s*(?:le|dès|en|maintenant|"145                      r"immédiatement|1er)[^.<,$]{0,50}", body)146        if m:147            avail = re.sub(r"\s+", " ", m.group(0)).strip()148            avail = re.split(r"\s+(?:UN MOIS|Description|Électro|Offre|Vous)",149                             avail)[0]150            out["availability"] = avail.strip()151152        # Prix de secours si absent de la carte (appliqué seulement si besoin)153        mp = re.search(r"(\d[\d\s ]*\$)\s*par mois", body)154        if mp:155            out["price_label"] = mp.group(1).strip()156157        # Liste « Détails : » (items <li> du widget Elementor — certaines pages158        # n'utilisent pas de puces « • » : extraction structurelle d'abord)159        amen: list[str] = []160        m = None161        mi = re.search(r"D[ée]tails\s*:?\s*</strong>", html)162        if mi:163            # le <ul> doit suivre de près (sinon : page à puces « • » sans <ul>,164            # et le premier </ul> venu serait le menu du pied de page)165            m = re.match(r".{0,1000}?<ul>(.*?)</ul>",166                         html[mi.end():mi.end() + 6000], re.S)167        if m:168            for li in re.findall(r"<li[^>]*>(.*?)</li>", m.group(1), re.S):169                t = re.sub(r"\s+", " ",170                           BeautifulSoup(li, "html.parser")171                           .get_text(" ", strip=True)).strip(" .•")172                if t:173                    amen.append(t)174        if not amen:   # repli : puces « • Eau chaude inclus » dans le texte175            amen = [re.sub(r"\s+", " ", a).strip(" .")176                    for a in re.findall(r"\s*([^•<\n]{3,60})", body)]177        # couper l'avertissement photos collé au dernier item178        amen = [re.split(r"\*\*|Les photos", a)[0].strip(" .")179                for a in amen]180        out["amenities"] = list(dict.fromkeys(a for a in amen if len(a) >= 3))[:20]181182        # Description : paragraphes entre « Description » et « Détails : »183        # (plus promotion « UN MOIS GRATUIT … » affichée sous le prix)184        desc_bits: list[str] = []185        mp = re.search(r"(UN MOIS GRATUIT[^.]*\.(?:\s*Prix régulier[^.]*\.)?)",186                       body)187        if mp:188            desc_bits.append("Promotion : " + re.sub(r"\s+", " ", mp.group(1)))189        m = re.search(r"<strong>\s*Description\s*</strong>\s*</p>(.*?)"190                      r"(?:<strong>\s*D[ée]tails|</div>\s*</div>\s*</div>)",191                      html, re.S)192        if m:193            txt = re.sub(r"\s+", " ", BeautifulSoup(m.group(1), "html.parser")194                         .get_text(" ", strip=True))195            if txt:196                desc_bits.append(txt)197        if not desc_bits:198            og = soup.find("meta", attrs={"property": "og:description"}) or \199                soup.find("meta", attrs={"name": "description"})200            if og and og.get("content"):201                desc_bits.append(og["content"].strip())202        out["description"] = " — ".join(desc_bits)[:600]203204        # Téléphone du bureau de location (lien tel:)205        for a in soup.select('a[href^="tel:"]'):206            num = re.sub(r"\D", "", a.get("href", "").split(",")[0])207            num = num[1:] if len(num) == 11 and num.startswith("1") else num208            if len(num) == 10:209                out["phone"] = f"{num[:3]}-{num[3:6]}-{num[6:10]}"210                break211212        out["images"] = [u for u in dict.fromkeys(IMG_RE.findall(html))213                         if not IMG_NOISE_RE.search(u)][:25]214        return out215216    def _apply_detail(self, lst: Listing, payload: dict) -> None:217        if not payload:218            return219        if payload.get("availability"):220            lst.availability = payload["availability"]221        if lst.price is None and payload.get("price_label"):222            lst.price_label = payload["price_label"]223            lst.price = parse_price(lst.price_label)224        if payload.get("amenities"):225            lst.amenities = payload["amenities"]226        if payload.get("description"):227            lst.description = payload["description"]228        if payload.get("phone"):229            lst.details.setdefault("contact", {})["phone"] = payload["phone"]230        if payload.get("images"):231            lst.images = payload["images"]232