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%
8.8 KB · 217 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/prona.py : connecteur Gestion Prona (gestionprona.ca)5#   Gestionnaire multi-régions : Cowansville, Saint-Jean-sur-Richelieu,6#   Saint-Hubert, Candiac, Saint-Hyacinthe (Montérégie) + Montréal, Québec,7#   Blainville. WordPress + thème immobilier Houzez rendu serveur (même8#   plateforme qu'immeubles_bc) : la page /properties/ expose des cartes9#   .item-listing-wrap (data-listid STABLE, titre, adresse taxonomique,10#   prix, chambres/sdb). La fiche /property/<slug>/ (via cache BD) fournit11#   la VILLE structurée (champ « Ville » du bloc adresse — la taxonomie de12#   la carte liste mêle quartier/arrondissement/MRC), la description, les13#   caractéristiques, le GPS et la galerie. Les locaux commerciaux/bureaux14#   sont exclus.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://gestionprona.ca"27LIST_URL = f"{BASE}/properties/"2829_COMMERCIAL_RE = re.compile(30    r"commerc|bureau|\blocal\b|stationnement|garage|entrep[oô]t", re.I)31_MAP_LATLNG_RE = re.compile(32    r'"lat"\s*:\s*"?(-?\d+\.\d+)"?\s*,\s*"lng"\s*:\s*"?(-?\d+\.\d+)"?')33# suffixe de redimensionnement WordPress (« -592x444.jpg » -> pleine taille)34_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)35_IMG_RE = re.compile(36    r"https://gestionprona\.ca/wp-content/uploads/[^\"'\s\\)]+"37    r"\.(?:jpe?g|png|webp)", re.I)38_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", re.I)394041def _clean_price_label(label: str) -> str:42    """'$1,450/mois' -> '$1450/mois' (virgule = milliers, pour parse_price)."""43    return re.sub(r"(\d),(\d{3})", r"\1\2", label)444546class PronaConnector(BaseConnector):47    source_id = "prona"48    request_delay = 0.649    max_details = 40     # garde-fou fiches détail (vraies requêtes par sync)5051    def fetch(self) -> list[Listing]:52        html = self.get(LIST_URL).text53        soup = BeautifulSoup(html, "html.parser")5455        listings: dict[str, Listing] = {}56        for card in soup.select("div.item-listing-wrap"):57            try:58                self._parse_card(card, listings)59            except Exception:60                continue6162        # fiches détail (cache BD) : ville structurée, description, GPS…63        self._fetched = 064        for lst in listings.values():65            key = hashlib.sha1(66                f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}"67                .encode("utf-8")).hexdigest()[:16]68            try:69                payload = self.detail(lst.external_id, key,70                                      lambda u=lst.url: self._fetch_detail(u))71            except Exception:72                continue73            self._apply_detail(lst, payload)74        return list(listings.values())7576    # -- carte Houzez -----------------------------------------------------------77    def _parse_card(self, card, listings: dict[str, Listing]) -> None:78        link = card.select_one("h2.item-title a[href]")79        if not link:80            return81        url = link["href"]82        title = link.get_text(strip=True)83        m = re.search(r"/property/([^/]+)/?", url)84        slug = m.group(1) if m else ""85        listid_el = card.select_one("[data-listid]")86        ext_id = (listid_el.get("data-listid") if listid_el else "") or slug87        if not ext_id or str(ext_id) in listings:88            return89        if _COMMERCIAL_RE.search(title):90            return                              # local commercial : exclu9192        status_el = card.select_one(".label-status")93        availability = status_el.get_text(strip=True) if status_el else ""94        if re.search(r"lou[ée]", availability, re.I):95            return9697        addr_el = card.select_one("address.item-address")98        address = addr_el.get_text(" ", strip=True) if addr_el else ""99100        price_el = card.select_one("li.item-price")101        price_label = price_el.get_text(strip=True) if price_el else ""102103        amenities: list[str] = []104        beds = ""105        beds_el = card.select_one("li.h-beds .hz-figure")106        if beds_el:107            beds = beds_el.get_text(strip=True)108            if beds:109                amenities.append(f"{beds} chambre(s)")110        baths_el = card.select_one("li.h-baths .hz-figure")111        if baths_el and baths_el.get_text(strip=True):112            amenities.append(f"{baths_el.get_text(strip=True)} salle(s) de bain")113114        unit_type = ""115        if beds.isdigit():116            unit_type = "Studio" if beds == "0" else \117                normalize_unit_type(f"{beds} chambres")118        # le titre porte souvent le vrai type (« Grand 4.5 … »)119        m = re.search(r"\b(\d[½.]5?)\b", title)120        if m:121            t = normalize_unit_type(m.group(1))122            if re.match(r"^\d½$", t):123                unit_type = t124125        images: list[str] = []126        thumb = card.select_one("img.wp-post-image[src]")127        if thumb:128            images = [_SIZE_SUFFIX.sub("", thumb["src"])]129130        listings[str(ext_id)] = Listing(131            source=self.source_id,132            external_id=str(ext_id),133            url=url,134            title=title,135            address=address,136            unit_type=unit_type,137            price=parse_price(_clean_price_label(price_label)),138            price_label=price_label,139            availability=availability,140            amenities=amenities,141            images=images,142        )143144    # -- fiche détail (Houzez) ----------------------------------------------------145    def _fetch_detail(self, url: str) -> dict:146        """Ville structurée, adresse, description, caractéristiques, GPS."""147        if self._fetched >= self.max_details:148            raise RuntimeError("budget de fiches détail atteint")149        self._fetched += 1150        html = self.get(url).text151        soup = BeautifulSoup(html, "html.parser")152        out: dict = {}153154        # bloc adresse : lignes « <label> <valeur> » (Ville, Adresse…)155        for li in soup.select("#property-address-wrap li"):156            txt = li.get_text(" ", strip=True)157            m = re.match(r"^(Adresse|Ville)\s+(.+)$", txt)158            if m:159                out["ville" if m.group(1) == "Ville" else "adresse"] = \160                    m.group(2).strip()161162        desc_el = soup.select_one("#property-description-wrap")163        if desc_el:164            txt = desc_el.get_text("\n", strip=True)165            txt = re.sub(r"^(La )?[Dd]escription\n", "", txt)166            out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1200]167168        feats = [a.get_text(" ", strip=True)169                 for a in soup.select("#property-features-wrap li")170                 if a.get_text(strip=True)]171        # bloc « Détails » structuré (superficie, pièces…)172        for li in soup.select("#property-detail-wrap li"):173            txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True))174            m = re.match(r"^Taille de la propriété:\s*([\d,\s]+)\s*sqft", txt)175            if m:176                try:177                    out["area"] = float(m.group(1).replace(",", "")178                                        .replace(" ", ""))179                except ValueError:180                    pass181        out["amenities"] = feats[:20]182183        m = _MAP_LATLNG_RE.search(html)184        if m:185            out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))186187        imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))188                if not _SKIP_IMG.search(u)]189        out["images"] = imgs[:20]190        return out191192    def _apply_detail(self, lst: Listing, d: dict) -> None:193        if not d:194            return195        if d.get("ville"):196            lst.city = d["ville"]197        if d.get("adresse"):198            lst.address = d["adresse"]199        if not lst.city and d.get("adresse"):200            # repli : sans champ « Ville », la 3e composante de l'adresse201            # complète Houzez est la municipalité (n°, rue, ville, MRC…)202            parts = [p.strip() for p in d["adresse"].split(",") if p.strip()]203            if len(parts) >= 3 and not re.search(r"\d", parts[2]):204                lst.city = parts[2]205        desc = d.get("description") or ""206        if desc and desc.strip() != lst.address.strip():207            lst.description = desc208        if d.get("amenities"):209            lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"]))210        if d.get("area") and lst.area_sqft is None:211            if 80 <= d["area"] <= 20000:212                lst.area_sqft = d["area"]213        if d.get("lat") is not None and d.get("lng") is not None:214            lst.lat, lst.lng = d["lat"], d["lng"]215        if d.get("images"):216            lst.images = d["images"]217