SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.3 KB · 171 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/promenade_des_forts.py : connecteur Promenade des Forts5#   (promenadedesforts.com, Groupe immobilier Brochu) — condos locatifs en6#   2 phases au 6275 et 6375, boulevard Étienne-Dallaire (quartier Umano,7#   Lévis). Site WordPress rendu serveur : chaque page d'étage8#   (/projets/phase-N/etage-M/) contient un plan SVG dont les liens d'unités9#   portent la classe « disponible » ou « loue ». La fiche de chaque unité10#   disponible donne modèle, grandeur (4½…), superficie totale, prix avec date11#   (« disponible 1er septembre 2026 à 1485 $ »), spécifications, plan et PDF.12#   Peu d'unités libres à la fois : les fiches sont visitées directement à13#   chaque sync (pas de cache détail), ~15-20 requêtes au total.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, infer_city, normalize_unit_type, parse_area_sqft22from .base import BaseConnector2324BASE = "https://www.promenadedesforts.com"25PHASES = (1, 2)26SECTOR = "Quartier Umano (Lévis)"2728# lien d'unité disponible sur le plan SVG d'une page d'étage29_UNIT_LINK_RE = re.compile(30    r'<a\s[^>]*href="(https?://[^"]*/projets/phase-\d+/etage-\d+/unite-\d+/?)"'31    r'[^>]*class="disponible"', re.I)32# pages d'étages listées dans la navigation d'une page de phase33_FLOOR_LINK_RE = re.compile(34    r'href="(https?://[^"]*/projets/phase-\d+/etage-\d+/?)"')35# adresse civique du pied de page (« 6275 et 6375, Boulevard Étienne-Dallaire,36# Levis, QC »)37_ADDR_RE = re.compile(r"(\d{4}\s+et\s+\d{4},?\s+[Bb]oulevard[^,<]+,\s*L[eé]vis)")383940class PromenadeDesFortsConnector(BaseConnector):41    source_id = "promenade_des_forts"42    request_delay = 0.743    max_floor_pages = 20      # garde-fou (2 phases x 6 étages aujourd'hui)44    max_unit_pages = 40       # garde-fou sur les fiches d'unités4546    # -- fiche d'une unité disponible -------------------------------------------47    def _parse_unit(self, url: str, html: str) -> Listing | None:48        soup = BeautifulSoup(html, "html.parser")49        m = re.search(r"phase-(\d+)/etage-(\d+)/unite-(\d+)", url)50        if not m:51            return None52        phase, floor, num = m.group(1), m.group(2), m.group(3)5354        # champs étiquetés de la fiche : « Modèle : », « Grandeur : »,55        # « Superficie totale : », « Prix : » (p.margin-bottom-0 puis p.h3)56        fields: dict[str, str] = {}57        for p in soup.select(".infosUnite p.margin-bottom-0"):58            label = p.get_text(" ", strip=True).rstrip(" :").lower()59            val = p.find_next_sibling("p")60            if label and val is not None:61                fields[label] = re.sub(r"\s+", " ", val.get_text(" ", strip=True))6263        # spécifications : liste à puces qui suit l'étiquette « Spécifications »64        amenities: list[str] = []65        for p in soup.select(".infosUnite p.margin-bottom-0"):66            if "spécifications" in p.get_text(strip=True).lower():67                ul = p.find_next("ul")68                if ul:69                    amenities = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))70                                 for li in ul.find_all("li")]71                break7273        # statut affiché sur la fiche (« Disponible » avec crochet vert)74        statut = soup.select_one(".infosUnite span.disponible")75        if statut is None:76            return None            # l'étage l'annonçait libre, la fiche non7778        # prix : « disponible 1er septembre 2026 à 1485 $ » ou « 1945 $ » —79        # le libellé complet sert aussi de disponibilité (date incluse)80        price_label = fields.get("prix", "")81        availability = (price_label if "disponible" in price_label.lower()82                        else statut.get_text(" ", strip=True))8384        desc: list[str] = []85        if fields.get("modèle"):86            desc.append(f"Modèle : {fields['modèle']}")87        pdf = soup.select_one('a.button[href$=".pdf"]')88        if pdf:89            desc.append(f"Fiche PDF : {pdf['href']}")9091        # plan de l'unité (image de la colonne de droite)92        images = []93        img = soup.select_one(".col-md-8 img[src]")94        if img and str(img["src"]).startswith("http"):95            images.append(str(img["src"]))9697        # adresse et contact du pied de page (présents sur chaque page)98        addr_m = _ADDR_RE.search(html)99        address = addr_m.group(1) if addr_m else ""100        contact: dict = {}101        tel = re.search(r'href="tel:([\d\s\-.]{10,14})"', html)102        if tel:103            d = re.sub(r"\D", "", tel.group(1))[-10:]104            if len(d) == 10:105                contact["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}"106        mail = re.search(r'href="mailto:([^"?]+)"', html)107        if mail:108            contact["email"] = mail.group(1).strip()109110        details: dict = {"floor": int(floor)}111        if contact:112            details["contact"] = contact113114        unit_type = normalize_unit_type(fields.get("grandeur", ""))115        return Listing(116            source=self.source_id,117            external_id=f"phase-{phase}-unite-{num}",118            url=url,119            title=f"Promenade des Forts phase {phase} — unité {num}"120                  f" ({unit_type})" if unit_type else121                  f"Promenade des Forts phase {phase} — unité {num}",122            address=address,123            sector=SECTOR,124            city=infer_city(SECTOR, default="Lévis"),125            unit_type=unit_type,126            price=None,                      # dérivé du libellé par finalize()127            price_label=price_label,128            availability=availability,129            area_sqft=parse_area_sqft(fields.get("superficie totale", "")),130            description=" | ".join(desc),131            amenities=amenities,132            details=details,133            images=images,134        )135136    def fetch(self) -> list[Listing]:137        # 1) Pages d'étages découvertes depuis les pages de phases138        floor_urls: list[str] = []139        for phase in PHASES:140            try:141                html = self.get(f"{BASE}/projets/phase-{phase}/").text142            except Exception:143                continue144            for u in _FLOOR_LINK_RE.findall(html):145                u = u.rstrip("/") + "/"146                if u not in floor_urls:147                    floor_urls.append(u)148149        # 2) Unités marquées « disponible » sur le plan SVG de chaque étage150        unit_urls: list[str] = []151        for floor_url in floor_urls[:self.max_floor_pages]:152            try:153                html = self.get(floor_url).text154            except Exception:155                continue156            for u in _UNIT_LINK_RE.findall(html):157                u = u.rstrip("/") + "/"158                if u not in unit_urls:159                    unit_urls.append(u)160161        # 3) Fiche de chaque unité disponible (prix, date, superficie, specs)162        listings: list[Listing] = []163        for url in unit_urls[:self.max_unit_pages]:164            try:165                lst = self._parse_unit(url, self.get(url).text)166            except Exception:167                continue168            if lst is not None:169                listings.append(lst)170        return listings171