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.8 KB · 141 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/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