SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
5.5 KB · 143 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/jcl.py : connecteur petites annonces Groupe JCL5#   (annonces.groupejcl.com) — journal local des Basses-Laurentides / Rive-Nord6#   (Sainte-Thérèse, Saint-Jérôme, Saint-Eustache et environs). WordPress +7#   plugin « Classified Listing » (rtcl) rendu serveur : on ne moissonne QUE la8#   catégorie immobilier/logements-a-louer (cartes .rtcl-listing-item : titre,9#   prix, localité, lien). La page détail (.rtin-des / .rtin-location) fournit10#   description, adresse et photos. Granularité : annonce (unité).11#   ⚠️ Portail de petites annonces de particuliers : certaines annonces restent12#   affichées longtemps — Lou-Ka les reprend telles qu'affichées par la source.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing21from .base import BaseConnector2223BASE = "https://annonces.groupejcl.com"24CAT_URL = f"{BASE}/categories/immobilier/logements-a-louer/"25MAX_PAGES = 102627IMG_RE = re.compile(28    r"https://annonces\.groupejcl\.com/wp-content/uploads/classified-listing/"29    r"[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I)30THUMB_RE = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpe?g|png|webp)$)", re.I)31PRICE_RE = re.compile(r"(\d[\d\s,.]*)\s*\$?")32REGION_RE = re.compile(r"\s+et\s+environs\s*$", re.I)333435class JclConnector(BaseConnector):36    source_id = "jcl"37    request_delay = 0.838    max_images = 103940    # -- page détail (cachée en BD via self.detail) ---------------------------41    def _detail(self, url: str) -> dict:42        try:43            html = self.get(url).text44        except Exception:45            return {}46        soup = BeautifulSoup(html, "html.parser")47        payload: dict = {}4849        el = soup.select_one(".rtin-content-area .rtin-content")50        if el is not None:51            payload["description"] = re.sub(52                r"\s+", " ", el.get_text(" ", strip=True))[:1500]5354        # « 367 rue leonard | Saint-Jérôme et environs, Québec »55        for el in soup.select(".rtin-location"):56            parts = [p.strip() for p in57                     el.get_text("|", strip=True).split("|") if p.strip()]58            street = [p for p in parts59                      if not re.search(r"et environs|Québec$", p, re.I)]60            if street:61                payload["address"] = street[0]62                break6364        imgs = []65        for u in dict.fromkeys(IMG_RE.findall(html)):66            full = THUMB_RE.sub("", u)67            if full not in imgs:68                imgs.append(full)69        payload["images"] = imgs[: self.max_images]70        return payload7172    # -- fetch -----------------------------------------------------------------73    def fetch(self) -> list[Listing]:74        listings: list[Listing] = []75        seen: set[str] = set()7677        for page in range(1, MAX_PAGES + 1):78            url = CAT_URL if page == 1 else f"{CAT_URL}page/{page}/"79            try:80                html = self.get(url).text81            except Exception:82                break83            soup = BeautifulSoup(html, "html.parser")84            items = soup.select(".rtcl-listing-item")85            if not items:86                break8788            new_on_page = 089            for it in items:90                a = it.select_one(".rtin-title a[href]")91                if a is None:92                    continue93                href = a["href"].split("?")[0]94                slug = href.rstrip("/").rsplit("/", 1)[-1]95                if not slug or slug in seen:96                    continue97                seen.add(slug)98                new_on_page += 199100                title = re.sub(r"\s+", " ", a.get_text(" ", strip=True))101102                price = None103                price_label = ""104                el = it.select_one(".rtcl-price-amount")105                if el is not None:106                    price_label = re.sub(r"\s+", " ",107                                         el.get_text(" ", strip=True))108                    m = PRICE_RE.search(price_label.replace(",", ""))109                    if m:110                        try:111                            val = float(m.group(1).replace(" ", "")112                                        .replace(" ", ""))113                            if 300 <= val <= 20000:114                                price = val115                        except ValueError:116                            pass117118                # localité : lien /listing-location/quebec/<ville>/119                city = ""120                loc = it.select_one('a[href*="/listing-location/"]')121                if loc is not None:122                    city = REGION_RE.sub(123                        "", loc.get_text(" ", strip=True)).strip()124125                key = f"{title}|{price_label}|{city}"126                det = self.detail(slug, key, lambda u=href: self._detail(u))127128                listings.append(Listing(129                    source=self.source_id,130                    external_id=slug,131                    url=href,132                    title=title,133                    address=det.get("address", ""),134                    city=city,135                    price=price,136                    price_label=price_label,137                    description=det.get("description", ""),138                    images=list(det.get("images") or []),139                ))140            if new_on_page == 0:141                break142        return listings143