Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/c3r.py : connecteur Gestion C3R (gestionc3r.com)5# Gestionnaire de la Mauricie (Trois-Rivières, Shawinigan, Saint-Boniface,6# Saint-Maurice). Refonte du site le 2026-09-08 : l'ancien site Wix (et son7# API wix-data, collections Import1/Import2) a été remplacé par un site8# statique Astro servi par Express — les collections n'existent plus9# (WDE0025) et les anciennes URLs redirigent vers /logements.10# Nouveau modèle : catalogue /logements?page=N paginé côté serveur11# (~18 cartes/page, 73 unités, 4 projets), chaque carte <article12# data-rental-unit> porte des attributs data-* propres (city, project,13# bedrooms, price, status) + type, adresse, salle de bain, superficie et14# photo dans le corps de la carte. Seul le statut « Disponible » est gardé15# (Loué / Réservé : non). Granularité : une annonce par unité.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type24from .base import BaseConnector2526BASE = "https://www.gestionc3r.com"27LIST_URL = f"{BASE}/logements"2829# seul « Disponible » = louable maintenant (Réservé / Loué : non)30KEEP_STATUS = {"disponible"}3132MAX_PAGES = 20 # 5 pages au lancement (2026-09-08) — marge de croissance333435def _num(raw: str | None) -> float | None:36 """Premier nombre d'un texte (« 1 575 $/mois », « 1100 pi² ») → float."""37 if raw is None:38 return None39 digits = re.sub(r"[^\d,.]", "", str(raw)).replace(",", ".")40 m = re.match(r"\d+(?:\.\d+)?", digits)41 return float(m.group(0)) if m else None424344class C3RConnector(BaseConnector):45 source_id = "c3r"46 request_delay = 0.74748 def fetch(self) -> list[Listing]:49 listings: list[Listing] = []50 seen: set[str] = set()51 cards_total = 05253 for page in range(1, MAX_PAGES + 1):54 params = {"page": page} if page > 1 else None55 # le serveur n'annonce pas de charset → requests décoderait en56 # latin-1 (« Loué ») ; la page déclare <meta charset="utf-8">57 html = self.get(LIST_URL, params=params).content.decode(58 "utf-8", errors="replace")59 soup = BeautifulSoup(html, "html.parser")60 cards = soup.select("article[data-rental-unit]")61 if not cards:62 break6364 new_on_page = 065 for card in cards:66 link = card.select_one("a[href]")67 href = (link.get("href") or "").strip() if link else ""68 slug = href.rstrip("/").split("/")[-1] if href else ""69 ext_id = slug or ""70 if not ext_id or ext_id in seen:71 continue # au-delà de la dernière page, le site re-sert72 seen.add(ext_id) # la même page → dédup par slug73 new_on_page += 174 cards_total += 17576 status = (card.get("data-status") or "").strip()77 if status.lower() not in KEEP_STATUS:78 continue7980 city = (card.get("data-city") or "").strip()81 price = _num(card.get("data-price"))82 beds = _num(card.get("data-bedrooms"))8384 el = card.select_one(".rental-card__type")85 unit_type = normalize_unit_type(86 el.get_text(strip=True) if el else "")87 el = card.select_one("h3")88 title = el.get_text(" ", strip=True) if el else ext_id89 el = card.select_one(".rental-card__location")90 address = el.get_text(" ", strip=True) if el else ""91 el = card.select_one(".rental-card__eyebrow span")92 project = el.get_text(" ", strip=True) if el else ""9394 baths = area = None95 for fact in card.select(".rental-card__facts > div"):96 dt = fact.select_one("dt span")97 dd = fact.select_one("dd")98 if not dt or not dd:99 continue100 label = dt.get_text(strip=True).lower()101 if "salle" in label:102 baths = _num(dd.get_text())103 elif "superficie" in label:104 area = _num(dd.get_text())105106 img = card.select_one(".rental-card__media img[src]")107 images = [img["src"].split("#")[0]] if img else []108109 details: dict = {}110 if project:111 details["project"] = project112113 listings.append(Listing(114 source=self.source_id,115 external_id=ext_id,116 url=f"{BASE}{href}" if href.startswith("/") else href,117 title=f"{unit_type or 'Appartement'} — {title}",118 address=address,119 city=city,120 unit_type=unit_type,121 bedrooms=beds,122 bathrooms=baths,123 price=price,124 availability=status,125 area_sqft=area,126 details=details,127 images=images,128 ))129130 if new_on_page == 0:131 break132133 if cards_total == 0:134 # une page catalogue saine contient toujours des cartes (tous135 # statuts confondus) — 0 carte = rendu partiel ou structure136 # changée : échec franc plutôt qu'un faux « 0 trouvé ok »137 raise RuntimeError(138 "c3r : aucune carte data-rental-unit parsée sur "139 f"{LIST_URL} — structure changée ?")140 return listings141