SPB Git

spb/lou-ka Public

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

HTML 99.7%
4.8 KB · 121 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/gestion_legrand.py : connecteur Gestion Le Grand (gestionlegrand.ca)5#   WordPress + Elementor. Les logements sont des articles de la catégorie6#   « Location » (id 28) : l'API wp-json livre id, lien, titre et contenu7#   complet en 1 requête ; la page /a-louer/ (2e requête) fournit les8#   vignettes (certains médias sont bloqués côté REST). Prix (« Prix :9#   1 200 $ / mois »), dispo et adresse lus dans le contenu de l'article.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, normalize_unit_type, parse_price18from .base import BaseConnector1920BASE = "https://gestionlegrand.ca"21API_URL = (f"{BASE}/wp-json/wp/v2/posts?categories=28&per_page=100"22           "&_fields=id,link,title,content")23LIST_URL = f"{BASE}/a-louer/"2425_PRIX_RE = re.compile(r"Prix\s*:\s*([\d\s.,]*\d\s*\$(?:\s*/?\s*mois)?)", re.I)26_ADDR_RE = re.compile(r"situ[ée]e?\s+au\s+(\d+[^,.]*,\s*(?:rue|boul(?:evard|\.)?|"27                      r"avenue|av\.|place|chemin)[^,.]+)", re.I)28_SECTEUR_RE = re.compile(r"secteur\s+(?:[\w\s]{0,40}?de\s+)?"29                         r"([A-ZÉ][\wé]+(?:-[A-ZÉa-z][\wéè]+)+)")303132class GestionLegrandConnector(BaseConnector):33    source_id = "gestion_legrand"34    request_delay = 0.63536    def fetch(self) -> list[Listing]:37        posts = self.get(API_URL).json()3839        # vignettes : la grille Elementor de /a-louer/ (post-<id> -> data-src)40        thumbs: dict[str, str] = {}41        try:42            soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")43            for art in soup.select("article.elementor-post"):44                pid = next((c.split("-", 1)[1] for c in art.get("class", [])45                            if re.fullmatch(r"post-\d+", c)), "")46                img = art.select_one("img[data-src], img[src]")47                if pid and img:48                    src = img.get("data-src") or img.get("src") or ""49                    if src.startswith("http"):50                        thumbs[pid] = src51        except Exception:52            pass5354        listings: list[Listing] = []55        for post in posts:56            try:57                lst = self._parse_post(post, thumbs)58            except Exception:59                continue60            if lst:61                listings.append(lst)62        return listings6364    def _parse_post(self, post: dict, thumbs: dict[str, str]) -> Listing | None:65        ext_id = str(post.get("id", ""))66        url = post.get("link", "")67        title = BeautifulSoup(post.get("title", {}).get("rendered", ""),68                              "html.parser").get_text(" ", strip=True)69        if not ext_id or not url or not title:70            return None71        # exclusions : locaux commerciaux, stationnements, rangements72        if re.search(r"commercial|bureau|stationnement|rangement|entrep[ôo]t",73                     title, re.I):74            return None7576        body = BeautifulSoup(post.get("content", {}).get("rendered", ""),77                             "html.parser")78        paras = [re.sub(r"\s+", " ", el.get_text(" ", strip=True))79                 for el in body.find_all(["p", "h2", "h3", "li"])]80        paras = [p for p in paras if p]81        text = "\n".join(dict.fromkeys(paras))8283        # « Disponible maintenant », « Disponible dès juin 2026 !! », …84        availability = next((p for p in paras85                             if re.match(r"disponible\b", p, re.I)), "")8687        m = _PRIX_RE.search(text)88        price_label = m.group(1).strip() if m else ""8990        m = _ADDR_RE.search(text)91        address = m.group(1).strip() if m else ""9293        m = _SECTEUR_RE.search(text)94        sector = m.group(1) if m else ""9596        # ville réelle : le titre se termine par « …, Drummondville » ;97        # tout le parc Le Grand y est (repli documenté dans le rapport)98        city = title.rsplit(",", 1)[1].strip() if "," in title else "Drummondville"99100        unit_type = normalize_unit_type(title)101        if not re.fullmatch(r"\d½\+?|Studio|Loft|Maison", unit_type or ""):102            unit_type = ""103104        images = [thumbs[ext_id]] if ext_id in thumbs else []105106        return Listing(107            source=self.source_id,108            external_id=ext_id,109            url=url,110            title=title,111            address=address,112            sector=sector,113            city=city,114            unit_type=unit_type,115            price=parse_price(price_label),116            price_label=price_label,117            availability=availability,118            description=text[:2000],119            images=images,120        )121