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/lacite.py : connecteur La Cité Gatineau (lacitegatineau.com)5# Tour de 200+ condos locatifs au 700, boulevard du Carrefour, Gatineau6# (Adam Real Estate). WordPress/Elementor + Toolset Views : la page7# /plans-des-appartements/ liste 14 TYPES d'unités (nom, chambres,8# superficie, plan) et chaque fiche /type-unite/<slug>/ publie les9# salles de bain, les superficies (unité/balcon/totale) et surtout la10# liste « APPARTEMENT(S) SIMILAIRE(S) » = numéros d'appartements11# actuellement offerts (« A – 702 ») ou « Aucun appartement disponible ».12# -> une annonce par APPARTEMENT LISTÉ DISPONIBLE (uid = numéro d'unité).13# AUCUN prix n'est publié nulle part -> price None, rien d'inventé.14# ⚠ robots.txt : « Crawl-delay: 10 » -> request_delay = 10 s ; une sync =15# 1 + 14 requêtes ≈ 2,5 min. Pas de cache détail : la disponibilité vit16# sur les fiches type et doit rester fraîche.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, strip_accents25from .base import BaseConnector2627BASE = "https://lacitegatineau.com"28LIST_URL = f"{BASE}/plans-des-appartements/"29ADDRESS = "700, boulevard du Carrefour, Gatineau"303132def _slugify(s: str) -> str:33 s = strip_accents(s.lower())34 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")353637class LaCiteConnector(BaseConnector):38 source_id = "lacite"39 request_delay = 10.0 # robots.txt : Crawl-delay: 1040 max_types = 204142 # -- fiche type ---------------------------------------------------------------43 def _parse_type(self, url: str) -> dict:44 """Salles de bain, superficies et numéros d'appartements offerts."""45 soup = BeautifulSoup(self.get(url).text, "html.parser")46 text = soup.get_text("\n", strip=True)47 out: dict = {"units": [], "baths": None, "sqft": None,48 "sqft_balcony": None, "sqft_total": None, "pdf": ""}4950 m = re.search(r"(\d+)\s*\n?\s*salle\(s\) de bain", text)51 if m:52 out["baths"] = int(m.group(1))53 for label, key in (("SUPERFICIE :", "sqft"),54 ("SUPERFICIE BALCON :", "sqft_balcony"),55 ("SUPERFICIE TOTALE :", "sqft_total")):56 mm = re.search(rf"{re.escape(label)}\s*\n?\s*([\d\s,]+)\s*pi", text)57 if mm:58 try:59 v = float(mm.group(1).replace(" ", "").replace(",", ""))60 if 40 <= v <= 20000:61 out[key] = v62 except ValueError:63 pass6465 # « APPARTEMENT(S) SIMILAIRE(S) : A – 702 … » jusqu'aux chambres66 mm = re.search(r"SIMILAIRE\(S\)\s*:?\s*\n(.*?)\n\d+\s*\n?chambre",67 text, re.S)68 if mm and not re.search(r"(?i)aucun appartement", mm.group(1)):69 for seg in mm.group(1).split("\n"):70 seg = re.sub(r"\s+", " ", seg).strip()71 if re.match(r"^[A-Z]\s*[–-]\s*\d+$", seg):72 out["units"].append(seg)7374 a = soup.find("a", href=re.compile(r"\.pdf$", re.I))75 if a:76 out["pdf"] = a["href"]77 return out7879 # -- fetch -----------------------------------------------------------------80 def fetch(self) -> list[Listing]:81 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")82 listings: dict[str, Listing] = {}83 items = soup.select("ul.wpv-loop > li")[: self.max_types]84 for li in items:85 try:86 a = li.select_one("a[href*='/type-unite/']")87 if not a:88 continue89 type_url = a["href"]90 type_name = re.sub(r"\s+", " ", a.get_text(" ", strip=True))91 # « 1 chambre(s) – 742 pi² » sous le titre92 li_text = re.sub(r"\s+", " ", li.get_text(" ", strip=True))93 beds = None94 mb = re.search(r"(\d+)\s*chambre", li_text)95 if mb:96 beds = int(mb.group(1))97 img = li.select_one("img[data-src], img[src^='http']")98 plan_img = (img.get("data-src") or img.get("src")99 if img else "") or ""100101 info = self._parse_type(type_url)102 for unit in info["units"]:103 num = re.sub(r"\s*[–-]\s*", "-", unit)104 ext = _slugify(num)105 if not ext or ext in listings:106 continue107 # les chaînes « 502-702-1102-1702 » du nom de type108 # ressemblent à des téléphones pour l'extracteur commun :109 # séparateur neutre « · » dans la description110 safe_name = re.sub(r"(\d)-(?=\d)", r"\1·", type_name)111 desc_bits = [f"Type {safe_name}"]112 if info.get("sqft_balcony"):113 desc_bits.append(114 f"balcon {info['sqft_balcony']:.0f} pi²")115 if info.get("sqft_total"):116 desc_bits.append(117 f"superficie totale {info['sqft_total']:.0f} pi²")118 details: dict = {}119 if info.get("baths"):120 details["bathrooms"] = info["baths"]121 if info.get("pdf"):122 details["floorplan_pdf"] = info["pdf"]123124 listings[ext] = Listing(125 source=self.source_id,126 external_id=ext,127 url=type_url,128 title=f"La Cité Gatineau — {unit}",129 address=ADDRESS,130 sector="",131 city="Gatineau",132 unit_type=(normalize_unit_type(f"{beds} chambres")133 if beds else ""),134 price=None, # aucun prix publié sur le site135 price_label="",136 # le site liste l'unité sous « APPARTEMENT(S)137 # SIMILAIRE(S) » sans date : rien d'inventé138 availability="",139 area_sqft=info.get("sqft"),140 description=" — ".join(desc_bits)[:600],141 details=details,142 images=[plan_img] if plan_img.startswith("http") else [],143 )144 except Exception:145 continue146 return list(listings.values())147