# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/la_cour.py : connecteur La Cour Appartements (lacourappartements.com) # 155, rue du Prince-Édouard, Saint-Roch (Québec) — 159 unités sur 8 étages. # Site WordPress avec le plugin « do-selecteur-plans » (même plateforme que # le1070.ca / kwayaweh.ca côté Immostar, et que AULA Lévis) : # - page /plans/ : polygones SVG par unité (class disponible/indisponible, # data-nom = n° d'unité, data-etage, data-type, data-prix, data-promotion) # - REST /wp-json/do-selecteur-plans/v1/unite?id= : montant, # superficie habitable/terrasse, date de libération, inclusions par unité, # plan (galerie PNG + PDF) # La carte SVG (attributs data-*) sert de clé de cache BD (self.detail) : le # REST n'est re-frappé que si l'unité a changé depuis la dernière sync. # La classe est PARTAGÉE : AulaConnector (aula.py) en hérite et ne change # que les constantes du projet — le parsing est identique. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, parse_price from .base import BaseConnector # photos du projet dans wp-content/uploads (jamais les logos, icônes, plans, # lettrages décoratifs ni les variantes redimensionnées -1536x864 etc.) _IMG_RE = re.compile( r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp)', re.I) _SKIP_IMG = re.compile( r"logo|icon|favicon|plan|selecteur|doodle|lettre|-\d{2,4}x\d{2,4}\.", re.I) class LaCourConnector(BaseConnector): source_id = "la_cour" request_delay = 0.6 max_rest_units = 90 # garde-fou : appels REST max par sync max_images = 10 # -- constantes du projet (surchargées par AulaConnector) ------------------ BASE = "https://lacourappartements.com" PLANS_PATH = "plans/" NAME = "La Cour Appartements" ADDRESS = "155, rue du Prince-Édouard, Québec, G1K 5V6" SECTOR = "Saint-Roch" CITY = "Québec" PHOTO_PAGES = ("projet/",) # pages crawlées pour les photos du projet AMENITY_PAGES = () # pages avec commodités (widgets Elementor/Jet) # -- helpers --------------------------------------------------------------- def _site_images(self) -> list[str]: """Photos du projet (uploads jpg/webp des pages PHOTO_PAGES).""" imgs: list[str] = [] for page in self.PHOTO_PAGES: try: html = self.get(f"{self.BASE}/{page}").text except Exception: continue for u in dict.fromkeys(_IMG_RE.findall(html)): if not _SKIP_IMG.search(u) and u not in imgs: imgs.append(u) return imgs[: self.max_images] def _project_amenities(self) -> list[str]: """Commodités du projet : items Jet Engine (.jet-listing-dynamic-repeater__item) des pages AMENITY_PAGES, plus les titres h6 Elementor de la première page (espaces communs). Les notes du genre « Disponible sur les étages… » sont ignorées.""" items: list[str] = [] for i, page in enumerate(self.AMENITY_PAGES): try: html = self.get(f"{self.BASE}/{page}").text except Exception: continue soup = BeautifulSoup(html, "html.parser") sel = ".jet-listing-dynamic-repeater__item" if i == 0: sel += ", h6.elementor-heading-title" for el in soup.select(sel): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if (3 <= len(t) <= 90 and t not in items and not t.lower().startswith("disponible sur")): items.append(t) return items[:30] def _svg_cards(self, html: str) -> list[tuple[str, str, dict]]: """Unités disponibles du sélecteur SVG : (n° d'unité, post-id, data-*). Chaque unité apparaît deux fois (polygone + bouton mobile) : dédup par n° d'unité. Les classes « indisponible » et « attente » sont exclues. """ soup = BeautifulSoup(html, "html.parser") cards: list[tuple[str, str, dict]] = [] seen: set[str] = set() for el in soup.select("[data-numero]"): cls = " ".join(el.get("class") or []) if not re.search(r"\bdisponible\b", cls) \ or re.search(r"indisponible|attente", cls): continue nom = (el.get("data-nom") or "").strip() pid = (el.get("data-numero") or "").strip() if not nom or not pid or nom in seen: continue seen.add(nom) attrs = {k: str(v) for k, v in el.attrs.items() if k.startswith("data-")} attrs["class"] = cls cards.append((nom, pid, attrs)) return cards # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: html = self.get(f"{self.BASE}/{self.PLANS_PATH}").text cards = self._svg_cards(html) images = self._site_images() project_amenities = self._project_amenities() rest_calls = 0 out: list[Listing] = [] for nom, pid, attrs in cards[: self.max_rest_units]: def _fetch(p=pid): nonlocal rest_calls if rest_calls >= self.max_rest_units: raise RuntimeError("budget de requêtes REST atteint") rest_calls += 1 return self.get( f"{self.BASE}/wp-json/do-selecteur-plans/v1/unite", params={"id": p}).json() try: # clé de cache BD = attributs SVG (prix/type/promo/état) : le # REST n'est appelé que si l'unité est nouvelle ou modifiée key = hashlib.sha1(json.dumps( attrs, sort_keys=True).encode()).hexdigest()[:20] data = self.detail(nom, key, _fetch) except Exception: continue if not data or data.get("etat") != "disponible": continue t = str(data.get("type") or attrs.get("data-type") or "") sup = str(data.get("superficie_habitable") or "").strip() terrasse = str(data.get("superficie_terrasse") or "").strip() etage = (attrs.get("data-etage") or "").strip() # disponibilité : date de libération (« | Janvier 2027 ») sinon # le libellé « a_partir » (« Libre le 8 août 2026 » / « Libre ») date_libre = str(data.get("date_libre") or "").strip(" |") a_partir = str(data.get("a_partir") or "").strip() avail = (f"Libre {date_libre}" if date_libre else a_partir or "Disponible") # prix : montant REST (« 1 453 $ »), repli sur data-prix du SVG montant = str(data.get("montant") or "").strip() if not montant and (attrs.get("data-prix") or "").strip(): montant = f"{attrs['data-prix'].strip()} $" price_label = f"{montant} par mois" if montant else "" desc = " — ".join(x for x in [ t, f"{sup} pi²" if sup else "", f"terrasse {terrasse} pi²" if terrasse else "", f"étage {etage}" if etage else ""] if x) # inclusions par unité (La Cour) + commodités du projet (AULA) amenities = [str(a).strip() for a in (data.get("inclusion") or []) if str(a).strip()] amenities += [a for a in project_amenities if a not in amenities] # plan de l'unité (galerie REST) en tête des photos du projet imgs = list(images) for g in reversed(data.get("gallery") or []): gu = g.get("url") if isinstance(g, dict) else str(g or "") if gu.startswith("http") and gu not in imgs: imgs.insert(0, gu) details: dict = {} if etage.isdigit(): details["floor"] = int(etage) pdf = str(data.get("pdf") or "") if pdf.startswith("http"): details["plan_pdf"] = pdf sup_val = None try: v = float(sup.replace(" ", "").replace(",", ".")) if 80 <= v <= 20000: sup_val = v except ValueError: pass out.append(Listing( source=self.source_id, external_id=nom, url=f"{self.BASE}/{self.PLANS_PATH}#unite-{nom}", title=f"{self.NAME} — Unité {nom}", address=self.ADDRESS, sector=self.SECTOR, city=infer_city(self.SECTOR, default=self.CITY), unit_type=t, price=parse_price(montant), price_label=price_label, availability=avail, area_sqft=sup_val, description=desc[:600], amenities=amenities[:30], details=details, images=imgs[: self.max_images + 3], )) return out