SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.7 KB · 147 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/quartier_plus.py : connecteur Le Quartier Plus (lequartierplus.com5#   — Thetford Mines, Chaudière-Appalaches). Complexe locatif 55+ autonome6#   (aucun service de repas/soins : appartements standards, lofts urbains et7#   unités bigénérationnelles) construit en 3 phases ; la page8#   /plans-et-visites-virtuelles/ embarque un sélecteur d'étages WordPress9#   (thème ubeo-theme-v5-child) dont TOUTES les unités sont rendues serveur :10#   blocs `div.unit#unit_<n>` avec numéro, statut (« disponible », « Construction11#   2026 »…), type (3½/4½/4½+/5½), lettre de modèle, superficie du logement et12#   du balcon, plan PNG/PDF. Seules les unités dont le statut contient13#   « disponible » sont retenues. Aucun prix par unité (seul un « à partir de14#   1195 $ » global est cité en prose) -> price=None, rien d'inventé.15#   L'adresse « 140, rue Kingsville » (phase 3) publiée sur la page n'est16#   attachée qu'aux unités dont le statut mentionne « Construction 2026 ».17#   external_id stable : numéro d'unité (unit_501 -> 501).18#   robots.txt Yoast ouvert (Disallow vide), sitemap public.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import re2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type27from .base import BaseConnector2829BASE = "https://www.lequartierplus.com"30LIST_URL = f"{BASE}/plans-et-visites-virtuelles/"3132# adresse de la phase 3, telle que publiée sur la page (bloc « Phase 3 »)33_PHASE3_ADDR = "140, rue Kingsville"3435_AREA_RE = re.compile(r"([\d\s,]+)\s*p")363738def _sqft(text: str) -> float | None:39    """« 1 097 p2 » -> 1097.0 (None si illisible)."""40    m = _AREA_RE.search(text)41    if not m:42        return None43    try:44        return float(m.group(1).replace(" ", "").replace(" ", "")45                     .replace(",", "."))46    except ValueError:47        return None484950class QuartierPlusConnector(BaseConnector):51    source_id = "quartier_plus"52    request_delay = 0.65354    def fetch(self) -> list[Listing]:55        html = self.get(LIST_URL).text56        soup = BeautifulSoup(html, "html.parser")5758        listings: dict[str, Listing] = {}59        for unit in soup.select("div.unit[id^=unit_]"):60            try:61                self._parse_unit(unit, listings)62            except Exception:63                continue64        return list(listings.values())6566    # -- bloc unité (div.unit#unit_<n>) ------------------------------------------67    def _parse_unit(self, unit, listings: dict[str, Listing]) -> None:68        ext_id = unit.get("id", "").removeprefix("unit_").strip()69        if not ext_id or ext_id in listings:70            return7172        header = unit.select_one("p.details_header")73        if header is None:74            return75        name_el = header.select_one("span.has-wpdc-x-large-font-size")76        name = name_el.get_text(" ", strip=True) if name_el else f"Unité {ext_id}"77        status_el = header.select_one("span.has-wpdc-medium-font-size")78        availability = re.sub(79            r"\s+", " ",80            status_el.get_text(" ", strip=True)) if status_el else ""8182        # seules les unités affichées « disponible » sont des annonces83        if "disponible" not in availability.lower():84            return85        if "indisponible" in availability.lower() or re.search(86                r"(?i)lou[ée]|réserv", availability):87            return8889        # colonnes du bloc unit_details : type (badge), modèle, superficies90        unit_label, model, area, balcony = "", "", None, None91        for col in unit.select(".unit_details > div"):92            txt = re.sub(r"\s+", " ", col.get_text(" ", strip=True))93            label = ""94            small = col.select_one(".small_text")95            if small:96                label = small.get_text(" ", strip=True).upper()97            if "SUPERFICIE" in label:98                area = _sqft(txt)99            elif "BALCON" in label:100                balcony = _sqft(txt)101            elif "TYPE" in label:102                strong = col.select_one("strong")103                model = strong.get_text(strip=True) if strong else ""104            elif re.match(r"^[1-6]½", txt):105                unit_label = txt106107        amenities: list[str] = []108        if model:109            amenities.append(f"Modèle {model}")110        if balcony:111            amenities.append(f"Balcon {balcony:g} pi²")112113        # plan du logement (PNG du sélecteur)114        images: list[str] = []115        plan = unit.select_one("p.unit_plan img[src]")116        if plan:117            u = plan["src"]118            if u.startswith("/"):119                u = BASE + u120            if u.startswith("http"):121                images.append(u)122123        # l'adresse publiée de la phase 3 ne vaut que pour ses unités124        address = ""125        if "construction 2026" in availability.lower():126            address = f"{_PHASE3_ADDR}, Thetford Mines"127128        title = f"Le Quartier Plus — {name}"129        if unit_label:130            title += f" ({unit_label})"131132        listings[ext_id] = Listing(133            source=self.source_id,134            external_id=ext_id,135            url=LIST_URL,136            title=title,137            address=address,138            city="Thetford Mines",139            unit_type=normalize_unit_type(unit_label.replace("+", "")),140            price=None,                    # aucun loyer par unité publié141            price_label="",142            availability=availability,143            area_sqft=area,144            amenities=amenities,145            images=images,146        )147