# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/quartier_plus.py : connecteur Le Quartier Plus (lequartierplus.com # — Thetford Mines, Chaudière-Appalaches). Complexe locatif 55+ autonome # (aucun service de repas/soins : appartements standards, lofts urbains et # unités bigénérationnelles) construit en 3 phases ; la page # /plans-et-visites-virtuelles/ embarque un sélecteur d'étages WordPress # (thème ubeo-theme-v5-child) dont TOUTES les unités sont rendues serveur : # blocs `div.unit#unit_` avec numéro, statut (« disponible », « Construction # 2026 »…), type (3½/4½/4½+/5½), lettre de modèle, superficie du logement et # du balcon, plan PNG/PDF. Seules les unités dont le statut contient # « disponible » sont retenues. Aucun prix par unité (seul un « à partir de # 1195 $ » global est cité en prose) -> price=None, rien d'inventé. # L'adresse « 140, rue Kingsville » (phase 3) publiée sur la page n'est # attachée qu'aux unités dont le statut mentionne « Construction 2026 ». # external_id stable : numéro d'unité (unit_501 -> 501). # robots.txt Yoast ouvert (Disallow vide), sitemap public. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.lequartierplus.com" LIST_URL = f"{BASE}/plans-et-visites-virtuelles/" # adresse de la phase 3, telle que publiée sur la page (bloc « Phase 3 ») _PHASE3_ADDR = "140, rue Kingsville" _AREA_RE = re.compile(r"([\d\s,]+)\s*p") def _sqft(text: str) -> float | None: """« 1 097 p2 » -> 1097.0 (None si illisible).""" m = _AREA_RE.search(text) if not m: return None try: return float(m.group(1).replace(" ", "").replace(" ", "") .replace(",", ".")) except ValueError: return None class QuartierPlusConnector(BaseConnector): source_id = "quartier_plus" request_delay = 0.6 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for unit in soup.select("div.unit[id^=unit_]"): try: self._parse_unit(unit, listings) except Exception: continue return list(listings.values()) # -- bloc unité (div.unit#unit_) ------------------------------------------ def _parse_unit(self, unit, listings: dict[str, Listing]) -> None: ext_id = unit.get("id", "").removeprefix("unit_").strip() if not ext_id or ext_id in listings: return header = unit.select_one("p.details_header") if header is None: return name_el = header.select_one("span.has-wpdc-x-large-font-size") name = name_el.get_text(" ", strip=True) if name_el else f"Unité {ext_id}" status_el = header.select_one("span.has-wpdc-medium-font-size") availability = re.sub( r"\s+", " ", status_el.get_text(" ", strip=True)) if status_el else "" # seules les unités affichées « disponible » sont des annonces if "disponible" not in availability.lower(): return if "indisponible" in availability.lower() or re.search( r"(?i)lou[ée]|réserv", availability): return # colonnes du bloc unit_details : type (badge), modèle, superficies unit_label, model, area, balcony = "", "", None, None for col in unit.select(".unit_details > div"): txt = re.sub(r"\s+", " ", col.get_text(" ", strip=True)) label = "" small = col.select_one(".small_text") if small: label = small.get_text(" ", strip=True).upper() if "SUPERFICIE" in label: area = _sqft(txt) elif "BALCON" in label: balcony = _sqft(txt) elif "TYPE" in label: strong = col.select_one("strong") model = strong.get_text(strip=True) if strong else "" elif re.match(r"^[1-6]½", txt): unit_label = txt amenities: list[str] = [] if model: amenities.append(f"Modèle {model}") if balcony: amenities.append(f"Balcon {balcony:g} pi²") # plan du logement (PNG du sélecteur) images: list[str] = [] plan = unit.select_one("p.unit_plan img[src]") if plan: u = plan["src"] if u.startswith("/"): u = BASE + u if u.startswith("http"): images.append(u) # l'adresse publiée de la phase 3 ne vaut que pour ses unités address = "" if "construction 2026" in availability.lower(): address = f"{_PHASE3_ADDR}, Thetford Mines" title = f"Le Quartier Plus — {name}" if unit_label: title += f" ({unit_label})" listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=LIST_URL, title=title, address=address, city="Thetford Mines", unit_type=normalize_unit_type(unit_label.replace("+", "")), price=None, # aucun loyer par unité publié price_label="", availability=availability, area_sqft=area, amenities=amenities, images=images, )