spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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