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/la_cour.py : connecteur La Cour Appartements (lacourappartements.com)5# 155, rue du Prince-Édouard, Saint-Roch (Québec) — 159 unités sur 8 étages.6# Site WordPress avec le plugin « do-selecteur-plans » (même plateforme que7# le1070.ca / kwayaweh.ca côté Immostar, et que AULA Lévis) :8# - page /plans/ : polygones SVG par unité (class disponible/indisponible,9# data-nom = n° d'unité, data-etage, data-type, data-prix, data-promotion)10# - REST /wp-json/do-selecteur-plans/v1/unite?id=<data-numero> : montant,11# superficie habitable/terrasse, date de libération, inclusions par unité,12# plan (galerie PNG + PDF)13# La carte SVG (attributs data-*) sert de clé de cache BD (self.detail) : le14# REST n'est re-frappé que si l'unité a changé depuis la dernière sync.15# La classe est PARTAGÉE : AulaConnector (aula.py) en hérite et ne change16# que les constantes du projet — le parsing est identique.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import json22import re2324from bs4 import BeautifulSoup2526from ..schema import Listing, infer_city, parse_price27from .base import BaseConnector2829# photos du projet dans wp-content/uploads (jamais les logos, icônes, plans,30# lettrages décoratifs ni les variantes redimensionnées -1536x864 etc.)31_IMG_RE = re.compile(32 r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp)',33 re.I)34_SKIP_IMG = re.compile(35 r"logo|icon|favicon|plan|selecteur|doodle|lettre|-\d{2,4}x\d{2,4}\.", re.I)363738class LaCourConnector(BaseConnector):39 source_id = "la_cour"40 request_delay = 0.641 max_rest_units = 90 # garde-fou : appels REST max par sync42 max_images = 104344 # -- constantes du projet (surchargées par AulaConnector) ------------------45 BASE = "https://lacourappartements.com"46 PLANS_PATH = "plans/"47 NAME = "La Cour Appartements"48 ADDRESS = "155, rue du Prince-Édouard, Québec, G1K 5V6"49 SECTOR = "Saint-Roch"50 CITY = "Québec"51 PHOTO_PAGES = ("projet/",) # pages crawlées pour les photos du projet52 AMENITY_PAGES = () # pages avec commodités (widgets Elementor/Jet)5354 # -- helpers ---------------------------------------------------------------55 def _site_images(self) -> list[str]:56 """Photos du projet (uploads jpg/webp des pages PHOTO_PAGES)."""57 imgs: list[str] = []58 for page in self.PHOTO_PAGES:59 try:60 html = self.get(f"{self.BASE}/{page}").text61 except Exception:62 continue63 for u in dict.fromkeys(_IMG_RE.findall(html)):64 if not _SKIP_IMG.search(u) and u not in imgs:65 imgs.append(u)66 return imgs[: self.max_images]6768 def _project_amenities(self) -> list[str]:69 """Commodités du projet : items Jet Engine70 (.jet-listing-dynamic-repeater__item) des pages AMENITY_PAGES, plus les71 titres h6 Elementor de la première page (espaces communs). Les notes72 du genre « Disponible sur les étages… » sont ignorées."""73 items: list[str] = []74 for i, page in enumerate(self.AMENITY_PAGES):75 try:76 html = self.get(f"{self.BASE}/{page}").text77 except Exception:78 continue79 soup = BeautifulSoup(html, "html.parser")80 sel = ".jet-listing-dynamic-repeater__item"81 if i == 0:82 sel += ", h6.elementor-heading-title"83 for el in soup.select(sel):84 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()85 if (3 <= len(t) <= 90 and t not in items86 and not t.lower().startswith("disponible sur")):87 items.append(t)88 return items[:30]8990 def _svg_cards(self, html: str) -> list[tuple[str, str, dict]]:91 """Unités disponibles du sélecteur SVG : (n° d'unité, post-id, data-*).9293 Chaque unité apparaît deux fois (polygone + bouton mobile) : dédup par94 n° d'unité. Les classes « indisponible » et « attente » sont exclues.95 """96 soup = BeautifulSoup(html, "html.parser")97 cards: list[tuple[str, str, dict]] = []98 seen: set[str] = set()99 for el in soup.select("[data-numero]"):100 cls = " ".join(el.get("class") or [])101 if not re.search(r"\bdisponible\b", cls) \102 or re.search(r"indisponible|attente", cls):103 continue104 nom = (el.get("data-nom") or "").strip()105 pid = (el.get("data-numero") or "").strip()106 if not nom or not pid or nom in seen:107 continue108 seen.add(nom)109 attrs = {k: str(v) for k, v in el.attrs.items()110 if k.startswith("data-")}111 attrs["class"] = cls112 cards.append((nom, pid, attrs))113 return cards114115 # -- fetch -----------------------------------------------------------------116 def fetch(self) -> list[Listing]:117 html = self.get(f"{self.BASE}/{self.PLANS_PATH}").text118 cards = self._svg_cards(html)119 images = self._site_images()120 project_amenities = self._project_amenities()121 rest_calls = 0122123 out: list[Listing] = []124 for nom, pid, attrs in cards[: self.max_rest_units]:125 def _fetch(p=pid):126 nonlocal rest_calls127 if rest_calls >= self.max_rest_units:128 raise RuntimeError("budget de requêtes REST atteint")129 rest_calls += 1130 return self.get(131 f"{self.BASE}/wp-json/do-selecteur-plans/v1/unite",132 params={"id": p}).json()133 try:134 # clé de cache BD = attributs SVG (prix/type/promo/état) : le135 # REST n'est appelé que si l'unité est nouvelle ou modifiée136 key = hashlib.sha1(json.dumps(137 attrs, sort_keys=True).encode()).hexdigest()[:20]138 data = self.detail(nom, key, _fetch)139 except Exception:140 continue141 if not data or data.get("etat") != "disponible":142 continue143144 t = str(data.get("type") or attrs.get("data-type") or "")145 sup = str(data.get("superficie_habitable") or "").strip()146 terrasse = str(data.get("superficie_terrasse") or "").strip()147 etage = (attrs.get("data-etage") or "").strip()148149 # disponibilité : date de libération (« | Janvier 2027 ») sinon150 # le libellé « a_partir » (« Libre le 8 août 2026 » / « Libre »)151 date_libre = str(data.get("date_libre") or "").strip(" |")152 a_partir = str(data.get("a_partir") or "").strip()153 avail = (f"Libre {date_libre}" if date_libre154 else a_partir or "Disponible")155156 # prix : montant REST (« 1 453 $ »), repli sur data-prix du SVG157 montant = str(data.get("montant") or "").strip()158 if not montant and (attrs.get("data-prix") or "").strip():159 montant = f"{attrs['data-prix'].strip()} $"160 price_label = f"{montant} par mois" if montant else ""161162 desc = " — ".join(x for x in [163 t, f"{sup} pi²" if sup else "",164 f"terrasse {terrasse} pi²" if terrasse else "",165 f"étage {etage}" if etage else ""] if x)166167 # inclusions par unité (La Cour) + commodités du projet (AULA)168 amenities = [str(a).strip() for a in (data.get("inclusion") or [])169 if str(a).strip()]170 amenities += [a for a in project_amenities if a not in amenities]171172 # plan de l'unité (galerie REST) en tête des photos du projet173 imgs = list(images)174 for g in reversed(data.get("gallery") or []):175 gu = g.get("url") if isinstance(g, dict) else str(g or "")176 if gu.startswith("http") and gu not in imgs:177 imgs.insert(0, gu)178179 details: dict = {}180 if etage.isdigit():181 details["floor"] = int(etage)182 pdf = str(data.get("pdf") or "")183 if pdf.startswith("http"):184 details["plan_pdf"] = pdf185186 sup_val = None187 try:188 v = float(sup.replace(" ", "").replace(",", "."))189 if 80 <= v <= 20000:190 sup_val = v191 except ValueError:192 pass193194 out.append(Listing(195 source=self.source_id,196 external_id=nom,197 url=f"{self.BASE}/{self.PLANS_PATH}#unite-{nom}",198 title=f"{self.NAME} — Unité {nom}",199 address=self.ADDRESS,200 sector=self.SECTOR,201 city=infer_city(self.SECTOR, default=self.CITY),202 unit_type=t,203 price=parse_price(montant),204 price_label=price_label,205 availability=avail,206 area_sqft=sup_val,207 description=desc[:600],208 amenities=amenities[:30],209 details=details,210 images=imgs[: self.max_images + 3],211 ))212 return out213