# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/edifia.py : connecteur Edifia Groupe Immobilier (edifiaimmobilier.com) # Le locatif d'Edifia est réparti sur plusieurs plateformes : # - Le Vital (levitalappartements.com, Lévis/Desjardins, 230 unités) # -> sélecteur Livya : la page « Plans et prix » pointe vers un module # app.livya.com dont le HTML (Next.js/RSC) embarque le JSON complet # des unités (prix, superficie, chambres, étage, date de libération) # - Capella (capellalevis.com, Lévis, résidence pour aînés, 198 unités) # -> sélecteur d'étages « sysc » : JSON `units = [[...]]` dans la page # - Le Cardinal Sud (appartementscardinal.com/cardinal-sud/) # -> même plateforme « sysc » (unités, dates, plans, inclusions) # - Le Cardinal Nord + unités Cardinal Sud absentes du micro-site # -> portail BuildingStack edifialocation.com (« Trouvez un logement ») : # /Listing/Listings embarque `var units = [...]` (prix, pi², chambres, # GPS, contact) et les pages /b/ donnent dates et commodités # Exclus : HUMĀ phases 1-2 (connecteur dédié huma.py), Le Stellar Chicoutimi # (Saguenay, hors région), Solis (condos en vente), Riva Ouest (Lavaltrie, # en construction — aucune unité affichée nulle part). # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city from .base import BaseConnector EDIFIALOC = "https://edifialocation.com" VITAL = "https://www.levitalappartements.com" CAPELLA = "https://capellalevis.com" CARDINAL = "https://appartementscardinal.com" # images à ignorer (logos, icônes, favicons, variantes responsives) _SKIP_IMG = re.compile(r"logo|favicon|icon|Mapmarker|-\d{2,4}x\d{2,4}\.", re.I) _IMG_RE = re.compile( r'https://[^"\'\s\)]+/(?:app|wp-content)/uploads/[^"\'\s\)]+' r'\.(?:jpe?g|webp|png)', re.I) def _extract_json(html: str, marker: str): """Décode la structure JSON qui suit `marker` dans un script inline.""" i = html.find(marker) if i < 0: return None try: data, _ = json.JSONDecoder().raw_decode(html[i + len(marker):]) except Exception: return None return data def _typologie(raw) -> str: """'4etdemiB' -> '4½', index sysc Capella (0/1/2) -> Studio/3½/4½.""" if isinstance(raw, int): return {0: "Studio", 1: "3½", 2: "4½"}.get(raw, "") s = str(raw or "").strip().lower() if "studio" in s: return "Studio" m = re.search(r"(\d)\s*etdemi", s) if m: return f"{m.group(1)}½" m = re.search(r"(\d)\s*(?:½|1/2)", s) if m: return f"{m.group(1)}½" return str(raw or "").strip() def _sqft(raw) -> float | None: try: v = float(str(raw).strip().replace(" ", "").replace(",", ".")) except (TypeError, ValueError): return None return v if 80 <= v <= 20000 else None class EdifiaConnector(BaseConnector): source_id = "edifia" request_delay = 0.6 max_images = 12 # -- helpers --------------------------------------------------------------- def _page_images(self, url: str) -> list[str]: """Photos (uploads WordPress) d'une page de projet.""" try: html = self.get(url).text except Exception: return [] imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html)) if not _SKIP_IMG.search(u)] return imgs[: self.max_images] # =========================================================================== # 1) Le Vital — sélecteur Livya (module app.livya.com embarqué) # =========================================================================== def _vital_amenities(self) -> list[str]: """Listes « Aires communes » et « Services et commodités » du site.""" items: list[str] = [] try: html = self.get(f"{VITAL}/espaces-communs-et-services/").text except Exception: return items soup = BeautifulSoup(html, "html.parser") # thème Bricks : chaque commodité est un bloc div.icon-box > .brxe-heading for el in soup.select(".icon-box .brxe-heading"): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if 3 <= len(t) <= 90 and t not in items: items.append(t) return items[:25] def _vital_units(self) -> list[dict]: """JSON des 230 unités : flux RSC (self.__next_f) du module Livya.""" plans_html = self.get(f"{VITAL}/plans-et-prix/").text soup = BeautifulSoup(plans_html, "html.parser") cont = soup.select_one(".livya-module-container-plans[data-entity]") script = soup.select_one("script[data-client][src*='livya']") if cont is None or script is None: return [] client = script.get("data-client") project = cont.get("data-project") entity = cont.get("data-entity") lang = cont.get("data-lang") or "fr" livya = self.get( f"https://app.livya.com/{lang}/{client}/projects/{project}" f"/plans/{entity}", params={"noLayout": "1"}).text chunks = re.findall( r'self\.__next_f\.push\(\[1,\s*"((?:[^"\\]|\\.)*)"\]\)', livya) blob = "".join(json.loads(f'"{c}"') for c in chunks) i = blob.find('"units":[') # le tableau, pas les libellés i18n if i < 0: return [] units = _extract_json(blob[i:], '"units":') return units if isinstance(units, list) else [] def _vital(self) -> list[Listing]: amenities = self._vital_amenities() images = self._page_images(f"{VITAL}/") out: list[Listing] = [] for u in self._vital_units(): fut = u.get("futureAvailability") or {} now_avail = u.get("availability") == "AVAILABLE" future_on = (fut.get("status") == "AVAILABLE" and (fut.get("startsOn") or "")[:10]) if not now_avail and not future_on: continue num = str(u.get("number") or "").lstrip("#").strip() if not num: continue if now_avail and not future_on: availability = "Disponible — occupation immédiate" else: availability = f"Libre le {future_on}" rooms = u.get("rooms") or 0 beds = u.get("roomsBed") or 0 unit_type = ("Studio" if beds == 0 and rooms < 1.5 else f"{int(rooms)}½" if float(rooms) % 1 else "") price = float(u.get("rentalPrice") or 0) or None baths = u.get("roomsBath") or 0 balcon = u.get("balconySize") or 0 desc = " — ".join(x for x in [ f"Type {u['typeName']}" if u.get("typeName") else "", str(u.get("floorDisplayName") or ""), f"{u.get('unitSize')} pi²" if u.get("unitSize") else "", f"balcon {balcon} pi²" if balcon else "", f"{beds} chambre(s)" if beds else "", f"{baths} salle(s) de bain" if baths else "", str(u.get("pricePrecision") or "")] if x) addr = ", ".join(x for x in [ u.get("address"), u.get("city"), u.get("postalCode")] if x) lat = u.get("latitude") lng = u.get("longitude") out.append(Listing( source=self.source_id, external_id=f"vital-{num}", url=f"{VITAL}/plans-et-prix/#unite-{num}", title=f"Le Vital — Unité {num}", address=addr, sector="Desjardins", # Lévis (secteur Desjardins) city=infer_city("Desjardins"), unit_type=unit_type, price=price, availability=availability, area_sqft=_sqft(u.get("unitSize")), description=desc[:600], amenities=list(amenities), images=list(images), lat=float(lat) if lat else None, lng=float(lng) if lng else None, )) return out # =========================================================================== # 2) Capella — résidence pour aînés (sélecteur « sysc », prix par unité) # =========================================================================== def _capella(self) -> list[Listing]: html = self.get(f"{CAPELLA}/logements-et-prix/").text floors = _extract_json(html, "units = ") or [] soup = BeautifulSoup(html, "html.parser") # adresse du pied de page (1500, rue Weyman, Lévis) address = "" addr_el = soup.select_one(".address div") if addr_el: address = re.sub(r"\s+", " ", addr_el.get_text(", ", strip=True)) # inclusions (grille d'icônes sous le titre « Les inclusions ») amenities = ["Résidence pour aînés autonomes et semi-autonomes"] for h2 in soup.find_all("h2"): title = h2.get_text(" ", strip=True).lower() if "inclusion" not in title and "options" not in title: continue section = h2.find_parent("section") suffix = " — en option" if "options" in title else "" for p in (section or h2.parent).select("p.big-text"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if 3 <= len(t) <= 90 and t + suffix not in amenities: amenities.append(t + suffix) photos = [u for u in dict.fromkeys(_IMG_RE.findall(html)) if not _SKIP_IMG.search(u)][: self.max_images] out: list[Listing] = [] for floor in floors: for u in floor: if u.get("state") != "Available": continue num = str(u.get("number") or "").strip() if not num: continue dispo = str(u.get("available_date") or "").strip() indoor = u.get("indoor_area") balcon = u.get("balcony_area") desc = " — ".join(x for x in [ f"Type {u['type']}" if u.get("type") else "", f"{indoor} pi²" if indoor else "", f"balcon {balcon} pi²" if balcon else ""] if x) imgs = list(photos) plan = str(u.get("plan_img_src") or "") if plan.startswith("http"): imgs.insert(0, plan) price = float(u.get("price") or 0) or None out.append(Listing( source=self.source_id, external_id=f"capella-{num}", url=f"{CAPELLA}/logements-et-prix/#unite-{num}", title=f"Capella (résidence pour aînés) — Unité {num}", address=address, sector="Desjardins", # quartier Umano, Lévis city="Lévis", unit_type=_typologie(u.get("typology")), price=price, availability=f"Libre {dispo}" if dispo else "Disponible", area_sqft=_sqft(indoor), description=desc[:600], amenities=list(amenities), images=imgs[: self.max_images + 1], )) return out # =========================================================================== # 3) Cardinal Sud — micro-site (sélecteur « sysc » avec dates et plans) # =========================================================================== def _cardinal_sud(self, bs_prices: dict) \ -> tuple[list[Listing], set[str], list[str]]: html = self.get(f"{CARDINAL}/cardinal-sud/").text floors = _extract_json(html, "units = ") or [] address, latlng = self._cardinal_map(html, "sud") photos = [u for u in dict.fromkeys(_IMG_RE.findall(html)) if not _SKIP_IMG.search(u)][: self.max_images] out: list[Listing] = [] seen: set[str] = set() for floor in floors: for u in floor: if u.get("state") != "Available": continue num = str(u.get("number") or "").strip() if not num: continue seen.add(num) dispo = str(u.get("available_date") or "").strip() indoor = u.get("indoor_area") balcon = u.get("balcony_area") incl = re.sub(r"\s+", " ", BeautifulSoup( u.get("description") or "", "html.parser") .get_text(" ", strip=True)) desc = " — ".join(x for x in [ f"Type {u['type']}" if u.get("type") else "", f"{indoor} pi²" if indoor else "", f"balcon {balcon} pi²" if balcon else "", incl] if x) imgs = list(photos) plan = str(u.get("plan_img_src") or "") if plan.startswith("http"): imgs.insert(0, plan) # prix : micro-site, sinon portail BuildingStack (même unité) price = float(u.get("price") or 0) or None if price is None: price = bs_prices.get(("Cardinal sud", num)) out.append(Listing( source=self.source_id, external_id=f"cardinal-sud-{num}", url=f"{CARDINAL}/cardinal-sud/#unite-{num}", title=f"Le Cardinal Sud — Unité {num}", address=address, sector="Val-Bélair", city="Québec", unit_type=_typologie(u.get("typology")), price=price, availability=f"Libre {dispo}" if dispo else "Disponible", area_sqft=_sqft(indoor), description=desc[:600], amenities=[], # complétées via BuildingStack images=imgs[: self.max_images + 1], lat=latlng[0] if latlng else None, lng=latlng[1] if latlng else None, )) return out, seen, photos @staticmethod def _cardinal_map(html: str, which: str) -> tuple[str, tuple | None]: """Adresse + GPS depuis les marqueurs `var map_points` du micro-site.""" points = _extract_json(html, "var map_points = ") or [] for p in points: if which.lower() not in str(p.get("nom", "")).lower(): continue addr = re.sub(r"\s+", " ", BeautifulSoup( p.get("adresse") or "", "html.parser") .get_text(" ", strip=True)) try: lat, lng = [float(x) for x in str(p["latitude_longitude"]).split(",")] return addr, (lat, lng) except (KeyError, ValueError): return addr, None return "", None # =========================================================================== # 4) Portail BuildingStack (edifialocation.com) — Cardinal Nord + restes # =========================================================================== def _buildingstack_units(self) -> list[dict]: """`var units = [...]` de la page ANNONCES (toutes unités affichées).""" html = self.get(f"{EDIFIALOC}/Listing/Listings").text return _extract_json(html, "var units = ") or [] def _building_page(self, pub_id: str) -> tuple[dict[str, str], list[str]]: """Page /b/ : date de disponibilité par unité + commodités.""" dates: dict[str, str] = {} amenities: list[str] = [] try: html = self.get(f"{EDIFIALOC}/b/{pub_id}").text except Exception: return dates, amenities soup = BeautifulSoup(html, "html.parser") for a in soup.select("ul.wpb-tabs-menu a.apartment-view-potential"): spans = [s.get_text(" ", strip=True) for s in a.find_all("span")] if len(spans) >= 6 and spans[0]: dates[spans[0]] = spans[5] # Unité -> Disponible for li in soup.select(".facilities ul li label"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if 3 <= len(t) <= 90 and t not in amenities: amenities.append(t) return dates, amenities def _mk_bs(self, u: dict, num: str, project: str, slug: str, url: str, sector: str, availability: str, amenities: list[str], images: list[str]) -> Listing: apt = u.get("Apartment") or {} addr = u.get("Address") or {} beds = apt.get("NumberOfBedrooms") or 0 baths = apt.get("NumberOfBathrooms") or 0 unit_type = "Studio" if beds == 0 else f"{beds + 2}½" desc = " — ".join(x for x in [ f"{apt.get('Area')} pi²" if apt.get("Area") else "", f"{beds} chambre(s)" if beds else "", f"{baths} salle(s) de bain" if baths else ""] if x) details: dict = {} contacts = (u.get("Building") or {}).get("ListingEmployeesContacts") or [] if contacts: c = contacts[0] details["contact"] = { k: v for k, v in [("name", c.get("FullName")), ("phone", c.get("FormattedPhoneNumber")), ("email", c.get("Email"))] if v} imgs = list(images) prev = u.get("PreviewUrl") or "" if prev.startswith("http"): imgs.insert(0, prev) try: lat, lng = float(addr.get("Latitude")), float(addr.get("Longitude")) except (TypeError, ValueError): lat = lng = None # prix présent dans le JSON du portail ; le site affiche # « Contactez-nous » (PriceFormatted) — les deux sont conservés price = float(apt.get("Price") or 0) or None return Listing( source=self.source_id, external_id=f"{slug}-{num}", url=url, title=f"{project} — Unité {num}", address=addr.get("Full") or "", sector=sector, city=addr.get("City") or "", unit_type=unit_type, price=price, price_label=str(apt.get("PriceFormatted") or ""), availability=availability, area_sqft=_sqft(apt.get("Area")), description=desc[:600], amenities=list(amenities), details=details, images=imgs[: self.max_images + 1], lat=lat, lng=lng, ) # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: listings: list[Listing] = [] # portail BuildingStack : prix par (immeuble, unité) pour enrichir try: bs_units = self._buildingstack_units() except Exception: bs_units = [] bs_prices: dict[tuple[str, str], float] = {} for u in bs_units: apt = u.get("Apartment") or {} price = float(apt.get("Price") or 0) or None if price: bs_prices[(str(u.get("BuildingName")), str(apt.get("UnitName")))] = price # 1) Le Vital (Livya) try: listings.extend(self._vital()) except Exception: pass # 2) Capella (sysc) try: listings.extend(self._capella()) except Exception: pass # 3) Cardinal Sud (micro-site sysc, prix complétés via le portail) cs_seen: set[str] = set() cs_photos: list[str] = [] try: cs_listings, cs_seen, cs_photos = self._cardinal_sud(bs_prices) listings.extend(cs_listings) except Exception: cs_listings = [] # 4) Portail BuildingStack : Cardinal Nord (aucune unité sur le # micro-site) + unités Cardinal Sud absentes du micro-site. # HUMĀ (connecteur huma.py) et les immeubles hors Québec/Lévis # (Le Stellar à Saguenay, etc.) sont exclus. bs_projects = { "Cardinal Nord": ("cardinal-nord", "Le Cardinal Nord", f"{CARDINAL}/cardinal-nord/", "Val-Bélair"), "Cardinal sud": ("cardinal-sud", "Le Cardinal Sud", f"{CARDINAL}/cardinal-sud/", "Val-Bélair"), } cardinal_amen: dict[str, list[str]] = {} by_building: dict[str, list[dict]] = {} for u in bs_units: name = str(u.get("BuildingName") or "") city = str((u.get("Address") or {}).get("City") or "") if "huma" in name.lower() or city not in ("Québec", "Lévis"): continue if name not in bs_projects: continue # immeuble inconnu : prudence by_building.setdefault(name, []).append(u) cn_images = self._page_images(f"{CARDINAL}/cardinal-nord/") \ if "Cardinal Nord" in by_building else [] for name, units in by_building.items(): slug, project, url, sector = bs_projects[name] pub = str((units[0].get("Building") or {}) .get("PublicListBuildingName") or "") dates, amenities = (self._building_page(pub) if pub else ({}, [])) cardinal_amen[slug] = amenities for u in units: num = str((u.get("Apartment") or {}).get("UnitName") or "") if not num: continue if slug == "cardinal-sud" and num in cs_seen: continue # déjà couvert par le micro-site dispo = dates.get(num, "") if not dispo: availability = "Disponible" elif re.search(r"\d", dispo): # « sept. 01, 2026 » availability = f"Libre {dispo}" else: # « Disponible dès maintenant! » availability = dispo images = cn_images if slug == "cardinal-nord" else cs_photos listings.append(self._mk_bs( u, num, project, slug, f"{EDIFIALOC}/b/{pub}", sector, availability, amenities, images)) # commodités du portail répercutées sur les unités micro-site Cardinal for lst in cs_listings: if not lst.amenities and cardinal_amen.get("cardinal-sud"): lst.amenities = list(cardinal_amen["cardinal-sud"]) # dédup par external_id (sécurité) uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values())