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/edifia.py : connecteur Edifia Groupe Immobilier (edifiaimmobilier.com)5# Le locatif d'Edifia est réparti sur plusieurs plateformes :6# - Le Vital (levitalappartements.com, Lévis/Desjardins, 230 unités)7# -> sélecteur Livya : la page « Plans et prix » pointe vers un module8# app.livya.com dont le HTML (Next.js/RSC) embarque le JSON complet9# des unités (prix, superficie, chambres, étage, date de libération)10# - Capella (capellalevis.com, Lévis, résidence pour aînés, 198 unités)11# -> sélecteur d'étages « sysc » : JSON `units = [[...]]` dans la page12# - Le Cardinal Sud (appartementscardinal.com/cardinal-sud/)13# -> même plateforme « sysc » (unités, dates, plans, inclusions)14# - Le Cardinal Nord + unités Cardinal Sud absentes du micro-site15# -> portail BuildingStack edifialocation.com (« Trouvez un logement ») :16# /Listing/Listings embarque `var units = [...]` (prix, pi², chambres,17# GPS, contact) et les pages /b/<id> donnent dates et commodités18# Exclus : HUMĀ phases 1-2 (connecteur dédié huma.py), Le Stellar Chicoutimi19# (Saguenay, hors région), Solis (condos en vente), Riva Ouest (Lavaltrie,20# en construction — aucune unité affichée nulle part).21# -----------------------------------------------------------------------------22from __future__ import annotations2324import json25import re2627from bs4 import BeautifulSoup2829from ..schema import Listing, infer_city30from .base import BaseConnector3132EDIFIALOC = "https://edifialocation.com"33VITAL = "https://www.levitalappartements.com"34CAPELLA = "https://capellalevis.com"35CARDINAL = "https://appartementscardinal.com"3637# images à ignorer (logos, icônes, favicons, variantes responsives)38_SKIP_IMG = re.compile(r"logo|favicon|icon|Mapmarker|-\d{2,4}x\d{2,4}\.", re.I)39_IMG_RE = re.compile(40 r'https://[^"\'\s\)]+/(?:app|wp-content)/uploads/[^"\'\s\)]+'41 r'\.(?:jpe?g|webp|png)', re.I)424344def _extract_json(html: str, marker: str):45 """Décode la structure JSON qui suit `marker` dans un script inline."""46 i = html.find(marker)47 if i < 0:48 return None49 try:50 data, _ = json.JSONDecoder().raw_decode(html[i + len(marker):])51 except Exception:52 return None53 return data545556def _typologie(raw) -> str:57 """'4etdemiB' -> '4½', index sysc Capella (0/1/2) -> Studio/3½/4½."""58 if isinstance(raw, int):59 return {0: "Studio", 1: "3½", 2: "4½"}.get(raw, "")60 s = str(raw or "").strip().lower()61 if "studio" in s:62 return "Studio"63 m = re.search(r"(\d)\s*etdemi", s)64 if m:65 return f"{m.group(1)}½"66 m = re.search(r"(\d)\s*(?:½|1/2)", s)67 if m:68 return f"{m.group(1)}½"69 return str(raw or "").strip()707172def _sqft(raw) -> float | None:73 try:74 v = float(str(raw).strip().replace(" ", "").replace(",", "."))75 except (TypeError, ValueError):76 return None77 return v if 80 <= v <= 20000 else None787980class EdifiaConnector(BaseConnector):81 source_id = "edifia"82 request_delay = 0.683 max_images = 128485 # -- helpers ---------------------------------------------------------------86 def _page_images(self, url: str) -> list[str]:87 """Photos (uploads WordPress) d'une page de projet."""88 try:89 html = self.get(url).text90 except Exception:91 return []92 imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))93 if not _SKIP_IMG.search(u)]94 return imgs[: self.max_images]9596 # ===========================================================================97 # 1) Le Vital — sélecteur Livya (module app.livya.com embarqué)98 # ===========================================================================99 def _vital_amenities(self) -> list[str]:100 """Listes « Aires communes » et « Services et commodités » du site."""101 items: list[str] = []102 try:103 html = self.get(f"{VITAL}/espaces-communs-et-services/").text104 except Exception:105 return items106 soup = BeautifulSoup(html, "html.parser")107 # thème Bricks : chaque commodité est un bloc div.icon-box > .brxe-heading108 for el in soup.select(".icon-box .brxe-heading"):109 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))110 if 3 <= len(t) <= 90 and t not in items:111 items.append(t)112 return items[:25]113114 def _vital_units(self) -> list[dict]:115 """JSON des 230 unités : flux RSC (self.__next_f) du module Livya."""116 plans_html = self.get(f"{VITAL}/plans-et-prix/").text117 soup = BeautifulSoup(plans_html, "html.parser")118 cont = soup.select_one(".livya-module-container-plans[data-entity]")119 script = soup.select_one("script[data-client][src*='livya']")120 if cont is None or script is None:121 return []122 client = script.get("data-client")123 project = cont.get("data-project")124 entity = cont.get("data-entity")125 lang = cont.get("data-lang") or "fr"126 livya = self.get(127 f"https://app.livya.com/{lang}/{client}/projects/{project}"128 f"/plans/{entity}", params={"noLayout": "1"}).text129 chunks = re.findall(130 r'self\.__next_f\.push\(\[1,\s*"((?:[^"\\]|\\.)*)"\]\)', livya)131 blob = "".join(json.loads(f'"{c}"') for c in chunks)132 i = blob.find('"units":[') # le tableau, pas les libellés i18n133 if i < 0:134 return []135 units = _extract_json(blob[i:], '"units":')136 return units if isinstance(units, list) else []137138 def _vital(self) -> list[Listing]:139 amenities = self._vital_amenities()140 images = self._page_images(f"{VITAL}/")141 out: list[Listing] = []142 for u in self._vital_units():143 fut = u.get("futureAvailability") or {}144 now_avail = u.get("availability") == "AVAILABLE"145 future_on = (fut.get("status") == "AVAILABLE"146 and (fut.get("startsOn") or "")[:10])147 if not now_avail and not future_on:148 continue149 num = str(u.get("number") or "").lstrip("#").strip()150 if not num:151 continue152 if now_avail and not future_on:153 availability = "Disponible — occupation immédiate"154 else:155 availability = f"Libre le {future_on}"156 rooms = u.get("rooms") or 0157 beds = u.get("roomsBed") or 0158 unit_type = ("Studio" if beds == 0 and rooms < 1.5159 else f"{int(rooms)}½" if float(rooms) % 1 else "")160 price = float(u.get("rentalPrice") or 0) or None161 baths = u.get("roomsBath") or 0162 balcon = u.get("balconySize") or 0163 desc = " — ".join(x for x in [164 f"Type {u['typeName']}" if u.get("typeName") else "",165 str(u.get("floorDisplayName") or ""),166 f"{u.get('unitSize')} pi²" if u.get("unitSize") else "",167 f"balcon {balcon} pi²" if balcon else "",168 f"{beds} chambre(s)" if beds else "",169 f"{baths} salle(s) de bain" if baths else "",170 str(u.get("pricePrecision") or "")] if x)171 addr = ", ".join(x for x in [172 u.get("address"), u.get("city"),173 u.get("postalCode")] if x)174 lat = u.get("latitude")175 lng = u.get("longitude")176 out.append(Listing(177 source=self.source_id,178 external_id=f"vital-{num}",179 url=f"{VITAL}/plans-et-prix/#unite-{num}",180 title=f"Le Vital — Unité {num}",181 address=addr,182 sector="Desjardins", # Lévis (secteur Desjardins)183 city=infer_city("Desjardins"),184 unit_type=unit_type,185 price=price,186 availability=availability,187 area_sqft=_sqft(u.get("unitSize")),188 description=desc[:600],189 amenities=list(amenities),190 images=list(images),191 lat=float(lat) if lat else None,192 lng=float(lng) if lng else None,193 ))194 return out195196 # ===========================================================================197 # 2) Capella — résidence pour aînés (sélecteur « sysc », prix par unité)198 # ===========================================================================199 def _capella(self) -> list[Listing]:200 html = self.get(f"{CAPELLA}/logements-et-prix/").text201 floors = _extract_json(html, "units = ") or []202 soup = BeautifulSoup(html, "html.parser")203204 # adresse du pied de page (1500, rue Weyman, Lévis)205 address = ""206 addr_el = soup.select_one(".address div")207 if addr_el:208 address = re.sub(r"\s+", " ",209 addr_el.get_text(", ", strip=True))210211 # inclusions (grille d'icônes sous le titre « Les inclusions »)212 amenities = ["Résidence pour aînés autonomes et semi-autonomes"]213 for h2 in soup.find_all("h2"):214 title = h2.get_text(" ", strip=True).lower()215 if "inclusion" not in title and "options" not in title:216 continue217 section = h2.find_parent("section")218 suffix = " — en option" if "options" in title else ""219 for p in (section or h2.parent).select("p.big-text"):220 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))221 if 3 <= len(t) <= 90 and t + suffix not in amenities:222 amenities.append(t + suffix)223 photos = [u for u in dict.fromkeys(_IMG_RE.findall(html))224 if not _SKIP_IMG.search(u)][: self.max_images]225226 out: list[Listing] = []227 for floor in floors:228 for u in floor:229 if u.get("state") != "Available":230 continue231 num = str(u.get("number") or "").strip()232 if not num:233 continue234 dispo = str(u.get("available_date") or "").strip()235 indoor = u.get("indoor_area")236 balcon = u.get("balcony_area")237 desc = " — ".join(x for x in [238 f"Type {u['type']}" if u.get("type") else "",239 f"{indoor} pi²" if indoor else "",240 f"balcon {balcon} pi²" if balcon else ""] if x)241 imgs = list(photos)242 plan = str(u.get("plan_img_src") or "")243 if plan.startswith("http"):244 imgs.insert(0, plan)245 price = float(u.get("price") or 0) or None246 out.append(Listing(247 source=self.source_id,248 external_id=f"capella-{num}",249 url=f"{CAPELLA}/logements-et-prix/#unite-{num}",250 title=f"Capella (résidence pour aînés) — Unité {num}",251 address=address,252 sector="Desjardins", # quartier Umano, Lévis253 city="Lévis",254 unit_type=_typologie(u.get("typology")),255 price=price,256 availability=f"Libre {dispo}" if dispo else "Disponible",257 area_sqft=_sqft(indoor),258 description=desc[:600],259 amenities=list(amenities),260 images=imgs[: self.max_images + 1],261 ))262 return out263264 # ===========================================================================265 # 3) Cardinal Sud — micro-site (sélecteur « sysc » avec dates et plans)266 # ===========================================================================267 def _cardinal_sud(self, bs_prices: dict) \268 -> tuple[list[Listing], set[str], list[str]]:269 html = self.get(f"{CARDINAL}/cardinal-sud/").text270 floors = _extract_json(html, "units = ") or []271 address, latlng = self._cardinal_map(html, "sud")272 photos = [u for u in dict.fromkeys(_IMG_RE.findall(html))273 if not _SKIP_IMG.search(u)][: self.max_images]274275 out: list[Listing] = []276 seen: set[str] = set()277 for floor in floors:278 for u in floor:279 if u.get("state") != "Available":280 continue281 num = str(u.get("number") or "").strip()282 if not num:283 continue284 seen.add(num)285 dispo = str(u.get("available_date") or "").strip()286 indoor = u.get("indoor_area")287 balcon = u.get("balcony_area")288 incl = re.sub(r"\s+", " ", BeautifulSoup(289 u.get("description") or "", "html.parser")290 .get_text(" ", strip=True))291 desc = " — ".join(x for x in [292 f"Type {u['type']}" if u.get("type") else "",293 f"{indoor} pi²" if indoor else "",294 f"balcon {balcon} pi²" if balcon else "",295 incl] if x)296 imgs = list(photos)297 plan = str(u.get("plan_img_src") or "")298 if plan.startswith("http"):299 imgs.insert(0, plan)300 # prix : micro-site, sinon portail BuildingStack (même unité)301 price = float(u.get("price") or 0) or None302 if price is None:303 price = bs_prices.get(("Cardinal sud", num))304 out.append(Listing(305 source=self.source_id,306 external_id=f"cardinal-sud-{num}",307 url=f"{CARDINAL}/cardinal-sud/#unite-{num}",308 title=f"Le Cardinal Sud — Unité {num}",309 address=address,310 sector="Val-Bélair",311 city="Québec",312 unit_type=_typologie(u.get("typology")),313 price=price,314 availability=f"Libre {dispo}" if dispo else "Disponible",315 area_sqft=_sqft(indoor),316 description=desc[:600],317 amenities=[], # complétées via BuildingStack318 images=imgs[: self.max_images + 1],319 lat=latlng[0] if latlng else None,320 lng=latlng[1] if latlng else None,321 ))322 return out, seen, photos323324 @staticmethod325 def _cardinal_map(html: str, which: str) -> tuple[str, tuple | None]:326 """Adresse + GPS depuis les marqueurs `var map_points` du micro-site."""327 points = _extract_json(html, "var map_points = ") or []328 for p in points:329 if which.lower() not in str(p.get("nom", "")).lower():330 continue331 addr = re.sub(r"\s+", " ", BeautifulSoup(332 p.get("adresse") or "", "html.parser")333 .get_text(" ", strip=True))334 try:335 lat, lng = [float(x) for x in336 str(p["latitude_longitude"]).split(",")]337 return addr, (lat, lng)338 except (KeyError, ValueError):339 return addr, None340 return "", None341342 # ===========================================================================343 # 4) Portail BuildingStack (edifialocation.com) — Cardinal Nord + restes344 # ===========================================================================345 def _buildingstack_units(self) -> list[dict]:346 """`var units = [...]` de la page ANNONCES (toutes unités affichées)."""347 html = self.get(f"{EDIFIALOC}/Listing/Listings").text348 return _extract_json(html, "var units = ") or []349350 def _building_page(self, pub_id: str) -> tuple[dict[str, str], list[str]]:351 """Page /b/<id> : date de disponibilité par unité + commodités."""352 dates: dict[str, str] = {}353 amenities: list[str] = []354 try:355 html = self.get(f"{EDIFIALOC}/b/{pub_id}").text356 except Exception:357 return dates, amenities358 soup = BeautifulSoup(html, "html.parser")359 for a in soup.select("ul.wpb-tabs-menu a.apartment-view-potential"):360 spans = [s.get_text(" ", strip=True) for s in a.find_all("span")]361 if len(spans) >= 6 and spans[0]:362 dates[spans[0]] = spans[5] # Unité -> Disponible363 for li in soup.select(".facilities ul li label"):364 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))365 if 3 <= len(t) <= 90 and t not in amenities:366 amenities.append(t)367 return dates, amenities368369 def _mk_bs(self, u: dict, num: str, project: str, slug: str, url: str,370 sector: str, availability: str, amenities: list[str],371 images: list[str]) -> Listing:372 apt = u.get("Apartment") or {}373 addr = u.get("Address") or {}374 beds = apt.get("NumberOfBedrooms") or 0375 baths = apt.get("NumberOfBathrooms") or 0376 unit_type = "Studio" if beds == 0 else f"{beds + 2}½"377 desc = " — ".join(x for x in [378 f"{apt.get('Area')} pi²" if apt.get("Area") else "",379 f"{beds} chambre(s)" if beds else "",380 f"{baths} salle(s) de bain" if baths else ""] if x)381 details: dict = {}382 contacts = (u.get("Building") or {}).get("ListingEmployeesContacts") or []383 if contacts:384 c = contacts[0]385 details["contact"] = {386 k: v for k, v in [("name", c.get("FullName")),387 ("phone", c.get("FormattedPhoneNumber")),388 ("email", c.get("Email"))] if v}389 imgs = list(images)390 prev = u.get("PreviewUrl") or ""391 if prev.startswith("http"):392 imgs.insert(0, prev)393 try:394 lat, lng = float(addr.get("Latitude")), float(addr.get("Longitude"))395 except (TypeError, ValueError):396 lat = lng = None397 # prix présent dans le JSON du portail ; le site affiche398 # « Contactez-nous » (PriceFormatted) — les deux sont conservés399 price = float(apt.get("Price") or 0) or None400 return Listing(401 source=self.source_id,402 external_id=f"{slug}-{num}",403 url=url,404 title=f"{project} — Unité {num}",405 address=addr.get("Full") or "",406 sector=sector,407 city=addr.get("City") or "",408 unit_type=unit_type,409 price=price,410 price_label=str(apt.get("PriceFormatted") or ""),411 availability=availability,412 area_sqft=_sqft(apt.get("Area")),413 description=desc[:600],414 amenities=list(amenities),415 details=details,416 images=imgs[: self.max_images + 1],417 lat=lat,418 lng=lng,419 )420421 # -- fetch -----------------------------------------------------------------422 def fetch(self) -> list[Listing]:423 listings: list[Listing] = []424425 # portail BuildingStack : prix par (immeuble, unité) pour enrichir426 try:427 bs_units = self._buildingstack_units()428 except Exception:429 bs_units = []430 bs_prices: dict[tuple[str, str], float] = {}431 for u in bs_units:432 apt = u.get("Apartment") or {}433 price = float(apt.get("Price") or 0) or None434 if price:435 bs_prices[(str(u.get("BuildingName")),436 str(apt.get("UnitName")))] = price437438 # 1) Le Vital (Livya)439 try:440 listings.extend(self._vital())441 except Exception:442 pass443444 # 2) Capella (sysc)445 try:446 listings.extend(self._capella())447 except Exception:448 pass449450 # 3) Cardinal Sud (micro-site sysc, prix complétés via le portail)451 cs_seen: set[str] = set()452 cs_photos: list[str] = []453 try:454 cs_listings, cs_seen, cs_photos = self._cardinal_sud(bs_prices)455 listings.extend(cs_listings)456 except Exception:457 cs_listings = []458459 # 4) Portail BuildingStack : Cardinal Nord (aucune unité sur le460 # micro-site) + unités Cardinal Sud absentes du micro-site.461 # HUMĀ (connecteur huma.py) et les immeubles hors Québec/Lévis462 # (Le Stellar à Saguenay, etc.) sont exclus.463 bs_projects = {464 "Cardinal Nord": ("cardinal-nord", "Le Cardinal Nord",465 f"{CARDINAL}/cardinal-nord/", "Val-Bélair"),466 "Cardinal sud": ("cardinal-sud", "Le Cardinal Sud",467 f"{CARDINAL}/cardinal-sud/", "Val-Bélair"),468 }469 cardinal_amen: dict[str, list[str]] = {}470 by_building: dict[str, list[dict]] = {}471 for u in bs_units:472 name = str(u.get("BuildingName") or "")473 city = str((u.get("Address") or {}).get("City") or "")474 if "huma" in name.lower() or city not in ("Québec", "Lévis"):475 continue476 if name not in bs_projects:477 continue # immeuble inconnu : prudence478 by_building.setdefault(name, []).append(u)479480 cn_images = self._page_images(f"{CARDINAL}/cardinal-nord/") \481 if "Cardinal Nord" in by_building else []482 for name, units in by_building.items():483 slug, project, url, sector = bs_projects[name]484 pub = str((units[0].get("Building") or {})485 .get("PublicListBuildingName") or "")486 dates, amenities = (self._building_page(pub) if pub487 else ({}, []))488 cardinal_amen[slug] = amenities489 for u in units:490 num = str((u.get("Apartment") or {}).get("UnitName") or "")491 if not num:492 continue493 if slug == "cardinal-sud" and num in cs_seen:494 continue # déjà couvert par le micro-site495 dispo = dates.get(num, "")496 if not dispo:497 availability = "Disponible"498 elif re.search(r"\d", dispo): # « sept. 01, 2026 »499 availability = f"Libre {dispo}"500 else: # « Disponible dès maintenant! »501 availability = dispo502 images = cn_images if slug == "cardinal-nord" else cs_photos503 listings.append(self._mk_bs(504 u, num, project, slug, f"{EDIFIALOC}/b/{pub}",505 sector, availability, amenities, images))506507 # commodités du portail répercutées sur les unités micro-site Cardinal508 for lst in cs_listings:509 if not lst.amenities and cardinal_amen.get("cardinal-sud"):510 lst.amenities = list(cardinal_amen["cardinal-sud"])511512 # dédup par external_id (sécurité)513 uniq: dict[str, Listing] = {}514 for lst in listings:515 uniq.setdefault(lst.external_id, lst)516 return list(uniq.values())517