SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.3 KB · 171 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/logispro.py : connecteur LogisPro Rimouski5#   (logisprorimouski.com — Rimouski, parc de 110 logements, 30+ ans). WordPress6#   + Elementor + WP Rocket, tout rendu serveur. La page /nos-logements/7#   (« Logements disponibles ») liste les unités en location : cartes Elementor8#   (adresse, type, photo, bouton « Voir la fiche » vers une page WP par9#   unité). La fiche unité /<slug>/ fait foi : h1 = type (« 4 1/2 pièces »),10#   h4 = adresse, widget statut (« Disponible » / « Non disponible » -> exclu),11#   widget prix nu (« 850.00 », parfois « 600.00$ 655.00$ »), description en12#   listes (inclusions, AUCUN ANIMAL, « Disponible Juillet 2026 ») + section13#   « À proximité », galerie complète. Fiches via cache BD. Quand aucun14#   logement n'est libre la page n'a plus de cartes -> [] (marché plein =15#   normal). robots.txt Yoast ouvert, sitemap XML.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://logisprorimouski.com"27LIST_URL = f"{BASE}/nos-logements/"2829# widget prix : nombres nus ou avec « $ » (« 850.00 », « 600.00$ 655.00$ »)30_PRICE_WIDGET_RE = re.compile(r"^[\d\s.,$]{3,30}$")31_BARE_PRICE_RE = re.compile(r"^\d{3,4}(?:\.\d{2})?$")32# date plus précise dans la description (« Disponible Juillet 2026 »)33_AVAIL_RE = re.compile(34    r"Disponible\s+(?:d[èe]s\s+)?(?:maintenant|janvier|f[ée]vrier|mars|avril|"35    r"mai|juin|juillet|ao[ûu]t|septembre|octobre|novembre|d[ée]cembre)"36    r"(?:\s+\d{4})?", re.I)3738# pages du site qui ne sont pas des fiches d'unité39_NON_UNITS = {"nos-logements", "nos-proprietes", "nous-joindre",40              "le-william", "le-st-laurent", "le-st-jean"}414243class LogisproConnector(BaseConnector):44    source_id = "logispro"45    request_delay = 0.646    max_details = 20     # garde-fou fiches détail (vraies requêtes par sync)4748    def fetch(self) -> list[Listing]:49        html = self.get(LIST_URL).text50        soup = BeautifulSoup(html, "html.parser")5152        self._fetched = 053        listings: dict[str, Listing] = {}54        # une carte = bouton « Voir la fiche » vers la page de l'unité55        for btn in soup.select("a.elementor-button[href]"):56            try:57                self._parse_card(btn, listings)58            except Exception:59                continue60        return list(listings.values())6162    # -- carte (page « Logements disponibles ») -----------------------------------63    def _parse_card(self, btn, listings: dict[str, Listing]) -> None:64        url = btn["href"]65        m = re.search(r"^https://logisprorimouski\.com/([^/?#]+)/?$", url)66        if not m:67            return68        ext_id = m.group(1)69        if ext_id in _NON_UNITS or ext_id in listings:70            return7172        # photo de la carte (la fiche fournit la galerie complète)73        card = btn74        img = None75        for _ in range(10):76            card = card.parent77            if card is None:78                break79            img = card.select_one('img[src*="/uploads/"]')80            if img is not None:81                break82        images = ([img["src"]] if img and img.get("src", "").startswith("http")83                  else [])8485        # la fiche unité fait foi (type h1, adresse h4, statut, prix, photos).86        # Pas de cache BD ici : la liste ne porte ni prix ni statut (aucun87        # signal de fraîcheur pour invalider un cache) et le parc affiché est88        # minuscule (~6 fiches par sync, plafonnées par max_details).89        try:90            payload = self._fetch_detail(url)91        except Exception:92            payload = {}9394        # unité retirée ou marquée « Non disponible » sur sa fiche : on saute95        if re.match(r"^(non\s+disponible|lou[ée])", payload.get("availability", ""),96                    re.I):97            return9899        unit_label = payload.get("unit_label", "")100        address = payload.get("address", "")101        lst = Listing(102            source=self.source_id,103            external_id=ext_id,104            url=url,105            title=f"{unit_label}{address}".strip(" —"),106            address=address,107            city="Rimouski",                  # tout le parc est à Rimouski108            unit_type=normalize_unit_type(unit_label),109            price=payload.get("price"),110            price_label=payload.get("price_label", ""),111            availability=payload.get("availability", ""),112            description=payload.get("description", ""),113            images=payload.get("images") or images,114        )115        listings[ext_id] = lst116117    # -- fiche unité (/<slug>/) -----------------------------------------------------118    def _fetch_detail(self, url: str) -> dict:119        """Type, adresse, statut, prix (widget nu), description, galerie."""120        if self._fetched >= self.max_details:121            raise RuntimeError("budget de fiches détail atteint")122        self._fetched += 1123        html = self.get(url).text124        soup = BeautifulSoup(html, "html.parser")125        out: dict = {}126127        h1 = soup.select_one("h1.elementor-heading-title")128        if h1:129            out["unit_label"] = h1.get_text(" ", strip=True)130        h4 = soup.select_one("h4.elementor-heading-title")131        if h4:132            out["address"] = h4.get_text(" ", strip=True)133134        # widgets texte Elementor : statut et prix (« 850.00 », « 600.00$ … »)135        for w in soup.select(".elementor-widget-text-editor .elementor-widget-container"):136            t = re.sub(r"\s+", " ", w.get_text(" ", strip=True))137            if "price_label" not in out and _PRICE_WIDGET_RE.match(t) \138                    and re.search(r"\d{3}", t):139                out["price_label"] = t140                if "$" in t:141                    out["price"] = parse_price(t)142                elif _BARE_PRICE_RE.match(t):143                    out["price"] = float(t)144            elif re.match(r"^(non\s+)?disponible\b.{0,40}$|^lou[ée]\b.{0,20}$",145                          t, re.I) and "availability" not in out:146                out["availability"] = t147148        # description : sections « Description » et « À proximité » (listes)149        parts: list[str] = []150        for h in soup.select("h5.elementor-heading-title"):151            label = h.get_text(strip=True)152            if not re.match(r"Description|À proximité", label, re.I):153                continue154            section = h.find_parent(class_="e-con")155            if section:156                txt = section.get_text("\n", strip=True)157                parts.append(re.sub(r"[ \t]+", " ", txt))158        if parts:159            desc = "\n".join(parts)160            out["description"] = desc[:1500]161            # date plus précise éventuelle (« Disponible Juillet 2026 »)162            m = _AVAIL_RE.search(desc)163            if m and len(m.group(0)) > len(out.get("availability", "")):164                out["availability"] = re.sub(r"\s+", " ", m.group(0))165166        out["images"] = [167            im["src"] for im in soup.select('img[src*="/uploads/"]')168            if im.get("src", "").startswith("http")169            and not im["src"].endswith(".svg")][:25]170        return out171