SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.3 KB · 179 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/alouer_sherbrooke.py : connecteur À Louer Sherbrooke5#   (alouersherbrooke.com) — 100+ logements dans 5 immeubles regroupés du6#   quartier Université (Sherbrooke) ; clientèle étudiants/professionnels/7#   retraités. WordPress (thème idconception) : la liste est servie par8#   admin-ajax `pagination-load-posts` (CPT appartements, 8 cartes/page,9#   pagination dans la réponse). Cartes : classe `dispo` (À louer, avec date10#   « À partir du … ») ou `loue` (Loué — exclues), grandeur (3½…), quartier,11#   inclusions et photo. Les fiches (via self.detail, cache BD) ajoutent la12#   description complète et la galerie. AUCUN prix ni adresse civique publiés13#   par la source (immeubles regroupés, quartier Université) — champs laissés14#   vides, rien d'inventé. external_id = slug du billet.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type24from .base import BaseConnector2526BASE = "https://www.alouersherbrooke.com"27AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"2829_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)303132class AlouerSherbrookeConnector(BaseConnector):33    source_id = "alouer_sherbrooke"34    request_delay = 0.635    max_pages = 12       # garde-fou (7 pages observées)36    max_details = 30     # garde-fou fiches détail (vraies requêtes)3738    def _ajax_page(self, page: int) -> BeautifulSoup:39        resp = self.session.post(40            AJAX_URL,41            data={"page": str(page), "action": "pagination-load-posts"},42            timeout=self.timeout)43        resp.raise_for_status()44        return BeautifulSoup(resp.text, "html.parser")4546    def fetch(self) -> list[Listing]:47        listings: dict[str, Listing] = {}48        for page in range(1, self.max_pages + 1):49            try:50                soup = self._ajax_page(page)51            except Exception:52                break53            cards = soup.select(".appartements__bloc")54            if not cards:55                break56            for card in cards:57                try:58                    lst = self._parse_card(card)59                except Exception:60                    continue61                if lst and lst.external_id not in listings:62                    listings[lst.external_id] = lst63            # borne réelle de pagination (liens numériques de la réponse)64            nums = [int(a.get_text(strip=True))65                    for a in soup.select(".cvf-universal-pagination li a")66                    if a.get_text(strip=True).isdigit()]67            if nums and page >= max(nums):68                break6970        # fiches détail (cache BD) : description + galerie71        self._fetched = 072        for lst in listings.values():73            key = hashlib.sha1(74                f"{lst.title}|{lst.availability}|{lst.unit_type}"75                .encode("utf-8")).hexdigest()76            try:77                payload = self.detail(lst.external_id, key,78                                      lambda u=lst.url: self._fetch_detail(u))79            except Exception:80                continue81            if payload.get("description"):82                lst.description = payload["description"]83            if payload.get("images"):84                lst.images = list(dict.fromkeys(payload["images"] + lst.images))[:20]85        return list(listings.values())8687    # -- carte AJAX -------------------------------------------------------------88    def _parse_card(self, card) -> Listing | None:89        classes = card.get("class") or []90        if "dispo" not in classes:91            return None          # « loue » = logement loué, exclu92        link = card.select_one("a[href*='/appartements/']")93        if not link:94            return None95        url = link["href"].split("?")[0]96        m = re.search(r"/appartements/([^/]+)/?$", url.rstrip("/") + "/")97        m = re.search(r"/appartements/([^/]+)", url)98        if not m:99            return None100        slug = m.group(1).strip("/")101102        title = (link.get("title") or "").strip()103        if not title:104            h2 = card.select_one("h2")105            title = h2.get_text(" ", strip=True) if h2 else slug.replace("-", " ")106107        # « À louer » + « À partir du 1 septembre 2026 »108        tag = card.select_one(".appartements__dispoTag")109        date = card.select_one(".appartements__date")110        availability = " ".join(x for x in (111            tag.get_text(" ", strip=True) if tag else "",112            date.get_text(" ", strip=True) if date else "") if x).strip()113114        gr = card.select_one(".appartement__grandeur")115        unit_type = normalize_unit_type(gr.get_text(strip=True) if gr else "")116        if not re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison",117                            unit_type or ""):118            unit_type = ""119120        qu = card.select_one(".appartement__quartier")121        sector = re.sub(r"\s+", " ",122                        qu.get_text(" ", strip=True)).strip() if qu else ""123124        amenities = []125        for li in card.select("li"):126            t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))127            if t and t not in amenities:128                amenities.append(t)129130        img = card.select_one("img[src]")131        images = []132        if img and str(img.get("src", "")).startswith("http"):133            images.append(_VARIANT_IMG.sub("", img["src"]))134135        return Listing(136            source=self.source_id,137            external_id=slug,138            url=url,139            title=title,140            address="",           # adresse civique non publiée par la source141            sector=sector,        # « Université »142            city="Sherbrooke",143            unit_type=unit_type,144            availability=availability,145            amenities=amenities,146            images=images,147        )148149    # -- fiche appartement --------------------------------------------------------150    def _fetch_detail(self, url: str) -> dict:151        if self._fetched >= self.max_details:152            raise RuntimeError("budget de fiches détail atteint")153        self._fetched += 1154        html = self.get(url).text155        soup = BeautifulSoup(html, "html.parser")156        out: dict = {}157158        # contenu du billet : listes descriptives (appartement, immeuble, à159        # proximité) — tout en texte brut, le textmine central s'en charge160        main = soup.select_one("article, .single__content, main") or soup161        parts: list[str] = []162        for el in main.find_all(["p", "li", "h2", "h3"]):163            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()164            if t and t not in parts:165                parts.append(t)166            if sum(len(x) for x in parts) > 1500:167                break168        if parts:169            out["description"] = " | ".join(parts)[:1500]170171        images = []172        for img in soup.select("img[src*='/wp-content/uploads/']"):173            src = _VARIANT_IMG.sub("", str(img.get("src") or ""))174            if src.startswith("http") and src not in images \175                    and not re.search(r"logo|icon|favicon", src, re.I):176                images.append(src)177        out["images"] = images[:20]178        return out179