# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lacite.py : connecteur La Cité Gatineau (lacitegatineau.com) # Tour de 200+ condos locatifs au 700, boulevard du Carrefour, Gatineau # (Adam Real Estate). WordPress/Elementor + Toolset Views : la page # /plans-des-appartements/ liste 14 TYPES d'unités (nom, chambres, # superficie, plan) et chaque fiche /type-unite// publie les # salles de bain, les superficies (unité/balcon/totale) et surtout la # liste « APPARTEMENT(S) SIMILAIRE(S) » = numéros d'appartements # actuellement offerts (« A – 702 ») ou « Aucun appartement disponible ». # -> une annonce par APPARTEMENT LISTÉ DISPONIBLE (uid = numéro d'unité). # AUCUN prix n'est publié nulle part -> price None, rien d'inventé. # ⚠ robots.txt : « Crawl-delay: 10 » -> request_delay = 10 s ; une sync = # 1 + 14 requêtes ≈ 2,5 min. Pas de cache détail : la disponibilité vit # sur les fiches type et doit rester fraîche. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector BASE = "https://lacitegatineau.com" LIST_URL = f"{BASE}/plans-des-appartements/" ADDRESS = "700, boulevard du Carrefour, Gatineau" def _slugify(s: str) -> str: s = strip_accents(s.lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") class LaCiteConnector(BaseConnector): source_id = "lacite" request_delay = 10.0 # robots.txt : Crawl-delay: 10 max_types = 20 # -- fiche type --------------------------------------------------------------- def _parse_type(self, url: str) -> dict: """Salles de bain, superficies et numéros d'appartements offerts.""" soup = BeautifulSoup(self.get(url).text, "html.parser") text = soup.get_text("\n", strip=True) out: dict = {"units": [], "baths": None, "sqft": None, "sqft_balcony": None, "sqft_total": None, "pdf": ""} m = re.search(r"(\d+)\s*\n?\s*salle\(s\) de bain", text) if m: out["baths"] = int(m.group(1)) for label, key in (("SUPERFICIE :", "sqft"), ("SUPERFICIE BALCON :", "sqft_balcony"), ("SUPERFICIE TOTALE :", "sqft_total")): mm = re.search(rf"{re.escape(label)}\s*\n?\s*([\d\s,]+)\s*pi", text) if mm: try: v = float(mm.group(1).replace(" ", "").replace(",", "")) if 40 <= v <= 20000: out[key] = v except ValueError: pass # « APPARTEMENT(S) SIMILAIRE(S) : A – 702 … » jusqu'aux chambres mm = re.search(r"SIMILAIRE\(S\)\s*:?\s*\n(.*?)\n\d+\s*\n?chambre", text, re.S) if mm and not re.search(r"(?i)aucun appartement", mm.group(1)): for seg in mm.group(1).split("\n"): seg = re.sub(r"\s+", " ", seg).strip() if re.match(r"^[A-Z]\s*[–-]\s*\d+$", seg): out["units"].append(seg) a = soup.find("a", href=re.compile(r"\.pdf$", re.I)) if a: out["pdf"] = a["href"] return out # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") listings: dict[str, Listing] = {} items = soup.select("ul.wpv-loop > li")[: self.max_types] for li in items: try: a = li.select_one("a[href*='/type-unite/']") if not a: continue type_url = a["href"] type_name = re.sub(r"\s+", " ", a.get_text(" ", strip=True)) # « 1 chambre(s) – 742 pi² » sous le titre li_text = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) beds = None mb = re.search(r"(\d+)\s*chambre", li_text) if mb: beds = int(mb.group(1)) img = li.select_one("img[data-src], img[src^='http']") plan_img = (img.get("data-src") or img.get("src") if img else "") or "" info = self._parse_type(type_url) for unit in info["units"]: num = re.sub(r"\s*[–-]\s*", "-", unit) ext = _slugify(num) if not ext or ext in listings: continue # les chaînes « 502-702-1102-1702 » du nom de type # ressemblent à des téléphones pour l'extracteur commun : # séparateur neutre « · » dans la description safe_name = re.sub(r"(\d)-(?=\d)", r"\1·", type_name) desc_bits = [f"Type {safe_name}"] if info.get("sqft_balcony"): desc_bits.append( f"balcon {info['sqft_balcony']:.0f} pi²") if info.get("sqft_total"): desc_bits.append( f"superficie totale {info['sqft_total']:.0f} pi²") details: dict = {} if info.get("baths"): details["bathrooms"] = info["baths"] if info.get("pdf"): details["floorplan_pdf"] = info["pdf"] listings[ext] = Listing( source=self.source_id, external_id=ext, url=type_url, title=f"La Cité Gatineau — {unit}", address=ADDRESS, sector="", city="Gatineau", unit_type=(normalize_unit_type(f"{beds} chambres") if beds else ""), price=None, # aucun prix publié sur le site price_label="", # le site liste l'unité sous « APPARTEMENT(S) # SIMILAIRE(S) » sans date : rien d'inventé availability="", area_sqft=info.get("sqft"), description=" — ".join(desc_bits)[:600], details=details, images=[plan_img] if plan_img.startswith("http") else [], ) except Exception: continue return list(listings.values())