SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.0 KB · 172 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/may_bourg.py : connecteur Gestion May Bourg (maybourg.com)5#   Parc de 162 unités à Bécancour (Centre-du-Québec). WordPress + Elementor +6#   Dynamic Content for Elementor : la page /repertoire-de-logements/ liste les7#   modèles disponibles en <article class="logement"> avec data-dce-post-id8#   stable (ID de post WP), titre, projet, adresse, prix (« 1350$ / mois »),9#   superficie (« 1048 P.C. ») et badge « Unités disponibles ». Les fiches10#   /logement/<slug>/ (via cache BD) ajoutent la description et la galerie.11#   robots.txt : /wp-json/ interdit → HTML seulement (respecté).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type, parse_price21from .base import BaseConnector2223BASE = "https://maybourg.com"24LIST_URL = f"{BASE}/repertoire-de-logements/"2526_PRICE_RE = re.compile(r"\d[\d\s,.]*\$")27_AREA_RE = re.compile(r"(\d[\d\s]*)\s*P\.?\s*C\.?", re.I)28_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp))", re.I)293031class MayBourgConnector(BaseConnector):32    source_id = "may_bourg"33    request_delay = 0.734    max_details = 20    # garde-fou fiches détail (parc ~7 modèles affichés)3536    def fetch(self) -> list[Listing]:37        html = self.get(LIST_URL).text38        soup = BeautifulSoup(html, "html.parser")3940        listings: dict[str, Listing] = {}41        # la grille DCE est dupliquée (variantes responsive) : dédup par post-id42        for art in soup.select("article.logement[data-dce-post-id]"):43            try:44                self._parse_card(art, listings)45            except Exception:46                continue4748        # fiches détail (cache BD) : description + galerie complète49        self._fetched = 050        for lst in listings.values():51            card_key = hashlib.sha1(52                f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}"53                .encode("utf-8")).hexdigest()54            try:55                payload = self.detail(lst.external_id, card_key,56                                      lambda u=lst.url: self._fetch_detail(u))57            except Exception:58                continue59            if payload.get("description"):60                lst.description = payload["description"]61            if payload.get("images"):62                lst.images = payload["images"]6364        return list(listings.values())6566    # -- carte de la grille DCE ------------------------------------------------67    def _parse_card(self, art, listings: dict[str, Listing]) -> None:68        ext_id = str(art.get("data-dce-post-id", "")).strip()69        link = art.select_one('a[href*="/logement/"]')70        if not ext_id or not link or ext_id in listings:71            return72        url = link["href"]7374        # widgets texte de la carte : titre, projet, adresse, prix, superficie,75        # badge « Unités disponibles » — identifiés par leur contenu (l'ordre76        # des conteneurs Elementor n'est pas garanti)77        texts: list[str] = []78        for el in art.select(".elementor-widget-text-editor "79                             ".elementor-widget-container"):80            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))81            if t and t not in texts:82                texts.append(t)8384        title = texts[0] if texts else ""85        if not title:86            return87        # exclusions : commercial / stationnement / rangement88        if re.search(r"commercial|stationnement|rangement|entrep[oô]t",89                     title, re.I):90            return9192        price_label = address = availability = area_txt = sector = ""93        for t in texts[1:]:94            if _PRICE_RE.search(t) and not price_label:95                price_label = t96            elif _AREA_RE.search(t) and not area_txt:97                area_txt = t98            elif re.search(r"disponible", t, re.I) and not availability:99                availability = t100            elif re.search(r"b[ée]cancour", t, re.I) and not address:101                address = t102            elif not sector:103                sector = t     # nom du projet (« Logements Port Royal »…)104105        # type d'unité : titre (« 4 1/2 – 2e étage »), repli sur la classe106        # taxonomique WP « type-de-logement-4-1-2 »107        unit_type = normalize_unit_type(title)108        if not unit_type:109            for c in art.get("class", []):110                m = re.match(r"type-de-logement-(\d)-1-2", c)111                if m:112                    unit_type = normalize_unit_type(f"{m.group(1)} 1/2")113                    break114115        area_sqft = None116        m = _AREA_RE.search(area_txt)117        if m:118            area_sqft = float(m.group(1).replace(" ", ""))119120        images: list[str] = []121        img = art.select_one("img[src]")122        if img:123            src = _SIZE_SUFFIX.sub("", img["src"].split("?")[0])124            if src.startswith("http"):125                images.append(src)126127        listings[ext_id] = Listing(128            source=self.source_id,129            external_id=ext_id,           # ID du post WordPress (stable)130            url=url,131            title=title,132            address=address,133            sector=sector,134            # tout le parc May Bourg (162 unités) est à Bécancour :135            # projets rue Roy, boul. de Port-Royal et Godefroy (cf. site)136            city="Bécancour",137            unit_type=unit_type,138            price=parse_price(price_label),139            price_label=price_label,140            availability=availability,141            area_sqft=area_sqft,142            images=images,143        )144145    # -- fiche détail /logement/<slug>/ -----------------------------------------146    def _fetch_detail(self, url: str) -> dict:147        """Description (paragraphe principal de la fiche) + galerie complète."""148        if self._fetched >= self.max_details:149            raise RuntimeError("budget de fiches détail atteint")150        self._fetched += 1151        html = self.get(url).text152        soup = BeautifulSoup(html, "html.parser")153        out: dict = {}154155        # description = plus long paragraphe des widgets texte (la fiche n'a156        # qu'un seul vrai bloc descriptif)157        paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))158                 for p in soup.select(".elementor-widget-text-editor p")]159        paras = [p for p in paras if len(p) > 120]160        if paras:161            out["description"] = max(paras, key=len)[:1200]162163        images: list[str] = []164        for img in soup.select("img[src*='/wp-content/uploads/']"):165            src = _SIZE_SUFFIX.sub("", (img.get("src") or "").split("?")[0])166            if src.startswith("http") and src not in images \167                    and not re.search(r"logo|icon", src, re.I):168                images.append(src)169        if images:170            out["images"] = images[:30]171        return out172