# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/c3r.py : connecteur Gestion C3R (gestionc3r.com) # Gestionnaire de la Mauricie (Trois-Rivières, Shawinigan, Saint-Boniface, # Saint-Maurice). Refonte du site le 2026-09-08 : l'ancien site Wix (et son # API wix-data, collections Import1/Import2) a été remplacé par un site # statique Astro servi par Express — les collections n'existent plus # (WDE0025) et les anciennes URLs redirigent vers /logements. # Nouveau modèle : catalogue /logements?page=N paginé côté serveur # (~18 cartes/page, 73 unités, 4 projets), chaque carte
porte des attributs data-* propres (city, project, # bedrooms, price, status) + type, adresse, salle de bain, superficie et # photo dans le corps de la carte. Seul le statut « Disponible » est gardé # (Loué / Réservé : non). Granularité : une annonce par unité. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.gestionc3r.com" LIST_URL = f"{BASE}/logements" # seul « Disponible » = louable maintenant (Réservé / Loué : non) KEEP_STATUS = {"disponible"} MAX_PAGES = 20 # 5 pages au lancement (2026-09-08) — marge de croissance def _num(raw: str | None) -> float | None: """Premier nombre d'un texte (« 1 575 $/mois », « 1100 pi² ») → float.""" if raw is None: return None digits = re.sub(r"[^\d,.]", "", str(raw)).replace(",", ".") m = re.match(r"\d+(?:\.\d+)?", digits) return float(m.group(0)) if m else None class C3RConnector(BaseConnector): source_id = "c3r" request_delay = 0.7 def fetch(self) -> list[Listing]: listings: list[Listing] = [] seen: set[str] = set() cards_total = 0 for page in range(1, MAX_PAGES + 1): params = {"page": page} if page > 1 else None # le serveur n'annonce pas de charset → requests décoderait en # latin-1 (« Loué ») ; la page déclare html = self.get(LIST_URL, params=params).content.decode( "utf-8", errors="replace") soup = BeautifulSoup(html, "html.parser") cards = soup.select("article[data-rental-unit]") if not cards: break new_on_page = 0 for card in cards: link = card.select_one("a[href]") href = (link.get("href") or "").strip() if link else "" slug = href.rstrip("/").split("/")[-1] if href else "" ext_id = slug or "" if not ext_id or ext_id in seen: continue # au-delà de la dernière page, le site re-sert seen.add(ext_id) # la même page → dédup par slug new_on_page += 1 cards_total += 1 status = (card.get("data-status") or "").strip() if status.lower() not in KEEP_STATUS: continue city = (card.get("data-city") or "").strip() price = _num(card.get("data-price")) beds = _num(card.get("data-bedrooms")) el = card.select_one(".rental-card__type") unit_type = normalize_unit_type( el.get_text(strip=True) if el else "") el = card.select_one("h3") title = el.get_text(" ", strip=True) if el else ext_id el = card.select_one(".rental-card__location") address = el.get_text(" ", strip=True) if el else "" el = card.select_one(".rental-card__eyebrow span") project = el.get_text(" ", strip=True) if el else "" baths = area = None for fact in card.select(".rental-card__facts > div"): dt = fact.select_one("dt span") dd = fact.select_one("dd") if not dt or not dd: continue label = dt.get_text(strip=True).lower() if "salle" in label: baths = _num(dd.get_text()) elif "superficie" in label: area = _num(dd.get_text()) img = card.select_one(".rental-card__media img[src]") images = [img["src"].split("#")[0]] if img else [] details: dict = {} if project: details["project"] = project listings.append(Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}{href}" if href.startswith("/") else href, title=f"{unit_type or 'Appartement'} — {title}", address=address, city=city, unit_type=unit_type, bedrooms=beds, bathrooms=baths, price=price, availability=status, area_sqft=area, details=details, images=images, )) if new_on_page == 0: break if cards_total == 0: # une page catalogue saine contient toujours des cartes (tous # statuts confondus) — 0 carte = rendu partiel ou structure # changée : échec franc plutôt qu'un faux « 0 trouvé ok » raise RuntimeError( "c3r : aucune carte data-rental-unit parsée sur " f"{LIST_URL} — structure changée ?") return listings