SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
9.3 KB · 220 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/lachance.py : connecteur Les Entreprises Lachance (lachance.qc.ca)5#   Constructeur-gestionnaire de l'Estrie : Sherbrooke (Fleurimont, Brompton,6#   Rock-Forest/St-Élie, Université), Magog, Orford, East Angus, Waterville.7#   Craft CMS (SEOmatic) : découverte par le sitemap sectionnel dédié8#   `forRentBuildingEntry` (une page par immeuble), chaque page immeuble liste9#   TOUTES ses unités en cartes `a.card-for-rent` : prix (avec promo barrée),10#   date de disponibilité (`.available-notice` — « Loué » quand non offert),11#   adresse, étage, superficie/chambres/sdb (icônes), type (h3.size-title).12#   Fiches unité visitées via self.detail() (cache BD, plafond max_details) :13#   description riche + galerie complète (img.img-swiper).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://lachance.qc.ca"26SITEMAP_URL = f"{BASE}/sitemaps-1-section-forRentBuildingEntry-1-sitemap.xml"2728_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")29_AREA_RE = re.compile(r"^([\d\s,]+)\s*pi2?\b", re.I)3031# « Sherbrooke (Fleurimont) », « East-Angus », « Orford », « Waterville »…32_CITY_SECTOR_RE = re.compile(r"^\s*(.+?)\s*(?:\((.+)\))?\s*$")333435def _split_city_sector(raw: str) -> tuple[str, str]:36    """« Sherbrooke (Fleurimont) » -> (Sherbrooke, Fleurimont) ; « East-Angus »37    -> (East Angus, ""). Jamais de ville inventée : on retourne le texte source."""38    # « #105 Sherbrooke (Fleurimont) » / « Orford #1300 » : retirer le numéro39    # d'unité résiduel accolé à la ville40    raw = re.sub(r"^\s*#?\d+[A-Za-z]?\s+(?=[A-ZÉÈÀ])", "", raw or "")41    raw = re.sub(r"\s*#\s*\d+[A-Za-z]?\s*$", "", raw)42    m = _CITY_SECTOR_RE.match(raw)43    if not m:44        return "", ""45    city = m.group(1).replace("East-Angus", "East Angus").strip(" ,")46    return city, (m.group(2) or "").strip()474849class _DetailBudget(Exception):50    """Budget de fiches unité atteint pour cette synchronisation."""515253class LachanceConnector(BaseConnector):54    source_id = "lachance"55    request_delay = 0.656    max_buildings = 100      # garde-fou : pages immeuble du sitemap57    max_details = 40         # garde-fou : vraies requêtes de fiches unité5859    def fetch(self) -> list[Listing]:60        # 1) Découverte : sitemap sectionnel forRentBuildingEntry (une URL par61        #    immeuble ; on ignore les fiches PDF également listées)62        xml = self.get(SITEMAP_URL).text63        building_urls = []64        for loc in _LOC_RE.findall(xml):65            if loc.lower().endswith(".pdf") or "/documents/" in loc:66                continue67            if loc.startswith(f"{BASE}/logements-a-louer/") and loc not in building_urls:68                building_urls.append(loc)6970        # 2) Pages immeuble : cartes unité (on saute les logements « Loué »)71        listings: dict[str, Listing] = {}72        for burl in building_urls[: self.max_buildings]:73            try:74                html = self.get(burl).text75            except Exception:76                continue77            soup = BeautifulSoup(html, "html.parser")78            for card in soup.select("a.card-for-rent"):79                try:80                    lst = self._parse_card(card)81                except Exception:82                    continue83                if lst and lst.external_id not in listings:84                    listings[lst.external_id] = lst8586        # 3) Fiches unité (cache BD) : description riche + galerie complète87        self._detail_fetches = 088        for lst in listings.values():89            key = hashlib.sha1(90                f"{lst.price_label}|{lst.availability}|{lst.unit_type}"91                .encode("utf-8")).hexdigest()9293            def fetch_fn(u=lst.url):94                if self._detail_fetches >= self.max_details:95                    raise _DetailBudget()96                self._detail_fetches += 197                return self._fetch_detail(u)9899            try:100                payload = self.detail(lst.external_id, key, fetch_fn)101            except _DetailBudget:102                continue103            except Exception:104                continue105            if payload.get("description"):106                lst.description = payload["description"]107            if payload.get("images"):108                lst.images = payload["images"]109110        return list(listings.values())111112    # -- carte unité (page immeuble) -------------------------------------------113    def _parse_card(self, card) -> Listing | None:114        href = (card.get("href") or "").split("?")[0].rstrip("/")115        m = re.search(r"/logements-a-louer/([\w\-%.]+)$", href)116        if not m:117            return None118        slug = m.group(1)119120        # disponibilité : « Dès maintenant », « 1 septembre 2026 »… ; « Loué »121        # (ou bandeau absent) = unité non offerte -> on saute122        notice = card.select_one(".available-notice")123        availability = notice.get_text(" ", strip=True) if notice else ""124        if not availability or re.search(r"lou[ée]", availability, re.I):125            return None126127        # prix : promo = prix courant en rouge, prix régulier barré128        # (text-decoration-line-through) ; price_label garde le texte affiché129        price = None130        price_label = ""131        h4 = card.select_one(".min-height-price h4")132        if h4:133            price_label = " ".join(h4.get_text(" ", strip=True).split())134            spans = h4.find_all("span")135            current = [s for s in spans136                       if "text-decoration-line-through" not in (s.get("class") or [])]137            effective = (current[-1].get_text(" ", strip=True)138                         if current else price_label)139            price = parse_price(effective) or parse_price(price_label)140141        # adresse : h3 de la carte, ex. « 1331, rue Quatre-Saisons,142        # Sherbrooke (Fleurimont) » — dernière portion = ville (secteur)143        addr_el = card.select_one("h3.font-size-14")144        address = " ".join(addr_el.get_text(" ", strip=True).split()) if addr_el else ""145        parts = [p.strip() for p in address.split(",") if p.strip()]146        city, sector = _split_city_sector(parts[-1]) if parts else ("", "")147148        # étage (libellé structuré de la carte)149        amenities: list[str] = []150        for p in card.select("p.font-size-14"):151            t = " ".join(p.get_text(" ", strip=True).split())152            if t.lower().startswith("étage"):153                amenities.append(t.replace("Étage: ", "Étage : "))154                break155156        # pied de carte : superficie (icône building), chambres (bed), sdb (bath)157        area = None158        for rd in card.select(".rent-detail"):159            svg = rd.select_one("svg")160            cls = " ".join(svg.get("class") or []) if svg else ""161            txt = " ".join(rd.get_text(" ", strip=True).split())162            if "building" in cls:163                m2 = _AREA_RE.match(txt)164                if m2:165                    try:166                        val = float(m2.group(1).replace(" ", "").replace(",", ""))167                        if 80 <= val <= 20000:168                            area = val169                    except ValueError:170                        pass171            elif "bed" in cls and txt:172                amenities.append(f"{txt} chambre(s)")173            elif "bath" in cls and txt:174                amenities.append(f"{txt} salle(s) de bain")175176        # type d'unité : bandeau h3.size-title de la carte (« 4 ½ », « Studio »)177        type_el = card.select_one("h3.size-title")178        unit_type = normalize_unit_type(179            type_el.get_text(" ", strip=True) if type_el else "")180181        img = card.select_one("img.img-card-for-rent")182        images = [img["src"]] if img and img.get("src", "").startswith("http") else []183184        return Listing(185            source=self.source_id,186            external_id=slug,187            url=f"{BASE}/logements-a-louer/{slug}",188            title=address or slug.replace("-", " "),189            address=address,190            sector=sector,191            city=city,192            unit_type=unit_type,193            price=price,194            price_label=price_label,195            availability=availability,196            area_sqft=area,197            amenities=amenities,198            images=images,199        )200201    # -- fiche unité -------------------------------------------------------------202    def _fetch_detail(self, url: str) -> dict:203        """Description riche (bloc .text-editor) + galerie (img.img-swiper)."""204        html = self.get(url).text205        soup = BeautifulSoup(html, "html.parser")206        payload: dict = {}207208        editor = soup.select_one(".col-info-desctiption .text-editor, .text-editor")209        if editor:210            txt = editor.get_text("\n", strip=True)211            payload["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]212213        images: list[str] = []214        for img in soup.select("img.img-swiper[src]"):215            src = img["src"]216            if src.startswith("http") and "/a-louer/" in src and src not in images:217                images.append(src)218        payload["images"] = images[:30]219        return payload220