# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/jcl.py : connecteur petites annonces Groupe JCL # (annonces.groupejcl.com) — journal local des Basses-Laurentides / Rive-Nord # (Sainte-Thérèse, Saint-Jérôme, Saint-Eustache et environs). WordPress + # plugin « Classified Listing » (rtcl) rendu serveur : on ne moissonne QUE la # catégorie immobilier/logements-a-louer (cartes .rtcl-listing-item : titre, # prix, localité, lien). La page détail (.rtin-des / .rtin-location) fournit # description, adresse et photos. Granularité : annonce (unité). # ⚠️ Portail de petites annonces de particuliers : certaines annonces restent # affichées longtemps — Lou-Ka les reprend telles qu'affichées par la source. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://annonces.groupejcl.com" CAT_URL = f"{BASE}/categories/immobilier/logements-a-louer/" MAX_PAGES = 10 IMG_RE = re.compile( r"https://annonces\.groupejcl\.com/wp-content/uploads/classified-listing/" r"[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I) THUMB_RE = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpe?g|png|webp)$)", re.I) PRICE_RE = re.compile(r"(\d[\d\s,.]*)\s*\$?") REGION_RE = re.compile(r"\s+et\s+environs\s*$", re.I) class JclConnector(BaseConnector): source_id = "jcl" request_delay = 0.8 max_images = 10 # -- page détail (cachée en BD via self.detail) --------------------------- def _detail(self, url: str) -> dict: try: html = self.get(url).text except Exception: return {} soup = BeautifulSoup(html, "html.parser") payload: dict = {} el = soup.select_one(".rtin-content-area .rtin-content") if el is not None: payload["description"] = re.sub( r"\s+", " ", el.get_text(" ", strip=True))[:1500] # « 367 rue leonard | Saint-Jérôme et environs, Québec » for el in soup.select(".rtin-location"): parts = [p.strip() for p in el.get_text("|", strip=True).split("|") if p.strip()] street = [p for p in parts if not re.search(r"et environs|Québec$", p, re.I)] if street: payload["address"] = street[0] break imgs = [] for u in dict.fromkeys(IMG_RE.findall(html)): full = THUMB_RE.sub("", u) if full not in imgs: imgs.append(full) payload["images"] = imgs[: self.max_images] return payload # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: listings: list[Listing] = [] seen: set[str] = set() for page in range(1, MAX_PAGES + 1): url = CAT_URL if page == 1 else f"{CAT_URL}page/{page}/" try: html = self.get(url).text except Exception: break soup = BeautifulSoup(html, "html.parser") items = soup.select(".rtcl-listing-item") if not items: break new_on_page = 0 for it in items: a = it.select_one(".rtin-title a[href]") if a is None: continue href = a["href"].split("?")[0] slug = href.rstrip("/").rsplit("/", 1)[-1] if not slug or slug in seen: continue seen.add(slug) new_on_page += 1 title = re.sub(r"\s+", " ", a.get_text(" ", strip=True)) price = None price_label = "" el = it.select_one(".rtcl-price-amount") if el is not None: price_label = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) m = PRICE_RE.search(price_label.replace(",", "")) if m: try: val = float(m.group(1).replace(" ", "") .replace(" ", "")) if 300 <= val <= 20000: price = val except ValueError: pass # localité : lien /listing-location/quebec// city = "" loc = it.select_one('a[href*="/listing-location/"]') if loc is not None: city = REGION_RE.sub( "", loc.get_text(" ", strip=True)).strip() key = f"{title}|{price_label}|{city}" det = self.detail(slug, key, lambda u=href: self._detail(u)) listings.append(Listing( source=self.source_id, external_id=slug, url=href, title=title, address=det.get("address", ""), city=city, price=price, price_label=price_label, description=det.get("description", ""), images=list(det.get("images") or []), )) if new_on_page == 0: break return listings