SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.9 KB · 190 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/courtemanche.py : connecteur Gestion Immobilière Courtemanche5#   (gestioncourtemanche.ca — Saguenay : Chicoutimi/Chicoutimi-Nord, plusieurs6#   centaines d'unités, leader du Saguenay). WordPress + Beaver Builder +7#   plugin maison « realestate », tout rendu serveur. Liste /location/tous/8#   (taxonomie real-estate-location) : cartes `.real-estate-box` avec type9#   (« 1 1/2 »), adresse, prix (« 700 $ / mois »), inclusions et date « Libre10#   le » ; quand aucun logement n'est libre, la page affiche « Aucun logement11#   disponible… » et le connecteur retourne une liste vide — situation normale12#   sur ce marché (taux d'inoccupation très bas au Saguenay). Fiche détail13#   /immobilier/<slug>/ (via cache BD) : description, caractéristiques14#   (taxonomie real-estate-characteristic), GPS (carte Google du plugin) et15#   secteur (lien « Retour » vers /location/<secteur>/).16#   robots.txt Yoast ouvert, sitemaps real-estate-location/-characteristic.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import re22from urllib.parse import unquote2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type, parse_price, strip_accents27from .base import BaseConnector2829BASE = "https://gestioncourtemanche.ca"30LIST_URL = f"{BASE}/location/tous/"3132_BG_URL_RE = re.compile(r"background-image\s*:\s*url\(([^)]+)\)")33_MAP_LATLNG_RE = re.compile(r"LatLng\((-?\d+\.\d+),\s*(-?\d+\.\d+)\)")3435# libellés des secteurs (taxonomie real-estate-location du site)36_SECTORS = {37    "chicoutimi-centres-commerciaux": "Chicoutimi — Centres commerciaux",38    "chicoutimi-uqac-cegep": "Chicoutimi — UQAC/CÉGEP",39    "chicoutimi-nord": "Chicoutimi-Nord",40}414243class CourtemancheConnector(BaseConnector):44    source_id = "courtemanche"45    request_delay = 0.646    max_details = 40     # garde-fou fiches détail (vraies requêtes par sync)4748    def fetch(self) -> list[Listing]:49        html = self.get(LIST_URL).text50        soup = BeautifulSoup(html, "html.parser")5152        self._fetched = 053        listings: dict[str, Listing] = {}54        # cartes présentes seulement quand des logements sont libres ; sinon la55        # page affiche « Aucun logement disponible dans ce secteur… » -> []56        for card in soup.select(".real-estate-list .real-estate-box"):57            try:58                self._parse_card(card, listings)59            except Exception:60                continue61        return list(listings.values())6263    # -- carte (plugin realestate) ---------------------------------------------------64    def _parse_card(self, card, listings: dict[str, Listing]) -> None:65        link = card.select_one('a.real-estate-gallery[href*="/immobilier/"]') \66            or card.select_one('a[href*="/immobilier/"]')67        if not link:68            return69        url = link["href"]70        m = re.search(r"/immobilier/([^/?#]+)", url)71        if not m:72            return73        ext_id = unquote(m.group(1)).strip("/")74        if not ext_id or ext_id in listings:75            return7677        infos = card.select_one(".real-estate-infos")78        if not infos:79            return80        type_el = infos.select_one(".type")81        unit_label = type_el.get_text(strip=True) if type_el else ""82        addr_el = infos.select_one(".address")83        address = re.sub(r"\s+", " ",84                         addr_el.get_text(" ", strip=True)) if addr_el else ""8586        # <dl> : Prix (« 700 $ / mois »), Inclus, Disponible le (AAAA-MM-JJ)87        price_el = infos.select_one("dd.prix")88        price_label = re.sub(r"\s+", " ",89                             price_el.get_text(" ", strip=True)) if price_el else ""90        incl_el = infos.select_one("dd.inclus")91        included = incl_el.get_text(" ", strip=True) if incl_el else ""92        libre_el = infos.select_one("dd.libre")93        availability = libre_el.get_text(strip=True) if libre_el else ""9495        # galerie : diaporama en background-image (liste et fiche)96        images: list[str] = []97        for div in card.select(".real-estate-gallery div[style]"):98            m_bg = _BG_URL_RE.search(div.get("style", ""))99            if m_bg:100                u = m_bg.group(1).strip("'\" ")101                if u.startswith("http") and u not in images:102                    images.append(u)103104        amenities = [f"Inclus : {included}"] if included else []105106        lst = Listing(107            source=self.source_id,108            external_id=ext_id,109            url=url,110            title=f"{unit_label}{address}".strip(" —"),111            address=address,112            city="Saguenay",                   # parc à Chicoutimi (ville de Saguenay)113            unit_type=normalize_unit_type(unit_label),114            price=parse_price(price_label),115            price_label=price_label,116            availability=availability,117            amenities=amenities,118            images=images[:25],119        )120121        key = hashlib.sha1(122            f"{unit_label}|{price_label}|{availability}|{included}"123            .encode("utf-8")).hexdigest()124        try:125            payload = self.detail(ext_id, key,126                                  lambda u=url: self._fetch_detail(u))127            self._apply_detail(lst, payload)128        except Exception:129            pass130        listings[ext_id] = lst131132    # -- fiche détail (/immobilier/<slug>/) --------------------------------------------133    def _fetch_detail(self, url: str) -> dict:134        """Description, caractéristiques, GPS et secteur (lien « Retour »)."""135        if self._fetched >= self.max_details:136            raise RuntimeError("budget de fiches détail atteint")137        self._fetched += 1138        html = self.get(url).text139        soup = BeautifulSoup(html, "html.parser")140        out: dict = {}141142        desc_el = soup.select_one(".real-estate-infos .description")143        if desc_el:144            txt = desc_el.get_text("\n", strip=True)145            out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]146147        out["characteristics"] = [148            re.sub(r"\s+", " ", li.get_text(" ", strip=True))149            for li in soup.select("ul.characteristics li")150            if li.get_text(strip=True)][:20]151152        # secteur : lien « Retour » vers la taxonomie /location/<slug>/153        back = soup.select_one('.real-estate-return a[href*="/location/"]')154        if back:155            m = re.search(r"/location/([^/?#]+)", back["href"])156            if m and m.group(1) != "tous":157                slug = m.group(1)158                out["sector"] = _SECTORS.get(159                    slug, slug.replace("-", " ").strip().title())160161        m = _MAP_LATLNG_RE.search(html)162        if m:163            out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))164165        # galerie pleine taille de la fiche (si plus complète que la liste)166        images: list[str] = []167        for div in soup.select(".real-estate-gallery div[style]"):168            m_bg = _BG_URL_RE.search(div.get("style", ""))169            if m_bg:170                u = m_bg.group(1).strip("'\" ")171                if u.startswith("http") and u not in images:172                    images.append(u)173        out["images"] = images[:25]174        return out175176    def _apply_detail(self, lst: Listing, d: dict) -> None:177        """Reporte le payload (frais ou en cache) sur l'annonce."""178        if not d:179            return180        if d.get("description"):181            lst.description = d["description"]182        if d.get("characteristics"):183            lst.amenities = list(dict.fromkeys(lst.amenities + d["characteristics"]))184        if d.get("sector"):185            lst.sector = d["sector"]186        if d.get("lat") is not None and d.get("lng") is not None:187            lst.lat, lst.lng = d["lat"], d["lng"]188        if d.get("images") and len(d["images"]) > len(lst.images):189            lst.images = d["images"]190